Stack Overflow

Bentuk buffer overflow yang paling sederhana adalah stack overflow. Di hampir semua OS populer, ini tidak lagi mudah dilakukan karena sudah ada banyak metode untuk mengatasinya (ini akan diceritakan di bagian lain, tentang teknik mitigasi stack overflow).

Dalam bagian ini saya akan membahas mengenai stack dan hubungannya dengan eksekusi program. Saya akan menjelaskan layout stack ketika sebuah fungsi dipanggil, termasuk juga jika fungsi itu memiliki variabel lokal. Terakhir saya akan menunjukkan bagaimana membajak alur eksekusi program dengan menggunakan stack buffer overflow.

Dalam bagian ini saya belum akan membahas shell code. Ini akan dibahas di seri berikutnya.

Berikut ini urutan pembahasan yang akan saya lakukan:

Stack dan eksekusi program

Ketika sebuah program berjalan, ada dua slot memori yang disediakan, yang satu adalah heap dan yang lain adalah stack. Heap digunakan untuk menyimpan variabel global dan alokasi dinamik, sementara stack digunakan untuk menyimpan variabel lokal, menyimpan alamat pada saat pemanggilan fungsi dan menyimpan nilai-nilai parameter dan kembalian untuk fungsi yang dipanggil.

Struktur data stack memiliki 2 operasi: push dan pop. Push artinya menaruh elemen ke stack dan pop artinya mengambil elemen dari stack. Stack bisa bergerak/bertumbuh (grows) naik atau turun.

Dalam paragraf ini, asumsikan ukuran 1 elemen stack adalah 1 byte. Bergerak naik artinya: setelah menambah elemen di stack, posisinya ditambah. Misalnya stack di posisi 1000, lalu kita masukkan 1 elemen, maka stack jadi di posisi 1001. Jika bergerak turun, stack diinisialisasi dengan sebuah nilai (misalnya: 2000), setiap kita memasukkan elemen, posisinya berkurang, jadi dalam kasus ini, setelah masuk satu elemen, posisi stack ada di 1999, masuk elemen berikutnya, posisinya jadi 1998. Dalam stack yang bergerak turun (grows down) Jika posisi stack sampai kurang 0, berarti stack tidak mampu lagi menampung apapun (overflow). Perlu dicatat meski dalam paragraf ini saya mengasumsikan ukuran satu elemen stack itu 1 byte, biasanya ukuran stack adalah kelipatan 4 byte.

Agar lebih mudah, berikutnya saya akan mengasumsikan arsitektur x86 32 bit. Sebagai informasi, di Intel x86 stacknya bergerak turun. Di Intel x86, lokasi stack ditunjuk oleh register sp (16 bit), esp (32 bit), atau rsp (64 bit). Ukuran elemen stack adalah 16 bit (2byte) di sistem 16 bit, 32 bit (4 byte) di sistem 32 bit, dan 64 bit (8 byte) di sistem 64 bit. Untuk contoh dalam bagian tulisan ini, saya gunakan sistem 32 bit, dengan ESP dimulai dari alamat 1028 desimal. Gambar yang akan saya buat adalah seperti ini: Warna merah adalah posisi stack pointer. Saya menggunakan ? untuk menyatakan bahwa nilai di lokasi memori itu tidak/belum terdefinisi.

ditaa-stack-1.png

Jadi elemen pertama yang masuk ada di paling kanan, kalau ada elemen berikutnya, masuk di sebelah kiri dst.

Di X86, ketika kita mempush sebuah nilai, pertama nilai stack dikurangi dulu, lalu datanya ditaruh, jadi kali pertama sebuah nilai dipush (misalnya 10), hasilnya adalah seperti ini:

ditaa-stack-1a.png

Ketika operasi pop dilakukan, pertama nilai di posisi esp disalin, lalu esp ditambah.

Stack dan return address

Setiap kali sebuah kode program memanggil (call) kode program lain (biasanya fungsi/prosedur), maka perlu prosessor perlu mengingat instruksi selanjutnya setelah call ini di alamat mana (ini disebut return address atau alamat kembali), supaya tahu harus meneruskan ke alamat mana setelah fungsi tersebut selesai . Posisi alamat berikutnya ini disimpan di stack.

Contoh pemanggilan fungsi proc1 tanpa parameter dalam assembly adalah seperti ini (angka di kiri adalah alamat letak instruksi tersebut berada):

0x10000 call proc1
0x10004 call proc2
0x10008 ... ; other instructions

