Technology

ความสำคัญของ Memory Safety กับ Modern Programming Language

By Arnon Puitrakul - 29 กรกฎาคม 2024

ความสำคัญของ Memory Safety กับ Modern Programming Language

จากประเด็น CrowdStrike แตก ส่งผลกระทบเป็นวงกว้าง สำหรับเรา เรียกว่าน่ากลัวกว่า Y2K มาก ๆ หลังจากเกิดเรื่องขึ้น ก็มีคนออกมาบอกว่า นี่นะ ถ้า Falcon ใช้ ภาษาที่มี Memory Safety มันจะไม่เกิดปัญหาแบบนี้ วันนี้เราเลยอยากจะมาเล่ากันว่า ทำไม Feature นี้มันจำเป็นกับภาษาใหม่ ๆ และ มันช่วยเราให้เขียนโปรแกรมได้ดีขึ้นอย่างไร

ปัญหา CrowdStrike เกี่ยวอะไรกับ Memory Safety

หลังจากเรื่องเกิดไม่นาน มีผู้ใช้ X ชื่อว่า @Perpertualmaniac ออกมา Post เรื่องที่เขาได้ลอง Dump memory ณ ตอนที่เกิดปัญหาขึ้นมา พบว่า มันเกิดปัญหาตอนที่มันพยายามเข้าถึง Memory บริเวณหนึ่งบนเครื่อง

เพื่อให้เห็นภาพมากขึ้น ให้เราคิดว่า Memory ในเครื่องของเรา ถูกแบ่งออกเป็นส่วนที่ OS ใช้ทำงาน และส่วนที่มันอนุญาติให้โปรแกรมสามารถเรียกใช้งานได้ โดยทั่วไป OS สมัยใหม่ เขามีระบบป้องกันตัวเองจากการเข้าถึงส่วนที่ไม่ได้รับอนุญาติหลากหลายกลไก เช่นการใช้ Protection Ring, Masking และ Segmentation

#include <stdio.h>

int main () {
  int* a = NULL;
  printf("%p\n", a);
  printf("%d", *a);
}

แต่ไม่ว่า เราจะวางกลไกป้องกันมากเท่าไหร่ มันไม่สามารถป้องกันได้ 100% แน่นอน ทำให้ OS ต้องมีกลไกในการจัดการสิ่งที่หลุดรอดออกมา ตั้งแต่พื้นฐานสุด ๆ หากเราแกล้ง มันด้วยการเข้าถึงส่วนที่เราไม่ควรเข้าถึง เช่น การใช้ Code ด้านบน เราสร้าง Null Pointer ขึ้นมาตัวนึง แล้วเราขอดู Address ของ Null Pointer และ อีกบรรทัดเราพยายามชี้เข้าไป

> gcc invalidAccessTester.c -o invalidAccessTester && ./invalidAccessTester                        
0x0
[1]    10924 segmentation fault  ./invalidAccessTester

เราจะเห็นว่า Address ของ Null Pointer มันจะเป็น 0x0 หรือคือ Address ที่ 0 และเมื่อเราพยายามชี้ไปที่ Address ที่ 0 เราจะโดน Segmentation Fault ทันที เป็นเพราะ OS มันรู้ว่า Application นี้ไม่ควรเข้าถึง Address นี้ มันเลยจัดการ Kill ชี้หน้าแนตใส่ซะเลย

#include <stdio.h>

struct Obj {
  int a;
  int b;
};

int main () {
  struct Obj* obj;
  printf("%p\n", obj);
  printf("%p\n", &obj->a);
  printf("%p", &obj->b);
  return 0;
}

งั้นเราลองทำให้มันเข้าใกล้ความเป็นจริงมากขึ้นด้วยการใช้งาน Data Structure ที่ซับซ้อนขึ้นอีกหน่อย อย่าง Structure เป็นแบบเดียวกับที่เขียนอยู่ใน X เรามี Structure ตัวนึงที่ภายในประกอบด้วย Interger ชื่อ a และ b ภายใน Main เราสร้าง Object จาก Structure และค่อยขอ Address ของตัว Object กับ สิ่งที่อยู่ใน Structure ออกมา

0x102e7bf20
0x102e7bf20
0x102e7bf24

