A Zeratul Journey, Detour: NAND OOB, One Plaintext File, and a Router's Entire Credential Vault
A detour from A Zeratul Journey: Porting Thingino to the Jooan S7-U / A Zeratul Journey, Continued
Every LTE Thingino build runs into the same wall eventually: a direct connection, camera to viewer, no relay server in the middle. Getting there on this Zeratul-based 4G build meant sorting out what address family the modem was handing back.
The cellular side came up IPv6 only. My home network is IPv4 end to end, no IPv6 anywhere on the LAN, so a direct connection was dead before it started. The obvious next move was checking whether the ISP-supplied router could be talked into running real IPv6 on the LAN side too, which meant popping the case on the GPON ONT (the fiber terminal box) in the closet to see what was actually configurable in there.
That is how a look at IPv6 settings turned into six hours with a chip clip and a hardware programmer instead.
A NAND dump from a hardware programmer should just work. Point binwalk-ng
at it, get a filesystem back. Instead I got a busybox binary that claimed
to be 4 GB, a bin/ash symlink pointing at binary garbage, and directories
that should have held a full root filesystem sitting empty. Multiple reads
off the chip produced the exact same corruption, so this was not a flaky
dump. Something in the extraction pipeline was wrong, and it took a
detour through NAND page geometry, a Ghidra decompile of a vendor crypto
library, and a router’s entire hardcoded credential store to find it.
A 4 GB busybox binary is not a real busybox binary
The target was a ZTE ZXHN F670L V9, a GPON ONT built around ZTE’s own
ZX279128S SoC, an ARM Cortex-A9 part. The dump was 132 MiB, read raw off
a single NAND package with a hardware programmer.
binwalk-ng found the JFFS2 filesystem signatures fine. Small partitions,
under 200 nodes each, extracted clean. The two large rootfs partitions,
25 MB and 15,242 nodes each, did not:
$ stat bin/busybox
size: 4294970154 Blocks: 656
4,294,970,154 bytes is suspiciously close to u32::MAX (4,294,967,295),
off by a few thousand. du on the same file reported 328 KB of actual
disk usage. The file was sparse: a huge logical size, almost no real data,
and the data that was there sat in scattered two- and three-byte runs
starting 12 KB into the file, no ELF header in sight.
The dump had spare area baked in
NAND flash reserves a small out-of-band (OOB) region per page for ECC and
bad-block metadata, alongside the addressable main data. A raw programmer
read that includes OOB produces a file shaped like [2048 bytes
data][64 bytes OOB][2048 bytes data][64 bytes OOB]..., not a flat image.
The math confirmed it. 132 MiB total, divided by the chip’s 4 internal
banks, is 33 MiB per bank. At a 2048+64 page geometry, 33 MiB of raw reads
holds exactly 32 MiB of real data per bank, 128 MiB across all four, which
is a normal NAND capacity. Sampling the presumed OOB position across the entire 132 MiB file
showed uniform 0xFF at every occurrence, not the semi-random bytes real
ECC data produces. The programmer’s raw dump mode was writing placeholder
padding into the spare area, not skipping it.
binwalk-ng read the file as one continuous byte stream. JFFS2 node
offset fields describe positions in the logical, OOB-stripped address
space. Every 2048 bytes, the tool’s read position and the filesystem’s
logical position drifted 64 bytes further apart. A handful of pages in, a
small partition, the drift stayed small enough that the extractor still
landed on real data. Thousands of pages in, a large partition, the drift
compounded into exactly the kind of garbage the busybox binary showed:
node headers parsed from the wrong bytes, sizes computed from noise, real
content scattered wherever the misaligned offsets happened to point.
The fix was a single strip pass:
period = 2048 + 64
while chunk := f.read(period):
out.write(chunk[:2048])
134,217,728 bytes out, exactly 128 MiB, exactly 4 times 32 MiB. Re-running
binwalk-ng against the cleaned image gave page-aligned JFFS2 offsets and
a clean extraction. busybox came back as a real 335,576-byte ARM ELF
binary, and bin/ash pointed where it should.
This was a data-shape problem, not a binwalk-ng bug. But the tool has no
way to know a dump carries OOB padding, and there is no way to tell it. An
optional preprocessing flag, something like --nand-oob 2048:64, would
turn this from a one-off script into a supported workflow. Anyone else
pulling a raw NAND dump with a page-and-spare programmer is going to hit
this exact corruption pattern.
One plaintext file was the root of trust for everything
With a clean rootfs in hand, several /etc config files stood out:
db_default_cfg.xml and friends had entropy over 7.9 bits per byte and
would not open as text. Encrypted, clearly, but by what key?
The trail led through libhardcode.so, an unstripped shared library whose
name undersold how much it was hiding. A Ghidra decompile of its key
derivation function, done in an afternoon instead of a slow hand
disassembly of ARM Thumb-2, turned up:
key = SHA256(key_string)
iv = SHA256(iv_string)[:16]
AES_cbc_encrypt(buf, out, len, key, iv, DECRYPT)
key_string and iv_string are not compiled in. They come from reading
and lightly transforming the contents of a single file: /etc/hardcode.
World-readable, -rw-r--r--, holding a 256-bit hex seed followed by a
model string. That one file, present byte-for-byte identical in two
firmware builds released over a year apart, is the entire root of trust
for a directory called /etc/hardcodefile/, six encrypted blobs covering
WiFi keys, TR-069 management credentials, GPON registration defaults, and
SSH/FTP/web-admin passwords, plus, through a second key pulled from that
same chain, 39 region-specific default-configuration databases, one per
ISP that ships this router under its own branding.
Writing a Python decryptor against the recovered derivation opened every
one of those files. All of it, on every device running this firmware,
protected by one file anyone can read with cat.
The router’s own auth check does not run
Digging into the TR-069 (CWMP) remote-management path turned up a second,
unrelated problem. The ConnectionRequest listener, the socket an ISP’s
Auto Configuration Server uses to reach into a subscriber’s router, is
guarded by an HTTP Digest check in httpd. Decompiling that check showed
the real logic:
if (*(char *)(cfg_base + 0x1a0) == '\0' ||
(strlen(username) == 0 && strlen(password) == 0)) {
return 0; // authentication succeeds, no digest check performed
}
A byte-by-byte reference scan across the entire binary found exactly one
place that reads cfg_base + 0x1a0: the line above. Nothing writes to it,
anywhere in the binary, and its compiled-in default is zero. There is no
code path that ever flips this flag. The bypass branch runs on every
boot, on every device, regardless of which of the 39 carrier profiles it
shipped with.
The listener binds specifically to the WAN interface, confirmed by
decompiling the socket setup, so this is not a LAN-only exposure. A
firewall-rule audit across every iptables string in the management
daemon showed Telnet, FTP, SSH, HTTP, and HTTPS explicitly blocked on both
the LAN bridge and WiFi. The ConnectionRequest port was not in that list,
on any interface.
None of this got IPv6 running on the LAN. That question is still open. But six hours with a chip clip turned into a NAND extraction bug worth fixing upstream, a credential store protected by nothing, and a remote-management bypass reachable from the WAN on every branded variant of this router, a good trade for an evening that started as a settings-page detour.
What you learned
- A raw NAND dump from a page-and-spare programmer is not automatically a flat filesystem image. Check the total size against known page geometries (2048+64, 4096+128) before assuming an extractor bug.
- Corruption that gets worse the deeper you scan, while staying clean in small regions, points at cumulative offset drift, not random damage.
- A library named for what it does (
libhardcode.so) is worth decompiling first. - When a “secret” file turns out to be world-readable and byte-identical across unrelated firmware releases, it is not a per-device secret. It is a constant, and everyone who has a copy of the firmware has it too.
- A reference scan for who writes to a security-critical byte, not just who reads it, is the fastest way to tell a real check from dead code.