Klipsch Flexus 2026 Firmware: Signing Key Cracked
Digital

Klipsch’s Security Theatre: Cracking the Flexus 2026 Firmware Signing Key

A few months after I shipped my open-source Home Assistant integration for the Klipsch Flexus, a soundbar over-the-air update killed it. Reads still worked; every write came back 401 Forbidden. What followed was a multi-week reverse-engineering investigation — a Flutter binary decompiled with a from-source toolchain, a WireGuard man-in-the-middle, an offline brute-force that hit a brick wall, and a phone-by-phone Frida hunt across five devices — to recover the exact request signature the 2026 Klipsch firmware now demands. This is the full write-up: what broke, every wall I hit, how the signature finally fell — and why the «security» Klipsch added turned out to be theatre, on a device that already gives its secrets away for free. The whole investigation, code and field notes are open on GitHub.

The Crime Scene: What the 2026 Firmware Actually Changed

The bug report was one line: «they changed the exchange.» The inputs were two PCAPdroid captures of mode switches and a live unit on the bench, and the investigation ran as a dated chronicle. I started where any protocol regression starts — read-only probing. The first thing I confirmed is the most important: the data model didn’t change at all. In the captures, almost everything had migrated to TLS:443; the one thing still in cleartext on port 80 was the event long-poll, GET /api/event/pollQueue?queueId={UUID}&timeout=5 — and those plaintext event frames spelled out exactly which settings were being touched (dialogMode, nightMode, audioDecoder, player:volume). Same paths, same value shapes as before. GET /api/getData returned 200 on both plain HTTP:80 and a brand-new TLS:443 endpoint. Nothing about reading the device was broken. Writing was a different story:

  • An unsigned POST /api/setData to any gated setting → 401 Forbidden.
  • The integration’s old GET /api/setData?... fallback → 405 "Strict HTTP required!" — the legacy write form is banned outright.
  • The event endpoints (/api/event/create, /api/event/subscribe) moved behind TLS and answer 501 "Not implemented!" on plain :80; an unknown pollQueue id returns 400, a registered one 200.
  • settings:/webserver/authMode now reads setData; the 401 advertises WWW-Authenticate: HMAC_SHA256, HMAC_SHA256_AES256 — two schemes by name, but no salt and no nonce to sign back.
  • :443 is now fronted by a self-signed certificate — Subject O=Klipsch Group Inc, CN=Klipsch-device, Issuer CN=Klipsch-CA, issued 2026-05-28, TLS 1.3.

So write-auth comes in three tiers. Volume and mute still write unsigned (POST-only now). Mode, dialog, night, EQ, Dirac, tone, input, decoder and every channel level need a signature. And GET-form writes are simply gone. Diagnosis: the firmware bolted a write-authentication layer onto an otherwise unchanged API — the data is the same, only the lock on the door is new. The full probe table lives in PROTOCOL_2026_CHANGES.md.

A Fortress With the Doors Wide Open

Before any of this, I had run a full network security assessment of this soundbar. An nmap -sV -p- sweep — 5,189 seconds of it — found 11 open TCP ports (80, 2019, 5002, 5003, 7000, 8008, 8009, 8443, 10001, 16500, 37205). Port 8008 is the part that matters: an unauthenticated Google Cast API. Every probe from the LAN, zero credentials. GET /setup/eureka_info200 and the device’s full identity. get_app_device_id200 with the device’s X.509 certificate and a signed attestation blob. The certificate it hands out is issued O=Klipsch Group Inc, OU=Cast, CN=Flexus Core 300, a 2048-bit RSA key valid for roughly twelve years (to 2036). The same eureka_info response also leaks the device’s RSA public key in Base64. No password, anywhere, for any of it.

It gets better. The Cast firmware underneath is two years stale — a January 2024 build — carrying five unpatched CVEs: one Moderate in the AMLogic U-Boot (CVE-2024-47036) and four High-severity ARM issues (CVE-2024-6790, CVE-2025-0050, CVE-2025-0427, CVE-2025-0819). The SoC is an AMLogic part with a publicly documented bootloader exploit chain — eMMC fault injection (CVE-2023-48424) into an AVB U-Boot bug (CVE-2023-48425) into a boot-command whitelist bypass (CVE-2023-6181), ending in custom unsigned firmware. The assessment closed with fourteen findings: three High, five Medium, three Low, and three positive.

This is the device that, in 2026, decided the urgent thing to lock down was writing a soundbar setting. It will hand a stranger on your Wi-Fi its identity, its certificate and its public key without so much as a 401 — but turning on Night Mode now requires AES-256 and an HMAC. Hold that thought.

