Skip to content

Stocker

Box Info

Platform: HackTheBox, OS: Linux (Ubuntu 20.04), Difficulty: Easy, Released: 2023-02-13, IP: 10.10.11.196 , stocker.htb

Attack Path

  1. A vhost sweep finds dev.stocker.htb, an Express + MongoDB shop. NoSQL auth bypass with {"username":{"$ne":null},"password":{"$ne":null}}.
  2. POST /api/order then GET /api/po/<id> renders the order to a PDF. The item title is placed into the HTML unsanitised, so an injected <script> runs in the PDF renderer’s browser context. That gives local file read via file:///.
  3. Read /var/www/dev/index.js. The Mongo URI is mongodb://dev:IHeardPassphrasesArePrettySecure@localhost/.... That password is reused by the user angoose. SSH in.
  4. angoose may sudo /usr/bin/node /usr/local/scripts/*.js. The * glob lets you traverse out (/usr/local/scripts/../../../home/angoose/x.js), so node runs a script you control, as root.

Credentials and Flags

WhereValue
Mongo URI in index.js, reused for angooseIHeardPassphrasesArePrettySecure
user.txt/home/angoose/user.txt
root.txt/root/root.txt

Overview

Stocker is the box I’d hand someone as a case study in what happens when a modern JavaScript stack trusts a little too much of what the client sends it. Four separate weaknesses chain together here, and what struck me as I worked through them is that none of them are exotic: every one is something I’ve genuinely seen bleed into production code. I started by treating the login flow as an ordinary Node/Express app and reached for NoSQL injection almost immediately, since Mongo queries are built from JSON objects rather than parameterized strings, and this app was passing req.body straight into findOne without coercing anything to a primitive type first. From there the box handed me a much more interesting escalation: a “print my receipt” feature that renders customer-controlled text into an HTML document, which a headless Chromium instance then turns into a PDF. Because that HTML was never sanitized, I could plant a <script> tag that executed with the renderer’s full browser context, including the ability to follow file:// and http:// URIs. That’s server-side XSS with a far scarier blast radius than the client-side kind, and it gave me arbitrary local file read on the box. Pulling the application source off disk turned up a MongoDB connection string with a plaintext password baked in, and that password turned out to be reused verbatim as the SSH password for the angoose account, a shortcut I still see constantly on real engagements. The path to root closed the loop nicely: a sudo rule let angoose run node against anything matching /usr/local/scripts/*.js, a wildcard the shell expands before sudo ever validates the result, so a directory-traversal payload slid straight past the intended restriction and let me execute arbitrary JavaScript as root.

Related NoSQLi boxes: Mentor is not in this vault, but see Cactus. Related PDF/SSRF: Bagel, Interface. Related sudo wildcard / script abuse: Bagel, Forge, Headless, Code.


Full Walkthrough

Nmap scan

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.5
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-generator: Eleventy v2.0.0
|_http-title: Stock - Coming Soon!

With only SSH and HTTP exposed, I pointed a browser at the site and got nothing more than a static “coming soon” placeholder generated by Eleventy, which told me there had to be more application hiding somewhere I hadn’t looked yet. My instinct on HTB boxes with a hostname like this is that the real app usually lives behind a subdomain that a plain directory scan against the root domain would never surface, so I ran a virtual host sweep instead:

ffuf -w /usr/share/SecLists/Discovery/DNS/subdomains-top1million-110000.txt -c \
  -u http://stocker.htb -H "Host: FUZZ.stocker.htb" --mc all --fs 178
dev                     [Status: 302, Size: 28, Words: 4, Lines: 1]

NoSQL authentication bypass

The sweep turned up dev, and adding it to /etc/hosts and browsing there landed me on a login portal. The response headers gave away X-Powered-By: Express, which confirmed a Node backend immediately and shifted my thinking away from classic SQL injection toward its NoSQL cousin, since Mongo queries are constructed from objects rather than concatenated SQL strings. I pulled a handful of the standard operator-injection payloads from PayloadsAllTheThings and tried the most common one against the login endpoint. It worked on the very first attempt.

POST /login HTTP/1.1
Host: dev.stocker.htb
Content-Type: application/json

{"username": {"$ne": null}, "password": {"$ne": null}}
HTTP/1.1 302 Found
Location: /stock

Why the $ne bypass works

The application authenticates with User.findOne({ username, password }), pulling both fields straight out of req.body with no type coercion applied. Under normal use those fields arrive as strings and Mongo performs a literal equality match. But if I send a JSON body where each value is itself an object, {"$ne": null}, Mongo doesn’t treat it as a string comparison at all: it interprets $ne as its “not equal to” operator, so the query effectively becomes “find a user whose username is not null and whose password is not null.” That matches the very first document in the collection and logs me in without ever supplying a real credential. The reason this only works with Content-Type: application/json rather than form encoding is that form values always arrive as strings; only a JSON payload can carry a nested operator object like this one. The fix is straightforward: coerce both fields to strings before they ever touch the query, or better, validate the incoming body against a strict schema that rejects anything but a primitive string.

HTML injection in the PDF receipt to file read

Logged in with my improvised admin account, I started poking at the /stock page, which lets you assemble a basket of items and submit it with POST /api/order. That call hands back an orderId, and requesting GET /api/po/<orderId> renders the corresponding order into a PDF receipt. My working assumption was that anything reflected into that PDF was probably built from raw HTML on the server side, so I checked how the basket items made it into the document and found that the title field of each item gets dropped straight into the markup with no escaping at all:

{"basket":[{"_id":"638f116eeb060210cbd83a8f",
  "title":"<script>x=new XMLHttpRequest;x.onload=function(){document.write(this.responseText)};x.open('GET','file:///etc/hosts');x.send();</script>",
  "price":76,"amount":1}]}

Once I placed that order and pulled up the resulting receipt, the PDF had /etc/hosts printed directly onto the page, confirming that my injected script had executed inside whatever engine was turning that HTML into a document, and had successfully reached out and read a file off the server’s own filesystem.

Server-side HTML/JS injection in PDF generators

Tools like wkhtmltopdf, Puppeteer, and dompdf don’t just lay out text, they drive a real (or near-real) browser engine on the server to turn HTML into a document. If user input lands in that HTML unescaped, the result is XSS that executes server-side rather than in some other visitor’s browser, and that’s a far more dangerous primitive to hand an attacker: a file:// URI reads local files, http://169.254.169.254/ reaches cloud instance metadata wherever that’s exposed, and http://localhost:<port>/ can touch internal services that were never meant to face the internet. That’s exactly why “render user-supplied content to a PDF” keeps showing up as a classic SSRF and local-file-read sink in real-world writeups, and it’s the same pattern I leaned on here. See the writeup by Namratha G M linked in References for the technique I based this on.

With arbitrary file read confirmed, my next move was pinning down exactly where the application lived on disk so I could pull its source rather than guess at paths. Sending a deliberately malformed JSON body to the order endpoint triggered an unhandled exception, and the resulting stack trace obligingly leaked the application root, /var/www/dev. With that path in hand, I reused the same file:// primitive to read the entry point directly:

file:///var/www/dev/index.js
const dbURI = "mongodb://dev:IHeardPassphrasesArePrettySecure@localhost/dev?authSource=admin&w=1";
// ... app.post("/login", ... User.findOne({ username, password }) ... )   // no hashing, confirms the $ne bypass

Password reuse to angoose

The source dump handed me a hardcoded MongoDB connection string, and the password inside it, IHeardPassphrasesArePrettySecure, was distinctive enough that I doubted it was used in only one place. Password reuse between a database account and a real shell account is one of the first things I test whenever a credential like this falls into my lap, so I tried it straight against SSH for the only non-root user referenced anywhere on the box:

ssh angoose@stocker.htb        # IHeardPassphrasesArePrettySecure

Privilege Escalation, sudo wildcard traversal

Once I had an interactive shell as angoose, the first thing I checked, as always, was what that account could run with elevated privileges:

angoose@stocker:~$ sudo -l
User angoose may run the following commands on stocker:
    (ALL) /usr/bin/node /usr/local/scripts/*.js

That rule lets angoose execute any .js file under /usr/local/scripts as root through node. The directory itself is locked down, so I couldn’t just drop a malicious script in there directly, but the rule as written doesn’t anchor the path the way its author almost certainly assumed it would.

The wildcard is a traversal

The rule sudo enforces is /usr/bin/node /usr/local/scripts/*.js, and the critical detail is that the * is a shell glob, expanded by the invoking shell before sudo ever sees the resulting string, not a pattern sudo itself parses safely. A * glob character happily matches / and .. segments inside a path, so a command like sudo /usr/bin/node /usr/local/scripts/../../../home/angoose/pwn.js still textually satisfies the rule /usr/local/scripts/*.js (the wildcard absorbs ../../../home/angoose/pwn.js), and sudo grants it without complaint. From there node simply executes whatever file I point it at, running as root because that’s what the rule authorizes. secure_path doesn’t help here either: that setting only constrains which node binary gets resolved when the invoked path isn’t absolute, and this rule already specifies a fully-qualified, correct binary path. It’s the argument that’s the actual hole.

Exploiting it was just a matter of dropping a one-liner that spawns an interactive root shell directly, then invoking node on it through a traversal path that still satisfies the glob:

echo 'require("child_process").spawn("/bin/bash", {stdio: [0, 1, 2]})' > /home/angoose/pwn.js
sudo /usr/bin/node /usr/local/scripts/../../../home/angoose/pwn.js
# id -> uid=0
cat /root/root.txt

That dropped me straight into a root shell, id confirmed uid=0, and root.txt was sitting there waiting.


Loot

FlagLocation
user.txt/home/angoose/user.txt
root.txt/root/root.txt

Lessons and Takeaways

Working through Stocker reinforced a handful of lessons I keep relearning on boxes like this one:

  • Cast every authentication input to a string, and validate the request body against a strict schema. The moment a framework lets a client submit a JSON object where a scalar was expected, an object like {"$ne": null} stops being test data and becomes a working exploit against findOne. This isn’t Mongo-specific either; any query builder or ORM that accepts loosely-typed client input is vulnerable to the same confusion.
  • Hash passwords, always, even in a dev environment. Finding findOne({username, password}) comparing plaintext values was its own finding independent of the NoSQL injection: it told me the moment I read the source that credentials on this box were never meant to survive a compromise.
  • Treat anything that reaches a PDF or HTML renderer as untrusted input, and lock the renderer down at the infrastructure level too: disable local file access where the library supports it, strip JavaScript execution if the use case allows it, and run the rendering process with no outbound network access so a file:// or http:// payload has nowhere to reach even if sanitization fails somewhere upstream.
  • Anchor every sudo command path completely, and never trust a trailing wildcard to constrain anything. A rule ending in *.js with no protection against directory traversal is a path traversal waiting to be used; prefer an exact, fully-qualified path, or wrap the target in a wrapper script that validates its own argument before doing anything privileged.
  • Never let a database credential double as the login password for a real account. It’s a shortcut I still see in production systems, and it’s exactly the kind of lateral movement that turns one leaked config file into a full user compromise.

Related Writeups

References