# Embedded Linux Attack Surface Analysis

> Build a stripped embedded Linux image with Buildroot, then audit open ports, syscalls, suid binaries, kernel modules, and writable paths, and harden it.

- Published: 2026-01-09
- Updated: 2026-07-10
- Difficulty: intermediate
- Series: Embedded Systems & Firmware
- Tags: Embedded, Security, Linux, Hardening
- Source: https://stevenfoerster.com/tutorials/attack-surface-analysis-of-a-minimal-embedded-linux-image/

## 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

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 is to build the skill of _seeing_ attack surface the way an attacker does; security depends on context, so no image is simply "secure."

If you've followed the [cross-compiling tutorial](https://stevenfoerster.com/tutorials/cross-compiling-and-debugging-with-buildroot-qemu-and-gdb/), 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.

```text
┌──────────────────────────────────────┐
│         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
└──────────────────────────────────────┘
```

```mermaid
graph TD
    A[Bootloader] --> B[Kernel]
    B --> C[Init System]
    C --> D[Services]
    D --> E[Network Interfaces]
    A -.- F{{Unsigned Images}}
    B -.- G{{Modules / Syscalls}}
    C -.- H{{SUID / Permissions}}
    D -.- I{{Open Ports}}
    E -.- J{{Exposed Protocols}}
    style F fill:#c62828,color:#fff
    style G fill:#c62828,color:#fff
    style H fill:#c62828,color:#fff
    style I fill:#c62828,color:#fff
    style J fill:#c62828,color:#fff
```

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.

```bash
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.

```text
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.

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

Boot the image in QEMU.

```bash
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

```bash
nmap -sV -p 2222,8080,1234 localhost -Pn --open
```

```text
PORT     STATE SERVICE VERSION
2222/tcp open  ssh     Dropbear sshd 2024.84
8080/tcp open  http    lighttpd 1.4.73
```

QEMU is translating the host ports to guest ports 22, 80, and 1234. Scan the host-side forwarded ports, then use the in-guest socket inventory below to record the actual guest bindings. A full scan of `localhost` cannot discover guest port 22 because QEMU exposes it as host port 2222.

### Listening sockets from inside the VM

```bash
netstat -tlnp 2>/dev/null || ss -tlnp
```

```text
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:

| Service  | Port | Binding | Runs as | Config location             |
| -------- | ---- | ------- | ------- | --------------------------- |
| dropbear | 22   | 0.0.0.0 | root    | /etc/init.d/S50dropbear     |
| lighttpd | 80   | 0.0.0.0 | root    | /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.

```bash
dropbear -V 2>&1
lighttpd -v
```

Search for CVEs against those versions. On your host:

```bash
# 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.

```bash
find / -perm -4000 -type f 2>/dev/null
```

```text
/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.

```bash
# 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.

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

### World-writable directories and files

```bash
# 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
```

```text
/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.

```bash
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

```bash
# 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

```bash
lsmod
```

```text
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.

```bash
# 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.

```bash
# 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)"
```

```text
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

```bash
# /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 (Kernel Address Space Layout Randomization) 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

```bash
# 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. Despite sharing the BPF name with libpcap-style packet filters, a seccomp filter is a different kind of BPF program: instead of inspecting network packets, it runs over a `seccomp_data` structure and decides, per call, what to do based on the syscall number and its arguments. The program is built from raw BPF instructions (`BPF_STMT`/`BPF_JUMP`) that load the syscall number, compare it against an allowlist, and return an action such as `SECCOMP_RET_ALLOW` or `SECCOMP_RET_KILL`. Create a profile for lighttpd.

First, trace what syscalls lighttpd actually uses.

```bash
# 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.

```text
% 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.

```c
#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.

```bash
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.

```bash
# /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.

```bash
# /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:

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

Create `board/qemu/post-build.sh`:

```bash
#!/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."
```

```bash
chmod +x board/qemu/post-build.sh
```

### Kernel hardening

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

```bash
mkdir -p board/qemu/linux-config-fragments
```

```bash
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

# Enable userfaultfd; the sysctl below limits unprivileged use to user-mode faults
CONFIG_USERFAULTFD=y

# Restrict perf
CONFIG_SECURITY_PERF_EVENTS_RESTRICT=y
EOF
```

In Buildroot menuconfig:

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

### Sysctl hardening

Create a sysctl config in the filesystem overlay.

```bash
mkdir -p board/qemu/rootfs-overlay/etc/sysctl.d/
```

```bash
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

# Restrict unprivileged userfaultfd to handling user-mode faults only
vm.unprivileged_userfaultfd = 0

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

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

Rebuild.

```bash
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

| Metric           | Before      | After                                       |
| ---------------- | ----------- | ------------------------------------------- |
| Listening ports  | 2 (0.0.0.0) | 2 (specific interface)                      |
| Services as root | 2           | 1 (lighttpd dropped, dropbear remains root) |
| Password auth    | Enabled     | Disabled                                    |

### Filesystem surface

| Metric                      | Before | After           |
| --------------------------- | ------ | --------------- |
| SUID binaries               | 2      | 0               |
| World-writable config files | 3      | 0               |
| Static libraries on target  | 12     | 0               |
| /proc hidepid               | No     | Yes (hidepid=2) |

### Kernel surface

| Metric                    | Before           | After                |
| ------------------------- | ---------------- | -------------------- |
| Loadable modules          | ~30              | 0 (monolithic)       |
| kallsyms visible to users | Yes              | No (kptr_restrict=2) |
| dmesg visible to users    | Yes              | No                   |
| ptrace scope              | 0 (unrestricted) | 2 (admin only)       |
| kexec available           | Yes              | No                   |

### Syscall surface

| Metric                         | Before | After                                 |
| ------------------------------ | ------ | ------------------------------------- |
| Syscalls available to lighttpd | ~380   | Tuned allowlist (typically far fewer) |
| ptrace usable by www-data      | Yes    | No                                    |
| mount usable by www-data       | Yes    | No                                    |

> [!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`.

```bash
#!/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.

```bash
# 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](https://stevenfoerster.com/tutorials/firmware-extraction-and-reverse-engineering-with-binwalk-and-ghidra/) 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
