I Found a MitM Vulnerability in a Camera's TLS Handshake (I Was Wrong): A Static Reversal of an mbedTLS Config Struct

There’s a particular kind of confidence that builds slowly over hours of decompiled assembly. You trace a state machine, you track a memory wipe, you find a security flag sitting at zero, and somewhere around hour five you start mentally drafting the vulnerability report. I know that feeling all too well now, because I spent an entire afternoon walking straight into it, on a binary that turned out to be innocent all along.

This is the story of the vulnerability I didn’t find, and the one function call that saved me from publishing something non-existant.

The Setup

This started as a blackbox: no datasheets, no vendor source, nothing but a Xiaomi camera and a screwdriver. I opened it up, identified the flash, literally fought with the board to give up its firmware, finally pulled it off, and decompiled everything that looked interesting until one binary miio_client pulled me in. This piece is about what I found inside it, tracing how it handles its TLS connection to the Mijia Cloud; the window where plaintext Wi-Fi credentials and device bind keys cross the wire.

Tracing the State Machine

Right before initiating TLS, the binary zeroes out a 40-byte stack structure, the connection blueprint and populates it with the SNI hostname, the resolved IP, and a pointer to the Root CA certificate. That’s a defensive pattern, nothing more: clear the stack buffer before writing real values into it, so nothing stale leaks through.

That blueprint gets handed to an orchestrator function, which builds out a much larger, separate structure: the mbedTLS configuration struct. And this is where the story actually starts.

The Wipe

memset(config, 0, 152);

This is a different struct from the 40-byte blueprint, the full 152-byte mbedTLS config, and the wipe here isn’t redundant defensive coding, it’s closer to what mbedtls_ssl_config_init() does in the library itself: the config struct has to start at zero before anything configures it. Nothing suspicious about the wipe on its own.

What is meaningful is what it leaves behind. authmode, the field that decides how strictly the TLS engine verifies the server’s certificate, lives inside that struct, and a zeroed struct leaves it at MBEDTLS_SSL_VERIFY_NONE. No verification at all, by default, unless something downstream explicitly sets it.

So I kept tracing.

The Wrapper That Looked Complete

Immediately after the wipe, the orchestrator hands the clean struct to a custom Xiaomi policy wrapper. I went through this wrapper byte by byte. It defines the endpoint as a TLS client using bitwise arithmetic, and it hardcodes several bytes; offsets 0x91, 0x92, and 0x93 to the value 3, pinning the connection to TLS 1.2 and closing off downgrade attacks. Careful work throughout this function, clearly deliberate.

But nowhere in that wrapper did I find anything touching authmode. No assignment of 2 (MBEDTLS_SSL_VERIFY_REQUIRED), no reference to that field at all.

I traced the flawed struct straight into the mbedTLS handshake engine. At State 3 — Server Certificate Evaluation the validation logic reads authmode to decide whether to check the certificate chain and hostname. With the field still sitting at zero, that check would never fire. The camera would extract the server’s public key from whatever certificate it was handed and move on, no questions asked.

I had, on paper, a clean Man-in-the-Middle path: present a rogue certificate, complete the handshake with your own key, harvest Wi-Fi credentials over the air.

Stress Testing My Finding

Before rushing to present a zero-day claim, I spent time trying to break my own theory using static analysis and sanity checks.

Test A: Hunting for Orphaned “Ghost Strings”

While searching memory strings, I found explicit error logs like "[E] %s: verify root cert is not valid!". If certificate verification was entirely skipped, why did error strings for invalid certificates exist in the binary at all?

To test whether these strings were actually reachable, I converted the string’s address to its little-endian representation (BD 4B 08 00) and ran a global hex search across the binary. The result was zero XREFs, i.e. zero cross-references.

The learning: statically linking mbedTLS pulls in entire object files, not just the functions you call. Dead error strings sit in .rodata whether or not the code path that logs them is ever reachable. The presence of a string means nothing if no instruction anywhere references its address.

Test B: Auditing External Executables (rsa_verify)

Scanning the SquashFS root filesystem, I found a standalone utility at /bin/rsa_verify. I wondered if the camera offloaded certificate validation to an external process, since that would explain a missing in-process check.

