Codegate 2026 — ERP_system Çözümü

Okuduğu her şeyi hash'leyen bir PHP stream filtresi oracle'a dönüşüyor: hash.php'deki SSRF'i halka açık bir yönlendirici üzerinden yalnız-localhost sır okuyucusuna zincirle, ardından bayrağı 500-e karşı-200 yan kanalından bayt bayt çıkar.

6 dk okuma
ibrahimsql
1.161 kelime

Codegate 2026 — ERP_system

Bir ERP portalı, çalışanları listeleyen bir panel ve küçük bir "hash" aracı. Bayrak veritabanında değil — /secret altında dosya başına bir bayt olarak bölünmüş. O dizine ulaşmak için üç şeyin aynı hizaya gelmesi gerekiyor: 127.0.0.1'den geliyormuş gibi görünen bir istek, zorunlu bir iç filtre ve toplu hâlde hiç okumana izin verilmeyen tek bir baytı gözlemleme yolu. Ev yapımı hash.sha256 filtresi son parçayı sağlıyor: bir hash aracını kör bir oracle'a çeviriyor.

Kapsam: yalnızca yerel reprodüksiyon. Challenge kaynağı CTF arşivinde açık; dışarıda hiçbir sisteme dokunulmadı.

1. Üç parça

Ek üç dosya gönderiyor; üçü ancak birlikte anlam kazanıyor:

  • app/src/hash.php — bir SSRF ve saldırgan kontrollü bir dış PHP filtre zinciri.
  • app/src/secret.php — gerçek sırrı okur, ama yalnızca 127.0.0.1'den.
  • app/src/filters.php — uygulamanın dayandığı özel stream filtrelerini kaydeder (hash.sha256, string.reverse, …).

Zincir şu: giriş yap, hash.php'yi 127.0.0.1/secret.php'ye geri yönlendiren halka açık bir URL'e yönelt ve hash.php'deki dış filtre zincirini PFCOE tarzı bir hata oracle'ı olarak kullanarak sırrı bayt bayt sızdır.

2. Kaynağı okumak

hash.php saldırgan kontrollü bir URL getirir ve gövdeyi saldırgan kontrollü bir filtre zincirinden geçirir. Şemayı ve çözümlenen IP'yi doğrular ama bu doğrulama yalnızca ilk host için geçerli:

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 http olmalı, host gerekli ...
$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;

İki gerçek önemli. Birincisi, file_get_contents() yönlendirmeleri takip eder; yani başta halka açık http:// olan ve 127.0.0.1'ye 302 dönen bir host IP kontrolünü geçer ama yine localhost'a düşer. İkincisi, $filter bir php://filter okuma zincirinin başına ekleniyor ve sonu her zaman uygulamanın kendi filtresi olan |hash.sha256 ile kapanıyor.

secret.php okuyucu. 127.0.0.1'den gelmeyen her şeyi reddeder, bir filter parametresi ister ve sabit biçimde bir satır yazdırır:

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;
}

Her /secret/secret_<index> dosyası bayrağın tam olarak bir baytını tutar. Yanıt Secret: <karakter>/<index>/<rastgele-tuz> biçimindedir.

Oracle'ı mümkün kılan filters.php'dir — hash.sha256 (ve hash.md5, string.reverse, utils.masking, string.strip_tags) filtresini gerçek stream filtresi olarak kaydeder:

stream_filter_register("hash.sha256", "Sha256HashingFilter");
stream_filter_register("string.reverse", "ReverseFilter");

Yani her hash.php yanıtı getirilen gövde değil, gövdenin SHA-256'sıdır. Gövdenin kendisi hiçbir zaman gösterilmez.

3. Zincir

  1. Giriş yap. Arşivdeki init.sql, mkim / erp123 gibi hesaplar seed eder.

  2. IP kontrolünü SSRF ile aş. hash.php'ye http://127.0.0.1/secret.php?index=N&filter=string.reverse adresine 302 dönen halka açık bir URL ver. İlk sıçrama halka açık (kabul edilir); yönlendirmenin ikinci sıçraması localhost'a iner, böylece secret.php REMOTE_ADDR = 127.0.0.1 görür. (string.reverse geçerli, zararsız bir iç filtredir — her sır dosyası bir bayt olduğundan ters çevirmek onu değiştirmez.)

  3. Sabit bir bayt. Yanıt Secret: X/index/<tuz>. Tuz her istekte rastgele, yani gövdenin tamamı sabit değil. Sabit olan bayt, offset 8'deki sır karakteridir:

    S  e  c  r  e  t  :  _  X
    0  1  2  3  4  5  6  7  8
  4. Filtreyi oracle'a çevir. hash.php, |hash.sha256'dan önce rastgele bir zincir koymamıza izin verir. PFCOE tarzı zincir gövdeyi base64'ler, bir pencereyi yerine kaydırır ve seçili bayt etrafında ya temiz tamamlanır (200) ya da bellek sınırını patlatır (500). Yanıt hiçbir zaman düz metin dönmediğinden tek sinyal 200/500'dür — ve bu, her seferinde bir base64 karakteri sınıflamaya yeter.

  5. Bir bayt çıkar. Gövdeyi base64'le, 8..11 konumlarını (dört base64 karakteri = üç baytlık çözüm: :, boşluk, X) kurtar, çöz ve decoded[2]'yi al — sır karakteri.

  6. Tekrarla. index = 0,1,2,… diye yürü, kurtarılan karakter / olana kadar. O /, index son dosyayı aştığında secret.php'nin yazdırdığı şeydir: file_get_contents() false döner, gövde Secret: /<index>/<tuz>'a çöker ve offset 8 bir eğik çizgi olur. Ondan önceki baytlar bayraktır.

