CIS490/vm/targets/shellshock/build.sh
Max Gorog 4d29b7236d PIPELINE §5 step 3: target VM build infrastructure + containment posture
§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>
2026-05-04 01:31:40 -05:00

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"