Cynthion is a versatile USB debugging and development platform designed for hardware hackers and security researchers. It enables real-time USB packet inspection, protocol emulation, and advanced debugging, making it ideal for analysing and reverse engineering USB-based protocols.

In this blog post, I’ll demonstrate how to use hardware emulation to spoof an Android device with ADB enabled. Using Cynthion’s hardware emulation capabilities, we’ll make a computer think it’s connected to a real Android phone and implement just enough of the ADB protocol to handle basic commands.

Protocol Analysis

To emulate a USB device, we first need to understand how it presents itself to the host and what communication occurs. With this information, we can create an emulation setup. Cynthion can be used as a USB protocol analyser. To start sniffing USB packets, first connect load the tracer firmware onto the device, then connect up the Cynthion device as described in the documentation

 cynthion run analyzer
Uploading target bitstream to FPGA with 222030 bytes...
Operation complete!

Your browser does not support SVG

With this firmware running, Packetry can be used to sniff packets.

Info

Packetry is a protocol analyser specifically designed to be used with a Cynthion device. Think of it like a Wireshark tailored for USB. Read the docs here

Screenshot of the packet capture

Creating the USB Device class

USB classes are essentially what save you from having to install a new device driver for every keyboard and mouse you buy. USB-IF created a set of classes that allow devices with similar functionality to use a single driver on the host computer. Keyboards and mice are HID devices, headsets are Audio devices, Webcams are video devices, and so on. When devices cannot conform to a standard class, the manufacturer implements a non-standard class, known as a Vendor Specific Class.

Do not confuse USB Devices with USB Device classes

In the context of USB Device classes, we are actually discussing a specific functionality of a single USB Class. Modern platforms such as Android devices are composite devices, meaning they implement multiple interfaces belonging to different USB classes. For example, an Android device may similtaniously expose endpoints such as

  • A Mass Storage device
  • A Media Transfer Protocol (MTP) device
  • A Vendor-Specific device for the Android Debug Bridge (ADB).

Device Descriptor

The device descriptor is the top-level descriptor of a USB device. This is the first thing the host asks for when a USB device is plugged in, containing information such as the vendor and product IDs.

By selecting the device descriptor from the devices tab in Packetry, it can be saved to a file. Packetry is capable of dissecting a variety of common USB transactions, which allows you to pull out information like this.

 xxd device.bin
00000000: 1201 1002 0000 0040 d118 e74e 1005 0102  .......@...N....
00000010: 0301                                     ..

The Facedancer library provides a way to parse the raw bytes and create a USB device descriptor.

Info

Packetry/Facedancer don’t currently provide a way to automatically resolve the string references, so this should be done manually. The strings can be copied out of the devices tab, and the transaction contains the string descriptor reference number. e.g. Getting string descriptor #2, language 0x0409 (English/US) for device 36, reading 16 of 255 requested bytes: 'Pixel 6'

In [11]: device_data = open("./device.bin", "rb").read()
    ...: strings = {1: "Google", 2: "Pixel 6", 3: "25161FDF60012T"}
    ...: device = USBDevice.from_binary_descriptor(device_data, strings=strings)
 
In [12]: print(device.generate_code())
 
@use_inner_classes_automatically
class Device(USBDevice):
    device_speed             = None
    device_class             = 0
    device_subclass          = 0
    protocol_revision_number = 0
    max_packet_size_ep0      = 64
    vendor_id                = 0x18d1
    product_id               = 0x4ee7
    manufacturer_string      = (1, 'Google')
    product_string           = (2, 'Pixel 6')
    serial_number_string     = (3, '25161FDF60012T')
    supported_languages      = (LanguageIDs.ENGLISH_US,)
    device_revision          = 0x0510
    usb_spec_version         = 0x0210
 
    class Configuration_1(USBConfiguration):
        number                 = 1
        configuration_string   = None
        max_power              = 500
        self_powered           = True
        supports_remote_wakeup = True

