7 điểm bởi GN⁺ 2025-07-25 | 1 bình luận | Chia sẻ qua WhatsApp
  • An toàn bộ nhớ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

 
GN⁺ 2025-07-25
Ý kiến Hacker News
  • Đây là chuyện từng xảy ra trong nhóm Dropbox của tôi: việc ghi vào cấu trúc dữ liệu trên server Go mà không đồng bộ gần như là một nghi thức nhập môn khiến các kỹ sư mới vào liên tục gây ra segfault
    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
  • Swift đang cố giải quyết vấn đề này, nhưng ngoài đời thực đã có quá nhiều mã không an toàn tồn tại sẵn nên việc thay đổi diễn ra rất chậm và đau đớn
  • Tôi có một thắc mắc: các cấu trúc cơ bản như map vốn dĩ không thread-safe, nên phải cẩn thận khi sửa đổi, điều này cũng được ghi khá rõ trong đặc tả Go
    Tôi muốn nghe chi tiết hơn về tình huống sự cố đã xảy ra ở Dropbox
  • Tôi muốn nhấn mạnh rằng “memory safety theo nghĩa của Rust hay Java” ở đây không phải là cách định nghĩa thuật ngữ theo nghĩa chặt chẽ
    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ộ
  • Để có góc nhìn rõ hơn, tôi muốn tự hỏi có bao nhiêu trường hợp biến thể trong Go là không memory-safe, hoặc xác suất để một chương trình Go thực sự không memory-safe là bao nhiêu
  • Java cũng không memory-safe theo nghĩa của Rust
  • Vấn đề này thường lặp lại giống như các soundness hole của Rust; không phải là chuyện vô nghĩa, nhưng xác suất gặp phải ngẫu nhiên là khá thấp
    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
  • Tôi từng có trải nghiệm mất vài tháng để bắt một bug data race trong Go
    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óm
    Vì 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
  • Các maintainer của Rust cũng công nhận soundness hole là bug
    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
  • Uber nói rằng chương trình Go “bộc lộ mức độ đồng thời nhiều hơn 8 lần” so với microservice Java, và tôi thắc mắc việc dùng “concurrency” như một danh từ đếm được ở đây chính xác có nghĩa là gì
  • Zig cũng tuyên bố memory-safe, nhưng không có khái niệm tương tự kiểu Send/Sync của Rust
    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
  • Ngay cả chương trình Zig single-thread được build bằng ReleaseSafe cũng không tránh khỏi rủi ro memory corruption trong mọi chế độ tối ưu hóa, ví dụ khi dereference một con trỏ đã hết lifetime của biến cục bộ
  • Tuyên bố memory safety của Zig gần như là một trò đùa
    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
  • Trong mã thực tế, trừ khi được thiết kế với ác ý, tôi chưa từng thấy mã Go có lỗ hổng do data race
    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
  • Tôi thực sự từng thấy mã Go dễ bị tấn công do data race
  • Tôi đang cảm thấy nỗi đau bảo trì còn lớn hơn cả CVE
    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á
  • Lý do memory safety quan trọng là vì đa số CVE của chương trình C đến từ bug memory safety
    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
  • Điều quan trọng là thực sự có thể làm gì trong các thread
    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
  • Vấn đề memory safety điển hình trong C thường có khả năng cao dẫn tới RCE(remote code execution)
    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
  • Dù CVE có nghiêm trọng hơn, data corruption/crash do bug threading rốt cuộc vẫn là bug mà ai đó phải triage, phân tích và sửa
  • Thực tế đáng buồn là đa số ngôn ngữ có thread đều mặc định cung cấp biến global và quyền truy cập bộ nhớ chia sẻ không giới hạn
    Đâ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
  • Rust là một ngôn ngữ hiện đại tiêu biểu có thể tích hợp thread safety vào hệ thống kiểu
    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ện
  • Message passing có thể gây ra nhiều vấn đề logic hơn chia sẻ bộ nhớ, như race condition/deadlock, nên nó không phải lời giải vạn năng
  • Trong Go, có xu hướng nhấn mạnh giao tiếp giữa các goroutine(channel, v.v.) hơn là trực tiếp chia sẻ bộ nhớ
    Xem tài liệu này
  • Dù truyền object giữa các goroutine bằng channel, Go cũng không có các khái niệm như kiểu sendable, ownership hay tham chiếu read-only, nên không dễ để dùng cho an toàn
    Ví dụ thực tế:
    func processData(lines <-chan []byte) {
     for line := range lines {
      fmt.Printf("processing line: %v\n", line)
     }
    }
    
    func main() {
     lines := make(chan []byte)
     go processData(lines)
    
     var buf bytes.Buffer
     for range 3 {
      buf.WriteString("mock data, assume this got read into the buffer from a file or something")
      lines <- buf.Bytes()
      buf.Reset()
     }
    }
    
    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ọi Reset() thì backing memory được tái sử dụng, khiến cả processDatamain cùng lúc truy cập vào cùng một vùng nhớ, từ đó xảy ra data race
    Trong 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ên
    Channel 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
  • Trong các chương trình golang thực tế, mẫu “giao tiếp bằng chia sẻ” tạo ra hàng loạt vấn đề logic, và rốt cuộc chia sẻ bộ nhớ vẫn là chuyện phổ biến
    Tức là race “an toàn” hay deadlock “an toàn” thậm chí còn hay gặp hơn
  • Các cuộc thảo luận về bug đồng thời thường có xu hướng bỏ qua một thực tế là trong đa số ứng dụng, phần lớn bug thực sự quan trọng lại phát sinh từ việc khóa, transaction, transaction isolation... bị áp dụng sai bên trong DB
    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 UPDATE trong SELECT thì race vẫn xảy ra như thường
    Ngay 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 DB
  • Thuật ngữ “memory safety” ban đầu xuất hiện để giải thích một khái niệm phức tạp, nhưng theo thời gian ý nghĩa của nó bị mở rộng hoặc thu hẹp lại
    Cấ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
  • Nếu phần quan trọng của thuật ngữ memory safety là “type confusion”, thì Go cũng không phải ngoại lệ
    Ví dụ trong bài cho thấy chỉ cần hiểu nhầm int là con trỏ thì memory corruption có thể xảy ra rất dễ dàng
    Trong 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ự
  • Data race là hành vi vi phạm memory safety vì nó có thể đẩy chương trình vào trạng thái mà đặc tả ngôn ngữ không nhận biết được, ví dụ bị buộc dừng bởi SIGSEGV
    Vì vậy ngôn ngữ nào có thể xảy ra data race thì không thể gọi là memory-safe
  • Như ví dụ trong bài, torn read của fat pointer do type confusion, hay out-of-bounds write do torn read của slice, đều có thể thực hiện được
    Trong các trường hợp như vậy, thật khó để gọi đó là memory-safe
  • Việc thuật ngữ phát triển và đổi nghĩa là chuyện rất thường gặp trong toán học và vật lý
    Để 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 biết cơ sở nào để theo định nghĩa của tác giả thì Java lại không memory-safe
    Tôi muốn một ví dụ cụ thể
  • Bản thân Go cũng chính thức có định nghĩa rất mơ hồ về memory safety
    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
  • Định nghĩa của memory safety cũng thay đổi đôi chút theo thời đại
    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