Challenge / Web

DoxPit

DoxPit is a sanitized challenge note from the local HTB archive, organized for quick review by category, difficulty, evidence flow, and reusable operator

MediumPublished 2025-09-09Sanitized local writeup

Scenario

DoxPit attack path

DoxPit is a sanitized challenge note from the local HTB archive, organized for quick review by category, difficulty, evidence flow, and reusable operator

Objective

Challenge walkthrough focused on Web evidence, validation, and reusable operator lessons.

DoxPit sanitized attack graph

Walkthrough flow

01

Source and route audit

02

Trust boundary flaw

03

Exploit request chain

04

Admin or proof proof

Source coverage

High source coverage

Status: complete. This article is generated from 4 sanitized Markdown sources and keeps raw flags, credentials, keys, cookies, and reusable secrets out of the rendered blog.

100% coverage
Evidence verdict

High confidence: the page is reconstructed from a primary walkthrough plus multiple supporting notes or evidence sources. Treat the chain as source-backed, while still checking the listed source files for sensitive values.

  • Web/DoxPit/writeup.md
  • htb-challenge/Web/DoxPit/notes.md
  • htb-challenge/Web/DoxPit/memory-summary.md
  • htb-challenge/Web/DoxPit/hypothesis-board.md

Technical Walkthrough

Writeup

Challenge

  • Name: DoxPit
  • Category: Web
  • Difficulty: Medium
  • Mode: hybrid

Summary

DoxPit is a hybrid Web challenge with a public Next.js frontend and an internal Flask "AV" service. The solved chain was:

  1. Abuse a vulnerable Next.js Server Actions redirect path to SSRF into the internal Flask service.
  2. Use the internal /register route to create a token-bearing user.
  3. Use that token against /home.
  4. Trigger Jinja SSTI through the directory parameter and read the randomized /flag* path.

Artifact Inventory

Reference analysis/artifact-inventory.json and summarize the relevant files or remote surface.

Relevant artifacts:

  • files/extracted/challenge/front-end/package.json: pins next to 14.1.0.
  • files/extracted/challenge/front-end/app/serverActions.tsx: exports a server action that calls redirect("/error").
  • files/extracted/challenge/av/application/blueprints/routes.py: exposes /register and token-authenticated /home.
  • files/extracted/challenge/av/application/util/general.py: defines the weak blacklist for directory.
  • files/extracted/entrypoint.sh: renames /flag.txt to /flag<10 hex>.txt.
  • analysis/ssrf-register-nextaction-response.txt: live proof that the Next-Action request reached internal Flask /register.
  • analysis/remote-solve-transcript.txt: sanitized execution transcript for the final solver run.

Analysis

The Next source and package metadata matched the Server Actions redirect SSRF class:

  • next is 14.1.0.
  • doRedirect() calls redirect("/error").
  • The live root page exposed a $<secret redacted>... value for the action.

The browser-style form request only produced a normal redirect. The server-action request shape that worked was:

  • POST /
  • Host: <attacker tunnel host>
  • Next-Action: <live action id>
  • Content-Type: text/plain;charset=UTF-8
  • body []

That returned the internal Flask registration response through the Next response, proving SSRF into <TARGET>:3000.

The Flask backend then supplied the second primitive. /register returns a token, and /home accepts either a logged-in Flask session or a valid token query parameter. /home rejects only a short blacklist in directory, then replaces {{ results.scanned_directory }} with the raw directory value before calling render_template_string(). That creates a Jinja SSTI sink.

The final payload avoided the blacklist by using Jinja statement delimiters, attr(), and generated underscores via format(95,95,95,95), then executed cat /flag* to handle the randomized filename.

Public research used:

  • Assetnote advisory on <secret redacted>: https://www.assetnote.io/resources/research/advisory-next-js-ssrf-cve-2024-34351/
  • Assetnote Server Actions SSRF background: https://www.assetnote.io/resources/research/digging-for-ssrf-in-nextjs-apps/

The public research was treated only as a lead. The exploit decision came from the provided source and the live SSRF proof.

