Proteksi Terhadap Buffer Overflow

Buffer overflow merupakan anomali dalam program. Ada dua pendekatan mencegahnya: membuat supaya program tidak memiliki buffer overflow dan merancang sistem, agar jika ada buffer overflow maka tidak ada efek buruknya.

Berikut ini urutan pembahasan yang akan saya lakukan:

Mencegah terjadinya overflow

Ada beberapa cara mencegah buffer overflow, berikut ini ringkasan beberapa pendekatan tersebut:

  • Menulis ulang program dalam bahasa lain. Kadang ini tidak mungkin dilakukan, terutama jika programnya low level.
  • Memakai library khusus untuk mencegah kesalahan umum (library semacam ini tidak selalu tersedia)
  • Melakukan code review (tapi kadang ada bug yang terlewat)
  • Memakai tools yang dapat melakukan pemeriksaan statik (kadang bug ditemukan, kadang tidak)

Untuk aplikasi jaringan, ada pendekatan lain: paket jaringan yang masuk bisa diperiksa, dan jika mengandung shellcode yang dikenal, atau mengandung banyak NOP, maka paket akan dicegah masuk. Tapi cara ini sangat tidak efektif, karena ada kemungkinan mencegah paket valid, dan ada banyak variasi shellcode yang tidak dikenal.

Beberapa masalah dengan pendekatan pencegahan adalah:

  • Membutuhkan waktu jika harus dilakukan pada semua program (entah itu mengubah program agar memakai library lain, melakukan code review, dsb)
  • Tidak 100% efektif dan tidak selalu bisa dilakukan (misalnya untuk OS tertentu, tidak ada tools yang bisa memeriksa kode dengan baik)

Karena pendekatan pencegahan tidak selalu bisa dilakukan, maka pendekatan kedua tetap diperlukan: bagaimana membuat buffer overflow tidak efektif lagi andaikan ada bug buffer overflow di program.

Membuat buffer overflow tidak efektif

Langkah pengaman berikutnya adalah: jika ternyata terjadi buffer overflow, bagaimana caranya agar efeknya tidak merusak. Tidak apa-apa jika program crash, karena programnya memang salah, tapi jangan sampai mempengaruhi security.

Nonexecutable data (executable space protection)

Hal pertama yang bisa dilakukan untuk mencegah buffer overflow adalah: mencegah agar bagian data tidak bisa dieksekusi. Ini membutuhkan support hardware (NX bit di x86 atau XN bit di ARM). Biasanya data dan program dianggap sama, jadi kita bisa menginjeksikan shellcode. Jika shellcode tidak bisa dieksekusi, maka eksploitasi akan gagal. Bagaimana proteksi ini bekerja? Ketika instruksi ret dieksekusi, maka eksekusi akan dilanjutkan di stack. Karena stack adalah data, dan data tidak boleh dieksekusi, maka akan terjadi segmentation fault.

Proteksi ini tidak menambah overhead, karena dilakukan di level hardware. Kelemahannya utamanya adalah: untuk saat ini tidak semua hardware punya fitur ini. Jadi meski proteksi ini cukup bagus, proteksi ini tidak selalu dipakai.

Karena bagian data tidak bisa dieksekusi, maka tidak mungkin lagi menginjeksi kode ke program. Proteksi ini ternyata bisa diatasi dengan menggunakan teknik yang bernama return-to-libc (akan dijelaskan lebih lanjut di artikel lain). Kita tidak menambahkan/menginjeksikan kode baru ke program, tapi menggunakan kode yang sudah ada (atau fungsi yang ada di library, misalnya yang ada di library C yang dipakai semua program C). Ini dimungkinkan karena alamat fungsi selalu sama ketika program dijalankan (tapi lihat topik Address Space Layout Randomization/ASLR di tulisan ini).

Dengan teknik ini, kita mengisi stack dengan nilai yang akan menjadi parameter sebuah fungsi, lalu mengeset return address ke suatu fungsi yang kita tahu ada di memori. Dalam kasus ini, kode yang dieksekusi adalah kode yang sudah ada di executable section, hanya saja kita memberikan parameter sesuai yang kita inginkan. Pertama kali teknik ini diumumkan, fungsi yang dipilih adalah fungsi system yang ada di library C, oleh karena itu teknik ini disebut dengan return-to-libc.

Teknik itu kemudian dikembangkan lebih lanjut, supaya dapat mengeksekusi lebih dari satu fungsi. Dan akhirnya dikembangkan secara penuh menjadi return-oriented-programming. Teknik ini cukup kompleks: mengumpulkan potongan kode yang sudah ada di dalam program (atau library) dan disusun supaya bisa melakukan apapun juga. Detailnya cukup kompleks, Anda bisa membacanya sendiri di http://cseweb.ucsd.edu/~hovav/talks/blackhat08.html.

Canary

Canary (burung Kenari) dipakai oleh penambang untuk mengindikasikan gas berbahaya. Jika ada gas berbahaya, burung kenari akan mati lebih dulu, jadi penambang bisa tahu dan segera keluar dari tambang. Apa hubungannya kenari dengan buffer overflow? Kalau Anda lihat di pembahasan stack buffer overflow, Anda akan melihat bahwa untuk menimpa return address EBP harus ditimpa dulu. Nah jika sebelum EBP kita letakkan sebuah nilai, maka nilai tersebut juga pasti ditimpa jika terjadi buffer overflow. Nilai inilah yang disebut sebagai kenari (kadang disebut juga guard)

ditaa-proteksi-1.png

