├── .gitignore ├── LICENSE.txt ├── README.md ├── inform_protocol.md ├── python ├── __init__.py ├── inform.py └── noop_server.py ├── requirements.txt └── reversing_tools ├── parse_mitm.py └── parse_pcap.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .sandbox_name 3 | keystore.py 4 | /data 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Michael Crute 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Ubiquiti mFi Controller 2 | ======================= 3 | This repo contains a python implementation of a parser for the Ubiquiti Inform 4 | Protocol. This protocol is used by Ubiquiti Unifi access points to communicate 5 | with the controller for ongoing metrics collection and configuration. It's also 6 | used in the Ubiquiti mFi products for command and control of light switches, 7 | power outlets, mPorts and power strips. 8 | 9 | The goal of this library is to build a fully functional replacement for the 10 | abandoned Ubiquiti controller. A fully functional python library for inform 11 | parsing and serialization exists in the python directory along with a sample 12 | server that just sends NOOP packets to devices checking in. The python work is 13 | considered finished and will be split into a new repo at some point. All 14 | further work will go into a Go (golang) API on which the controller will be 15 | built. 16 | -------------------------------------------------------------------------------- /inform_protocol.md: -------------------------------------------------------------------------------- 1 | # Ubiquiti Inform Protocol 2 | 3 | The mFi uses the Ubiquiti inform protocol to handle all communications to and 4 | from the controller. This is the way that it transmits the current state of the 5 | system to the controller (looks like it just sends mca-dump output) as well as 6 | how it receives the instructions from the controller as to what to enable or 7 | disable. 8 | 9 | Everything appears to be pull-based, even provisioning. This makes sense with 10 | the cloud controller where the controller has no access to your network. I 11 | have not documented yet how anything works within the protocol, that is next. 12 | This documents the overall protocol itself. 13 | 14 | The device will inform by executing an HTTP POST with an encrypted payload to 15 | http://controller:6080/inform at a regular interval (default is 10 seconds) and 16 | will expect an encrypted payload to be returned. If the device gets a command 17 | response instead of a noop response it will immediately do another inform; this 18 | continues until the controller sends the next noop response. Responses never 19 | appear to contain multiple commands. 20 | 21 | ## Raw Packet Structure 22 | | Size | Purpose | Data Type | 23 | | -------- | ------------------------- | --------- | 24 | | 4 bytes | magic number | integer | 25 | | 4 bytes | version | integer | 26 | | 6 bytes | mac address | string | 27 | | 2 bytes | flags | short | 28 | | 16 bytes | AES initialization vector | string | 29 | | 4 bytes | data version | integer | 30 | | 4 bytes | data length | integer | 31 | | n bytes | AES encrypted payload | string | 32 | 33 | ## Raw Packet Constraints 34 | * magic must == `1414414933` (TNBU) 35 | * data version must < `1` 36 | * `flags & 0x1 != 0` means encrypted 37 | * `flags & 0x2 != 0` means compressed 38 | 39 | ## Payload Types 40 | The payload is AES encrypted in CBC mode using PKCS5 padding. They key is the 41 | device auth key from the database or a master key that is hard coded if the 42 | device has not been provisioned yet. The master key is hard coded in the 43 | controller code in the DeviceManager class and pretty easy to find. 44 | 45 | MASTER_KEY = "ba86f2bbe107c7c57eb5f2690775c712" 46 | 47 | On devices running protocol version 1 the encrypted payload is just JSON data. 48 | In version 0 of the protocol the data was key=value pairs separated by 49 | newlines. All of the mFi hardware I have access to uses protocol version 1. 50 | 51 | The payloads break down into two categories; those coming into the controller 52 | and those going out of the controller. 53 | 54 | 55 | ## Output Payloads 56 | Output payloads are those that originate from the controller and are bound for 57 | the device. These always appear to contain a \_type field. I have observed the 58 | following output payloads. 59 | 60 | ### Firmware Upgrade 61 | _type: upgrade 62 | url: full url to firmware.bin 63 | datetime: rfc3339 formatted date, server time 64 | server_time_in_utc: server time in UTC as a unix timestamp (string) 65 | version: firmware version (string) 66 | time: server time as unix timestamp (int) 67 | _id: unknown id string (5232701de4b0457a2f2f031f) 68 | device_id: device ID from mongodb 69 | 70 | ### Config Update 71 | _type: setparam 72 | port_cfg: configuration for ports as string 73 | analog_cfg: analog port config (empty for mPower) 74 | authorized_guests: authorized guests file (empty) 75 | blocked_sta: blocked stations (empty) 76 | cfgversion: management config version 77 | mgmt_cfg: management config file 78 | port_cfg: output port config (set for mPower) 79 | system_cfg: system config file 80 | server_time_in_utc: server time in UTC as a unix timestamp (string) 81 | 82 | ### Reboot 83 | _type: reboot 84 | datetime: rfc3339 formatted date, server time 85 | device_id: device ID from mongodb 86 | server_time_in_utc: server time in UTC as a unix timestamp (string) 87 | time: server time as unix timestamp (int) 88 | _id: unknown id string (5232701de4b0457a2f2f031f) 89 | 90 | ### Heartbeat / No-Op 91 | _type: noop 92 | interval: next checkin time in seconds (integer) 93 | server_time_in_utc: server time in UTC as a unix timestamp (string) 94 | 95 | ### Locate Mode 96 | This mode didn't appear to change anything. My guess is that this should 97 | trigger the LED to blink as it does on the Unifi devices but appears to have no 98 | effect, at least on the mPower devices. 99 | 100 | _type: cmd 101 | cmd: locate 102 | datetime: rfc3339 formatted date, server time 103 | device_id: device ID from mongodb 104 | server_time_in_utc: server time in UTC as a unix timestamp (string) 105 | time: server time as unix timestamp (int) 106 | _id: unknown id string (5232701de4b0457a2f2f031f) 107 | 108 | ### Command 109 | _type: cmd 110 | _admin: admin data object 111 | _id: mongodb id of admin 112 | lang: admin language (en_US) 113 | name: admin username 114 | x_password: admin password 115 | _id: unknown id string (5232701de4b0457a2f2f031f) 116 | datetime: rfc3339 formatted date, server time 117 | server_time_in_utc: server time in UTC as a unix timestamp (string) 118 | time: server time as unix timestamp (int) 119 | device_id: device ID from mongodb 120 | cmd: command to use (mfi-output to change outputs) 121 | mac: device mac address 122 | model: device model (Outlet for mPower) 123 | off_volt: always 0?? (int) 124 | port: device port to update (int) 125 | sId: sensor ID 126 | timer: always 0?? (int) 127 | val: output value (int) 128 | volt: val and volt set to 1 to turn on, 0 to turn off (int) 129 | dimmer_ramp: always 1?? (int) (only for switch and outlet) 130 | 131 | 132 | ## Input Payloads 133 | Incoming packets appear to be a JSON version of the out put of the `mca-dump` 134 | command on the device. There is definitely some Unifi legacy in here. It 135 | appears that mFi is just using the Unfi firmware and has hacked it a bit for 136 | their use-case so most of the fields outside of alarm are not relevant to the 137 | mFi use-case. 138 | 139 | alarm: list of sensors 140 | index: port name 141 | sId: sensor ID hash 142 | time: device time 143 | 144 | // For mPort Only 145 | tag: kind of reading presented (magnetic, temperature, humidity) 146 | type: kind of device (input, analog, output) 147 | val: value (float) 148 | 149 | // For mPower Only 150 | entries: list of entry objects 151 | tag: kind of reading (output, pf, energy_sum, v_rms, i_rms, active_pwr) 152 | type: sensor type (output, analog, rmsSum, rms) 153 | val: value (float) 154 | 155 | if_table: list of interfaces and stats 156 | ip: interface ip 157 | mac: interface mac address 158 | name: interface device name (dev handle) 159 | rx_bytes: bytes received on the interface 160 | rx_dropped: packets dropped by the interface 161 | rx_errors: receive errors on the interface 162 | rx_packets: packets received on the interface 163 | tx_bytes: bytes transmitted by the interface 164 | tx_dropped: trasmit drops on the interface 165 | tx_errors: transmit errors on the interface 166 | tx_packets: number of packets transmitted by the interface 167 | type: appears to be the same as name 168 | 169 | radio_table: list of radios in the device 170 | builtin_ant_gain: gain of builtin antenna 171 | builtin_antenna: boolean, does device have antenna 172 | max_txpower: maximum transmit power 173 | name: name of radio 174 | radio: radio type (ex: ng) 175 | scan_table: list, unknown 176 | 177 | vap_table: table of joined wireless networks 178 | bssid: network SSID 179 | ccq: client connection qality 180 | channel: channel number 181 | essid: network friendly name 182 | id: mode? (ex: user) 183 | name: uplink device name 184 | num_sta: number of connected stations (always 0) 185 | radio: radio type (ex: ng) 186 | rx_bytes: bytes received on the interface 187 | rx_dropped: packets dropped by the interface 188 | rx_errors: receive errors on the interface 189 | rx_packets: packets received on the interface 190 | tx_bytes: bytes transmitted by the interface 191 | tx_dropped: trasmit drops on the interface 192 | tx_errors: transmit errors on the interface 193 | tx_packets: number of packets transmitted by the interface 194 | rx_crypts: unknown 195 | rx_frags: received fragmented packets 196 | rx_nwids: received network beacons 197 | tx_power: transmitting power of the radio (assumed in dBm) 198 | tx_retries: number of transmit retries on interface 199 | usage: same as id 200 | 201 | hostname: hostname of device ("ubnt" unless changed) 202 | ip: IP of device 203 | mac: mac address of primary interface 204 | mfi: boolean, indicates if an mfi device 205 | model: device model name 206 | model_display: display name for device 207 | serial: device serial number 208 | uptime: uptime in seconds since last reboot 209 | version: firmware version 210 | default: boolean, device is unconfigured 211 | cfgversion: string, unknown (ex: c3846443e1b4860b) 212 | guest_token: string, unknown (ex: 364E8B215D16AB963A53232E3873000C) 213 | inform_url: string, url to which the device is reporting 214 | isolated: boolean, can the device reach the rest of the network 215 | localversion: string, unknown (ex: ?) 216 | locating: boolean, is the device in locating mode (blinking LED) 217 | portversion: string, unknown (ex: 443eb55240f26367) 218 | time: integer, device time as unix timestamp 219 | trackable: boolean as string, unknown 220 | uplink: string, unix device name (dev handle) of the primary uplink device 221 | 222 | 223 | ## Config Samples 224 | These are some observed configuration payloads for the configuration packets. 225 | In their json form it is a single line string with newlines encoded as `\n`. 226 | 227 | ### mgmt cfg 228 | mgmt.is_default=false 229 | mgmt.authkey=41d6529fd555fbb1bdeeafeb995510fa 230 | mgmt.cfgversion=f1bb359840b519a4 231 | mgmt.servers.1.url=http://172.16.0.38:6080/inform 232 | mgmt.selfrun_guest=pass 233 | selfrun_guest=pass 234 | cfgversion=f1bb359840b519a4 235 | 236 | 237 | ### port cfg 238 | port.0.sensorId=52210822e4b0959e7fe94009 239 | vpower.1.rep_output=1 240 | vpower.1.rep_pf=1 241 | vpower.1.rep_energy_sum=1 242 | vpower.1.rep_v_rms=1 243 | vpower.1.rep_i_rms=1 244 | vpower.1.rep_active_pwr=1 245 | vpower.1.relay=1 246 | vpower.1.output_tag=output 247 | vpower.1.pf_tag=pf 248 | vpower.1.energy_sum_tag=energy_sum 249 | vpower.1.v_rms_tag=v_rms 250 | vpower.1.i_rms_tag=i_rms 251 | vpower.1.active_pwr_tag=active_pwr 252 | port.1.sensorId=5221082be4b0959e7fe9400a 253 | vpower.2.rep_output=1 254 | vpower.2.rep_pf=1 255 | vpower.2.rep_energy_sum=1 256 | vpower.2.rep_v_rms=1 257 | vpower.2.rep_i_rms=1 258 | vpower.2.rep_active_pwr=1 259 | vpower.2.relay=1 260 | vpower.2.output_tag=output 261 | vpower.2.pf_tag=pf 262 | vpower.2.energy_sum_tag=energy_sum 263 | vpower.2.v_rms_tag=v_rms 264 | vpower.2.i_rms_tag=i_rms 265 | vpower.2.active_pwr_tag=active_pwr 266 | port.2.sensorId=5221083be4b0959e7fe9400b 267 | vpower.3.rep_output=1 268 | vpower.3.rep_pf=1 269 | vpower.3.rep_energy_sum=1 270 | vpower.3.rep_v_rms=1 271 | vpower.3.rep_i_rms=1 272 | vpower.3.rep_active_pwr=1 273 | vpower.3.relay=0 274 | vpower.3.output_tag=output 275 | vpower.3.pf_tag=pf 276 | vpower.3.energy_sum_tag=energy_sum 277 | vpower.3.v_rms_tag=v_rms 278 | vpower.3.i_rms_tag=i_rms 279 | vpower.3.active_pwr_tag=active_pwr 280 | 281 | 282 | ### system cfg 283 | # users 284 | users.status=enabled 285 | users.1.name=admin 286 | users.1.password=Mq9xt5C8DjcLA 287 | users.1.status=enabled 288 | # bridge 289 | bridge.status=disabled 290 | bridge.1.devname=br0 291 | bridge.1.fd=1 292 | bridge.1.stp.status=disabled 293 | bridge.1.port.1.devname=eth1 294 | snmp.status=disabled 295 | ppp.status=disabled 296 | pwdog.status=disabled 297 | dnsmasq.status=disabled 298 | dhcpd.status=disabled 299 | httpd.status=disabled 300 | httpd.port.http=80 301 | httpd.port=80 302 | igmpproxy.status=disabled 303 | telnetd.status=disabled 304 | tshaper.status=disabled 305 | netmode=bridge 306 | ntpclient.status=disabled 307 | ntpclient.1.server=pool.ntp.org 308 | ntpclient.1.status=disabled 309 | syslog.status=enabled 310 | resolv.status=enabled 311 | resolv.host.1.name=OfficePowerStrip 312 | resolv.nameserver.1.status=disabled 313 | resolv.nameserver.2.status=disabled 314 | dhcpc.status=enabled 315 | dhcpc.1.status=enabled 316 | dhcpc.1.devname=eth1 317 | route.status=enabled 318 | vlan.status=disabled 319 | radio.1.ack.auto=disabled 320 | radio.1.ackdistance=300 321 | radio.1.acktimeout=30 322 | radio.1.ampdu.status=enabled 323 | radio.1.clksel=1 324 | radio.1.countrycode=840 325 | radio.1.cwm.enable=0 326 | radio.1.cwm.mode=1 327 | radio.1.forbiasauto=0 328 | radio.1.channel=0 329 | radio.1.ieee_mode=11nght40 330 | radio.1.mcastrate=auto 331 | radio.1.mode=managed 332 | radio.1.puren=0 333 | radio.1.rate.auto=enabled 334 | radio.1.rate.mcs=auto 335 | radio.1.txpower=auto 336 | # wlans (radio) 337 | radio.status=enabled 338 | radio.countrycode=840 339 | aaa.status=disabled 340 | wireless.status=enabled 341 | dhcpc.2.status=enabled 342 | dhcpc.2.devname=ath0 343 | bridge.1.port.2.devname=ath0 344 | radio.1.devname=ath0 345 | radio.1.status=enabled 346 | aaa.1.br.devname=br0 347 | aaa.1.devname=ath0 348 | aaa.1.driver=madwifi 349 | aaa.1.ssid= 350 | aaa.1.status=disabled 351 | wireless.1.mode=managed 352 | wireless.1.devname=ath0 353 | wireless.1.status=enabled 354 | wireless.1.authmode=1 355 | wireless.1.l2_isolation=disabled 356 | wireless.1.is_guest=false 357 | wireless.1.security=none 358 | wireless.1.addmtikie=disabled 359 | wireless.1.ssid= 360 | wireless.1.hide_ssid=enabled 361 | wireless.1.mac_acl.status=disabled 362 | wireless.1.mac_acl.policy=deny 363 | wireless.1.wmm=enabled 364 | # netconf 365 | netconf.status=enabled 366 | netconf.1.devname=eth1 367 | netconf.1.autoip.status=disabled 368 | netconf.1.ip=0.0.0.0 369 | netconf.1.promisc=enabled 370 | netconf.1.status=enabled 371 | netconf.1.up=enabled 372 | netconf.2.devname=br0 373 | netconf.2.autoip.status=disabled 374 | netconf.2.ip=0.0.0.0 375 | netconf.2.status=enabled 376 | netconf.2.up=enabled 377 | netconf.3.devname=ath0 378 | netconf.3.autoip.status=disabled 379 | netconf.3.ip=0.0.0.0 380 | netconf.3.promisc=enabled 381 | netconf.3.status=enabled 382 | netconf.3.up=enabled 383 | qos.status=enabled 384 | qos.group.1.rate=100 385 | qos.group.2.rate=100 386 | qos.group.6.rate=100 387 | qos.if.1.devname=eth1 388 | qos.if.1.devspeed=100 389 | qos.if.1.group=1 390 | qos.if.2.devname=ath0 391 | qos.if.2.devspeed=150 392 | qos.if.2.group=20 393 | -------------------------------------------------------------------------------- /python/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcrute/ubntmfi/963517b79eeb794bace8545a8e7f9c3d10f1f869/python/__init__.py -------------------------------------------------------------------------------- /python/inform.py: -------------------------------------------------------------------------------- 1 | import json 2 | import copy 3 | import struct 4 | import binascii 5 | from Crypto.Cipher import AES 6 | from cStringIO import StringIO 7 | 8 | 9 | class BinaryDataStream(object): 10 | """Directional binary data stream 11 | 12 | Reads and writes binary data from any stream-like object. This object is 13 | not bi-directional. Does no interpertation just unpacking and packing. 14 | """ 15 | 16 | def __init__(self, data): 17 | self.data = data 18 | 19 | @classmethod 20 | def for_output(cls): 21 | return cls(StringIO()) 22 | 23 | def read_int(self): 24 | return struct.unpack(">i", self.data.read(4))[0] 25 | 26 | def read_short(self): 27 | return struct.unpack(">h", self.data.read(2))[0] 28 | 29 | def read_string(self, length): 30 | return self.data.read(length) 31 | 32 | def write_int(self, data): 33 | self.data.write(struct.pack(">i", data)) 34 | 35 | def write_short(self, data): 36 | self.data.write(struct.pack(">h", data)) 37 | 38 | def write_string(self, data): 39 | self.data.write(data) 40 | 41 | def get_output(self): 42 | return self.data.getvalue() 43 | 44 | 45 | class Cryptor(object): 46 | """AES encryption strategy 47 | 48 | Handles AES crypto by wrapping pycrypto. Does padding and un-padding as 49 | well as key conversions when needed. 50 | """ 51 | 52 | def __init__(self, key, iv): 53 | self.iv = iv 54 | self.key = key 55 | self.cipher = AES.new(key.decode("hex"), AES.MODE_CBC, iv) 56 | 57 | @staticmethod 58 | def unpad(s): 59 | return s[0:-ord(s[-1])] 60 | 61 | @staticmethod 62 | def pad(s, BS=16): 63 | return s + (BS - len(s) % BS) * chr(BS - len(s) % BS) 64 | 65 | def decrypt(self, payload): 66 | return self.unpad(self.cipher.decrypt(payload)) 67 | 68 | def encrypt(self, payload): 69 | return self.cipher.encrypt(self.pad(payload)) 70 | 71 | 72 | class InformPacket(object): 73 | """Inform model object 74 | 75 | Holds basic, parsed, inform packet data. Does some interpertation for 76 | fields like flags. Can be passed to and from the serialiser. This class 77 | only fully supports version 1 of the inform data protocol. Version 0 78 | payload parsing is not supported. 79 | """ 80 | 81 | ENCRYPTED_FLAG = 0x1 82 | COMPRESSED_FLAG = 0x2 83 | 84 | def __init__(self): 85 | self.magic_number = None 86 | self.version = None 87 | self.mac_addr = None 88 | self.flags = None 89 | self.iv = None 90 | self.data_version = None 91 | self.data_length = None 92 | self.raw_payload = None 93 | self._used_key = None 94 | 95 | def response_copy(self): 96 | """Copy object for use in response 97 | 98 | Generates a deep copy of the object and removes the payload so that it 99 | can be used to respond to the station that send this inform request. 100 | """ 101 | new_obj = copy.deepcopy(self) 102 | new_obj.raw_payload = None 103 | return new_obj 104 | 105 | @staticmethod 106 | def _format_mac_addr(mac_bytes): 107 | return ":".join([binascii.hexlify(i) for i in mac_bytes]) 108 | 109 | def _has_flag(self, flag): 110 | return self.flags & flag != 0 111 | 112 | @property 113 | def formatted_mac_addr(self): 114 | return self._format_mac_addr(self.mac_addr) 115 | 116 | @property 117 | def is_encrypted(self): 118 | return self._has_flag(self.ENCRYPTED_FLAG) 119 | 120 | @property 121 | def is_compressed(self): 122 | return self._has_flag(self.COMPRESSED_FLAG) 123 | 124 | @property 125 | def payload(self): 126 | if self.data_version == 1: 127 | return json.loads(self.raw_payload.decode("latin-1")) 128 | else: 129 | return self.raw_payload 130 | 131 | @payload.setter 132 | def payload(self, value): 133 | self.raw_payload = json.dumps(value) 134 | 135 | 136 | class InformSerializer(object): 137 | """Inform protocol version 1 parser/serializer 138 | 139 | Handles the parsing of the inform binary protocol to python objects and 140 | seralization of python objects to inform binary protocol. Handles 141 | cryptography and data formats. Compatible only with version 1 of the data 142 | format. 143 | """ 144 | 145 | MASTER_KEY = "ba86f2bbe107c7c57eb5f2690775c712" 146 | PROTOCOL_MAGIC = 1414414933 147 | MAX_VERSION = 1 148 | 149 | def __init__(self, key=None, key_bag=None): 150 | self.key = key 151 | self.key_bag = key_bag or {} 152 | 153 | def _decrypt_payload(self, packet): 154 | i = 0 155 | key = self.key_bag.get(packet.formatted_mac_addr) 156 | 157 | for key in (key, self.key, self.MASTER_KEY): 158 | if key is None: 159 | continue 160 | 161 | decrypted = Cryptor(key, packet.iv).decrypt(packet.raw_payload) 162 | 163 | json.loads(decrypted.decode("latin-1")) 164 | packet.raw_payload = decrypted 165 | packet._used_key = key 166 | break 167 | 168 | def parse(self, input): 169 | input_stream = BinaryDataStream(input) 170 | 171 | packet = InformPacket() 172 | 173 | packet.magic_number = input_stream.read_int() 174 | assert packet.magic_number == self.PROTOCOL_MAGIC 175 | 176 | packet.version = input_stream.read_int() 177 | assert packet.version < self.MAX_VERSION 178 | 179 | packet.mac_addr = input_stream.read_string(6) 180 | packet.flags = input_stream.read_short() 181 | packet.iv = input_stream.read_string(16) 182 | packet.data_version = input_stream.read_int() 183 | packet.data_length = input_stream.read_int() 184 | 185 | packet.raw_payload = input_stream.read_string(packet.data_length) 186 | 187 | if packet.is_encrypted: 188 | self._decrypt_payload(packet) 189 | 190 | return packet 191 | 192 | def _encrypt_payload(self, packet): 193 | if packet.data_version != 1: 194 | raise ValueError("Can no encrypt contents of pre 1.0 packets") 195 | 196 | key = packet._used_key if packet._used_key else self.MASTER_KEY 197 | return Cryptor(key, packet.iv).encrypt(json.dumps(packet.payload)) 198 | 199 | def serialize(self, packet): 200 | output = BinaryDataStream.for_output() 201 | 202 | output.write_int(packet.magic_number) 203 | output.write_int(packet.version) 204 | output.write_string(packet.mac_addr) 205 | output.write_short(packet.flags) 206 | output.write_string(packet.iv) 207 | output.write_int(packet.data_version) 208 | output.write_int(packet.data_length) 209 | output.write_string(self._encrypt_payload(packet)) 210 | 211 | return output.get_output() 212 | -------------------------------------------------------------------------------- /python/noop_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from StringIO import StringIO 4 | from SocketServer import TCPServer 5 | from inform import InformSerializer 6 | from ConfigParser import SafeConfigParser 7 | from SimpleHTTPServer import SimpleHTTPRequestHandler 8 | 9 | 10 | class Handler(SimpleHTTPRequestHandler): 11 | 12 | def __init__(self, *args, **kwargs): 13 | SimpleHTTPRequestHandler.__init__(self, *args, **kwargs) 14 | self.parser = InformSerializer(key_bag=get_keys("inform.cfg")) 15 | 16 | def _get_keys(self, filename): 17 | cfg = SafeConfigParser() 18 | cfg.read(filename) 19 | 20 | return dict((sect, cfg.get(sect, "authkey")) 21 | for sect in cfg.sections()) 22 | 23 | def do_POST(self): 24 | length = int(self.headers['content-length']) 25 | body = StringIO(self.rfile.read(length)) 26 | packet = self.parser.parse(body) 27 | 28 | noop_packet = packet.response_copy() 29 | noop_packet.payload = { "_type": "noop", "interval": 10 } 30 | 31 | buffer = StringIO(self.parser.serialize(noop_packet)) 32 | 33 | self.send_response(200) 34 | self.send_header("Content-type", "application/x-binary") 35 | self.send_header("Connection", "close") 36 | self.send_header("User-Agent", "Unifi Controller") 37 | self.send_header("Content-Length", buffer.len) 38 | self.end_headers() 39 | 40 | return buffer 41 | 42 | 43 | httpd = TCPServer(("", 9966), Handler) 44 | print "serving on 9966" 45 | httpd.serve_forever() 46 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dpkt==1.8.7 2 | mitmproxy==0.17 3 | pycrypto==2.6.1 4 | -------------------------------------------------------------------------------- /reversing_tools/parse_mitm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import os 5 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'python')) 6 | 7 | import json 8 | from cStringIO import StringIO 9 | from libmproxy.flow import FlowReader 10 | from inform import InformSerializer 11 | 12 | 13 | def make_serializer(from_file): 14 | with open(from_file) as fp: 15 | keystore = {i['mac']: i['x_authkey'] for i in json.load(fp)} 16 | 17 | return InformSerializer("", keystore) 18 | 19 | 20 | def dumps_pretty(obj): 21 | return json.dumps(obj, sort_keys=True, indent=4, separators=(',', ': ')) 22 | 23 | 24 | if __name__ == "__main__": 25 | ser = make_serializer("data/devices.json") 26 | 27 | with open('data/mitm/onoff.txt', 'rb') as fp, open('test.out', 'w') as fp2: 28 | read = FlowReader(fp) 29 | 30 | for rec in read.stream(): 31 | res = ser.parse(StringIO(rec.response.content)) 32 | req = ser.parse(StringIO(rec.request.content)) 33 | 34 | # print req.payload 35 | if res.payload['_type'] == 'cmd': 36 | print dumps_pretty(res.payload) 37 | # print dumps_pretty(req.payload) 38 | # print dumps_pretty(res.payload) 39 | print 40 | -------------------------------------------------------------------------------- /reversing_tools/parse_pcap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import os 5 | sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'python')) 6 | 7 | import dpkt 8 | import json 9 | from cStringIO import StringIO 10 | from inform import InformSerializer 11 | 12 | 13 | def go_debug(filename): 14 | arr = lambda x: [ord(i) for i in x] 15 | packet = ser.parse(open('test_files/2.bin')) 16 | return { 17 | "magic": packet.magic_number, 18 | "version": packet.version, 19 | "mac": arr(packet.mac_addr), 20 | "flags": packet.flags, 21 | "iv": arr(packet.iv), 22 | "data_version": packet.data_version, 23 | "data_len": packet.data_length, 24 | "raw_payload": json.packet.raw_payload, 25 | "formatted_mac": packet.formatted_mac_addr, 26 | "is_enc": packet.is_encrypted, 27 | "is_comp": packet.is_compressed, 28 | } 29 | 30 | 31 | def collect_records(from_file): 32 | records = [] 33 | buffer = StringIO() 34 | 35 | for ts, buf in dpkt.pcap.Reader(open(from_file)): 36 | eth = dpkt.ethernet.Ethernet(buf) 37 | data = eth.data.tcp.data.split("\r\n")[-1] 38 | 39 | if data.startswith("TNBU") and buffer.tell() != 0: 40 | records.append(buffer.getvalue()) 41 | buffer.seek(0) 42 | buffer.write(data) 43 | else: 44 | buffer.write(data) 45 | 46 | return records 47 | 48 | 49 | def make_serializer(from_file): 50 | with open(from_file) as fp: 51 | keystore = {i['mac']: i['x_authkey'] for i in json.load(fp)} 52 | 53 | return InformSerializer("", keystore) 54 | 55 | 56 | if __name__ == "__main__": 57 | import glob 58 | ser = make_serializer("data/devices.json") 59 | 60 | for file in glob.glob("data/test_files/*.bin"): 61 | fn, ext = os.path.splitext(file) 62 | path = os.path.dirname(fn) 63 | fn = os.path.basename(fn) 64 | 65 | with open(file) as fin, open(os.path.join(path, fn + ".txt"), 'w') as fout: 66 | json.dump(json.loads(ser.parse(fin).raw_payload), fout, sort_keys=True, 67 | indent=4, separators=(',', ': ')) 68 | 69 | # for i, data in enumerate(collect_records("mfi.out")): 70 | # try: 71 | # packet = ser.parse(StringIO(data)) 72 | # print packet.raw_payload 73 | # except ValueError: 74 | # pass 75 | --------------------------------------------------------------------------------