Pragyan 2026 — Plumbing Çözüm Yazısı

Son hâli tertemiz görünen, çoktan gönderilmiş bir Docker imajı. OCI katmanlarını soymak yapı geçmişini, bir whiteout arkasına gizlenmiş silinmiş bir durum dosyasını ve geliştiricinin üzerine yazdığını sandığı özgün çıktıyı ortaya çıkarıyor. İki sızıntı özel bir blok şifresini tersine çevirip bayrağı geri veriyor.

5 dk okuma
ibrahimsql
829 kelime

Pragyan 2026 — Plumbing

Bir konteyner adli bilişim sorusu. Bize çoktan derlenmiş ve gönderilmiş bir Docker imajı veriliyor. Çalışan konteyner temiz görünüyor, ama imajın kendisi bir tarih kitabıdır: her katman kopyalananı saklar, her yapı komutu yazılanı saklar — bir betiğe boruyla aktarılıp unutulması gereken sır dâhil.

  • Etkinlik: Pragyan 2026
  • Kategori: Adli Bilişim (Forensics)
  • Dosya: app.tar
  • Bayrak formatı: p_ctf{...}

1. Tar'ın içinde ne var

Text
$ file app.tar
app.tar: POSIX tar archive
$ tar -xf app.tar -C x && find x -maxdepth 2
x/oci-layout
x/blobs/sha256
x/manifest.json
x/index.json

Bu bir OCI imaj düzeni — docker save / ctr image export komutlarının ürettiği disk üstü format. Düzen basittir:

  • index.json — app:latest için bir manifestoya işaret eder
  • manifest.json — config blob'unu ve katman blob'larını listeler
  • blobs/sha256/… — içeriğe göre adreslenmiş asıl blob'lar

2. Config blob'u yapı geçmişidir

Config blob'u (b3f4caf1…, manifest.json'da Config olarak geçer) imaj yapılandırmasıdır. history dizisi her Dockerfile komutunu created_by olarak kaydeder — tıpkı docker history --no-trunc'un gösterdiği gibi:

Bash
$ jq '.history[].created_by' <(cat blobs/sha256/b3f4caf1*)
...
"COPY process.py . # buildkit"
"COPY env /app/.env # buildkit"
"RUN /bin/sh -c echo \"p_ctf{d0ck3r_l34k5_p1p3l1n35}X|O\" | python3 process.py # buildkit"
"RUN /bin/sh -c rm /tmp/state_round7.bin # buildkit"
"RUN /bin/sh -c echo \"uhh it was here ;-;\" > /app/output.bin # buildkit"
"COPY DEV_NOTES.txt . # buildkit"
"COPY process2.py /app/process.py # buildkit"

İşte orada. Sır bir RUN satırına yazılmıştı. Bir dosyayı sonraki bir katmanda kaldırmak, onu oluşturan komutu kaldırmaz — geçmiş kalıcıdır ve bayrak orada durmaktadır.

Geçmiş aynı zamanda tüm örtbas hikâyesini anlatır: bir hata ayıklama dosyası state_round7.bin silinmiş, çıktının üzerine bir alay mesajı (uhh it was here ;-;) yazılmış, "temiz" bir process2.py gerçek process.py'nin üzerine kopyalanmış ve DEV_NOTES.txt kanıtları saklama yapılacaklar listesi gibi okunuyor.

3. Silinen dosyaları kurtarma

Docker'da bir dosyayı silmek onu yok etmez; yeni katmana bir whiteout girdisi (sıfır baytlık .wh.<ad> dosyası) ekler, gerçek baytlar ise eski katmanın tar arşivinde kalır. Her katmanı açtığımızda tam olarak bunu görüyoruz:

Text
layer 7 : /app/output.bin        /tmp/state_round7.bin      <- gerçek veri
layer 8 : /tmp/.wh.state_round7.bin                          <- whiteout (silme)
layer 9 : /app/output.bin                                    <- üzerine yazılan alay
layer 10: /app/DEV_NOTES.txt
layer 11: /app/process.py                                    <- process2.py (boş)

Sonraki katmanların silmeye çalıştığı iki dosya, layer 7'de hayatta kalmış:

Bash
$ xxd layers/7/tmp/state_round7.bin
00000000: 0b24 7a67 2f16 302b 273e 376f 282e 6a53   .$zg/.0+'>7o(.jS
$ xxd layers/7/app/output.bin
00000000: 2f62 3335 7758 7631 7438 4f02 3a71 2960   /b35wXv1t8O.:q)`

state_round7.bin 16 bayt, output.bin 16 bayt. İkisi de yapı sırasındaki çalıştırmanın artıkları.

4. Özgün betiği ve anahtarı okuma

Gerçek process.py (üzerine yazılmadan önce, layer 5'ten) basit bir blok şifresidir:

Python
KEY = load_key()          # /app/.env : AES_KEY=THIS_IS_AES_KEY!
ROUNDS = 10
DEBUG_ROUND = 7
 
def xor_block(a, b):
    return bytes(x ^ y for x, y in zip(a, b))
 
def permute(block):       # 4 bayt sola döndür
    return block[4:] + block[:4]
 
def encrypt_block(block, leak_debug=False):
    state = block
    for r in range(1, ROUNDS + 1):
        state = xor_block(state, KEY)
        state = permute(state)
        if leak_debug and r == DEBUG_ROUND:
            open("/tmp/state_round7.bin", "wb").write(state)   # sızıntı
    return state
 
data   = sys.stdin.buffer.read().strip()
block1 = data[:16]
block2 = data[16:32]
encrypt_block(block1, leak_debug=True)     # 7. tur durumunu sızdırır
open("/app/output.bin", "wb").write(encrypt_block(block2))

İmaja kopyalanan .env anahtarı açıkça verir:

AES_KEY=THIS_IS_AES_KEY!

Yani: sırrın ilk yarısı 7. turda iç durumunu sızdırır, ikinci yarısı ise şifrelenip output.bin'e yazılır. Her iki yarı da kurtarılabilir.

5. Şifreyi tersine çevirme

Her tur state = rotate_left_by_4( state XOR key )'dir. Tersi, ters sıra ve ters işlemdir: state = rotate_right_by_4( state ) XOR key. Sızan 7. tur durumuyla 1. bloğu elde etmek için 7 turu, gerçek şifreli metinle 2. bloğu elde etmek için 10 turun tamamını tersine çeviririz:

Python
KEY = b"THIS_IS_AES_KEY!"
xor = lambda a, b: bytes(x ^ y for x, y in zip(a, b))
rot = lambda b: b[4:] + b[:4]        # ileri permütasyon
ror = lambda b: b[-4:] + b[:-4]      # ters permütasyon
 
def decrypt(state, rounds):
    for _ in range(rounds):
        state = xor(ror(state), KEY)
    return state
 
state7 = open("7/tmp/state_round7.bin", "rb").read()   # 7. turda sızmış
ct2    = open("7/app/output.bin", "rb").read()         # özgün şifreli metin
 
block1 = decrypt(state7, 7)      # tur 7 -> 1 tersine
block2 = decrypt(ct2, 10)        # tur 10 -> 1 tersine
 
print(block1, block2)
# b'p_ctf{d0ck3r_l34'  b'k5_p1p3l1n35}X|O'
  1. bloğu yeniden şifreleyip sızan 7. tur durumunu ürettiğini (ve 2. bloğu yeniden şifreleyip saklanan şifreli metni verdiğini) doğrulamak, tersine çevirmenin tam olduğunu kanıtlar.

6. Bayrak

Kurtarılan iki yarıyı birleştiriyoruz:

print((block1 + block2).decode())
# p_ctf{d0ck3r_l34k5_p1p3l1n35}X|O
p_ctf{d0ck3r_l34k5_p1p3l1n35}

Sondaki X|O, ikinci 16 baytlık bloğu doldurmak için eklenmiş dolgudan ibaretti. Bayrak, yapı geçmişine gömülü olanla eşleşiyor — sorunun amaçlanan sızıntısı ile onun iskeleti uyuşuyor.

7. Çıkarımlar

  • RUN komutları ölümsüzdür. Bir yapı adımına yazılan sır, kaç sonraki katman rm uygularsa uygulasın imaj yapılandırmasında sonsuza dek yaşar.
  • Bir dosyayı silmek yalnızca bir whiteout yazar; baytlar onu oluşturan katmanda kalır. docker save hepsini gönderir.
  • İmaja kopyalanan .env bir sır deposu değildir.
  • "Son konteyner temiz görünüyor" ifadesi, altındaki katmanlar hakkında hiçbir şey söylemez. Çalışan dosya sistemine değil, her zaman geçmişe ve her katman tar arşivine bakın.
---
Bu yazıyı paylaş:
TwitterLinkedInFacebook

Ne düşünüyorsun?

Tepki bırakarak geri bildirim ver

İlgili Yazılar