Challenge / Web

Dark Runes

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

EasyPublished 2025-08-23Sanitized local writeup

Scenario

Dark Runes attack path

Dark Runes 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.

Dark Runes sanitized attack graph

Walkthrough flow

01

Registration allowed the username admin.

02

The admin middleware trusted only the username value...

03

An admin-owned document could be exported to PDF.

04

Encoded HTML inside an allowed tag survived...

05

node-html-markdown emitted contents without escaping,...

Source coverage

High source coverage

Status: complete. This article is generated from 6 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/Dark-Runes/writeup.md
  • htb-challenge/Web/Dark-Runes/notes.md
  • htb-challenge/Web/Dark-Runes/memory-summary.md
  • htb-challenge/Web/Dark-Runes/hypothesis-board.md
  • HTB/_knowledge/exports/ctf-lightrag-latest-203412/documents/challenge__Web__Dark-Runes__memory-summary.md.29f6b6de8c.md
  • HTB/_knowledge/exports/ctf-lightrag-latest-203412/documents/challenge__Web__Dark-Runes__notes.md.d9a2ce064d.md

Technical Walkthrough

Writeup

Challenge

  • Name: Dark-Runes
  • Category: Web
  • Difficulty: Easy
  • Mode: hybrid

Summary

Dark Runes is a source-provided Express/EJS web challenge. The application has a weak admin model and an unsafe PDF export chain:

  • any user can register with the username admin
  • the admin middleware only checks req.user.username === "admin"
  • admin users can export their own stored documents as PDF
  • sanitized document content can be transformed back into raw HTML through node-html-markdown
  • markdown-pdf renders the result with PhantomJS from a file:// context

The solve registers as admin, stores an encoded local-file iframe inside an allowed <pre> tag, exports the document, and extracts the flag text from the generated PDF.

Artifact Inventory

Key artifacts:

  • files/a12c73ac-7e6c-4d41-bb9e-75cb112a35c0.zip: original challenge archive
  • files/extracted/src/routes/auth.js: registration/login and signed cookie issuance
  • files/extracted/src/middlewares.js: authentication and username-only admin check
  • files/extracted/src/routes/documents.js: document creation and sanitizer configuration
  • files/extracted/src/routes/generate.js: admin PDF export routes
  • files/extracted/src/utils/exporter.js: markdown-pdf/PhantomJS rendering
  • files/extracted/Dockerfile: places the flag at /flag.txt
  • analysis/source-audit.md: exploit decision and source-backed reasoning
  • analysis/local-transform-validation.txt: local sanitizer/renderer transform validation
  • solve/solve.py: reproducible remote solver

Analysis

The first key issue is authorization. The app does not store a role for users. It signs a cookie containing { username, id }, validates the signature, then treats any authenticated user named admin as an admin. Since /register does not reserve or block the admin username, self-registering admin unlocks the admin-only export route.

The second key issue is the PDF pipeline. /documents sanitizes submitted content with sanitize-html, but default allowed tags include <pre>. If encoded HTML is placed inside <pre>, the sanitizer keeps it as text. node-html-markdown then converts the sanitized HTML to Markdown and emits <pre> contents without escaping. That turns encoded HTML back into raw HTML.

The export route passes that Markdown to markdown-pdf with HTML enabled. markdown-pdf uses PhantomJS and sets a file:// base URL. Local validation showed that raw iframe/object/embed/script payloads can load a local text file into the PDF text layer.

The working payload pattern was:

html
<pre>&lt;iframe src="file:///flag.txt"&gt;&lt;/iframe&gt;</pre>

After transformation, PhantomJS receives a real iframe pointing at /flag.txt, and the resulting PDF contains the flag text.

Solve

Run:

bash
python3 Web/Dark-Runes/solve/solve.py \
  --base-url http://<TARGET>:31106 \
  --output Web/Dark-Runes/loot/flag-candidate.txt \
  --pdf-output Web/Dark-Runes/loot/generated.pdf \
  --transcript Web/Dark-Runes/analysis/remote/solve-transcript-redacted.txt
python3 scripts/challenge_harness.py capture-flag Web/Dark-Runes --from loot/flag-candidate.txt

The solver:

  1. registers and logs in as admin
  2. creates a document containing the encoded iframe payload
  3. exports that document through /document/export/:id
  4. stores the generated PDF under loot/
  5. runs pdftotext on the PDF
  6. writes the HTB-format flag candidate under loot/
  7. writes only a redacted transcript under analysis/remote/

Flag

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

Lessons

  • Username-based admin checks are fragile when registration can claim the privileged name.
  • Sanitizer output should not be fed into a second parser that can reinterpret encoded text as executable or loadable HTML.
  • PDF renderers that run from file:// contexts can become local file read gadgets.
  • A source-provided challenge should still validate library behavior locally before remote exploitation.

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: Dark-Runes
  • Category: Web
  • Difficulty: Easy
  • Mode: hybrid
  • Remote instance: <TARGET>:31106
  • Start time: 2026-06-08T04:06:37Z
  • 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/a12c73ac-7e6c-4d41-bb9e-75cb112a35c0.zip37687<hash redacted>Zip archive data, at least v2.0 to extract, compression method=deflatezip entries: 25 shown in artifact inventory JSON

Evidence Ledger

TimeActionOutput/FileFindingConfidenceNext
2026-06-08T04:06:37Zharness initchallenge-state.jsonWorkspace initialized with deterministic state fileHighInventory artifacts
2026-06-08T04:06:57Zartifact inventoryanalysis/artifact-inventory.json1 artifact(s) inventoriedHighBuild or update hypotheses
2026-06-08T04:07:04Zhypothesis recordedhypothesis-board.mdSource-first web audit: inspect routes, authentication, template rendering, file access, upload/parsing, and any blueprint/rune-related endpoints before remote exploitation.MediumExtract the source archive, map dependency stack and route handlers, then test the smallest source-backed request chain.
2026-06-08T04:07:16Zcheckpoint recordedanalysis/checkpoint-triage-20260608T040716637446Z-718e5b7b.mdCheckpoint for TRIAGEHighUse checkpoint to drive next decision
2026-06-08T04:07:27Zlocal memory recordanalysis/local-memory-records.mdPrior local notes reviewed as fallback/advisory contextMediumValidate against current evidence
2026-06-08T04:12:48Zsource auditanalysis/source-audit.mdSource audit recordedHighGate before exploit
2026-06-08T04:12:56Zevaluatoranalysis/evaluator-20260608T041256880335Z-9273a711.mdProceedHighRun the solver against the remote instance, then capture the flag through the harness if returned.
2026-06-08T04:13:11Zflag captureloot/flag.txtHTB-format flag captured; raw value kept in loot onlyHighWrite solution and run completion gate
2026-06-08T04:14:06Zcompletion 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: Dark-Runes
  • Difficulty: Easy
  • Source workspace: <local workspace>

Validated Solve Chain

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

  1. Registration allowed the username admin.
  2. The admin middleware trusted only the username value from the signed cookie.
  3. An admin-owned document could be exported to PDF.
  4. Encoded HTML inside an allowed <pre> tag survived sanitization as text.
  5. node-html-markdown emitted <pre> contents without escaping, turning encoded iframe HTML back into raw HTML.
  6. markdown-pdf rendered the raw iframe with PhantomJS from a file:// context.
  7. The iframe loaded the local flag file into the PDF text layer, and the solver extracted the HTB-format flag from the PDF text.

Reusable Lessons

  • If admin is represented only as a username, test whether that username can be self-registered.
  • Sanitizer bypasses can appear across parser boundaries, especially when safe text is later decoded or emitted without escaping.
  • PDF/HTML renderers can be local file read gadgets when they render user-controlled HTML from a file-origin page.
  • Avoid unnecessary brute force when a normal, authenticated feature chain reaches the same sink.

Dead Ends

  • The debug export route requires a rotating access pass. It was not needed because the normal admin export route was reachable through self-registration.

Tool Quirks

  • Full local app install failed on the host because the native SQLite dependency did not build against the host Node version.
  • Docker CLI was present, but the daemon was not running.
  • Pure JS transform packages were installed in analysis/js-transform-test/ for local sanitizer and Markdown conversion validation.
  • Generated PDFs that may contain raw flag material are kept under loot/.

Evidence Paths

  • analysis/source-audit.md
  • analysis/local-transform-validation.txt
  • analysis/remote/solve-transcript-redacted.txt
  • solve/solve.py
  • loot/flag.txt
  • loot/generated.pdf

Ingestion Decision

  • Proposed for LightRAG: yes, after user approval
  • 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
1Source-first web audit: inspect routes, authentication, template rendering, file access, upload/parsing, and any blueprint/rune-related endpoints before remote exploitation.Provided archive plus live remote instance indicates source+remote Web challenge.Exact vulnerability and exploit chain.Extract the source archive, map dependency stack and route handlers, then test the smallest source-backed request chain.Mediumactive

Closed Branches

BranchEvidence TestedFailure OutputReason ClosedRevisit Condition

Memory Summary

approval_required: true

Sanitized Memory Summary

Metadata

  • Platform: HackTheBox Challenges
  • Category: Web
  • Challenge: Dark-Runes
  • Difficulty: Easy
  • Source workspace: <local workspace>

Validated Solve Chain

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

  1. Registration allowed the username admin.
  2. The admin middleware trusted only the username value from the signed cookie.
  3. An admin-owned document could be exported to PDF.
  4. Encoded HTML inside an allowed <pre> tag survived sanitization as text.
  5. node-html-markdown emitted <pre> contents without escaping, turning encoded iframe HTML back into raw HTML.
  6. markdown-pdf rendered the raw iframe with PhantomJS from a file:// context.
  7. The iframe loaded the local flag file into the PDF text layer, and the solver extracted the HTB-format flag from the PDF text.

Reusable Lessons

  • If admin is represented only as a username, test whether that username can be self-registered.
  • Sanitizer bypasses can appear across parser boundaries, especially when safe text is later decoded or emitted without escaping.
  • PDF/HTML renderers can be local file read gadgets when they render user-controlled HTML from a file-origin page.
  • Avoid unnecessary brute force when a normal, authenticated feature chain reaches the same sink.

Dead Ends

  • The debug export route requires a rotating access pass. It was not needed because the normal admin export route was reachable through self-registration.

Tool Quirks

  • Full local app install failed on the host because the native SQLite dependency did not build against the host Node version.
  • Docker CLI was present, but the daemon was not running.
  • Pure JS transform packages were installed in analysis/js-transform-test/ for local sanitizer and Markdown conversion validation.
  • Generated PDFs that may contain raw flag material are kept under loot/.

Evidence Paths

  • analysis/source-audit.md
  • analysis/local-transform-validation.txt
  • analysis/remote/solve-transcript-redacted.txt
  • solve/solve.py
  • loot/flag.txt
  • loot/generated.pdf

Ingestion Decision

  • Proposed for LightRAG: yes, after user approval
  • Requires user approval before ingestion: yes

Notes

Notes

Scope

  • Challenge: Dark-Runes
  • Category: Web
  • Difficulty: Easy
  • Mode: hybrid
  • Remote instance: <TARGET>:31106
  • Start time: 2026-06-08T04:06:37Z
  • 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/a12c73ac-7e6c-4d41-bb9e-75cb112a35c0.zip37687<hash redacted>Zip archive data, at least v2.0 to extract, compression method=deflatezip entries: 25 shown in artifact inventory JSON

Evidence Ledger

TimeActionOutput/FileFindingConfidenceNext
2026-06-08T04:06:37Zharness initchallenge-state.jsonWorkspace initialized with deterministic state fileHighInventory artifacts
2026-06-08T04:06:57Zartifact inventoryanalysis/artifact-inventory.json1 artifact(s) inventoriedHighBuild or update hypotheses
2026-06-08T04:07:04Zhypothesis recordedhypothesis-board.mdSource-first web audit: inspect routes, authentication, template rendering, file access, upload/parsing, and any blueprint/rune-related endpoints before remote exploitation.MediumExtract the source archive, map dependency stack and route handlers, then test the smallest source-backed request chain.
2026-06-08T04:07:16Zcheckpoint recordedanalysis/checkpoint-triage-20260608T040716637446Z-718e5b7b.mdCheckpoint for TRIAGEHighUse checkpoint to drive next decision
2026-06-08T04:07:27Zlocal memory recordanalysis/local-memory-records.mdPrior local notes reviewed as fallback/advisory contextMediumValidate against current evidence
2026-06-08T04:12:48Zsource auditanalysis/source-audit.mdSource audit recordedHighGate before exploit
2026-06-08T04: <REDACTED>, then capture the flag through the harness if returned.
2026-06-08T04: <REDACTED>
2026-06-08T04:14:06Zcompletion 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.

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 Dark Runes, 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.