Berkner Tech

Spotting Backdoors and Debug Hooks in Firmware

Finding backdoors and forgotten debug hooks inside shipped embedded 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/ | head
Example output
bin/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
Example output
#!/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)'
Example output
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/cli
Example output
0x000148a0 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;
}
Example output
# 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 build

If 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'
Example output
/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 -tlnp
Example output
Proto 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
Example output
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/shadow
Example output
Loaded 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"
Example output
[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
Example output
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/ | head
Example output
usr/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.


References and Further Reading