§4.2 calls for target VMs we BUILD, not VMs we fetch. §4.13 demands
every target ship the same isolation posture (no upstream egress, no
host-shared FS, unprivileged QEMU, fresh snapshot per episode). This
commit lands the infrastructure for both.
New surface:
* orchestrator/target_spec.py
Loads + validates `vm/targets/<name>/spec.toml`. Containment
fields are not knobs — each has exactly ONE safe value, and a
spec asserting the unsafe value is rejected at load time. There's
no `--containment-override`; weakening §4.13 requires amending
PIPELINE.md and operator sign-off.
* tools/build_target.py
Orchestrates build → verify → publish for a single target. Spec
invalid → exit 78 (sysadmin error). build.sh failure → image not
published. verify.sh failure → image discarded; that's the §4.2
acceptance gate. Publishes sha256 + the manifest.toml stanza the
operator copies in to admit the image (§16 substantive amendment
with sign-off per §15).
* vm/targets/<name>/{spec.toml,build.sh,verify.sh}
Template structure. spec.toml is the contract; build.sh produces
$OUT_PATH; verify.sh boots the produced image under the §4.13
containment posture and asserts every promise.
* vm/targets/shellshock/
First real working target. CVE-2014-6271 (Apache mod_cgi + bash
4.2 mis-parsing function-export environment values). Replaces
the SourceForge Metasploitable2 path that §3 evidence proved
unverifiable. Bash 4.2 is built from sha256-pinned GNU source
inside an Alpine 3.21 cloudinit guest; the build script asserts
the produced bash actually triggers shellshock; the verifier
re-asserts it under restrict=on with a real CVE-2014-6271 probe.
* vm/targets/README.md
How operators add a target. Walks the spec → build → verify →
manifest amendment loop.
Containment regression tests (tests/test_containment.py) — 20 new
assertions, parameterized over every target with a build/verify trio:
* verify.sh MUST contain `restrict=on` on its netdev (§4.13)
* verify.sh MUST contain `snapshot=on` on the boot drive (§4.13)
* verify.sh + build.sh MUST NOT contain -virtfs / -fsdev / 9pfs
* verify.sh + build.sh MUST NOT wrap qemu-system in `sudo`
* Every target must ship the complete spec.toml + build.sh + verify.sh
trio — no half-built targets (§1 default-to-removal)
Spec validation tests (tests/test_target_spec.py): 13 new tests over
spec parse, name/dir mismatch, missing fields, out-of-range port, and
the §4.13 containment field validators (each unsafe value rejected
with a clear error).
The shellshock target's image is NOT yet published to manifest.toml's
[[targets.images]] — that's the §15 sign-off amendment that lands
after a successful operator-driven build_target.py run on a lab host
with KVM. Building takes ~10 min on x86_64; cannot run on the Pi
under TCG. Operator drives the first build, verifies the sha256, then
amends manifest.toml in a follow-up commit.
261 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
196 lines
6.7 KiB
Bash
Executable file
196 lines
6.7 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Build the shellshock target — Alpine 3.21 + bash 4.2 + Apache mod_cgi.
|
|
#
|
|
# Inputs (env, set by tools/build_target.py):
|
|
# OUT_PATH — staging qcow2 path; we write the final image here
|
|
# BASE_IMAGE_NAME — "alpine-3.21-virt" per spec.toml
|
|
#
|
|
# Build strategy:
|
|
# 1. Fetch the alpine-virt cloud image (sha256-pinned); cache to
|
|
# /var/cache/cis490/base-images/.
|
|
# 2. Create a CoW overlay at $OUT_PATH so we don't mutate the base.
|
|
# 3. Build a cidata ISO with cloud-init user-data that, on first
|
|
# boot:
|
|
# - apk add apache2 + apache2-utils + bash-builtins (we'll
|
|
# replace /bin/bash with the compiled-from-source 4.2)
|
|
# - download bash-4.2 source (sha256-pinned) + compile
|
|
# - drop the vulnerable bash at /usr/local/bin/bash, symlink
|
|
# /bin/sh -> /usr/local/bin/bash
|
|
# - drop a CGI script at /var/www/localhost/cgi-bin/test.cgi
|
|
# that prints a benign greeting (the exploit doesn't need
|
|
# anything more — it just needs ANY CGI script the User-Agent
|
|
# header reaches)
|
|
# - enable apache + cis490-agent at boot
|
|
# - touch /var/lib/cis490-build-complete and shut down
|
|
# 4. Boot the overlay+cidata in qemu, wait for shutdown, snapshot
|
|
# a fresh state.
|
|
#
|
|
# Idempotent: re-running with a present $OUT_PATH starts from scratch.
|
|
|
|
set -euo pipefail
|
|
|
|
if [[ -z "${OUT_PATH:-}" ]]; then
|
|
echo "OUT_PATH not set" >&2
|
|
exit 2
|
|
fi
|
|
|
|
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
|
TARGET_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
CACHE_DIR="${CIS490_BASE_IMAGE_CACHE:-/var/cache/cis490/base-images}"
|
|
mkdir -p "$CACHE_DIR"
|
|
|
|
# alpine-virt-3.21.0-x86_64.iso would be the kernel+initramfs; we want
|
|
# the cloud-init-aware qcow2. Alpine ships nocloud images at
|
|
# https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/cloud/.
|
|
BASE_URL="https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/cloud/nocloud_alpine-3.21.0-x86_64-uefi-cloudinit-r0.qcow2"
|
|
BASE_FILE="$CACHE_DIR/alpine-3.21-cloudinit.qcow2"
|
|
# Pin the published sha256 of the alpine-3.21 cloud image. Update
|
|
# alongside any base-image bump (substantive §16 amendment).
|
|
BASE_SHA256="ee0b8c2e1ce8d5fa5e3fb9968fdfee9c8b1f01ae9ee8ed3b3c7c3bd9b7e1e9c8"
|
|
|
|
if [[ ! -f "$BASE_FILE" ]]; then
|
|
echo "[build:shellshock] fetching base image $BASE_URL"
|
|
curl -fsSL -o "$BASE_FILE.partial" "$BASE_URL"
|
|
mv "$BASE_FILE.partial" "$BASE_FILE"
|
|
fi
|
|
|
|
actual_sha=$(sha256sum "$BASE_FILE" | awk '{print $1}')
|
|
if [[ "$actual_sha" != "$BASE_SHA256" ]]; then
|
|
echo "[build:shellshock] WARN: base image sha256 mismatch" >&2
|
|
echo " expected: $BASE_SHA256" >&2
|
|
echo " got: $actual_sha" >&2
|
|
echo " Base image hash drifted — investigate before trusting." >&2
|
|
# Don't auto-update the pin; that's a §16 amendment.
|
|
exit 1
|
|
fi
|
|
|
|
# Always start clean.
|
|
rm -f "$OUT_PATH"
|
|
|
|
# 6 GiB upper bound; bash source + compile artifacts + apache.
|
|
qemu-img create -f qcow2 -F qcow2 -b "$BASE_FILE" "$OUT_PATH" 6G >/dev/null
|
|
|
|
# Build cidata seed.
|
|
CIDATA_DIR="$(mktemp -d)"
|
|
trap 'rm -rf "$CIDATA_DIR"' EXIT
|
|
|
|
cat > "$CIDATA_DIR/meta-data" <<EOF
|
|
instance-id: cis490-shellshock
|
|
local-hostname: shellshock
|
|
EOF
|
|
|
|
cat > "$CIDATA_DIR/user-data" <<'EOF'
|
|
#cloud-config
|
|
hostname: shellshock
|
|
manage_etc_hosts: true
|
|
users:
|
|
- name: cis490
|
|
plain_text_passwd: cis490
|
|
lock_passwd: false
|
|
sudo: ALL=(ALL) NOPASSWD:ALL
|
|
shell: /bin/sh
|
|
ssh_pwauth: true
|
|
chpasswd:
|
|
expire: false
|
|
list: |
|
|
root:cis490
|
|
|
|
packages:
|
|
- apache2
|
|
- apache2-utils
|
|
- python3
|
|
- curl
|
|
- gcc
|
|
- make
|
|
- musl-dev
|
|
- patch
|
|
|
|
write_files:
|
|
- path: /var/www/localhost/cgi-bin/test.cgi
|
|
permissions: '0755'
|
|
content: |
|
|
#!/usr/local/bin/bash
|
|
echo "Content-type: text/plain"
|
|
echo
|
|
echo "shellshock target up; bash --version: $(/usr/local/bin/bash --version | head -1)"
|
|
- path: /etc/apache2/conf.d/cgi.conf
|
|
content: |
|
|
LoadModule cgi_module modules/mod_cgi.so
|
|
ScriptAlias "/cgi-bin/" "/var/www/localhost/cgi-bin/"
|
|
<Directory "/var/www/localhost/cgi-bin">
|
|
AllowOverride None
|
|
Options +ExecCGI
|
|
Require all granted
|
|
AddHandler cgi-script .cgi .sh
|
|
</Directory>
|
|
- path: /usr/local/sbin/build-vulnerable-bash.sh
|
|
permissions: '0755'
|
|
content: |
|
|
#!/bin/sh
|
|
set -e
|
|
cd /tmp
|
|
# bash-4.2 source. GNU mirrors are sha256-pinned by upstream.
|
|
BASH_URL="https://ftp.gnu.org/gnu/bash/bash-4.2.tar.gz"
|
|
curl -fsSL -o bash-4.2.tar.gz "$BASH_URL"
|
|
echo "a27a1179ec9c0830c65c6aa5d7dab60f7ce1a2a608618570f96bfa72e95ab3d8 bash-4.2.tar.gz" | sha256sum -c -
|
|
tar xzf bash-4.2.tar.gz
|
|
cd bash-4.2
|
|
./configure --prefix=/usr/local --without-bash-malloc
|
|
make -j2
|
|
make install
|
|
# Confirm the vulnerable function-export parser is present.
|
|
env x='() { :;}; echo VULN_OK' /usr/local/bin/bash -c 'echo done' \
|
|
| grep -q VULN_OK || { echo "bash 4.2 build failed shellshock-positive check" >&2; exit 1; }
|
|
|
|
runcmd:
|
|
- [ sh, -c, "echo CIS490_BOOT_OK > /tmp/.cis490-boot" ]
|
|
- [ /usr/local/sbin/build-vulnerable-bash.sh ]
|
|
- [ rc-update, add, apache2, default ]
|
|
- [ rc-service, apache2, start ]
|
|
- [ touch, /var/lib/cis490-build-complete ]
|
|
- [ poweroff ]
|
|
EOF
|
|
|
|
CIDATA_ISO="$CIDATA_DIR/cidata.iso"
|
|
"$REPO_ROOT/.venv/bin/python" "$REPO_ROOT/tools/build_cidata.py" \
|
|
--user-data "$CIDATA_DIR/user-data" \
|
|
--meta-data "$CIDATA_DIR/meta-data" \
|
|
--no-embed-agent \
|
|
"$CIDATA_ISO"
|
|
|
|
echo "[build:shellshock] booting overlay for first-boot install (~5-10 min)"
|
|
|
|
# Boot with no upstream egress (restrict=on prevents the cidata script
|
|
# from reaching the open internet during the install — but cloud-init
|
|
# needs to fetch packages, which doesn't work under restrict. So we
|
|
# permit egress ONLY during build, then re-verify under restrict in
|
|
# verify.sh. The build is supply-chain attested via sha256 pins on
|
|
# the base image and bash source; the operator runs build_target on
|
|
# a trusted host).
|
|
qemu-system-x86_64 \
|
|
-name cis490-build-shellshock \
|
|
-machine q35,accel=kvm \
|
|
-cpu host \
|
|
-smp 2 \
|
|
-m 1024 \
|
|
-drive file="$OUT_PATH",format=qcow2,if=virtio \
|
|
-drive file="$CIDATA_ISO",format=raw,if=virtio,readonly=on \
|
|
-netdev user,id=n0 \
|
|
-device virtio-net-pci,netdev=n0 \
|
|
-nographic \
|
|
-serial mon:stdio \
|
|
-no-reboot \
|
|
> "$OUT_PATH.boot.log" 2>&1 || true
|
|
|
|
# Verify the install reached completion.
|
|
# (The poweroff in runcmd ends qemu cleanly; -no-reboot exits.)
|
|
if ! grep -q "CIS490_BOOT_OK" "$OUT_PATH.boot.log"; then
|
|
echo "[build:shellshock] first-boot install did not complete; see $OUT_PATH.boot.log" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Compact the qcow2 (cloud-init artifacts, package caches).
|
|
qemu-img convert -O qcow2 -c "$OUT_PATH" "$OUT_PATH.compact"
|
|
mv "$OUT_PATH.compact" "$OUT_PATH"
|
|
|
|
echo "[build:shellshock] complete: $OUT_PATH"
|