Challenge Info
Spoiler
Armaxis is a weapon-dispatch web app guarded by JWT auth. A password-reset endpoint that validates the token but never checks who it belongs to (IDOR) lets us reset the admin’s password from a token mailed to an inbox we control. As admin, the weapon “Note” field is parsed as Markdown — and the image handler drops the URL straight into execSync(curl -s ${url}), giving OS command injection → RCE.
In the contrary the official HTB path and description of the challenge goes as follows:
The challenge involves exploiting an Insecure Direct Object Reference (IDOR) vulnerability in the password reset functionality and then leveraging a Local File Inclusion (LFI) vulnerability in the markdown parser.
Artifacts Provided
- Application source — the Node/Express backend (routes, auth,
markdown.js). - Live web instance — the Armaxis app plus a throwaway inbox bound to
[email protected]for receiving reset mail.
Code-review challenge: the whole solution is readable in the source before a single request is sent.
Analysis
Recon — the app and its two halves
The login page (Armaxis) offers register / login / Forgot Password. Registering lands you on a near-empty dashboard — “No weapons dispatched yet.” — and that’s the entire footprint available to a normal user.
Two things stand out in source:
- Auth is JWT, HS256, and
roleis just a field inside the token body. Every account registers as non-admin, and the app verifies the token server-side — so forgingrole: adminwould need the signing secret. Cracking it (hashcat -m 16500) gets nowhere; the secret is strong. Dead end, but a useful one to rule out early. - A second service is in scope: the mailbox at
[email protected], where password-reset tokens land. The reason two apps exist is the whole hint — the bug lives in the seam, in how the reset token issued to our inbox is allowed to act on another account.
Stage 1 — IDOR in the password reset (→ admin)
The reset flow sends a token to [email protected] (an inbox we read). The token itself is an opaque 16-byte hex value bound server-side to a userId — nothing to tamper with there. The flaw is one layer up, in how the token is consumed:
| |
| |
getPasswordReset(token) returns the reset row including the userId it was issued for — and the handler never compares it against the account being changed. Step (2) resolves the target purely from the attacker-controlled email body field, and step (3) resets that user. Token ownership and reset target are fully decoupled — textbook IDOR / missing ownership check (CWE-639 / broken access control).
So: request a reset to [email protected], read the valid token out of our inbox, then submit it against the admin’s address.
| |
| |
getUserByEmail query is parameterized (SELECT * FROM users WHERE email = ?) — no SQLi, and no coaxing it to id=0. The instinct (“make the server act on a user id I shouldn’t”) is right, but the lever isn’t in the SQL; it’s the un-validated email field in the request body. Don’t burn time on the reset token contents either — it’s random and DB-bound, nothing to forge.Log in with the new admin password. The navbar gains Dispatch Weapon, and /weapons/dispatch is now reachable:
| |

Stage 2 — command injection in the Markdown parser
The Note (Markdown) field is handed to parseMarkdown, which pre-processes images before rendering:
| |
The intent is server-side image inlining: for every , fetch the bytes with curl, base64 them, and swap in a data: URI. Three failures stack:
- Template-string interpolation —
urlis concatenated raw into the command string, no quoting, no escaping, no scheme/char validation (.*?captures anything inside the parens). execSyncruns via/bin/sh -c— every shell metacharacter is live:;,|,&&,$(...), backticks,>.urlis fully attacker-controlled — straight from the Note field through capture group 1.
So  becomes curl -s http://127.0.0.1/;id → two commands. OS command injection, CWE-78.
;id in the Note field renders as literal text — the /\!\[.*?\]\((.*?)\)/ pattern only fires on a well-formed , so the execSync callback never runs without the image wrapper. Every payload must be shaped .<img>, not the page. The callback returns base64 of curl’s stdout embedded in a data: URI — and since execSync captures the whole command line’s stdout, an injected command’s output lands there too. So injected output is not echoed as visible text, but it is sitting in the <img src>: read the base64 out of the rendered DOM and decode. That turns the “blind” sink into a readable one with no listener at all.No reverse shell needed — the flag is one injected command away. Inject cat and let its stdout ride back through the parser’s own base64-into-<img> behaviour:

The callback runs curl -s http://127.0.0.1/;cat /flag.txt; curl fails harmlessly, cat dumps the flag, and execSync’s captured stdout gets base64’d into the embedded image’s src. Read it straight out of the rendered DOM and decode:
| |
Flag
The decoded <img src> is the contents of /flag.txt:
Spoiler
HTB{m4rkd0wn_bugs_1n_th3_w1ld!}curl honors the file:// scheme, so a payload of  makes the parser run curl -s file:///flag.txt, read the flag, and base64 it straight into the rendered <img src> — pull it from the DOM and decode. That’s the author’s route (and also a blind SSRF primitive via http://). The unquoted interpolation makes full command injection available too, which is strictly more powerful — same sink, bigger payoff. Both land the flag; file:// LFI is the minimal intended one.
Full chain
Armaxis (weapon-dispatch app, JWT/HS256 auth)
└─ register + request password reset → token mailed to [email protected] (inbox we read)
└─ IDOR: POST /reset-password {token, email: [email protected], newPassword}
└─ token validated but ownership never checked → admin password reset
└─ log in as admin → Dispatch Weapon (Note = Markdown)
└─ parseMarkdown:  → execSync(`curl -s ${URL}`) (unquoted)
├─ command injection: 
│ → stdout base64'd into <img src> → decode → FLAG
└─ (intended)  → curl file:// LFI → FLAG
email body field to say whose password to change; the markdown parser trusts the image url to be a safe argument to a shell. One is broken access control, the other is command injection, but the review instinct is identical: follow every user-controlled value to the sink that consumes it, and check what it’s allowed to address. Worth keeping as a paired reference for code-review / secure-dev work.