A multi-headed BLE security & reverse-engineering tool for the ESP32 - named for the chimera because it's one beast made of many parts, and because it speaks BLE in all three roles: Host (central - scan, connect, enumerate, decode), Peripheral (clone/emulate a device), and MITM (both at once - proxy between a real device and its host). Enumerate a full GATT tree, read/write/ subscribe to characteristics, decode advertising data, parse HID report maps, clone a device, direction-find it, or sit in the middle and log the traffic - from a serial command line or the on-device Cardputer UI.
Built on NimBLE-Arduino 2.x.
⚠️ Responsible use. ChimeraBLE is a dual-use security research tool: it can impersonate/clone devices, man-in-the-middle connections, run a honeypot, and sniff HID/notification traffic. Use it only on devices you own or are explicitly authorized to test. You are responsible for complying with all applicable laws. Provided with no warranty (see LICENSE).
Supported boards
| Env | Board | MCU | Front-end |
|---|---|---|---|
esp32dev |
ESP32 WROOM | classic Xtensa dual-core | serial CLI |
xiao_esp32c5 |
Seeed Studio XIAO ESP32-C5 | RISC-V single-core | serial CLI |
cardputer |
M5Stack Cardputer ADV | ESP32-S3 (Stamp-S3A) | on-device M5 UI |
All targets use the pioarduino
platform (Arduino-ESP32 3.x / ESP-IDF 5.5) and share the same BLE engine
(scanner, connection, gatt, adv_parser, uuid_db, hid_parser, oui_db,
fingerprint, clone). build_src_filter swaps only the front-end: the serial
targets use main.cpp + cli.cpp; the Cardputer uses src/ui/ (an
M5 menu UI in App/UI) and runs the engine natively on its own S3 radio - no
companion device. The XIAO ESP32-C5 board def lives in
boards/seeed_xiao_esp32c5.json.
Build & flash
cd ChimeraBLE
# build both targets
pio run
# pick a target
pio run -e esp32dev
pio run -e xiao_esp32c5
# flash + monitor a specific target
pio run -e xiao_esp32c5 -t upload
pio device monitor -e xiao_esp32c5 # serial console @ 115200
# one-time: flash the OUI vendor database to the device filesystem (LittleFS).
# Needed for OUI-based vendor identification; also (re)formats LittleFS, so do
# this BEFORE saving clone profiles you want to keep.
pio run -e xiao_esp32c5 -t uploadfs
After flashing, open the monitor. You'll get a ble> prompt (it changes to
ble-connected> while a device is connected). Type help for the command list,
or just start with scan.
Cardputer ADV (on-device UI)
pio run -e cardputer -t upload # flash firmware
pio run -e cardputer -t uploadfs # one-time: OUI DB onto LittleFS
No serial console needed - the UI is on the screen. Keyboard navigation:
| Key | Action |
|---|---|
; / . |
move selection up / down (scroll in Logs) |
/ or Enter |
open / activate |
, or Del |
back (stops a fox hunt) |
' |
command mode - type any CLI command (e.g. secure mitm iocap=display_yesno, clone save x) and Enter |
Screens: Main → Scan / Devices / GATT / Command / Logs. Devices lists
scan results (RSSI + name or ~fingerprint); select one for Device Detail
(Connect+dump, or Foxhunt). Foxhunt shows a live signal bar. Anything the
serial CLI can do is reachable from command mode, since both share the same
dispatch (cli::execute).
Quick start
ble> scan 10 # scan 10 seconds
ble> list # see what was found
ble> info 3 # inspect device #3's advertising data
ble> connect 3 # connect to it
ble> secure # encrypt/bond (needed for many characteristics)
ble> dump # enumerate everything + auto-parse HID if present
ble> sub 0x002d # subscribe to a notify characteristic
... press buttons / trigger the device, watch notifications stream ...
ble> disconnect
Command reference
Scanning & discovery
| Command | What it does |
|---|---|
scan [secs] |
Active scan for nearby devices (default 10s). Results are deduped by MAC. |
stop |
Stop an in-progress scan early. |
list |
Show the last scan's results, sorted by signal strength (strongest/nearest first) with indices renumbered 0..N to match: index, MAC, RSSI, adv byte count, and name + fingerprint guess. No-name devices show ~guess (e.g. ~Apple (Nearby), ~BTHome sensor). Named devices show name | guess when the guess adds something (e.g. Vertuo_DV6_… | company 0x…), since many self-reported names are cryptic. RPA/random addresses are tagged [RPA]/[rand]. |
info <idx> |
Full advertising payload for a device, hex + decoded AD records (flags, UUID lists, name, Tx power, appearance, service data, manufacturer data with Apple/Microsoft/Google sub-decoding), plus a fingerprint section: best-guess identity, address type (public / random-static / RPA), and every identifying signal broken out. |
foxhunt <idx|mac> |
RF direction finding. Continuously tracks one device's RSSI and streams a live signal-strength bar - walk around and watch it climb as you get closer. Shows current dBm, a bar, the peak seen, and a closer/farther trend. stop to end. |
Device fingerprinting (no connection needed): lots of BLE devices advertise no name - beacons, trackers, sensors, phones in privacy mode. The tool guesses what they are from the advertising data, in priority order:
- Service-data protocol UUIDs - Eddystone, Google Fast Pair, BTHome, Exposure Notification, Xiaomi/MiBeacon, Tile, etc. (strongest signal).
- Manufacturer company ID → vendor name, with Apple Continuity sub-type (iBeacon / Nearby / Handoff / AirPods / FindMy …).
- Recognizable custom services (e.g. Nordic UART).
- SIG service UUIDs → device class (HID input device, Heart Rate monitor, Fitness machine, Environmental sensor …).
- Appearance → device category.
- OUI vendor (public addresses only) → IEEE-assigned product brand, e.g.
fc:b9:7e→ GE Appliances,0c:95:05→ Chamberlain Group. This complements the company ID: the company ID is the SIG member (often the chip/parent -0x0929is Qingdao Haier), while the OUI is the IEEE product brand (GE Appliances). Requires the OUI database on the filesystem (uploadfs). - Address type - public, random-static, or resolvable-private (RPA, the
rotating privacy address most phones/watches use). Shown as
[RPA]inlist.
The full Bluetooth SIG company-identifier list (4,000 entries) is embedded in
flash; the IEEE OUI table (39,000 entries) lives as a binary on LittleFS and is
binary-searched at runtime. Both are generated from authoritative sources (the
SIG/Nordic numbers DB and Wireshark's manuf). RPA-clustering across MAC
rotations (fingerprint hashing) is intentionally not implemented.
Connection
| Command | What it does |
|---|---|
connect <idx|mac> |
Connect to a device by its scan index or MAC (aa:bb:cc:dd:ee:ff). Auto-requests a 247-byte MTU to speed up reads. |
disconnect |
Drop the current link and clear the cached GATT tree. |
mtu <bytes> |
Manually request an ATT MTU (23-517). |
status |
Show current link state, encryption/bond status, scan state, and whether a GATT tree is cached. |
Security & bonding
| Command | What it does |
|---|---|
secure |
Request encryption / bonding on the current link with the active security config. |
secure show |
Print the current security config without initiating pairing. |
secure <flags> |
Update the security config, then secure. Flags can be combined. |
secure auto {on|off} |
Auto-initiate pairing immediately after every connect. Needed for HID peripherals (keyboards, remotes) that lead with a security request and drop the link if pairing doesn't start in time. |
bonds |
List bonded devices stored in NVS. |
forget <mac> |
Delete one stored bond. |
forget all |
Delete all stored bonds. |
Security flags (for secure <flags>):
| Flag | Meaning |
|---|---|
bond / nobond |
Store the pairing keys (default bond). |
mitm / nomitm |
Require man-in-the-middle protection / authenticated pairing (default nomitm). |
lesc / legacy |
LE Secure Connections vs legacy pairing (default lesc). |
iocap=none |
No input/output - "Just Works" pairing (default). |
iocap=display |
Display only. |
iocap=display_yesno |
Display + yes/no - enables Numeric Comparison. |
iocap=keyboard |
Keyboard only - passkey entry. |
iocap=keyboard_display |
Keyboard + display. |
Example: secure nobond mitm iocap=display_yesno
Note: Bonding to the ESP32 may evict a device's existing bond to its real host (e.g. pairing a remote to the ESP32 can unpair it from its TV). If a device refuses to pair because of a stale bond on the ESP32, clear it with
forget <mac>and try again.
Connecting to HID devices (keyboards, remotes, controllers): these require an encrypted link and send a security request the instant you connect. Two things matter - pairing must start immediately, and it usually must be authenticated (MITM), not Just Works:
secure mitm iocap=display_yesno # authenticated pairing (Numeric Comparison)
secure auto on # pair immediately on connect
connect <idx>
If the device uses Numeric Comparison (a passkey appears on both the device and, say, your Mac when you pair normally), the ESP32 prints the 6-digit value and auto-accepts - confirm the matching prompt on the device:
[sec] >>> NUMERIC COMPARISON passkey = 481516 <<<
If the device uses Passkey Entry (it displays a code you must type), the tool prompts you - read the code off the device and run:
passkey 481516
secure auto on alone (Just Works, iocap=none) is enough for devices that
accept unauthenticated pairing, but many HID peripherals reject it with HCI
0x205 Authentication Failure.
Automatic MITM upgrade: you usually don't have to do this by hand. If a
connect drops with 0x205/0x206 while MITM/auto-secure were off, the tool
automatically enables mitm iocap=display_yesno + secure auto on and retries
the connection once:
connect 40
[link] disconnected reason=0x205 (Authentication Failure ...)
[sec] pairing was rejected - auto-enabling MITM numeric comparison ... and retrying once
[link] connecting to 80:e1:26:...
[sec] >>> NUMERIC COMPARISON passkey = 481516 <<<
[sec] auth complete encrypted=1 bonded=1
It only upgrades once per connect attempt, and only if those settings weren't
already on - so a persistent 0x205 after the upgrade points instead at a
stale bond: forget all on the ESP32 and unpair the ESP32 on the target,
then retry. Disconnect reasons are decoded inline (0x206 = key missing,
0x208 = timeout, 0x213 = remote terminated, etc.).
The ATT MTU is negotiated lazily on the first GATT operation rather than during connect, so it doesn't race a device-led pairing handshake. You'll see
[mtu] negotiated MTU = Nwhen youdump, not at connect time.
GATT operations
All characteristic-targeting commands accept either a handle (hex 0x002a
or decimal 42) or a UUID via the uuid: prefix (uuid:2A19 for a 16-bit
SIG UUID, or uuid:6e400001-b5a3-f393-e0a9-e50e24dcca9e for a full 128-bit
UUID). Handles are unique per connection; a UUID lookup returns the first match.
| Command | What it does |
|---|---|
dump |
Enumerate all services, characteristics, and descriptors; read every readable value; resolve SIG UUIDs to friendly names. Auto-runs the HID parser if a HID service (0x1812) is present. |
read <h|uuid:U> |
One-shot read of a characteristic. |
write <h|uuid:U> <hex> |
Write with response. Hex accepts 01:02:03, 01-02-03, 01 02 03, or 010203. |
writenr <h|uuid:U> <hex> |
Write without response. |
sub <h|uuid:U> |
Subscribe to notifications/indications. Each event streams live with a timestamp, source UUID, and payload. |
unsub <h|uuid:U> |
Unsubscribe. |
subs |
List currently active subscriptions. |
Decoders
| Command | What it does |
|---|---|
hid |
Find the HID service (0x1812), read the Report Map (0x2A4B), and parse it per USB HID 1.11: a decoded item tree (Usage Page/Usage names, collections, Input/Output/Feature flags) plus a per-Report-ID byte-layout cheatsheet for interpreting live notifications. Requires a prior dump. |
hid stream |
Subscribe to the device's HID Input report characteristics and decode notifications live. Keyboard reports become key names (e.g. LShift+A, Enter); other report types print as raw bytes. Press keys on the device and watch them stream over serial. Requires a connected device with a prior dump. |
hid stream off |
Stop the live stream and unsubscribe. |
Cloning (peripheral emulation)
Capture a connected device's attribute table + advertising data into a profile, then re-expose the ESP32 as a BLE peripheral that mirrors it. See Cloning a device below for the workflow and limitations.
| Command | What it does |
|---|---|
clone save <name> |
Serialize the dumped device's GATT tree (services, characteristics, properties, values, descriptors) + captured adv payload to /clones/<name>.json on LittleFS. Works from the dump snapshot, so it still succeeds after the device drops the link - just connect + dump at some point first. |
clone list |
List saved profiles. |
clone load <name> |
Load a profile into memory. |
clone info |
Show the loaded profile's structure. |
clone advertise |
Bring up a NimBLEServer mirroring the loaded profile and start advertising (replays the captured adv payload). Drops any active central link first. |
clone stop |
Stop advertising. |
clone delete <name> |
Delete a saved profile. |
Output mode
| Command | What it does |
|---|---|
json on |
Switch to JSON-line output: one JSON object per event. Suppresses the ble> prompt for clean piping. |
json off |
Back to human-readable output. |
json |
Show the current mode. |
Misc
| Command | What it does |
|---|---|
help |
Print the built-in command list. |
JSON mode
With json on, machine-parseable events are emitted one JSON object per line.
Interactive output (help, errors, status, security config) stays human-readable.
Event types:
{"ev":"scan_end","t":12340,"count":7}
{"ev":"device","t":12345,"idx":0,"mac":"aa:bb:cc:dd:ee:ff","addr_type":1,"rssi":-58,"name":"AR","adv":"02 01 06 03 02 12 18"}
{"ev":"svc","t":12500,"uuid":"0x180F","start":"0x002A","end":"0x002C"}
{"ev":"chr","t":12501,"svc":"0x180F","uuid":"0x2A19","handle":"0x002B","props":"R--N--","value":"64"}
{"ev":"dsc","t":12502,"chr_handle":"0x002B","uuid":"0x2902","handle":"0x002C","value":"01 00"}
{"ev":"dump_end","t":12550}
{"ev":"read","t":12600,"uuid":"0x2A19","handle":"0x002B","value":"63"}
{"ev":"notify","t":12700,"svc":"0x1812","uuid":"0x2A4D","handle":"0x002D","bytes":"02 01 00 00"}
The props string is a 6-char field: R read, W write, w write-no-response,
N notify, I indicate, B broadcast (- where absent).
Example - diff two devices' GATT trees:
jq -c 'select(.ev=="chr") | {svc, uuid, props}' deviceA.jsonl | sort > A.txt
jq -c 'select(.ev=="chr") | {svc, uuid, props}' deviceB.jsonl | sort > B.txt
diff A.txt B.txt
Typical workflows
Reverse-engineer an unknown peripheral
scan 15 → list → info <idx> → connect <idx> → secure → dump
The dump resolves known UUIDs, reads all values, and shows the full attribute table. Subscribe to notify characteristics and exercise the device to capture its live protocol.
Decode an HID device (remote, keyboard, controller)
connect <idx> → secure → dump → hid
hid turns the raw Report Map into report layouts so you can interpret the
bytes that arrive on each notify.
Watch keystrokes from a BLE keyboard live
connect <idx> → (auto-secures) → dump → hid stream
Now press keys on the keyboard - they decode in real time:
[hid] stream active on 1 characteristic(s) - press keys on the device
[hid kbd] H
[hid kbd] I
[hid kbd] LShift+1
[hid kbd] Enter
hid stream off to stop. Non-keyboard input reports (consumer/media, mouse)
print as raw bytes - cross-reference them against the hid report layout.
Capture for offline analysis
json on → connect <idx> → secure → dump → sub <handle>
Pipe the serial stream to a file and process with jq.
Find a device physically (fox hunt)
scan → list → foxhunt <idx>
Continuously tracks that one device's RSSI with a live bar; move around and the
bar climbs as you close in. Peak-hold smooths fast advertisers; a "no signal"
note appears if it drops out of range. stop to end.
[fox] hunting 80:e1:26:76:6c:46 (Flipper) - move around, stronger = closer. Type 'stop' to end.
[fox] -71 dBm [######## ] peak -71 ...
[fox] -63 dBm [########## ] peak -63 closer
[fox] -55 dBm [############ ] peak -55 closer
Cloning a device
Mirror a real device as a BLE peripheral for impersonation testing or to replay a captured device to a host:
scan → connect <idx> → secure → dump # capture the attribute table (snapshot)
clone save myremote # serialize to /clones/myremote.json
clone advertise myremote # spoofs the target MAC (reboots once), then advertises
... device reboots, comes back advertising as the target ...
... point a host/phone at it ...
clone stop
How it works: dump reads every characteristic/descriptor value into an
in-memory snapshot. clone save serializes that snapshot - plus the captured
advertising payload - to JSON on LittleFS. Because it works from the snapshot
rather than live reads, clone save still works after the target drops the
link (many devices, e.g. Sonos, close an idle BLE connection seconds after the
dump finishes). clone advertise rebuilds the tree on a NimBLEServer, sets
each characteristic's properties and stored value, and replays the advertising
bytes verbatim.
The GATT snapshot persists across disconnect and is only cleared when you connect to the next device. So
connect → dump → (link drops) → clone saveworks fine. Liveread/write/sub/hidagainst a dropped link will report "not connected" rather than acting on stale data - reconnect to use them.
MAC spoofing (automatic reboot): the BT controller latches its public
address at init, so to advertise under the target's MAC the firmware sets it
before the stack starts. clone advertise detects the address mismatch,
writes the profile name to /clones/.active, and reboots; on the next boot the
MAC is applied and advertising resumes automatically. You'll see:
clone advertise sonos
[clone] rebooting to advertise under target MAC b8:e9:37:xx:xx:xx ...
... device reboots ...
[clone] boot: set BT MAC = b8:e9:37:xx:xx:xx (ok)
[clone] resuming advertise after MAC-spoof reboot
[clone] advertising as 'sonos' on MAC B8:E9:37:XX:XX:XX - 5 services, 18 characteristics
[clone] target MAC b8:e9:37:xx:xx:xx (matched)
A normal power cycle (no clone advertise pending) boots with the real ESP32
MAC in standard scan/connect mode - the .active trigger is one-shot.
Other limitations to be aware of:
- Security: cloned characteristics use open access (no encryption). A host that expects an encrypted/bonded link to the original may behave differently.
- GAP/GATT services: the GAP (0x1800) and GATT (0x1801) services and the CCCD descriptor (0x2902) are owned by the NimBLE stack and can't be recreated as app services (a duplicate 0x1800 would be malformed). The target's Device Name (0x2A00) and Appearance (0x2A01) are replicated - they're pushed into the stack's GAP service, so a host that connects and reads them sees the target's identity. What does not carry over: the exact 0x1800 handle numbers and config-only characteristics like Preferred Connection Parameters (0x2A04) and Central Address Resolution (0x2AA6), which follow the stack's build rather than the target's layout.
- One profile per boot: NimBLE can't cleanly rebuild a GATT table at
runtime.
clone advertisebuilds the server once;clone stoponly stops advertising. Advertising a different profile reboots automatically (needed for the MAC change anyway).
Prebuilt firmware
Prebuilt binaries are attached to the GitHub Releases page (not committed to
the tree): the Cardputer ChimeraBLE.bin (sideload via the M5 launcher, or a
merged 0x0 image), and the OUI vendor database oui.bin.
- Cardputer: copy
oui.bininto the/ChimeraBLE/folder on the microSD card (i.e./ChimeraBLE/oui.bin) for MAC-vendor names (optional - everything else works without it). ChimeraBLE keeps all its files in that one folder and creates it on first launch, or you can make it yourself and dropoui.binin. SeeCARDPUTER_GUIDE.md. - Serial boards (esp32dev / xiao_esp32c5): the OUI DB lives on LittleFS -
generate it with
python tools/make_oui_bin.py(writesdata/oui.bin), thenpio run -e <env> -t uploadfs.
Comments