UNPKG

btlejuice

Version:

Bluetooth Low-Energy spoofing and MitM framework

711 lines (606 loc) 23.5 kB
/* * BtleJuice Proxy * * This module provides the Proxy class that may be used to create a link * between a dummy device and the real device. * * By doing so, characteristics operations (read, write, notify) are forwarded * to the real device allowing Man-in-the-Middle scenarii (including sniffing * and logging). * * Supported messages: * * - connect: asks the proxy to connect to a specific device and relay to it. * - scan_devices: asks the proxy to scan for reachable devices * - stop: asks the proxy to stop (scanning or relaying operations) * - write: write data to a characteristic * - read: read data from a characteristic * - notify: register for notification for a given characteristic * * Produced messages: * - hello: sent on client connection to notify it is ready to operate * - ready: sent when the proxy is connected to the target device, * ready to relay. * - discover: sent to announce the discovery of a specific device. * - read: data read * - write: data write operation result * - notify: notify operation result **/ var async = require('async'); var events = require('events'); var noble = require('noble'); var util = require('util'); var colors = require('colors'); var server = require('socket.io'); /* Missing constants. */ var ATT_OP_WRITE_RESP = 0x13; var ATT_OP_PREPARE_WRITE_REQ = 0x16; var ATT_OP_EXECUTE_WRITE_RESP = 0x19; var Proxy = function(options){ /* Profiling related properties. */ this.devices = {}; /* BLE target. */ this.device = null; this.target = null; this.state = 'disconnected'; this.config = null; this.pendingActions = []; /* Websocket server options. */ if (options && (options.port != undefined)) { this.port = options.port; } else { /* Default port. */ this.port = 8000; } /* Create server. */ this.server = new server(); this.client = null; /* LE Adv report */ this.le_adv_handler = null; this.watchdog = null; /* GATT cache. */ this.gattCache = {}; }; /** * start * * Start the proxy. **/ Proxy.prototype.start = function(){ /* Start server. */ console.log(('[info] Server listening on port '+this.port).bold); /* Set connection handler. */ this.server.on('connection', function(client){ console.log(('[info] Client connected').green); this.client = client; /* Set config handler. */ client.on('target', function(config){ this.configure(config); }.bind(this)); /* Set discovery handler. */ client.on('scan_devices', function(){ this.scanDevices(); }.bind(this)); /* Set the stop handler. */ client.on('stop', function(){ this.stop(); }.bind(this)); /* Set the status handler. */ client.on('status', function(){ this.notifyStatus(); }.bind(this)); client.on('disconnect', function(){ this.onClientDisconnect(); }.bind(this)); /* Notify client. */ this.send('hello'); }.bind(this)); /* Listen on this.port. */ this.server.listen(this.port); } Proxy.prototype.onClientDisconnect = function(){ if (this.client != null) { console.log('[warning] client disconnected'); this.client = null; } }; Proxy.prototype.send = function() { /* Forward to client if any. */ if (this.client != null) { this.client.emit.apply(this.client, arguments); } else { console.log('[error] client disconnected.'.red); } }; /** * configure * * Provides information about the target to connect to and initiates * the BLE connection to this target. **/ Proxy.prototype.configure = function(target){ console.log('Configuring proxy ...'.bold); this.target = target.toLowerCase(); /* If already connected to a target, drop the connection. */ if (this.device != null) { /* Remove noble listeners. */ noble.removeAllListeners('discover'); /* Disconnect from device. */ this.device.disconnect(function(){ /* Reset services and characteristics. */ this.nservices = 0; this.ncharacteristics = 0; this.services = null; /* Start target acquisition. */ this.state = 'acquisition'; this.acquireTarget(); }.bind(this)); } else { /* Start target acquisition. */ this.state = 'acquisition'; this.acquireTarget(null); } } /** * acquireTarget * * Scan for the specified target and launch connection when found. **/ Proxy.prototype.acquireTarget = function(config) { console.log(('[status] Acquiring target ' + this.target).bold); /* If device is already known, use the cache. */ if (this.target in this.devices && this.devices[this.target].cache != null) { var peripheral = this.devices[this.target].cache.peripheral; this.connectDevice(peripheral); } else { /* Track BLE advertisement reports. */ if (this.le_adv_handler != null) noble._bindings._gap._hci.removeListener( 'leAdvertisingReport', this.le_adv_handler ) this.le_adv_handler = function(status, type, address, addressType, report, rssi){ this.discoverDeviceAdv(address, report, rssi); }.bind(this); noble._bindings._gap._hci.on( 'leAdvertisingReport', this.le_adv_handler ); /* Track BLE advertisement reports. */ noble.on('discover', function(peripheral){ if (peripheral.address.toLowerCase() === this.target.toLowerCase()) { noble.stopScanning(); this.connectDevice(peripheral, config); } }.bind(this) ); /* Start scanning when ble device is ready. */ noble.startScanning(null, true); } }; /** * discoverDeviceAdv() * * Keep track of discovered raw devices' advertisement records. **/ Proxy.prototype.discoverDeviceAdv = function(bdaddr, report, rssi)  { if (!(bdaddr in this.devices) && bdaddr != null) { /* Save advertisement data. */ this.devices[bdaddr] = { services: {}, adv_records: report, scan_data: null, connected: false, cache: null }; } else if (bdaddr in this.devices) { /* Save scan response. */ this.devices[bdaddr].scan_data = report; } }; Proxy.prototype.isAllDiscovered = function() { /* First, check if all services have been discovered. */ for (var serviceUuid in this.discovered) { if (this.discovered[serviceUuid].done === false) return false; } /* Then check if all characteristics have been discovered. */ for (var serviceUuid in this.discovered) { for (var characUuid in this.discovered[serviceUuid].characteristics) { //console.log(serviceUuid+':'+characUuid); if (this.discovered[serviceUuid].characteristics[characUuid] === false) return false; } } /* All discovered, fill GATT cache. */ var bdaddr = this.target.replace(/:/g,'').toLowerCase(); var handle = noble._bindings._handles[bdaddr]; var gatt = noble._bindings._gatts[handle]; /* Patching longWrite(). */ gatt.longWrite = function(serviceUuid, characteristicUuid, data, withoutResponse) { var characteristic = this._characteristics[serviceUuid][characteristicUuid]; var limit = this._mtu - 5; var prepareWriteCallback = function(data_chunk) { return function(resp) { var opcode = resp[0]; if (opcode != ATT_OP_PREPARE_WRITE_RESP) { debug(this._address + ': unexpected reply opcode %d (expecting ATT_OP_PREPARE_WRITE_RESP)', opcode); } else { var expected_length = data_chunk.length + 5; if (resp.length !== expected_length) { /* the response should contain the data packet echoed back to the caller */ debug(this._address + ': unexpected prepareWriteResponse length %d (expecting %d)', resp.length, expected_length); } } }.bind(this); }.bind(this); /* split into prepare-write chunks and queue them */ var offset = 0; while (offset < data.length) { var end = offset+limit; var chunk = data.slice(offset, end); this._queueCommand(this.prepareWriteRequest(characteristic.valueHandle, offset, chunk), prepareWriteCallback(chunk)); offset = end; } /* queue the execute command with a callback to emit the write signal when done */ this._queueCommand(this.executeWriteRequest(characteristic.valueHandle), function(resp) { var opcode = resp[0]; if (opcode === ATT_OP_EXECUTE_WRITE_RESP && !withoutResponse) { this.emit('write', this._address, serviceUuid, characteristicUuid); } }.bind(this)); }; /* Patching write() to support longWrite mode. */ gatt.write = function(serviceUuid, characteristicUuid, data, withoutResponse) { var characteristic = this._characteristics[serviceUuid][characteristicUuid]; if (withoutResponse) { this._queueCommand(this.writeRequest(characteristic.valueHandle, data, true), null, function() { this.emit('write', this._address, serviceUuid, characteristicUuid); }.bind(this)); } else if (data.length + 3 > this._mtu) { return this.longWrite(serviceUuid, characteristicUuid, data, withoutResponse); } else { this._queueCommand(this.writeRequest(characteristic.valueHandle, data, false), function(data) { var opcode = data[0]; if (opcode === ATT_OP_WRITE_RESP) { this.emit('write', this._address, serviceUuid, characteristicUuid); } }.bind(this)); } }; this.devices[this.target].cache = { peripheral: this.device, /* Noble peripheral object. */ services: gatt._services, /* GATT services. */ characteristics: gatt._characteristics, /* GATT characteristics. */ descriptors: gatt._descriptors, /* GATT descriptors. */ handles: gatt._handles, /* GATT handles -- fixed by BtleJuice if option is set */ bjServices: this.services, /* BtleJuice services structure. */ }; return true; } /** * connectDevice * * Connect to the target device and start services discovery. **/ Proxy.prototype.connectDevice = function(peripheral) { this.device = peripheral; this.discovered = {}; this.services = {}; /* Remove any connect callback (required by Noble) */ this.device.removeAllListeners('connect'); this.device.connect(function(error) { /* If GATT cache already filled, then mark device as connected. */ var gattCache = this.devices[this.target].cache; if (this.devices[this.target].cache != null) { console.log('Target in cache, restoring ...'); this.state = 'connected'; /* Restore Noble internal structures on reconnection. */ var bdaddr = this.target.replace(/:/g,'').toLowerCase(); var handle = noble._bindings._handles[bdaddr]; var gatt = noble._bindings._gatts[handle]; gatt._services = gattCache.services; gatt._characteristics = gattCache.characteristics; gatt._descriptors = gattCache.descriptors; this.device = gattCache.peripheral; this.services = gattCache.bjServices; /* Defuse watchdog if any. */ if (this.watchdog != null) { clearTimeout(this.watchdog); this.watchdog = null; } this.setGattHandlers(); this.state = 'forwarding'; this.send('profile', this.formatProfile()); this.send('ready', true); console.log('[status] Proxy configured and ready to relay !'.green); } else { /* Setup the disconnect handler. */ peripheral.removeAllListeners('disconnect'); peripheral.on('disconnect', function(){ this.onDeviceDisconnected(); }.bind(this)); if (error == undefined) { /* Save device profile. */ this.currentDevice = peripheral; this.devices[this.currentDevice.address].connected = true; this.devices[this.currentDevice.address].name = peripheral.advertisement.localName; var device = this.devices[this.currentDevice.address]; var deviceUuid = this.currentDevice.address.split(':').join('').toLowerCase(); /* Discover services ... */ this.send('discover_services'); this.state = 'connected'; console.log(('[info] Proxy successfully connected to the real device').green); console.log(('[info] Discovering services and characteristics ...').bold); /* Characteristics discovery watchdog (20 seconds). */ if (this.watchdog == null) { this.watchdog = setTimeout(function(){ console.log('[error] discovery timed out, stopping proxy.'); this.watchdog = null; this.stop(); }.bind(this), 60000); } /* Connection OK, now discover services. */ peripheral.discoverServices(null, function(error, services) { //console.log('services discovered'); //console.log(services); if (error == null) { for (var service in services) { this.services[services[service].uuid] = {}; this.discovered[services[service].uuid] = { done: false, characteristics: {} }; var device = this.devices[this.currentDevice.address]; device.services[services[service].uuid] = { startHandle: noble._bindings._gatts[deviceUuid]._services[services[service].uuid].startHandle, endHandle: noble._bindings._gatts[deviceUuid]._services[services[service].uuid].endHandle, characteristics: {} }; /* We are using a closure to keep a copy of the service's uuid. */ services[service].discoverCharacteristics(null, (function(serviceUuid){ return function(error, characs){ if (error == null) { for (var c in characs) { this.services[serviceUuid][characs[c].uuid] = characs[c]; /* Characteristic is not discovered by default. */ this.discovered[serviceUuid].characteristics[characs[c].uuid] = false; /* Save characteristic. */ var _service = device.services[serviceUuid]; _service.characteristics[characs[c].uuid] = { uuid: characs[c].uuid, properties: characs[c].properties, descriptors: [], /* We read the handles from Noble's internal objects and save them. */ startHandle: noble._bindings._gatts[deviceUuid]._characteristics[serviceUuid][characs[c].uuid].startHandle, valueHandle: noble._bindings._gatts[deviceUuid]._characteristics[serviceUuid][characs[c].uuid].valueHandle, endHandle: noble._bindings._gatts[deviceUuid]._characteristics[serviceUuid][characs[c].uuid].endHandle }; characs[c].discoverDescriptors((function(t, service, charac){ return function(error, descriptors) { if (error == undefined) { var device = t.devices[t.currentDevice.address]; var _charac = device.services[service].characteristics[charac]; for (var desc in descriptors) { _charac.descriptors.push({ 'uuid': descriptors[desc].uuid, 'handle': noble._bindings._gatts[deviceUuid]._descriptors[service][charac][descriptors[desc].uuid].handle, 'value': (descriptors[desc].uuid == '2901')?new Buffer([]):null }); } t.onCharacteristicDiscovered(service, charac); } else { console.log('[error] cannot discover descriptor for service '+ service+':'+charac); } } })(this, serviceUuid, characs[c].uuid)); } this.discovered[serviceUuid].done = true; } else { console.log(('[error] cannot discover characteristic ' + charac).red) } }; })(services[service].uuid).bind(this)); } } else { console.log(('[error] cannot discover service ' + serviceUuid).red); } }.bind(this)); } else { this.send('ready', false); } } }.bind(this)); }; Proxy.prototype.onDeviceDisconnected = function(){ console.log('[error] Remote device has just disconnected'.red); /* Defuse watchdog if any. */ if (this.watchdog != null) { console.log('disarming watchdog'); clearTimeout(this.watchdog); this.watchdog = null; } /* Reset services and characteristics. */ this.services = null; this.discovered = {}; /* Notify core device disconnected if not stopping. */ if ((this.state != 'stopping') && (this.state != 'disconnected')) { this.send('device.disconnect', this.target); } this.send('status', 'disconnected'); /* Mark as disconnected. */ this.state = 'disconnected'; this.device = null; }; Proxy.prototype.formatProfile = function() { /* Create our serialized data. */ var device_info = {}; device_info['ad_records'] = this.devices[this.target].adv_records.toString('hex'); if (this.devices[this.target].scan_data != null) device_info['scan_data'] = this.devices[this.target].scan_data.toString('hex'); else device_info['scan_data'] = ''; device_info['name'] = this.devices[this.target].name; device_info['services'] = []; device_info['address'] = this.target; for (var _service in this.devices[this.target].services) { var _chars = this.devices[this.target].services[_service].characteristics; var service = {}; service['uuid'] = _service; service['startHandle'] = this.devices[this.target].services[_service].startHandle; service['endHandle'] = this.devices[this.target].services[_service].endHandle; service['characteristics'] = []; for (var device_char in _chars) { var char = {}; char['uuid'] = _chars[device_char]['uuid']; char['properties'] = _chars[device_char]['properties']; char['descriptors'] = _chars[device_char]['descriptors']; char['startHandle'] = _chars[device_char]['startHandle']; char['valueHandle'] = _chars[device_char]['valueHandle']; char['endHandle'] = _chars[device_char]['endHandle']; service['characteristics'].push(char); } device_info['services'].push(service); } return device_info; } Proxy.prototype.onDiscoverCharacteristic = function(peripheral, service, charac, callback) { charac.discoverDescriptors((function(_this, peripheral, service, charac, callback){ return function(error, descriptors) { callback(); } })(this, peripheral, service, charac, callback)); }; Proxy.prototype.onCharacteristicDiscovered = function(service, characteristic) { this.discovered[service].characteristics[characteristic] = true; if (this.isAllDiscovered()) { /* Defuse watchdog if any. */ if (this.watchdog != null) { clearTimeout(this.watchdog); this.watchdog = null; } this.setGattHandlers(); this.state = 'forwarding'; this.send('profile', this.formatProfile()); this.send('ready', true); console.log('[status] Proxy configured and ready to relay !'.green); } }; /** * setGattHandlers * * Install basic GATT operations handlers. **/ Proxy.prototype.setGattHandlers = function(){ if (this.client == null) return; /* Remove previous listeners. */ this.client.removeAllListeners('ble_read'); this.client.removeAllListeners('ble_write'); this.client.removeAllListeners('ble_notify'); /* Install read handler. */ this.client.on('ble_read', function(service, characteristic, offset){ /* Force lower case. */ service = service.toLowerCase(); characteristic = characteristic.toLowerCase(); /* Read characteristic. */ if (this.services != null) { this.services[service][characteristic].read(function(error, data){ this.send('ble_read_resp', service, characteristic, new Buffer(data)); }.bind(this)); } }.bind(this)); /* Install write handler. */ this.client.on('ble_write', function(service, characteristic, data, withoutResponse){ /* Force lower case. */ service = service.toLowerCase(); characteristic = characteristic.toLowerCase(); /* Write characteristic. */ if (this.services != null) { this.services[service][characteristic].write(data, withoutResponse, function(error){ this.send('ble_write_resp', service, characteristic, error); }.bind(this)); } }.bind(this)); /* Install notify handler. */ this.client.on('ble_notify', function(service, characteristic, enable){ if (this.services != null) { /* Register our automatic read handler. */ this.services[service][characteristic].removeAllListeners('data'); this.services[service][characteristic].on('data', function(_this, _service, _charac){ return function(data, isnotif)  { if (isnotif) { _this.send('ble_data', _service, _charac, data, isnotif); } }; }(this, service, characteristic)); /* Subscribe for notification. */ this.services[service][characteristic].notify(enable, function(_this, _service, _charac){ return function(error) { _this.send('ble_notify_resp', service, characteristic, error); }; }(this, service, characteristic)); } }.bind(this)); }; Proxy.prototype.scanDevices = function(){ if (this.state == 'connected') { noble.disconnect(); } /* Get discovery announces. */ noble.removeAllListeners('discover'); noble.on('discover', function(peripheral){ /* Forward discovery message to consumer. */ this.send('discover', peripheral.address, peripheral.advertisement.localName, peripheral.rssi); }.bind(this) ); this.state = 'scanning'; noble.startScanning(null, true); }; Proxy.prototype.stop = function(){ this.state = 'stopping'; console.log('[i] Stopping current proxy.'.bold); /* If already connected to a target, drop the connection. */ if (this.device != null) { /* Disconnect from device. */ this.device.disconnect(function(){ /* Reset services and characteristics. */ this.services = null; this.discovered = null; /* Mark as disconnected. */ this.state = 'disconnected'; this.device = null; }.bind(this)); } /* Reset. */ this.state = 'disconnected'; this.services = null; this.discovered = null; /* Notify client if any. */ if (this.client != null) { this.send('stopped'); } }; Proxy.prototype.notifyStatus = function(){ switch(this.state) { case 'connected': this.send('status', 'connected'); break; case 'disconnected': this.send('status', 'ready'); break; case 'scanning': this.send('status', 'scanning'); break; default: this.send('status', 'busy'); break; }; } if (!module.parent) { var proxy = new Proxy(null); proxy.start(); } else { module.exports = Proxy; }