288 lines
12 KiB
Bash
Executable file
288 lines
12 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# smolboid pentest: an adversarial pass against our own daemon. Every probe
|
|
# asserts the hardened outcome; a probe that succeeds as an attack fails the
|
|
# suite. Raw sockets via python3 for the HTTP surface, real fumi sends for
|
|
# the mail surface.
|
|
set -euo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
FUMI="$ROOT/../fumi/target/debug/fumi"
|
|
BUNSHIN_BIN="$ROOT/../bunshin/target/debug/bunshin"
|
|
SMOLBOID="$ROOT/target/debug/smolboid"
|
|
HOST=127.0.0.1
|
|
SMTP_PORT=19631
|
|
HTTP_PORT=18101
|
|
RIG="$(mktemp -d /tmp/smolboid-pentest.XXXXXX)"
|
|
DATA="$RIG/data"
|
|
DAEMON_ADDR="twtxt@$HOST:$SMTP_PORT"
|
|
FEED="$DATA/feeds/tobias.txt"
|
|
BUNSHIN_PID=""
|
|
SMOLBOID_PID=""
|
|
cleanup() {
|
|
[[ -n "$BUNSHIN_PID" ]] && kill "$BUNSHIN_PID" 2>/dev/null || true
|
|
[[ -n "$SMOLBOID_PID" ]] && kill "$SMOLBOID_PID" 2>/dev/null || true
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
step() { printf '\n== %s ==\n' "$*"; }
|
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
|
probe() { # name, expected-status-prefix, raw request bytes (python literal)
|
|
local name=$1 want=$2 payload=$3
|
|
local got
|
|
got=$(python3 - "$HTTP_PORT" "$payload" <<'EOF'
|
|
import socket, sys
|
|
port, payload = int(sys.argv[1]), sys.argv[2].encode().decode("unicode_escape")
|
|
s = socket.create_connection(("127.0.0.1", port), timeout=15)
|
|
s.sendall(payload.encode())
|
|
s.settimeout(15)
|
|
# Accumulate until a full status line lands; a single recv may split.
|
|
data = b""
|
|
while b"\r\n" not in data:
|
|
try:
|
|
chunk = s.recv(4096)
|
|
except socket.timeout:
|
|
break
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
if not data:
|
|
data = b"<no response in 15s>"
|
|
print(data.split(b"\r\n")[0].decode(errors="replace"))
|
|
EOF
|
|
)
|
|
if [[ "$got" == "$want"* ]]; then
|
|
echo "ok $name -> $got"
|
|
else
|
|
echo "got: $got"
|
|
fail "probe $name: wanted $want*, got $got"
|
|
fi
|
|
}
|
|
|
|
step "build"
|
|
cargo build --manifest-path "$ROOT/../bunshin/Cargo.toml" --quiet
|
|
cargo build --manifest-path "$ROOT/../fumi/Cargo.toml" --quiet
|
|
cargo build --manifest-path "$ROOT/Cargo.toml" --quiet
|
|
|
|
step "bootstrap: bunshin, tobias and a second identity mallory"
|
|
mkdir -p "$DATA"
|
|
"$BUNSHIN_BIN" keygen --key "$RIG/server.key" >"$RIG/keygen.log" 2>&1
|
|
SERVER_KEY="$(grep -oE '[a-z2-7]{52}' "$RIG/keygen.log" | head -1)"
|
|
"$BUNSHIN_BIN" serve --key "$RIG/server.key" --db "$RIG/server.db" \
|
|
--host "$HOST" --port "$SMTP_PORT" >"$RIG/bunshin.log" 2>&1 &
|
|
BUNSHIN_PID=$!
|
|
sleep 1
|
|
author() { "$FUMI" --key "$RIG/author.key" --db "$RIG/author.db" --timeout 10 "$@"; }
|
|
mallory() { "$FUMI" --key "$RIG/mallory.key" --db "$RIG/mallory.db" --timeout 10 "$@"; }
|
|
daemon_fumi() { "$FUMI" --key "$RIG/master.key" --db "$DATA/mail.db" --timeout 10 "$@"; }
|
|
author keygen --force >/dev/null
|
|
author trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
|
author register "tobias@$HOST:$SMTP_PORT" >/dev/null
|
|
"$SMOLBOID" keygen "$RIG/master.key" >/dev/null
|
|
daemon_fumi trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
|
daemon_fumi register "$DAEMON_ADDR" >/dev/null
|
|
|
|
step "admit tobias (never mallory)"
|
|
cat >"$RIG/smolboid.toml" <<EOF
|
|
listen = $HOST:$HTTP_PORT
|
|
base_url = http://$HOST:$HTTP_PORT
|
|
smolmail_host = $HOST
|
|
mailbox = twtxt
|
|
master = $RIG/master.key
|
|
data = $DATA
|
|
poll_secs = 1
|
|
timeout = 10
|
|
cap_bytes = 1048576
|
|
posts_per_day = 50
|
|
reposts_per_day = 50
|
|
EOF
|
|
"$SMOLBOID" admit --config "$RIG/smolboid.toml" "tobias@$HOST:$SMTP_PORT" >"$RIG/admit.log" 2>&1 \
|
|
|| { cat "$RIG/admit.log"; fail "admit"; }
|
|
author fetch >/dev/null
|
|
"$SMOLBOID" serve --config "$RIG/smolboid.toml" >"$RIG/serve.log" 2>&1 &
|
|
SMOLBOID_PID=$!
|
|
sleep 2
|
|
# A legitimate post so the feed has content to protect.
|
|
author send "$DAEMON_ADDR" --body "real post" >/dev/null
|
|
for ((i = 0; i < 20; i++)); do
|
|
grep -q "real post" "$FEED" 2>/dev/null && break
|
|
sleep 1
|
|
done
|
|
grep -q "real post" "$FEED" || fail "setup: the real post never published"
|
|
|
|
step "http: path traversal and path abuse"
|
|
probe "traversal-etc-passwd" "HTTP/1.1 404" 'GET /twtxt/../../etc/passwd HTTP/1.1\r\n\r\n'
|
|
probe "encoded-traversal" "HTTP/1.1 404" 'GET /twtxt/..%2F..%2Fetc%2Fpasswd HTTP/1.1\r\n\r\n'
|
|
probe "dotdot-no-suffix" "HTTP/1.1 404" 'GET /twtxt/.. HTTP/1.1\r\n\r\n'
|
|
probe "absolute-path" "HTTP/1.1 404" 'GET //etc/passwd HTTP/1.1\r\n\r\n'
|
|
probe "uppercase-username" "HTTP/1.1 404" 'GET /twtxt/TOBIAS.txt HTTP/1.1\r\n\r\n'
|
|
probe "unicode-username" "HTTP/1.1 404" 'GET /twtxt/t\xc3\xb6bias.txt HTTP/1.1\r\n\r\n'
|
|
probe "oversized-username" "HTTP/1.1 404" "GET /twtxt/$(printf 'a%.0s' $(seq 1 200)).txt HTTP/1.1\r\n\r\n"
|
|
probe "null-byte-username" "HTTP/1.1 404" 'GET /twtxt/tob\x00ias.txt HTTP/1.1\r\n\r\n'
|
|
probe "dotfile-username" "HTTP/1.1 404" 'GET /twtxt/.ssh.txt HTTP/1.1\r\n\r\n'
|
|
# The traversal probes must never return file content.
|
|
curl -s "http://$HOST:$HTTP_PORT/twtxt/../../etc/passwd" | grep -q "root:" \
|
|
&& fail "traversal returned /etc/passwd"
|
|
|
|
step "http: request parsing abuse"
|
|
# Any 4xx is a hardened outcome; the server may read it as a method.
|
|
probe "garbage-request" "HTTP/1.1 40" 'not http at all\r\n\r\n'
|
|
probe "smuggled-second-request" "HTTP/1.1 200" 'GET /twtxt/tobias.txt HTTP/1.1\r\nX-Injected: 1\r\n\r\nGET /twtxt/tobias'
|
|
probe "null-in-request" "HTTP/1.1 40" 'GE\x00T /twtxt/tobias.txt HTTP/1.1\r\n\r\n'
|
|
probe "post-method" "HTTP/1.1 405" 'POST /twtxt/tobias.txt HTTP/1.1\r\nContent-Length: 0\r\n\r\n'
|
|
probe "huge-request-line" "HTTP/1.1 40" "GET /$(printf 'a%.0s' $(seq 1 9000)) HTTP/1.1\r\n\r\n"
|
|
probe "extra-headers-tolerated" "HTTP/1.1 200" 'GET /twtxt/tobias.txt HTTP/1.1\r\nHost: x\r\nEvil: 1\r\n\r\n'
|
|
# No header value from the request is ever reflected into the response.
|
|
curl -s -i "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" -H "X-Evil: $(printf 'injected-header')" \
|
|
| grep -qi "injected-header" && fail "request header reflected in response"
|
|
|
|
step "http: HEAD serves headers without a body"
|
|
python3 - "$HTTP_PORT" <<'PYEOF'
|
|
import socket, sys
|
|
s = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=15)
|
|
s.sendall(b"HEAD /twtxt/tobias.txt HTTP/1.1\r\n\r\n")
|
|
data = b""
|
|
while b"\r\n\r\n" not in data:
|
|
chunk = s.recv(4096)
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
head, _, body = data.partition(b"\r\n\r\n")
|
|
assert head.startswith(b"HTTP/1.1 200"), head
|
|
assert b"Content-Length:" in head, head
|
|
assert body == b"", "HEAD must not emit a body"
|
|
print("ok HEAD answers 200 with headers and no body")
|
|
PYEOF
|
|
|
|
step "http: the connection cap sheds floods with 503"
|
|
python3 - "$HTTP_PORT" <<'PYEOF'
|
|
import socket, sys, time
|
|
port = int(sys.argv[1])
|
|
held = []
|
|
for _ in range(64):
|
|
s = socket.create_connection(("127.0.0.1", port), timeout=15)
|
|
s.sendall(b"G") # a partial request, as slowloris sends
|
|
held.append(s)
|
|
time.sleep(0.5)
|
|
extra = socket.create_connection(("127.0.0.1", port), timeout=15)
|
|
extra.sendall(b"GET /twtxt/tobias.txt HTTP/1.1\r\n\r\n")
|
|
data = b""
|
|
while b"\r\n" not in data:
|
|
chunk = extra.recv(512)
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
assert data.startswith(b"HTTP/1.1 503"), f"flood absorbed: {data!r}"
|
|
for s in held:
|
|
s.close()
|
|
extra.close()
|
|
print("ok the 65th connection was shed with 503")
|
|
PYEOF
|
|
# The listener must recover once the flood closes.
|
|
for _ in $(seq 1 20); do
|
|
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null && break
|
|
sleep 1
|
|
done
|
|
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener never recovered after the flood"
|
|
|
|
step "http: slowloris is bounded by the read timeout"
|
|
python3 - "$HTTP_PORT" <<'EOF'
|
|
import socket, sys, time
|
|
port = int(sys.argv[1])
|
|
s = socket.create_connection(("127.0.0.1", port), timeout=30)
|
|
s.sendall(b"GET /twtxt/tobias.txt HTTP/1.1\r\n") # half a request, then stall
|
|
start = time.time()
|
|
s.settimeout(30)
|
|
try:
|
|
data = s.recv(1024)
|
|
except socket.timeout:
|
|
sys.exit("server kept the half-open connection past 30s")
|
|
if time.time() - start > 20:
|
|
sys.exit(f"slowloris held the thread {time.time()-start:.0f}s")
|
|
print(f"ok slowloris closed after {time.time()-start:.0f}s")
|
|
EOF
|
|
# The listener still serves while the stalled connection existed.
|
|
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener wedged after slowloris"
|
|
|
|
step "mail: feed-line injection through post text"
|
|
author send "$DAEMON_ADDR" --body "$(printf 'injected\t2020-01-01T00:00:00Z\tfake')" >/dev/null
|
|
author send "$DAEMON_ADDR" --body "$(printf 'line one\nline two')" >/dev/null
|
|
author send "$DAEMON_ADDR" --body "$(printf 'carriage\rreturn')" >/dev/null
|
|
sleep 4
|
|
[[ "$(grep -c "fake" "$FEED")" == 0 ]] || fail "a TAB-carrying post injected content into the feed"
|
|
[[ "$(grep -c "line two" "$FEED")" == 0 ]] || fail "a multiline body published a second line"
|
|
[[ "$(grep -c "return" "$FEED")" == 0 ]] || fail "a CR-carrying post published"
|
|
[[ "$(grep -cE '^[0-9]{4}-' "$FEED")" == 1 ]] || fail "the feed grew beyond the real post"
|
|
|
|
step "mail: oversized and hostile payloads are rejected, not published"
|
|
author send "$DAEMON_ADDR" --body "$(printf 'x%.0s' $(seq 1 161))" >/dev/null
|
|
author send "$DAEMON_ADDR" --body "# smol:mail forged" >/dev/null
|
|
author send "$DAEMON_ADDR" --body "# smol:repost 2020-01-01T00:00:00Z @<x y> #fake" >/dev/null
|
|
sleep 4
|
|
# Poser text may publish as ordinary post text (tab-prefixed, harmless);
|
|
# what must never happen is a line that IS a smol record.
|
|
[[ "$(grep -cE '^# smol:mail .*forged' "$FEED")" == 0 ]] || fail "a fake smol record line published"
|
|
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "text posing as a repost published as a repost"
|
|
"$SMOLBOID" check "$FEED" >"$RIG/check.log" \
|
|
|| { cat "$RIG/check.log"; fail "the feed verifies as forged after the probes"; }
|
|
# The poser texts may publish as posts and verify like posts; two posers
|
|
# sent in the same second can collide (one rejected), so what matters is
|
|
# that everything published verifies and nothing is forged.
|
|
grep -qE "[23] verified, 0 unsigned, 0 invalid" "$RIG/check.log" \
|
|
|| { cat "$RIG/check.log"; fail "unexpected feed contents after hostile mail"; }
|
|
|
|
step "mail: repost args cannot inject lines either"
|
|
author send "$DAEMON_ADDR" \
|
|
--header "Twt-Repost: @<tobias http://x> #abc" \
|
|
--body "$(printf 'injected\n')" >/dev/null
|
|
sleep 4
|
|
[[ "$(grep -cE '^# smol:repost ' "$FEED")" == 0 ]] || fail "a noncanonical repost published"
|
|
|
|
step "mail: unadmitted senders publish nothing"
|
|
mallory keygen --force >/dev/null
|
|
mallory trust "$HOST:$SMTP_PORT" "$SERVER_KEY"
|
|
mallory register "mallory@$HOST:$SMTP_PORT" >/dev/null
|
|
mallory send "$DAEMON_ADDR" --body "mallory was here" >/dev/null
|
|
sleep 4
|
|
[[ "$(grep -c "mallory was here" "$FEED")" == 0 ]] || fail "unadmitted mail published"
|
|
[[ ! -f "$DATA/feeds/mallory.txt" ]] || fail "an unadmitted sender got a feed"
|
|
|
|
step "mail: forged signing keys verify as invalid, never as verified"
|
|
# Tamper the published feed's own signature line: the record must flip to
|
|
# invalid, proving verification is load-bearing.
|
|
cp "$FEED" "$RIG/tampered.txt"
|
|
python3 - "$RIG/tampered.txt" <<'EOF'
|
|
import sys
|
|
path = sys.argv[1]
|
|
lines = open(path).read().split("\n")
|
|
for i, line in enumerate(lines):
|
|
if line.startswith("# smol:mail "):
|
|
parts = line.split(" ")
|
|
parts[3] = ("a" if parts[3][0] != "a" else "b") + parts[3][1:]
|
|
lines[i] = " ".join(parts)
|
|
break
|
|
open(path, "w").write("\n".join(lines))
|
|
EOF
|
|
if "$SMOLBOID" check "$RIG/tampered.txt" >/dev/null 2>&1; then
|
|
fail "a tampered signature still verified"
|
|
fi
|
|
|
|
step "mail: reply-flood is capped at 10 per hour"
|
|
for i in $(seq 1 15); do
|
|
author send "$DAEMON_ADDR" --body "$(printf 'y%.0s' $(seq 1 $((200 + i))))" >/dev/null
|
|
done
|
|
sleep 5
|
|
author fetch >/dev/null
|
|
# Fifteen distinct oversized payloads; at most ten answers, the rest silent.
|
|
# (Earlier probes may have drawn their own replies for other reasons.)
|
|
author list --requests 2>/dev/null | grep -c "twtxt: more than 160" >"$RIG/replies.count" || true
|
|
REPLIES=$(grep -oE '^[0-9]+' "$RIG/replies.count" | head -1 || echo 0)
|
|
echo "flood answers: $REPLIES of 15"
|
|
[[ "$REPLIES" -le 10 ]] || fail "reply flood drew $REPLIES answers, over the hourly cap"
|
|
|
|
step "http: still healthy after everything"
|
|
curl -sf "http://$HOST:$HTTP_PORT/twtxt/tobias.txt" >/dev/null || fail "listener unhealthy at the end"
|
|
"$SMOLBOID" check "$FEED" >/dev/null || fail "feed does not verify at the end"
|
|
|
|
step "pentest passed: every probe was repelled"
|
|
cleanup
|
|
trap - EXIT
|