• 35 min

Buffer Overflow คืออะไร ทำไมต้องสนใจด้วย

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

เป็ดไอคอนของเรื่องเล่าชาวอัลกอ Practical Algorithms
Practical Algorithms: เรื่องเล่าชาวอัลกอ
เพจที่อยากให้คนไทยมีเนื้อหาอัลกอริทึมดีๆ ให้ได้อ่านกัน
น้องอัลกอมอง Buffer ที่กำลังล้นออกมาแล้วก็อุทานว่า Overflowwwww

ทุกปัญหาของการ Overflow เนี่ย คำนำหน้ามันจะเป็นตัวบอกอยู่แล้วว่ากำลังพูดถึงเรื่องอะไร แล้วปัญหาคืออะไรสักอย่างที่มัน “ทะลุ”ของสิ่งนั้นออกมา แล้วทำให้เกิดความเสียหายกับตัวโปรแกรม หรือค่าในโปรแกรม จนเป็นสาเหตุให้โปรแกรมทำงานผิดพลาดหรือทำในสิ่งที่เราไม่ต้องการได้

แล้วถ้างั้น Buffer ที่มันทะลุไปเนี่ย คืออะไรล่ะ?

Buffer คืออะไร

Buffer หรือจริงๆจะเรียกอีกชื่อว่า Data Buffer ก็คือหน่วยความจำที่เราไปสร้างไว้เพื่อที่จะใส่ข้อมูลบางอย่างลงไปนั่นแหละ โดยส่วนมากแล้วในโปรแกรมที่ทำงานอยู่ ก็มักจะเป็นที่ แรม(Ram) อ่ะนะ

ถ้านึกไม่ออกให้ลองคิดเป็นว่าเราจะประกาศ Array มาสักตัวเพื่อเก็บสตริงก็ได้

Array ขนาด 8 byte ใช้สำหรับเก็บสตริง “Hello\0” โดยที่ให้มี ค่าบางอื่นๆอยู่ทั้งด้านหน้า และด้านหลังของ array ตัวนี้ แล้วให้มีวงเล็บครอบข้างบนเพื่อบอกว่า array ขนาด 8 byte นี่เรียกว่า buffer หน่วยความจำ (Buffer) สำหรับการเก็บสตริง

Array ขนาด 8 byte ใช้สำหรับเก็บสตริง “Hello\0” โดยที่ให้มี ค่าบางอื่นๆอยู่ทั้งด้านหน้า และด้านหลังของ array ตัวนี้ แล้วให้มีวงเล็บครอบข้างบนเพื่อบอกว่า array ขนาด 8 byte นี่เรียกว่า buffer หน่วยความจำ (Buffer) สำหรับการเก็บสตริง

โดย Array ที่เราประกาศไว้ หรือพื้นที่ที่เราจองไว้สำหรับเก็บข้อมูลเนี่ย จะเรียกว่า Buffer ที่มันจะอยู่ในสิ่งที่เรียกว่า Stack Memory ซึ่งอยู่บน Ram นั่นแหละ ซึ่งมันก็แปลว่ารอบๆข้างของ Buffer เนี่ยมันก็มีของอย่างอื่นอยู่ด้วย

ส่วนมากแล้ว Buffer จะมีขนาดที่ชัดเจน อย่างเช่นว่า 8 Bytes, 128 Bytes หรืออะไรก็ว่าไป

ตัวอย่างการประกาศ char Array ในภาษา C++

1
char buffer[8]; // Array ที่มีขนาด Fix ไว้ที่ 8 Bytes
2
char buffer[128]; // Array ที่มีขนาด Fix ไว้ที่ 128 Bytes
3
char buffer[256]; // Array ที่มีขนาด Fix ไว้ที่ 256 Bytes

ด้วยความที่จะอธิบายแบบเป็นแผงข้อมูลยาวๆไป แบบรูปข้างบนมันก็จะอธิบายยาก หลังจากนี้เราจะขอแบ่งให้มันเป็นแถวละ 8 bytes ละกันเพื่อความง่าย และเอาจริงๆเวลาโค้ดมันประกาศมันก็พยายามประกาศทีละ 8 bytes ด้วยอ่ะนะ

เรียง Buffer ใหม่แทนที่จะเป็นเส้นตรงยาวๆให้เป็นบล๊อกๆ ละ 8 ช่องแทน โดยเป็นว่าแถวนึงมี 8 ช่อง แล้วค่อยๆไล่จากบนลงล่าง ขอแบ่งแถวละ 8 bytes

เรียง Buffer ใหม่แทนที่จะเป็นเส้นตรงยาวๆให้เป็นบล๊อกๆ ละ 8 ช่องแทน โดยเป็นว่าแถวนึงมี 8 ช่อง แล้วค่อยๆไล่จากบนลงล่าง ขอแบ่งแถวละ 8 bytes

แล้วก็หลังจากนี้พอเราประกาศตัวแปร เดี๋ยวจะวาดเป็นแถวๆแยกๆกันจะได้มองง่ายขึ้นอีกหน่อย

หลังจากเรียง Buffer ใหม่เป็นแถวๆแล้ว ก็แบ่งให้แถวมันห่างกันหน่อยนึง แยกแต่ละบล๊อกให้มองง่ายๆหน่อย

หลังจากเรียง Buffer ใหม่เป็นแถวๆแล้ว ก็แบ่งให้แถวมันห่างกันหน่อยนึง แยกแต่ละบล๊อกให้มองง่ายๆหน่อย

ลองดูตัวอย่างเวลาเราประกาศตัวแปรในฟังก์ชัน กันนิดนึง

เทียบกับลำดับการประกาศตัวแปร และสิ่งนี้เกิดจากค่อยๆใส่ byte ซ้อนๆกันขึ้นไป มาก่อนอยู่ล่าง การเรียงตัวของ Buffer แต่ละตัวเมื่อเทียบกับการประกาศ

เทียบกับลำดับการประกาศตัวแปร และสิ่งนี้เกิดจากค่อยๆใส่ byte ซ้อนๆกันขึ้นไป โดยตัวแปรที่ประกาศก่อนจะอยู่ล่าง การเรียงตัวของ Buffer แต่ละตัวเมื่อเทียบกับการประกาศ

ทีนี้ตัวโปรแกรมมันพยายามประกาศทีละ 8 bytes ให้ได้ ดังนั้นบางทีที่เราประกาศตัวแปรที่ขนาดน้อยกว่า 8 bytes เราก็อาจจะได้ทั้งแถวมาเลยก็ได้เหมือนกัน (พูดเฉยๆ เรื่องนี้ไม่ได้มีผลตรงๆกัน Buffer Overflow)

ตัวแปรแต่ละประเภทใช้กี่ byte เท่าไหร่ int ใช้ 4, long long ใช้ 8, float ใช้ 4,double ใช้ 8,char ใช้ 1 ตัวอย่างการใช้พื้นที่ของตัวแปรแต่ละตัว

ตัวแปรแต่ละประเภทใช้กี่ byte เท่าไหร่ int ใช้ 4, long long ใช้ 8, float ใช้ 4,double ใช้ 8,char ใช้ 1 ตัวอย่างการใช้พื้นที่ของตัวแปรแต่ละตัว

แล้วมันไปมีปัญหาตรงไหน

เคยคิดเล่นๆมะ ว่าสมมติถ้า Array มันขนาดแค่ 8 Bytes แต่เราพยายามจะยัด สตริง(String) ขนาดเกิน 8 Bytes ลงไปจะเป็นยังไง

ไม่ต้องสมมติก็ได้ ลองเอาโค้ดนี้ไปรันเล่นเลย 😀

1
#include<stdio.h>
2
int 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

รูป terminal จากการใส่ค่า 123456789012345678901234567890 เข้าไปในโปรแกรมก่อนหน้านี้ แล้วได้ผลลัพท์เป็น Segmentation Fault ผลลัพธ์การใส่ค่า input ลงไป

เรื่องของเรื่อง คือคอมพิวเตอร์มันไม่ได้ฉลาด และจะทำตรงตามทุกอย่างที่เราบอกให้มันทำทุกอย่าง ถ้าเราบอกให้รับสตริงเข้าไปทั้งก้อน (scanf("%s", buffer);) มันก็รับแล้วพยายามยัดเข้าไปทั้งก้อน โดยที่ไม่ได้สนหรอกว่า สุดท้ายแล้ว buffer ที่ส่งให้ไปเก็บจะมีที่พอให้เก็บไหม (โดยเฉพาะภาษา Low-Level อย่าง C,C++)

แล้วด้วยความที่ buffer ขนาดแค่ 8 Byte อ่ะเนอะ จะไปพอใส่สตริง 123456789012345678901234567890 (31 Bytes รวม ‘\0’ ด้วย) ทั้งก้อนได้ยังไง มันก็ทะลุออกตัวอื่นไง 😆