Currently the Configuration is just a placeholder, so the next step is to import the actual data.

Configuration and Interface Descriptors

Configuration descriptors define the overall configuration of the USB device, such as power and features available over USB. A single device can have multiple configurations, but a host will only use one at a time. Within this configuration, the Interface Descriptor defines a specific, independent function of the device, such as the ADB functionality. These interfaces contain one or more Endpoint Descriptors, which are the data pipes that enable communication.

Device Descriptor
├── Configuration Descriptor
    ├── Interface Descriptor
        ├── Endpoint Descriptor

We can repeat the process from above to extract the raw binary data from Packetry, and parse it using Facedancer

In [35]: device_data = open("./device.bin", "rb").read()
    ...: config_data = open("./config.bin", "rb").read()
    ...: interface_data = open("./interface0.bin", "rb").read()
    ...: endpoint1_in_data = open("./endpoint1_in.bin", "rb").read()
    ...: endpoint1_out_data = open("./endpoint1_out.bin", "rb").read()
    ...: device = USBDevice.from_binary_descriptor(device_data, strings=strings)
    ...: config = USBConfiguration.from_binary_descriptor(config_data, strings=strings)
    ...: interface = USBInterface.from_binary_descriptor(interface_data, strings=strings)
    ...: interface.add_endpoint(USBEndpoint.from_binary_descriptor(endpoint1_in_data))
    ...: interface.add_endpoint(USBEndpoint.from_binary_descriptor(endpoint1_out_data))
    ...: config.add_interface(interface)
    ...: device.add_configuration(config)
    ...: print(device.generate_code())
 
@use_inner_classes_automatically
class Device(USBDevice):
    device_speed             = None
    device_class             = 0
    device_subclass          = 0
    protocol_revision_number = 0
    max_packet_size_ep0      = 64
    vendor_id                = 0x18d1
    product_id               = 0x4ee7
    manufacturer_string      = (1, 'Google')
    product_string           = (2, 'Pixel 6')
    serial_number_string     = (3, '25161FDF60012T')
    supported_languages      = (LanguageIDs.ENGLISH_US,)
    device_revision          = 0x0510
    usb_spec_version         = 0x0210
 
    class Configuration_1(USBConfiguration):
        number                 = 1
        configuration_string   = None
        max_power              = 500
        self_powered           = False
        supports_remote_wakeup = False
 
        class Interface_0(USBInterface):
            number           = 0
            alternate        = 0
            class_number     = 255
            subclass_number  = 66
            protocol_number  = 1
            interface_string = (4, 'ADB Interface')
 
            class Endpoint_1_OUT(USBEndpoint):
                number               = 1
                direction            = USBDirection.OUT
                transfer_type        = USBTransferType.BULK
                synchronization_type = USBSynchronizationType.NONE
                usage_type           = USBUsageType.DATA
                max_packet_size      = 512
                interval             = 0
                extra_bytes          = bytes([])
 
            class Endpoint_1_IN(USBEndpoint):
                number               = 1
                direction            = USBDirection.IN
                transfer_type        = USBTransferType.BULK
                synchronization_type = USBSynchronizationType.NONE
                usage_type           = USBUsageType.DATA
                max_packet_size      = 512
                interval             = 0
                extra_bytes          = bytes([])

After fixing up the code a little and importing the class into a script, it should look something like this

