Watch the persistence that does not need root
Everything drift watched needed root to write: /etc, /usr/local/{bin,sbin} and
the two .ssh directories. A dejima session runs as the ordinary account, so the
cheapest persistence available to it was outside every watched path - a user
systemd unit, a crontab in the spool rather than /etc, a shell rc file, or a
binary in ~/.local/bin, which is on that account's PATH and is where dejima
installs its own CLIs.
Named files under the home directory rather than the directory itself, which
holds the work this box exists for and would be enormous and drift on every
save.
Two more gaps were in how the state was collected rather than where.
SYMLINKS WERE INVISIBLE. `find -type f` returns nothing for one, so replacing a
watched binary with a link to another removed a FILE line and added nothing, and
a removal reads as tidying rather than as substitution. Recorded as LINK lines
with their targets, which also catches user-unit enablement, since that writes a
symlink into a .wants directory.
UDP LISTENERS WERE INVISIBLE. -ltn is TCP only, so a beacon binding a datagram
socket produced no line at all. Adding -u also ADDS A COLUMN: the local address
moves from field 4 to field 5 and field 4 becomes the send queue, so a flag
added without moving the field would have recorded a queue depth as a port on
every line. The netid is printed too, since a UDP and a TCP listener on the same
number are different facts.
v6, guest only and live-safe, because the PORT format change means a baseline
taken before this differs from every state after it. The note says to re-accept
on the guard and to read what it is about to record first: those paths have
never been looked at, so the first baseline over them is the one that decides
what "normal" means there.
None of this widens what the guard's key may run. The audit already ran as root
under the same sudoers rule and already returned this shape of text; it was
looking in fewer places.
This commit is contained in:
+35
-2
@@ -616,10 +616,29 @@ BASELINE="$BASELINE_DIR/baseline"
|
||||
# report its own changes forever.
|
||||
drift_paths() {
|
||||
printf '%s\n' /etc /usr/local/bin /usr/local/sbin
|
||||
local home
|
||||
local home f
|
||||
home=$(devbox_home)
|
||||
[ -d "$home/.ssh" ] && printf '%s\n' "$home/.ssh"
|
||||
[ -d /root/.ssh ] && printf '%s\n' /root/.ssh
|
||||
|
||||
# Persistence that does not need root, and so does not need the container
|
||||
# escape either. A dejima session runs as this account: it can write a user
|
||||
# unit, a crontab, a shell rc file, or a binary on its own PATH, and none of
|
||||
# those touched anything watched above.
|
||||
#
|
||||
# A crontab is not in /etc. `crontab -e` writes to the spool, which is the
|
||||
# whole point of it being a separate command.
|
||||
[ -d "$home/.config/systemd/user" ] && printf '%s\n' "$home/.config/systemd/user"
|
||||
[ -d "$home/.local/bin" ] && printf '%s\n' "$home/.local/bin"
|
||||
[ -d /var/spool/cron/crontabs ] && printf '%s\n' /var/spool/cron/crontabs
|
||||
|
||||
# Named individually rather than by taking the home directory, which holds
|
||||
# the work this box exists for and would be both enormous and drift on every
|
||||
# save. find treats a file argument as a one-entry tree, so these arrive as
|
||||
# FILE lines like everything else.
|
||||
for f in .bashrc .bash_profile .bash_aliases .profile .zshrc .zshenv; do
|
||||
[ -f "$home/$f" ] && printf '%s\n' "$home/$f"
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -639,14 +658,28 @@ drift_state() {
|
||||
find "$p" -type f -print0 2>>"$DRIFT_ERRS" \
|
||||
| xargs -0 -r sha256sum 2>>"$DRIFT_ERRS" \
|
||||
| sed 's/^\\\{0,1\}\([0-9a-f]\{64\}\) /FILE \1 /' || true
|
||||
|
||||
# SYMLINKS, which -type f does not see at all. Replacing a watched
|
||||
# binary with a link to another one changed no FILE line and no hash:
|
||||
# the file is simply gone from the listing, and a removal reads as
|
||||
# tidying rather than as substitution. It is also how a user unit gets
|
||||
# enabled, since that writes a symlink into a .wants directory.
|
||||
find "$p" -type l -printf 'LINK %p -> %l\n' 2>>"$DRIFT_ERRS" || true
|
||||
done < <(drift_paths)
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl list-unit-files --state=enabled --no-legend --no-pager 2>/dev/null \
|
||||
| awk 'NF {print "UNIT " $1}'
|
||||
fi
|
||||
# UDP as well as TCP. -ltn is TCP only, so a UDP listener was invisible: a
|
||||
# beacon or a backdoor binding a datagram socket produced no line at all.
|
||||
#
|
||||
# The netid is printed because adding -u ADDS A COLUMN. With -t alone the
|
||||
# local address is field 4; with both it is field 5, and field 4 is the
|
||||
# send queue. A flag added without moving the field would have recorded a
|
||||
# queue depth as a port, quietly, on every line.
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -H -ltn 2>/dev/null | awk 'NF {print "PORT " $4}'
|
||||
ss -H -ltun 2>/dev/null | awk 'NF {print "PORT " $1 " " $5}'
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -238,3 +238,78 @@ stub_state() {
|
||||
run main all
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
# --- what the collector can see ----------------------------------------------
|
||||
#
|
||||
# Three things it could not, each a way to persist that left every hash it took
|
||||
# unchanged.
|
||||
|
||||
@test "a symlink is recorded, since hashing cannot see one" {
|
||||
# `find -type f` returns nothing for a symlink, so replacing a watched
|
||||
# binary with a link to another one removed a FILE line and added nothing.
|
||||
# A removal reads as tidying rather than as substitution.
|
||||
scratch="$BATS_TEST_TMPDIR/links"
|
||||
mkdir -p "$scratch"
|
||||
echo real > "$scratch/binary"
|
||||
ln -s /bin/sh "$scratch/swapped"
|
||||
drift_paths() { printf '%s\n' "$scratch"; }
|
||||
DRIFT_ERRS="$BATS_TEST_TMPDIR/errs"; : > "$DRIFT_ERRS"
|
||||
run drift_state
|
||||
[[ "$output" == *"LINK $scratch/swapped -> /bin/sh"* ]]
|
||||
}
|
||||
|
||||
@test "a udp listener is recorded, not only tcp" {
|
||||
# -ltn is TCP only. A beacon binding a datagram socket produced no line.
|
||||
scratch="$BATS_TEST_TMPDIR/empty"; mkdir -p "$scratch"
|
||||
drift_paths() { printf '%s\n' "$scratch"; }
|
||||
DRIFT_ERRS="$BATS_TEST_TMPDIR/errs"; : > "$DRIFT_ERRS"
|
||||
ss() { printf 'udp UNCONN 0 0 0.0.0.0:1194 0.0.0.0:*\ntcp LISTEN 0 128 0.0.0.0:22 0.0.0.0:*\n'; }
|
||||
export -f ss 2>/dev/null || true
|
||||
run drift_state
|
||||
[[ "$output" == *"PORT udp 0.0.0.0:1194"* ]]
|
||||
[[ "$output" == *"PORT tcp 0.0.0.0:22"* ]]
|
||||
}
|
||||
|
||||
@test "the port line reads the address, not the column that -u shifted" {
|
||||
# Adding -u adds the netid column, so the local address moves from field 4
|
||||
# to field 5 and field 4 becomes the send queue. A flag added without moving
|
||||
# the field would record a queue depth as a port, on every line, quietly.
|
||||
scratch="$BATS_TEST_TMPDIR/empty2"; mkdir -p "$scratch"
|
||||
drift_paths() { printf '%s\n' "$scratch"; }
|
||||
DRIFT_ERRS="$BATS_TEST_TMPDIR/errs"; : > "$DRIFT_ERRS"
|
||||
ss() { printf 'tcp LISTEN 0 128 192.0.2.5:8080 0.0.0.0:*\n'; }
|
||||
export -f ss 2>/dev/null || true
|
||||
run drift_state
|
||||
[[ "$output" == *"PORT tcp 192.0.2.5:8080"* ]]
|
||||
[[ "$output" != *"PORT tcp 128"* ]]
|
||||
}
|
||||
|
||||
@test "the paths cover persistence that needs no root" {
|
||||
# A dejima session runs as this account. It can write a user unit, a
|
||||
# crontab, a shell rc file or a binary on its own PATH without ever needing
|
||||
# the container escape, and none of those was watched.
|
||||
# H, not "home": drift_paths declares `local home` before it calls
|
||||
# devbox_home, so a stub reading $home sees that empty local under bash's
|
||||
# dynamic scoping and trips set -u.
|
||||
H="$BATS_TEST_TMPDIR/home"
|
||||
mkdir -p "$H/.config/systemd/user" "$H/.local/bin" "$H/.ssh"
|
||||
echo x > "$H/.bashrc"
|
||||
devbox_home() { printf '%s\n' "$H"; }
|
||||
run drift_paths
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"$H/.config/systemd/user"* ]]
|
||||
[[ "$output" == *"$H/.local/bin"* ]]
|
||||
[[ "$output" == *"$H/.bashrc"* ]]
|
||||
}
|
||||
|
||||
@test "the home directory itself is not watched, only named files under it" {
|
||||
# It holds the work this box exists for. Hashing it would be enormous and
|
||||
# would drift on every save, which is the shape of alarm that gets muted.
|
||||
H="$BATS_TEST_TMPDIR/home2"
|
||||
mkdir -p "$H/.ssh"
|
||||
echo x > "$H/.bashrc"
|
||||
devbox_home() { printf '%s\n' "$H"; }
|
||||
run drift_paths
|
||||
[ "$(printf '%s\n' "$output" | grep -cx "$H")" -eq 0 ]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
version: 6
|
||||
title: Watch the persistence that does not need root
|
||||
layers: guest
|
||||
snapshot: none
|
||||
downtime: none
|
||||
---
|
||||
|
||||
# v6: watch the persistence that does not need root
|
||||
|
||||
Guest only, live-safe, no snapshot. **If you run dejima-guard, this needs a
|
||||
fresh baseline on the guard**, which is the whole of the work.
|
||||
|
||||
## Why
|
||||
|
||||
`drift` watched `/etc`, `/usr/local/bin`, `/usr/local/sbin` and the two `.ssh`
|
||||
directories. Everything in that list needs root to write. A dejima session runs
|
||||
as the ordinary account, so the cheapest persistence available to it was outside
|
||||
every watched path:
|
||||
|
||||
- a **user systemd unit** in `~/.config/systemd/user`, enabled without sudo;
|
||||
- a **crontab**, which `crontab -e` writes to `/var/spool/cron/crontabs` and not
|
||||
to `/etc`;
|
||||
- a **shell rc file**, run on every login;
|
||||
- a **binary in `~/.local/bin`**, which is on that account's PATH and is where
|
||||
dejima installs its own CLIs.
|
||||
|
||||
Two more gaps were in how the state was collected rather than where:
|
||||
|
||||
- **Symlinks were invisible.** `find -type f` returns nothing for one, so
|
||||
replacing a watched binary with a link to another removed a `FILE` line and
|
||||
added nothing. A removal reads as tidying rather than as substitution.
|
||||
- **UDP listeners were invisible.** `ss -ltn` is TCP only, so a beacon or a
|
||||
backdoor binding a datagram socket produced no line at all.
|
||||
|
||||
None of this widens what the guard's key may run. The audit already ran as root
|
||||
under the same sudoers rule and already returned this shape of text; it was
|
||||
looking in fewer places.
|
||||
|
||||
## 1. Guest
|
||||
|
||||
```sh
|
||||
cd ~/dejima-devbox-hardener
|
||||
git pull
|
||||
sudo ./bin/dejima-devbox-bootstrap
|
||||
sudo ./bin/dejima-devbox-upgrade mark-guest 6
|
||||
```
|
||||
|
||||
Live-safe. Bootstrap installs only what is missing and does not restart Docker,
|
||||
so sessions in flight are not disturbed.
|
||||
|
||||
## 2. Re-accept the baseline, on the guard
|
||||
|
||||
**Do this or every cycle alerts.** `PORT` lines now carry the protocol, so a
|
||||
baseline taken before this differs from every state taken after it on every
|
||||
port, and the new paths appear as additions.
|
||||
|
||||
On the machine running dejima-guard:
|
||||
|
||||
```sh
|
||||
dejima-guard accept
|
||||
dejima-guard check # should be clean
|
||||
```
|
||||
|
||||
**Read what `accept` is about to record before you run it.** The state is
|
||||
larger now and covers places nobody was watching before, so this is the first
|
||||
time anything in those paths has been looked at. Take it on a box you believe is
|
||||
clean; one taken later records whatever is there as normal.
|
||||
|
||||
If you do not run a guard, `sudo dejima-devbox-audit drift --accept` does the
|
||||
same thing on the box itself, with the caveat that repository has always
|
||||
carried: a baseline root can rewrite bounds an intruder who did not think to
|
||||
look, and not one who did.
|
||||
|
||||
## What this does not change
|
||||
|
||||
Nothing about the firewall, the node, or what the guard is permitted to run. No
|
||||
new command, no new sudoers entry, no new key. The same call returns more.
|
||||
Reference in New Issue
Block a user