มีช่องสีเหลืองอยู่ 8 ช่องที่เราพยายามใส่ของลงไปเกินจาก 8 ช่องนั้นทำให้ทะลุออกไปหาช่องอื่นๆที่มีสีแดง Buffer ตอนมันโดนใส่ค่าเกินจนทะลุ

มีช่องสีเหลืองอยู่ 8 ช่องที่เราพยายามใส่ของลงไปเกินจาก 8 ช่องนั้นทำให้ทะลุออกไปหาช่องอื่นๆที่มีสีแดง Buffer ตอนมันโดนใส่ค่าเกินจนทะลุ

โปรแกรมก็พยายามจะเขียนลงไปนั่นแหละ แค่มันจะทะลุ Array buffer ออกไปเลย แล้วไปชนอย่างอื่น ที่มันอาจจะมีผลต่อโปรแกรมเราก็ได้

Overflow ไปกระทบอะไรได้บ้าง

โดยปกติแล้วตัวที่มันทะลุไปเนี่ย มันก็แค่ไปชน buffer หรือตัวแปรอื่นๆที่อยู่ใกล้เคียงกันกับตัวที่เราพยายามใส่ข้อมูลนั่นแหละ

ลองดูตัวอย่างโค้ดนี้ละกัน ก็คือเราพยายามจะยัดสตริง “PracticalAlgo” ลงไปใน buffer d ซึ่งมันขนาดเล็กเกินไปที่จะใส่ข้อมูลลงไปทั้งหมด มันก็เลยไปทับ buffer c แทน:

1
#include <stdio.h>
2
#include <string.h>
3
int 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 ที่โดนเขียนข้อมูลทับไปมันก็จะมีปัญหา เพราะข้อมูลมันโดนเปลี่ยนไปหน่อยนึง (ก็ไม่หน่อยอ่ะนะ) ดูได้จากผลลัพธ์จากการรันโค้ดด้านล่าง:

1
c is Hello12
2
After strcpy, d is PracticalAlgo
3
c is lAlgo

หรือดูจากรูปก็ได้ว่าเกิดอะไรขึ้น

ใส่ค่า PracticalAlgo ที่มีขนาด 14 ไบต์ใส่ลง d ที่มีขนาดเก็บได้แค่ 8 ไบต์แล้วเกิน ไปทับ c บางส่วน การเรียง Buffer หลังจากที่ Buffer d ทะลุเข้า Buffer c

ใส่ค่า PracticalAlgo ที่มีขนาด 14 ไบต์ใส่ลง d ที่มีขนาดเก็บได้แค่ 8 ไบต์แล้วเกิน ไปทับ c บางส่วน การเรียง Buffer หลังจากที่ Buffer d ทะลุเข้า Buffer c

แล้วถ้าเราเขียนทับไปเยอะๆก็อาจจะเข้าไปชน b กับ a ด้วยก็ได้

อีกตัวอย่างนึงคือ มันไม่จำเป็นต้องไปทะลุใส่สตริงอย่างเดียวก็ได้ มันเข้าไปในตัวเลขก็ได้เหมือนกัน:

1
#include <stdio.h>
2
int 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 ที่เราประกาศตัวแปรไว้เนี่ย มันยังมีอย่างอื่นอีกเยอะ

ก้อน buffer ที่อธิบายไปก่อนหน้านี้ จริงๆแล้วเรียกว่า call stack ที่ข้างล่างจากตรงที่มีตัวแปรที่เราประกาศ ยังมีของอย่างอื่นอีก Stack memory

ก้อน buffer ที่อธิบายไปก่อนหน้านี้ จริงๆแล้วเรียกว่า call stack ที่ข้างล่างจากตรงที่มีตัวแปรที่เราประกาศ ยังมีของอย่างอื่นอีก Stack memory

ข้างใต้ตรงนั้นยังมีข้อมูลสำคัญในการรันโปรแกรมเช่น Base Pointer Register, Return Address และ ข้อมูลของฟังก์ชันอื่นๆอีก