from facedancer import *
from facedancer import main
 
 
@use_inner_classes_automatically
class ADBPixel(USBDevice):
    device_speed = None
    device_class = 0
    device_subclass = 0
    protocol_revision_number = 0
    max_packet_size_ep0 = 64
    vendor_id = 0x18D1
    product_id = 0x4EE7
    manufacturer_string = "Google"
    product_string = "Pixel 6"
    serial_number_string = "25161FDF60012T"
    supported_languages = (LanguageIDs.ENGLISH_US,)
    device_revision = 0x0510
    usb_spec_version = 0x0210
 
    class Configuration_1(USBConfiguration):
        number = 1
        configuration_string = None
        max_power = 500
        self_powered = False
        supports_remote_wakeup = False
 
        class Interface_0(USBInterface):
            number = 0
            alternate = 0
            class_number = 255
            subclass_number = 66
            protocol_number = 1
            interface_string = "ADB Interface"
 
            class Endpoint_1_OUT(USBEndpoint):
                number = 1
                direction = USBDirection.OUT
                transfer_type = USBTransferType.BULK
                synchronization_type = USBSynchronizationType.NONE
                usage_type = USBUsageType.DATA
                max_packet_size = 512
                interval = 0
                extra_bytes = bytes([])
 
            class Endpoint_1_IN(USBEndpoint):
                number = 1
                direction = USBDirection.IN
                transfer_type = USBTransferType.BULK
                synchronization_type = USBSynchronizationType.NONE
                usage_type = USBUsageType.DATA
                max_packet_size = 512
                interval = 0
                extra_bytes = bytes([])
 
 
main(ADBPixel)

Testing the Script

To test the script, flash the Cynthion with the facedancer firmware and rewire the board.

Your browser does not support SVG

When running lsusb, an Android device will be detected, with annotation that debugging is enabled on the device.

 cynthion run facedancer
Updating SoC firmware flash with 59328 bytes...
Operation complete!
Uploading target bitstream to FPGA with 397055 bytes...
Operation complete!
 
> lsusb -d 18d1:4ee7
Bus 001 Device 058: ID 18d1:4ee7 Google Inc. Nexus/Pixel Device (charging + debug)

When running adb devices, a device is shown, however it’s displaying as offline.

 adb devices
List of devices attached
25161FDF60012T  offline

This is because ADB server on the host is attempting to communicate with the device, but the responses have not been implemented

INFO    | endpoint       | EP1 received 24 bytes of data; but has no handler.
INFO    | endpoint       | EP1 received 64 bytes of data; but has no handler.
INFO    | endpoint       | EP1 received 64 bytes of data; but has no handler.
INFO    | endpoint       | EP1 received 64 bytes of data; but has no handler.
INFO    | endpoint       | EP1 received 64 bytes of data; but has no handler.
INFO    | endpoint       | EP1 received 5 bytes of data; but has no handler.

Implementing the ADB Protocol

Initial Connection

The first transaction after the device setup is a bulk transfer. The first 4 bytes decodes to CNXN , which matches up with this blog, showing it’s an initial connection request. 43, 4E, 58, 4E is ascii CNXN

Transaction #267 with 3 packets
Timestamp: 187,396,200 ns from capture start
Packets: #1581 to #1583
OUT transaction on device 36, endpoint 1 with 24 data bytes, ACK response
Payload: [43, 4E, 58, 4E, 01, 00, 00, 01, 00, 00, 10, 00, 05, 01, 00, 00, 47, 66, 00, 00, BC, B1, A7, B1]

We can pack/unpack ADB message headers to better display and handle them.

class Message:
    def __init__(self, command, arg0, arg1, data_length, data_crc32, magic):
        self.command = struct.pack("=I", command)
        self.arg0 = arg0
        self.arg1 = arg1
        self.data_length = data_length
        self.data_crc32 = data_crc32
        self.magic = magic
 
    def pack(self):
        return struct.pack(
            "=I I I I I I",
            struct.unpack("=I", self.command)[0],
            self.arg0,
            self.arg1,
            self.data_length,
            self.data_crc32,
            self.magic,
        )
 
    def __str__(self):
        message = f"command: {self.command}\n"
        message += f"arg0: {self.arg0}\n"
        message += f"arg1: {self.arg1}\n"
        message += f"data_length: {self.data_length}\n"
        message += f"data_crc32: {self.data_crc32}\n"
        message += f"magic: {self.magic}\n"
        return message
 
    @classmethod
    def unpack(cls, buffer):
        unpacked_data = struct.unpack("=IIIIII", buffer)
        return cls(*unpacked_data)

