Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
15
Section 15 · follows 14

Five hypotheses I was wrong about, and a key on the cmdline

Written
2026-05-16
Commit
e113205
Log
Ref/mirror-recon/recon-2026-05-16-*.txt

This post discusses techniques specific to reverse-engineering a device I personally purchased. In the U.S., Section 1201 of the DMCA includes exemptions for security research, right-to-repair, and personal-use modifications. I am not a lawyer; if you’re doing similar work and want professional guidance, the EFF runs a free legal hotline at info@eff.org for security researchers and reverse engineers.

The previous post covered the access path, how I got a root shell on my own mirror. This one covers what that shell did to the project’s mental model. The short answer: it corrected several of the load-bearing beliefs I’d built my plan on. The longer answer is below.

If you’re reading this for the fun of it, the headline is that the LUKS unlock material for the entire encrypted filesystem is sitting in plain sight on /proc/cmdline of the running system. If you’re reading because you also have a Naked mirror you’re trying to revive, the corrections below are where your evening goes.

§ 15.1

Hypothesis 1: .noInternet is a sticky state that blocks scans indefinitely

What I believed: the mirror’s BLE-visible mirrorError byte gets stuck at NO_INTERNET (3) because some firmware-internal internet check fails (my guess was a failed ngrok tunnel registration to a dead account). That error blocks all scan actions at the DeviceState=ERROR precondition gate. To unblock the scan flow, I thought I had to disable that check or fake the internet successfully.

What’s actually true: mirrorError is NO_INTERNET only for a brief boot transition window, roughly 3 seconds between WiFi association and the steady-state settle. After that it sits at NO_ERROR (1) forever, even though the coordinator’s cloud API calls keep failing every 10 minutes with Error resolving address. The coordinator does not flip the error back when those cloud calls fail.

The evidence was right there in /data/logs/myeasylog.log. Every boot shows the same sequence:

01:54:25 Set mirror error no wifi 201:54:25 WARNING: Wifi conn status is not connected, setting mirror to not configured01:54:28 Set mirror error no internet 301:54:28 Set config wifi conn status connected no int 201:54:28 Set mirror error no internet 301:54:28 Set config wifi conn status connected 101:54:28 Set mirror error no err 101:54:54 Request Exception (cloud_api_scanner_status): Error resolving address01:54:54 ERROR: Unable to update cloud status with error 1

The line Set config wifi conn status connected 1 is WiFi finishing association, and Set mirror error no err 1 right after it is the settle to NO_ERROR. Drawn out:

Fig. 15-1
sequenceDiagram
    participant Boot as Mirror boot
    participant Err as mirrorError byte
    Boot->>Err: NO_WIFI (2)
    Boot->>Err: NO_INTERNET (3), WiFi still associating
    Note over Boot: WiFi finishes associating (~3s)
    Boot->>Err: NO_ERROR (1), steady state
    Note over Err: stays NO_ERROR even as cloud calls fail every 10 min

The iPhone debug app’s mirrorExternalState reads that returned .error on previous test runs were happening inside that roughly 3-second transition window after boot. I wasn’t observing a sticky state. I was observing a fast one and reading it at exactly the wrong moment.

Why this matters: roughly six weeks of project planning was scoped around “I have to fix the internet check before scans will work.” I don’t. The precondition gate is open in steady state. The scan flow should actually work end-to-end now, modulo whatever the dead cloud upload does to the post-scan path. My next test run will be the first one with this corrected understanding.

§ 15.2

Hypothesis 2: The mirror runs Android

What I believed: for most of the project my internal notes referred to the mirror’s compute as “Android on Celeron.” This came from misreading a teardown photo and was never properly verified. Several documents (including an earlier draft of Ref/SCAN_WORKFLOW.md) refer to “the mirror’s Android compute board.”

What’s actually true: Yocto Linux. Specifically the “sumo” release (Yocto 2.5, April 2018), running kernel 4.14.48 built April 2019, a BusyBox 1.27.2 userspace, systemd init. /etc/os-release says ID="nakedlabs", VERSION="2.0 (sumo)", PRETTY_NAME="Poky (Yocto Project Reference Distro) 2.0 (sumo)".

This started getting corrected in a previous session (the network recon showed an Intel mini-PC profile, and aDutchofMuch’s Reddit comment matched), but it’s worth restating in print, because it changes the toolchain assumptions for any future work. There’s no Android dev kit to fight, no SELinux policies, no ADB. There’s a Yocto image, a systemd unit list, and package management that is neither apt nor Android (it’s smart on this build).

§ 15.3

Hypothesis 3: The coordinator is Go-compiled

