Buffer Overflow คืออะไร ทำไมต้องสนใจด้วย
Buffer Overflow เป็นปัญหาตอนเขียนโปรแกรมที่เกิดขึ้นได้บ่อยมากในภาษา Low-Level อย่าง C/C++ แต่มันจะอันตรายยังไง แล้วเราจะป้องกันมันได้ยังไงกันล่ะ


ทุกปัญหาของการ Overflow เนี่ย คำนำหน้ามันจะเป็นตัวบอกอยู่แล้วว่ากำลังพูดถึงเรื่องอะไร แล้วปัญหาคืออะไรสักอย่างที่มัน “ทะลุ”ของสิ่งนั้นออกมา แล้วทำให้เกิดความเสียหายกับตัวโปรแกรม หรือค่าในโปรแกรม จนเป็นสาเหตุให้โปรแกรมทำงานผิดพลาดหรือทำในสิ่งที่เราไม่ต้องการได้
แล้วถ้างั้น Buffer ที่มันทะลุไปเนี่ย คืออะไรล่ะ?
Buffer คืออะไร
Buffer หรือจริงๆจะเรียกอีกชื่อว่า Data Buffer ก็คือหน่วยความจำที่เราไปสร้างไว้เพื่อที่จะใส่ข้อมูลบางอย่างลงไปนั่นแหละ โดยส่วนมากแล้วในโปรแกรมที่ทำงานอยู่ ก็มักจะเป็นที่ แรม(Ram) อ่ะนะ
ถ้านึกไม่ออกให้ลองคิดเป็นว่าเราจะประกาศ Array มาสักตัวเพื่อเก็บสตริงก็ได้
หน่วยความจำ (Buffer) สำหรับการเก็บสตริง
หน่วยความจำ (Buffer) สำหรับการเก็บสตริง
โดย Array ที่เราประกาศไว้ หรือพื้นที่ที่เราจองไว้สำหรับเก็บข้อมูลเนี่ย จะเรียกว่า Buffer ที่มันจะอยู่ในสิ่งที่เรียกว่า Stack Memory ซึ่งอยู่บน Ram นั่นแหละ ซึ่งมันก็แปลว่ารอบๆข้างของ Buffer เนี่ยมันก็มีของอย่างอื่นอยู่ด้วย
ส่วนมากแล้ว Buffer จะมีขนาดที่ชัดเจน อย่างเช่นว่า 8 Bytes, 128 Bytes หรืออะไรก็ว่าไป
ตัวอย่างการประกาศ char
Array ในภาษา C++
1char buffer[8]; // Array ที่มีขนาด Fix ไว้ที่ 8 Bytes2char buffer[128]; // Array ที่มีขนาด Fix ไว้ที่ 128 Bytes3char buffer[256]; // Array ที่มีขนาด Fix ไว้ที่ 256 Bytes
ด้วยความที่จะอธิบายแบบเป็นแผงข้อมูลยาวๆไป แบบรูปข้างบนมันก็จะอธิบายยาก หลังจากนี้เราจะขอแบ่งให้มันเป็นแถวละ 8 bytes ละกันเพื่อความง่าย และเอาจริงๆเวลาโค้ดมันประกาศมันก็พยายามประกาศทีละ 8 bytes ด้วยอ่ะนะ
ขอแบ่งแถวละ 8 bytes
ขอแบ่งแถวละ 8 bytes
แล้วก็หลังจากนี้พอเราประกาศตัวแปร เดี๋ยวจะวาดเป็นแถวๆแยกๆกันจะได้มองง่ายขึ้นอีกหน่อย
แยกแต่ละบล๊อกให้มองง่ายๆหน่อย
แยกแต่ละบล๊อกให้มองง่ายๆหน่อย
ลองดูตัวอย่างเวลาเราประกาศตัวแปรในฟังก์ชัน กันนิดนึง
การเรียงตัวของ Buffer แต่ละตัวเมื่อเทียบกับการประกาศ
การเรียงตัวของ Buffer แต่ละตัวเมื่อเทียบกับการประกาศ
ทีนี้ตัวโปรแกรมมันพยายามประกาศทีละ 8 bytes ให้ได้ ดังนั้นบางทีที่เราประกาศตัวแปรที่ขนาดน้อยกว่า 8 bytes เราก็อาจจะได้ทั้งแถวมาเลยก็ได้เหมือนกัน (พูดเฉยๆ เรื่องนี้ไม่ได้มีผลตรงๆกัน Buffer Overflow)
ตัวอย่างการใช้พื้นที่ของตัวแปรแต่ละตัว
ตัวอย่างการใช้พื้นที่ของตัวแปรแต่ละตัว
แล้วมันไปมีปัญหาตรงไหน
เคยคิดเล่นๆมะ ว่าสมมติถ้า Array มันขนาดแค่ 8
Bytes แต่เราพยายามจะยัด สตริง(String) ขนาดเกิน 8
Bytes ลงไปจะเป็นยังไง
ไม่ต้องสมมติก็ได้ ลองเอาโค้ดนี้ไปรันเล่นเลย 😀
1#include<stdio.h>2int main()3{4 char buffer[8];5 scanf("%s", buffer);6 printf("You entered: %s\n", buffer);7}
พอ Compile เสร็จแล้วรันโปรแกรมดู ถ้าเราใส่ input ไปแบบ 1234567
มันก็ปกติไม่ได้มีอะไร ตัวโปรแกรมก็จะตอบ You entered: 1234567
มาซึ่งก็ตามสิ่งที่มันควรจะเป็นแหละ
เอาจริงๆถึงเราลองใส่เกิน 8 Byte มันก็อาจจะยังปกติ อย่างเช่น ใส่ 1234567890
ลงไป ถ้าโชคดีหน่อยมันก็คงตอบ You entered: 1234567890
กลับมา (ถ้าโชคดีอ่ะนะ)
เอาใหม่ คราวนี้ลอง 123456789012345678901234567890
ดูสิ บึ้มแน่ 555
ผลลัพธ์การใส่ค่า input ลงไป
เรื่องของเรื่อง คือคอมพิวเตอร์มันไม่ได้ฉลาด และจะทำตรงตามทุกอย่างที่เราบอกให้มันทำทุกอย่าง ถ้าเราบอกให้รับสตริงเข้าไปทั้งก้อน (scanf("%s", buffer);
) มันก็รับแล้วพยายามยัดเข้าไปทั้งก้อน โดยที่ไม่ได้สนหรอกว่า สุดท้ายแล้ว buffer
ที่ส่งให้ไปเก็บจะมีที่พอให้เก็บไหม (โดยเฉพาะภาษา Low-Level อย่าง C,C++)
แล้วด้วยความที่ buffer
ขนาดแค่ 8 Byte อ่ะเนอะ จะไปพอใส่สตริง 123456789012345678901234567890
(31 Bytes รวม ‘\0’ ด้วย) ทั้งก้อนได้ยังไง มันก็ทะลุออกตัวอื่นไง 😆
Buffer ตอนมันโดนใส่ค่าเกินจนทะลุ
Buffer ตอนมันโดนใส่ค่าเกินจนทะลุ
โปรแกรมก็พยายามจะเขียนลงไปนั่นแหละ แค่มันจะทะลุ Array buffer
ออกไปเลย แล้วไปชนอย่างอื่น ที่มันอาจจะมีผลต่อโปรแกรมเราก็ได้
Overflow ไปกระทบอะไรได้บ้าง
โดยปกติแล้วตัวที่มันทะลุไปเนี่ย มันก็แค่ไปชน buffer หรือตัวแปรอื่นๆที่อยู่ใกล้เคียงกันกับตัวที่เราพยายามใส่ข้อมูลนั่นแหละ
ลองดูตัวอย่างโค้ดนี้ละกัน ก็คือเราพยายามจะยัดสตริง “PracticalAlgo” ลงไปใน buffer d
ซึ่งมันขนาดเล็กเกินไปที่จะใส่ข้อมูลลงไปทั้งหมด มันก็เลยไปทับ buffer c
แทน:
1#include <stdio.h>2#include <string.h>3int main()4{5 char a[12];6 int b;7 char c[8] = "Hello12";8 char d[8];9 printf("c is %s\n", c);10 strcpy(d, "PracticalAlgo");11 printf("After strcpy, d is %s\n", d);12 printf("c is %s\n", c);13}
พอมันเป็นแบบนี้ buffer c
ที่โดนเขียนข้อมูลทับไปมันก็จะมีปัญหา เพราะข้อมูลมันโดนเปลี่ยนไปหน่อยนึง (ก็ไม่หน่อยอ่ะนะ) ดูได้จากผลลัพธ์จากการรันโค้ดด้านล่าง:
1c is Hello122After strcpy, d is PracticalAlgo3c is lAlgo
หรือดูจากรูปก็ได้ว่าเกิดอะไรขึ้น
การเรียง Buffer หลังจากที่ Buffer d ทะลุเข้า Buffer c
การเรียง Buffer หลังจากที่ Buffer d ทะลุเข้า Buffer c
แล้วถ้าเราเขียนทับไปเยอะๆก็อาจจะเข้าไปชน b
กับ a
ด้วยก็ได้
อีกตัวอย่างนึงคือ มันไม่จำเป็นต้องไปทะลุใส่สตริงอย่างเดียวก็ได้ มันเข้าไปในตัวเลขก็ได้เหมือนกัน:
1#include <stdio.h>2int main()3{4 int b = 10;5 char a[8];6 scanf("%s", a); // ใส่ 123456789012 เป็น input ลงไป7 printf("Int b should be 10: but is %d\n", b);8}
ถ้าลองรันโค้ดนี้แล้วใส่ข้อมูลนำเข้าลง console เป็น 123456789012
หรืออะไรก็ได้ที่เป็นสริงที่ยาวเกิน 7 ตัวอักษร
เราจะเห็นว่า ค่า b
มันจะเพี้ยนไปเป็นค่าแปลกๆ เพราะโดนข้อมูลตอนเราเขียนสตริง a
ไปทับ
แน่นอนว่าจุดสำคัญจริงๆมันไม่ใช่แค่ ค่าตัวแปรมันเพี้ยนๆไป แล้วก็จบหรอก เพราะว่าจริงๆแล้วข้างใต้ Stack Memory ที่เราประกาศตัวแปรไว้เนี่ย มันยังมีอย่างอื่นอีกเยอะ
Stack memory
Stack memory
ข้างใต้ตรงนั้นยังมีข้อมูลสำคัญในการรันโปรแกรมเช่น Base Pointer Register, Return Address และ ข้อมูลของฟังก์ชันอื่นๆอีก
แล้วส่วนใหญ่ที่มัน Overflow ไปโดนแล้วมีปัญหาคือพวก Return Address ของฟังก์ชันนี่แหละ อารมณ์แบบค่าที่เก็บตำแหน่งของ Code ที่เมื่อพอรันฟังก์ชันจบแล้ว Code จะต้องกลับไปรันที่บรรทัดไหนนั่นแหละ
จริงๆแล้วมีของใต้นั้นไปคือคือสามตัวนี้
จริงๆแล้วมีของใต้นั้นไปคือคือสามตัวนี้
แล้วข้อมูลพวกนี้จะถูกสร้างขึ้นมาใหม่เรื่อยทับตัวเดิมเข้าไป ทุกครั้งที่มีการเรียกฟังก์ชัน อย่างในรูป เราก็มีข้อมูลของ Return Address และ RBP ของตัวฟังก์ชัน main
แต่เมื่อ main
เรียกฟังก์ชันอื่นเพิ่ม เราก็จะได้ทั้ง Return Address และ RBP ของฟังก์ชันใหม่มาอยู่ข้างบนอีกที
ในทุกๆฟังก์ชันก็จะมีสามตัวนี้อยู่ใน Stack memory
ในทุกๆฟังก์ชันก็จะมีสามตัวนี้อยู่ใน Stack memory
โอเคกลับเข้าเรื่อง Return Address…
ด้วยความที่ว่า Return Address เป็นตัวบอกว่าโค้ดจะต้องกลับไปอ่านต่อที่ไหน ตอนที่มีการเรียกฟังก์ชัน มันก็จะแอบเก็บไว้แหละว่าเดี๋ยวต้องกลับไปทำบรรทัดไหนต่อนะ อะไรประมาณนี้
Return Address
Return Address
แต่ถ้าเกิดว่า เราดันทำ Buffer Overflow ไปทับโดนตรงนั้น แล้วค่า Return Address มันเพี้ยน โปรแกรมก็จะไม่รู้จะกลับไปที่ไหนไงล่ะ
แล้วถ้าโชคดีหน่อยได้เลขแปลกๆมาใน Return Address แล้วมันอ่านไม่ได้ หรือบอกว่าตรงนั้นไม่ใช่โค้ด โปรแกรมเราก็จะโดน Segmentation Fault
ตอนมันพยายามจะกลับไปที่ตำแหน่งโค้ด (เมื่อทำฟังก์ชันจบ)
ซึ่งตอนเขียนโปรแกรมแบบ Competitive มันก็คงจบแค่นั้น (อย่างที่บอกนั่นคือโชคดีแล้ว)
ปัญหาใหญ่กว่าคือ ถ้าตรงนั้นดันโดนทับ แล้วเป็นค่าที่มัน Return กลับไปได้จริงๆล่ะ?
ประเด็นคือเราสามารถเขียนทับ Return Address ให้มันไปชี้เข้าตำแหน่งโค้ดอื่นได้ไง ขอแค่เรารู้ว่าตำแหน่งของโค้ดอื่นที่เราจะส่งไปมันอยู่ตรงในโปรแกรม เราก็แค่ทำให้มัน Overflow เข้าไปโดน Return Address แล้วแก้ค่าในนั้นให้กลายเป็นตำแหน่งอื่นก็จบแล้ว
แล้วพอฟังก์ชันที่เรา Overflow ไปทับทำงานจบ มันก็จะกลับไปหาตำแหน่งที่เราแอบใส่เอาไว้ไง
ความอันตรายตอน Overflow ทับ Return Address
ความอันตรายตอน Overflow ทับ Return Address
ซึ่งก็แปลว่าเราสั่งโปรแกรมให้ไปทำอะไรก็ได้ยังไงล่ะ!!! ส่วนมากเอาง่ายๆเลยก็จับเปิด Shell เลย สนุกแน่ 🥲
จะป้องกันยังไงดี
สำหรับคนที่เขียนโปรแกรมบนภาษาแบบ Low-Level อย่าง C หรือ C++ นะฮะ
-
อันดับแรกเลยคือ อย่าลืมเช็กขนาดของสิ่งที่รับมาทุกครั้ง ดูก่อนว่ามันจะเกิน Buffer ที่เราประกาศไหม
-
นอกจากเช็กขนาด ถ้าเอาดีๆหน่อยก็ลิมิตการรับเข้าตั้งแต่แรก
-
ทำความสะอาด(Sanitize) ข้อมูลนำเข้าทุกครั้ง คิดซะว่าอย่าเชื่อใจ User Input ไปเลย
นอกเหรือจากนั้นยังมีเรื่อง Stack Cookie หรือ Stack Canary (แล้วแต่จะเรียก), Address Space Layout Randomization(ASLR), Position Independent Executable(PIE) อีก แต่ Compiler เดี๋ยวนี้มันทำให้หมดละ ไม่รู้ก็ยังไม่ใช่ปัญหาขนาดนั้น
อ้อ เท่าที่เห็นเหมือนเดี๋ยวนี้ Compiler บางตัวมันเตือนให้ด้วยนะว่าอาจจะ Overflow ได้ แต่ผมลืมละว่าตัวไหนบ้าง 😅
https://www.cloudflare.com/learning/security/threats/buffer-overflow/