Solve

The reproducible solver is solve/solve.py. It:

  1. Fetches the live root page and extracts the server-action ID.
  2. Starts a local HTTP redirect helper.
  3. Uses a Cloudflare tunnel, or a supplied stable tunnel, as the attacker-controlled Host.
  4. Sends the validated Next-Action request to SSRF into internal Flask /register.
  5. Parses the returned token.
  6. Sends a second SSRF request to the authenticated /home scan route with the returned bearer value and the encoded directory payload.
  7. Writes only the flag candidate to the requested output file.

Final tracked execution used:

bash
python3 scripts/challenge_exec.py Web/DoxPit -- python3 Web/DoxPit/solve/solve.py \
  --base-url http://<TARGET>:31944 \
  --tunnel-url https://unable-names-upgrades-flying.trycloudflare.com \
  --helper-port 8123 \
  --output Web/DoxPit/analysis/flag-candidate.txt \
  --transcript Web/DoxPit/analysis/remote-solve-transcript.txt

Then the harness captured the candidate into loot/flag.txt.

Flag

Raw flag is stored in loot/flag.txt and intentionally not reproduced here.

Lessons

  • For source-backed Medium Web challenges, source audit plus one high-signal live validation is stronger than broad fuzzing.
  • Next.js Server Actions requests are not always equivalent to browser form submissions; the Next-Action header and request body shape mattered here.
  • Blacklists that block literal Jinja expression delimiters and attribute syntax can still be bypassed with statement delimiters plus dynamic attribute access.
  • Randomized flag filenames should be read with a glob such as /flag* when the entrypoint proves the naming convention.

Source-Backed Dossier

The sections below are merged from companion Markdown notes for the same case. They are rendered after sanitization so the article stays precise without publishing raw flags, credentials, or target-specific secrets.

Notes

Scope

  • Challenge: DoxPit
  • Category: Web
  • Difficulty: Medium
  • Mode: hybrid
  • Remote instance: <TARGET>:31944
  • Start time: 2026-06-12T13:54:35Z
  • Operator: harness
  • State file: challenge-state.json

Harness Status

  • Current phase: see challenge-state.json
  • Next allowed actions: see next-action.json
  • Raw flags and sensitive material stay in loot/ only. Do not paste them here.

Artifact Inventory

FileSizeSHA256TypeNotes
files/a12c7371-7866-481d-81ef-4747c2fff58c.zip70557<hash redacted>Zip archive data, at least v2.0 to extract, compression method=deflatezip entries: 55 shown in artifact inventory JSON
files/extracted/Dockerfile1621<hash redacted>ASCII text
files/extracted/build_docker.sh134<hash redacted>Bourne-Again shell script text executable, ASCII text
files/extracted/challenge/av/application/app.py645<hash redacted>Python script text executable, ASCII text
files/extracted/challenge/av/application/blueprints/routes.py2889<hash redacted>Python script text executable, ASCII text
files/extracted/challenge/av/application/config.py261<hash redacted>Python script text executable, ASCII text
files/extracted/challenge/av/application/static/css/styles.css1858<hash redacted>ASCII text
files/extracted/challenge/av/application/static/images/icon.png2387<hash redacted>PNG image data, 64 x 64, 8-bit/color RGBA, non-interlaced
files/extracted/challenge/av/application/templates/error.html415<hash redacted>HTML document text, ASCII text
files/extracted/challenge/av/application/templates/index.html918<hash redacted>HTML document text, ASCII text
files/extracted/challenge/av/application/templates/layouts/base.html144<hash redacted>HTML document text, ASCII text
files/extracted/challenge/av/application/templates/layouts/head.html245<hash redacted>HTML document text, ASCII text
files/extracted/challenge/av/application/templates/login.html716<hash redacted>HTML document text, ASCII text
files/extracted/challenge/av/application/templates/register.html682<hash redacted>HTML document text, ASCII text
files/extracted/challenge/av/application/templates/scan.html1446<hash redacted>HTML document text, ASCII text
files/extracted/challenge/av/application/util/database.py2086<hash redacted>Python script text executable, ASCII text
files/extracted/challenge/av/application/util/general.py202<hash redacted>Python script text executable, ASCII text
files/extracted/challenge/av/application/util/scanner.py1858<hash redacted>Python script text executable, ASCII text
files/extracted/challenge/av/requirements.txt86<hash redacted>ASCII text
files/extracted/challenge/av/run.py198<hash redacted>Python script text executable, ASCII text
files/extracted/challenge/front-end/.eslintrc.json40<hash redacted>JSON data
files/extracted/challenge/front-end/.gitignore391<hash redacted>ASCII text
files/extracted/challenge/front-end/app/asciiart.tsx8961<hash redacted>Java source, Unicode text, UTF-8 text
files/extracted/challenge/front-end/app/error/page.tsx545<hash redacted>Java source, ASCII text
files/extracted/challenge/front-end/app/globals.css3776<hash redacted>ASCII text
files/extracted/challenge/front-end/app/hof/page.tsx2562<hash redacted>Java source, Unicode text, UTF-8 text
files/extracted/challenge/front-end/app/layout.tsx623<hash redacted>HTML document text, ASCII text
files/extracted/challenge/front-end/app/navbar.tsx2040<hash redacted>ASCII text
files/extracted/challenge/front-end/app/page.tsx5161<hash redacted>Java source, Unicode text, UTF-8 text
files/extracted/challenge/front-end/app/pastetable.tsx1731<hash redacted>HTML document text, ASCII text
files/extracted/challenge/front-end/app/serverActions.tsx120<hash redacted>Java source, ASCII text
files/extracted/challenge/front-end/next-env.d.ts201<hash redacted>ASCII text
files/extracted/challenge/front-end/next.config.mjs92<hash redacted>ASCII text
files/extracted/challenge/front-end/package.json530<hash redacted>JSON data
files/extracted/challenge/front-end/public/doxpit-ascii.png11592<hash redacted>PNG image data, 555 x 277, 8-bit/color RGBA, non-interlaced
files/extracted/challenge/front-end/public/oni-ascii.png40868<hash redacted>PNG image data, 555 x 526, 8-bit/color RGBA, non-interlaced
files/extracted/challenge/front-end/tsconfig.json574<hash redacted>JSON data
files/extracted/config/supervisord.conf419<hash redacted>ASCII text
files/extracted/entrypoint.sh215<hash redacted>POSIX shell script text executable, ASCII text
files/extracted/flag.txt26<hash redacted>ASCII text, with no line terminators

Evidence Ledger