The device responds to the CNXN transaction with an authentication challenge. This involves sending 20 bytes of ‘random’ data for the host to sign with it’s private key.

def send_auth_challenge(self):
    self.connection_state = DEVICE_CONNECTING
    challenge = bytes.fromhex("51f1a602004c488588d069f17d7321fad1650d2f")
    response = Message(
        command=int.from_bytes(b"AUTH", "little"),
        arg0=AUTH_TYPE_CHALLENGE,
        arg1=0,
        data_length=len(challenge),
        data_crc32=sum(challenge) ^ 0xFFFFFFFF,
        magic=int.from_bytes(b"AUTH", "little") ^ 0xFFFFFFFF,
    )
    return [response.pack(), challenge]

Authentication

The device responds to this request with an AUTH challenge (41, 55, 54, 48 is AUTH). The first transaction is the AUTH message header, a second transaction contains the auth challenge material. This is interesting because documentation such as the Synactiv blog post seemed to imply that a messages header and contents was bundled in the same transaction. They do, however, account for this in their implementation of ADB.

Transaction #285 with 3 packets
Timestamp: 187,753,034 ns from capture start
Packets: #1616 to #1618
IN transaction on device 36, endpoint 1 with 24 data bytes, ACK response
Payload: [41, 55, 54, 48, 01, 00, 00, 00, 00, 00, 00, 00, 14, 00, 00, 00, 00, 00, 00, 00, BE, AA, AB, B7]

Transaction #286 with 3 packets
Timestamp: 187,766,217 ns from capture start
Packets: #1619 to #1621
IN transaction on device 36, endpoint 1 with 20 data bytes, ACK response
Payload: [4C, BF, 28, 3B, F2, 2F, EC, 13, 56, 95, 6B, 0E, 62, 66, 8F, 37, 59, 42, DB, ED]

The host will sign the 20 bytes of random data with it’s private key and send that signed data back to the device.

Info

Handling messages sent over multiple transactions required introducing a state machine into the code. This involved parsing the message header to get the expected message data size, and recieving transactions until everything has been recieved before handling the message

Transaction #310 with 3 packets
Timestamp: 188,297,834 ns from capture start
Packets: #1664 to #1666
OUT transaction on device 36, endpoint 1 with 24 data bytes, ACK response
Payload: [41, 55, 54, 48, 02, 00, 00, 00, 00, 00, 00, 00, 00, 01, 00, 00, CF, 82, 00, 00, BE, AA, AB, B7]

Transaction #311 with 3 packets
Timestamp: 188,299,934 ns from capture start
Packets: #1667 to #1669
OUT transaction on device 36, endpoint 1 with 256 data bytes, ACK response
Payload: [87, 9B, 81, 02, CA, 0C, 81, A7, 10, A4, DA, 36, 3B, FD, 8C, 49, 1A, 32, A8, 40, 58, 7C, E0, C9, 1E, 5F, 00, 98, 9D, 66, DE, 18, F1, B2, C6, 73, 28, 37, 70, 18, 39, B2, FC, 3E, D8, 6B, A6, EC, CB, 88, EF, D6, EC, 3C, 0E, A3, D0, B6, FC, 9B, 22, 04, CF, D1, 58, 5D, 5E, 26, 4F, EC, F4, A7, 5B, 70, 49, D6, D5, AE, C0, D4, FE, 6F, 5A, D2, 37, 41, B6, E5, B1, 67, C7, DA, EF, AD, D3, DB, F1, C3, B8, 61, 0F, F7, FA, A8, EA, E8, 10, BA, 6B, 48, 2E, 08, 75, 35, A4, 4D, 90, 28, 14, 7A, C1, 4C, FC, AD, 3A, 20, 6B, C7, 08, 4C, 3A, 54, 05, 04, BB, 1D, 1E, 0D, 27, 42, BD, 43, E3, 03, C2, 66, 61, 49, 1A, 6F, 61, F5, 72, 7F, 33, 3A, 0D, 04, 73, 6A, F8, 2B, 44, 04, D4, C7, 78, A0, 43, 4F, 6C, 6B, 6C, 90, 75, 2B, D2, 6B, 9F, B1, C1, 7D, F1, 97, 62, CD, BC, F1, 84, 5E, 9A, BE, 2E, 89, FC, AF, 83, B0, DD, 94, 10, 80, 53, BB, 73, B2, BE, AC, 73, 1C, 9D, AA, B0, 09, F0, CA, 6C, EF, E1, 85, 7A, 52, D9, DE, 3D, 5E, DF, 90, 89, 3C, E6, 55, ED, D9, 45, 41, 9E, 15, 26, EF, E8, 26, 27, 64, D3, F8, 71, A2, 34, F2, B3, 18, 0D, 00, A6, 70]