เราจะเห็นว่า มันไม่ได้ซับซ้อนอะไรมาก เริ่มจาก Address ของ Object ที่สร้างจาก Structure เราจะได้ Address ตำแหน่งที่ลงท้ายด้วย 20 เป็นหัวของมัน ซึ่งมันจะตรงกับ Address ในบรรทัดต่อมา ซึ่งคือ a ที่เป็น Integer และ บรรทัดสุดท้าย จะเห็นว่ามันแตกต่างกัน แต่ดูไม่ยากว่า มันบวกขึ้นไป 4 เป็นเพราะ Integer บนเครื่องที่เรารันนั้นมีขนาด 4 Byte มันเลยเลื่อนไปอีก 4 ตำแหน่งนั่นเอง

#include <stdio.h>

struct Obj {
  int a;
  int b;
};

int main () {
  struct Obj* obj = NULL;
  printf("%p\n", obj);
  printf("%p\n", &obj->a);
  printf("%p", &obj->b);
  return 0;
}

แล้วถ้าตอนสร้างเรา Initialise ให้ Pointer ของ obj เป็น Null Pointer ละ มันจะเกิดอะไรขึ้น Address จะเปลี่ยนไปอย่างไร

0x0
0x0
0x4

สั้น ๆ ง่ายเลยคือ มันก็จะเอาตำแหน่งที่ 0 อย่างที่เราเห็นใน Null Pointer ก่อนหน้านี้ บวกด้วย Offset ของขนาดนั่นเอง ซึ่ง เราจะเห็นว่า ตำแหน่งที่ 0 และ 4 ไม่น่าจะเป็นตำแหน่งที่ Application ทั่ว ๆ ไปสามารถเข้าถึงได้แน่นอน แน่นอนว่า เมื่อเราพยายามเข้าถึง Segmentation Fault แน่นอนจากที่เราทดลองให้ดูก่อนหน้า

ความบรรลัยมันเกิดขึ้นเมื่อ Falcon มันเป็นโปรแกรมพิเศษกว่าโปรแกรมที่เรายกตัวอย่าง มันคือ Driver หรือโปรแกรมที่ทำหน้าที่เหมือนคนกลาง คอยเชื่อมระหว่าง OS และ Hardware เป็นโปรแกรมที่ใกล้กับ Hardware มากที่สุดละ เช่น Driver ของ GPU เมื่อ OS สั่งบอกว่า นาย ๆ คำนวณเลขอันนี้ให้หน่อย แทนที่ OS จะคุยกับ GPU โดยตรงซึ่งมันคุยกันไม่รู้เรื่อง ร้อยพ่อพันแม่ มันก็จะคุยกับล่ามอย่าง Driver แล้วค่อยไปคุยกับ GPU อีกทีนั่นเอง ถ้าเราไม่ทำแบบนั้น บอกเลยว่า OS บวมเท่าบ้านแน่นอน เป็นหลักการที่เราใช้งานกันแบบนี้มานานมากแล้ว

พอมันเป็นโปรแกรมพิเศษมาก ๆ ทำให้มันได้สิทธิ์การเข้าถึง Memory และ OS ที่ลึกมาก ๆ จากตัวอย่างก่อนหน้าที่เมื่อเรารันมันไม่เกิด BSOD หรือ Kernel Panic เป็นเพราะ Application ที่เราเขียนไม่ได้มีสิทธิ์อะไรมากมาย เทียบกับ Falcon ที่เป็น Driver ทำงานอยู่ในสิทธิ์ที่สูงกว่ามาก ๆ

ทีนี้ พอคนเขียน Falcon ไป Dereference Null Pointer มันเลยกลายเป็น Falcon พยายามเข้าถึง Memory ส่วนที่เป็น OS อาจจะเป็น Kernel ของ OS เอง ซึ่งมันมีสิทธิ์ไง พอมันโดนปุ๊บส่วนสำคัญ ๆ ของ OS อาจจะทำงานผิดปกติจนเกิดข้อพลาดที่ไม่สามารถจัดการได้อีก เลยทำให้เกิด BSOD หรือ Kernel Panic บน Linux นั่นเอง

