Beken tooling
The tooling for Beken (BK72xx) plugs, worked out on the Sonoff S61 - see docs/beken-s61-emulation.md. Not part of the Autoflash tool itself: these drive the BekenEmulator with this project’s patches, read and write the flash formats a conversion passes through, or analyse a firmware image statically. The experiments that proved how the stock firmware pairs, and that nothing in the conversion needs (AirKiss replay, BLE provisioning, the button and pairing drives), are in archive/s61-research/.
The emulator comes with this repository as a container image, kept as the
S60’s is: emulator/ pins the upstream commit and its source
archive (on the repository’s emulator-source-beken-<commit> release) and holds
this project’s patches, and its bk-emu.sh pulls the published image for that
version or builds it, fetches the public S61s dump from upstream’s repository
(Sonoff’s firmware, which this repository never holds) and runs a tool in the
container, with this checkout at /work:
tools/beken/emulator/bk-emu.sh python tools/beken/integrity_gate.py
In the container the emulator is at /opt/beken-emulator, the tools’ default
BK_EMU_ROOT, and the fetched dump their default S61_DUMP; the image’s Python
has capstone for the static tools too (angr, for decomp.py only, goes
into a virtual environment of your own, never the system). The examples below
leave out the bk-emu.sh python prefix and assume the current directory is
tools/beken.
Static analysis
Work on the logical image (the dump with its CRC stripes removed, so a CPU
address equals a file offset). Produce it once with unstripe.py, then point
the other tools at it.
unstripe.py— un-stripe a raw Beken dump to a logical image and decode its RBL update headers. Uses the fork’s CRC routine.python3 unstripe.py <raw-dump> logical.bindisasm.py— disassemble a window (capstone), Thumb by default.python3 disasm.py logical.bin 0x4203c 24xref.py— find references to an address: BL/BLX callers over the code region plus 32-bit data-word refs (foraddrandaddr|1).python3 xref.py logical.bin 0x3eba0 0x21e50strxref.py— locate code by the log/format string it uses: finds the string, its literal-pool refs, and disassembles theldrthat loads each.python3 strxref.py logical.bin "pairingMode by" "softap:ssid"decomp.py— decompile functions to pseudo-C with angr (best-effort; a reading aid alongsidedisasm.py). No Java/Ghidra needed.python3 decomp.py logical.bin 0x4203c
Driving the emulator
Those marked archived are in archive/s61-research/, and are run from there.
-
integrity_gate.py— lets the stock image boot under emulation past the flash-UID integrity gate that otherwise hangs the app layer (see the main doc). Forces the identitymemcmpat0x29c3cto “equal” (the outcome it has on real hardware, whose UID the emulator’s synthetic one can’t match) and shows the firmware then boot the app framework naturally:key_addregisters the button, the provisioned-check runs, andais_appfw_taskis created. This operates only inside the emulator, on a dump of the owner’s own plug; it is never written to or run on a real plug.python3 integrity_gate.py -
long_press.py(archived) — with the gate cleared, drive the button long-press end to end through the firmware’s own key stack. Two interventions: the integrity bypass, and raising Timer 5’s interrupt status (timer3_5_ctlbit 9) each slow tick — the key-driver’s periodic runs on thebk_timer= Timer 5, which the emulator does not raise (it raises only Timer 3, the RTOS tick). With both, the key-driver timer thread (0x3eba0) wakes naturally, its scan (0x421e0) runs, and a press-and-hold of pins 0/2 trips the real long-press action0x42250.python3 long_press.py -
pair_drive.py(archived) — drive the firmware into pairing mode and watch the emulated radio follow. With the gate cleared and the app framework cycling, nothing sets a pairing method, so the device idles; this tool calls the firmware’s own pairing-method dispatcher0x21eb4from inside the appfw task’s context. Method 0 (coexist) brings up soft-AP — the MAC leaves monitor mode and transmits beacons, and the virtual peer sees the device as an access point; method 1 enters station mode and probes/associates.airkissinstead drives the sniffer-start0x223b0(the RX-FIQ handler object goes live) — but note the AirKiss monitor callback0x403b20is only ever a stub in this build, so there is no decoder behind it (see the main doc). Shares the integrity bypass + Timer-5 raise withlong_press.py.python3 pair_drive.py 0(soft-AP)| 1(station)| airkiss. EnvSSID/PASS,SECS,FIRE_AT. -
cloud_push.py— a whole S61 conversion, the way the tool would drive one: the plug online against the stub (cloud_online.CloudSession), pushed an eWeLinkupgradefor an rt_ota.rblwrapped byfota_wrap.pyand served from a placeholder public address the relay sends to this host; the plug downloads it in ranges and stages it at0x286000, checked byte for byte, and with--applythe next boot has the stock bootloader apply it and the new firmware start. The plug’s Wi-Fi is written into a copy of the dump’s EasyFlash store first, so that firmware finds it as on a provisioned plug.python3 cloud_push.py image.rbl out.bin --apply(envSECS,APPLY_SECS) -
stage_apply.py— the apply half of a conversion, or of a reversion, in one command: stage an rt_ota.rbl(OpenBeken’s, the ESPHome build’s, or the repackaged stock app’s) in the download slot of a copy of a dump, seed test Wi-Fi credentials in its EasyFlash store, and boot the stock bootloader over it with the emulator modelling the flash writes; the flash as the run leaves it is saved, forbk-emu.sh run OUT --wifi --wifi-peer ap ...to show the installed firmware start afresh and join.python3 stage_apply.py dump.bin image.rbl out.bin(envSECS,SSID,PASS,SEED=0) -
seed_join.py— put the emulated plug on a network the faithful way. Hooks the firmware’s ownef_get_env_blob(EasyFlash V4,0x12f438) and returns the tool’s SSID/passphrase forwifi-ssid/wifi-pass— making it a provisioned plug at the credential read, with nothing else forced. Attaches the fork peer as a WPA2-PSK AP with the same creds; the plug then does the real sequence (station → associate → WPA2 four-way → IPv4 DHCP → 192.168.1.100). This corrects the old “seeding creds leaves it idle” result — that was the integrity gate, which this clears too. EnvSSID/PASS/CH/SECS.python3 seed_join.py -
ble_provision.py(archived) — synthesise the eWeLink BLE provisioning session without a BLE radio: feed the provisioning messages (DEVICE_INFO, AUTH_TOKEN, HIDDEN_AP_CONNECT/Wi-Fi, SERVER_INFO) through the firmware’s command dispatcher0x72940, so its own consumer task runs the handlers in context (driving them directly corrupts the shared state; the dispatcher does not). Data fields are injected plaintext past the AES (the key is the device apikey at RAM0x40ce90). The handlers run and the plug joins the tool’s AP (WPA2 + DHCP). This exercises the real eWeLink provisioning path, but does not by itself dial the cloud — that needs the pairing-completion trigger (cloud_dial.py). EnvSEQ/SSID/PASS/SECS.python3 ble_provision.py -
cloud_dial.py— the full provisioning outcome: the emulated plug dials the eWeLink (CoolKit) cloud. Seeds Wi-Fi creds (join + DHCP), then synthesises the pairing-success trigger by force-calling the firmware’s own start-cloud handler0x2c204(the single call the pairing state machine makes on success).ais_link_taskthen opens a TCP connection and sendsGET /dispatch/device, retried with backoff. The dispatch target lives in the AIS context (*0x4044ac, factory placeholderhttp://8.8.8.8:80); by default this tool repoints it to a local stub on the emulator’s AP gateway (http://192.168.1.1:80), so the dial stays on this host.DISPATCHsteers it elsewhere;DISPATCH=keeps the factory placeholder. Owner’s own account only: never point it at the real eWeLink cloud with anyone’s account or plug but your own. EnvDISPATCH/SSID/PASS/SECS/FORCE_AT.python3 cloud_dial.py -
cloud_online.py— complete the eWeLink cloud cycle: the plug reaches ONLINE. Stands up the CoolKit stub (tools/espressif/emulator/stub_cloud/stub_server.py,--plaindispatch mode) on loopback and bridges the netstack relay to it (plaintext dispatch + TLS IoT, fully contained — nothing leaves the host), seeds Wi-Fi creds and fires the pairing trigger ascloud_dial.pydoes, then drives dispatch → TLS/api/ws→register→{error:0, config}ONLINE. Two things it handles that the S60 does not: the S61 dispatch reply must be compact JSON with adomainkey and a stringport; and the IoTwss://cert is verified, cleared by a trust-anchor swap — the honest S60--trust-catechnique — applied atmbedtls_x509_crt_parse(0x125e4c) because the S61 assembles its CoolKit CA in RAM rather than keeping it in flash. It generates its own EC P-256 CA + leaf (openssl) and serves the leaf; the firmware’s verify path is untouched. EnvSSID/PASS/SECS/FORCE_AT/HB.python3 cloud_online.py. Its set-up is aCloudSession, whichcloud_push.pybuilds on. -
button_sim.py(archived) — older button sim (predates the integrity-gate finding and natural registration; defaults to the wrong pin 3). Force-runs the block init andkey_add, then presses by driving the GPIO low and latching the capture status instate.pwmn_stat, reaching the armed long-press timer.python3 button_sim.py [pin] -
airkiss_capture.sh(archived) — put a Wi-Fi adapter that can capture in monitor mode (an Intel AX200 can) into it on the router’s channel and capture a phone’s eWeLink AirKiss/smartconfig broadcast to a pcap. Needs root; takes over the Wi-Fi adapter while capturing.sudo ./airkiss_capture.sh <channel> [iface] [out.pcap] -
airkiss_replay.py(archived) — replay captured 802.11 frames into the emulated S61’s Wi-Fi MAC (monitor mode) so the firmware’s own AirKiss decoder can consume them; an AP-role peer with the known SSID/pass then lets the device associate. Stdlib-only pcap/radiotap parsing (DLT 127/105).python3 airkiss_replay.py capture.pcap --ssid MyNet --pass MyPasspython3 airkiss_replay.py --selftest(synthetic-frame pipeline check)
AirKiss end-to-end procedure
- Pick an SSID/password to provision to (a real or throwaway network).
sudo ./airkiss_capture.sh <router-channel> wlp1s0 airkiss.pcap- On the phone: eWeLink app → add the plug → Wi-Fi/quick (AirKiss) pairing → enter that SSID/password → start. Let it run ~30-60 s, Ctrl-C the capture.
python3 airkiss_replay.py airkiss.pcap --ssid <SSID> --pass <PASS>- Watch whether the firmware leaves monitor mode, probes/associates, gets DHCP.
Conversion and config migration
The conversion tool’s own S61 route is tools/autoflash/routes/beken/,
built on the pieces here: fota_wrap for the push through eWeLink, rbl and
stock_app for Sonoff’s firmware made again from the public dump, uf2 for
an update sent to ESPHome, backup.factory_mac for the backup.
Reading the stock config and carrying the Wi-Fi credentials into OpenBeken. See
the config-migration section of the emulation reference
for the full findings (config partition map, OpenBeken mainConfig_t format, the
signed-char Tiny_CRC8, the 0x1e1000 config offset).
ef40_dump.py— enumerate the stock EasyFlash V4 (EF40) key-value stores from a flash dump, with CRC validation. Showswifi-ssid/wifi-pass,elecCalibCoefand the rest.python3 ef40_dump.py <flash.bin>ef40.py— EF40 reader/writer library:parse_store()reads KV40 nodes;provision()writes syntheticwifi-ssid/wifi-passnodes (for building a provisioned test dump, since the public dump is join-unprovisioned).obk_config.py— build an OpenBekenmainConfig_tblob carrying given Wi-Fi credentials (correct ident/version/offsets,sizeof3584, and the signed-charTiny_CRC8the shipped binary uses).build_config(ssid, pass).wifi_migrate.py— the offline chain: readwifi-ssid/wifi-passfrom a stock dump’s EF40 → build an OpenBeken config → write it at the config partition of an OpenBeken image. Version-fragile (depends on the exact config format); prefer the OpenBeken driver below.python3 wifi_migrate.py --stock stock.bin --obk OpenBK7238_QIO.bin --out converted.binopenbeken/drv_autoflash.c, with each plug’s table for it (devices/sonoff-s61/drv_autoflash_sonoff_s61.c) — the dependable mechanism to ship. An OpenBeken driver that, on first boot, reads the stock EF40 creds withbeken_hal_flash_readand sets them via OpenBeken’s ownCFG_SetWiFiSSID()/CFG_SetWiFiPass()— so it survives OpenBeken config-format changes across releases — then carries the factory meter calibration over and applies the plug’s wiring. Reads the stock store only (reversible). Build recipe and theAutoflashRevertToStockcommand indevices/sonoff-s61/openbeken.md.devices/sonoff-s61/esphome.md— the ESPHome route: howdevices/sonoff-s61/esphome.yaml(LibreTiny) reads the stock Wi-Fi and meter calibration from the EF40 store at boot, its 4 MB flash layout, the build, the reversion through ESPHome’s own OTA, and what the emulator validated.stock_app.py— extract the stock app from a dump, verified against the FNV-1a hash in its RBL header (extract()), and pack it again as the RBL the stock bootloader applies for a revert to stock (stock_rbl()).python3 stock_app.py dump.bin -o stock_app_1.0.2.binrbl.py,aes.py— rt_ota RBL images as the SDK’s packer makes them: gzip, PKCS#7 and AES-256-CBC under RT-Thread’s public default key, and the 96-byte header with its CRC-32s and FNV-1a hash.rbl.pack()gives the same body byte for byte asrt_ota_packaging_tool_cli -c gzip -s aes;rbl.unpack()checks and unpacks one. The AES is written out here because the tool runs on Python’s standard library, which has none.uf2.py— the UF2 an ESPHome built on LibreTiny takes as an update, with an RBL in itsdownloadpart for the stock bootloader to apply: byte for byte whatltchiptool uf2 write --legacy IMAGE=device:downloadwrites for the S61’s board. How a plug on ESPHome goes to OpenBeken or back to stock.backup.py— copy a converted plug’s per-device flash region (factory TLV, EasyFlash store, system ENV;0x3e0000-0x400000) out over OpenBeken’s REST API intocaptures/plugs/<mac>/sonoff-backup/, the S60’s layout, never overwriting.python3 backup.py --plug http://<plug> --stock-rbl stock_app_1.0.2.rbl
Status
- Root cause: the app layer never started because the
apptask hangs in a flash-UID factory integrity self-check (bk_flash_get_uidhashed vs a stored cert; the emulator’s synthetic UID can’t match).integrity_gate.pyclears it and the firmware boots the app framework and registers the button by itself (pins 0 and 2). - Key-driver timer thread wake (found + handled): the periodic that times the
button hold runs on the
bk_timerhardware timer = Timer 5; the emulator raised only Timer 3 (the RTOS tick), so the thread (0x3eba0) never woke.long_press.pyraises Timer 5’s status and the thread wakes natively, the scan (0x421e0) runs, and the long-press action0x42250fires (it drives the relay/LED viabk_gpio_output). A blanket raise in the emulator’s_pacingregresses 3 other BK7238 self-tests, so it is scoped to the tool; a clean fork fix needs per-timer-period modelling. - Pairing mode reached: the appfw state machine (
ais_appfw_task) is alive and cycling, waiting onrt_event_recv(0x134da8, event[0x4040e0]). Calling the firmware’s pairing-method dispatcher0x21eb4from that task’s context (pair_drive.py) brings the emulated radio up: method 0 beacons as a soft-AP (the peer sees an access point), method 1 enters station mode and associates. So pairing mode is observably entered. - On a network, the faithful way:
seed_join.pyseedswifi-ssid/wifi-passat the firmware’s ownef_get_env_bloband the plug connects for real — associate → WPA2 four-way → IPv4 DHCP → 192.168.1.100. This corrects the main doc’s old “seeding leaves it idle” note (that was the integrity gate).ble_provision.pyreaches the same Wi-Fi join via the real eWeLink BLE provisioning messages, driven through the firmware’s dispatcher. - Cloud dial achieved:
cloud_dial.pygets the plug to dial the eWeLink cloud. Left alone after the join the plug does Matter (_matterc); the eWeLink cloud link is created only on pairing success. That trigger is one firmware call (the start-cloud handler0x2c204, whose only callers are in the pairing state machine) — synthesising it, with the AIS cloud context already factory-populated (UUID/model/MAC/dispatch-URL at*0x4044ac), makesais_link_tasksend the CoolKitGET /dispatch/deviceover TCP, steerable at a local stub. A genuinely bound dump is still useful only for a faithful server + device-secret. See the main doc for the full gate chain. - OTA / conversion path mapped: the S61 flashes updates the same
CoolKit way as the S60 — an
upgradecommand names adownloadUrlthe sender controls. The cloud download is a CoolKit FOTA container (magic0x1beef11e, HTTP 206, version gate, CRC32s); the bootloader then applies anrt_otaRBL. Tested by staging an RBL directly in the download partition and booting: the bootloader erases + writes a third-party unsigned image to the app — no signature wall (rt_ota_custom_verifyis a link-address check, not crypto). The apply path is LZMA decompression + standard CRC-32 (with an FNV-1araw_hash), all in software, with no payload decryption — thecryptfield is a header flag, not a cipher, and the download path writes bytes verbatim too. So the whole OTA chain has no signature, no encryption and no secret key. Proven with a real image: OpenBeken ships BK7238OpenBK7238_<ver>.rblOTA images; staged on the stock bootloader (no bypasses) it decompresses + writes the full image, passes every firmware gate including the final hash verify, and boots OpenBeken — the emulator performs the whole apply itself (once it models the flash JEDEC id + XIP write-through; see the main doc). The full image also stages verbatim over the cloud path (byte-for-byte).fota_wrap.pywraps any.rblin the FOTA container (and can serve it). (It is not a hardware-AES engine with a baked key, as an earlier version of this page said.) See the main doc. - Cloud ONLINE achieved:
cloud_online.pycompletes the handshake against a local stub — dispatch → TLS/api/ws→register→ ONLINE, then heartbeats. Two S61-specific pieces: the dispatch reply must be compact JSON with adomainkey and a stringport; and thewss://IoT cert is verified, cleared by the S60’s honest trust-anchor swap (--trust-ca) applied atmbedtls_x509_crt_parse(0x125e4c), since the S61 builds its CoolKit CA in RAM rather than storing it in flash. No verification is weakened — only the trusted root is swapped for the tool’s own, and the stub serves a leaf signed by it. - AirKiss — decoder stubbed in this build: frame delivery into
the MAC is solved (100 %,
sent == rxon a synthetic self-test and a real 7446-frame capture) once injection is gated on the RX-FIQ handler object (S61_RX_OBJ_PTR, default0x403c10); the tool setsmonitor_rx_deliveron the fork’s MAC. But there is no decoder to reach:0x403b20is the monitor-frame callback (RX path reads it andblxes it per frame), and the only value the firmware ever registers is the stub0x12c483(bx lr). Setting it and replaying 11 000+ frames calls the stub once per frame, 0 faults, nothing decoded. eWeLink kept the AirKiss scaffolding but compiled out the EZ decoder; real pairing is soft-AP + BLE. The delivery/replay tooling is retained for firmware that does decode. - A capture carries the credentials of whatever network it was taken on, in its
frame lengths, so a
.pcap/.pcapngis never committed — the leak scan refuses one by its name. A regression fixture uses synthetic frames instead (airkiss_replay.py --selftest), not a captured file.