Cracking Open the App: blutter and the Dart Snapshot

If the device now signs writes, the signing logic lives in the official app — Klipsch Connect Plus (com.klipsch.connectxp v2.3.7, build 2026051321). And that is the first wall: it’s a Flutter app. The control logic isn’t in the Java/DEX layer where jadx can reach it; it’s compiled into lib/arm64-v8a/libapp.so as a Dart AOT snapshot — machine code with no symbol table. (The neighbouring libdc.so/libduff.so are the Dirac Live filter SDK, a red herring I had to rule out before confirming they never touch the /api/* channel.) General-purpose native decompilers don’t help here — r2 pdc and r2ghidra can’t resolve the Dart string pool, so the output is unreadable. Dart AOT needs blutter, which in turn needs a Dart SDK built from source at the exact version the app shipped with: I built Dart 3.10.0-232 from source to drive it. Once it ran, the recovered function names gave the whole scheme away: generateUsernameFromMac, generatePasswordFromMac, and an HmacAuthHelper class with generateAuthHeader and setCredentials. The crypto primitives underneath are the PointyCastle library.

The Password Was Never a Secret

The username derivation, recovered from generateUsernameFromMac, is an anticlimax: it returns the string "user" unconditionally — the StreamUnlimited webserver’s default account. The password derivation fell almost as fast, read verbatim from generatePasswordFromMac:

cleaned  = MAC.replaceAll(RegExp("[^A-F0-9]"), "")   // keep UPPER hex → 12 chars
password = base64( cleaned + "KlipschSupport!!88" )

// worked example, MAC AA:BB:CC:DD:EE:FF:
//   base64("AABBCCDDEEFFKlipschSupport!!88")
//   = QUFCQkNDRERFRUZGS2xpcHNjaFN1cHBvcnQhITg4

The entire «secret» is one hardcoded 18-character string — KlipschSupport!!88 — glued to the device’s MAC address and Base64-encoded. Base64 is an encoding, not encryption: pipe it through base64 -d and it reverses to plaintext. There is no salt, no key-derivation function, no per-install secret; the constant is byte-for-byte identical in every Flexus on earth, and only the MAC varies. The app provisions exactly this password onto the soundbar during onboarding — its own logs read «Pushing webserver password to device, username: user» — which is why the unit rejects unsigned writes the moment setup finishes. (One subtle trap: that uppercase-only regex silently drops lowercase hex, so a MAC read as aa:bb:... derives the wrong password unless you re-uppercase first. My implementation does.) And the MAC isn’t a secret either — it’s broadcast in ARP, in mDNS, in the device’s BLE advertisement, and handed out by that same unauthenticated Cast API on 8008. That was the easy half. The signature did not come so quietly.

The Signature That Wouldn’t Yield

Decompilers don’t resolve Dart AOT string pools, so I traced generateAuthHeader by hand through annotated blutter assembly, following each stack slot: [fp-0x168] holds the nonce, [fp-0x170] the millisecond timestamp, [fp-0x180].field_f the AES ciphertext, and [fp-0x150] is loaded into x2 right before the Hmac constructor — which I first took for the key, though it later resolved to the username. I could follow the SHA-256 call at 0x6d8240, the canonical-string interpolation at 0x6d8be4, and the header interpolation at 0x6d8cd4, and from those pin the header layout. What I could not pin were the last few bytes that decide everything: the exact order of the canonical string, which value keys the HMAC, and the AES mode — the call site (stream_unlimited_api_service.dart) left AESMode.sic (CTR) and AESMode.cbc both plausible. With only a binary oracle to test against (the device answers 200 or a hint-free 401) and the request body itself encrypted, brute-forcing those permutations by static analysis was disproportionately expensive. Time to stop reading the code and start watching it run.

17 Signatures — and a Brick Wall

Catching a live signature was its own fight. A plain HTTP proxy saw nothing — Flutter sends straight to the device IP, ignoring the system proxy — and PCAPdroid with a user CA couldn’t decrypt the :443 traffic. What worked was mitmproxy in WireGuard mode, with AllowedIPs scoped to the soundbar alone (so mDNS discovery stayed on the real Wi-Fi) and --ssl-insecure to accept the Klipsch-CA cert. The app doesn’t certificate-pin, so it decrypted cleanly: 17 live signed setData calls, captured in full. A real one looks like this:

Authorization: HMAC_SHA256_AES256 dXNlcg==.UpnlFt5z.1781204748147.gncG+ZgSG5WsjzMtVoWOsRpZNw3c4VAo5AS2NLwXtyQ=
                                  └ b64("user") └ nonce └ ts(ms)  └ HMAC-SHA256 (32 bytes, base64)

body: { "path": "settings:/cinema/dialogMode", "role": "value", "value": "" }

And then the wall I didn’t see coming. With a real signature as an exact oracle, I brute-forced the derivation offline. For the HMAC: about ten key candidates against every ordering of the method, path, role, timestamp, user, nonce and cipher fields, as both strings and raw bytes, for both of the device’s MACs. Zero matches. For the AES body: seven candidate keys × six MAC forms × four cipher modes (CBC, CTR, CFB, OFB) × five IV positions × three padding trims, checked against a known plaintext (postProcessorMode="music"). Zero readable plaintexts. The per-request key simply was not a naive function of the password I’d recovered — my best guess, sha256(password), was wrong, and nothing nearby worked either. There was no shortcut left. I had to capture ground truth from inside the running app with Frida — and that is where the investigation turned into a phone safari.

The Phone Safari: A Matrix of Dead-Ends

Frida ground-truth needs a device that can do two things at once: see the soundbar on the LAN, and let an instrumentation framework hook the app. Every device I tried could do one but not the other. The clean way to lose a week is to discover that constraint one device at a time:

  • iPhone — gave the clean WireGuard MITM capture, but no jailbreak means no Frida.
  • Android emulator (Pixel 8, API 35, arm64) — Frida ran flawlessly, but it was blocked by three things at once: no Bluetooth (the app reads the MAC and finds the bar over BLE), multicast mDNS doesn’t cross the emulator’s NAT, and Dart’s HTTP rides its own BoringSSL inside libflutter.so with SSL_write/SSL_read stripped to zero exports — so even TLS interception needs a byte-pattern hook. Frida yes, bar no.
  • Samsung Galaxy Z Fold6 — saw the bar, but Knox/RKP silently defeats the Frida Interceptor. With objection I merged the split APK, injected the gadget and re-signed; the gadget loads, memory reads succeed, attach returns no error — yet no trampolines are ever written, so even hooks on memcpy and clock_gettime (thousands of calls a second) fire zero times. A perfectly quiet dead end on a locked flagship.
  • Redmi 5 Plus (rooted) — willing, but stuck on Android 7.1.2 / SDK 25, below the app’s minSdk 28; even after an apktool downgrade it was too old to run.

The asymmetry was maddening: Samsung had the bar but not Frida (Knox); the emulator had Frida but not the bar (NAT); the iPhone had the MITM but not Frida (no jailbreak). The requirement crystallised as any non-Samsung physical Android. The rig that finally broke it was a rooted OnePlus Nord CE (Android 13 / SDK 33, Magisk) — the Interceptor worked there, confirmed by a self-test hook on malloc that actually fired and a trampoline byte-check that actually changed. Now I just had to make the hooks catch the right function at the right moment.

Two Insights That Cracked It

The hooks stayed silent at first, for two reasons worth keeping. The first: with attach, Frida writes the trampoline into memory, but the CPU keeps executing the hot Dart function’s old bytes straight from the instruction cache — the patch never takes. The cure is spawn, not attach: launch the app under Frida so the function is patched before its first execution. (Even the invocation is fiddly — sleep 600 | frida -D <serial> -f <pkg> -l hook.js, keeping stdin alive to dodge a spawn-timeout.) The second: the APK variant. The APK variant blutter saw had extractNativeLibs=false, so libapp.so was mapped from inside the package and the offsets drifted; reinstalling a build with extractNativeLibs=true landed the library on disk and lined the offsets up 1:1. (A tempting «16 KB page-alignment» theory for that drift was a complete red herring — both devices used 4 KB pages; the real causes were the wrong APK variant plus attaching to an idle app that runs almost no Dart.)

There was one more catch: Dart-AOT functions use Dart’s own register convention (a thread and pool register, not the C ABI), so you cannot call them directly from Frida — you have to trigger them naturally. So, sitting next to the bar, I drove the app by hand: onboarding to fire generatePasswordFromMac, a mode switch to fire generateAuthHeader. Hooks on generateAuthHeader, Hmac.convert and Encrypter.encrypt finally printed the exact key, canonical string and AES parameters. Two facts fell out that no amount of static guessing had reached: the per-request key is SHA256(base64decode(nonce) + password) — the nonce isn’t just transported, it’s mixed into the key — and the encrypted value is base64(iv + AES-256-CBC(...)), with the IV prepended to the ciphertext rather than sent as its own field. Reproduced in Python, the offline build matched the app byte for byte, and the soundbar answered HTTP 200 — state really changed. The full day-by-day account is in the field report (also in Russian).

The Algorithm — and Why It’s Pure Theatre

Here is the recovered scheme in full:

username = "user"                                  # generateUsernameFromMac (constant)
password = base64(MAC_UPPER_HEX + "KlipschSupport!!88")  # generatePasswordFromMac
nonce    = base64(6 random bytes)                  # client-generated, per request
ts       = str(int(time.time() * 1000))            # client-generated, ms
key      = sha256(base64decode(nonce) + password)  # ONE 32-byte key for AES *and* HMAC
value    = base64(iv + AES_256_CBC(key, iv, PKCS7(compact_json(value))))   # iv prepended
body     = json(indent=4){"path", "role": "value", "value"}   # pretty-printed, 4-space
canonical= "user" + "." + nonce + "." + ts + "." + url + "." + body   # username raw here…
sig      = base64(HMAC_SHA256(key, canonical))
Authorization: HMAC_SHA256_AES256 base64("user").nonce.ts.sig         # …base64'd only in the header

The fiddly details that cost days are all here: one 32-byte key does both the AES body encryption and the HMAC; the signed body is pretty-printed (4-space indent, key order path, role, value) while the encrypted value inside it is compact JSON (separators=(",",":")); the IV is prepended to the ciphertext, not sent separately; the role is "value" for settings and "activate" for actions like power (the wrong role returns a telltale HTTP 500 — a valid signature, wrong verb). And the detail that cost a whole wrong theory: the nonce, IV and timestamp are all client-generated. There is no server challenge — the 401 header only advertises the scheme name. The whole request is reproducible offline.

Which is what makes it theatre. The password is base64(public MAC + hardcoded constant); the MAC is public on the wire and, per the assessment above, handed out by the device’s own unauthenticated Cast API alongside its certificate and RSA key. The constant is identical in every install. So any device on the network computes the same credential in a single line, and — because there is no server nonce — it can sign a perfectly valid request entirely offline. The AES-and-HMAC ceremony adds no strength whatsoever against that attacker. The only party it actually locks out is the legitimate local client — Home Assistant, this integration — until it reimplements the scheme. It raises the cost of interoperability, not the cost of attack: a compliance-shaped checkbox bolted onto soundbar writes, on a device that simultaneously leaks its identity, certificate and keys to the whole subnet and runs two-year-old firmware with unpatched High-severity CVEs. It’s the same IoT-security blind spot — backed by 6 patents in information security — that I harden for consulting clients. Security theatre, precisely.

What Real Write-Auth Would Have Looked Like

None of this is hard to do properly — that’s what stings. A scheme that actually resisted a LAN attacker would change four things. Server-issued challenge: the 401 should carry a server nonce the client must fold into the signature, so a captured request can’t be replayed and nothing can be precomputed offline — exactly the field this design omits. A real per-device secret: provisioned out of band and never derivable from a public MAC plus a shipped constant; a single leaked constant shouldn’t unlock the entire product line. Key separation: deriving an encryption key and a MAC key from one 32-byte value via a KDF, instead of using the same key for AES and HMAC. Transport identity that means something: a certificate chain the client can actually pin, rather than a self-signed Klipsch-CA that every client is told to ignore with --ssl-insecure. The irony is that the device already ships RSA keys and a certificate — the materials for real mutual authentication were on board the whole time; they just guard the wrong door.

The Engineering Underneath

Recovering the signature was only half the work; the integration also has to be kind to a fragile device. The Flexus runs a single-threaded HTTP server — fire two requests at once and one of them hangs — so every call is serialised through an asyncio.Lock, with per-operation timeouts tuned to the hardware’s real behaviour: 8 s for reads, 10 s for writes, 15 s for power (the device genuinely needs that long to wake). Failures get two retries at a 0.5 s backoff before giving up, and a partial poll degrades gracefully — a settings read that fails falls back to its cached value instead of marking the device offline.

The sharper trick is standby-aware polling. A naive integration that polls 20+ parameters every cycle makes a sleeping soundbar spike to 40-second response times and flap its entities unavailable. Instead the coordinator probes the power state first; if the bar reports networkStandby it returns one request’s worth of cached state and stretches the interval from 15 s to 60 s. Optimistic UI updates (a 5-second TTL covering the device’s settle time) make the dashboard feel instant, and a delayed re-poll confirms the real value. The credit for the cleanest fix, though, goes to a read: since v2.5.9 the integration gets the signing MAC deterministically from settings:/system/primaryMacAddress — an auth-free read the device answers itself — instead of the old dance of brute-forcing last-byte MAC neighbours (wired …:3D vs wireless …:3E) until one authenticated. The lock the firmware added, defeated by a field the firmware happily reads out.

The Payoff: 41 Entities, Control in Standby

Once writes were signed, I pushed the integration (now at v2.5.15) well past its original 20 entities to 41: one media player, 5 selects, 14 numbers, 10 switches and 11 sensors. Concretely, that’s the media player (volume and mute stay unsigned); selects for night mode, dialog mode, EQ preset, Dirac filter and LED mode; 14 numbers — 11 channel levels at ±6 dB plus lip-sync delay (0–300 ms), balance (−10…10) and idle timeout (0–3600 s); 10 switches including loudness, do-not-disturb, auto-standby, OTA-updates and BLE pairing; and 11 sensors, among them two that make the auth itself observable — a Signing MAC sensor exposing the resolved credential and gated paths, and a Network Link sensor.

The Klipsch Flexus device page in Home Assistant showing all 41 entities exposed by the integration
The Klipsch Flexus device page in Home Assistant — all 41 entities, every write HMAC-signed.

The arc to get there is its own changelog: v2.4.1 fixed the 405 by switching writes to POST; v2.4.2 added a command-health probe; v2.5.0 brought the HMAC signing; v2.5.7–2.5.9 added the extra settings, toggles and the deterministic MAC; later builds fixed settings reverting in standby. The headline feature falls out of that last fix: all 41 entities stay controllable while the bar is asleep — it applies and persists writes in standby, and the integration caches the value you set so the next standby poll never reverts it. Same single-threaded, retry-with-backoff automation engineering as the first build, now with a signature on every write.

The Verdict

The irony writes itself: locking the API behind a constant and a public MAC protected nothing — it just turned a working integration into a multi-week reverse-engineering project, and the result is a better integration. Local control is fully restored and expanded, the soundbar never has to phone home, and every step — the signing scheme, the field report, the security assessment, the code — is open source with CI, HACS validation and CodeQL scanning. If a future firmware rotates the constant, tools/extract_secret.py re-derives it straight from the next APK: it downloads the ~144 MB package, unpacks the nested split to reach libapp.so, and recovers the secret on its own. The practical takeaway for everyone else is the unglamorous one: this is precisely why IoT devices belong on an isolated VLAN — not because the Night Mode signature is dangerous, but because the device guarding it leaks everything else for free. The lock changed; the lockpick is already written.

Need help reverse-engineering a stubborn device, hardening IoT, or designing a smart-home that you actually control? Book a free consultation →

Frequently Asked Questions

Did the 2026 Klipsch firmware break the Home Assistant integration?

Only writes. Reads (getData) keep working on both HTTP:80 and the new TLS:443, but most setData writes now need an HMAC signature and return 401 unsigned, while the old GET-form write returns 405. Volume and mute still write unsigned. Integration v2.5.0+ signs the rest automatically — just update.

How was the Klipsch write signature reverse-engineered?

The Flutter app’s Dart-AOT snapshot was decompiled with blutter (driven by a from-source Dart 3.10.0 SDK), which gave up the password derivation. A real signature was captured with a WireGuard-mode mitmproxy, but offline brute-forcing it failed completely. The exact key and canonical string were finally pinned with a Frida hook on a rooted OnePlus — using spawn rather than attach, after Samsung’s Knox, an emulator’s NAT and an old Redmi each blocked the way.

Is the Klipsch write authentication actually secure?

No. The password is base64(MAC + "KlipschSupport!!88") — a hardcoded constant shared across every device plus the public MAC (broadcast over ARP, mDNS, BLE and the device’s own unauthenticated Cast API), with no server challenge, so any LAN device reproduces it and can sign offline. The AES/HMAC layer raises interoperability cost, not attacker cost — security theatre. Keep IoT devices on an isolated VLAN.

Why is the soundbar’s open Cast API a bigger problem than the write-auth?

Because it needs no credentials at all. Port 8008 answers unauthenticated requests from any LAN host with the device’s identity, X.509 certificate, RSA public key and a signed attestation blob, on firmware that is two years stale with five unpatched CVEs. Locking soundbar writes while leaving that wide open is the core irony of the 2026 update.

Can I control the soundbar while it is in standby?

Yes. All 41 entities stay available in standby; the device applies and persists settings while asleep, and the integration caches the value you set so the standby poll (a single request, every 60 s) never reverts it.

Need a consultation?

If you need professional expertise — book your free 15-minute consultation.

Rate article