The learning: real-time, millisecond-sensitive TLS state machines don’t fork/exec external processes mid-handshake. Inspecting rsa_verify showed it was used strictly by system startup scripts, to verify cryptographic signatures on OTA firmware packages before flashing. Nothing to do with the TLS handshake.

Test C: Dissecting HTTPS vs. Raw TCP/TLS vs. UDP

I noticed the Root CA load was wrapped in an if (param_2[3] != 0) check. Was the camera silently falling back to unencrypted HTTP when no CA was provided?

The learning: miio_client appeared to run two distinct communication modes, cloud communication over raw TCP/TLS using JSON payloads, and local LAN discovery over UDP using symmetric token-based encryption. The if check probably exists because the binary reuses the same memory structures for local UDP sockets, where a Root CA is irrelevant. For cloud targets specifically, the CA pointer was consistently populated.

Three ways to break my own theory, all of them dead ends. The hypothesis held up to everything I threw at it so far.

The One Line I’d Walked Past

Feeling confident the stress-tests held, I brought the analysis to my mentor. I walked through how the config struct was zeroed and how the custom wrapper never wrote 2 to authmode.

They looked at it and pointed at a single line sitting in plain sight. Not buried, not obfuscated, just easy to skip once you’ve decided the wrapper is where the bug lives. It sat in the parent orchestrator, right after the wrapper returned, among a whole sea of other glue and wrapper calls I’d already scanned past:

FUN_00059b2c(piVar6, 2);

I jumped into its decompiled body. One line:

*(byte *)(config + 0x94) = *(byte *)(config + 0x94) & 0xf3 | (param_2 & 3) << 2;

authmode lives at offset 0x94, in bits 2 and 3. 0xf3 is 11110011 in binary, a mask that clears exactly those two bits and leaves everything else untouched. The argument, 2, gets shifted left by two and written straight into the cleared space.

Decompiled code rarely gives you exact function names, a thing that makes reverse engineering so interesting (yet sometimes frustrating!). It wasn’t needed here though, it was clear that this function was mbedtls_ssl_conf_authmode. And it was being called with 2: full, mandatory verification.

What Actually Happened

The developers hadn’t forgotten to secure the connection. They’d just placed the fix in the orchestrator instead of inside the vendor wrapper as design choice probably, but definitely not an oversight. Because that call executes immediately after the wrapper and before the handshake loop begins, authmode was locked to 2 before the connection ever reached certificate evaluation. When State 3 runs, the signature check executes exactly as it should: verified against the hardcoded Mijia Root CA, MitM attempts dropped before they get anywhere near the Wi-Fi credentials.

There was no vulnerability. There was simply code I’d missed.

Lessons

  1. A missing assignment inside one function doesn’t mean the assignment is missing. Config structs get built across multiple functions all the time. Check all full surrounding call chains before writing the report, not just the function that looked suspicious.

  2. Confidence is not confirmation. I had a clean, plausible exploit path and a coherent story. Plausible isn’t the same as verified, and the two feel identical from the inside. Memset to VERIFY_NONE is only dangerous if nothing downstream corrects it. Prove the absence all the way to the handshake, not just at the point where the default looks bad.

  3. Most firmware today is a well audited open-source library glued to the vendor’s own state machine. mbedTLS here is doing exactly what it’s supposed to. The actual attack surface was never inside the library; it was in the vendor’s glue code stitching it together; the wrapper functions, the JSON structs feeding the state machine, the handoffs between library default and vendor override. That join point, and the point where the firmware meets the user (provisioning, input parsing, credential handling), are genuinely where I’d bet on finding real bugs first.

  4. That doesn’t mean every missed setting is a bug. I stress-tested every branch I could find, reread the disassembly more than once, and still walked past the one function that mattered; sitting a few lines below the wrapper, in the middle of a dozen other glue calls that all looked equally unremarkable. The instinct to get excited at a zeroed security field is the right instinct to have. Acting on it before you’ve walked the entire call chain is how you end up writing a report about a bug that isn’t there.

Closing Thoughts

Walking away from a binary without a vulnerability can feel like wasted time. It isn’t. The same discipline that catches a side-channel leak on a logic analyzer or tracks physical degradation in a sensor over months applies here: exhaust your own hypothesis before you trust it. A sterile writeup that only shows the finding loses the part that actually builds skill: the dead end, and the one function call that closed it.