DEV Community

Arturo Enrique Mata Garcia
Arturo Enrique Mata Garcia

Posted on

Lynx: a passive, read-only security auditor for Debian and Ubuntu

Some servers I look after run firewalls generated years ago by Firewall Builder (fwbuilder), on distros where iptables quietly became a front end for nftables. When I need a quick answer to "is this box reasonably configured?", I don't want to install a framework, and I definitely don't want a tool that changes things while it looks.

So I wrote Lynx: a single Bash script that audits a Debian or Ubuntu host in read-only mode, prints a scored report, and saves hash-verified evidence for whoever has to review it.

Lynx is not a compliance tool, and I'll be clear about that below. It's a fast, readable first pass.

TL;DR

  • Lynx audits a Debian or Ubuntu host in read-only mode: 29 scored controls, a 0-100 score, and SHA-256 hashed evidence.
  • It understands iptables-legacy, iptables-nft, nftables and fwbuilder-generated rules.
  • On a real Debian 13 dev server it scored 47/100. An earlier build had scored 56, and the difference was a bug that let Docker's rules pass for a host firewall.
  • It is a fast first pass, not a compliance scanner.

    What Lynx is

  • One Bash script, no dependencies beyond what Debian/Ubuntu already ship (awk, grep, sed, find, stat).

  • Needs root, because it reads /etc/shadow, firewall rulesets and kernel settings.

  • Passive: it never edits configuration, installs, or removes packages. It doesn't even run apt update, because that would be an active action.

  • Apache-2.0 licensed.

    What it checks

Six sections add up to a 100-point score across 29 controls. A seventh section is informational and doesn't score.

# Section Weight Examples
1 Kernel (sysctl) 30 SYN cookies, ICMP redirects, source routing, rp_filter, kptr_restrict, ptrace_scope, ASLR
2 Network stack 10 conntrack, IP forwarding, bridge-nf-call-iptables
3 Firewall 25 Rules on the INPUT path, stateful rule, default-deny on INPUT/FORWARD, IPv6
4 SSH and hardening 15 PermitRootLogin, PasswordAuthentication, blocked legacy modules, core dumps
5 Users 10 Single UID 0, no empty passwords, system accounts without login shells
6 Permissions 10 /etc/shadow, /etc/passwd, world-writable directories
7 Info (no score) – Listening ports, sudoers NOPASSWD, pending security updates, mount options, SUID inventory

Each control returns PASS, FAIL, or N/A. N/A means "doesn't apply here" (IPv6 disabled, no sshd installed) and awards the points so you aren't penalized for attack surface you don't have.

Design decisions worth explaining

Read sysctl from /proc/sys. Reading the files directly means Lynx doesn't depend on the sysctl binary or on PATH being sane under su.

Look at all three firewall backends. On modern Debian, iptables may point to iptables-nft, and rules loaded in legacy and in nft don't see each other. Lynx reads iptables-legacy-save, iptables-nft-save and nft list ruleset, and warns if rules live in more than one place.

Understand fwbuilder output. fwbuilder generates -m state --state ESTABLISHED,RELATED rather than -m conntrack --ctstate, and often ends INPUT with a jump to a user chain that finishes in DROP. Lynx recognizes both styles and follows jumps (up to a depth limit) to find the final deny.

Evidence you can verify. Raw dumps (rulesets, sshd -T, sudoers, listening ports, a copy of any .fw scripts found) go to lynx_evidencias_<timestamp>/ with a SHA256SUMS manifest. The report gets its own .sha256. The hash is computed after the report is finished, so it can't change the thing it protects.

Putting it to the test on a real Debian 13 dev server

Tests with simulated dumps prove that the parsers work. They don't prove the tool says anything useful about a messy, real machine. So I ran Lynx on a development server I'm free to poke at.

The test machine

  • Debian GNU/Linux 13 (trixie), kernel 6.12.96, amd64
  • Docker installed: one bridge (docker0) and a container published on port 3000
  • OpenSSH on 22, a Python service on 3001, avahi-daemon, and dhcpcd on several interfaces
  • iptables is the nft variant (/usr/sbin/xtables-nft-multi), no legacy rules, no fwbuilder, and ufw enabled as a systemd service
  • A typical dev box: working, but never through a hardening pass One note on the output below: I kept it verbatim, and the report is in Spanish. The firewall labels read, in order: rules loaded, stateful rule, INPUT default-deny, FORWARD default-deny, IPv6 filtered.

