Spotting Backdoors and Debug Hooks in Firmware

Most hidden access in firmware is not a planted backdoor. It is a debug feature someone forgot to remove. The effect on your attack surface is identical: a path into the device that was never meant to ship. Here is how I hunt for those paths in an extracted image.
Start From the Filesystem and Strings
Once you have carved the root filesystem out with binwalk -e, the fastest first pass is a search for the words developers use when they are taking shortcuts. Magic strings and debug labels survive into production far more often than anyone would like.
# search the extracted rootfs for tell-tale debug language
grep -rniE 'backdoor|debug|test_?mode|factory|engineer|hidden|secret' \
_firmware.bin.extracted/squashfs-root/ | headbin/cli: if (!strcmp(cmd, "factorytest")) enter_test_shell(); etc/init.d/telnetd_debug: telnetd -l /bin/sh -p 9999 www/cgi-bin/diag.cgi: system(cmd); // engineer diag, remove before ship usr/sbin/keygen: /* debug: static seed when DBG=1 */
Every one of those lines is worth chasing. A startup script that launches telnetd with /bin/sh as the login shell is an unauthenticated root console on a high port, and the comment remove before ship is exactly the kind of thing that ships anyway.
Inventory What Starts at Boot
A backdoor is only dangerous if it runs. Read the init scripts to see what the device launches automatically, because that is the set of services an attacker can actually reach without any further work.
cat _firmware.bin.extracted/squashfs-root/etc/init.d/rcS
#!/bin/sh mount -a httpd -h /www dropbear -p 22 /usr/sbin/telnetd_debug & # <-- not in any manual /usr/sbin/mfgd --listen 53210 & # <-- manufacturing daemon
Two of those five lines are undocumented. An entry in an init script is not theoretical: it runs on every boot, so anything questionable there goes straight to the top of the list.
Hidden Command Handlers
Command parsers are the richest hunting ground. A serial or network handler that switches on a command string almost always has options that never made it into the manual. In a disassembler, find the dispatch function and read every branch, not just the documented ones.
# list the command strings the parser compares against
strings -n 4 usr/sbin/cli | grep -iE '^(set|get|sys|dbg|adb|mfg|root|unlock)'sys_reboot sys_factory dbg_dumpmem dbg_shell mfg_unlock get_status
The documented commands are the get_ and set_ family. The dbg_dumpmem, dbg_shell, and mfg_unlock handlers are the ones that matter. Trace each to see what it gates and whether anything stops a normal user from calling it.
Cross-Referencing a String to Its Code
Finding the string is half the job. The other half is jumping to the code that uses it. In Ghidra, the defined-strings window plus references shows you the function behind each suspicious string in two clicks, and the radare2 equivalent does it from the command line.
# in radare2: find the string, then list code references to it
r2 -q -c 'izz~dbg_shell ; axt @ str.dbg_shell' usr/sbin/cli0x000148a0 19 18 .rodata ascii dbg_shell fcn.0000a210 0xa244 [DATA] lea str.dbg_shell # -> handler at fcn.0000a210 references the dbg_shell string
Now you are reading the handler itself rather than guessing from the name. The reference tells you which function to decompile, and the function tells you what the hidden command actually does.
Following the Magic Value
When a function compares input against a fixed string or a specific number and then unlocks behavior, that comparison is a hook. These branches frequently open a debug shell, dump memory, or skip authentication outright. The decompiled logic tends to look like this.
// decompiled handler, cleaned up
int check_unlock(char *input) {
if (strcmp(input, "Zte521") == 0) { // hardcoded knock
g_auth_level = ADMIN;
return 1;
}
return 0;
}The fixed value is effectively a master key, and because it lives in the firmware, anyone who reads the image holds it. A single shared knock string across an entire product line means recovering it once unlocks every unit in the field.
The Authentication Bypass Pattern
A close cousin is the check that was meant to be temporary. A debug flag that short-circuits the password comparison is the classic shape, and it survives because it makes development convenient right up until it ships.
// decompiled login path, cleaned up
int do_login(char *user, char *pass) {
if (g_debug_mode) return 1; // <-- bypass
return strcmp(pass, lookup_hash(user)) == 0;
}# g_debug_mode is set from an env var read at boot:
# if (getenv("DBG")) g_debug_mode = 1;
# and DBG is exported in /etc/init.d/rcS on the test buildIf the flag can be reached from anything an attacker controls, an environment variable, a config file, a strap pin, then the password check is decorative. Finding where the flag is set is as important as finding the bypass itself.
Debug Builds That Shipped by Accident
Verbose logging, assertion strings with full source paths, and test menus that never got compiled out all signal a debug build that escaped. The source paths alone are a gift, because they map the firmware’s internal structure for you.
strings rootfs/bin/appd | grep -E '\.c:[0-9]+|assert|DEBUG|TODO'
/home/dev/proj/src/auth.c:142: assert(user != NULL) /home/dev/proj/src/cmd.c:88: DEBUG: skipping signature check /home/dev/proj/src/net.c:301: TODO: remove test endpoint
The line skipping signature check in a debug path is exactly the kind of convenience that turns into a vulnerability when the debug flag is reachable in production. Find where that flag is set and whether an attacker can set it too.
Network Listeners Nobody Mentioned
Cross-check every service that binds to a port against the documentation. An undocumented listener is a classic way in, whether it was left by a careless developer or planted deliberately. If you can run the firmware, even in emulation, enumerate the open ports directly.
# inside the running or emulated device
netstat -tlnpProto Local Address State PID/Program tcp 0.0.0.0:80 LISTEN 412/httpd tcp 0.0.0.0:22 LISTEN 398/dropbear tcp 0.0.0.0:9999 LISTEN 455/telnetd tcp 0.0.0.0:53210 LISTEN 502/mfgd
Port 80 and 22 are expected. The telnetd on 9999 and the mfgd manufacturing daemon on a high random port are not in any manual. Treat anything undocumented as hostile until proven otherwise, because an attacker scanning the device will find it regardless of intent.
Hardcoded Accounts in the Password File
While you are in the filesystem, read /etc/passwd and /etc/shadow. A second root-level account, or a hash you can crack offline, is a backdoor in the most literal sense.
grep ':0:' squashfs-root/etc/passwd
root:x:0:0:root:/root:/bin/sh admin:x:0:0:admin:/:/bin/sh # second uid-0 account remotesupport:x:0:0::/:/bin/sh # undocumented support login
Three accounts with UID 0 is two too many. The vendor documents one. The other two are the kind of standing access that gets a product onto a vulnerability tracker.
Cracking a Shipped Password Hash
When the shadow file ships with real hashes, an offline crack tells you whether the credentials are weak as well as present. A weak shared root password is functionally a backdoor, because every unit accepts it.
# pull the hash and run it against a wordlist offline
john --wordlist=rockyou.txt squashfs-root/etc/shadowLoaded 1 password hash (md5crypt) admin123 (root) 1g 0:00:00:02 100% 0.4525g/s ...
An md5crypt hash that falls to a common wordlist in two seconds is a finding on its own. A device that ships the same crackable root password across the fleet has handed every owner, and every attacker, the keys.
Triggering the Hook in Emulation
When static analysis suggests a hook, emulation lets you confirm it without the hardware. Running the binary under user-mode QEMU and feeding it the magic value shows whether the branch really fires.
# run the CLI under emulation and send the suspected knock
qemu-arm -L squashfs-root ./squashfs-root/usr/sbin/cli <<< "unlock Zte521"[cli] auth level raised to ADMIN [cli] debug commands enabled: dumpmem, shell, peek, poke #
A confirmed trigger turns a suspicion into a demonstrated vulnerability, and a demonstrated vulnerability is the kind that gets fixed. It also tells you exactly what the hidden access grants, which is what determines the severity.
Hunting in the Web Interface
Many devices put their hidden access in the web interface, because that is where the developers spent their time. The CGI scripts and their handlers often accept undocumented parameters that the production UI never sends. Grepping the web root for direct command execution and hidden parameters is quick and frequently fruitful.
grep -rniE 'system\(|popen\(|exec|eval|hidden|admin_debug' squashfs-root/www/ | head
www/cgi-bin/diag.cgi: system(get_param("ping_host")); // unsanitized
www/cgi-bin/sys.cgi: if (get_param("debug")=="1") show_admin();
www/js/app.js: // var DEBUG_ENDPOINT = "/cgi-bin/raw.cgi";A system() call fed straight from a request parameter is command injection, and a debug=1 parameter that reveals an admin view is a hidden door. The commented-out endpoint in the JavaScript is a lead too, because the endpoint usually still exists on the device even though the UI no longer calls it.
Checking for a Hardcoded Key or Seed
Hidden access is not always a shell. A hardcoded cryptographic key or a predictable random seed is a backdoor into the device’s data rather than its prompt. Search for the constants and the weak seeding patterns that betray them.
grep -rniE 'srand\(|aes_key|0x[0-9a-f]{16,}|-----BEGIN' squashfs-root/ | headusr/sbin/keygen: srand(0x1234); // fixed seed -> predictable keys
etc/ssl/private/device.key: -----BEGIN PRIVATE KEY----- (shared across fleet)
lib/libcrypto_helper.so: static aes_key[16] = { 0xde, 0xad, ... }A fixed srand seed means every device generates the same “random” keys, and a private key shipped in the filesystem is shared across the whole fleet. Both let an attacker who reads one image impersonate or decrypt any unit, which is a backdoor by any practical definition.
Telling Debug From Malice, and Why It Does Not Matter
Most hidden access is a forgotten debug convenience, not a planted backdoor. The useful question is not who put it there, it is whether it would be dangerous in an attacker’s hands. If the answer is yes, it should be compiled out of production builds, not merely hidden behind an undocumented command.
I treat intent as irrelevant to the risk rating and relevant only to the remediation conversation. A planted backdoor is a supply-chain problem. A forgotten debug shell is a build-hygiene problem. Both get the same severity in the report, because both give the same access to whoever finds them.
Building It Into the Process
The defense is not heroics, it is a build that strips debug paths automatically. Gate every debug feature behind a compile-time flag that is off in release, fail the build if certain symbols survive, and scan the final image for the same strings an attacker would grep for. If your own pipeline cannot find the debug shell, neither side has done its job.
Where This Fits
A hidden-access sweep is part of every firmware review I run, and it pairs naturally with extraction and secure-boot analysis. If you want a fresh set of eyes searching your image for the doors you forgot you left open, that is the kind of work we do at Berkner Tech.