Challenge / Web

OfflineA

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

EasyPublished 2025-10-07Sanitized local writeup

Scenario

OfflineA attack path

OfflineA 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.

OfflineA 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 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/OfflineA/writeup.md
  • htb-challenge/Web/OfflineA/notes.md
  • htb-challenge/Web/OfflineA/memory-summary.md
  • htb-challenge/Web/OfflineA/hypothesis-board.md
  • HTB/_knowledge/exports/ctf-lightrag-latest-203412/documents/challenge__Web__OfflineA__memory-summary.md.31c04b3e0e.md
  • HTB/_knowledge/exports/ctf-lightrag-latest-203412/documents/challenge__Web__OfflineA__notes.md.5e021eabf4.md

Technical Walkthrough

Writeup

Challenge

  • Name: OfflineA
  • Category: Web
  • Difficulty: Easy
  • Mode: hybrid

Summary

OfflineA is a PHP frontend plus an internal Flask service behind Selenium PDF generation. The break comes from a deliberate parser mismatch: PHP validates the last duplicate url parameter while Flask consumes the first. That lets a request look harmless to bartender.php while Selenium is pointed at internal Flask endpoints. From there, a Python str.format() primitive in /logs leaks the runtime Flask <secret redacted>, which is then used to forge an HS256 JWT for the protected /bartender endpoint and recover the flag from the secrets table rendered into a PDF.

Artifact Inventory

The key files were analysis/static/bartender.php.txt, analysis/static/internal-app.py.txt, analysis/static/init_db.py.txt, and analysis/static/internal-template-bartender.html.txt. bartender.php validates a public URL and forwards the raw query string to internal Flask /generate; app.py consumes the first duplicate url, sends Selenium to that address, stores the visited URL in SQLite history, and exposes /logs plus JWT-protected /bartender. The remote surface was minimal: / served the bartender UI and /bartender.php produced PDFs or redirected to no_way.pdf.

Analysis

The exploit hinges on two verified parser behaviors. bartender.php reads $_GET['url'] but forwards $_SERVER['<secret redacted>'], while Flask later reads request.args.get('url'). Local validation in analysis/local-validation/duplicate-param-parser-check.txt proved the split: PHP keeps the last duplicate url, Werkzeug keeps the first.

That parser split gives control of Selenium’s internal target. The first stage seeds history with a URL containing {logify.__globals__[app].config[<secret redacted>]}. This works because /logs builds history_1 from history rows and then calls history_1.format(logify=logify), a primitive already confirmed in analysis/local-validation/python-format-leak-check.txt. Rendering /logs into a PDF leaks a 64-hex Flask secret in page text.

With that secret in hand, the next stage signs an HS256 JWT such as {"username":"admin","is_admin":true}. The protected /bartender route returns the entire secrets table as JSON, and init_db.py shows that the real flag is stored there under oldest_user_of_bartender. The successful live run against the refreshed instance is summarized in analysis/remote/exploit-success-20260608.md.

Solve

The reproducible solver is solve/solve.py. It performs three internal PDF-generation stages against the public bartender.php endpoint using ordered duplicate query parameters:

  1. Seed history with the format-string URL while PHP validates an external safe URL.
  2. Render /logs to extract candidate Flask <secret redacted> values from a generated PDF.
  3. Forge an HS256 JWT, render internal /bartender?token: <redacted>, and extract the flag from the resulting PDF.

The script writes the raw candidate only to loot/flag-candidate.txt. capture-flag then stores the final HTB flag in loot/flag.txt.

Flag

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

Lessons

A request can be safe in one parser and dangerous in the next. OfflineA depended on PHP validating one interpretation of the query string while Flask executed another. Once a cross-parser mismatch exists, seemingly unrelated bugs like a format-string read in /logs and a JWT-protected admin route can chain cleanly into a full secret disclosure path.

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: OfflineA
  • Category: Web
  • Difficulty: Easy
  • Mode: hybrid
  • Remote instance: <TARGET>:32008
  • Start time: 2026-06-07T18:10:21Z
  • 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/a12c73b2-77a7-40e1-93fa-3e8f3e619ae8.zip3244438<hash redacted>Zip archive data, at least v1.0 to extract, compression method=storezip entries: 21 shown in artifact inventory JSON