Nah jika kita ganti instruksi terakhir sebelum ret dengan instruksi untuk mengecek apakah nilai kenari masih sama dengan nilai awal atau tidak, maka kita bisa mengecek apakah terjadi buffer overflow atau tidak. Jika iya, maka program bisa langsung berhenti. Berikut ini contoh implementasinya untuk program kosong di C (hanya return 0 saja, OS OpenBSD):

         .file   "test.c"
         .globl  __stack_smash_handler
         .section        .rodata
 .LC0:
         .string "main"
         .text
         .globl  main
         .type   main, @function
 main:
         pushl   %ebp
         movl    %esp, %ebp
         subl    $24, %esp
         andl    $-16, %esp
         movl    $0, %eax
         subl    %eax, %esp
         movl    __guard, %eax
         movl    %eax, -24(%ebp)
         movl    $0, %eax
         movl    -24(%ebp), %edx
         cmpl    __guard, %edx
         je      .L2
         subl    $8, %esp
         pushl   -24(%ebp)
         pushl   $.LC0
         call    __stack_smash_handler
         addl    $16, %esp
 .L2:
         leave
         ret
         .size   main, .-main

Instruksi utama program sebenarnya hanya ini:

     movl    $0, %eax

Tapi perhatikan bahwa di awal ada instruksi yang menyimpan nilai __guard (ini akan diisi nilai acak setiap kali program dijalankan) dan disimpan di posisi ebp-24

         movl    __guard, %eax
         movl    %eax, -24(%ebp)

Dan di akhir, nilai ini dicek lagi, apakah masih sama atau tidak, jika tidak maka __stack_smash_handler akan dipangil.

         movl    -24(%ebp), %edx
         cmpl    __guard, %edx
         je      .L2
         subl    $8, %esp
         pushl   -24(%ebp)
         pushl   $.LC0
         call    __stack_smash_handler
         addl    $16, %esp
 .L2:
         leave
         ret
         .size   main, .-main

Tentunya kelemahan utama pemakaian kenari adalah: ada overhead setiap kali fungsi dipanggil. Harus ada pemeriksaan.

Ada beberapa jenis kenari:

  • Nilai tetap (0x0000000). Ini akan mencegah overwrite oleh fungsi-fungsi string (strcpy, strcat, dsb) karena fungsi string pasti tidak bisa mengoverwrite canary dengan nilai 0 (akan berhenti). Dengan kata lain: jika ada overflow di fungsi string, maka pasti nilai canary menjadi bukan nol.
  • Nilai NUL (0x00), karakter ganti baris CR (0x0d), LF (0x0a) dan akhir file EOF (0xff). Nilai ini akan menghentikan fungsi manipulasi string, dan juga menghentikan loop yang memproses sampai karakter akhir baris atau akhir file.
  • Nilai acak: nilai kenari dihasilkan acak ketika program berjalan
  • Nilai acak di XOR-kan dengan alamat kembali

Pendekatan pertama dan kedua bisa gagal jika fungsi yang error adalah fungsi selain manipulasi string (misalnya fread). Semua pendekatan bisa gagal jika salah satu variabel lokal adalah pointer ke fungsi, pointer itu dipakai untuk memanggil fungsi, dan kita bisa menimpa pointer tersebut. Masih ada beberapa kelemahan implementasi yang bisa dimanfaatkan (tergantung versi compiler dan library), tapi tidak akan dibahas di sini.

Address Space Layout Randomization (ASLR)

Jika kedua hal sebelumnya diimplementasikan, maka akan cukup sulit mengeksploitasi program. Tapi meski dengan proteksi Canary, kadang kita masih bisa menimpa return address (dan kadang canary tidak diaktifkan karena memakan CPU terlalu banyak). Lalu kita akan kena hambatam berikutnya: stack tidak bisa dieksekusi, tapi ini masih bisa diatasi dengan return-to-libc. Ini mungkin karena alamat fungsi selalu tetap. Dengan ASLR, alamat ini akan dirandomisasi oleh OS, sehingga alamatnya tidak lagi tetap. Ini akan menyulitkan serangan return-to-libc.

Di sistem Intel 64 bit, ASLR ini sangat efektif, tapi di sistem Intel 32 bit kurang efektif, karena prosessor hanya bisa mengubah 16 bit saja (65536 kemungkinan), dan ini bisa di brute-force dalam beberapa menit saja.

Pointer protection

Dalam pendekatan ini: setiap pointer akan "dienkripsi". Nilai pointer defaultnya dalam kondisi terenkirpsi, lalu akan didekripsi ketika akan dipakai. Cara yang disarankan adalah dengan meng-XOR-kan pointer dengan suatu nilai. Tadinya diusulkan agar teknik ini dimasukkan ke dalam compiler, tapi cara ini sangat lambat, sehingga tidak dipakai secara luas. Windows XP SP2 dan Windows memiliki API untuk mengenkripsi dan mendekripsi pointer, tapi programmer harus menambahkan kode proteksi secara manual.

Penutup

Melewati satu proteksi buffer overflow relatif mudah dilakukan. Melewati dua proteksi semakin sulit, dan melewati tiga proteksi menjadi sangat sulit. Meskipun proteksi sudah sangat hebat, dan akan melindungi sebagian besar program dari buffer overflow, beberapa program masih bisa dieksploitasi, jika beberapa kondisi dipenuhi (tergantung OS, Library, dsb). Programmer yang baik seharusnya tidak menggantungkan diri pada proteksi dari OS, tapi berusaha agar membuat program yang aman.

Copyright © 2009-2018 Yohanes Nugroho