From 96e4c0bf354e1883a8aa72b0397d5f3c7b1e9bc1 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Sun, 24 Mar 2019 21:50:07 +1100 Subject: [PATCH 1/4] ble: de-duplicate beacon code for eddystone (includes 1 fix-up; excludes iBeacon in #1893) --- scapy/contrib/eddystone.py | 30 +++------------------ scapy/layers/bluetooth.py | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/scapy/contrib/eddystone.py b/scapy/contrib/eddystone.py index d6652e25a52..be283453584 100644 --- a/scapy/contrib/eddystone.py +++ b/scapy/contrib/eddystone.py @@ -13,13 +13,12 @@ # scapy.contrib.status = loads from scapy.compat import orb -from scapy.packet import bind_layers, Packet from scapy.fields import IntField, SignedByteField, StrField, BitField, \ StrFixedLenField, ShortField, FixedPointField, ByteEnumField from scapy.layers.bluetooth import EIR_Hdr, EIR_ServiceData16BitUUID, \ - EIR_Flags, EIR_CompleteList16BitServiceUUIDs, HCI_Hdr, HCI_Command_Hdr, \ - HCI_Cmd_LE_Set_Advertising_Data, HCI_LE_Meta_Advertising_Report + EIR_CompleteList16BitServiceUUIDs, LowEnergyBeaconHelper from scapy.modules import six +from scapy.packet import bind_layers, Packet EDDYSTONE_UUID = 0xfeaa @@ -93,7 +92,7 @@ def any2i(self, pkt, x): return x -class Eddystone_Frame(Packet): +class Eddystone_Frame(Packet, LowEnergyBeaconHelper): # https://github.com/google/eddystone/blob/master/protocol-specification.md name = "Eddystone Frame" fields_desc = [ @@ -104,33 +103,12 @@ class Eddystone_Frame(Packet): def build_eir(self): """Builds a list of EIR messages to wrap this frame.""" - return [ - EIR_Hdr() / EIR_Flags(flags=[ - "general_disc_mode", "br_edr_not_supported"]), + return LowEnergyBeaconHelper.base_eir + [ EIR_Hdr() / EIR_CompleteList16BitServiceUUIDs(svc_uuids=[ EDDYSTONE_UUID]), EIR_Hdr() / EIR_ServiceData16BitUUID() / self ] - def build_advertising_report(self): - """Builds HCI_LE_Meta_Advertising_Report containing this frame.""" - - return HCI_LE_Meta_Advertising_Report( - type=0, # Undirected - atype=1, # Random address - data=self.build_eir() - ) - - def build_set_advertising_data(self): - """Builds a HCI_Cmd_LE_Set_Advertising_Data containing this frame. - - This includes the HCI_Hdr and HCI_Command_Hdr layers. - """ - - return HCI_Hdr() / HCI_Command_Hdr() / HCI_Cmd_LE_Set_Advertising_Data( - data=self.build_eir() - ) - class Eddystone_UID(Packet): # https://github.com/google/eddystone/tree/master/eddystone-uid diff --git a/scapy/layers/bluetooth.py b/scapy/layers/bluetooth.py index d84a59b321b..0a3f82ced40 100644 --- a/scapy/layers/bluetooth.py +++ b/scapy/layers/bluetooth.py @@ -1192,6 +1192,61 @@ class HCI_LE_Meta_Long_Term_Key_Request(Packet): bind_layers(SM_Hdr, SM_Signing_Information, sm_command=0x0a) +########### +# Helpers # +########### + +class LowEnergyBeaconHelper: + """ + Helpers for building packets for Bluetooth Low Energy Beacons. + + Implementors provide a :meth:`build_eir` implementation. + + This is designed to be used as a mix-in -- see + ``scapy.contrib.eddystone`` and ``scapy.contrib.ibeacon`` for examples. + """ + + # Basic flags that should be used by most beacons. + base_eir = [EIR_Hdr() / EIR_Flags(flags=[ + "general_disc_mode", "br_edr_not_supported"]), ] + + def build_eir(self): + """ + Builds a list of EIR messages to wrap this frame. + + Users of this helper must implement this method. + + :returns: List of HCI_Hdr with payloads that describe this beacon type + :rtype: list[HCI_Hdr] + """ + raise NotImplementedError("build_eir") + + def build_advertising_report(self): + """ + Builds a HCI_LE_Meta_Advertising_Report containing this frame. + + :rtype: HCI_LE_Meta_Advertising_Report + """ + + return HCI_LE_Meta_Advertising_Report( + type=0, # Undirected + atype=1, # Random address + data=self.build_eir() + ) + + def build_set_advertising_data(self): + """Builds a HCI_Cmd_LE_Set_Advertising_Data containing this frame. + + This includes the :class:`HCI_Hdr` and :class:`HCI_Command_Hdr` layers. + + :rtype: HCI_Hdr + """ + + return HCI_Hdr() / HCI_Command_Hdr() / HCI_Cmd_LE_Set_Advertising_Data( + data=self.build_eir() + ) + + ########### # Sockets # ########### From 8410ad1e66f47779ecf2d162c8df6c784ffbc370 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Sun, 31 Mar 2019 10:55:20 +1100 Subject: [PATCH 2/4] eddystone: fixup docstrings (cherry-pick befd7536) --- scapy/contrib/eddystone.py | 62 ++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/scapy/contrib/eddystone.py b/scapy/contrib/eddystone.py index be283453584..62406697e23 100644 --- a/scapy/contrib/eddystone.py +++ b/scapy/contrib/eddystone.py @@ -6,11 +6,21 @@ # Copyright (C) Michael Farrell # This program is published under a GPLv2 (or later) license # -# The Eddystone specification can be found at: -# https://github.com/google/eddystone/blob/master/protocol-specification.md -# # scapy.contrib.description = Eddystone BLE proximity beacon # scapy.contrib.status = loads +""" +scapy.contrib.eddystone - Google Eddystone Bluetooth LE proximity beacons. + +The Eddystone specification can be found at: +https://github.com/google/eddystone/blob/master/protocol-specification.md + +These beacons are used as building blocks for other systems: + +* Google's Physical Web +* RuuviTag +* Waze Beacons + +""" from scapy.compat import orb from scapy.fields import IntField, SignedByteField, StrField, BitField, \ @@ -93,7 +103,11 @@ def any2i(self, pkt, x): class Eddystone_Frame(Packet, LowEnergyBeaconHelper): - # https://github.com/google/eddystone/blob/master/protocol-specification.md + """ + The base Eddystone frame on which all Eddystone messages are built. + + https://github.com/google/eddystone/blob/master/protocol-specification.md + """ name = "Eddystone Frame" fields_desc = [ BitField("type", None, 4), @@ -111,7 +125,11 @@ def build_eir(self): class Eddystone_UID(Packet): - # https://github.com/google/eddystone/tree/master/eddystone-uid + """ + An Eddystone type for transmitting a unique identifier. + + https://github.com/google/eddystone/tree/master/eddystone-uid + """ name = "Eddystone UID" fields_desc = [ SignedByteField("tx_power", 0), @@ -122,7 +140,11 @@ class Eddystone_UID(Packet): class Eddystone_URL(Packet): - # https://github.com/google/eddystone/tree/master/eddystone-url + """ + An Eddystone type for transmitting a URL (to a web page). + + https://github.com/google/eddystone/tree/master/eddystone-url + """ name = "Eddystone URL" fields_desc = [ SignedByteField("tx_power", 0), @@ -152,7 +174,11 @@ def from_url(url): class Eddystone_TLM(Packet): - # https://github.com/google/eddystone/tree/master/eddystone-tlm + """ + An Eddystone type for transmitting beacon telemetry information. + + https://github.com/google/eddystone/tree/master/eddystone-tlm + """ name = "Eddystone TLM" fields_desc = [ ByteEnumField("version", None, { @@ -163,7 +189,11 @@ class Eddystone_TLM(Packet): class Eddystone_TLM_Unencrypted(Packet): - # https://github.com/google/eddystone/blob/master/eddystone-tlm/tlm-plain.md + """ + A subtype of Eddystone-TLM for transmitting telemetry in unencrypted form. + + https://github.com/google/eddystone/blob/master/eddystone-tlm/tlm-plain.md + """ name = "Eddystone TLM (Unencrypted)" fields_desc = [ ShortField("batt_mv", 0), @@ -174,7 +204,13 @@ class Eddystone_TLM_Unencrypted(Packet): class Eddystone_TLM_Encrypted(Packet): - # https://github.com/google/eddystone/blob/master/eddystone-tlm/tlm-encrypted.md + """ + A subtype of Eddystone-TLM for transmitting telemetry in encrypted form. + + This implementation does not support decrypting this data. + + https://github.com/google/eddystone/blob/master/eddystone-tlm/tlm-encrypted.md + """ name = "Eddystone TLM (Encrypted)" fields_desc = [ StrFixedLenField("etlm", None, 12), @@ -184,7 +220,13 @@ class Eddystone_TLM_Encrypted(Packet): class Eddystone_EID(Packet): - # https://github.com/google/eddystone/tree/master/eddystone-eid + """ + An Eddystone type for transmitting encrypted, ephemeral identifiers. + + This implementation does not support decrypting this data. + + https://github.com/google/eddystone/tree/master/eddystone-eid + """ name = "Eddystone EID" fields_desc = [ SignedByteField("tx_power", 0), From b24aca4331628f3a88aa931e82ec4efb7aaf2c08 Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Sun, 24 Mar 2019 19:51:44 +1100 Subject: [PATCH 3/4] bluetooth: add support for magic EIR_Manufacturer_Specific_Data payloads --- scapy/layers/bluetooth.py | 22 ++++++++++++++++++ test/bluetooth.uts | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/scapy/layers/bluetooth.py b/scapy/layers/bluetooth.py index 0a3f82ced40..d1f2a3f56f9 100644 --- a/scapy/layers/bluetooth.py +++ b/scapy/layers/bluetooth.py @@ -701,6 +701,28 @@ class EIR_Manufacturer_Specific_Data(EIR_Element): XLEShortField("company_id", None), ] + registered_magic_payloads = {} + + @classmethod + def register_magic_payload(cls, payload_cls, magic_check=None): + """Registers a class using magic data.""" + if magic_check is None: + if hasattr(payload_cls, "magic_check"): + magic_check = payload_cls.magic_check + else: + raise TypeError("magic_check not specified, and {} has no " + "attribute magic_check".format(payload_cls)) + + cls.registered_magic_payloads[payload_cls] = magic_check + + def default_payload_class(self, payload): + for cls, check in six.iteritems( + EIR_Manufacturer_Specific_Data.registered_magic_payloads): + if check(payload): + return cls + + return Packet.default_payload_class(self, payload) + def extract_padding(self, s): # Needed to end each EIR_Element packet and make PacketListField work. plen = EIR_Element.length_from(self) - 2 diff --git a/test/bluetooth.uts b/test/bluetooth.uts index 3d22a338351..9dbad26056d 100644 --- a/test/bluetooth.uts +++ b/test/bluetooth.uts @@ -152,6 +152,54 @@ scapy_packet = HCI_Hdr(scan_resp_raw_data) assert(raw(scapy_packet[EIR_Manufacturer_Specific_Data].payload) == b'\x00_B31147D2461\xfc\x00\x03\x0c\x00\x00') assert(scapy_packet[EIR_Manufacturer_Specific_Data].company_id == 0x154) += Parse EIR_Manufacturer_Specific_Data with magic + +class ScapyManufacturerPacket(Packet): + magic = b'SCAPY!' + fields_desc = [ + StrFixedLenField("header", magic, len(magic)), + ShortField("x", None), + ] + +class ScapyManufacturerPacket2(Packet): + magic = b'!SCAPY' + fields_desc = [ + StrFixedLenField("header", magic, len(magic)), + ShortField("y", None), + ] + @classmethod + def magic_check(cls, payload): + return payload.startswith(cls.magic) + +EIR_Manufacturer_Specific_Data.register_magic_payload( + ScapyManufacturerPacket, lambda p: p.startswith(ScapyManufacturerPacket.magic)) +EIR_Manufacturer_Specific_Data.register_magic_payload(ScapyManufacturerPacket2) + +# Test decode +p = EIR_Hdr(b'\x0b\xff\xff\xffSCAPY!\xab\x12') + +p.show() +assert p[EIR_Manufacturer_Specific_Data].company_id == 0xffff +assert p[ScapyManufacturerPacket].x == 0xab12 + +p = EIR_Hdr(b'\x0b\xff\xff\xff!SCAPY\x12\x34') + +p.show() +assert p[EIR_Manufacturer_Specific_Data].company_id == 0xffff +assert p[ScapyManufacturerPacket2].y == 0x1234 + +# Test encode +p = EIR_Hdr()/EIR_Manufacturer_Specific_Data(company_id=0xffff)/ScapyManufacturerPacket(x=0x5678) +assert raw(p) == b'\x0b\xff\xff\xffSCAPY!\x56\x78' + +# Test bad setup +try: + EIR_Manufacturer_Specific_Data.register_magic_payload(conf.raw_layer) +except TypeError: + pass +else: + assert False, "expected exception" + = Parse EIR_ServiceData16BitUUID d = hex_bytes("043e1902010001abcdef7da97f0d020102030350fe051650fee6c2ac") From f3ad78b43993e6d7516cb0459add8ba1930b121a Mon Sep 17 00:00:00 2001 From: Michael Farrell Date: Mon, 8 Apr 2019 19:24:17 +1000 Subject: [PATCH 4/4] altbeacon: New layer! (includes fixups) --- doc/scapy/layers/bluetooth.rst | 42 ++++++++++++++- scapy/contrib/altbeacon.py | 82 +++++++++++++++++++++++++++++ scapy/contrib/altbeacon.uts | 94 ++++++++++++++++++++++++++++++++++ scapy/layers/bluetooth.py | 30 ++++++++++- 4 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 scapy/contrib/altbeacon.py create mode 100644 scapy/contrib/altbeacon.uts diff --git a/doc/scapy/layers/bluetooth.rst b/doc/scapy/layers/bluetooth.rst index 091036dad92..a945f1529ff 100644 --- a/doc/scapy/layers/bluetooth.rst +++ b/doc/scapy/layers/bluetooth.rst @@ -384,8 +384,46 @@ Setting up advertising Changing advertisements may not take effect until advertisements have first been :ref:`stopped `. -Eddystone URL beacon -^^^^^^^^^^^^^^^^^^^^ +AltBeacon +^^^^^^^^^ + +`AltBeacon`__ is a proximity beacon protocol developed by Radius Networks. This +example sets up a virtual AltBeacon: + +__ https://github.com/AltBeacon/spec + +.. code-block:: python3 + + # Load the contrib module for AltBeacon + load_contrib('altbeacon') + + ab = AltBeacon( + id1='2f234454-cf6d-4a0f-adf2-f4911ba9ffa6', + id2=1, + id3=2, + tx_power=-59, + ) + + bt.sr(ab.build_set_advertising_data()) + +Once :ref:`advertising has been started `, the beacon may then be +detected with `Beacon Locator`__ (Android). + +.. note:: + + Beacon Locator v1.2.2 `incorrectly reports the beacon as being an + iBeacon`__, but the values are otherwise correct. + +__ https://github.com/vitas/beaconloc +__ https://github.com/vitas/beaconloc/issues/32 + +Eddystone +^^^^^^^^^ + +`Eddystone`__ is a proximity beacon protocol developed by Google. This uses an +Eddystone-specific service data field. + +__ https://github.com/google/eddystone/ This example sets up a virtual `Eddystone URL`__ beacon: diff --git a/scapy/contrib/altbeacon.py b/scapy/contrib/altbeacon.py new file mode 100644 index 00000000000..75ce3aeaf5d --- /dev/null +++ b/scapy/contrib/altbeacon.py @@ -0,0 +1,82 @@ +# -*- mode: python3; indent-tabs-mode: nil; tab-width: 4 -*- +# altbeacon.py - protocol handlers for AltBeacon +# +# This file is part of Scapy +# See http://www.secdev.org/projects/scapy for more information +# Copyright (C) Michael Farrell +# This program is published under a GPLv2 (or later) license +# +# scapy.contrib.description = AltBeacon BLE proximity beacon +# scapy.contrib.status = loads +""" +scapy.contrib.altbeacon - AltBeacon Bluetooth LE proximity beacons. + +The AltBeacon specification can be found at: https://github.com/AltBeacon/spec +""" + +from scapy.fields import ByteField, ShortField, SignedByteField, \ + StrFixedLenField +from scapy.layers.bluetooth import EIR_Hdr, EIR_Manufacturer_Specific_Data, \ + UUIDField, LowEnergyBeaconHelper +from scapy.packet import Packet + + +# When building beacon frames, one should use their own manufacturer ID. +# +# However, most software (including the AltBeacon SDK) requires explicitly +# registering particular manufacturer IDs to listen to, and the only ID used is +# that of Radius Networks (the developer of the specification). +# +# To maximise compatibility, Scapy's implementation of +# LowEnergyBeaconHelper.build_eir (for constructing frames) uses Radius +# Networks' manufacturer ID. +# +# Scapy's implementation of AltBeacon **does not** require a specific +# manufacturer ID to detect AltBeacons - it uses +# EIR_Manufacturer_Specific_Data.register_magic_payload. +RADIUS_NETWORKS_MFG = 0x0118 + + +class AltBeacon(Packet, LowEnergyBeaconHelper): + """ + AltBeacon broadcast frame type. + + https://github.com/AltBeacon/spec + """ + name = "AltBeacon" + magic = b"\xBE\xAC" + fields_desc = [ + StrFixedLenField("header", magic, len(magic)), + + # The spec says this is 20 bytes, with >=16 bytes being an + # organisational unit-specific identifier. However, the Android library + # treats this as UUID + uint16 + uint16. + UUIDField("id1", None), + + # Local identifier + ShortField("id2", None), + ShortField("id3", None), + + SignedByteField("tx_power", None), + ByteField("mfg_reserved", None), + ] + + @classmethod + def magic_check(cls, payload): + """ + Checks if the given payload is for us (starts with our magic string). + """ + return payload.startswith(cls.magic) + + def build_eir(self): + """Builds a list of EIR messages to wrap this frame.""" + + # Note: Company ID is not required by spec, but most tools only look + # for manufacturer-specific data with Radius Networks' manufacturer ID. + return LowEnergyBeaconHelper.base_eir + [ + EIR_Hdr() / EIR_Manufacturer_Specific_Data( + company_id=RADIUS_NETWORKS_MFG) / self + ] + + +EIR_Manufacturer_Specific_Data.register_magic_payload(AltBeacon) diff --git a/scapy/contrib/altbeacon.uts b/scapy/contrib/altbeacon.uts new file mode 100644 index 00000000000..1f17cb08bce --- /dev/null +++ b/scapy/contrib/altbeacon.uts @@ -0,0 +1,94 @@ +% AltBeacon unit tests +# +# Type the following command to launch start the tests: +# $ test/run_tests -P "load_contrib('altbeacon')" -t scapy/contrib/altbeacon.uts +# +# AltBeaconParser tests adapted from: +# https://github.com/AltBeacon/android-beacon-library/blob/master/lib/src/test/java/org/altbeacon/beacon/AltBeaconParserTest.java + ++ AltBeacon tests + += Setup + +def next_eir(p): + return EIR_Hdr(p[Padding]) + += Presence check + +AltBeacon + += AltBeaconParserTest.testRecognizeBeacon + +d = hex_bytes('02011a1bff1801beac2f234454cf6d4a0fadf2f4911ba9ffa600010002c509') +p = EIR_Hdr(d) + +# First is a flags header +assert EIR_Flags in p + +# Then the AltBeacon +p = next_eir(p) +assert p[EIR_Manufacturer_Specific_Data].company_id == RADIUS_NETWORKS_MFG +assert p[AltBeacon].mfg_reserved == 9 + + += AltBeaconParserTest.testDetectsDaveMHardwareBeacon + +d = hex_bytes('02011a1bff1801beac2f234454cf6d4a0fadf2f4911ba9ffa600050003be020e09526164426561636f6e20555342020a03000000000000000000000000') +p = EIR_Hdr(d) + +# First is Flags +assert EIR_Flags in p + +# Then the AltBeacon +p = next_eir(p) +assert p[EIR_Manufacturer_Specific_Data].company_id == RADIUS_NETWORKS_MFG +assert AltBeacon in p + +# Then CompleteLocalName +p = next_eir(p) +assert p[EIR_CompleteLocalName].local_name == b'RadBeacon USB' + +# Then TX_Power_Level +p = next_eir(p) +assert p[EIR_TX_Power_Level].level == 3 + += AltBeaconParserTest.testParseWrongFormatReturnsNothing + +d = hex_bytes('02011a1aff1801ffff2f234454cf6d4a0fadf2f4911ba9ffa600010002c509') +p = EIR_Hdr(d) + +# First is Flags +assert EIR_Flags in p + +# Then the EIR_Manufacturer_Specific_Data +p = next_eir(p) +assert p[EIR_Manufacturer_Specific_Data].company_id == RADIUS_NETWORKS_MFG +assert AltBeacon not in p + += AltBeaconParserTest.testParsesBeaconMissingDataField + +d = hex_bytes('02011a1aff1801beac2f234454cf6d4a0fadf2f4911ba9ffa600010002c50000') +p = EIR_Hdr(d) + +# First is Flags +assert EIR_Flags in p + +# Then the EIR_Manufacturer_Specific_Data +p = next_eir(p) +assert p[EIR_Manufacturer_Specific_Data].company_id == RADIUS_NETWORKS_MFG +assert p[AltBeacon].id1 == uuid.UUID('2f234454-cf6d-4a0f-adf2-f4911ba9ffa6') +assert p[AltBeacon].id2 == 1 +assert p[AltBeacon].id3 == 2 +assert p[AltBeacon].tx_power == -59 + += Build EIR + +p = AltBeacon( + id1=uuid.UUID('2f234454-cf6d-4a0f-adf2-f4911ba9ffa6'), + id2=1, + id3=2, + tx_power=-59, +) + +d = raw(p.build_eir()[-1]) +assert d == hex_bytes('1bff1801beac2f234454cf6d4a0fadf2f4911ba9ffa600010002c500') diff --git a/scapy/layers/bluetooth.py b/scapy/layers/bluetooth.py index d1f2a3f56f9..2c328aa7f32 100644 --- a/scapy/layers/bluetooth.py +++ b/scapy/layers/bluetooth.py @@ -705,7 +705,35 @@ class EIR_Manufacturer_Specific_Data(EIR_Element): @classmethod def register_magic_payload(cls, payload_cls, magic_check=None): - """Registers a class using magic data.""" + """ + Registers a payload type that uses magic data. + + Traditional payloads require registration of a Bluetooth Company ID + (requires company membership of the Bluetooth SIG), or a Bluetooth + Short UUID (requires a once-off payment). + + There are alternatives which don't require registration (such as + 128-bit UUIDs), but the biggest consumer of energy in a beacon is the + radio -- so the energy consumption of a beacon is proportional to the + number of bytes in a beacon frame. + + Some beacon formats side-step this issue by using the Company ID of + their beacon hardware manufacturer, and adding a "magic data sequence" + at the start of the Manufacturer Specific Data field. + + Examples of this are AltBeacon and GeoBeacon. + + For an example of this method in use, see ``scapy.contrib.altbeacon``. + + :param Type[Packet] payload_cls: + A reference to a Packet subclass to register as a payload. + :param Callable[[bytes], bool] magic_check: + (optional) callable to use to if a payload should be associated + with this type. If not supplied, ``payload_cls.magic_check`` is + used instead. + :raises TypeError: If ``magic_check`` is not specified, + and ``payload_cls.magic_check`` is not implemented. + """ if magic_check is None: if hasattr(payload_cls, "magic_check"): magic_check = payload_cls.magic_check