Isi proc 1:

ret    ; fungsi kosong, hanya kembali

Ketika calll proc1 dieksekusi, maka alamat 0x10004 dipush di stack.

ditaa-stack-2.png

Ketika proc1 selesai, maka instruksi ret (return) dieksekusi. Instruksi ini akan mem-pop isi stack, dan melompat ke alamat yang ada di stack (dalam kasus ini melompat ke instruksi di 0x10004). Berikutnya ketika proc2 dipanggil, alamat setelah call proc2 akan dipush distack.

Catatan tambahan: untuk optimasi, beberapa compiler (misalnya gcc versi baru) tidak menggunakan instruksi push, tapi menggunakan instruksi mov dengan index esp

Stack dan parameter

Jika fungsi memiliki parameter, maka parameter ini bisa diberikan kepada fungsi melalui register atau melalui stack. Jika jumlah parameter tidak banyak dan merupakan tipe dasar (int, pointer) maka register bisa digunakan, jika banyak atau bukan tipe dasar, maka stack akan digunakan. Jika menggunakan register, tentunya stack tidak disentuh. Pemanggil fungsi akan mengeset beberapa nilai di register, dan fungsi akan membaca parameternya dari register. Ketentuan mengenai apa yang dilewatkan melalui register dan apa yang dilewatkan melalui stack merupakan masalah besar ketika dulu ada banyak compiler, saat ini ada beberapa konvensi yang disetujui bersama, detailnya bisa dilihat di http://en.wikipedia.org/wiki/X86_calling_conventions

Jika stack yang dipakai, maka pemanggil perlu mem-push parameter ke stack. Misalnya proc1 adalah fungsi yang memiliki 1 parameter berupa integer (void proc1(int param)).

0x10000 push $1234
0x10005 call proc1
0x10009 ... ; other instructions

Syntax assembly yang populer untuk x86 ada 2: intel dan at&t. Syntax intel banyak dipakai di lingkungan DOS/Windows. Saya sekarang memakai syntax at&t (default gcc). Hal paling penting yang perlu diperhatikan adalah urutan operan terbalik antara at&t dan intel (MOV EAX, EBX menjadi movl %ebx, %eax).

Yang terjadi di sini adalah: angka 1234 di-push ke stack, lalu ketika proc1 dipanggil (0x10005) maka alamat 0x10009 dipush ke stack juga. Posisi stack ketika masuk ke proc1 adalah seperti ini:

ditaa-stack-3.png

Jika ada lebih dari satu parameter void proc1(int param1, int param2), yang terakhir di-push pertama (konvensi standar C):

0x10000 push $5678 ;parameter ke-1
0x10005 push $1234 ;parameter ke-2
0x10009 call proc1
0x1000C ... ; other instructions

ditaa-stack-4.png

Di sisi fungsi yang dipanggil: data yang berada di top of stack ([sp]) adalah alamat kembali, dan data berikutnya adalah parameter pertama (misalnya ukuran elemen stack adalah 4 byte, maka parameter ada di alamat: [sp+4], ingat stack di x86 bergerak turun). Mengakses parameter dengan cara [sp+4] merepotkan karena posisi stack selalu berubah-ubah selama fungsi berjalan (misalnya ketika fungsi tersebut memanggil fungsi lain, atau melakukan komputasi yang butuh stack).

Biasanya cara yang digunakan adalah menyimpan posisi stack saat ini (esp) ke dalam register ebp, dan setelah itu akses bisa dilakukan via register ebp.

mov    %esp,%ebp

Tapi masalahnya isi register ebp sendiri perlu disimpan (kalau tidak, nilainya akan kacau setelah kita memanggil sebuah fungsi). Lalu ebp ini perlu disimpan di mana? di stack. Jadi biasanya bagian awal suatu fungsi adalah seperti ini:

push   %ebp
mov    %esp,%ebp

dan diakhiri seperti ini:

pop    %ebp
ret

Karena esp sudah disalin ke ebp, maka untuk mengakses parameter pertama, kita menggunakan [ebp+8], ingat tadinya [esp+4], tapi perlu ditambah 4 karena kita menambah ebp ke stack.

Stack dan variabel lokal

Sekarang kita masuk ke kegunaan stack berikutnya: untuk menampung variabel lokal. Untuk menampung variabel lokal, stack tidak dianggap sebagai stack (tidak diakses dengan push/pop), tapi dianggap sebagai lokasi memori yang kontigu. Compiler akan menghitung berapa besar variabel lokal yang dibutuhkan lalu akan mengurangi nilai stack (ini efeknya terhadap esp seperti mem-push banyak data). Ini dilakukan dengan instruksi sub, misalnya

