Reverse Engineering a BLE Gimbal Controller with Frida
The problem
I recently got this device from a member of Hackerspace Trójmiasto. The Feiyu Scorp C gimbal only ships with a closed-source Android app, no public API, no documentation, no way to script it from a computer. The app talks to the gimbal over Bluetooth Low Energy (BLE), so in principle every command it can send is just a handful of bytes over a GATT characteristic. If I could capture those bytes, I could replay them from anything — a Python script, a Flask server, a Raspberry Pi.
This post walks through the process: hooking the Android app with Frida, capturing the raw BLE writes, and turning them into a small Python controller.
Step 1: Find the GATT characteristics
Before touching the app, I paired the gimbal and used nRF Connect (Android) to poke around its GATT table. Most BLE peripherals expose a vendor-specific service with a write characteristic (commands go in) and a notify characteristic (status/telemetry comes out). For the Scorp C:
DEVICE_ADDRESS: 94:B5:55:D8:25:5A
WRITE_CHAR: 0000ff01-0000-1000-8000-00805f9b34fb
NOTIFY_CHAR: 0000ff02-0000-1000-8000-00805f9b34fb
Knowing the characteristics tells you where the commands land, but not what bytes to send. For that you need to watch the real app talk to the real device.
Step 2: Hook the app with Frida
Frida lets you inject JavaScript into a running process and intercept function calls — including the Android Bluetooth stack’s writeCharacteristic calls, before they ever leave the phone. Setup:
- Rooted Android device (or emulator with root), USB debugging enabled
frida-serverrunning on the device, matching the hostfridaversion- The Feiyu app installed and BLE-paired with the gimbal
The hook itself targets android.bluetooth.BluetoothGatt.writeCharacteristic:
Java.perform(function () {
const BluetoothGatt = Java.use("android.bluetooth.BluetoothGatt");
BluetoothGatt.writeCharacteristic.overload(
"android.bluetooth.BluetoothGattCharacteristic"
).implementation = function (characteristic) {
const value = characteristic.getValue();
const hex = Array.from(value, function (b) {
return ("0" + (b & 0xff).toString(16)).slice(-2);
}).join(" ");
console.log("[WRITE] " + characteristic.getUuid() + " " + hex);
return this.writeCharacteristic(characteristic);
};
});
Running this and then mashing every button in the app (joystick directions, mode switches, record/photo toggles) produces a log of every command the app is capable of sending:
[WRITE] 0000ff01-0000-1000-8000-00805f9b34fb 24 3c 00 01 0d 0a
[WRITE] 0000ff01-0000-1000-8000-00805f9b34fb 24 3c 00 02 0e 0a
[WRITE] 0000ff01-0000-1000-8000-00805f9b34fb 24 3c 00 10 1c 0a
...
Each distinct hex string, correlated with the button that was pressed while it was captured, becomes a documented command.
Step 3: Figure out the connection sequence
Devices like this usually aren’t stateless — sending a “move” command cold, without whatever handshake the app does on connect, is a common way to get silently ignored. Diffing the very first writes after each fresh pairing against the steady-state traffic showed a fixed 8-write connection sequence, followed by a repeating keep-alive packet every ~800ms. Skip either one and the gimbal stops responding within a few seconds even though the BLE link itself stays up.
That distinction — one-time setup vs. continuous heartbeat vs. one-shot command vs. held-down directional command — is the actual reverse-engineering result here. The bytes are trivial to copy; understanding when and how long to send them is the part that takes iteration.
Step 4: Replay from Python
With the command table and connection sequence in hand, bleak (a cross-platform async BLE library) can drive the gimbal directly:
import asyncio
from bleak import BleakClient
DEVICE_ADDRESS = "94:B5:55:D8:25:5A"
WRITE_CHAR = "0000ff01-0000-1000-8000-00805f9b34fb"
CONNECTION_SEQUENCE = [
"24 3c 00 00 0c 0a",
# ... remaining captured handshake writes
]
async def connect_and_init():
async with BleakClient(DEVICE_ADDRESS) as client:
for cmd_hex in CONNECTION_SEQUENCE:
await client.write_gatt_char(WRITE_CHAR, bytes.fromhex(cmd_hex))
await asyncio.sleep(0.03)
print("Gimbal initialized.")
asyncio.run(connect_and_init())
From there it’s straightforward to wrap the write in a small Flask app with a web UI: a background asyncio loop owns the BLE connection and heartbeat, and Flask request handlers hand commands off to it with asyncio.run_coroutine_threadsafe.
Takeaways
- BLE traffic capture doesn’t require rooting the protocol — you don’t need to know why
24 3c 00 10 1c 0ameans “tilt up,” you just need to capture it reliably and reproduce the timing around it. - Frida’s Java hooks are the highest-leverage tool for this kind of work on Android — no APK decompilation needed if you can intercept the call at the framework boundary instead.
- Timing state (handshakes, heartbeats, hold-vs-toggle) is usually the hard part, not the payload bytes themselves.