What I believed: a 722 KB statically linked binary doing BLE GATT plus scan state machine plus cloud upload all in one process sounded like a Go binary. Naked Labs the company looked like a Go-heavy shop based on their engineering-team blog posts.

What’s actually true: C++. The mangled symbols in strings /usr/bin/coordinator are unmistakable. _ZN3std-style mangling, STL containers in the type signatures. A real example:

_Z16set_mirror_error12mirror_error_Z22cloud_api_upload_filesRNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEERSt6vectorIS4_SaIS4_EERi

The binary links against libaws-cpp-sdk-s3.so, libaws-cpp-sdk-core.so, and libnss3.so (Mozilla NSS for TLS). It’s a single-binary C++ application using the AWS C++ SDK for direct S3 uploads.

Why this matters: binary-patching strategy. A 722 KB statically-linked C++ binary with the AWS SDK baked in is meaningfully harder to modify than a Go binary would be. There’s no easy go-bindata extraction, no obvious function-table approach. If I ever need to change the coordinator’s behavior, that’s Ghidra territory. It reinforces the “minimum-viable intervention” principle: patch only as a last resort.

§ 15.4

Hypothesis 4: The mirror’s upload endpoint is api.nakedfit.net (just that)

What I believed: from APK auditing earlier in the project, the mirror’s upload destination was hardcoded to https://api.nakedfit.net/<endpoint>. So redirecting that one domain to a LAN coordinator (DNS override, /etc/hosts, and so on) would be enough to catch the scan upload.

What’s actually true: it’s both. The coordinator strings include hardcoded references to https://api.nakedfit.net and /scan and /data/scans/, and the binary links the AWS C++ SDK for direct S3 uploads. So the upload path is almost certainly: token issue / metadata to api.nakedfit.net, then the binary payload PUT directly to S3 via the SDK.

The architectural implication is that redirecting api.nakedfit.net is only half of catching the upload. S3 traffic doesn’t go through /etc/hosts consistently. The AWS SDK has its own DNS-resolution behavior, and S3 URLs use bucket-specific subdomains of s3.amazonaws.com. I may need iptables-level redirection for the S3 half, or to construct presigned-URL responses that point at a LAN endpoint dressed up to look like S3.

I won’t know exactly until I see one upload attempt in detail. That’s what the next dry-run scan is meant to capture.

§ 15.5

Hypothesis 5: The rootfs is just ext4 and I can mount it directly

What I believed: standard Yocto image, ext4 root partition, pop the M.2 SSD into an enclosure and read it. The whole SSD-pull plan in Ref/PHYSICAL_ACCESS.md was built around this.

What’s actually true: the rootfs is LUKS-encrypted. Both A/B rootfs slots and the /data partition are LUKS version 1 (AES-XTS-PLAIN64, SHA-256). Only the boot partition (/dev/sda1, vfat) is unencrypted, because GRUB has to read it.

If I’d pulled the SSD as planned, mounting /dev/sda2 on a Linux box would have produced “unknown filesystem.” Without the key, I’d have been stuck. The SSD-pull plan would have failed.

This is the most interesting hypothesis-correction of the night, because it inverts the story of why the console route was the right call. I’d assumed the SSD pull was the deterministic path, because it didn’t depend on guessing a password. In fact it would have hit the LUKS wall, and I’d have been doubly stuck: encrypted disk plus no shell to read keys from. The “open the case and use the existing video output” route was strictly better in retrospect, not just easier.

The key is right there

Here’s where the post earns its title. Once you have root on the running system, cat /proc/cmdline shows something like:

kexec_boot ro reboot=pci kexec_key=<REDACTED: per-device LUKS key>

That kexec_key= value is base64-encoded LUKS unlock material (172 characters, 128 raw bytes on my unit). It’s specific to this one device, so I’m not reproducing it here. The architecture is a kexec boot: the initrd in /mnt/bootfs/initrd (27 MB, fairly chunky) performs the LUKS unlock using hardware-derived material at the very start of boot, then kexec’s into the runtime kernel, passing the unlock material forward on the kernel command line. The runtime kernel uses it to keep the dm-crypt mappings live. And a running system’s /proc/cmdline reflects whatever the current kernel was booted with, the unlock material included.

Fig. 15-2
sequenceDiagram
    participant Initrd
    participant Disk as Encrypted partitions
    participant Runtime as Runtime kernel
    Initrd->>Disk: unlock with hardware-derived material
    Disk-->>Initrd: dm-crypt mappings opened
    Initrd->>Runtime: kexec, unlock material passed on cmdline
    Note over Runtime: material readable at /proc/cmdline (value redacted here)
    Runtime->>Disk: keeps dm-crypt mappings live