Oracle mantığı Synacktiv'in php_filter_chains_oracle_exploit projesinden uyarlanmıştır. Bu challenge için yayımlanan çözüm; başta convert.base64-encode, bir get_nth() kaydırıcısı, patlatma olarak dechunk artı tekrarlı convert.iconv.L1.UCS-4 ve her base64 karakterini hatayı tetikleyip tetiklemeyerek sınıflayan string.rot13 / string.tolower / çeşitli convert.iconv.* dalları kullanır.

4. Yerel reprodüksiyon

Zinciri çevrimdışı yeniden kurdum. Canlı hedeften birkaç dürüst sapma; hepsi dış servis olmadan çalışmak için gerekli:

  • Yönlendirici. http://httpbin.org/redirect-to?url=… yerine aynı 302'yi üreten yerel bir redir.php kullandım.
  • Halka açık-IP koruması. hash.php, kendi doğrulaması sırasında özel hedefleri engeller; normalde halka açık bir yönlendirici bunu karşılar. Çevrimdışı halka açık host yok, bu yüzden yerel kopyada koruma gevşetildi (dosyada açıkça işaretli). Test edilen ilkel — oracle olarak php://filter + hash.sha256 — değişmiyor.
  • Sır yolu. İmaj mutlak /secret/'e yazar; yerelde okuyucu __DIR__ . "/secret/"'e bakar.
  • Giriş. Arşivdeki hash.php admin-korumalıdır (if (!$_SESSION['is_admin']) die(...)) ve init.sql yalnızca admin'i yer tutucu parolayla (FAKE_PASSWORD) seed eder. Özgün halka açık hedef düşük yetkili bir hesaba izin veriyordu; yerel koşu için bir admin oturumu seed ediyorum. SSRF ve filtre davranışı değişmiyor.
  • Çalışma ortamı. Oracle glibc iconv gerektirir. macOS libiconv birkaç zinciri geçersiz bildiriyor (örneğin Homebrew derlemesinde IBM1140, CP930, CSIBM932, CP1390, CP1122 hepsi INVALID), bu yüzden iki PHP sunucusunu memory_limit=128M ile glibc imajı içinde çalıştırdım:
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'

İki php -S örneği gerekir; PHP'nin yerleşik sunucusu tek iş parçacıklıdır ve yönlendirici zinciri kendi yönlendirmesini işleyen aynı sunucuya karşı kilitlenir.

Sağlama olarak, yalnız-localhost okuyucu aynı öneki ama her çağrıda taze bir tuz döndürür:

Secret: c/0/1b8101c81fc6fd679f0d2e702a02d598
Secret: c/0/e1c9c9742a037fe559901658db504229

Oracle ilkeli tam olarak 200'e karşı 500'dür:

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

Solver'ı container'a karşı çalıştırmak bayrağı bayt bayt kurtarır:

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. Bayrak ve dersler

codegate2026{fake_flag}

Bu, sağlanan imaja gömülü bayraktır (Dockerfile: SECRET="codegate2026{fake_flag}"); canlı yarışma örneği farklı bir değer kullanıyordu.

Dersler:

  • Saldırgan girdiyi hash'lemek onu güvenli kılmaz. Veriyi okuyup hakkında rapor veren bir filtre, yalnızca sabit genişlikte bir özet yayınlasa bile bir oracle'dır — 200'e karşı 500 bir yan kanaldır.
  • Yönlendirmeyi takip eden bir getirme, son sıçramayı değil ilk sıçramayı doğrular. file_get_contents() 302'leri takip ederken ilk host'un IP'sini kontrol etmek klasik bir SSRF atlatmasıdır.
  • Yalnız-localhost yetkilendirme değildir. REMOTE_ADDR == 127.0.0.1 doğrudan isabeti durdurur ama uygulamanın kendi giden isteğiyle kolayca karşılanır.
  • Saldırı yüzeyi özel stream filtrelerinde yaşar. hash.sha256 kaydetmek her okuma yolunu saldırgan kontrollü bir dönüşüme çevirdi.
---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar