- An toàn bộ nhớ và an toàn luồng không phải là những khái niệm có thể tách rời; nếu không có an toàn luồng thì không thể đạt được an toàn bộ nhớ thực sự
- Với những ngôn ngữ không an toàn luồng như Go, chỉ riêng các vấn đề về luồng cũng có thể phá vỡ an toàn bộ nhớ
- Một số ngôn ngữ như Java đảm bảo an toàn ở cấp độ ngôn ngữ bằng cách xử lý cả data race như hành vi đã được định nghĩa thông qua mô hình bộ nhớ đồng thời
- Go dễ bị tổn thương trước data race và đã tồn tại các trường hợp vi phạm an toàn bộ nhớ trong thực tế
- Thuộc tính thực sự quan trọng cần được xem xét là sự vắng mặt của Undefined Behavior (hành vi không được định nghĩa)
Không thể đảm bảo an toàn bộ nhớ nếu thiếu an toàn luồng
Sự nhầm lẫn về khái niệm: an toàn bộ nhớ vs an toàn luồng
- Gần đây an toàn bộ nhớ nhận được rất nhiều sự chú ý, nhưng định nghĩa chính xác của nó trên thực tế vẫn chưa thật rõ ràng
- Theo cách hiểu truyền thống, an toàn bộ nhớ dùng để chỉ những ngôn ngữ ngăn chặn truy cập bộ nhớ kiểu use-after-free hoặc out-of-bounds
- Trong khi đó, an toàn luồng đề cập đến các chương trình không có lỗi đồng thời, và hai khái niệm này thường được xem là tách biệt
- Tác giả cho rằng sự phân biệt này về thực chất không mấy hữu ích, và nhấn mạnh rằng điều chúng ta thật sự muốn là không có Undefined Behavior (UB)
Vi phạm an toàn bộ nhớ do data race: ví dụ với Go
- Để cho thấy vấn đề của việc tách riêng an toàn bộ nhớ và an toàn luồng, tác giả đưa ra ví dụ về ngôn ngữ Go
- Go thường được xếp vào nhóm ngôn ngữ an toàn bộ nhớ, nhưng trong chương trình như dưới đây, chỉ riêng data race cũng có thể gây ra lỗi bộ nhớ
liên tục thay đổi globalVar sang các giá trị thuộc kiểu khác nhau (Int, Ptr), đồng thời ở một goroutine khác đọc giá trị đó và gọi phương thức
- Khi hai luồng chồng lấn lên nhau và lần lượt cập nhật riêng hai con trỏ nội bộ của
globalVar (data, vtable), việc đọc ở thời điểm trung gian có thể tạo ra trạng thái trộn lẫn, dẫn đến truy cập bộ nhớ sai
- Kết quả là chương trình có thể cố tham chiếu tới một địa chỉ không hợp lệ (ví dụ
0x2a; hệ thập lục phân 42) và kết thúc do lỗi
- Hiện tượng này cũng tương tự với interface, slice... trong Go, và xảy ra do nhiều trường không được cập nhật một cách nguyên tử
Cách các ngôn ngữ khác xử lý đồng thời và an toàn bộ nhớ
- Các ngôn ngữ khác như Java cũng có thể gặp data race, nhưng chúng áp dụng mô hình bộ nhớ đồng thời được định nghĩa rõ ràng để đảm bảo chương trình không phá vỡ chính ngôn ngữ
- Ví dụ: Java thiết kế mô hình bộ nhớ rất tinh vi để ngay cả trong môi trường đa luồng, chương trình cũng không rơi vào lỗi runtime như lỗi phân đoạn cưỡng bức
- Phần lớn ngôn ngữ kiểm soát vấn đề đồng thời theo một trong hai cách sau
- Định nghĩa mô hình bộ nhớ sao cho mọi chương trình đồng thời đều được đảm bảo hành vi nhất quán (đổi lại là hạn chế tối ưu hóa của compiler và tăng gánh nặng triển khai)
- Java, C#, OCaml, JavaScript, WebAssembly...
- Dùng hệ thống kiểu mạnh để cấm phần lớn data race và chỉ cho phép một số ít ngoại lệ được xử lý an toàn (Rust, strict concurrency của Swift)
- Go không theo cả hai hướng này
- Chỉ đảm bảo an toàn bộ nhớ khi không có data race
- Có công cụ phát hiện data race, nhưng trong chương trình thực tế, rất khó xác minh mọi tình huống chỉ bằng kiểm thử
- Nghiên cứu và kinh nghiệm thực tế đều đã ghi nhận nhiều trường hợp vi phạm an toàn bộ nhớ có thật
Mô hình bộ nhớ của Go và vấn đề tài liệu hóa
- Tài liệu chính thức về mô hình bộ nhớ của Go nói rằng đa số race có kết quả bị giới hạn, nhưng không giải thích rõ rằng một số data race có thể dẫn đến kết quả vô hạn định
- Cũng có ý kiến cho rằng nó tương tự Java/JavaScript, nhưng hai ngôn ngữ này đã đầu tư nhiều công sức hơn Go rất nhiều để đảm bảo an toàn đồng thời
- Chỉ trong một vài mục chi tiết của tài liệu mới có nhắc một cách hạn chế rằng một số data race có thể gây ra hành vi hoàn toàn không được định nghĩa
Kết luận: sự vắng mặt của Undefined Behavior (UB) mới là mục tiêu thực sự
- Trên thực tế, thuộc tính mà người dùng thật sự mong muốn là chương trình không phá vỡ chính ngôn ngữ (không có UB)
- Các lỗ hổng bảo mật phát sinh từ vi phạm an toàn bộ nhớ là vì UB đã thực sự xảy ra
- Một khi UB xảy ra, mọi hành vi sau đó đều không thể dự đoán, và kẻ tấn công có thể lợi dụng điều đó
- Khác biệt bản chất giữa ngôn ngữ 'an toàn' và 'không an toàn' nằm ở khả năng phát sinh UB
- Thay vì chia nhỏ thành an toàn bộ nhớ, an toàn luồng, an toàn kiểu... thì bản thân việc có phát sinh UB hay không mới là cốt lõi
- Trên thực tế, mức độ an toàn tồn tại theo một phổ liên tục; Go an toàn hơn C nhưng không đảm bảo sự an toàn hoàn toàn
- Dựa trên dữ liệu thực tế, rất khó để 'chứng minh' mức độ an toàn của Go, và điều quan trọng là phải hiểu đúng những hệ quả có phần phản trực giác từ các lựa chọn thiết kế của từng ngôn ngữ
1 bình luận
Ý kiến Hacker News
Swift cũng gặp đúng vấn đề này, và tôi từng viết một chương trình để cho thấy Swift cũng rất dễ gây segfault khi truy cập cấu trúc dữ liệu dùng chung
Nói Go là memory-safe theo nghĩa như Rust hay Java thì hơi cường điệu
Tôi muốn nghe chi tiết hơn về tình huống sự cố đã xảy ra ở Dropbox
Memory safety, rốt cuộc, là một thuật ngữ trong bảo mật phần mềm hơn là một khái niệm của PLT(programming language theory)
Rốt cuộc thì các lập trình viên Go cũng hiểu khá rõ sự khác biệt này, nên Go lấy cách tiếp cận kiểu “đừng giao tiếp bằng cách chia sẻ, hãy chia sẻ bằng cách giao tiếp” làm tiền đề cơ bản
Tất nhiên ngoài thực tế khái niệm này chưa được hiện thực hóa đầy đủ, và mọi người đều hiểu rằng Go hiện đại cũng có rất nhiều chia sẻ và cần đồng bộ
Trên thực tế, sau nhiều năm vận hành Go, tôi gần như chưa từng thấy lỗi này xảy ra thực tế
Uber từng tổng hợp chi tiết các bug xảy ra trong mã Go, và bài viết này có bảng cho thấy mức độ thường xuyên của vấn đề trong thực tế
Trong Go, đa số các vấn đề truy cập đồng thời vào map hay slice xảy ra trên cùng một slice, và cần có hiện tượng “torn read” nên trên thực tế không phổ biến
Dù vậy, lý do mọi người tránh được khá tốt có lẽ là vì thường đủ cẩn thận và nhận thức được rủi ro của việc gán lại biến trong tình huống truy cập đồng thời
Ngôn ngữ cũng có sẵn atomics, channel, mutex, nên việc dùng sai trong tình huống truy cập đồng thời thực tế không thường xuyên, lại còn có race detector nên nếu có vấn đề thì thường tìm ra khá nhanh
Dù có tổn hao hiệu năng, tôi nghĩ vấn đề torn read chỉ là loại lỗi có thể sửa được, và trong mã Go đang chạy production thì đây chưa phải vấn đề lớn
Video liên quan
Race detector cũng không phát hiện được gì, và chẳng ai hiểu chuyện gì đang xảy ra
Cuối cùng hóa ra là bộ đếm vòng lặp bị overflow, khiến cùng một phép tính bị lặp lại vô số lần, và thỉnh thoảng request mất 3 phút thay vì 100ms
Tôi gián tiếp phát hiện vấn đề trong production bằng
perf, và kinh nghiệm debug với vai trò kỹ sư nền tảng đã giúp ích rất nhiều cho cả nhómVì tiếp xúc với đủ loại tình huống race trong Go, cá nhân tôi thật sự mong Rust được áp dụng ở khắp nơi
Ví dụ issue này cần một đợt refactor lớn ở compiler nên mất rất nhiều thời gian
Trên thực tế đến nay vẫn chưa có nhiều mã Zig đồng thời nên vấn đề chưa bùng lên mạnh, nhưng tôi nghĩ khi async được dùng rộng rãi hơn thì nhiều vấn đề có thể nổ ra cùng lúc
Tất nhiên nó vẫn ít bug hơn C, nhưng C++ cũng vậy và đâu ai gọi C++ là memory-safe
Tất nhiên điều đó không có nghĩa là rủi ro hoàn toàn bằng không, nhưng nó gợi ý rằng xét từ góc độ bảo mật của ứng dụng Go, đây có lẽ không phải vấn đề ưu tiên cao
Trong khi đó, với mã C/C++, 60~75% lỗ hổng ngoài đời thực đến từ vấn đề memory safety
Tôi nghĩ memory safety cũng là một phổ liên tục, và sau một mức nào đó thì hiệu dụng giảm dần
Dù là bug không thể exploit được thì cuối cùng vẫn là bug phải sửa
Vì thời gian dành cho bảo trì nhiều hơn rất nhiều so với phát triển ban đầu, nếu có thể giảm bảo trì thì dù việc ra mắt ban đầu bị chậm lại tôi vẫn nghĩ là đáng giá
Ngược lại, trong Go, thread safety không phải nguyên nhân chính gây CVE
Về mặt lý thuyết thì có cơ sở, nhưng ngoài thực tế nó không nổi bật lắm
Khi chia sẻ bộ nhớ, nếu làm hỏng cấu trúc dữ liệu thì thread khác có thể dẫn đến hành vi không an toàn hoặc sai lệch
Ví dụ nếu một thread thay đổi kích thước vector trong khi thread khác truy cập, thì thao tác vốn an toàn trong thực thi tuần tự lại trở nên nguy hiểm trong môi trường đồng thời
Go cũng không thể miễn nhiễm với điều này
Ngược lại, nếu vấn đề thread safety chỉ dừng ở segfault thì có thể cùng lắm chỉ là tấn công DoS(denial of service)
Race condition cũng có thể dẫn tới kiểu tấn công mạnh hơn, nhưng việc kích hoạt khó hơn rất nhiều
Đây là nguyên nhân chính của data corruption và race
Trong nhiều tình huống, mô hình dựa trên process tốt hơn thread cho concurrency, nhưng nhược điểm là quá nặng
Nếu mặc định mọi dữ liệu cần cho mỗi thread đều được truyền bằng message passing, tôi nghĩ phần lớn các vấn đề này sẽ biến mất
Dù sao thì nền tảng của chúng ta cho phép dùng biến global và bộ nhớ chia sẻ, nên nếu không muốn thì cứ tự tránh dùng
Mục tiêu ban đầu của Rust không phải là ngôn ngữ hệ thống memory-safe mà là ngôn ngữ hệ thống thread-safe, còn memory safety là kết quả tự nhiên đi kèm
Trong Rust, có thể dùng structured concurrency như
thread::scope, nên làm việc với thread rất thuận tiệnXem tài liệu này
Ví dụ thực tế: Trong đoạn mã trên,
buf.Bytes()truyền đi bằng cách tham chiếu nguyên vùng nhớ bên trong, và khi gọiReset()thì backing memory được tái sử dụng, khiến cảprocessDatavàmaincùng lúc truy cập vào cùng một vùng nhớ, từ đó xảy ra data raceTrong Rust, mã như vậy thậm chí còn không compile được vì đó là hai mutable reference, nên nó sẽ buộc phải chuyển ownership hoặc copy
Trong Go thì điều này rất dễ gây nhầm lẫn;
bytes.Buffer.ReadBytes("\n")hay.String()trả về bản copy nên an toàn, nhưng.Bytes()thì nguy hiểm như trênChannel của Rust ngăn chặn tận gốc vấn đề này bằng khái niệm ownership/chuyển giao, còn Go thì không có lớp bảo vệ như vậy
Kết quả là nó có vẻ vừa chậm hơn mutex, vừa tạo ra trải nghiệm khó dùng đúng hơn cho người mới học Go
Tức là race “an toàn” hay deadlock “an toàn” thậm chí còn hay gặp hơn
Trong lý thuyết PL, cách tiếp cận race freedom của Rust có thể hấp dẫn, nhưng ở ứng dụng thực tế thì dữ liệu quan trọng dù sao cũng nằm hết trong RDBMS, và ví dụ nếu không dùng
FOR UPDATEtrongSELECTthì race vẫn xảy ra như thườngNgay cả khi ứng dụng Rust hoàn toàn không dùng
unsafe, race vẫn tồn tại tùy theo DBCấu trúc của Go gần như không cho phép bug memory corruption, và điều này có thể thấy qua việc thiếu vắng exploit thực tế
Nếu theo lập luận của bài viết này, thì hầu hết ngôn ngữ bậc cao khác(trong bài gần như chỉ trừ Java) cũng sẽ không còn được xem là memory-safe
Rust có thể “an toàn hơn” Go, nhưng “memory safety” không phải là một phổ liên tục mà là một khái niệm đậu/trượt
Nếu muốn khẳng định một ngôn ngữ là memory-unsafe, bắt buộc phải đưa ra POC
Ví dụ trong bài cho thấy chỉ cần hiểu nhầm
intlà con trỏ thì memory corruption có thể xảy ra rất dễ dàngTrong bản demo người ta cố tình dùng 42 nên gây segfault, nhưng nếu dùng địa chỉ thật thì sẽ gây corruption thực sự
SIGSEGVVì vậy ngôn ngữ nào có thể xảy ra data race thì không thể gọi là memory-safe
Trong các trường hợp như vậy, thật khó để gọi đó là memory-safe
Để tránh kiểu vấn đề này, người ta đôi khi gắn tên người như “Gaussian Curvature”, “Riemann Integrals”
Cũng có những trường hợp “giữ nghĩa ban đầu ở phạm vi hẹp nhưng mở rộng ở nghĩa rộng hơn”, như ví dụ “Galois Group”
Memory safety cũng không ngoại lệ
Tôi muốn một ví dụ cụ thể
Trong FAQ và các câu trả lời về memory safety hay unions, Go có ngầm ám chỉ mình là memory-safe, nhưng thực tế không rõ điều đó có nghĩa chính xác là gì
Trong bài trình bày năm 2012 của Rob Pike có câu “Not purely memory safe”, nhưng ngay cả “purely” cũng không được định nghĩa
Trong tài liệu về race detector của Go, định nghĩa “safe” cũng rất mơ hồ(tài liệu ví dụ)
Ở bên ngoài, người ta thậm chí còn thường xuyên khẳng định rất mạnh rằng Go là “memory-safe programming language”
Ví dụ như tài liệu bảo mật của fly.io hay tài liệu phân loại Go là memory safe trên memorysafety.org
Nhưng trong cùng những tài liệu đó, “Out of Bounds Reads and Writes” cũng được mô tả là vấn đề memory safety, mà lỗi Go được chỉ ra trong bài viết lại đúng thuộc nhóm này
Ít nhất, tôi nghĩ Go và cộng đồng của nó cần làm rõ ý nghĩa chính xác của “memory safety”
Chừng nào còn tồn tại các trường hợp như thế này, sẽ thận trọng hơn nếu không gọi Go là ngôn ngữ memory-safe mà không có giải thích
Vào thời Go ra đời, quan điểm chủ đạo là “có garbage collector thì là memory-safe”, và so với C/C++ thì rõ ràng an toàn hơn nhiều