OfflineA
OfflineA is a sanitized challenge note from the local HTB archive, organized for quick review by category, difficulty, evidence flow, and reusable operator
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.
Walkthrough flow
Source and route audit
Trust boundary flaw
Exploit request chain
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.
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:
- Seed history with the format-string URL while PHP validates an external safe URL.
- Render
/logsto extract candidate Flask<secret redacted>values from a generated PDF. - 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
| File | Size | SHA256 | Type | Notes |
|---|---|---|---|---|
files/a12c73b2-77a7-40e1-93fa-3e8f3e619ae8.zip | 3244438 | <hash redacted> | Zip archive data, at least v1.0 to extract, compression method=store | zip entries: 21 shown in artifact inventory JSON |
files/extracted/web_offlinea/Dockerfile | 551 | <hash redacted> | ASCII text, with CRLF line terminators | |
files/extracted/web_offlinea/build-docker.sh | 130 | <hash redacted> | Bourne-Again shell script text executable, ASCII text | |
files/extracted/web_offlinea/challenge/internal/app.py | 6659 | <hash redacted> | Python script text executable, ASCII text, with CRLF line terminators | |
files/extracted/web_offlinea/challenge/internal/init_db.py | 1083 | <hash redacted> | Python script text executable Python script text executable, ASCII text | |
files/extracted/web_offlinea/challenge/internal/templates/bartender.html | 131 | <hash redacted> | HTML document text, ASCII text | |
files/extracted/web_offlinea/challenge/service/bartender.php | 3416 | <hash redacted> | PHP script text, ASCII text, with CRLF line terminators | |
files/extracted/web_offlinea/challenge/service/images/bar.png | 3020206 | <hash redacted> | PNG image data, 1536 x 1024, 8-bit/color RGB, non-interlaced | |
files/extracted/web_offlinea/challenge/service/images/bartender.jpg | 254455 | <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.html | 6246 | <hash redacted> | HTML document text, ASCII text | |
files/extracted/web_offlinea/challenge/service/pdfs/no_way.pdf | 8381 | <hash redacted> | PDF document, version 1.4, 1 pages | |
files/extracted/web_offlinea/config/supervisord.conf | 662 | <hash redacted> | ASCII text | |
files/extracted/web_offlinea/flag.txt | 27 | <hash redacted> | ASCII text | |
files/extracted/web_offlinea/requirements.txt | 43 | <hash redacted> | ASCII text, with CRLF line terminators |
Evidence Ledger
| Time | Action | Output/File | Finding | Confidence | Next |
|---|---|---|---|---|---|
| 2026-06-07T18:10:21Z | harness init | challenge-state.json | Workspace initialized with deterministic state file | High | Inventory artifacts |
| 2026-06-07T18:10:58Z | artifact inventory | analysis/artifact-inventory.json | 14 artifact(s) inventoried | High | Build or update hypotheses |
| 2026-06-07T18:12:17Z | hypothesis recorded | hypothesis-board.md | Exploit 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. | High | 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. |
| 2026-06-07T18:12:17Z | source audit | analysis/source-audit.md | Source audit recorded | High | Gate before exploit |
| 2026-06-07T18:12:37Z | checkpoint recorded | analysis/checkpoint-hypothesis_ready-20260607T181237088431Z-2f03a3fa.md | Checkpoint for <secret redacted> | High | Use checkpoint to drive next decision |
| 2026-06-07T18:12:37Z | research task | analysis/research/task-20260607T181237090660Z-8f22e9be.md | Research task created for advisory investigation | Medium | Record research output |
| 2026-06-07T18:12:37Z | local memory search | analysis/research/local-memory-search-20260607T181237610542Z-60dae707.md | Found 8 safe prior-note result(s) | Medium | Record useful result or skip |
| 2026-06-07T18:12:56Z | RAG query | analysis/rag/rag-query-20260607T181247686013Z-a0aebf6d.txt | RAG helper exited 0; output saved | Medium | Record retrieval tag and validation |
| 2026-06-07T18:13:20Z | local memory record | analysis/local-memory-records.md | Prior local notes reviewed as fallback/advisory context | Medium | Validate against current evidence |
| 2026-06-07T18:13:20Z | RAG record | analysis/rag-records.md | Retrieved memory tagged MISSING | Medium | Validate or reject with live evidence |
| 2026-06-07T18:13:37Z | evaluator | analysis/evaluator-20260607T181337702219Z-f4d6ec22.md | Validate first | High | Validate PHP side locally/containerized, then record Proceed evaluator and run the minimal remote chain. |
| 2026-06-07T18:22:08Z | evaluator | analysis/evaluator-20260607T182208875242Z-28202054.md | Proceed | High | Run 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:18Z | branch closed | hypothesis-board.md | The 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. | High | Rerank hypotheses |
| 2026-06-08T04:40:00Z | remote availability | analysis/remote/service-timeouts-20260608.md | After the deadlocked attempts, plain GET / timed out for more than five minutes; the exploit chain is ready, but the current blocker is frontend responsiveness. | High | Retry with the corrected external safe URL once the target serves the root page again. |
| 2026-06-08T04:47:00Z | remote availability | analysis/remote/service-timeouts-20260608.md | A fresh retry still timed out on GET / at 10s, 15s, and 30s, so the exploit chain cannot start yet. | High | Wait for the target to recover, then rerun the solver with the external safe URL default. |
| 2026-06-08T05:02:13Z | remote exploit | analysis/remote/exploit-success-20260608.md | The corrected solver succeeded on the refreshed instance at <TARGET>:31233 using the external safe URL and the validated duplicate-parameter split. | High | Capture the flag through the harness and complete the workspace. |
| 2026-06-07T19:02:32Z | flag capture | loot/flag.txt | HTB-format flag captured; raw value kept in loot only | High | Write solution and run completion gate |
| 2026-06-07T19:04:18Z | completion gate | challenge-state.json | Completion gate passed; state marked COMPLETE | High | Optional 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
/logsformat-string leak -> forged HS256 JWT -> internal/bartendersecrets 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 insidebartender.php. solve/solve.pynow defaults the safe URL tohttp://example.com/to avoid that self-deadlock.- A refreshed instance at
<TARGET>:31233served 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:
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.
| Rank | Path | Evidence | Missing Proof | Cheapest Validation | Confidence | Status |
|---|---|---|---|---|---|---|
| 1 | Exploit 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. | High | Active |
Closed Branches
| Branch | Evidence Tested | Failure Output | Reason Closed | Revisit Condition |
|---|---|---|---|---|
| Use the challenge public root as the last duplicate url for PHP validation | analysis/remote/service-timeouts-20260608.md | The 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
| File | Size | SHA256 | Type | Notes |
|---|---|---|---|---|
files/a12c73b2-77a7-40e1-93fa-3e8f3e619ae8.zip | 3244438 | <hash redacted> | Zip archive data, at least v1.0 to extract, compression method=store | zip entries: 21 shown in artifact inventory JSON |
files/extracted/web_offlinea/Dockerfile | 551 | <hash redacted> | ASCII text, with CRLF line terminators | |
files/extracted/web_offlinea/build-docker.sh | 130 | <hash redacted> | Bourne-Again shell script text executable, ASCII text | |
files/extracted/web_offlinea/challenge/internal/app.py | 6659 | <hash redacted> | Python script text executable, ASCII text, with CRLF line terminators | |
files/extracted/web_offlinea/challenge/internal/init_db.py | 1083 | <hash redacted> | Python script text executable Python script text executable, ASCII text | |
files/extracted/web_offlinea/challenge/internal/templates/bartender.html | 131 | <hash redacted> | HTML document text, ASCII text | |
files/extracted/web_offlinea/challenge/service/bartender.php | 3416 | <hash redacted> | PHP script text, ASCII text, with CRLF line terminators | |
files/extracted/web_offlinea/challenge/service/images/bar.png | 3020206 | <hash redacted> | PNG image data, 1536 x 1024, 8-bit/color RGB, non-interlaced | |
files/extracted/web_offlinea/challenge/service/images/bartender.jpg | 254455 | <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.html | 6246 | <hash redacted> | HTML document text, ASCII text | |
files/extracted/web_offlinea/challenge/service/pdfs/no_way.pdf | 8381 | <hash redacted> | PDF document, version 1.4, 1 pages | |
files/extracted/web_offlinea/config/supervisord.conf | 662 | <hash redacted> | ASCII text | |
files/extracted/web_offlinea/flag.txt | 27 | <hash redacted> | ASCII text | |
files/extracted/web_offlinea/requirements.txt | 43 | <hash redacted> | ASCII text, with CRLF line terminators |
Evidence Ledger
| Time | Action | Output/File | Finding | Confidence | Next |
|---|---|---|---|---|---|
| 2026-06-07T18:10:21Z | harness init | challenge-state.json | Workspace initialized with deterministic state file | High | Inventory artifacts |
| 2026-06-07T18:10:58Z | artifact inventory | analysis/artifact-inventory.json | 14 artifact(s) inventoried | High | Build or update hypotheses |
| 2026-06-07T18: <REDACTED>, then sign a JWT and make Selenium print /bartender secrets into a public PDF. | High | 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. | |||
| 2026-06-07T18:12:17Z | source audit | analysis/source-audit.md | Source audit recorded | High | Gate before exploit |
| 2026-06-07T18:12:37Z | checkpoint recorded | analysis/checkpoint-hypothesis_ready-20260607T181237088431Z-2f03a3fa.md | Checkpoint for <secret redacted> | High | Use checkpoint to drive next decision |
| 2026-06-07T18:12:37Z | research task | analysis/research/task-20260607T181237090660Z-8f22e9be.md | Research task created for advisory investigation | Medium | Record research output |
| 2026-06-07T18:12:37Z | local memory search | analysis/research/local-memory-search-20260607T181237610542Z-60dae707.md | Found 8 safe prior-note result(s) | Medium | Record useful result or skip |
| 2026-06-07T18:12:56Z | RAG query | analysis/rag/rag-query-20260607T181247686013Z-a0aebf6d.txt | RAG helper exited 0; output saved | Medium | Record retrieval tag and validation |
| 2026-06-07T18:13:20Z | local memory record | analysis/local-memory-records.md | Prior local notes reviewed as fallback/advisory context | Medium | Validate against current evidence |
| 2026-06-07T18:13:20Z | RAG record | analysis/rag-records.md | Retrieved memory tagged MISSING | Medium | Validate or reject with live evidence |
| 2026-06-07T18:13:37Z | evaluator | analysis/evaluator-20260607T181337702219Z-f4d6ec22.md | Validate first | High | Validate 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:18Z | branch closed | hypothesis-board.md | The 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. | High | Rerank hypotheses |
| 2026-06-08T04:40:00Z | remote availability | analysis/remote/service-timeouts-20260608.md | After the deadlocked attempts, plain GET / timed out for more than five minutes; the exploit chain is ready, but the current blocker is frontend responsiveness. | High | Retry with the corrected external safe URL once the target serves the root page again. |
| 2026-06-08T04:47:00Z | remote availability | analysis/remote/service-timeouts-20260608.md | A fresh retry still timed out on GET / at 10s, 15s, and 30s, so the exploit chain cannot start yet. | High | Wait 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:18Z | completion gate | challenge-state.json | Completion gate passed; state marked COMPLETE | High | Optional 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 insidebartender.php. solve/solve.pynow defaults the safe URL tohttp://example.com/to avoid that self-deadlock.- A refreshed instance at
<TARGET>:31233served 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:
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.