I bought a SwitchBot AI Art Frame because I was curious whether I could write my own firmware for it. The plan was open-ended: figure out what’s inside, work out how the device talks to its cloud, see how far that gets me toward replacing the firmware with something I controlled. What I actually found over an evening of poking is a stack with three layers of branding on it, a public S3 bucket I wasn’t expecting, a hardcoded AES key in the Android app that lets anyone derive their own cloud API credentials, and a firmware blob whose contents tell a useful story about secure-boot even though they aren’t directly readable. Here’s what’s in this device, what’s exposed, and why writing my own firmware turned out to be a closed door.
What it is, internally
USB-C is charge-only; lsusb sees nothing when you plug it in, so there’s no easy “maybe there’s a hidden serial port” hypothesis to chase.
Static analysis of the SwitchBot Android app (v9.27, pulled via apkeep -d apk-pure) is where the layering becomes obvious. The dex files are full of com.thingclips.smart.* classes. The native library libBleLib.so exports Java_com_thingclips_ble_jni_BLEJniLib_* symbols. “ThingClips” is Tuya’s rebrand. So the firmware running on the frame is the Tuya MCU SDK with SwitchBot branding on top of it. SwitchBot designed the enclosure, did the FCC filing, and runs the customer-facing app, but the silicon and the stack inside is Tuya’s. This is the same arrangement as most “smart” devices sold under brand names you’ve heard of: hundreds of Bunnings/Arlec products, Lidl Silvercrest, Treatlife, plenty of Wyze accessories.
The device’s FCC filing is 2AKXB-W8402010, registered to Woan Technology (Shenzhen) (SwitchBot’s filing entity, FCC grantee code 2AKXB). The SoC inside, visible on the internal photos in that filing, is a Beken BK7236: ARMv8-M Cortex-M33, Wi-Fi 6, BT 5. Not ESP32, so the LibreTiny / OpenBeken / ESPHome ecosystem doesn’t apply: BK7231T/N have community ports, BK7236 does not (yet). The chip launched in 2023, and as far as I can tell from GitHub, only Beken’s own ARMINO SDK and Tuya’s TuyaOpen-T3 SDK support it. The Samsung SmartThings folks have an in-progress port. That’s the entire BK7236 open-source ecosystem in mid-2026.
What the BLE side looks like
A passive scan with bleak finds it instantly:
B0:E9:FE:XX:XX:XX RSSI -35
service_uuids: ['0xfd3d']
service_data 0xfd3d: 00006400113e10
mfr_data (mfr 0x0969): b0e9feXXXXXX01570000
Manufacturer ID 0x0969 is Woan Technology, SwitchBot’s filing entity. Service UUID 0xFD3D is the one SwitchBot has registered with the Bluetooth SIG. Even though the firmware is Tuya inside, the BLE advertisement is SwitchBot-flavoured. Bytes 6 and 7 of the manufacturer data are 01 57: flag and battery percentage (0x57 = 87, which the app later confirmed). The GATT surface tells the same story: connecting and enumerating services shows nothing Tuya at all (no 0xA201, no 0xFD50, no 0x1910). Only SwitchBot’s proprietary command/response pair:
Service cba20d00-224d-11e6-9fb8-0002a5d5c51b
• cba20003-... [notify] ← RX from device
• cba20002-... [write,wnr] ← TX to device
SwitchBot wrote a translation layer in the Tuya firmware so the outside world only sees their protocol. Sending the documented “get device info” command (57 02) and a handful of others all came back with the same 4-byte response: 09 00 00 00. Every opcode, the same rejection. That’s the device telling me I haven’t authenticated. Newer SwitchBot devices use an encrypted channel keyed by a per-device password established during the initial pairing handshake. Without that key, every command goes nowhere.
The art-frame protocol, decoded from the APK
The SwitchBot app ships per-device JavaScript modules under assets/jsCode/. The art frame’s is right there: woArtFrame_property.js and woArtFrame_service.js, together 16 KB of JavaScript with Chinese comments documenting every BLE command:
const ARTF_Property_Battery = "820"
const ARTF_Property_CurImageIndex = "9601" // 0:7.3"; 1:13.3"; 2:31.5"
const ARTF_Property_ShowMode = "9603" // 0:static; 1:slideshow
const ARTF_Property_ImageIndex = "9606" // 10-image index array
const ARTF_Property_ScreenOrientation = "9608"
// 57 0F 7A 01 <type> <index> set image operation
// 57 0F 7A 02 <index> set current displayed image
// 57 0F 7A 03 00 <picIdx> <utc4> static mode + optional end time
// 57 0F 7A 03 01 <interval2> <picList> slideshow mode
// 57 0F 7A 03 02 <interval2> <start2> <end2> <repeat> scheduled slideshow
// 57 0F 7A 05 <isOpen> <sync3> <duration3> AI scenario mode
// 57 0F 7B 02 query current display mode
// 57 0F 7B 05 query AI scenario mode
// 57 0F 7A 00 trigger Wi-Fi connect (start update)
There’s a second variant in the same file: WoArtFrameLuma. It uses a 2-byte image index (max 20,480 images vs the standard model’s 10) and has additional commands for batch delete, query-exists, delete-all. Either a higher-storage SKU or the 31.5” version.
switchbot_config.json
The APK ships with assets/switchbot_config.json, the entire backend configuration in plaintext, for both production and dev environments:
"prd": {
"endPoint": {
"wonderlabs": "https://wonderlabs.us.api.switchbot.net",
"account": "https://account.api.switchbot.net",
"publish": "https://publish.api.switchbot.net"
},
"otaUrl": "https://www.wohand.com/version/wocaotech/release.json",
"mqttEndpoint": "a2alhn2dfztqv9",
"clientId": "<oauth-client-id>",
"cognitoClientId": "<cognito-client-id>",
"tyAppKey": "<REDACTED>",
"tyAppSecret": "<REDACTED>"
}
The interesting line is tyAppSecret. My first read was that this had to be SwitchBot’s developer-tier Tuya credential, the kind of OEM key that authenticates them as a Tuya partner against openapi.tuyaXX.com. After actually testing, it’s narrower than that. These are Tuya Mobile App SDK credentials, the pair that authenticates “an instance of the SwitchBot app talking to Tuya’s mobile gateway” at a1.tuyaXX.com/api.json. They identify the app, not the developer.
The practical blast radius is small. With these keys an attacker could, in principle, make app-level requests to Tuya’s mobile gateway as the SwitchBot app, possibly register a fresh end-user account scoped to SwitchBot’s app namespace, and burn some of SwitchBot’s mobile-SDK quota. What they cannot do: enumerate SwitchBot’s customer accounts, see other people’s paired devices, or push firmware. SwitchBot’s actual users live in their own Cognito-backed identity store (see the cognitoClientId above), not in any Tuya user pool. The art frame is paired to a SwitchBot account, not a Tuya one. A freshly-minted Tuya user under SwitchBot’s app namespace would have no devices and no view into anyone’s fleet.
Empirically, even the limited path doesn’t easily work. I tried signing requests to a1.tuyaXX.com/api.json with four publicly-documented Tuya Mobile SDK sign variants (MD5 with & separator, MD5 with || separator, HMAC-SHA256, MD5 with prefix-only). Two known-anonymous actions, tuya.m.timezone.list and tuya.m.country.list, returned SING_VALIDATE_FALED_4: the gateway recognises the appKey, the request is well-formed, the signature is wrong. Other action names returned API_OR_API_VERSION_WRONG. So the appKey is real and known to Tuya, but whatever sign scheme the current app uses isn’t one of the documented mobile-SDK variants. Either Tuya rolled a private variant for newer SDKs, or there’s an additional secret that lives in the binary rather than the config JSON. Closing that gap would mean another round of reversing against libBleLib.so and friends.
Still redacted here. App-instance credentials shipped in plaintext are worth rotating on their own merits, and publishing them adds zero value to anyone who can already pull the APK. SwitchBot should still rotate.
SwitchBot’s CDN, and a public-S3 surprise
OTA blobs live on www.wohand.com. The dex still has hard-coded URLs for older products:
https://s3.amazonaws.com/www.wohand.com/version/wocaotech/firmware/WoButton/WoButton_app_LATEST.zip
https://s3.amazonaws.com/www.wohand.com/version/wocaotech/firmware/WoLock/WoLock_app_LATEST.zip
https://s3.amazonaws.com/www.wohand.com/version/wocaodebug/firmware/WoHand/WoHand_app_V<version>.bin
This bucket is listable to anonymous requests. curl http://www.wohand.com/ returns a standard S3 ListBucketResult XML. Query parameters (prefix, marker, list-type=2) all get silently stripped by whatever’s in front of S3, so you only ever see the alphabetically-first 1000 keys, but that’s enough to characterise the layout. About 700 of those keys are version/wocaodebug/aihub/{device-MAC}/{timestamp}.jpg, cached AI-generated artwork from production devices in the wild, keyed by hardware MAC. I didn’t fetch any. SwitchBot should probably look at that bucket policy.
I brute-forced URL guesses with the obvious _app_LATEST.bin / _app_V<n>.bin patterns from the older products. Roughly 10,000 HEAD requests, zero hits. For newer products SwitchBot moved to a dynamic OTA flow: the URL gets returned by a cloud API call, not constructed by convention. Which meant figuring out how the app actually authenticates was unavoidable.
A complete programmatic auth chain, no app required
The login endpoint is POST https://account.api.switchbot.net/account/api/v2/user/login. The request body model is LoginReq in the dex. Eight fields, three of them optional:
{
"username": "<email>",
"password": "<password>",
"verifyCode": "",
"clientId": "5nnwmhmsa9xxskm14hd85lm9bm",
"grantType": "password",
"deviceInfo": {
"deviceId": "5a9f8e7d6c5b4a39",
"deviceName": "Pixel 6",
"model": "Pixel 6",
"appVersion": "9.27"
}
}
That returns an ES256 JWT issued by switchbot.s3.amazonaws.com with a 2-hour expiry, plus a refresh token. The JWT carries userID, client_id, scope switchbot account. Standard OAuth password-grant flow with custom claims.
The interesting part is what comes next. The Wonderlabs API (wonderlabs.us.api.switchbot.net), which has all the device and firmware endpoints, sits behind AWS API Gateway with IAM authentication enabled. Authorization: Bearer <jwt> gets you:
{"message": "Authorization header requires 'Credential' parameter.
Authorization header requires 'Signature' parameter.
Authorization header requires 'SignedHeaders' parameter."}
Classic AWS SigV4 demand. You’d normally need to exchange the JWT for temporary AWS credentials via AssumeRoleWithWebIdentity and sign each request with SigV4. That’s a real chunk of code to write from scratch.
There’s one back door, though. The endpoint POST /wonder/openapi/openUser/token accepts the SwitchBot JWT directly. Its model is in OpenApiBody.java:
class OpenApiBody(val operation: String, val version: Int)
Send {"operation":"get","version":2} and get back:
{
"statusCode": 100,
"body": {
"token": "5d67124a...0aadf2f45208df...", // 128 hex chars
"secretKey": "bd012e25...64237bb5de" // 64 hex chars
}
}
Token and secret. SwitchBot’s documented public Cloud API (api.switch-bot.com/v1.1/devices) needs exactly this pair. Plug them straight in, sign requests with HMAC-SHA256 per the docs, and… 401 Unauthorized.
That’s because what came back is AES-CBC ciphertext, hex-encoded. The decryption key and IV are checked into the APK as 16-byte ASCII string constants in OpenApiActivity.java:
private final String iv = "<REDACTED 16-byte ASCII IV>";
private final String key = "<REDACTED 16-byte ASCII key>";
AES-128, hardcoded, ships with every install on the Play Store. Decrypt the two strings, get the real token (96 hex) and secret (32 hex), and the public API immediately works.
So the chain end to end is: op read credentials → POST login → ES256 JWT → POST token endpoint → AES-CBC decrypt with hardcoded key → real OpenAPI creds. No app needed. No Frida, no MITM, no Magisk. Tested:
GET https://api.switch-bot.com/v1.1/devices
HTTP/2 200
{"deviceList":[{"deviceId":"B0E9FEXXXXXX","deviceName":"AI Art Frame",
"deviceType":"AI Art Frame","enableCloudService":true,"hubDeviceId":""}]}
That’s a meaningful security gap. Anyone with a SwitchBot account and a few hundred lines of Python can derive their own working Cloud API credentials. The “developer credentials” the docs say you generate in the app via a long-press on the version string are the same token+secret produced by this AES decryption. The app is just decrypting and showing them to you.
Getting the firmware URL
With OpenAPI access in hand, none of the documented endpoints return firmware information for the art frame. The good stuff is on Wonderlabs, which still demands SigV4 for most paths. But trying every documented endpoint shape eventually shows that POST /firmware/v1/version/query accepts the JWT directly, not SigV4. The matching Retrofit interface in the dex is DeviceService.getDeviceOtaInfo. Its request model is DeviceOtaInfoReq, a langCode and a list of DeviceItemInfo objects with deviceID, deviceType, bleVersion.
deviceType: "WoArtFrame" returns data: []. Empty. Every variant I tried returned the same thing. The fix was in the dex’s DeviceType enum:
DeviceType("WoArtFrame", 102, "W1130000", R.string.text_type_art_frame)
The enum constant name is WoArtFrame, but its code (the value that goes over the wire) is W1130000. Once I sent that, the response was immediate:
{
"resultCode": 100,
"data": [{
"deviceID": "B0E9FEXXXXXX",
"deviceType": "",
"packInfo": [{
"packType": "W1130000_wifi_BK7326",
"updateTime": 1779789601020,
"version": "12",
"md5": "c0827adc624ff1b1ceb9bb8d86c21d07",
"url": "https://www.wohand.com/version/wocaotech/firmwareV2/W1130000_wifi_BK7326/WoArtFrame_bk_app_V12.rbl",
"langDesc": "Resolved some known issues."
}]
}]
}
Two things worth noting in that URL. First, the path is firmwareV2, not firmware. That’s why brute-force was never going to hit it; the legacy products use firmware/ and the W11/W84-era products use firmwareV2/. Second, the chip designation in the path is BK7326. Either an internal typo for BK7236 or a specific package variant. Functionally it’s the chip we’ve been discussing.
curl -O the URL, the file is 949,816 bytes, MD5 matches c0827adc624ff1b1ceb9bb8d86c21d07. Anonymous HTTPS download, no cert pinning, no auth.
The firmware blob, and the actual answer on secure-boot
The first four bytes of the file are RBL\0. That’s RT-Thread’s bootloader image format, which is exactly what TuyaOpen-T3’s bk7236_nosecurity.wrapper produces. Header:
magic: RBL\0
version: 258
algorithm: 0 (claims "no encryption")
timestamp: 1775188291 -> 2026-04-18T22:31:31Z
name: "app"
algorithm: 0 is supposed to mean unencrypted CRC-only. So the body should be a readable ARM Cortex-M binary with a vector table at the start and lots of plaintext strings (URLs, log messages, FreeRTOS task names) further in.
It isn’t. The entire 949 KB body has uniform Shannon entropy of 7.997 bits/byte in every chunk I checked, max possible is 8.0:
chunk 0 [ 0.. 59301]: entropy=7.997
chunk 1 [ 59301.. 118602]: entropy=7.997
...
chunk 15 [ 889515.. 948816]: entropy=7.997
strings -n 8 returns nothing recognisable: no URLs, no broker hostnames, no library version strings, no log messages. That’s the textbook signature of either compression or encryption applied to the entire payload.
I scanned the file for standard compression headers (gzip 1f 8b, zstd 28 b5 2f fd, lz4, lzma, zlib in all flavours) at every offset in the first 16 KB. No hits. I also scanned for a valid Cortex-M vector table, looking for a 32-bit little-endian initial-SP value in the 0x20000000..0x20200000 SRAM range followed by an odd reset vector. Nothing matches. So whatever this body is, it’s not raw ARM code, and it’s not any of the off-the-shelf compressors.
That leaves two plausible explanations. Either (a) the payload is encrypted, which would be consistent with SwitchBot building against TuyaOpen-T3’s bk7236.wrapper (the secure path) with the secure-boot eFuse BIT(3) burned, or (b) the payload uses a custom compression scheme baked into the Tuya MCU SDK. Tuya’s docs do mention an OTA-time compression step they call lzma_compress, but you’d expect some signature at the start of a compression stream. The absence of any recognisable header anywhere in the payload is the bit that tips me toward encryption rather than compression.
Either way, the practical consequence is the same. The OTA channel is unsigned at the protocol level (CRC + MD5 is all the cloud claims), but the binary you pull from the CDN is not directly executable on the chip without whatever transformation the bootloader applies, and that transformation is gated by a key that isn’t in the public surface. If it’s encryption, you need the eFuse-stored key. If it’s custom compression, you need to reimplement Tuya’s algorithm correctly enough that the BL2 still accepts your output. Both are doors with the same lock on them from outside.
So the secure-boot eFuse BIT(3) question, the one I’d been hoping to answer by opening the case and reading the chip directly, gets a strong-but-not-certain answer from the bytes of the public firmware blob: most likely burned. The OTA channel being unsigned at the protocol layer doesn’t help anyone, because the device won’t run anything that isn’t first run through whichever transformation the bootloader expects.
Where it sits now
Everything we have:
| What | Status |
|---|---|
| Complete BLE protocol map for the art frame | Got it |
| Programmatic SwitchBot API auth (no app needed) | Got it |
| The hardcoded AES key for cloud credentials | Got it |
| Working OpenAPI session for device list / status / commands | Got it |
| Firmware blob, V12, MD5-verified, anonymously downloaded | Got it |
| Plaintext firmware contents | Encrypted or custom-compressed, either way unreadable |
| Path to patch + reflash custom firmware | Closed without the bootloader’s transformation key |
| Path to read eFuse and confirm secure-boot definitively | Would still need UART access |
The hardware door (opening the device, attaching to UART, dumping flash) probably wouldn’t help if the encryption hypothesis is right: the flash would be encrypted with the same key the OTA blob is, and dumping it gets you another 4 MB of unreadable bytes. If the payload turns out to be custom-compressed rather than encrypted, the flash dump would be readable but the channel for pushing modified images is still gated by the BL2’s expectations.
A chip-level hardware attack (voltage glitching, side-channel) could in principle bypass either lock. Those are real techniques, but they’re a different sport: equipment in the four-figure range and weeks of work per device, against a chip with PSA-Certified-L2 mitigations designed precisely to make that hard.
I’m sending it back. The realistic ceiling for non-destructive reverse engineering on this device is exactly where we got: we know everything about how it talks to the cloud, we can download its firmware, and we know that’s all we’ll get. €111 for a confirmed dead-end is on the expensive side of curiosity.
What was worth doing
The findings outlive the device:
- BK7236 is shipping in consumer products with at least one non-trivial bootloader-level transformation applied to OTA images. FCC photos and a public unreadable firmware blob to back it up. Anyone considering BK7236 reverse-engineering work in 2026 should start with that prior, not the more optimistic LibreTiny / OpenBeken precedent from BK7231 days.
- The SwitchBot Android app ships app-instance credentials in plaintext (
tyAppKey,tyAppSecret) and its cloud API auth via a hardcoded AES key. The Tuya keys are Mobile App SDK credentials, not developer-tier OpenAPI ones, so the practical blast radius is “act as an instance of the SwitchBot app against Tuya’s mobile gateway,” not “act as SwitchBot the developer.” Still worth rotating: they’re identity, even if narrow, and they’re sitting unprotected in every install. The separate AES-key issue is the bigger deal: a user’s SwitchBot developer-API credentials can be derived programmatically without ever opening the app. The token+secret you get from Profile → Preferences → Developer Options are the same ones the script above produces. SwitchBot should tie that step to a per-session value rather than a constant baked into every APK. - The aihub S3 bucket publicly lists ~700 production users’ AI-generated artwork keyed by device MAC. Anyone can browse them. Worth a responsible-disclosure note.
- Complete BLE protocol for the art frame: every property, every service ID, every command, in case anyone else picks this device up.
- The dynamic OTA flow used by W11xxxxx-era SwitchBot products is now mapped:
/firmware/v1/version/querywith the right device-type code returns an unsigned-but-encrypted blob URL onwohand.com/.../firmwareV2/.
If you’re integrating any SwitchBot Wi-Fi/BLE device into an open setup, this is the structure you’d be working against. It’s not impossible. switchbota proved that for the Plug Mini in 2022. But that was BK7231, two product generations and one secure-boot enablement ago. The newer hardware is genuinely closed.
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.