sub    $0x10,%esp ; alokasikan 16 byte (10 heksadesimal) untuk variabel lokal

Compiler biasanya mengalokasikan lebih banyak dari seharusnya sesuai dengan alignment CPU, dan juga level optimasi compiler. Anda bisa membaca mengenai alignment di http://en.wikipedia.org/wiki/Data_structure_alignment

Misalnya fungsi C seperti ini:

void f()
{
    int d;
    int e;
}

Total memori yang dibutuhkan: 8 byte. Dalam assembly, fungsi di atas adalah seperti ini (dengan setting agar optimasi tidak dilakukan oleh compiler):

0:   55                      push   %ebp
1:   89 e5                   mov    %esp,%ebp
3:   83 ec 10                sub    $0x10,%esp
6:   c9                      leave
7:   c3                      ret

Perhatikan instruksi leave instruksi tersebut sama dengan:

mov %esp, %ebp
pop %ebp

Pada compiler gcc, ada beberapa cara mendapatkan listing assembly dari kode C. Pertama dengan -S, program tidak akan dicompile, dan assembly akan dihasilkan dengan banyak simbol tambahan

 gcc -S namafile.c

Alternatif lain adalah dengan mengkompilasi seperti biasa, lalu kita mendisassemble object filenya (.o) atau executable filenya.

objdump -d namafile.o

Alternatif lain lagi adalah dengan menggunakan gdb (GNU debugger)

gdb nama_file_exe
(gdb) disas nama_fungsi

Kembali ke topik, sebenarnya hanya diperlukan 8 byte untuk fungsi f, tapi compiler menyiapkan 16 byte ini kadang dilakukan untuk optimasi. Contoh berikutnya jika kita melakukan operasi pada d.

void f()
{
    int d;
    int e;
    d = 2;
    e = 3;
}

Dalam assembly:

 0:   55                      push   %ebp
 1:   89 e5                   mov    %esp,%ebp
 3:   83 ec 10                sub    $0x10,%esp
 6:   c7 45 f8 02 00 00 00    movl   $0x2,-0x8(%ebp)
 d:   c7 45 fc 03 00 00 00    movl   $0x3,-0x4(%ebp)
14:   c9                      leave
15:   c3                      ret

Instruksi di alamat 6 menunjukkan bahwa variabel lokal pertama diakses dengan [ebp-8], dan variabel lokal kedua dengan [ebp-4].

Anggap fungsi f dipanggil seperti ini:

0x100000 call f
0x100005 .. ;instruksi lain

Dan di fungsi f itu sendiri adalah seperti ini:

 push   %ebp   
 mov    %esp,%ebp  ; di sini EBP = ESP = 1020
 sub    $0x10,%esp
 movl   $0x2,-0x8(%ebp) ; variabel lokal d
 movl   $0x3,-0x4(%ebp) ; variabel lokal e
 leave
 ret

Maka isi stack setelah instruksi push %ebp adalah:

ditaa-stack-5.png

Dan setelah ESP dikurangi 0x10 (16 desimal) sub $0x10,%esp

ditaa-stack-6.png

Stack dan variabel lokal array (dan stack overflow)

Sekarang saya akan menunjukkan sebuah hal yang menarik: jika kita memiliki variabel lokal yang berupa array, bagaimana layout stacknya. Saya juga ingin sekaligus menunjukkan contoh buffer overflow dengan mengisi variabel melewati deklarasinya.

void a()
{
    int d[2];
    d[0] = 11; //0x0b
    d[1] = 22; //0x16
    d[2] = 33; //0x21
    d[3] = 44; //0x2c

}

Dalam assembly:

 00000000 <a>:
    0:   55                      push   %ebp
    1:   89 e5                   mov    %esp,%ebp ; di sini EBP = ESP = 1020
    3:   83 ec 10                sub    $0x10,%esp
    6:   c7 45 f8 0b 00 00 00    movl   $0xb,-0x8(%ebp) 
    d:   c7 45 fc 16 00 00 00    movl   $0x16,-0x4(%ebp)
   14:   c7 45 00 21 00 00 00    movl   $0x21,0x0(%ebp)
   1b:   c7 45 04 2c 00 00 00    movl   $0x2c,0x4(%ebp)
   22:   c9                      leave  
   23:   c3                      ret 

