Sandbox phần mềm: Những điều cơ bản mà lập trình viên cần biết
Sandbox phần mềm là kỹ thuật giới hạn quyền hạn của một tiến trình mà không cần quyền quản trị, giúp cô lập mã độc và giảm thiểu thiệt hại khi bị khai thác lỗ hổng. Bài viết phân tích các mô hình như Capsicum, seccomp, Landlock và cách áp dụng mô hình actor cùng bảo mật dựa trên capability để xây dựng sandbox hiệu quả.

Sandbox phần mềm là một lĩnh vực phần lớn vẫn còn hoang sơ. Những mảnh ghép cần thiết để triển khai sandbox tốt trong phần mềm của bạn nằm rải rác khắp nơi, và những người tiên phong vẫn chưa gom đủ kiến thức thành một tấm bản đồ thống nhất để dẫn đường cho những người mới. Bài viết này chia sẻ kinh nghiệm cá nhân của tác giả trong quá trình xây dựng hỗ trợ sandbox cho Emilua.
Minh họa cơ chế sandbox trong hệ điều hành
Sandbox là gì?
Định nghĩa được Julien Tinnes và Chris Evans đưa ra tại hội nghị Hack In The Box Malaysia 2009 vẫn còn nguyên giá trị:
Khả năng hạn chế đặc quyền của một tiến trình mà không cần quyền quản trị trên máy.
Định nghĩa này gồm hai điểm then chốt. Thứ nhất, sandbox không được phép yêu cầu quyền quản trị hệ thống. Thứ hai, nó phải hạn chế được đặc quyền của tiến trình.
Các quản trị viên hệ thống thường dựa vào quyền truy cập hệ thống tập tin để cô lập các dịch vụ (các daemon UNIX). Nếu cho phép chương trình bên thứ ba tự do thay đổi các quyền đó thì chính sách mà quản trị viên đang cố gắng thực thi sẽ bị vô hiệu hóa hoàn toàn.
Hơn nữa, các chương trình bên thứ ba tự tạo ra những thế giới ảo riêng, và quyền truy cập hệ thống tập tin UNIX thường không phù hợp để mô hình hóa các chính sách bảo mật trong những thế giới ảo đó. Bạn có dùng quyền UNIX để quyết định ai được xem dòng trạng thái Twitter hay nhắn tin cho bạn trên Identi.ca không? Chắc chắn là không.
Một tiến trình luôn chạy trên nền một hệ điều hành, và có những tài nguyên do kernel cung cấp mà tiến trình đó tương tác (chẳng hạn như tập tin). Chính giao diện này mới là điều mà lập trình viên phần mềm cần quan tâm. Các trình duyệt như Firefox chạy plugin DRM, và ta muốn chạy những plugin bên thứ ba đó mà không cho chúng quyền truy cập vào mọi tập tin mà Firefox có thể truy cập (thường là toàn bộ thư mục HOME của người dùng).
Các công cụ truyền thống như setuidgid không giúp được gì trong tình huống này. Chúng không phải là giao diện dành cho lập trình viên phần mềm. Với việc hạ đặc quyền theo cách lập trình, các giao diện UNIX truyền thống tỏ ra rất yếu, và những hệ điều hành nơi khoảng trống này thực sự quan trọng sẽ cung cấp các giao diện mở rộng vượt ra ngoài UNIX truyền thống, chẳng hạn như Capsicum trên FreeBSD hay Seccomp trên Linux.
Cạm bẫy của Linux namespaces
Khi chưa có giao diện sandbox tốt, lập trình viên vẫn tìm cách tạo sandbox bằng cách lạm dụng những cơ chế chỉ dành cho quyền quản trị. Kỹ thuật điển hình nhất là một chương trình nhị phân suid hỗ trợ thiết lập chroot jail.
Vấn đề rõ ràng của cách làm này là chúng không khả dụng cho mọi chương trình. Cho phép bất kỳ chương trình nào cài đặt nhị phân suid sẽ phá hỏng mọi biện pháp bảo mật. Nhị phân suid tương đương với việc tạm thời nâng đặc quyền lên toàn quyền quản trị hệ thống. Đặc quyền chỉ nên giảm, không bao giờ được tăng (nguyên tắc đặc quyền tối thiểu).
Một lo ngại liên quan khác là không nên thiết kế các API phản tác dụng bằng cách làm bề mặt tấn công của kernel tăng theo cấp số nhân. Sự bùng nổ của Docker đã phổ biến Linux namespaces như một cơ chế cô lập dịch vụ với chi phí thấp. Tuy nhiên, bên trong một user namespace lồng nhau, tiến trình chạy với quyền superuser trong namespace đó, nghĩa là những đường code trong kernel vốn chỉ dành cho superuser nay lại khả dụng cho mọi người dùng.
Chúng ta đã có hơn một thập kỷ code kernel chưa từng được viết với tiền đề này. Quyết định đó đã gây ra nhiều vấn đề bảo mật trong quá khứ, và chắc chắn sẽ còn tái diễn. Andy Lutomirski từng nhận xét rằng việc dùng CLONE_NEWUSER để có được CAP_NET_ADMIN trên bất kỳ network namespace nào và từ đó truy cập API cấu hình mạng là một rủi ro khổng lồ. Ví dụ, người dùng không có đặc quyền có thể lập trình iptables.
Việc cho phép user namespace là ổn miễn là bạn giới hạn giao diện này cho các công cụ container hóa đáng tin cậy như Docker. Tuy nhiên Linux namespaces là một giao diện tồi tệ cho sandbox phần mềm. Các giao diện sandbox mới hơn trên Linux như Landlock được thiết kế cẩn thận để không làm bề mặt tấn công của kernel tăng theo cấp số nhân.
Định nghĩa lại sandbox
Sandbox cũng có thể được định nghĩa là:
Một môi trường thực thi bị hạn chế, được kiểm soát, ngăn phần mềm có khả năng độc hại truy cập bất kỳ tài nguyên hệ thống nào ngoại trừ những tài nguyên mà phần mềm được cấp phép.
Không có sự đồng thuận thực sự về những đặc tính nào là cần thiết để một đoạn code được coi là sandboxed, và các định nghĩa thường rất lỏng lẻo. Vì vậy, một thuật ngữ khác có thể hữu ích hơn: "discretionary privilege dropping" (hạ đặc quyền tùy ý), do J. Tinnes đề xuất. Đây chính là kiểu sandbox mà bài viết này tập trung vào.
Discretionary privilege dropping không thay thế chính sách quản trị hệ thống. Thay vào đó, chúng bổ trợ cho nhau và nên được áp dụng song song.
Vậy làm sao để đi từ một chương trình không có sandbox sang một chương trình có sandbox trên các hệ điều hành thực tế hiện nay? Trên mọi hệ điều hành phổ biến, ranh giới đặc quyền nằm ở cấp tiến trình. Thông tin xác thực được gắn với từng tiến trình, và đó là thứ kernel kiểm tra để quyết định liệu tiến trình có thể có được tài nguyên mới bằng quyền hạn xung quanh hay không.
Linux thực ra khác biệt khi gắn thông tin xác thực ở cấp luồng (thread), nhưng thiết kế lấy luồng làm gốc không thể hoạt động, và đó là lý do glibc phải làm thêm việc để đồng bộ thông tin xác thực giữa các luồng. Các nhà phát triển GNOME từng nghĩ họ có thể làm việc ở cấp luồng và đã bị chứng minh là sai với CVE-2023-43641.
Mô hình actor cho phát triển ứng dụng phân tán
Một khi đã phân chia chương trình thành các tiến trình riêng biệt, ta có thể tiến hành các bước tiếp theo: gán đặc quyền khác nhau cho từng vùng cô lập, và xử lý giao tiếp giữa chúng.
Các nhà nghiên cứu từ Capsicum của FreeBSD đã có mô hình tư duy đúng đắn để phát triển sandbox từ hơn một thập kỷ trước:
Phát triển ứng dụng phân vùng tất yếu là phát triển ứng dụng phân tán, với các thành phần phần mềm chạy trong các tiến trình khác nhau và giao tiếp qua truyền thông điệp.
Mô hình actor là một trong những mẫu phổ biến nhất cho phát triển hệ phân tán. Erlang có lẽ là người dùng mang tính biểu tượng nhất. Tuy nhiên mối quan tâm của Erlang với mô hình actor nằm ở tính sẵn sàng cao và khả năng chịu lỗi.
Các điểm quan trọng của mô hình actor bao gồm:
- Actor có thể quản lý trạng thái nội bộ của chính nó.
- Actor có thể gửi thông điệp cho các actor khác.
- Actor có thể đưa địa chỉ của các actor khác vào trong thông điệp.
Nếu tóm gọn mô hình actor thành các lựa chọn thiết kế cụ thể trong ngôn ngữ lập trình hay framework, đây là những gì ta quan tâm:
- Có một hàm để tạo actor, hàm này trả về địa chỉ của actor mới.
- Địa chỉ của actor có thể dùng để gửi thông điệp.
- Địa chỉ của actor cũng có thể là một thông điệp hoặc một phần của thông điệp lớn hơn.
- Có một hàm để nhận thông điệp, đọc các thông điệp được xếp hàng cho actor đang gọi.
- Có thể truy xuất địa chỉ của actor hiện tại.
- Một actor không chạy song song với chính nó.
Với Emilua, thiết kế này quy về ba hàm. Nếu dùng mô hình actor cho sandbox, mỗi tiến trình sẽ là một actor. UNIX domain socket có thể dùng để truyền thông điệp giữa các actor. Khi sinh ra một actor mới, ta thiết lập thừa kế socket để có thể giao tiếp với nó. Socket đó sẽ chính là địa chỉ của actor.
Bảo mật dựa trên capability
Thiết kế này cũng giải quyết một vấn đề khác cho sandbox: chuyển tài nguyên cho các tiến trình bị hạn chế. "Mọi thứ đều là tập tin (file descriptor)" là một trong những câu nói nổi tiếng nhất trong văn hóa UNIX. Nếu có thể gửi file descriptor thì ta có một phạm vi tài nguyên rất rộng để làm việc từ các tiến trình sandbox, bao gồm thiết bị (như /dev/random, giao tiếp GPU) và xử lý tiến trình như pidfd, procdesc.
Đây chính là những tài nguyên ta quan tâm khi sandbox chương trình. Nếu chứng minh được rằng ta không làm rò rỉ file descriptor cho sai actor, ta có thể dùng mô hình actor. May thay, có một mô hình đã được nghiên cứu kỹ lưỡng giải quyết vấn đề này: bảo mật dựa trên capability. Thậm chí còn có một ngôn ngữ lập trình dựa trên cả mô hình actor và bảo mật capability: Pony.
Chỉ có một khoảng trống nhỏ cần lấp đầy để kết hợp cả hai mô hình: bảo mật dựa trên capability giả định các token không thể giả mạo, nhưng mô hình actor dùng địa chỉ vốn có thể giả mạo. Trong trường hợp này, vấn đề đã được giải quyết bằng cách dùng kênh (channel) thay vì địa chỉ. API giữ nguyên và không ai nhận ra sự khác biệt.
Về việc dùng file descriptor như capability, nguyên tắc chung là tránh dùng ioctl. Trên hệ thống UNIX thường chỉ chạy kiểm tra quyền khi một file descriptor mới được tạo, chứ không kiểm tra khi file descriptor hiện có được dùng. Hành vi này tương thích với capability.
Capsicum — hình mẫu của mọi sandbox
Capsicum là giao diện hỗ trợ tốt hơn cho việc dùng file descriptor như capability, đã có mặt trong FreeBSD từ bản 9.0. Một trong những tiện ích mà Capsicum cung cấp là hàm cap_enter. Hàm này hạ đặc quyền của tiến trình bằng cách vô hiệu hóa hoàn toàn quyền hạn xung quanh.
Chỉ cần một lời gọi hàm là ta đã hạ đặc quyền. Mọi truy cập hệ thống sẽ phải thực hiện thông qua file descriptor đang mở. Nếu ta cố mở tập tin, open sẽ thất bại vì quyền hạn xung quanh đã bị vô hiệu hóa. Nếu ta cố kết nối socket tới một điểm cuối nào đó, thao tác sẽ thất bại với lý do tương tự.
Cơ chế cô lập tiến trình với Chromium sandbox
Khi nghiên cứu về Capsicum được công bố, các nhà nghiên cứu đã chỉnh sửa Chromium để dùng Capsicum và so sánh nỗ lực cần thiết cho từng cơ chế sandbox trong Chromium. Capsicum chỉ cần 100 dòng code. So sánh với seccomp là 11.301 dòng, hay Windows là 22.350 dòng. Nếu bạn chỉ học một cơ chế sandbox trong đời, hãy chọn Capsicum.
Capsicum cũng cung cấp kiểm soát truy cập chi tiết hơn cho file descriptor. Ví dụ, có thể dùng Capsicum để cho phép một tiến trình chờ trên một semaphore nhưng không được post lên nó. File descriptor thường được tạo với toàn bộ quyền được gán, sau đó có thể giảm bớt thông qua hàm cap_rights_limit.
Xử lý file descriptor nhận từ sandbox
Một khi đã nhận file descriptor từ sandbox, đã đến lúc thao tác trên nó. Tuy nhiên nếu làm ẩu, luồng của ta sẽ bị chặn. Cần tránh các thao tác chặn để né một số nỗ lực tấn công từ chối dịch vụ (DoS).
Tóm tắt những điều cần biết:
close()được cho là luôn thành công trên Linux, không cần kiểm tra lỗi.- Có thể cần tạo một luồng để chạy
close()nếu các tập tin "chậm đóng" là vấn đề. - Dùng
fstattrên file descriptor nhận được để kiểm tra xem đó có phải socket không. - Trên socket, dùng
MSG_DONTWAITchorecv(). - Trên phi socket, dùng proactor (sự kiện hoàn thành thay vì sự kiện sẵn sàng) để thực hiện thao tác IO, chẳng hạn io_uring trên Linux hay POSIX AIO trên FreeBSD.
- Trên FreeBSD, có thể dùng
aio_read2()vớiAIO_OP2_FOFFSETđể đọc mà không cần offset.
Trên thực tế có hai trường hợp sử dụng: tiến trình đáng tin cậy tạo tài nguyên rồi gửi cho tiến trình sandbox không đáng tin, hoặc tiến trình sandbox không đáng tin tạo tài nguyên rồi gửi đi nơi khác. Nếu file descriptor được tạo bởi tiến trình đáng tin cậy thì phạm vi lựa chọn rộng hơn. Tuy nhiên trạng thái như O_NONBLOCK được chia sẻ giữa mọi bản sao của file descriptor và trở thành vấn đề ngay khi file descriptor tới tiến trình sandbox đầu tiên.
Khi nào cần sandbox?
Có một lý do khiến ta sandbox code: các dự án lớn dần cho đến khi không thể đảm bảo code không có lỗi. Một lỗi nhỏ trong một trong hàng chục module của dự án không nên đồng nghĩa với việc toàn bộ hệ thống bị xâm phạm khi hacker khai thác. Thiệt hại cần được khoanh vùng.
Các chính sách bảo mật được thực thi bởi công cụ hệ điều hành bên ngoài code của bạn chỉ có thể hoạt động ở cấp chương trình hoặc người dùng. Chương trình vẫn sẽ bị xâm phạm hoàn toàn và hacker sẽ có quyền truy cập vào mọi dữ liệu và thông tin xác thực mà chương trình đó có.
Theo nguyên tắc chung, mọi shell đều nên có sandbox. Shell là những chương trình đóng vai trò màng ngăn giữa người vận hành và một thế giới ảo nào đó. Máy tính bảng, điện thoại và laptop hiển thị shell đồ họa để tương tác với chương trình, cửa sổ và tập tin. Máy chủ dùng shell dạng văn bản. Tương tự, trình duyệt web đóng vai trò shell cho thế giới www.
Không ngạc nhiên nếu Firefox và Chrome là những phần mềm duy nhất bạn biết có dùng discretionary privilege dropping. Chúng là shell nên điều này quan trọng với chúng, và chúng là những dự án được tài trợ rất tốt.
Tuy nhiên chúng không nên là những phần mềm duy nhất có hỗ trợ sandbox tích hợp. Lấy Telegram làm ví dụ. Một lỗi phân tích media đúng cách có thể đồng nghĩa với việc hacker truy cập toàn bộ lịch sử trò chuyện của tôi. Con tôi rời trường lúc mấy giờ? Tôi tin tưởng giao thông tin thẻ tín dụng cho những ai? Tôi sẽ đi du lịch và để nhà không người lúc nào? Đó chỉ là vài ví dụ về thiệt hại có thể xảy ra do thiếu sandbox trong Telegram. Không chỉ Telegram, mọi ứng dụng nhắn tin tức thời đều nên dùng sandbox. Phân tích media nên luôn được thực hiện trong các sandbox chuyên dụng.
Sandbox cho code hiện có
Bước đầu tiên theo hướng này là một cách tiếp cận thực tế với kỹ thuật thực tế: đừng viết lại toàn bộ code từ đầu. Người dùng Capsicum gọi khả năng chạy code không chỉnh sửa trong sandbox là oblivious sandboxing.
Nơi cần nhìn để triển khai oblivious sandboxing thực ra khá rõ ràng: các hàm quyền hạn xung quanh. Các dự án như Super Capsicumizer 9000 làm đúng điều này. Chúng tiêm một thư viện động vào tiến trình bằng LD_PRELOAD để chèn vào các lời gọi quyền hạn xung quanh. Kỹ thuật này thực ra đã cũ và các dự án như fakeroot đã dùng nó hàng thập kỷ.
Lập trình viên hầu như không bao giờ gọi syscall trực tiếp mà dựa vào libc để thực hiện syscall thay họ. Đó là lý do cách tiếp cận này hiệu quả đến vậy. Tất cả những gì bạn phải làm là viết một định nghĩa cho hàm từ libc mà bạn muốn chèn. Nếu liên kết với libc động, hàm của bạn sẽ được nạp trước và được dùng thay thế.
Seccomp — công cụ tốt nhưng dùng sai mục đích
Seccomp không phải là cơ chế tốt cho discretionary privilege dropping. Seccomp là cơ chế tốt cho tăng cường bảo mật hệ điều hành. Seccomp là cơ chế lọc syscall có thể lập trình dựa trên chương trình BPF.
Cơ chế này có thể dùng để từ chối truy cập quyền hạn xung quanh bằng cách không cho phép các syscall làm việc trên tên, chẳng hạn open, bind. Tất cả những gì bạn phải làm là vô hiệu hóa quyền hạn xung quanh.
Vấn đề với danh sách đen (blacklist) đã quá nổi tiếng. Các phiên bản kernel mới có thể thêm syscall mới. Bạn không biết hôm nay syscall nào sẽ được thêm vào ngày mai. Với Seccomp, vấn đề còn tệ hơn. Linux cho phép hệ thống đa kiến trúc và số hiệu syscall thay đổi rất khác nhau giữa các kiến trúc. Ngay cả khi bạn chặn các syscall như acct trên chương trình x86-64, một sandbox bị xâm phạm có thể vượt qua bộ lọc syscall bằng cách chạy một file thực thi x86.
Ngay cả khi chuyển sang danh sách trắng (whitelist), ta vẫn bị ám ảnh bởi những chi tiết triển khai này. Một khi đã xem qua các danh sách đó để triển khai chính sách seccomp của riêng mình, bạn sẽ gặp một vấn đề khác: thứ tự tham số syscall trên Linux thay đổi giữa các kiến trúc. Đó là lý do Docker dùng nhiều quy tắc cho syscall clone. Tại sao ta phải trở thành chuyên gia về quy ước syscall Linux để làm điều mà người dùng FreeBSD hoàn thành trong 10 giây chỉ bằng cách gọi cap_enter()?
Cấu trúc sandbox cho các tiến trình không đáng tin cậy
Kafel là một ngôn ngữ và thư viện để chỉ định chính sách lọc syscall. Các chính sách được biên dịch thành code BPF có thể dùng với seccomp-filter. Kafel là dự án hứa hẹn nhất cho việc tái sử dụng chính sách Seccomp mà tác giả từng thấy. Tuy nhiên, cơ sở dữ liệu syscall của Kafel cực kỳ nghèo nàn nên bạn sẽ cần vài thay đổi để nó hoạt động. Kafel sẽ không bao giờ được các dự án như Docker chấp nhận chừng nào nó còn giữ cơ sở dữ liệu syscall nghèo nàn và hỗ trợ đa kiến trúc kém như vậy.
Kết luận
Bài viết này chỉ mới điểm qua những điều cơ bản về sandbox trên Linux và FreeBSD. Còn nhiều bài học khác mà tác giả muốn chia sẻ, nhưng xin để dành cho một bài viết sau.
Thông điệp chính dành cho lập trình viên Việt Nam đang xây dựng phần mềm xử lý dữ liệu không đáng tin cậy: hãy coi sandbox là một phần thiết yếu của kiến trúc, không phải một tính năng thêm vào sau. Đặc biệt với các ứng dụng chat, trình duyệt nhúng, công cụ xử lý tập tin do người dùng tải lên, việc cô lập các thành phần rủi ro cao như phân tích media hay giải nén tài liệu là bước bảo vệ đơn giản nhưng mang lại hiệu quả lớn.
Và hãy nhớ: đặc quyền chỉ nên giảm, không bao giờ được tăng.


