spec(hardening-test-plan): fix four bugs surfaced by executor

Four corrections noted by the test plan's executor in commit 461b8d0:

- PID-lookup race: pgrep+head can pick the wrong instance. Replace
  with systemctl show -p MainPID --value left4me-server@N.service.
- gdb-from-host ptrace check: nsenter into only the mount namespace
  with root caps bypasses the SECCOMP filter, so the test is a false
  positive. Replace with systemd-run-with-same-directives probe, or
  syscall-filter inspection.
- D5 pgrep pattern: 'srcds_linux.*\@2' doesn't match because @N is
  in the unit name, not argv. Use systemctl show -p MainPID.
- scmp_sys_resolver is in the seccomp package on Debian 13, not
  libseccomp-dev.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-15 14:58:46 +02:00
parent 8e678b6765
commit 37309ba399
No known key found for this signature in database

View file

@ -132,7 +132,7 @@ pgrep -af srcds_linux
# Expect: at least one PID matching left4dead2 # Expect: at least one PID matching left4dead2
# 4. From inside the unit's namespace: process appears as configured uid # 4. From inside the unit's namespace: process appears as configured uid
PID=$(pgrep -f 'srcds_linux.*left4dead2' | head -1) PID=$(systemctl show -p MainPID --value left4me-server@1.service)
sudo cat /proc/$PID/status | grep -E '^Uid|^Gid' sudo cat /proc/$PID/status | grep -E '^Uid|^Gid'
# Expect: uid 980 (left4me) — outside the namespace, the kernel reports # Expect: uid 980 (left4me) — outside the namespace, the kernel reports
# the unit's User=. Inside the namespace it's also 980 (identity map). # the unit's User=. Inside the namespace it's also 980 (identity map).
@ -194,7 +194,7 @@ sudo systemctl restart left4me-server@1
sudo systemctl is-active left4me-server@1 sudo systemctl is-active left4me-server@1
# 2. From inside the unit's namespace: invisible files # 2. From inside the unit's namespace: invisible files
PID=$(pgrep -f 'srcds_linux.*left4dead2' | head -1) PID=$(systemctl show -p MainPID --value left4me-server@1.service)
sudo nsenter --target $PID --mount -- ls -la /var/lib/left4me/left4me.db 2>&1 sudo nsenter --target $PID --mount -- ls -la /var/lib/left4me/left4me.db 2>&1
# Expect: No such file or directory # Expect: No such file or directory
@ -328,9 +328,28 @@ sudo journalctl -u left4me-server@1 -kf
# 4. Verify ptrace is blocked # 4. Verify ptrace is blocked
GUNICORN_PID=$(pgrep -f 'gunicorn.*l4d2web' | head -1) GUNICORN_PID=$(pgrep -f 'gunicorn.*l4d2web' | head -1)
PID=$(pgrep -f 'srcds_linux.*left4dead2' | head -1) PID=$(systemctl show -p MainPID --value left4me-server@1.service)
sudo nsenter --target $PID --mount -- /usr/bin/gdb --batch -p $GUNICORN_PID 2>&1 | tail -5 # NOTE: A naive `sudo nsenter --target $PID --mount -- gdb -p $TARGET`
# Expect: ptrace: Operation not permitted (or seccomp denial) # runs gdb as root with full caps in only the mount namespace; the
# unit's SECCOMP filter doesn't apply, so the result is not meaningful.
# Use one of these instead:
#
# Option A: probe inside the same hardening profile.
sudo systemd-run --pty --uid=left4me --gid=left4me \
-p NoNewPrivileges=true \
-p PrivateUsers=true \
-p CapabilityBoundingSet= \
-p AmbientCapabilities= \
-p SystemCallArchitectures='native x86' \
-p SystemCallFilter='@system-service' \
-p SystemCallFilter='~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged' \
-- /usr/bin/gdb --batch -p $GUNICORN_PID 2>&1 | tail -3
# Expect: ptrace: Operation not permitted (or seccomp-related kill)
#
# Option B: inspect the compiled SystemCallFilter directly.
sudo systemd-analyze syscall-filter left4me-server@1.service 2>&1 \
| grep -E '^(ptrace|process_vm)' || echo "blocked (not in allow list)"
# Expect: "blocked (not in allow list)"
``` ```
**Pass criteria:** unit active for ≥10 min, no SECCOMP kills, plugins **Pass criteria:** unit active for ≥10 min, no SECCOMP kills, plugins
@ -338,7 +357,7 @@ load, ptrace blocked.
**Failure handling:** if SECCOMP kills appear: **Failure handling:** if SECCOMP kills appear:
- Identify the syscall from the audit line (`syscall=<num> compat=0`), - Identify the syscall from the audit line (`syscall=<num> compat=0`),
resolve via `scmp_sys_resolver -a $(uname -m) <num>` (libseccomp-dev). resolve via `scmp_sys_resolver -a $(uname -m) <num>` (`seccomp` package on Debian 13).
- Relax the filter: remove the offending group from the deny list, OR - Relax the filter: remove the offending group from the deny list, OR
switch from kill (default) to log (`SystemCallErrorNumber=EPERM`) switch from kill (default) to log (`SystemCallErrorNumber=EPERM`)
for that group. for that group.
@ -374,7 +393,7 @@ sudo systemctl restart left4me-server@1
sudo systemctl is-active left4me-server@1 sudo systemctl is-active left4me-server@1
# 2. /proc visibility narrowed # 2. /proc visibility narrowed
PID=$(pgrep -f 'srcds_linux.*left4dead2' | head -1) PID=$(systemctl show -p MainPID --value left4me-server@1.service)
sudo nsenter --target $PID --mount --pid -- ls /proc | head -20 sudo nsenter --target $PID --mount --pid -- ls /proc | head -20
# Expect: only the unit's own PIDs (srcds_run, srcds_linux, # Expect: only the unit's own PIDs (srcds_run, srcds_linux,
# child threads). NOT gunicorn or other PIDs. # child threads). NOT gunicorn or other PIDs.
@ -541,7 +560,7 @@ sudo systemctl status left4me-server@1 --no-pager | head -10
# Active (running), recent green # Active (running), recent green
# S2: srcds is in-game # S2: srcds is in-game
PID=$(pgrep -f 'srcds_linux.*left4dead2' | head -1) PID=$(systemctl show -p MainPID --value left4me-server@1.service)
[ -n "$PID" ] && echo "OK: srcds PID $PID" || echo "FAIL" [ -n "$PID" ] && echo "OK: srcds PID $PID" || echo "FAIL"
# S3: from outside, RCON responds # S3: from outside, RCON responds
@ -590,7 +609,7 @@ work end-to-end.
**Verify:** **Verify:**
```bash ```bash
PID=$(pgrep -f 'srcds_linux.*left4dead2' | head -1) PID=$(systemctl show -p MainPID --value left4me-server@1.service)
GUNICORN_PID=$(pgrep -f 'gunicorn.*l4d2web' | head -1) GUNICORN_PID=$(pgrep -f 'gunicorn.*l4d2web' | head -1)
# D1.a — srcds cannot read DB # D1.a — srcds cannot read DB
@ -606,8 +625,27 @@ sudo nsenter --target $PID --mount -- ls /opt 2>&1 | head -5
# Expect: empty listing or No such file or directory # Expect: empty listing or No such file or directory
# D2.a — srcds cannot ptrace gunicorn (syscall filter) # D2.a — srcds cannot ptrace gunicorn (syscall filter)
sudo nsenter --target $PID --mount -- /usr/bin/gdb --batch -p $GUNICORN_PID 2>&1 | tail -3 # NOTE: A naive `sudo nsenter --target $PID --mount -- gdb -p $TARGET`
# Expect: Operation not permitted # runs gdb as root with full caps in only the mount namespace; the
# unit's SECCOMP filter doesn't apply, so the result is not meaningful.
# Use one of these instead:
#
# Option A: probe inside the same hardening profile.
sudo systemd-run --pty --uid=left4me --gid=left4me \
-p NoNewPrivileges=true \
-p PrivateUsers=true \
-p CapabilityBoundingSet= \
-p AmbientCapabilities= \
-p SystemCallArchitectures='native x86' \
-p SystemCallFilter='@system-service' \
-p SystemCallFilter='~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged' \
-- /usr/bin/gdb --batch -p $GUNICORN_PID 2>&1 | tail -3
# Expect: ptrace: Operation not permitted (or seccomp-related kill)
#
# Option B: inspect the compiled SystemCallFilter directly.
sudo systemd-analyze syscall-filter left4me-server@1.service 2>&1 \
| grep -E '^(ptrace|process_vm)' || echo "blocked (not in allow list)"
# Expect: "blocked (not in allow list)"
# D2.b — srcds cannot read /proc/<gunicorn>/environ # D2.b — srcds cannot read /proc/<gunicorn>/environ
sudo nsenter --target $PID --mount -- cat /proc/$GUNICORN_PID/environ 2>&1 | head -1 sudo nsenter --target $PID --mount -- cat /proc/$GUNICORN_PID/environ 2>&1 | head -1
@ -622,9 +660,28 @@ sudo nsenter --target $PID --mount -- sudo -n /usr/local/libexec/left4me/left4me
# Expect: a sudo error about no new privileges, or operation not permitted # Expect: a sudo error about no new privileges, or operation not permitted
# D5 — server@1 cannot ptrace server@2's srcds # D5 — server@1 cannot ptrace server@2's srcds
PID2=$(pgrep -f 'srcds_linux.*\@2' | head -1) PID2=$(systemctl show -p MainPID --value left4me-server@2.service)
[ -n "$PID2" ] && sudo nsenter --target $PID --mount -- /usr/bin/gdb --batch -p $PID2 2>&1 | tail -3 # NOTE: A naive `sudo nsenter --target $PID --mount -- gdb -p $TARGET`
# Expect: Operation not permitted (cross-instance userns OR syscall filter) # runs gdb as root with full caps in only the mount namespace; the
# unit's SECCOMP filter doesn't apply, so the result is not meaningful.
# Use one of these instead:
#
# Option A: probe inside the same hardening profile.
[ -n "$PID2" ] && sudo systemd-run --pty --uid=left4me --gid=left4me \
-p NoNewPrivileges=true \
-p PrivateUsers=true \
-p CapabilityBoundingSet= \
-p AmbientCapabilities= \
-p SystemCallArchitectures='native x86' \
-p SystemCallFilter='@system-service' \
-p SystemCallFilter='~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete @privileged' \
-- /usr/bin/gdb --batch -p $PID2 2>&1 | tail -3
# Expect: ptrace: Operation not permitted (or seccomp-related kill)
#
# Option B: inspect the compiled SystemCallFilter directly.
sudo systemd-analyze syscall-filter left4me-server@1.service 2>&1 \
| grep -E '^(ptrace|process_vm)' || echo "blocked (not in allow list)"
# Expect: "blocked (not in allow list)"
# Bonus — confirm PrivateUsers is in effect # Bonus — confirm PrivateUsers is in effect
sudo readlink /proc/$PID/ns/user sudo readlink /proc/$PID/ns/user
@ -744,8 +801,26 @@ WEB_PID=$(pgrep -f 'gunicorn.*l4d2web' | head -1)
sudo -u left4me /usr/bin/gdb --batch -p $PID 2>&1 | tail -3 sudo -u left4me /usr/bin/gdb --batch -p $PID 2>&1 | tail -3
# (might still succeed if the operator runs as root — what matters is # (might still succeed if the operator runs as root — what matters is
# from inside the web unit's namespace) # from inside the web unit's namespace)
sudo nsenter --target $WEB_PID --mount -- /usr/bin/gdb --batch -p $PID 2>&1 | tail -3 # NOTE: A naive `sudo nsenter --target $WEB_PID --mount -- gdb -p $TARGET`
# Expect: Operation not permitted (SystemCallFilter blocks ptrace) # runs gdb as root with full caps in only the mount namespace; the
# unit's SECCOMP filter doesn't apply, so the result is not meaningful.
# Use one of these instead:
#
# Option A: probe inside the same hardening profile.
sudo systemd-run --pty --uid=left4me --gid=left4me \
-p NoNewPrivileges=true \
-p CapabilityBoundingSet= \
-p AmbientCapabilities= \
-p SystemCallArchitectures='native x86' \
-p SystemCallFilter='@system-service' \
-p SystemCallFilter='~@debug @mount @raw-io @reboot @swap @cpu-emulation @obsolete' \
-- /usr/bin/gdb --batch -p $PID 2>&1 | tail -3
# Expect: ptrace: Operation not permitted (or seccomp-related kill)
#
# Option B: inspect the compiled SystemCallFilter directly.
sudo systemd-analyze syscall-filter left4me-web.service 2>&1 \
| grep -E '^(ptrace|process_vm)' || echo "blocked (not in allow list)"
# Expect: "blocked (not in allow list)"
``` ```
**Pass criteria:** all of above. **Pass criteria:** all of above.
@ -1065,6 +1140,8 @@ When all tests complete:
- **Test 3 baseline**: spec says ptrace_scope default is `1`; observed - **Test 3 baseline**: spec says ptrace_scope default is `1`; observed
`0` on Debian 13. Update the "Expect" line. `0` on Debian 13. Update the "Expect" line.
**Resolved 2026-05-15 via the hardening-refactor plan.** The four bugs are fixed in-place in the test commands above. See `docs/superpowers/plans/2026-05-15-hardening-refactor.md` Task 8.
## Pointers ## Pointers
- Threat model: `docs/superpowers/specs/2026-05-15-hardening-threat-model.md` - Threat model: `docs/superpowers/specs/2026-05-15-hardening-threat-model.md`