Tutorial

Attack Surface Analysis of a Minimal Embedded Linux Image

Build a stripped embedded Linux image with Buildroot, then systematically audit it: open ports, exposed syscalls, suid binaries, kernel modules, and writable paths — then harden it and measure the difference.

6 min read intermediate

Prerequisites

  • Comfortable with the Linux command line
  • Basic understanding of Linux security concepts (permissions, syscalls)
  • Familiarity with Buildroot or willingness to follow the cross-compiling tutorial

Part 2 of 3 in Embedded Systems & Firmware

Table of Contents

Embedded Linux images ship on millions of devices, and most of them carry more attack surface than they need. A default Buildroot or Yocto configuration includes development tools, extra kernel modules, permissive filesystem permissions, and no syscall filtering. Each of these is an opportunity for an attacker who gets a foothold.

This tutorial takes the opposite approach. You’ll build a minimal image, audit every layer of it — network services, filesystem, kernel, and syscall exposure — then apply targeted hardening and measure the reduction. The goal isn’t to produce a “secure” image (security depends on context), but to build the skill of seeing attack surface the way an attacker does.

If you’ve followed the cross-compiling tutorial, you already have a Buildroot environment. This tutorial builds on it.

What counts as attack surface

Attack surface is anything an adversary can interact with to influence system behavior. On an embedded Linux system, it breaks down into layers.

┌──────────────────────────────────────┐
│         Network Services             │  ← ports, protocols, daemons
├──────────────────────────────────────┤
│         Userspace Binaries           │  ← suid, capabilities, writable paths
├──────────────────────────────────────┤
│         Syscall Interface            │  ← available syscalls, seccomp policy
├──────────────────────────────────────┤
│         Kernel                       │  ← modules, /proc, /sys, sysctl
├──────────────────────────────────────┤
│         Bootloader / Firmware        │  ← unsigned images, debug interfaces
└──────────────────────────────────────┘

We’ll audit top to bottom, then harden bottom to top.

Building the baseline image

Start with a Buildroot configuration that’s representative of a typical IoT device: BusyBox userspace, a network daemon, and a web interface.

cd ~/buildroot

podman run --rm -it -v "$(pwd)":/home/builder/buildroot:Z \
  -w /home/builder/buildroot buildroot-env \
  sh -c 'make qemu_arm_versatile_defconfig && make menuconfig'

Enable packages that simulate a realistic embedded system.

Target packages → BusyBox
  → Show packages that are also provided by busybox: YES

Target packages → Networking applications
  → dropbear: YES              (SSH)
  → lighttpd: YES              (web server)

Target packages → Debugging, profiling and benchmark
  → strace: YES

System configuration
  → Root password: root
  → /dev management: Dynamic using devtmpfs
  → Enable root login with password: YES

Kernel
  → (leave defaults — this gives us a full module set to audit)

Build it.

podman run --rm -v "$(pwd)":/home/builder/buildroot:Z \
  -w /home/builder/buildroot buildroot-env \
  make -j$(nproc)

Boot the image in QEMU.

qemu-system-arm \
  -M versatilepb \
  -m 256M \
  -kernel output/images/zImage \
  -dtb output/images/versatile-pb.dtb \
  -drive file=output/images/rootfs.ext4,if=scsi,format=raw \
  -append "root=/dev/sda console=ttyAMA0,115200" \
  -net nic,model=rtl8139 \
  -net user,hostfwd=tcp::2222-:22,hostfwd=tcp::8080-:80,hostfwd=tcp::1234-:1234 \
  -nographic

Log in as root. This is your baseline — the “before” snapshot.

Layer 1: Network service audit

Start from the outside in. What can a network attacker see?

Port scan from the host

nmap -sV -p- localhost -Pn --open 2>/dev/null | grep -E "^[0-9]"
22/tcp   open  ssh      Dropbear sshd 2024.84
80/tcp   open  http     lighttpd/1.4.73

Listening sockets from inside the VM

netstat -tlnp 2>/dev/null || ss -tlnp
Proto  Local Address   Foreign Address  State   PID/Program name
tcp    0.0.0.0:22      0.0.0.0:*        LISTEN  87/dropbear
tcp    0.0.0.0:80      0.0.0.0:*        LISTEN  91/lighttpd

Record everything. For each listening service, document:

ServicePortBindingRuns asConfig location
dropbear220.0.0.0root/etc/init.d/S50dropbear
lighttpd800.0.0.0root/etc/lighttpd/lighttpd.conf