First run: 56/100

[PASS] +5 pts | Firewall rules loaded into the kernel (any backend)
[PASS] +7 pts | Stateful ESTABLISHED,RELATED rule (conntrack, state, or nft ct state)
[FAIL]   0 pts | INPUT with DROP/REJECT policy or final deny rule
[PASS] +3 pts | FORWARD with DROP/REJECT policy (or forwarding disabled)
[PASS] +3 pts | IPv6 filtered by firewall (or IPv6 disabled)
...
 AUDIT SCORE: 56 / 100 (56%)  -  29 controls evaluated
Enter fullscreen mode Exit fullscreen mode

Four passes and one failure reads like a firewall that mostly works. It wasn't. The same report listed nine sockets listening on all interfaces, SSH among them, and the INPUT check was the only line telling the truth.

Why 56 was too generous

Docker installs its own rules in the FORWARD path, including an ESTABLISHED,RELATED accept. My checks searched any chain for those rules, so Docker's traffic rules satisfied "the host has a stateful firewall". That's a false positive that makes a machine look safer than it is, which is the worst kind of bug in a security tool.

The fix was to evaluate only chains reachable from INPUT:

# Chains reachable from a starting chain (follows -j / -g into user chains)
ipt_reach() {   # $1 = iptables-save dump (filter table), $2 = starting chain
    awk -v start="$2" '
        $1=="-A" { for (i=3;i<=NF;i++) if ($i=="-j" || $i=="-g") { n++; from[n]=$2; to[n]=$(i+1) } }
        END {
            seen[start]=1
            do { changed=0
                 for (k=1;k<=n;k++) if ((from[k] in seen) && !(to[k] in seen)) { seen[to[k]]=1; changed=1 }
            } while (changed)
            for (c in seen) print c
        }' <<<"$1"
}
Enter fullscreen mode Exit fullscreen mode

The stateful and "rules present" checks now only count rules in INPUT or in chains INPUT jumps to, which is how UFW's ufw-before-input and fwbuilder's RULE_N chains work. The nftables side does the same, starting from chains with hook input and following jump and goto.

I validated this against 25 simulated cases: Docker alone, UFW active, UFW plus Docker, fwbuilder with a direct rule, fwbuilder via a RULE_1 chain, native nftables with sets, and an empty ruleset. That exercise also exposed a bug in my first nft parser, which treated a rule beginning with counter packets … as a named-object declaration and lost track of the chain.

Two more checks needed the same kind of honesty. bridge-nf-call-iptables now ignores bridges created by Docker, libvirt, LXD, CNI and Podman. And rp_filter is evaluated the way the kernel does it: the maximum of the all value and the per-interface value, not all alone.

Second run: 47/100

Same machine, corrected build:

[FAIL]   0 pts | Firewall rules filtering incoming traffic (INPUT)
[FAIL]   0 pts | Stateful rule ESTABLISHED,RELATED (conntrack, state, or nft ct state)
[FAIL]   0 pts | INPUT with DROP/REJECT policy or final deny rule
[PASS] +3 pts | FORWARD with DROP/REJECT policy (or forwarding disabled)
[FAIL]   0 pts | IPv6: inbound traffic filtered by firewall (or IPv6 disabled)
...
 AUDIT SCORE: 47 / 100 (47%)  -  29 controls evaluated
Enter fullscreen mode Exit fullscreen mode

The score went down by nine points, and the arithmetic is fully explained by five controls:

Control First build Second build Points
rp_filter (effective, per interface) FAIL PASS +3
bridge-nf-call-iptables FAIL N/A (only docker0) +3
Rules on the INPUT path PASS FAIL −5
Stateful rule on INPUT PASS FAIL −7
IPv6 inbound filtering PASS FAIL −3
Total 56 47 −9

Two corrections raised the score because the old checks were too strict. Three lowered it because they were too lenient. The net effect is a lower number and a more truthful report.

The backends section shows what was really loaded:

- iptables binary: /usr/sbin/xtables-nft-multi
- iptables-legacy rules: 0 (IPv4) / 0 (IPv6)
- iptables-nft rules: 15 (IPv4) / 7 (IPv6)
- nftables (ruleset) : with rules
Enter fullscreen mode Exit fullscreen mode

So there are 15 IPv4 and 7 IPv6 rules loaded, but Lynx finds none reachable from INPUT and no default-deny on it. The "persistence" block lists ufw as enabled in systemd. That only means the service starts at boot, not that it enforces a policy, which is exactly why Lynx reports persistence as information rather than as a pass.

What the rest of the report says about the box

  • Exposure. 18 listening sockets, 9 of them bound to all interfaces: OpenSSH (22, IPv4 and IPv6), the Python service (3001), docker-proxy (3000, IPv4 and IPv6), and avahi-daemon (mDNS on 5353 plus random UDP ports). mDNS on a server is rarely needed.
  • SSH. Both the PermitRootLogin and the PasswordAuthentication checks failed. With port 22 reachable on every interface and no inbound filter, that is the combination that deserves attention first.
  • Kernel. Failures: ICMP redirects (IPv4 and IPv6), send_redirects, kptr_restrict, ptrace_scope, and IP forwarding (most likely because of Docker). Passes: SYN cookies, source routing, rp_filter, dmesg_restrict, ASLR and suid_dumpable.
  • Patching. 106 packages upgradable, 36 of them security updates, and no unattended-upgrades. The apt cache was 0 days old, so the count is trustworthy.
  • Mount options. /tmp and /dev/shm are separate mounts but lack noexec. /var/tmp and /home inherit from / and lack nodev, nosuid and noexec.
  • Files. 49 SUID/SGID binaries, no world-writable files, and 22,273 files with no owner or group. I haven't investigated that last number; on a Docker host I would start with container volumes and image layers.
  • Good news. AppArmor active, NTP synchronized, journald persistent, a single UID 0, no empty passwords, correct permissions on /etc/shadow and /etc/passwd, no legacy network services installed (telnet, rsh, NIS, tftp, xinetd), and no world-writable directories without the sticky bit. The final level was LOW (47%), which I think is a fair description of an unhardened dev server.

Cross-checking Lynx's findings

A report is only worth trusting if you can verify it by hand. These commands reproduce the key findings, and the raw material is also in the evidence directory:

sudo ufw status verbose                          # is UFW actually enforcing anything?
sudo iptables -S INPUT                           # INPUT policy and rules (nft backend)
sudo nft list ruleset | grep -A3 'hook input'    # base chains on the input hook
ss -tulpn                                        # what is listening, and who owns it
sysctl -a | grep rp_filter                       # per-interface rp_filter values
Enter fullscreen mode Exit fullscreen mode

What I took from this

  1. A score is only as honest as its scoping. Counting rules from any chain made Docker's traffic rules look like a host firewall. Scoping the check to what the inbound path can actually reach fixed it.
  2. Simulated tests and real runs catch different bugs. The simulations found a parser bug. Only the real machine showed that a perfectly working check was answering the wrong question.
  3. A lower score is not a regression. After the fix the number was worse and the report was better. ## Try it
git clone https://github.com/<your-username>/lynx.git
cd lynx
less lynx.sh          # please read it before running anything as root
chmod +x lynx.sh
sudo ./lynx.sh
Enter fullscreen mode Exit fullscreen mode

Reports go to /var/lib/lynx/<hostname>/ by default. Useful variables:

sudo REPORT_DIR=/srv/audits ./lynx.sh        # custom output directory
sudo LYNX_FLAT=1 REPORT_DIR=/srv/audits ./lynx.sh   # no per-host subdirectory
sudo ES_ROUTER=1 ./lynx.sh                   # host routes on purpose: forwarding isn't a failure
sudo SKIP_SLOW=1 ./lynx.sh                   # skip the slow find-based inventories
sudo SIN_EVIDENCIAS=1 ./lynx.sh              # report only, no raw evidence
Enter fullscreen mode Exit fullscreen mode

One naming note: Debian and Ubuntu already have a text-mode web browser called lynx. If you install the script system-wide, give it a different name:

sudo install -m 0750 -o root -g root lynx.sh /usr/local/sbin/lynx-audit
Enter fullscreen mode Exit fullscreen mode

To verify an audit later:

sha256sum -c lynx_reporte_<timestamp>_<pid>.txt.sha256
cd lynx_evidencias_<timestamp>_<pid> && sha256sum -c SHA256SUMS
Enter fullscreen mode Exit fullscreen mode

Limitations

  • Narrow coverage. 29 scored controls is a small slice of something like a CIS Benchmark. It doesn't cover filesystem layout, PAM and password policy, most sshd options, auditd or integrity monitoring. Some checks resemble CIS recommendations but differ; for example, Lynx accepts PermitRootLogin prohibit-password, while CIS requires no. For formal compliance, use OpenSCAP or CIS-CAT.
  • Small validation base. One real Debian 13 development server (the two runs described above), an Ubuntu 24.04 environment, and simulated firewall dumps for everything else. Treat it as young software and try it on a test machine first.
  • Static sshd fallback. If sshd -T fails, Lynx reads the config files directly and doesn't interpret Match blocks.
  • Patch counts depend on apt's cache. Lynx warns when the cache is old.
  • fwbuilder detection is heuristic. It looks for the "Firewall Builder" marker in *.fw files and boot scripts.
  • The report is in Spanish. The script grew up in a Spanish-speaking environment; translations and an i18n layer would be a welcome contribution.

Who it's for

Sysadmins and infrastructure teams who want a fast, repeatable first look at Debian/Ubuntu servers. Auditors who need evidence with checksums. Anyone migrating from iptables-legacy or fwbuilder toward nftables who needs to see what is really loaded. Students who want a readable example of how these checks work.

It is not meant to replace a compliance scanner, a penetration test, or your own judgment about what "secure" means for your environment.

Contribute

Lynx is Apache-2.0. Issues and pull requests are welcome. Before sending one, run bash -n lynx.sh and shellcheck lynx.sh, and keep new controls read-only so the "passive" promise stays true.

If you run it and find a false positive or a check that misreads your setup, I want to hear about it. The Docker bug above only showed up because someone (me) ran the script on a real machine.

🔗 Useful Links & Documentation

  curl -fsSLO https://raw.githubusercontent.com/matarturo/lynx/main/lynx.sh
  chmod +x lynx.sh
  sudo ./lynx.sh
Enter fullscreen mode Exit fullscreen mode

Si el directorio está en un sistema de archivos montado con noexec, ejecútalo con sudo bash lynx.sh.


Conclusion & Call to Action

Auditing modern Linux systems reveals a recurring technical limitation in generic tools: the superficial handling of hybrid network backends (iptables-legacy, iptables-nft, and nftables) and a tendency toward false positives—such as erroneously assuming that container forwarding (FORWARD) rules protect host traffic. Addressing this requires reachability analysis at the parsing level that traces jumps (-j, -g) directly from INPUT chains, reads kernel state natively from /proc/sys to avoid path dependencies, and evaluates complex metrics like the effective rp_filter per interface. Reliable infrastructure assessment requires auditing actual socket exposure across all interfaces, reviewing restrictive mount options (noexec, nodev, nosuid), correlating pending security patches, and generating verifiable evidence via SHA-256 cryptographic hashes without altering the operating system's state.

Technical actions to improve infrastructure security posture:

  • Ingress-plane reachability validation: Do not rely on declarative service persistence (such as active UFW or systemd states); verify via parsing that default-deny and filtering rules effectively reside in the INPUT path and are not bypassed by traffic from virtual network bridges or hypervisors.

  • Exposure and attack surface control: Conduct passive audits to detect globally listening sockets (services on ports 22, 3000, 3001, mDNS, etc.), permissive sshd hardening parameters, and the inventory of SUID/SGID binaries or ownerless files. Guarantee of evidence immutability: Ensure that any control script or routine calculates hash manifests (SHA-256) after report consolidation to preserve forensic traceability for configuration analysis and configuration drift detection.

Clone the repository, test it against your servers, and see what your current scanner has been missing. Drop a star, open an issue, or share your feedback

Top comments (0)