Tanpa opsi optimasi, gcc akan membuat alokasi variabel lokal di stack mirip seperti sebelumnya. Asumsikan fungsi ini dipanggil seperti ini:

0x100000 call a
0x100005 .. ;instruksi lain

ditaa-stack-7.png

lokasi memori untuk d[0] dan d[1] terdefinisi di dalam stack, tapi d[2] dan d[3] tidak. Akses d[2] bisa menimbulkan error karena mengubah ebp yang tersimpan, akses d[3] , bila kita mencoba menimpa d[3] maka alamat kembali akan tertimpa. Mengakses varibel array lokal di luar range yang terdefinisi menyebabkan stack overflow.

Mengapa C tidak membatasi akses array? Ada dua alasan: pertama pengecekan indeks array akan memerlukan komparasi, dan ini tidak efisien. Kedua: ada teknik yang membutuhkan akses array lebih dari deklarasinya (lihat struct hack: http://c-faq.com/struct/structhack.html).

Exploitasi stack overflow sederhana: mengalihkan ke fungsi lain

Lihatlah contoh program kecil ini

#include <stdio.h>
#include <stdlib.h>

void g()
{
    printf("inside g\n");
}

void f(int idx, int val)
{
    int b[1];
    printf("inside f\n");
    b[idx] = val;
}

int main()
{
    f(0, 1);
    return 0;
}

Jika fungsi f dipanggil oleh fungsi main, maka fungsi f akan dieksekusi, lalu program akan berhenti. Keluaran program adalah:

 inside f

Kita lihat versi assembly fungsi f (output dari gcc -S):

  .LC1:
        .string      "inside f"
        .text
  .globl f
        .type      f, @function
  f:
        pushl     %ebp
        movl      %esp, %ebp
        subl      $40, %esp
        movl      $.LC1, (%esp)
        call      puts
        movl      8(%ebp), %eax  ; masukkan param pertama (idx) ke eax
        movl      12(%ebp), %edx ; masukkam param kedua (val) ke edx
        movl      %edx, -12(%ebp,%eax,4) ; b mulai dari ebp-12
        leave
        ret

Atau bagi yang lebih familiar dengan syntax intel.

 .LC1:
    .string "inside f"
    .text
 .globl f
    .type   f, @function
 f:
    push    ebp
    mov ebp, esp
    sub esp, 40
    mov DWORD PTR [esp], OFFSET FLAT:.LC1
    call    puts
    mov eax, DWORD PTR [ebp+8]
    mov edx, DWORD PTR [ebp+12]
    mov DWORD PTR [ebp-12+eax*4], edx
    leave
    ret

Secara singkat: variabel array b dimulai dari ebp-12. Begini keadaan stack setelah subl $40, %esp.

ditaa-stack-8.png

Setelah mengetahui layout stacknya. Kita bisa menambah baris yang menarik f(4, (int)g);:

int main()
{
    f(0, 1);
    f(4, (int)g);
    return 0;
}

Sekarang keluaran program menjadi:

inside f
inside f
inside g
Segmentation fault

Kenapa bisa begitu? sederhana sekali penjelasannya, ketika baris ini dipanggil:

f(4, (int)g);

Yang terjadi di fungsi f adalah:

b[idx] = val;    

Perhatikan bahwa ketika pemanggilan dilakukan: nilai idx adalah 4 dan val adalah alamat fungsi g. Seperti terlihat dalam gambar kondisi stack, d[4] adalah alamat kembali program

Catatan: di GCC versi relatif baru, proteksi stack diaktifkan secara default (yang menyebabkan stack overflow sulit dieksploitasi). Penjelasan mengenai hal ini akan diberikan di bagian lain. Jika contoh tidak berjalan, gunakan command line berikut ini gcc -fno-stack-protector -o bug bug.c.

Penutup

Di sini Anda sudah bisa melihat bahwa alur eksekusi program bisa dialihkan ke fungsi lain. Sebenarnya pengetahuan ini sudah cukup untuk serangan buffer overflow yang disebut dengan "return-to-libc" (ini akan dibahas lebih dalam di bagian lain), tapi biasanya kita ingin mengeksekusi program kita sendiri ketika terjadi overflow.

Di bagian mengenai shellcode, akan saya tunjukkan bagaimana caranya agar kita bisa menginjeksi kode baru yang akan dieksekusi (kode yang tidak ada di program), kode baru ini bisa melakukan apa saja yang kita inginkan.

Copyright © 2009-2018 Yohanes Nugroho