Reverse engineering the Bluetooth protocol of a concert lightstick

How to reconstruct a lightstick's Bluetooth protocol to control it from a PC without the official app: traffic capture, static analysis of the Android app, packet format and checksum.

Reverse engineeringBLEAndroidJADXPython
Contents
  1. The app and Bluetooth Low Energy
  2. First method: recording the traffic
  3. Comparing packets
  4. Second method: static analysis of the app
  5. Control from a PC
  6. Why use both methods
  7. Limits

In short

  • The lightstick is controlled from a phone app over Bluetooth Low Energy. I wanted to understand the commands so I could use it without the app.
  • I recorded the phone's Bluetooth traffic while using the app with known colours, then compared the packets with each other.
  • The comparison explains almost everything: colour, effect, start and end of the packet. The rest comes from static analysis of the app: decompiling it with apktool and JADX, and reading the code that builds the packets.
  • The second-to-last byte is a check value (checksum): it has to be recalculated for every command.
  • In the end the lightstick can be driven from a PC with a few lines of Python.

A lightstick is the light-up wand the audience waves at concerts. This one pairs with a phone, and the app lets you pick colour, brightness and effect. The goal was to drive it without the app, from a PC. That takes reverse engineering: reconstructing an undocumented protocol by watching how the app talks to the device and by reading the app's own code. It's the same process, on a small scale, used to understand how an app handles data and communication in a security assessment.

The app and Bluetooth Low Energy

On first launch the app asks you to scan the QR code under the lightstick. From then on the link is Bluetooth Low Energy (BLE), the variant of Bluetooth designed for small, low-power devices: sensors, fitness bands, "smart" gadgets.

How a BLE device talks. A BLE device exposes services, each with a number of characteristics: small slots identified by a code (UUID). The phone writes bytes into one slot to send a command, and reads another, or asks to be notified when it changes, to receive data. The scheme is called GATT. Understanding the protocol means understanding what to write and into which slot.

The lightstick has one service with three characteristics. Only one matters for commands, the writable one:

UUID (short form)Role
FFF0service
FFF2write: commands go here
FFF1read and notify: the lightstick's replies

The app does only a few things: pick a colour, set the brightness and choose an effect. The effects are steady, flash, breathing, candle, "party", rainbow, multicolour and heart. There's also an off command.

There are two ways to find out what the app sends: listen to the Bluetooth traffic, or read the app's code. I used both, and each covered what the other left open.

Bluetooth log phone's HCI snoop Android app vendor's APK Wireshark what gets sent apktool + JADX why it looks that way Packet format fields, length, checksum Control from a PC in Python
The traffic shows what is sent; the app's code explains the fields the traffic alone doesn't.

First method: recording the traffic

Android has a developer option that writes all of the phone's Bluetooth traffic to a file: "Enable Bluetooth HCI snoop log". HCI is the interface between the operating system and the Bluetooth chip, so every packet exchanged with the lightstick ends up in the log.

The important thing is to know exactly what you are recording. I turned on the log and used the app with values that are easy to spot, noting the order: full red, full green, full blue, white, then a few effects. The simpler the values, the easier they are to find in the bytes.

On some phones the file isn't directly accessible: it ends up inside the Android bug report, compressed and encoded in a format called btsnooz. The Android project provides a script that extracts it back into the standard btsnoop format. From there the file opens in Wireshark, and filtering for GATT writes to characteristic FFF2 leaves only the commands sent by the app.

Comparing packets

Each command is a 14-byte string. This is the one for white, taken from the capture:

55 AA 06 00 01 5B 5B 5B 01 80 80 13 FE EF

Lining the packets up, some parts never change and others change along with what you do in the app:

  • 55 AA at the start and FE EF at the end never change: they delimit the packet;
  • three bytes change with the colour: red, green and blue. In white all three are 5B;
  • one byte changes with the effect: 01 is steady light;
  • 06 00 01 and 80 80 stay the same from one colour to the next;
  • the second-to-last byte, 13 in white, changes from one colour to another, but not in an obvious way.

One odd thing: white wasn't FF FF FF but 5B 5B 5B, that is 91 out of 255. The reason became clear later.

Second method: static analysis of the app

The capture tells you what is sent, but not why. To put names on the fields you need the other half of the reverse engineering: reading the app. This is static analysis: studying the code without running or modifying it, with the same process used in a security assessment of an Android app.

What an APK is. An Android app is a ZIP archive with an .apk extension. Inside are the manifest (package name, permissions, components), the resources (strings, images, layouts) and the code, compiled to Dalvik bytecode in the classes.dex files. Bytecode isn't directly readable, but it keeps enough structure to be turned back into something understandable.

