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.

7 min read
ibrahimsql
1,331 words

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 from 127.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:

PHP
// 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:

PHP
// 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

  1. Log in. The archive's init.sql seeds accounts such as mkim / erp123.

  2. SSRF past the IP check. Give hash.php a public URL that 302s to http://127.0.0.1/secret.php?index=N&filter=string.reverse. The first hop is public (accepted); the redirect's second hop hits localhost, so secret.php sees REMOTE_ADDR = 127.0.0.1. (string.reverse is a valid, harmless inner filter — each secret file is one byte, so reversing leaves it unchanged.)

  3. 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
  4. Turn the filter into an oracle. hash.php lets 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/500 is the only signal — and it is enough to classify one base64 character at a time.

  5. Recover one byte. Base64-encode the body, recover positions 8..11 (four base64 characters = a three-byte decode: :, space, X), decode, and take decoded[2] — the secret character.

  6. Repeat. Walk index = 0,1,2,… until the recovered character is /. That / is what secret.php prints when index runs past the last file: file_get_contents() returns false, so the body collapses to Secret: /<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 local redir.php that emits the same 302.
  • Public-IP guard. hash.php blocks 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.sha256 as an oracle — is unchanged.
  • Secret path. The image writes to the absolute /secret/; locally the reader points at __DIR__ . "/secret/".
  • Login. The archived hash.php is admin-gated (if (!$_SESSION['is_admin']) die(...)) and init.sql seeds only admin with 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. macOS libiconv reports several chains invalid (for example IBM1140, CP930, CSIBM932, CP1390, CP1122 are all INVALID under the Homebrew build), so I ran the two PHP servers inside a glibc image with memory_limit=128M:
Bash
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/e1c9c9742a037fe559901658db504229

The oracle primitive is exactly 200 vs 500:

filter=[string.rot13...]                         -> http=200
filter=[convert.base64-encode|dechunk|...]       -> http=500

Running the solver against the container recovers the flag one byte at a time:

Text
[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 — 200 vs 500 is 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() follows 302s is a classic SSRF bypass.
  • Localhost-only is not authorization. REMOTE_ADDR == 127.0.0.1 stops a direct hit but is trivially satisfied by the app's own outbound request.
  • Attack surface lives in custom stream filters. Registering hash.sha256 made every read path an attacker-controllable transform.
---
Share this post:

What do you think?

React to show your appreciation

Related Posts