files/extracted/web_offlinea/Dockerfile551<hash redacted>ASCII text, with CRLF line terminators
files/extracted/web_offlinea/build-docker.sh130<hash redacted>Bourne-Again shell script text executable, ASCII text
files/extracted/web_offlinea/challenge/internal/app.py6659<hash redacted>Python script text executable, ASCII text, with CRLF line terminators
files/extracted/web_offlinea/challenge/internal/init_db.py1083<hash redacted>Python script text executable Python script text executable, ASCII text
files/extracted/web_offlinea/challenge/internal/templates/bartender.html131<hash redacted>HTML document text, ASCII text
files/extracted/web_offlinea/challenge/service/bartender.php3416<hash redacted>PHP script text, ASCII text, with CRLF line terminators
files/extracted/web_offlinea/challenge/service/images/bar.png3020206<hash redacted>PNG image data, 1536 x 1024, 8-bit/color RGB, non-interlaced
files/extracted/web_offlinea/challenge/service/images/bartender.jpg254455<hash redacted>JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16, Exif Standard: [TIFF image data, little-endian, direntries=3, orientation=upper-left, software=Picasa], baseline, precision 8, 1024x1024, components 3
files/extracted/web_offlinea/challenge/service/index.html6246<hash redacted>HTML document text, ASCII text
files/extracted/web_offlinea/challenge/service/pdfs/no_way.pdf8381<hash redacted>PDF document, version 1.4, 1 pages
files/extracted/web_offlinea/config/supervisord.conf662<hash redacted>ASCII text
files/extracted/web_offlinea/flag.txt27<hash redacted>ASCII text
files/extracted/web_offlinea/requirements.txt43<hash redacted>ASCII text, with CRLF line terminators

Evidence Ledger

TimeActionOutput/FileFindingConfidenceNext
2026-06-07T18:10:21Zharness initchallenge-state.jsonWorkspace initialized with deterministic state fileHighInventory artifacts
2026-06-07T18:10:58Zartifact inventoryanalysis/artifact-inventory.json14 artifact(s) inventoriedHighBuild or update hypotheses
2026-06-07T18:12:17Zhypothesis recordedhypothesis-board.mdExploit PHP/Flask duplicate-parameter parsing mismatch to send a safe public URL to PHP validation while Flask/Selenium receives an internal localhost URL. Use an encoded Python format-string payload in the stored history URL to leak Flask <secret redacted> from /logs, then sign a JWT and make Selenium print /bartender secrets into a public PDF.HighRun local PHP+Flask stack or minimal parser tests to confirm PHP duplicate parameter chooses last url while Flask/Werkzeug chooses first, then test encoded {logify.__globals__[app].config[<secret redacted>]} payload in history and generated PDF output.
2026-06-07T18:12:17Zsource auditanalysis/source-audit.mdSource audit recordedHighGate before exploit
2026-06-07T18:12:37Zcheckpoint recordedanalysis/checkpoint-hypothesis_ready-20260607T181237088431Z-2f03a3fa.mdCheckpoint for <secret redacted>HighUse checkpoint to drive next decision
2026-06-07T18:12:37Zresearch taskanalysis/research/task-20260607T181237090660Z-8f22e9be.mdResearch task created for advisory investigationMediumRecord research output
2026-06-07T18:12:37Zlocal memory searchanalysis/research/local-memory-search-20260607T181237610542Z-60dae707.mdFound 8 safe prior-note result(s)MediumRecord useful result or skip
2026-06-07T18:12:56ZRAG queryanalysis/rag/rag-query-20260607T181247686013Z-a0aebf6d.txtRAG helper exited 0; output savedMediumRecord retrieval tag and validation
2026-06-07T18:13:20Zlocal memory recordanalysis/local-memory-records.mdPrior local notes reviewed as fallback/advisory contextMediumValidate against current evidence
2026-06-07T18:13:20ZRAG recordanalysis/rag-records.mdRetrieved memory tagged MISSINGMediumValidate or reject with live evidence
2026-06-07T18:13:37Zevaluatoranalysis/evaluator-20260607T181337702219Z-f4d6ec22.mdValidate firstHighValidate PHP side locally/containerized, then record Proceed evaluator and run the minimal remote chain.
2026-06-07T18:22:08Zevaluatoranalysis/evaluator-20260607T182208875242Z-28202054.mdProceedHighRun the minimal remote chain with solve/solve.py: seed history with the encoded format-string URL, render /logs to leak <secret redacted>, forge HS256 JWT, render internal /bartender, and capture the flag through the harness.
2026-06-07T18:41:18Zbranch closedhypothesis-board.mdThe public frontend is php -S <TARGET>:8000, so choosing the challenge's own public root as the safe last url deadlocks bartender.php during url_check() self-fetch and is not a valid exploit branch.HighRerank hypotheses
2026-06-08T04:40:00Zremote availabilityanalysis/remote/service-timeouts-20260608.mdAfter the deadlocked attempts, plain GET / timed out for more than five minutes; the exploit chain is ready, but the current blocker is frontend responsiveness.HighRetry with the corrected external safe URL once the target serves the root page again.
2026-06-08T04:47:00Zremote availabilityanalysis/remote/service-timeouts-20260608.mdA fresh retry still timed out on GET / at 10s, 15s, and 30s, so the exploit chain cannot start yet.HighWait for the target to recover, then rerun the solver with the external safe URL default.
2026-06-08T05:02:13Zremote exploitanalysis/remote/exploit-success-20260608.mdThe corrected solver succeeded on the refreshed instance at <TARGET>:31233 using the external safe URL and the validated duplicate-parameter split.HighCapture the flag through the harness and complete the workspace.
2026-06-07T19:02:32Zflag captureloot/flag.txtHTB-format flag captured; raw value kept in loot onlyHighWrite solution and run completion gate
2026-06-07T19:04:18Zcompletion gatechallenge-state.jsonCompletion gate passed; state marked COMPLETEHighOptional sanitized memory summary approval