Extracting and decompiling

The APK can be pulled from the phone with adb or taken from a public APK archive. From there, two complementary tools:

  • apktool extracts the manifest and resources in readable form and disassembles the bytecode into smali, a kind of assembly for Android's virtual machine. Smali is faithful to the bytecode line by line: when something in the decompiled code is unclear, that's where the truth is.
  • JADX decompiles the bytecode into Java. The result isn't the original source, but it's much faster to read. It also has a GUI with text search and cross-references: click a method and see who calls it.

In practice you read in JADX and fall back to smali when the decompiler gets something wrong, for example loops or conversions between numeric types.

Finding your way around

A modern app contains dozens of libraries: analytics, ads, UI components. The vendor's own code is often a small part of it, and if the app is obfuscated, classes and methods have names like a, b, c. You need a foothold.

Here there were two, both already known from the capture:

  1. The UUIDs. The GATT slots FFF0 and FFF2 must appear in the code as strings, in the long form 0000fff2-0000-1000-8000-00805f9b34fb. Searching for them leads straight to the class that handles the Bluetooth connection.
  2. Android's Bluetooth APIs. Every BLE app eventually calls writeCharacteristic on the system BluetoothGatt class. System APIs can't be obfuscated, so searching for calls to that method finds every place where the app sends data to the device.

Following the flow

Once you've found where the bytes leave, you walk backwards: who calls the send function, and with what data. In this app the chain was short:

  1. the colour screen, when the user moves the slider, calls a method that takes colour, brightness and effect;
  2. that method hands the values to a class that builds the packet;
  3. the packet goes into the send queue and then into writeCharacteristic.

The class in step 2 is the one that matters, and it was easy to spot because it wasn't obfuscated: it had an explicit name, and its constants had names like header, trailer, light command. That's common, because obfuscators leave alone whatever the app uses through reflection or shares with other libraries.

Going the other way is just as useful: from the UI buttons (whose labels live in the resources, so you find them by searching for the word on screen) down to the code that handles the tap. That's also how you find commands that exist but don't show up in normal use, such as the concert ones described below.

What you get

The packet-building class gives the full format:

ByteIn whiteMeaning
1–255 AAstart of packet
3–406 00data length: 6 bytes, least significant byte first
501command: light control
6–85B 5B 5Bred, green, blue, already scaled by brightness
901effect: steady
1080reaction sensitivity (low, medium, high), with the top bit always set
1180fixed value, "no mode"
1213checksum
13–14FE EFend of packet

The mystery of white at 91 is solved like this: the app doesn't send brightness in a separate field, it multiplies the three colours by the chosen brightness, with a 5% minimum. So in the capture the brightness must have been around 36%: 255 × 0.36 is about 91.

The checksum

13 is a checksum: the sum of the command byte and all the data bytes, keeping only the lowest 8 bits. For white:

01 + 5B + 5B + 5B + 01 + 80 + 80 = 0x213  →  13

Copying it from the capture works only for that white at that brightness: a packet generator has to recalculate it for every command.

What a checksum is for. It's a number computed from the message's content and sent along with it. The receiver repeats the calculation: if the result doesn't match, the message was damaged on the way. It's the same idea as the check digit at the end of a credit card number.

The rest of the protocol

The same structure is used for other commands, which the app uses mostly at concerts: reading the battery level, setting the seat position and the animation version. The seat position probably tells the lightstick where it is in the venue, for coordinated effects. Read requests use the corresponding command code plus A0: 01 sets the light, A1 asks for its state.

Control from a PC

With the format clear, driving the lightstick from a computer is simple. I used Python with the bleak library, which works on Windows, macOS and Linux:

  1. scan for nearby Bluetooth devices and pick the one that advertises itself over Bluetooth with a name containing "DreamCatcher";
  2. connect;
  3. build the packet with colour, brightness and effect, and write it to characteristic FFF2.

As a test I wrote a fade that cycles through red, green, blue and white while varying brightness, sending a command roughly every 20 milliseconds. If a send doesn't complete within 5 seconds, the script treats the lightstick as disconnected and stops, instead of hanging.

Why use both methods

The capture alone gave me most of the protocol, but I couldn't have said what 06 00 or 13 were, and I'd have written a packet builder that only worked for the cases I'd recorded. The code alone would have given me the field names, but not the confirmation that the lightstick actually behaves the way the app says.

Together, the traffic tells you what happens and the code explains why.

Limits

  • The script only implements light control: I found the concert commands in the app's code but haven't tested them on the device.
  • I didn't touch the lightstick's firmware: everything here goes through the interface the app already uses.

← All posts