Warning

Both services bind to 0.0.0.0 and run as root. On a real device, this means any network-adjacent attacker can reach them, and any vulnerability grants root. This is the default on most embedded systems.

Service version and known vulnerabilities

Check the exact versions.

dropbear -V 2>&1
lighttpd -v

Search for CVEs against those versions. On your host:

# Quick CVE check (requires internet)
curl -s "https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch=dropbear+2024" | python3 -m json.tool | head -30

Even if no CVEs exist today, each listening service is a future vulnerability waiting to happen. The question is always: does this service need to be here?

Layer 2: Filesystem audit

SUID and SGID binaries

SUID binaries run with the file owner’s privileges regardless of who executes them. They’re a classic privilege escalation vector.

find / -perm -4000 -type f 2>/dev/null
/bin/busybox
/usr/bin/passwd

BusyBox with SUID is particularly dangerous. BusyBox is a multi-call binary — hundreds of utilities in one executable. If it’s SUID root, any of those utilities run as root.

# List all busybox applets
busybox --list | wc -l
# 396

# These all run as root if busybox is suid:
busybox sh
busybox vi
busybox wget
busybox nc

Check SGID binaries too.

find / -perm -2000 -type f 2>/dev/null

World-writable directories and files

# World-writable directories
find / -type d -perm -0002 -not -path "/proc/*" -not -path "/sys/*" -not -path "/dev/*" 2>/dev/null

# World-writable files
find / -type f -perm -0002 -not -path "/proc/*" -not -path "/sys/*" -not -path "/dev/*" 2>/dev/null
/tmp
/var/tmp
/var/run
/etc/lighttpd/lighttpd.conf
/etc/dropbear
...

Writable config files are especially dangerous. If an attacker can write to lighttpd.conf, they can make the web server execute arbitrary code on next restart.

File capabilities

Linux capabilities are finer-grained than SUID but equally dangerous if over-assigned.

find / -exec getcap {} + 2>/dev/null

On a minimal Buildroot image this usually returns nothing, but on production images you’ll often find cap_net_raw on ping or cap_net_bind_service on web servers.

Sensitive files with weak permissions

# SSH keys readable by non-root?
ls -la /etc/dropbear/

# Password hashes
ls -la /etc/shadow

# Any credentials or keys in common locations
find / \( -name "*.pem" -o -name "*.key" -o -name "id_rsa" -o -name "*.conf" \) 2>/dev/null | \
  xargs ls -la 2>/dev/null

Layer 3: Kernel attack surface

The kernel is the largest and most privileged component. Every loaded module, every /proc and /sys entry, and every enabled syscall is potential attack surface.

Loaded kernel modules

lsmod
Module                  Size  Used by
rtl8139cp              28672  0
8139too                32768  0
mii                    16384  2 rtl8139cp,8139too
...

Each loaded module adds code running in ring 0. Modules for hardware not present on the device are pure unnecessary attack surface.

# Count loaded modules
lsmod | wc -l

# Total size of loaded module code
lsmod | awk 'NR>1 {sum+=$2} END {print sum/1024 " KB"}'

Kernel configuration

Check which features are compiled in.

# If /proc/config.gz exists
zcat /proc/config.gz | grep -E "^CONFIG_" | wc -l

# Security-relevant options
zcat /proc/config.gz | grep -iE "(SECURITY|SELINUX|APPARMOR|SECCOMP|STACKPROTECTOR|FORTIFY)"
CONFIG_SECCOMP=y
CONFIG_STACKPROTECTOR=y
CONFIG_FORTIFY_SOURCE=y
# CONFIG_SECURITY_SELINUX is not set
# CONFIG_SECURITY_APPARMOR is not set

Note

Most embedded kernels ship without SELinux or AppArmor. This means there’s no mandatory access control — any process running as root has full system access, and any privilege escalation from a non-root process gives the attacker everything.

Exposed kernel interfaces

# /proc entries that leak information
ls /proc/kallsyms 2>/dev/null && echo "kallsyms exposed"
cat /proc/sys/kernel/kptr_restrict
# 0 = kernel addresses visible to all users

# Writable sysctl values
sysctl -a 2>/dev/null | wc -l

# /sys entries
find /sys -writable 2>/dev/null | head -20