The device calculates whether the two devices have been paired before by verifying the signed data against the stored public keys of previously connected devices. If there is not an known key, it would trigger an authentication request to the user. Since we just want to establish a session, we can accept any returned data - since the RSA keys are only used to device verification, not message encryption.

def handle_auth(self):
    self.connection_state = DEVICE_CONNECTED
    data = b"device::ro.product.name=oriole;ro.product.model=Pixel_6"
    response = Message(
        command=int.from_bytes(b"CNXN", "little"),
        arg0=16777217,
        arg1=1048576,
        data_length=len(data),
        data_crc32=sum(data) ^ 0xFFFFFFFF,
        magic=int.from_bytes(b"CNXN", "little") ^ 0xFFFFFFFF,
    )
 

After adding this functionality into the Python script, a connected device should be shown by adb server.

b'CNXN\x01\x00\x00\x01\x00\x00\x10\x00\x05\x01\x00\x00Gf\x00\x00\xbc\xb1\xa7\xb1'
command: b'CNXN'
arg0: 16777217
arg1: 1048576
data_length: 261
data_crc32: 26183
magic: 2980557244

b'host::features=shell_v2,cmd,stat_v2,ls_v2,fixed_push_mkdir,apex,'
Expecting another 197 bytes
b'abb,fixed_push_symlink_timestamp,abb_exec,remount_shell,track_ap'
Expecting another 133 bytes
b'p,sendrecv_v2,sendrecv_v2_brotli,sendrecv_v2_lz4,sendrecv_v2_zst'
Expecting another 69 bytes
b'd,sendrecv_v2_dry_run_send,openscreen_mdns,devicetracker_proto_f'
Expecting another 5 bytes
b'ormat'
Expecting another 0 bytes
b'AUTH\x02\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00(\x80\x00\x00\xbe\xaa\xab\xb7'
command: b'AUTH'
arg0: 2
arg1: 0
data_length: 256
data_crc32: 32808
magic: 3081480894