Key Findings

  • PHP duplicate-parameter behavior is now validated with a real container: PHP keeps the last url, while Flask/Werkzeug keeps the first.
  • The source-backed exploit path remains: duplicate-url split -> internal /logs format-string leak -> forged HS256 JWT -> internal /bartender secrets PDF.
  • The public frontend runs as single-threaded php -S, so the challenge's own public root cannot be used as the safe validation URL inside bartender.php.
  • solve/solve.py now defaults the safe URL to http://example.com/ to avoid that self-deadlock.
  • A refreshed instance at <TARGET>:31233 served the root page normally and the exploit completed successfully with the corrected safe URL.

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: OfflineA
  • 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.

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
1Exploit PHP/Flask duplicate-parameter parsing mismatch to send a safe public URL to PHP validation while Flask/Selenium receives an internal localhost URL. Use an encoded Python format-string payload in the stored history URL to leak Flask <secret redacted> from /logs, then sign a JWT and make Selenium print /bartender secrets into a public PDF.bartender.php validates only PHP's parsed url and forwards raw <secret redacted> to http://<TARGET>:5000/generate. Flask request.args.get('url') may select a different duplicate url value. internal app.py stores visited URLs in SQLite history and logify() applies history_1.format(logify=logify). /bartender returns all secrets but requires HS256 JWT signed with app.config['<secret redacted>'].Run local PHP+Flask stack or minimal parser tests to confirm PHP duplicate parameter chooses last url while Flask/Werkzeug chooses first, then test encoded {logify.__globals__[app].config[<secret redacted>]} payload in history and generated PDF output.HighActive

Closed Branches

BranchEvidence TestedFailure OutputReason ClosedRevisit Condition
Use the challenge public root as the last duplicate url for PHP validationanalysis/remote/service-timeouts-20260608.mdThe public frontend is php -S <TARGET>:8000, so choosing the challenge's own public root as the safe last url deadlocks bartender.php during url_check() self-fetch and is not a valid exploit branch.Retry with an external 200 OK safe URL after the remote frontend becomes responsive again.

Memory Summary

approval_required: true

Sanitized Memory Summary

Metadata

  • Platform: HackTheBox Challenges
  • Category: Web
  • Challenge: OfflineA
  • 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.

Reusable Lessons

-

Dead Ends

-

Tool Quirks

-

Evidence Paths

-

Ingestion Decision

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

Notes

Notes

Scope

  • Challenge: OfflineA
  • Category: Web
  • Difficulty: Easy
  • Mode: hybrid
  • Remote instance: <TARGET>:32008
  • Start time: 2026-06-07T18:10:21Z
  • 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/a12c73b2-77a7-40e1-93fa-3e8f3e619ae8.zip3244438<hash redacted>Zip archive data, at least v1.0 to extract, compression method=storezip entries: 21 shown in artifact inventory JSON
files/extracted/web_offlinea/Dockerfile551<hash redacted>ASCII text, with CRLF line terminators
files/extracted/web_offlinea/build-docker.sh130<hash redacted>Bourne-Again shell script text executable, ASCII text
files/extracted/web_offlinea/challenge/internal/app.py6659<hash redacted>Python script text executable, ASCII text, with CRLF line terminators
files/extracted/web_offlinea/challenge/internal/init_db.py1083<hash redacted>Python script text executable Python script text executable, ASCII text
files/extracted/web_offlinea/challenge/internal/templates/bartender.html131<hash redacted>HTML document text, ASCII text
files/extracted/web_offlinea/challenge/service/bartender.php3416<hash redacted>PHP script text, ASCII text, with CRLF line terminators
files/extracted/web_offlinea/challenge/service/images/bar.png3020206<hash redacted>PNG image data, 1536 x 1024, 8-bit/color RGB, non-interlaced
files/extracted/web_offlinea/challenge/service/images/bartender.jpg254455<hash redacted>JPEG image data, JFIF standard 1.01, aspect ratio, density 1x1, segment length 16, Exif Standard: [TIFF image data, little-endian, direntries=3, orientation=upper-left, software=Picasa], baseline, precision 8, 1024x1024, components 3
files/extracted/web_offlinea/challenge/service/index.html6246<hash redacted>HTML document text, ASCII text
files/extracted/web_offlinea/challenge/service/pdfs/no_way.pdf8381<hash redacted>PDF document, version 1.4, 1 pages
files/extracted/web_offlinea/config/supervisord.conf662<hash redacted>ASCII text
files/extracted/web_offlinea/flag.txt27<hash redacted>ASCII text
files/extracted/web_offlinea/requirements.txt43<hash redacted>ASCII text, with CRLF line terminators