/proc/kallsyms with kptr_restrict=0 gives an attacker a complete map of kernel symbol addresses — KASLR becomes useless.

Layer 4: Syscall exposure

Every available syscall is an entry point into the kernel. A web server doesn’t need ptrace, mount, or kexec_load, but by default every syscall is available to every process.

Cataloging available syscalls

# List all syscalls this kernel supports
cat /proc/kallsyms | grep " T sys_" | wc -l
# or
ausyscall --dump 2>/dev/null | wc -l

On a typical embedded kernel, 300-400 syscalls are available. A minimal web server needs maybe 40.

Writing a seccomp-bpf profile

Seccomp-bpf restricts which syscalls a process can make. Create a profile for lighttpd.

First, trace what syscalls lighttpd actually uses.

# On the target
strace -f -c lighttpd -D -f /etc/lighttpd/lighttpd.conf 2>&1

Let it run for a minute, handle a few requests from the host (curl http://localhost:8080/), then kill it. The -c flag summarizes syscall usage.

% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- -------
 28.57    0.000400          20        20           read
 21.43    0.000300          15        20           write
 14.29    0.000200          10        20           close
  7.14    0.000100          10        10           openat
  7.14    0.000100          10        10           fstat
  7.14    0.000100          50         2           accept4
  ...

Now write a starter seccomp policy. Create seccomp_lighttpd.c on your host.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <sys/prctl.h>
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <linux/audit.h>
#include <stddef.h>

#ifndef SECCOMP_RET_LOG
#define SECCOMP_RET_LOG SECCOMP_RET_ALLOW
#endif

/* Starter policy: allow core syscalls and log everything else while tuning */
static struct sock_filter filter[] = {
    /* Ensure this filter is only used on ARM */
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, AUDIT_ARCH_ARM, 1, 0),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),

    /* Load syscall number */
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
             offsetof(struct seccomp_data, nr)),

    /* Allow list */
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read,        0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write,       0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_close,       0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat,      0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_fstat64,      0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_accept4,     0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_socket,      0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_bind,        0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_listen,      0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_epoll_ctl,   0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_epoll_wait,  0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_mmap2,       0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_brk,         0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_futex,       0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_rt_sigaction, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_rt_sigprocmask, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_clock_gettime, 0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_getpid,      0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit,        0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_exit_group,  0, 1),
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

    /* During profiling, log unknown syscalls but allow them */
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_LOG),
};

int main() {
    struct sock_fprog prog = {
        .len = sizeof(filter) / sizeof(filter[0]),
        .filter = filter,
    };

    if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
        perror("prctl(PR_SET_NO_NEW_PRIVS)");
        return 1;
    }

    if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog) != 0) {
        perror("prctl(PR_SET_SECCOMP)");
        return 1;
    }

    /* Replace this with execve of the target process */
    execl("/usr/sbin/lighttpd", "lighttpd",
          "-D", "-f", "/etc/lighttpd/lighttpd.conf", NULL);

    perror("execl");
    return 1;
}

Tip

Use SECCOMP_RET_LOG first This sample defaults to SECCOMP_RET_LOG for unknown syscalls so the service keeps running while you tune the allowlist. After your dmesg logs are clean, change the final return action to SECCOMP_RET_KILL (or SECCOMP_RET_ERRNO) to enforce the policy.

Cross-compile and test it. Place the source in the Buildroot directory so the container can see it.

cp seccomp_lighttpd.c ~/buildroot/

podman run --rm -v ~/buildroot:/home/builder/buildroot:Z \
  -w /home/builder/buildroot buildroot-env \
  sh -c '
    CROSS="$(find output/host/bin -maxdepth 1 -type f -name "*-gcc" | head -1 | sed "s/gcc$//")"
    ${CROSS}gcc -o seccomp_lighttpd seccomp_lighttpd.c -static
  '

scp -P 2222 ~/buildroot/seccomp_lighttpd root@localhost:/root/

Hardening the image

Now apply fixes for everything the audit found. Modify the Buildroot configuration and add overlay files.

Network services

Bind services to specific interfaces and drop privileges.

# /etc/lighttpd/lighttpd.conf
# QEMU user-mode networking guest IP (replace on real hardware)
server.bind = "10.0.2.15"

# Drop privileges after binding
server.username = "www-data"
server.groupname = "www-data"

For dropbear, restrict to key-based auth and bind to a management interface.