แล้วส่วนใหญ่ที่มัน Overflow ไปโดนแล้วมีปัญหาคือพวก Return Address ของฟังก์ชันนี่แหละ อารมณ์แบบค่าที่เก็บตำแหน่งของ Code ที่เมื่อพอรันฟังก์ชันจบแล้ว Code จะต้องกลับไปรันที่บรรทัดไหนนั่นแหละ

 รูป Function stack address ที่เป็นแนวตั้ง โดยให้ Buffer อยู่ข้างบน ลงมาเป็นตัวแปรอื่นๆ แล้วตามด้วย RBP แล้วก็ตามด้วย Return Address โดยให้มี return address ชี้ที่ตำแหน่งที่ต้องรันต่อ จริงๆแล้วมีของใต้นั้นไปคือคือสามตัวนี้

 รูป Function stack address ที่เป็นแนวตั้ง โดยให้ Buffer อยู่ข้างบน ลงมาเป็นตัวแปรอื่นๆ แล้วตามด้วย RBP แล้วก็ตามด้วย Return Address โดยให้มี return address ชี้ที่ตำแหน่งที่ต้องรันต่อ จริงๆแล้วมีของใต้นั้นไปคือคือสามตัวนี้

แล้วข้อมูลพวกนี้จะถูกสร้างขึ้นมาใหม่เรื่อยทับตัวเดิมเข้าไป ทุกครั้งที่มีการเรียกฟังก์ชัน อย่างในรูป เราก็มีข้อมูลของ Return Address และ RBP ของตัวฟังก์ชัน main แต่เมื่อ main เรียกฟังก์ชันอื่นเพิ่ม เราก็จะได้ทั้ง Return Address และ RBP ของฟังก์ชันใหม่มาอยู่ข้างบนอีกที

ถ้าเรียก function เพิ่ม ก็จะมีสามตัวนั้นเพิ่มเรื่อยๆ ในทุกๆฟังก์ชันก็จะมีสามตัวนี้อยู่ใน Stack memory

ถ้าเรียก function เพิ่ม ก็จะมีสามตัวนั้นเพิ่มเรื่อยๆ ในทุกๆฟังก์ชันก็จะมีสามตัวนี้อยู่ใน Stack memory

โอเคกลับเข้าเรื่อง Return Address…

ด้วยความที่ว่า Return Address เป็นตัวบอกว่าโค้ดจะต้องกลับไปอ่านต่อที่ไหน ตอนที่มีการเรียกฟังก์ชัน มันก็จะแอบเก็บไว้แหละว่าเดี๋ยวต้องกลับไปทำบรรทัดไหนต่อนะ อะไรประมาณนี้

ช่อง 8 ช่องที่แทน ตำแหน่งของ Return Address มีการไฮไลต์สีเหลือง Return Address

ช่อง 8 ช่องที่แทน ตำแหน่งของ Return Address มีการไฮไลต์สีเหลือง Return Address

แต่ถ้าเกิดว่า เราดันทำ Buffer Overflow ไปทับโดนตรงนั้น แล้วค่า Return Address มันเพี้ยน โปรแกรมก็จะไม่รู้จะกลับไปที่ไหนไงล่ะ

แล้วถ้าโชคดีหน่อยได้เลขแปลกๆมาใน Return Address แล้วมันอ่านไม่ได้ หรือบอกว่าตรงนั้นไม่ใช่โค้ด โปรแกรมเราก็จะโดน Segmentation Fault ตอนมันพยายามจะกลับไปที่ตำแหน่งโค้ด (เมื่อทำฟังก์ชันจบ)

ซึ่งตอนเขียนโปรแกรมแบบ Competitive มันก็คงจบแค่นั้น (อย่างที่บอกนั่นคือโชคดีแล้ว)

ปัญหาใหญ่กว่าคือ ถ้าตรงนั้นดันโดนทับ แล้วเป็นค่าที่มัน Return กลับไปได้จริงๆล่ะ?

ประเด็นคือเราสามารถเขียนทับ Return Address ให้มันไปชี้เข้าตำแหน่งโค้ดอื่นได้ไง ขอแค่เรารู้ว่าตำแหน่งของโค้ดอื่นที่เราจะส่งไปมันอยู่ตรงในโปรแกรม เราก็แค่ทำให้มัน Overflow เข้าไปโดน Return Address แล้วแก้ค่าในนั้นให้กลายเป็นตำแหน่งอื่นก็จบแล้ว

แล้วพอฟังก์ชันที่เรา Overflow ไปทับทำงานจบ มันก็จะกลับไปหาตำแหน่งที่เราแอบใส่เอาไว้ไง

Return Address มีข้อมูลชี้ไปหาฟังก์ชันอื่นๆแทนสิ่งที่มันควรจะไป ความอันตรายตอน Overflow ทับ Return Address

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/
0

บทความอื่นๆ