b'\xa2\xcc\xfc\xe7\xfcs\xe1$\xa3In~\xa60\xa6\x1f\xc1N7^jT\xe53\xd2\\\xee\xa7J<fk\xee?\xd9G\xb7v\x94rzp\x89~\xaa\xbee\x83\xfe\x16\xb8\xfa\xc5}\xe2\x82\xa5\x9e\x13\xc2\x14}AJ'
Expecting another 192 bytes
b'\xb5)6\xcf\xc19\xda\xdd\xa9Y\xa3\xf5\xff\x90\xc2\x189\xa3\xf3\xe5`\xd4\x9d>\xf7\x90\xb3!c\x8ec\x9b\xd4dG=\x8b.\xa3\x061\x8e\xd5\x03eG&\xd8 \xd9P\xe6+\xf9\xaf:\xc7\x82U\x91\xa2m\xee^'
Expecting another 128 bytes
b'\xc4\x1e\xc0\xbf I\xf0\xf3\x00.v\x8b#\x01\x87K\xcf\xab\xcfZ\xf9\xd9t/\x97\x14\x83\xa9\xae\xfaKp\x1b;\x98\xaa&j\xeefX\x16lE\xef*\xb3\x1b^\xdde\x88\xf8\xa4v\xe40l\x7f\xad\x1f\x19\x1a\xbd'
Expecting another 64 bytes
b"\xe1\xa2\x89`'\xb5\xb10\x1e\x90w\ty\xbdU\xdf<\x81s!\x12\x1d\x92\x8f\xe3(~\x07\xf7!$\xf6\xf8 bW\xfe\xbe\xddL\r\xab\xfb2@S\x93\x06\xf5/q\xe2\xbfS\x12\x1a\xea\x9a\xfe\x85EN\x1bB"
Expecting another 0 bytes
 adb devices -l
List of devices attached
25161FDF60012T         device 1-10 product:oriole model:Pixel_6 transport_id:165

Executing Commands

The main use case for hardware emulation of a device to spoof traffic, rather than just spoof that the device exists. Spoofing shell commands over adb is relatively simple - the general flow of a shell command executed via ADB is the following

  1. The host sends the device an OPEN message. The message header contains the local_id number of the session. The body of the message contains the stream destination and command to execute
  2. The device responds with an OKAY message it’s own local_id, along with the remote_id which corresponds to the local_id of the host. These identifiers are how multiple sessions can be differenciated.
  3. The device responds with a WRTE message, containing the result of the executed command.

An example of implementing this behaviour for a simple ‘id’ command could look like this.

def handle_open(self, local_id, data):
    if data[:6] == b"shell:":
        command = data[6:].strip(b"\x00")
        print(command)
        match command:
            case b"id":
                result = b"uid=2000(shell) gid=2000(shell) groups=2000(shell)\n\x00"
                open_response = Message(
                    command=int.from_bytes(b"OKAY", "little"),
                    arg0=25,
                    arg1=local_id,
                    data_length=0,
                    data_crc32=0,
                    magic=int.from_bytes(b"OKAY", "little") ^ 0xFFFFFFFF,
                )
                wrte_response = Message(
                    command=int.from_bytes(b"WRTE", "little"),
                    arg0=25,
                    arg1=local_id,
                    data_length=len(result),
                    data_crc32=0,
                    magic=int.from_bytes(b"WRTE", "little") ^ 0xFFFFFFFF,
                )
 
                return [open_response.pack(), wrte_response.pack(), result]
 

Warning

Implementing support for other stream destinations or an interacting shell session would be slighly more complicated, and isn’t really the focus of this blog post

[Host to Device]
command: b'OPEN'
arg0: 308 (Host local_id)
arg1: 0
data_length: 9
data_crc32: 0
magic: 2981801904

Payload: b'shell:id\x00'

[Device to Host]
command: b'OKAY'
arg0: 25 (device local_id)
arg1: 308 (host local_id)
data_length: 0
data_crc32: 0
magic: 2797515952

[Device to Host]
command: b'WRTE'
arg0: 25 (device local_id)
arg1: 308 (host_localid)
data_length: 52
data_crc32: 0
magic: 3131813288

Payload: b'uid=2000(shell) gid=2000(shell) groups=2000(shell)\n\x00'

This creates the following behaviour

❯ adb shell id
uid=2000(shell) gid=2000(shell) groups=2000(shell)

Conclusion

Emulating hardware devices can be very useful for red teaming and vulnerability research. It allows you to interact with a device as if there is hardware present, which can be useful for products that are hard to aquire, or when you cannot connect sensitive hardware to untrusted devices.

Additonally, by emulating a device, you can manipulate communication and exploit vulnerabilities in the host’s USB stack - a common tactic of forensics companies. I hope this post showed how one might go about creating an MVP of an emulated device.