# /etc/init.d/S50dropbear - modify DROPBEAR_ARGS
DROPBEAR_ARGS="-w -s -p 10.0.2.15:22"
# -w: disallow root login
# -s: disable password auth

Warning

-w -s requires a non-root account with SSH keys already provisioned. If you haven’t created that account yet, keep serial console access open so you don’t lock yourself out.

Filesystem hardening

Remove SUID from BusyBox and restrict writable paths.

Create a Buildroot post-build script. In menuconfig:

System configuration → Custom scripts to run after building: board/qemu/post-build.sh

Create board/qemu/post-build.sh:

#!/bin/bash
TARGET=$1

# Create dedicated unprivileged service account for lighttpd
grep -q '^www-data:' "${TARGET}/etc/group" || echo 'www-data:x:81:' >> "${TARGET}/etc/group"
grep -q '^www-data:' "${TARGET}/etc/passwd" || echo 'www-data:x:81:81:www-data:/var/www:/bin/false' >> "${TARGET}/etc/passwd"
mkdir -p "${TARGET}/var/www"
chown 81:81 "${TARGET}/var/www"

# Remove SUID from busybox
chmod u-s "${TARGET}/bin/busybox"

# Restrict sensitive directories
chmod 700 "${TARGET}/etc/dropbear"
chmod 600 "${TARGET}/etc/shadow"

# Remove unnecessary suid
find "${TARGET}" -perm -4000 -exec chmod u-s {} \;

# Make /etc read-only where possible
# (lighttpd.conf should not be writable at runtime)
chmod 444 "${TARGET}/etc/lighttpd/lighttpd.conf"

