
Reverse-engineering notes and Python tooling for JBL Wave Buds 2 (BES1306) firmware: OTA container format, flash layout, BLE/GATT protocol, and CRC-32 trust model.
Reverse-engineering notes and reproducible tooling for the JBL Wave Buds 2
(Bestechnic BES1306, PID 2100): the on-flash layout, the OTA container
format down to the byte, the BLE/GATT command protocol and the BES "TOTA v2"
encrypted transport, the device- and app-side trust boundaries, the factory
console, and a reproducible method for loading a self-built image onto a unit you
own.
The analysis spans two builds — the IDA-analyzed wavebuds.elf (Apr 2025) and
the distribution package 2.9.0 (Sep 2026) — a fully specified 8-stream OTA
container (verified by byte-identical repack), the CRC-32 integrity model
confirmed on both the app and device sides, a 54/59-entry factory command table,
12 OTA RAM state variables, a 7,960-function enumeration, and a cross-build
differential. Semantic recovery is deep for the OTA/flash/CRC/factory subsystems
and enumeration-only elsewhere — see the completeness audit.
[!WARNING] This is owner-performed hardware research, not an official JBL update. Flashing a modified image is irreversible: the download phase is staged and brick-safe, but activation of a CRC-valid-but-broken image has no exposed recovery path. Read SAFETY.md before reproducing anything.
Türkçe sürüm: README.tr.md

Hardware result on the tested unit: after the app + user-CA MITM OTA pipeline was validated, both earbuds connect, report 100%, and every app control (ANC/Ambient, gestures, EQ) works. The one-byte-modified image was accepted the same way and the device stayed fully functional.
Addresses are from the analyzed build (wavebuds.elf); see the
address ledger and provenance.
0x2C0918BC checks config, each segment, and the
whole image with the CRC-32 routine 0x2C0CE944 (table 0x2C0F5218) plus a
"BEST" magic. A CRC-valid image with one modified byte was accepted and
booted.com.jbl.counterfeit.Rsa2048DynamicVerifier) is
anti-counterfeit (device authenticity), not firmware signing.0x28120000 while the
running application is untouched, so the download phase is brick-safe.0x2C0905E4: MEMORY[0x20026BA0] ∈ {2,4}).UNRESOLVED.The modification is intentionally minimal — one byte of the decompressed image.
It changes the RTX kernel string RTX V5.5.1 → V5.5.2 at decompressed offset
64096, and the container CRC-32 is recomputed. It is cosmetic by design: the
point is to prove the device accepts a modified-but-CRC-valid image, not to
change behavior.
Base (owner-supplied; not redistributed)
official 2.9.0 package 549,558 bytes SHA-256 836cb251f9fb2261dde14f39ba18d358c48018817f3c3d1b527e1ac5f3ab7ba2
decompressed image 955,596 bytes SHA-256 489663e80b76f20f4c94671064908569fac5f4e3df10ab6d7369baffe094f67d
container CRC-32 A0C7CCEC (valid), input range [0x0, 0x862AD)
The repository intentionally contains no JBL firmware. Supply your own legally obtained package and reproduce:
python3 tools/ota_unpack.py <official_2.9.0_package.bin> --extract image.bin
python3 tools/ota_repack.py make-test <official_2.9.0_package.bin> test.bin
python3 tools/ota_unpack.py test.bin # exactly one decompressed byte differs; CRC valid
The repacked container's exact bytes depend on the LZMA encoder; the device verifies CRC-32, not a package hash (see docs/04), and the one-byte decompressed diff is deterministic. Delivery through the app is in docs/08.
| Flash map | 2 MB NOR partitioning |
| OTA command flow | the sequence and its CRC-32 checkpoints |
| Trust boundaries | what each stage verifies, and where image trust rests |