Blog

Static firmware analysis: 20 findings from the image alone

No device, no network, just the IoTGoat firmware image unpacked. The agent found the backdoor binary, the telnet daemon, the command injection endpoint and a Mirai default credential before anything was ever powered on.

· 7 min read

Firmware analysis is the part of embedded security that happens before anyone has a device. You have an image, you unpack the filesystem, and you read it. No network, no target, no risk of breaking production hardware, and no possibility of pretending an issue is exploitable when you have not touched a live system.

We gave Darkmoon the OWASP IoTGoat firmware image and nothing else. The firmware specialist agent extracted the squashfs root and produced 20 findings.

What the agent found

SeverityCount
Critical4 (1 exploited, 3 confirmed)
High7 (confirmed)
Medium7 (confirmed)
Low2 (confirmed)
Total20

The exploited column is the honest part of this report. Nineteen of the twenty findings are marked confirmed, because you cannot exploit a filesystem you have merely unpacked. The one marked exploited is the credential, because a hash that gets cracked is a fact rather than an assessment.

The backdoor, before it ever ran

The image contains a binary at /usr/bin/shellback. The agent read its symbol table and its startup wiring, and let the two together make the case:

strings squashfs-root/usr/bin/shellback
-> fork, socket, bind, listen, accept, dup2, execve, close, htons, write

cat squashfs-root/etc/rc.local
# Put your custom commands here that should be executed once
# the system init finished. By default this file does nothing.

/usr/bin/shellback &
telnetd -p 65534 &
exit 0

A binary that binds a socket, accepts a connection, forks, duplicates file descriptors onto it and calls execve is a shell server. Started from rc.local at boot, with an init script and a UCI config entry alongside it, it is a persistent one. The telnet daemon on the next line is a second critical finding: cleartext credentials on a high port that nothing scans by default.

The command injection, read out of the source

cat squashfs-root/usr/lib/lua/luci/controller/iotgoat/iotgoat.lua

module("luci.controller.iotgoat.iotgoat", package.seeall)
local http = require("luci.http")
function index()
    entry({"admin", "iotgoat"}, firstchild(), "IoTGoat", 60).dependent=false
    entry({"admin", "iotgoat", "cmdinject"}, template("iotgoat/cmd"), "", 1)
    entry({"admin", "iotgoat", "webcmd"}, call("webcmd"))
end

The webcmd function passes a form value into io.popen with no validation, as root. The route table also registers a hidden cmdinject page. Static analysis finds this in seconds and finds it completely, including the routes that no link on the interface points to, which is something a crawler against a live device would miss.

The credential, and the one that did not crack

This is the pair of findings worth the whole post, because they came from the same file and ended differently.

cat squashfs-root/etc/shadow
root:$1$<md5crypt hash>:18145:0:99999:7:::
iotgoatuser:$1$<md5crypt hash>:18145:0:99999:7:::

john --wordlist=iot_wordlist.txt /tmp/iotgoat/unshadowed.txt
Loaded 2 password hashes with 2 different salts (md5crypt [MD5 32/64 X2])
7ujMko0vizxv     (iotgoatuser)
1g 0:00:00:00 100% ...  Session completed
john --wordlist=/usr/share/wordlists/rockyou.txt /tmp/iotgoat/unshadowed.txt
Loaded 2 password hashes with 2 different salts (md5crypt [MD5 32/64 X2])
Remaining 1 password hash
0g 0:00:01:34 100% 0g/s 152000p/s
Session completed [not cracked with rockyou.txt]

Two hashes, two different claims

The iotgoatuser hash cracked instantly to a Mirai botnet default credential, so that finding is rated critical and marked exploited. The root hash did not crack against rockyou.txt in the time budget, so its finding is titled, in the report's own words, weak root password hash, not cracked but brute-forceable, rated high, and marked confirmed.

The temptation with a weak hashing algorithm is to write root password compromised and move on. MD5crypt genuinely is unsuitable and the finding says so. What it does not say is that the password was recovered, because it was not.

What else the image gave up

  • PII in an embedded database. usr/lib/lua/luci/controller/iotgoat/sensordata.db is a SQLite file whose sensors table stores a name, an email address and a birthdate alongside each reading. Six records shipped inside the firmware.
  • An update channel over plain HTTP. Every feed in /etc/opkg/distfeeds.conf points at http://downloads.openwrt.org/..., which makes package installation a machine-in-the-middle opportunity.
  • An open WiFi access point configured in /etc/config/wireless with option encryption 'none'.
  • UPnP with secure mode off in /etc/config/upnpd, listening on port 5000.
  • Dropbear permitting root password authentication, from the config file rather than from a banner.
  • An exposed LuCI file browser endpoint, again found in the route table.
  • An end of life dependency stack, each version read from the binary or its opkg control file: dnsmasq 2.73, Dropbear 2017.75, BusyBox 1.28.4, kernel 4.14.95, mbedTLS 2.14.1, wpa_supplicant and hostapd from May 2018, on OpenWrt 18.06.2.
  • A duplicate dnsmasq user entry in /etc/shadow and a hardcoded public DNS resolver in the network config, both rated low.

For each outdated component the report lists the known CVEs with their scores, for example the DNSpooq set against dnsmasq below 2.83, and the BusyBox wget heap overflow. Those are published advisories matched to a version string read out of the binary. The report presents them as version based exposure, which is what they are, not as confirmed exploitation.

Remediation

  • Remove the backdoor binary and its four persistence hooks, and rebuild the image.
  • Remove the telnet daemon from rc.local, and the iotgoatuser account from the image.
  • Delete the IoTGoat LuCI controller, and the file browser endpoint, or authenticate them and stop passing input to io.popen.
  • Move password storage from MD5crypt to a modern algorithm, and disable root password authentication.
  • Switch opkg feeds to HTTPS and enforce signature checking on packages.
  • Ship the access point with WPA2 or WPA3, and UPnP with secure mode on or disabled entirely.
  • Remove personal data from images that ship to customers.
  • Rebuild on a supported OpenWrt release and refresh the whole dependency stack.

What this proves about autonomous pentesting

Static analysis has a natural honesty constraint: there is no live system, so nothing can be claimed as exploited by accident. This report used that constraint well. It found the backdoor by symbols and boot wiring, the injection by route table and function body, the credential by cracking, and the outdated stack by version strings, and it labelled each one with the confidence the method supports.

It is also the cheapest test in this batch. No hardware, no network access, no coordination window. If you build or buy embedded devices, running this on every image before it ships is achievable continuously.

Darkmoon is open source under GPL-3.0: repository, documentation.

Run it against your own lab

Darkmoon is open source (GPL-3.0) and self hosted. Clone it, point it at a target you own, and read every line.