Mô hình đơn giản hóa của Fil-C
(corsix.org)- Cấu trúc theo dõi con trỏ C/C++ bằng cách gắn kèm siêu dữ liệu AllocationRecord và thực hiện kiểm tra biên bộ nhớ khi dereference
- Với gán con trỏ, phép toán số học, truyền tham số hàm, giá trị trả về, cho tới các lệnh gọi
malloc·free, giá trị con trỏ gốc và siêu dữ liệu tương ứng được di chuyển cùng nhau hoặc được chuyển thành lời gọi chuyên dụng của Fil-C - Siêu dữ liệu con trỏ trong bộ nhớ heap được lưu riêng trong invisible_bytes; khi tải/lưu con trỏ thì đọc và ghi cả giá trị lẫn siêu dữ liệu, đồng thời áp dụng thêm kiểm tra căn chỉnh
filc_freechỉ giải phóngvisible_bytesvàinvisible_bytes, còn bản thân AllocationRecord vẫn được giữ lại; phần dọn dẹp về sau do bộ gom rác đảm nhiệm, còn biến cục bộ có khả năng để lộ địa chỉ sẽ được nâng lên heap- Dù vẫn còn các độ phức tạp triển khai thực tế như luồng, con trỏ hàm, tối ưu bộ nhớ và hiệu năng, mô hình này vẫn có thể được dùng như một ví dụ hệ thống cụ thể cho việc kiểm chứng an toàn bộ nhớ của mã C/C++ quy mô lớn hoặc cho pointer provenance
Mô hình Fil-C đơn giản hóa
- Fil-C sử dụng cấu trúc theo dõi siêu dữ liệu AllocationRecord* đi kèm con trỏ để xử lý mã C/C++ theo hướng an toàn bộ nhớ
- Bản triển khai thực tế dùng cách viết lại LLVM IR, nhưng mô hình đơn giản là tự động biến đổi mã nguồn C/C++
- Với mỗi biến cục bộ kiểu con trỏ trong hàm, thêm một biến cục bộ
AllocationRecord*tương ứng - Ví dụ, với
T1* p1thì thêmAllocationRecord* p1ar = NULL
- Các phép gán và tính toán đơn giản trên biến cục bộ kiểu con trỏ sẽ di chuyển cả AllocationRecord* cùng với giá trị con trỏ gốc
p1 = p2được biến đổi thànhp1 = p2, p1ar = p2arp1 = p2 + 10cũng đi kèmp1ar = p2ar- Cast từ số nguyên sang con trỏ sẽ đặt siêu dữ liệu thành
NULL - Cast từ con trỏ sang số nguyên được giữ nguyên
- Khi truyền tham số hàm và trả về giá trị, AllocationRecord* cũng được truyền bổ sung cùng con trỏ; một số lời gọi thư viện chuẩn cụ thể sẽ được thay bằng hàm chuyên dụng của Fil-C
- Các lời gọi
mallocvàfreeđược chuyển thànhfilc_malloc,filc_free - Ví dụ,
p1 = malloc(x); free(p1);trở thành{p1, p1ar} = filc_malloc(x); filc_free(p1, p1ar);
- Các lời gọi
filc_mallockhông chỉ cấp phát một vùng nhớ được yêu cầu mà thực hiện ba lần cấp phát- Cấp phát đối tượng
AllocationRecord - Cấp phát
visible_bytescho dữ liệu thực - Cấp phát
invisible_bytesbằngcallocđể lưu siêu dữ liệu vô hình AllocationRecordgồm các trườngvisible_bytes,invisible_bytes,length
- Cấp phát đối tượng
Dereference và kiểm tra biên
- Khi dereference con trỏ, dùng AllocationRecord* đi kèm để thực hiện kiểm tra biên
- Kiểm tra siêu dữ liệu con trỏ có khác
NULLhay không - Tính chênh lệch giữa vị trí con trỏ hiện tại và địa chỉ bắt đầu của
visible_bytes - Kiểm tra offset có nhỏ hơn tổng chiều dài hay không
- Kiểm tra phần chiều dài còn lại có đủ cho kích thước đối tượng bị dereference hay không
- Kiểm tra siêu dữ liệu con trỏ có khác
- Cả đọc lẫn ghi đều áp dụng cùng quy trình kiểm tra
- Trước
x = *p1cũng thực hiện kiểm tra - Trước
*p2 = xcũng thực hiện kiểm tra cùng dạng
- Trước
- Cấu trúc này ngăn chặn việc truy cập vượt ra ngoài phạm vi cấp phát của đối tượng mà con trỏ đang trỏ tới
Con trỏ trong heap và invisible_bytes
- Các con trỏ được lưu trong bộ nhớ heap không thể được compiler quản lý bằng biến riêng như biến cục bộ, nên dùng invisible_bytes
- Nếu tại vị trí
visible_bytes + icó một con trỏ, thìAllocationRecord*tương ứng sẽ được lưu tại vị tríinvisible_bytes + i - Tức là
invisible_byteshoạt động như một mảng có kiểu phần tử làAllocationRecord*
- Nếu tại vị trí
- Khi đọc hoặc ghi giá trị con trỏ từ bộ nhớ, ngoài kiểm tra biên thông thường còn thêm kiểm tra căn chỉnh
- Kiểm tra offset
icó là bội số củasizeof(AllocationRecord*)hay không - Chỉ khi điều kiện này đúng mới có thể truy cập
invisible_bytesan toàn như mảngAllocationRecord**
- Kiểm tra offset
- Khi load con trỏ, siêu dữ liệu cũng được load cùng với con trỏ dữ liệu
p2 = *p1sẽ được thêmp2ar = *(AllocationRecord**)(p1ar->invisible_bytes + i)saup2 = *p1
- Khi store con trỏ, không chỉ giá trị con trỏ mà cả siêu dữ liệu tương ứng cũng được lưu lại
*p1 = p2sau khi lưu dữ liệu thực sẽ thực hiện*(AllocationRecord**)(p1ar->invisible_bytes + i) = p2ar
filc_free và bộ gom rác
filc_freekhi con trỏ khácNULLsẽ kiểm tra tính khớp với AllocationRecord rồi chỉ giải phóng hai vùng nhớ- Kiểm tra
par != NULL - Kiểm tra
p == par->visible_bytes - Giải phóng
visible_bytesvàinvisible_bytes - Sau đó đặt
visible_bytes,invisible_bytesthànhNULL, cònlengththành 0
- Kiểm tra
- Dù
filc_malloccấp phát ba đối tượng,filc_freekhông giải phóng chính đối tượng AllocationRecord- Phần khác biệt này do bộ gom rác xử lý
- Trong mô hình đơn giản, GC stop-the-world là đủ; Fil-C thực tế dùng bộ thu gom song song, đồng thời, tăng dần
- GC lần theo các đối tượng
AllocationRecord - Các
AllocationRecordkhông còn reachable sẽ được đưa vào diện giải phóng
- GC lần theo các đối tượng
- GC còn thực hiện thêm hai công việc
- Khi giải phóng
AllocationRecordkhông còn reachable thì gọifilc_free - Mọi con trỏ trỏ tới
AllocationRecordcólengthbằng 0 sẽ được đổi sang mộtAllocationRecordchuẩn duy nhất có độ dài 0
- Khi giải phóng
- Nhờ cách này, kể cả không gọi
freethì cũng không dẫn đến rò rỉ bộ nhớ- GC sẽ tự động giải phóng
- Tuy vậy, gọi
freevẫn cho phép giải phóng bộ nhớ sớm hơn so với chờ GC
- Sau
free,AllocationRecordtương ứng cuối cùng sẽ trở thành không còn reachable và có thể được dọn dẹp về sau
Địa chỉ biến cục bộ thoát ra ngoài và nâng lên heap
- Khi có GC, phạm vi có thể xử lý an toàn địa chỉ biến cục bộ được mở rộng
- Nếu địa chỉ của biến cục bộ đã bị lấy, và compiler không thể chứng minh rằng địa chỉ đó không thoát ra ngoài vòng đời của biến, thì biến sẽ được nâng lên thành cấp phát trên heap
- Những biến cục bộ kiểu này được cấp phát bằng
mallocthay vì stack- Không cần chèn riêng lệnh
freetương ứng - GC sẽ đảm nhiệm việc thu gom
- Không cần chèn riêng lệnh
Phiên bản memmove của Fil-C
memmovetrong thư viện chuẩn C xử lý bộ nhớ tùy ý, nên compiler không thể biết bên trong có con trỏ hay không- Để xử lý điều này, áp dụng heuristic
- Các con trỏ trong vùng nhớ tùy ý phải nằm trọn vẹn trong phạm vi vùng nhớ đó
- Con trỏ phải được căn chỉnh đúng
- Vì quy tắc này, ngay cả khi cùng di chuyển 8 byte vẫn có thể có khác biệt trong hành vi
- Nếu
memmovemột khối 8 byte đã căn chỉnh trong một lần, thì phầninvisible_bytestương ứng cũng được di chuyển theo - Nếu chia thành 8 lần
memmove, mỗi lần 1 byte, thìinvisible_bytessẽ không di chuyển
- Nếu
Độ phức tạp bổ sung trong triển khai thực tế
-
Luồng
- Tính đồng thời làm tăng độ phức tạp của GC
filc_freekhông thể giải phóng bộ nhớ ngay lập tức- Vì có thể tồn tại race condition giữa luồng đang giải phóng và luồng khác đang truy cập cùng vùng nhớ
- Các phép toán nguyên tử trên con trỏ cũng cần xử lý bổ sung
- Việc viết lại cơ bản biến load/store con trỏ thành hai lần load/store nên làm mất tính nguyên tử
-
Con trỏ hàm
- Siêu dữ liệu bổ sung trong
AllocationRecordđánh dấuvisible_byteskhông phải dữ liệu thông thường mà là con trỏ tới mã thực thi - Khi gọi qua con trỏ hàm
p1, cần kiểm trap1 == p1ar->visible_bytesvà đồng thời kiểm trap1arcó đại diện cho con trỏ hàm hay không - Để ngăn tấn công nhầm lẫn kiểu trên con trỏ hàm, còn cần xác minh type signature ở ABI gọi hàm
- Một cách là làm cho mọi hàm đều có cùng một type signature
- Xử lý như việc gói toàn bộ tham số vào một struct và truyền qua bộ nhớ
- Ở biên ABI, mỗi hàm sẽ chỉ nhận một
AllocationRecordduy nhất tương ứng với struct đó
- Siêu dữ liệu bổ sung trong
-
Tối ưu sử dụng bộ nhớ
- Có thể cân nhắc để
filc_mallockhông cấp phátinvisible_bytesngay mà trì hoãn tới khi cần - Cũng có thể cân nhắc đặt
AllocationRecordvàvisible_byteschung trong một lần cấp phát - Nếu
mallocnền tảng gắn siêu dữ liệu ở phần đầu mỗi vùng cấp phát, thì cũng có thể đưa siêu dữ liệu đó vàoAllocationRecord
- Có thể cân nhắc để
-
Tối ưu hiệu năng
- Tính an toàn bộ nhớ của Fil-C đi kèm chi phí hiệu năng
- Vẫn còn dư địa áp dụng nhiều kỹ thuật để lấy lại một phần hiệu năng đã mất
Khi nào dùng Fil-C
- Có thể dùng khi mã C/C++ quy mô lớn trông như vẫn chạy được nhưng chưa có kiểm chứng an toàn bộ nhớ, và có thể chấp nhận việc đưa vào GC cùng suy giảm hiệu năng lớn để đổi lấy an toàn bộ nhớ
- Bài viết nhắc tới khả năng đây có thể là biện pháp tạm thời trước khi viết lại bằng Java, Go hoặc Rust
- Cũng có thể chạy Fil-C với mục đích phát hiện lỗi bộ nhớ như ASan
- Có thể chạy mã C/C++ dưới Fil-C để kiểm tra lỗi bộ nhớ
- Với các ngôn ngữ mà ngôn ngữ dùng lúc biên dịch và lúc chạy là một, và có độ an toàn mạnh ở thời điểm biên dịch, thì còn có thể dùng cho đánh giá an toàn ở compile time
- Ví dụ được nhắc tới là Zig
- Dù đánh giá runtime có thể không an toàn, đánh giá compile time vẫn có thể dùng cấu hình Fil-C
- Điều này cũng có ý nghĩa như một ví dụ hệ thống cụ thể khi bàn về pointer provenance
- Bài viết nêu câu hỏi liệu có thể tối ưu
if (p1 == p2) { f(p1); }thànhif (p1 == p2) { f(p2); }khip1vàp2cùng kiểu hay không - Trong Fil-C, do
AllocationRecord*truyền vàoflà khác nhau nên câu trả lời được nêu rõ là không thể - Ở điểm này, Fil-C đóng vai trò như một ví dụ hệ thống cụ thể có pointer provenance
- Bài viết nêu câu hỏi liệu có thể tối ưu
1 bình luận
Ý kiến trên Hacker News
Cũng còn chỗ để thử nghiệm counting tham chiếu hoặc các biến thể của invisible capability system, và tôi nghĩ cũng có thể tiết kiệm bộ nhớ đổi lại một chút chi phí gián tiếp tham chiếu
Hy vọng điều này sẽ hữu ích cho những ai muốn dùng cả hai cùng nhau để tạo hermetic builds
Upon freeing an unreachable AllocationRecord, call filc_free on it.Theo tôi, ý định ở đây có vẻ là phải giải phóng trước phần bộ nhớ mà các trường
visible_bytesvàinvisible_bytesđang trỏ tới rồi mới giải phóng AR không còn khả năng truy cậpSo với câu “rewrite it in Rust” vì an toàn, việc có thể biên dịch các chương trình C hiện có để trở nên hoàn toàn an toàn bộ nhớ nghe còn thú vị hơn nhiều
Thứ nhất, Fil-C chậm hơn và nặng hơn. Nếu điều đó chấp nhận được thì cũng có thể nói rằng trong 10 năm qua lẽ ra người ta đã nên chuyển sang Java hay C# trước Rust
Thứ hai, rốt cuộc vẫn là đang dùng C. Điều đó ổn cho việc bảo trì mã nguồn cũ, nhưng nếu phải viết nhiều mã mới thì tôi thấy Rust dễ chịu hơn nhiều
Thứ ba, Fil-C là an toàn lúc chạy, còn Rust có thể biểu đạt một phần ở thời điểm biên dịch. Xa hơn nữa, các ngôn ngữ như WUFFS còn cố chứng minh tính an toàn ở giai đoạn biên dịch mà không cần check lúc chạy, nên khác ở chỗ mã có thể sai về mặt logic nhưng vẫn ngăn được crash hay thực thi mã tùy ý
Đã có các thread như Fil-Qt: A Qt Base build with Fil-C experience, Linux Sandboxes and Fil-C, Ported freetype, fontconfig, harfbuzz, and graphite to Fil-C, A Note on Fil-C, Notes by djb on using Fil-C, Fil-C: A memory-safe C implementation, Fil's Unbelievable Garbage Collector
Bạn vẫn có thể viết mã không an toàn bộ nhớ, chỉ là giờ kết quả của nó sẽ gần với một vụ crash chắc chắn hơn là một lỗ hổng
Nếu bạn xây thứ như web API nhận đầu vào không đáng tin cậy, thì kiểu vấn đề này rốt cuộc vẫn có thể dẫn đến denial-of-service, nên tuy tốt hơn nhưng khó mà nói là đã đủ tốt
Tôi không có ý hạ thấp bản thân công việc của Fil-C, chỉ là tôi thấy cách tiếp cận ở runtime có những giới hạn rất rõ
Nhưng nói công bằng thì Fil-C chậm hơn Rust khá nhiều, và cũng dùng nhiều bộ nhớ hơn
Bù lại, Fil-C hỗ trợ safe dynamic linking và ở vài khía cạnh thậm chí có thể nói là an toàn nghiêm ngặt hơn Rust
Rốt cuộc đây là trade-off, nên mỗi người cứ chọn theo tình huống của mình
Vì thế nên dù ý tưởng này thú vị về mặt kỹ thuật, về mặt cảm xúc lại có vẻ không dễ được đón nhận
Giá trị capability và pointer có thể bị xé trong lúc gán, nên nếu luồng xen kẽ theo cách xấu thì có thể truy cập đối tượng bằng con trỏ sai và gây ra hành vi sai lệch tùy ý
Bản thân giới hạn này thì tôi chấp nhận được, nhưng điều đáng tiếc là bầu không khí mà ngay cả những người ủng hộ cũng mạnh tay công kích những ai chỉ ra vấn đề đó
Tiếc là đó cũng là một trong những nguyên nhân lớn gây ra overhead
Những cách làm kiểu này đã nhiều lần được triển khai rồi bị loại bỏ vì hoặc không đảm bảo đủ về bảo mật, hoặc khó vượt qua các non-fat ABI boundaries, hoặc overhead quá lớn
Hơn nữa, tôi nghĩ filc cũng không thể được giải thích chỉ bằng fat pointer đơn thuần