จากปัญหาตรงนี้ ทำให้หลาย ๆ คนออกมาบอกว่า หาก Falcon ถูกเขียนด้วย ภาษาสมัยใหม่อย่าง Rust ปัญหานี้จะไม่เกิดเลย

ทำไมภาษา C ถึงอนุญาติให้เราทำอะไรแบบนี้ ?

อาจจะเกิดคำถามว่า อ้าว แล้วทำไม ตัวภาษา C และ C++ เองถึงอนุญาติให้ Developer สามารถเข้าถึง Memory จนเกิดปัญหาลักษณะนี้ได้ละ เราต้องเข้าใจก่อนนะว่า ภาษา C มันเกิดขึ้นมาตั้งแต่ช่วง 70s ซึ่งตอนนั้นคอมพิวเตอร์มันไม่ได้มี Memory และ Performance สูงเหมือนทุกวันนี้ เรียกว่า เราจะต้องรีดทุกส่วนของคอมพิวเตอร์ออกมา เพื่อให้โปรแกรมมันทำงานได้เร็วมากที่สุด

ซึ่งการที่ภาษาอนุญาติให้ Developer สามารถเข้าถึง และ จัดการ Memory ได้อย่างอิสระแบบนั้น ทำให้เราสามารถ Optimise Application ของเราได้มากที่สุด เพื่อสู้กับข้อจำกัดทางด้าน Hardware นั่นเอง

#include <stdio.h>

int* doSthFunc (int a[]) {
  // Do Sth Here
  return a;
}
int main () {
  int a[100000][100000];
  doSthFunc(a);
  return 0;
}

เช่น เราบอกว่า เราจะต้อง Pass 2D Integer Array ขนาด 100,000 x 100,000 เริ่มจาก ถ้าเราจองพื้นที่บน main() ถ้า Integer 1 ตัวเรากิน 4 Bytes นั่นแปลว่า... การสร้าง Array บน main() เราจะต้องใช้ 40 GB แล้วเราจะต้องโยนมันเข้าไปในอีก Function นั่นเท่ากับว่า เราจะต้องจองอีก 40GB เพื่อ Copy Array ก้อนเดิมนั่นแหละไปทำงานบนอีก Function กลายเป็นว่า เราต้องใช้ 80 GB เลยนะ

อ่านมา อาจจะคิดนะว่า คนบ้าอะไรวะ จะขอ Allocate Array ขนาด 100,000 x 100,000 แต่เราจะบอกว่า ปัจจุบัน Array ขนาดนี้ เรื่องเล็กมาก ๆ แค่รัน Neural Network เราดีลกับ Array ขนาดอลังการกว่านี้มาก ๆ นี่คือเราใจดีมาก ๆ แล้ว 🤣

#include <stdio.h>

int* doSthFunc (int *a) {
  // Do Sth Here
  return a;
}
int main () {
  int a[100000][100000];
  doSthFunc(a);
  return 0;
}

แต่พอ เรามี Pointer ขึ้นมา เราสามารถทำแบบด้านบนได้ คือการ Pass by reference หรือก็คือ เราโยนแค่ Address ของ Array เข้าไปตัวเดียวเท่านั้น เท่ากับว่า เราจะใช้ Memory แค่ 40GB + 64 bits โดยอันหลังก็คือ ขนาดของ Address บนคอมพิวเตอร์ระบบ 64-bits ซึ่งแน่นอนว่า น้อยกว่า 80 GB เมื่อเราใช้ Pass by value แน่นอน

หรือกระทั่ง มันทำให้เราสามารถทำงานแบบ Multithread ได้มีประสิทธิภาพมากขึ้นด้วย จากเดิมที่เราอาจจะต้อง Call-by-value ให้กับแต่ละ Thread เราก็สามารถโยนแค่ Pointer เข้าไป แล้วให้แต่ละ Thread Access Memory ในส่วนเดียวกันได้เลย (จุดนี้แหละ ที่จะแตกกันเยอะ ๆ อย่างไม่น่าเชื่อ เพราะรายละเอียดการไล่ Flow มันยุ่งยาก ไหนจะเรื่อง Race Condition ที่อาจเกิดขึ้นได้อีก ต้องระวังมาก ๆ)

นอกจากท่าที่ทำให้ดูก่อนหน้านี้ การใช้ Pointer ยังทำให้เราสามารถเล่นท่าแปลก ๆ มากกว่าเดิมได้ คือ อยู่ ๆ เราขอจอง Memory สักก้อนด้วย malloc() แล้วเก็บที่อยู่ของมันมา จะใช้อะไรค่อยคิด และยังสามารถ Pass by reference ทำงานบน Function อื่น ๆ ได้อีก

เราจะเห็นว่า การมี Pointer บน ภาษาเก่า ๆ อย่าง C และ C++ มันทำให้ Developer สามารถจัดการโปรแกรมได้ดีขึ้น เพื่อสู้กับขีดจำกัดของ Hardware ในสมัยก่อนได้ เราคิดว่า นี่แหละคือ เหตุผลว่า ทำไมภาษา C และ C++ ถึงมี Pointer ให้เราเข้าถึง Memory ได้ตรง ๆ ขนาดนี้

Power comes with responsibility

อย่างที่ Spiderman ได้บอกไว้ว่า Power comes with responsibility หากเรามีอำนาจแต่ไร้ความรับผิดชอบ ถามว่าสิ่งที่เกิดขึ้นมีอะไรได้บ้าง อย่างที่เราเห็นได้จากเคสของ Crowdstrike คือ BSOD กันทั่วโลกสำหรับคนที่ใช้งาน แตกยับ แน่นอนว่าเป็นประสบการณ์ที่แย่มาก ๆ สำหรับลูกค้า และลูกค้าของลูกค้าแน่นอนอันนี้ไม่ต้องสืบ

GitHub - benjamin-42/Trident: 32-bit exploit for iOS <9.3.5
32-bit exploit for iOS <9.3.5. Contribute to benjamin-42/Trident development by creating an account on GitHub.

อีกปัญหาที่อาจเกิดตามมา เป็นเรื่องของ ความปลอดภัย ทั้งของ Application และ System เอง เช่น Wannacry ที่เป็น Ransomware ระบาดเป็นวงกว้างอยู่ช่วงนึง นั้นก็ใช้ช่องโหว่ Out-of-Bound Write ในการเข้าถึงด้วยเช่นกัน หรืออันที่เราพึ่งอ่านมา คือ Trident Exploit ก็เล่นกับกลไกการจัดการ Memory บน iPhone

Memory Safety Feature ทำอะไรบ้าง ?

บนภาษาใหม่ ๆ เราจะเห็นว่า ส่วนใหญ่ เขาไม่ให้เราเข้าถึง หรือจอง Memory ตรง ๆ เท่ากับภาษา C และ C++ เลย เนื่องจาก การเข้าถึง Memory ลักษณะนี้ มันเป็นเหมือน Land of unordered คือ เราทำอะไรได้เยอะมาก แต่แลกมากับความอันตรายแบบสุด ๆ

ปัญหาที่เรามักจะเจอในการทำงานกับ Memory มี 3 เรื่องคือ ใช้เสร็จแล้วลืม Free ออก, Free เร็วไป และ เข้าถึงผิด Address โดยเฉพาะเมื่อเราทำงานกับโปรแกรมที่ซับซ้อนขึ้น มี Flow ที่ซับซ้อน ทำให้ทั้งสามปัญหานี้เป็นปัญหาที่เราอาจเจอได้

บนภาษาใหม่ ๆ เขาเลยมี Feature หรือกลไกบางอย่างที่ลดโอกาสความผิดพลาดที่อาจจะเกิดจากการทำงานกับ Memory ลงไปได้ อย่างน้อยที่สุดคือ เราไม่จำเป็นต้องมานั่ง Free Memory เอง และผลพลอยได้คือ Application ที่ปลอดภัยมากกว่าเดิม และโอกาสเกิดความผิดพลาดน้อยกว่าเดิมด้วยเช่นกัน ยกตัวอย่างเช่น Python, Java และ Scala ที่ใช้ Garbage Collection

หลักการทำงานของ Garbage Collection คือ เมื่อโปรแกรมทำงานไปเรื่อย ๆ Garbage Collector มันจะทำงานอยู่เบื้องหลัง คอยเข้ามาเช็คตลอดเวลาว่า ภายใน Heap ของเรานั้นมี Memory ส่วนไหนที่ได้ถูกชี้จากฝั่ง Call Stack ตัวไหนที่ไม่ถูกชี้ Garbage Collector ก็จะเข้าไป Free ส่วนนั้นออกไป

แต่เมื่อ Garbage Collector มันต้องทำงานเช็คตลอดเวลา ทำให้มันต้องเอา Resource มาไล่เก็บ Memory ส่วนที่เราไม่ใช้แล้วออกเองโดยอัตโนมัติ ทำให้เกิดคำถามว่ามันมีวิธีอื่นที่ดีกว่าอีกมั้ย

จริง ๆ มี ใน Rust เอง เขาใช้ Concept ที่เรียกว่า Ownership พูดง่าย ๆ คือ ทุกการจอง หรือข้อมูลใน Memory มันจะต้องมีเจ้าของอยู่เสมอ หากเจ้าของถูกทำลาย เช่น Call Stack ที่มีเจ้าของมันโดน Pop ออกมา ข้อมูลที่เจ้าของมันถืออยู่ก็จะโดน Free หรือ Clear ออกไปจาก Memory ทันที ซึ่งกระบวนการนี้ มันถูกคิดตั้งแต่เวลาที่ Source Code ถูก Compile เลย หรือก็คือ เวลาเรา Compile พวกคำสั่งที่ใช้สำหรับการ Free Memory มันจะถูกยัดใส่เข้ามา ณ ตอนที่เรา Compile เลย ทำให้เราไม่ต้องรันระบบสำหรับการ Free Memory เพิ่มเติม

นอกจากกลไกในการช่วยเรา Free Memory ที่ไม่ใช้งานแล้ว มันยังมีกลไกอื่น ๆ ในการป้องกันพวก Buffer Overflow และการตรวจสอบ Null Pointer อีกเยอะมาก เรื่องของ Memory Safety มันเป็น Topics เป็นศาสตร์และศิลป์ในการออกแบบมาก ๆ ไม่ว่าจะฝั่งการเขียน Application และตัวภาษาเอง

การเลิกใช้ภาษาที่ไม่มี Memory Safety คือทางออกเหรอ ?

ความเห็นส่วนตัวเรา คือ ใช่ และ ไม่ใช่ คือ การใช้ภาษาที่มี Memory Safe Feature เป็นเรื่องดี มันทำให้เราสามารถลดโอกาสที่จะเกิดข้อผิดพลาดที่เกิดจาก Memory ได้ และมีตัวเลือกของภาษาที่มี Feature นี้ เรียกว่า เกือบทุกภาษาที่เราใช้กันใน Mainstream เลยละ เช่น Rust, Go, Scala และ Python

แต่ไม่ใช่ คือ การเปลี่ยนภาษาที่เขียน มันไม่ได้ง่ายขนาดนั้น โดยเฉพาะในองค์กรที่ต้องดูแล Codebase ที่ถูกเขียนมานานและยังคงใช้ทำงานอยู่ การจะเปลี่ยนรอบนึงหมายถึงทรัพยากรทั้งในเรื่องของคน เงิน และ เวลาจำนวนมาก ไหนจะความเสี่ยงที่อาจจะเกิดขึ้นเมื่อเราต้องเขียนโปรแกรมใหม่ทั้งหมด ไหนจะเรื่องของ คน ที่ต้องไปเรียนภาษาใหม่ พวก Pattern บางอย่างที่มันอาศัยประสบการณ์ก็ต้องหาคนเข้ามาจัดการ การเปลี่ยน Stack การทำงาน มันมีหลายเรื่องมากกว่าที่เราคิด มากกว่าการเปลี่ยนภาษาแน่นอน ดังนั้นสำหรับกลุ่มนี้ เราแนะนำว่า การค่อย ๆ เปลี่ยนแปลงสำหรับ Codebase ที่จะเขียนขึ้นใหม่ เรียกว่า การล้างเลือดมันยาก เราก็ค่อย ๆ ถ่ายมันออกเรื่อย ๆ ละกัน

เช่นเราเอง ในงานใหม่ ๆ ที่เข้ามา เราก็จะพยายามขยับไปใช้ Rust แทน C++ ในแง่ของการลงทุน เราไม่ได้ลงทุนอะไรมาก เพราะเราสามารถเขียนได้ทั้ง 2 ภาษาอยู่แล้ว แต่สาเหตุหลัก ๆ คือ การป้องกันความชิบหายที่อาจจะเกิดขึ้นมากกว่า ซึ่ง CrowdStrike เป็นตัวอย่างที่ดีเลยละว่า ทำไมเราถึงอยากไปเริ่มงานใหม่ ๆ กับ Rust ฮา ๆ

สรุป

การใช้งานภาษาที่เป็น Memory Safe เราคิดว่าเป็นตัวเลือกที่ดีสำหรับการเขียนโปรแกรมในสมัยใหม่ โดยเฉพาะ Application ที่ซีเรียสเรื่องของความปลอดภัยสูง ๆ หรือเป็น Mission Critical อย่างน้อยที่สุด มันพอจะเป็น Safeguard ให้เราในระดับหนึ่ง แต่มันไม่ใช่ Silver Bullet สำหรับการแก้ปัญหาทั้งหมดแบบ 100% ทุกวิธีย่อมมีช่องโหว่ และโอกาสเกิดความผิดพลาดได้เสมอ แต่เราสามารถใช้ Design Pattern และกลไกการตรวจสอบ Code เพื่อลดความเสี่ยงนั้นได้ ดังนั้นเราก็ยังคงต้องเรียนรู้และเข้าใจปรากฏการณ์ที่อาจจะเกิดขึ้นพร้อมทั้งวิธีลดความเสี่ยงของมันอยู่ดี โดยเฉพาะเมื่อคุณเรียน Computer Science แมร่งคือเรื่องพื้นฐานมาก ๆ ยังไงแกก็ไม่รอดแน่นอน หึหึ

Read Next...

AI Watermark กับความรับผิดชอบต่อการใช้ AI

AI Watermark กับความรับผิดชอบต่อการใช้ AI

หลังจากดูงาน Google I/O 2024 ที่ผ่านมา เรามาสะดุดเรื่องของการใส่ Watermark ลงไปใน Content ที่ Generate จาก AI วันนี้เราจะมาเล่าให้อ่านกันว่า วิธีการทำ Watermark ใน Content ทำอย่างไร...

เราจำเป็นต้องใช้ NPU จริง ๆ เหรอ

เราจำเป็นต้องใช้ NPU จริง ๆ เหรอ

ก่อนหน้านี้เราทำ Content เล่าความแตกต่างระหว่าง CPU, GPU และ NPU ทำให้เราเกิดคำถามขึ้นมาว่า เอาเข้าจริง เราจำเป็นต้องมี NPU อยู่ในตลาดจริง ๆ รึเปล่า หรือมันอาจจะเป็นแค่ Hardware ตัวนึงที่เข้ามาแล้วก็จากไปเท่านั้น วันนี้เราจะมาเล่าให้อ่านกัน...

Database 101 : Spreadsheet ไม่ใช่ Database โว้ยยยย

Database 101 : Spreadsheet ไม่ใช่ Database โว้ยยยย

บทความนี้ เราเขียนสำหรับมือใหม่ หรือคนที่ไม่ได้เรียนด้านนี้แต่อยากรู้ละกัน สำหรับวันนี้เรามาพูดถึงคำที่ถ้าเราทำงานกับพวก Developer เขาคุยกันบ่อย ๆ ใช้งานกันเยอะ ๆ อย่าง Database กันว่า มันคืออะไร ทำไมเราต้องใช้ และ เราจะมีตัวเลือกอะไรในการใช้งานบ้าง...

Hacker Crack โปรแกรมอย่างไร

Hacker Crack โปรแกรมอย่างไร

หากใครที่อายุใกล้ ๆ 30 ต้องเคยผ่านประสบการณ์โลกออนไลน์ในยุค 90s' มาไม่มากก็น้อย เป็นยุคที่เราเน้นใช้โปรแกรมเถื่อน ขายกันอยู่ในห้างพั____พ กันฉ่ำ ๆ ตำรวจตรวจแล้วเราไม่มีขายตัว แต่เคยสงสัยถึงที่มาของโปรแกรมเหล่านี้มั้ยว่า เขา Crack กันอย่างไร วันนี้เราจะมาเล่าให้อ่านกัน...