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.
Contents
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 |
|---|---|
FFF0 | service |
FFF2 | write: commands go here |
FFF1 | read 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.
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 AAat the start andFE EFat 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:
01is steady light; 06 00 01and80 80stay the same from one colour to the next;- the second-to-last byte,
13in 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:
- The UUIDs. The GATT slots
FFF0andFFF2must appear in the code as strings, in the long form0000fff2-0000-1000-8000-00805f9b34fb. Searching for them leads straight to the class that handles the Bluetooth connection. - Android's Bluetooth APIs. Every BLE app eventually calls
writeCharacteristicon the systemBluetoothGattclass. 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:
- the colour screen, when the user moves the slider, calls a method that takes colour, brightness and effect;
- that method hands the values to a class that builds the packet;
- 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:
| Byte | In white | Meaning |
|---|---|---|
| 1–2 | 55 AA | start of packet |
| 3–4 | 06 00 | data length: 6 bytes, least significant byte first |
| 5 | 01 | command: light control |
| 6–8 | 5B 5B 5B | red, green, blue, already scaled by brightness |
| 9 | 01 | effect: steady |
| 10 | 80 | reaction sensitivity (low, medium, high), with the top bit always set |
| 11 | 80 | fixed value, "no mode" |
| 12 | 13 | checksum |
| 13–14 | FE EF | end 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:
- scan for nearby Bluetooth devices and pick the one that advertises itself over Bluetooth with a name containing "DreamCatcher";
- connect;
- 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.