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:
parent
8e678b6765
commit
37309ba399
1 changed files with 93 additions and 16 deletions
|
|
@ -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`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue