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
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.
Walkthrough flow
Registration allowed the username admin.
The admin middleware trusted only the username value...
An admin-owned document could be exported to PDF.
Encoded HTML inside an allowed tag survived...
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.
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-pdfrenders the result with PhantomJS from afile://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 archivefiles/extracted/src/routes/auth.js: registration/login and signed cookie issuancefiles/extracted/src/middlewares.js: authentication and username-only admin checkfiles/extracted/src/routes/documents.js: document creation and sanitizer configurationfiles/extracted/src/routes/generate.js: admin PDF export routesfiles/extracted/src/utils/exporter.js:markdown-pdf/PhantomJS renderingfiles/extracted/Dockerfile: places the flag at/flag.txtanalysis/source-audit.md: exploit decision and source-backed reasoninganalysis/local-transform-validation.txt: local sanitizer/renderer transform validationsolve/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:
<pre><iframe src="file:///flag.txt"></iframe></pre>After transformation, PhantomJS receives a real iframe pointing at /flag.txt, and the resulting PDF contains the flag text.
Solve
Run:
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.txtThe solver:
- registers and logs in as
admin - creates a document containing the encoded iframe payload
- exports that document through
/document/export/:id - stores the generated PDF under
loot/ - runs
pdftotexton the PDF - writes the HTB-format flag candidate under
loot/ - 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
| File | Size | SHA256 | Type | Notes |
|---|---|---|---|---|
files/a12c73ac-7e6c-4d41-bb9e-75cb112a35c0.zip | 37687 | <hash redacted> | Zip archive data, at least v2.0 to extract, compression method=deflate | zip entries: 25 shown in artifact inventory JSON |
Evidence Ledger
| Time | Action | Output/File | Finding | Confidence | Next |
|---|---|---|---|---|---|
| 2026-06-08T04:06:37Z | harness init | challenge-state.json | Workspace initialized with deterministic state file | High | Inventory artifacts |
| 2026-06-08T04:06:57Z | artifact inventory | analysis/artifact-inventory.json | 1 artifact(s) inventoried | High | Build or update hypotheses |
| 2026-06-08T04:07:04Z | hypothesis recorded | hypothesis-board.md | Source-first web audit: inspect routes, authentication, template rendering, file access, upload/parsing, and any blueprint/rune-related endpoints before remote exploitation. | Medium | Extract the source archive, map dependency stack and route handlers, then test the smallest source-backed request chain. |
| 2026-06-08T04:07:16Z | checkpoint recorded | analysis/checkpoint-triage-20260608T040716637446Z-718e5b7b.md | Checkpoint for TRIAGE | High | Use checkpoint to drive next decision |
| 2026-06-08T04:07:27Z | local memory record | analysis/local-memory-records.md | Prior local notes reviewed as fallback/advisory context | Medium | Validate against current evidence |
| 2026-06-08T04:12:48Z | source audit | analysis/source-audit.md | Source audit recorded | High | Gate before exploit |
| 2026-06-08T04:12:56Z | evaluator | analysis/evaluator-20260608T041256880335Z-9273a711.md | Proceed | High | Run the solver against the remote instance, then capture the flag through the harness if returned. |
| 2026-06-08T04:13:11Z | flag capture | loot/flag.txt | HTB-format flag captured; raw value kept in loot only | High | Write solution and run completion gate |
| 2026-06-08T04:14:06Z | completion gate | challenge-state.json | Completion gate passed; state marked COMPLETE | High | Optional sanitized memory summary approval |
Key Findings
-
RAG / Advisory Memory
RAG output is advisory only. Record evaluated retrievals with:
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.
- Registration allowed the username
admin. - The admin middleware trusted only the username value from the signed cookie.
- An admin-owned document could be exported to PDF.
- Encoded HTML inside an allowed
<pre>tag survived sanitization as text. node-html-markdownemitted<pre>contents without escaping, turning encoded iframe HTML back into raw HTML.markdown-pdfrendered the raw iframe with PhantomJS from afile://context.- 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.mdanalysis/local-transform-validation.txtanalysis/remote/solve-transcript-redacted.txtsolve/solve.pyloot/flag.txtloot/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.
| Rank | Path | Evidence | Missing Proof | Cheapest Validation | Confidence | Status |
|---|---|---|---|---|---|---|
| 1 | Source-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. | Medium | active |
Closed Branches
| Branch | Evidence Tested | Failure Output | Reason Closed | Revisit 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.
- Registration allowed the username
admin. - The admin middleware trusted only the username value from the signed cookie.
- An admin-owned document could be exported to PDF.
- Encoded HTML inside an allowed
<pre>tag survived sanitization as text. node-html-markdownemitted<pre>contents without escaping, turning encoded iframe HTML back into raw HTML.markdown-pdfrendered the raw iframe with PhantomJS from afile://context.- 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.mdanalysis/local-transform-validation.txtanalysis/remote/solve-transcript-redacted.txtsolve/solve.pyloot/flag.txtloot/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
| File | Size | SHA256 | Type | Notes |
|---|---|---|---|---|
files/a12c73ac-7e6c-4d41-bb9e-75cb112a35c0.zip | 37687 | <hash redacted> | Zip archive data, at least v2.0 to extract, compression method=deflate | zip entries: 25 shown in artifact inventory JSON |
Evidence Ledger
| Time | Action | Output/File | Finding | Confidence | Next |
|---|---|---|---|---|---|
| 2026-06-08T04:06:37Z | harness init | challenge-state.json | Workspace initialized with deterministic state file | High | Inventory artifacts |
| 2026-06-08T04:06:57Z | artifact inventory | analysis/artifact-inventory.json | 1 artifact(s) inventoried | High | Build or update hypotheses |
| 2026-06-08T04:07:04Z | hypothesis recorded | hypothesis-board.md | Source-first web audit: inspect routes, authentication, template rendering, file access, upload/parsing, and any blueprint/rune-related endpoints before remote exploitation. | Medium | Extract the source archive, map dependency stack and route handlers, then test the smallest source-backed request chain. |
| 2026-06-08T04:07:16Z | checkpoint recorded | analysis/checkpoint-triage-20260608T040716637446Z-718e5b7b.md | Checkpoint for TRIAGE | High | Use checkpoint to drive next decision |
| 2026-06-08T04:07:27Z | local memory record | analysis/local-memory-records.md | Prior local notes reviewed as fallback/advisory context | Medium | Validate against current evidence |
| 2026-06-08T04:12:48Z | source audit | analysis/source-audit.md | Source audit recorded | High | Gate before exploit |
| 2026-06-08T04: <REDACTED>, then capture the flag through the harness if returned. | |||||
| 2026-06-08T04: <REDACTED> | |||||
| 2026-06-08T04:14:06Z | completion gate | challenge-state.json | Completion gate passed; state marked COMPLETE | High | Optional sanitized memory summary approval |
Key Findings
-
RAG / Advisory Memory
RAG output is advisory only. Record evaluated retrievals with:
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.