Evidence Ledger

TimeActionOutput/FileFindingConfidenceNext
2026-06-07T18:10:21Zharness initchallenge-state.jsonWorkspace initialized with deterministic state fileHighInventory artifacts
2026-06-07T18:10:58Zartifact inventoryanalysis/artifact-inventory.json14 artifact(s) inventoriedHighBuild or update hypotheses
2026-06-07T18: <REDACTED>, then sign a JWT and make Selenium print /bartender secrets into a public PDF.HighRun local PHP+Flask stack or minimal parser tests to confirm PHP duplicate parameter chooses last url while Flask/Werkzeug chooses first, then test encoded {logify.__globals__[app].config[<secret redacted>]} payload in history and generated PDF output.
2026-06-07T18:12:17Zsource auditanalysis/source-audit.mdSource audit recordedHighGate before exploit
2026-06-07T18:12:37Zcheckpoint recordedanalysis/checkpoint-hypothesis_ready-20260607T181237088431Z-2f03a3fa.mdCheckpoint for <secret redacted>HighUse checkpoint to drive next decision
2026-06-07T18:12:37Zresearch taskanalysis/research/task-20260607T181237090660Z-8f22e9be.mdResearch task created for advisory investigationMediumRecord research output
2026-06-07T18:12:37Zlocal memory searchanalysis/research/local-memory-search-20260607T181237610542Z-60dae707.mdFound 8 safe prior-note result(s)MediumRecord useful result or skip
2026-06-07T18:12:56ZRAG queryanalysis/rag/rag-query-20260607T181247686013Z-a0aebf6d.txtRAG helper exited 0; output savedMediumRecord retrieval tag and validation
2026-06-07T18:13:20Zlocal memory recordanalysis/local-memory-records.mdPrior local notes reviewed as fallback/advisory contextMediumValidate against current evidence
2026-06-07T18:13:20ZRAG recordanalysis/rag-records.mdRetrieved memory tagged MISSINGMediumValidate or reject with live evidence
2026-06-07T18:13:37Zevaluatoranalysis/evaluator-20260607T181337702219Z-f4d6ec22.mdValidate firstHighValidate PHP side locally/containerized, then record Proceed evaluator and run the minimal remote chain.
2026-06-07T18: <REDACTED>, render /logs to leak <secret redacted>, forge HS256 JWT, render internal /bartender, and capture the flag through the harness.
2026-06-07T18:41:18Zbranch closedhypothesis-board.mdThe public frontend is php -S <TARGET>:8000, so choosing the challenge's own public root as the safe last url deadlocks bartender.php during url_check() self-fetch and is not a valid exploit branch.HighRerank hypotheses
2026-06-08T04:40:00Zremote availabilityanalysis/remote/service-timeouts-20260608.mdAfter the deadlocked attempts, plain GET / timed out for more than five minutes; the exploit chain is ready, but the current blocker is frontend responsiveness.HighRetry with the corrected external safe URL once the target serves the root page again.
2026-06-08T04:47:00Zremote availabilityanalysis/remote/service-timeouts-20260608.mdA fresh retry still timed out on GET / at 10s, 15s, and 30s, so the exploit chain cannot start yet.HighWait for the target to recover, then rerun the solver with the external safe URL default.
2026-06-08T05: <REDACTED>
2026-06-07T19: <REDACTED>
2026-06-07T19:04:18Zcompletion gatechallenge-state.jsonCompletion gate passed; state marked COMPLETEHighOptional sanitized memory summary approval

Key Findings

  • PHP duplicate-parameter behavior is now validated with a real container: PHP keeps the last url, while Flask/Werkzeug keeps the first.
  • The source-backed exploit path remains: <REDACTED>
  • The public frontend runs as single-threaded php -S, so the challenge's own public root cannot be used as the safe validation URL inside bartender.php.
  • solve/solve.py now defaults the safe URL to http://example.com/ to avoid that self-deadlock.
  • A refreshed instance at <TARGET>:31233 served the root page normally and the exploit completed successfully with the corrected safe URL.

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 OfflineA, 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.