TimeActionOutput/FileFindingConfidenceNext
2026-06-12T13:54:35Zharness initchallenge-state.jsonWorkspace initialized with deterministic state fileHighInventory artifacts
2026-06-12T13:54:45Zartifact inventoryanalysis/artifact-inventory.json40 artifact(s) inventoriedHighBuild or update hypotheses
2026-06-12T14:04:48Zhypothesis recordedhypothesis-board.mdNext.js 14.1.0 Server Actions SSRF reaches internal Flask AV, registers a token, then authenticated /home directory SSTI reads randomized /flag*.txtHighRun solve/solve.py through challenge_exec; it starts a tunnel helper, obtains token via SSRF, sends SSTI payload via SSRF, and writes only a flag candidate file.
2026-06-12T14:04:59Zsource auditanalysis/source-audit.mdSource audit recordedHighGate before exploit
2026-06-12T14:04:59Zresearch recordanalysis/research/research-records.mdResearch tagged MATCHEDMediumValidate against current evidence
2026-06-12T14:05:10Zcheckpoint recordedanalysis/checkpoint-analysis-20260612T140510771810Z-24ca7f0a.mdCheckpoint for ANALYSISHighUse checkpoint to drive next decision
2026-06-12T14:05:32ZRAG queryanalysis/rag/rag-query-20260612T140519857091Z-f0660a97.txtRAG helper exited 0; output savedMediumRecord retrieval tag and validation
2026-06-12T14:05:50ZRAG recordanalysis/rag-records.mdRetrieved memory tagged MISSINGMediumValidate or reject with live evidence
2026-06-12T14:06:07Zinstrumentation plananalysis/instrumentation-plan.mdCapture the flag through a bounded Next.js SSRF to internal Flask token creation followed by token-authenticated Jinja SSTI against /home.HighStop after two failed SSRF/SSTI attempts without a new response difference; record failure and do not continue blind remote probing.
2026-06-12T14:06:24Zlocal memory searchanalysis/research/local-memory-search-20260612T140624922497Z-e5672713.mdFound 8 safe prior-note result(s)MediumRecord useful result or skip
2026-06-12T14:06:41Zlocal memory recordanalysis/local-memory-records.mdPrior local notes reviewed as fallback/advisory contextMediumValidate against current evidence
2026-06-12T14:06:53Zevaluatoranalysis/evaluator-20260612T140653692969Z-dd8b471f.mdProceedHighRun gated solver through scripts/challenge_exec.py Web/DoxPit -- python3 Web/DoxPit/solve/solve.py --base-url http://<TARGET>:31944 --output Web/DoxPit/analysis/flag-candidate.txt --transcript Web/DoxPit/analysis/remote-solve-transcript.txt
2026-06-12T14:11:37Zflag captureloot/flag.txtHTB-format flag captured; raw value kept in loot onlyHighWrite solution and run completion gate
2026-06-12T14:12:35Zcompletion gatechallenge-state.jsonCompletion gate passed; state marked COMPLETEHighOptional sanitized memory summary approval
2026-06-12T14:13:24Zcompletion gatechallenge-state.jsonCompletion gate passed; state marked COMPLETEHighOptional sanitized memory summary approval

Key Findings

-

RAG / Advisory Memory

RAG output is advisory only. Record evaluated retrievals with:

bash
scripts/challenge_harness.py rag-record <workspace> --query "..." --tag MATCHED|PARTIAL|MISSING|<secret redacted>|GENERIC --validation "..."

Secrets/Flags

Raw flags and sensitive material stay in loot/ only. Use scripts/challenge_harness.py capture-flag to validate and record flag capture without printing the value.

Memory Summary

Metadata

  • Platform: HackTheBox Challenges
  • Category: Web
  • Challenge: DoxPit
  • Difficulty: Medium
  • Source workspace: <local workspace>

Validated Solve Chain

Concepts only. Do not include raw flags, reusable credentials, tokens, cookies, private keys, or live secrets.

1.

Reusable Lessons

-

Dead Ends

-

Tool Quirks

-

Evidence Paths

-

Ingestion Decision

  • Proposed for LightRAG: yes/no
  • Requires user approval before ingestion: yes

Hypothesis Board

Keep no more than 3 active hypotheses on Easy/Medium and 5 on Hard unless the user explicitly asks for breadth.

RankPathEvidenceMissing ProofCheapest ValidationConfidenceStatus
1Next.js 14.1.0 Server Actions SSRF reaches internal Flask AV, registers a token, then authenticated /home directory SSTI reads randomized /flag*.txtSource pins next 14.1.0 and has server action redirect("/error"); Flask /register returns tokens; /home accepts token auth and inserts raw directory into render_template_string after a weak blacklist; live Next-Action Host-header request returned internal Flask registration HTML and token.Need final bounded SSRF request to /home with validated blacklist-safe Jinja payload and harness flag capture.Run solve/solve.py through challenge_exec; it starts a tunnel helper, obtains token via SSRF, sends SSTI payload via SSRF, and writes only a flag candidate file.HighActive

Closed Branches

BranchEvidence TestedFailure OutputReason ClosedRevisit Condition

Technical analogy

How to remember this solve

Think of the web app like a building with signs on every door. The solve usually comes from reading the map carefully, finding the door the app forgot to hide, then sending the exact request that proves you understand the route.

For DoxPit, keep the mental model simple: identify the trusted assumption, prove it with the smallest safe test, then automate or repeat only the part that directly leads to the flag.