No TPM querying. No clevis. The material is simply present, readable via a standard /proc read.

It’s worth being precise about what this does and doesn’t mean, rather than reaching for the word “broken.” The encryption behaves exactly as full-disk encryption is designed to: it protects the data at rest against someone who powers the device off and pulls the SSD, the classic offline or “evil maid” scenario, and against that threat it holds. What it does not do is add a second layer against someone who already has a root shell on the live system, because at that point the unlock material is in the kernel’s hands and therefore in /proc. That’s the normal trade-off of transparent full-disk encryption on an unattended appliance, not a defect. Naked Labs most likely weighed the BLE, WiFi, and SSH access surface and concluded, reasonably, that an adversary who got that far was a bigger problem than disk encryption was ever meant to solve.

For my purposes as the owner, the practical upshot is that my own encrypted partition backups are decryptable at any time with a documented procedure. The hardware is fully archived, recoverable, and inspectable.

§ 15.6

What this means for the next phase

These five corrections, the first one especially (the scan flow isn’t actually blocked), drop a lot of complexity out of the next phase. The updated picture:

  • I do not need to work around the .noInternet check. It is not active in steady state.
  • I do not need to intercept api.nakedfit.net before attempting scans. The mirror’s failure mode when the cloud is dead is just “log the retry, soldier on,” not “refuse to scan.”
  • I do need to figure out what the mirror does when a scan completes and the upload fails. That’s a different question, and I’ll know more after the next dry-run.
  • I do not need to attempt firmware patching unless cert pinning or some other ungated dependency surfaces. Strong preference for /etc/hosts overrides, CA injection, and traffic redirection, all reversible.
  • I do have the hardware to run the processing pipeline on the LAN (an M2 Max and an RTX 4090), so that piece is unblocked.

The next session opens with a dry-run scan against a fully-instrumented mirror: live coordinator log via journalctl -u naked-coordinator -f, btmon for BLE, and a kludged-together packet capture, since BusyBox doesn’t ship tcpdump. The biggest open question, the shape of the upload payload, gets resolved by whatever bytes the coordinator attempts to PUT during a complete scan attempt. Once I know that shape, the LAN processing pipeline scope tightens from “6 to 12 weeks” to “exactly this implementation, exactly that long.”

Five wrong hypotheses corrected in an evening. The other thing I came away with is a complete forensic backup of every disk partition, in both encrypted and decrypted form, captured while the system was running and the keys were live. Even if everything else about this project went sideways tomorrow, the mirror’s bits are saved and I have what I need to read them.

§ 15.7
§ 15.8
Agent prompts Procedure 15-A as prompts for a coding agent · 2 prompts

Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.

1
Script a full recon capture over SSH
Write a script that SSHes into the mirror (ssh mirror, or ssh root@<mirror-ip> if that alias isn't set up) and runs this fixed battery of read-only diagnostic commands in order: grep "Set mirror error" /data/logs/myeasylog.log to confirm the boot sequence settles "no wifi 2" -> "no internet 3" -> "no err 1" within a few seconds of each boot; cat /etc/os-release; uname -a; strings /usr/bin/coordinator | grep _Z | head -n 20; strings /usr/bin/coordinator | grep -Ei 'nakedfit|s3|amazonaws'; lsblk; and cryptsetup status on each dm-crypt mapping lsblk reports. Label each command's output with the command that produced it, and write the whole concatenated result to a local timestamped file following the existing naming convention, e.g. Ref/mirror-recon/recon-$(date +%Y%m%d-%H%M%S).txt. Then print a one-line pass/fail summary for five checks: boot-error settle sequence present, OS is Yocto (not Android), coordinator binary has C++ (_Z) symbols and links an AWS SDK, both "nakedfit" and "s3"/"amazonaws" strings are present in the binary, and the rootfs partitions show as crypto_LUKS in lsblk. Base every pass/fail only on what the captured text actually contains, never on assumption.
2
Back up your own device's LUKS unlock key
Write a script that SSHes into the mirror, runs cat /proc/cmdline, and extracts the value of the kexec_key= parameter with a regex. Write only that extracted value to a local file stored next to my existing encrypted partition backups (not inside any git repo and not into any shared log), named clearly with the device identifier and date, and chmod the file 600 immediately after writing it. Do not print the full key value to stdout, to any log file, or to any committed file. Instead confirm success by printing the value's length in characters and a short non-reversible fingerprint of it, such as a sha256 hash or the first 6 and last 4 characters. This key unlocks my own device's encrypted disk and is sensitive backup material specific to this one unit, so treat it the same way you would treat a disk password: never echo it in full or send it anywhere other than the local backup file.