# Remove development artifacts
rm -f "${TARGET}"/usr/lib/*.a         # static libraries
rm -rf "${TARGET}/usr/include"        # headers
rm -rf "${TARGET}/usr/share/man"      # man pages

# Restrict /proc and /sys mount options (added to fstab)
cat >> "${TARGET}/etc/fstab" << 'EOF'
proc  /proc  proc  nosuid,nodev,noexec,hidepid=2  0  0
EOF

echo "Post-build hardening applied."
chmod +x board/qemu/post-build.sh

Kernel hardening

Create a kernel config fragment to enable security features and disable unnecessary ones.

mkdir -p board/qemu/linux-config-fragments
cat > board/qemu/linux-config-fragments/hardening.cfg << 'EOF'
# Disable kernel module loading at runtime
# CONFIG_MODULES is not set

# Restrict dmesg to root
CONFIG_SECURITY_DMESG_RESTRICT=y

# Hide kernel symbols from non-root
CONFIG_KALLSYMS=y
# (set kptr_restrict via sysctl instead)

# Stack protection
CONFIG_STACKPROTECTOR=y
CONFIG_STACKPROTECTOR_STRONG=y

# Harden memory allocator
CONFIG_SLAB_FREELIST_RANDOM=y
CONFIG_SLAB_FREELIST_HARDENED=y

# Harden copy to/from userspace
CONFIG_FORTIFY_SOURCE=y
CONFIG_HARDENED_USERCOPY=y

# Disable kexec (prevents loading unsigned kernels at runtime)
# CONFIG_KEXEC is not set

# Restrict unprivileged eBPF
CONFIG_BPF_UNPRIV_DEFAULT_OFF=y

# Restrict userfaultfd to root
CONFIG_USERFAULTFD=y

# Restrict perf
CONFIG_SECURITY_PERF_EVENTS_RESTRICT=y
EOF

In Buildroot menuconfig:

Kernel → Linux Kernel → Additional configuration fragment files:
  board/qemu/linux-config-fragments/hardening.cfg

Sysctl hardening

Create a sysctl config in the filesystem overlay.

mkdir -p board/qemu/rootfs-overlay/etc/sysctl.d/
cat > board/qemu/rootfs-overlay/etc/sysctl.d/hardening.conf << 'EOF'
# Hide kernel pointers
kernel.kptr_restrict = 2

# Restrict dmesg
kernel.dmesg_restrict = 1

# Disable SysRq
kernel.sysrq = 0

# ASLR (full randomization)
kernel.randomize_va_space = 2

# Restrict ptrace
kernel.yama.ptrace_scope = 2

# Network hardening
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.all.accept_source_route = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.tcp_syncookies = 1
EOF

In menuconfig, set the overlay directory:

System configuration → Root filesystem overlay directories:
  board/qemu/rootfs-overlay

Rebuild.

podman run --rm -v ~/buildroot:/home/builder/buildroot:Z \
  -w /home/builder/buildroot buildroot-env \
  make -j$(nproc)

Measuring the difference

Boot the hardened image and re-run every audit check. Track the results side by side.

Network surface

MetricBeforeAfter
Listening ports2 (0.0.0.0)2 (specific interface)
Services as root21 (lighttpd dropped, dropbear remains root)
Password authEnabledDisabled

Filesystem surface

MetricBeforeAfter
SUID binaries20
World-writable config files30
Static libraries on target120
/proc hidepidNoYes (hidepid=2)

Kernel surface

MetricBeforeAfter
Loadable modules~300 (monolithic)
kallsyms visible to usersYesNo (kptr_restrict=2)
dmesg visible to usersYesNo
ptrace scope0 (unrestricted)2 (admin only)
kexec availableYesNo

Syscall surface

MetricBeforeAfter
Syscalls available to lighttpd~380Tuned allowlist (typically far fewer)
ptrace usable by www-dataYesNo
mount usable by www-dataYesNo

Insight

The compound effect No single hardening measure is sufficient. An attacker who bypasses seccomp still faces a monolithic kernel with no module loading. An attacker who finds a kernel vulnerability still can’t read kallsyms to locate gadgets. Defense in depth means each layer makes the next layer’s weaknesses harder to reach.

Building an audit script

Automate the audit so you can run it against any image. Create audit.sh.

#!/bin/bash
echo "=== Embedded Linux Attack Surface Audit ==="
echo "Date: $(date)"
echo "Hostname: $(hostname)"
echo "Kernel: $(uname -r)"
echo ""

echo "--- Network Services ---"
ss -tlnp 2>/dev/null || netstat -tlnp
echo ""

echo "--- SUID Binaries ---"
find / -perm -4000 -type f 2>/dev/null
echo ""

echo "--- SGID Binaries ---"
find / -perm -2000 -type f 2>/dev/null
echo ""

echo "--- World-Writable Files (non /proc,sys,dev) ---"
find / -type f -perm -0002 -not -path "/proc/*" -not -path "/sys/*" \
  -not -path "/dev/*" -not -path "/tmp/*" 2>/dev/null
echo ""

echo "--- File Capabilities ---"
find / -exec getcap {} + 2>/dev/null
echo ""

echo "--- Loaded Kernel Modules ---"
lsmod 2>/dev/null || echo "No module support"
echo ""

echo "--- Kernel Security Config ---"
for param in kptr_restrict dmesg_restrict randomize_va_space yama/ptrace_scope; do
  val=$(cat /proc/sys/kernel/$param 2>/dev/null)
  echo "  kernel.$param = ${val:-N/A}"
done
echo ""

echo "--- Seccomp Available ---"
grep -c Seccomp /proc/1/status 2>/dev/null && echo "  Yes" || echo "  No"
echo ""

echo "--- Total Userspace Binaries ---"
find /usr/bin /usr/sbin /bin /sbin -type f 2>/dev/null | wc -l
echo ""

echo "=== Audit Complete ==="

Run it before and after hardening, diff the output, and you have a documented security posture change.

# Before hardening
sh audit.sh > /tmp/audit_before.txt

# After hardening (on the new image)
sh audit.sh > /tmp/audit_after.txt

diff /tmp/audit_before.txt /tmp/audit_after.txt

Limitations and next steps

This audit covers the most common attack surface categories, but it’s not exhaustive.

What we didn’t cover:

  • Hardware debug interfaces — JTAG, UART, SWD pins that bypass all software security; these require physical access to assess
  • Bootloader security — U-Boot without verified boot allows arbitrary kernel loading; configuring a signed boot chain (HAB fuses, FIT image signing, dm-verity) is a separate topic
  • Supply chain — Buildroot packages are pulled from upstream sources; verifying integrity of the toolchain and packages is a separate problem
  • Application-layer bugs — SQL injection in the web UI, command injection in CGI scripts, authentication bypasses; these require code review, not system audit

Where to go from here:

  • Apply this methodology to real firmware extracted from a device — the firmware extraction tutorial shows how to get the filesystem
  • Automate the audit in CI — run it against every Buildroot build and fail the pipeline if SUID binaries or open ports appear
  • Write seccomp profiles for every daemon, not just the web server
  • Add a read-only root filesystem (squashfs) and tmpfs overlays for runtime state