bài về file descriptor ta thấy open trả về một fd. Nhưng trước khi trả fd, nhân phải quyết định: tiến trình này có được phép mở file này không? Cơ chế kiểm quyền đó — ai kiểm, kiểm khi nào, kiểm cái gì — ẩn chứa vài điều bất ngờ mà tôi vấp phải khi đo, bắt đầu bằng việc thử nghiệm sai hoàn toàn vì một chi tiết của môi trường.

Kiểm quyền khi mở file

Nhân kiểm quyền lúc open, một lần

Mỗi file có ba nhóm bit quyền — owner (chủ), group (nhóm), other (còn lại) — mỗi nhóm ba bit rwx (đọc/ghi/thực thi). Khi một tiến trình gọi open, nhân so uid/gid hiệu lực (effective) của tiến trình với các bit đó: nếu tiến trình là chủ file thì xét bit owner, cùng nhóm thì xét bit group, còn lại xét bit other; nếu quyền cần thiết (ví dụ đọc) không có, open bị từ chối với EACCES ("Permission denied").

Hai điểm mấu chốt về khi nàoai kiểm sẽ thành chủ đề của các phép đo dưới đây: kiểm ở lúc open (không phải lúc read/write), và root (uid 0) bỏ qua mọi bit quyền. Container mặc định chạy là root — điều này chính là cái bẫy đầu tiên tôi sập vào.

Một lần tôi đo hớ: thử nghiệm bảo mật khi đang là root

Tôi viết chương trình đo và chạy nó ngay trong container (mặc định là root, uid 0). Tôi tạo một file với quyền 0000 — cấm tất cả, không ai được đọc — rồi thử open nó để đọc:

euid=0 (root) open('/tmp/s000' mode 000): THÀNH CÔNG

Mở được! Phản ứng đầu tiên của tôi suýt là "à, đọc file cấm-hết vẫn được, chắc bit quyền không thật sự ép". Nhưng đó là điều bất khả về mặt logic: một file 0000 mà mở được thì bit quyền có nghĩa gì? Con số bất khả ấy chỉ thẳng vào nguyên nhân: root (uid 0) bỏ qua mọi bit quyền. Root có thể mở bất cứ file nào bất kể quyền của nó — đó là đặc quyền của siêu người dùng. Tôi đã kiểm bảo mật khi đang là root, và root khiến mọi kiểm tra quyền trở nên vô nghĩa.

Kiểm đúng phải dưới danh tính của một người dùng thường. Tôi hạ effective uid xuống 1000 rồi thử lại:

euid=1000 open('/tmp/s000' mode 000): Permission denied (errno=13)

Bây giờ mới đúng: EACCES, đúng như bit quyền quy định. Bài học đo lường đầu tiên: đo hành vi quyền khi đang chạy root là đo... không gì cả — root là một biến ẩn khiến mọi file đều mở được. Đây là lý do khi kiểm thử phân quyền của một ứng dụng, bạn phải chạy nó dưới đúng người dùng nó sẽ dùng khi thật, không phải root; chạy dưới root cho cảm giác an toàn giả.

Kiểm ở lúc open, không phải lúc read

Điểm bất ngờ thứ hai là khi nào nhân kiểm. Trực giác nhiều người là "nhân kiểm quyền mỗi khi tôi đọc file". Sai. Nhân kiểm một lần duy nhất, tại lúc open; một khi open thành công, fd trở thành một cái vé mang theo quyền đã cấp, và các read/write sau đó không kiểm lại. Tôi đo bằng cách mở một file khi còn quyền, rồi đổi quyền file trong lúc fd đang mở:

euid=1000 open('/tmp/ok' mode 644): OK (fd=3)         <- mở khi còn quyền đọc
(root vừa chmod /tmp/ok -> 000 trong khi fd vẫn mở)
euid=1000 read qua fd CŨ: 'HELLO-WORLD'               <- VẪN đọc được!
euid=1000 open LẠI ('/tmp/ok' giờ 000): EACCES        <- open MỚI bị chặn

Ba dòng này kể một điều quan trọng. Sau khi file bị chmod về 000, đọc qua fd vẫn thành công — vì quyền đã được kiểm lúc open, và việc đổi quyền sau đó không thu hồi fd đang mở. Nhưng một open mới của cùng file (giờ là 000) bị từ chối EACCES. Nói cách khác, chmod chỉ ảnh hưởng các lần mở tương lai, không ảnh hưởng các fd đã mở. Và strace xác nhận EACCES xuất hiện ở syscall openat, không phải ở read — permission là một cái cổng ở lối vào (open), không phải một trạm kiểm soát ở mỗi lần đọc.

Đây là một sự thật bảo mật quan trọng và hay bị hiểu sai: bạn không thể "khóa" một file khỏi một tiến trình đang đọc nó bằng cách chmod. Nếu một tiến trình đã mở file, nó tiếp tục đọc được dù bạn đổi quyền về 000 hay xóa quyền của nó — cái vé đã cấp. Muốn thật sự chặn, bạn phải khiến tiến trình đó đóng fd (hoặc kết thúc nó).

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: kiểm thử phân quyền phải dưới đúng danh tính, không phải root. Rất nhiều lỗi bảo mật lọt qua vì được thử trong môi trường chạy root (như container mặc định) nơi mọi thứ đều mở được. Khi viết một dịch vụ đọc/ghi file của người dùng, chạy thử nó dưới một uid không đặc quyền để thấy các EACCES thật sự sẽ xảy ra ở production. Và nhớ: quyền được kiểm theo effective uid, nên một chương trình setuid (như sudo, passwd) chạy dưới quyền chủ file của nó chứ không phải người gọi — một nguồn lỗi leo thang đặc quyền nếu dùng ẩu.

Hệ quả thứ hai: fd là một capability, không phải một cái tên. Vì quyền gắn với fd tại lúc mở, một fd đã mở là quyền truy cập cụ thể vào một open file description (như bài fd đo), độc lập với tên file và quyền hiện tại của nó. Điều này vừa hữu ích (một tiến trình đặc quyền có thể mở file rồi truyền fd cho tiến trình kém đặc quyền qua Unix socket — trao quyền hẹp mà không trao đường dẫn) vừa nguy hiểm (một fd rò rỉ là một quyền rò rỉ). Một chi tiết dễ quên cùng họ: bit thực thi (x) trên một thư mục không phải "chạy" mà là quyền "đi vào" thư mục đó để chạm tới file bên trong — nên một file 644 nằm trong thư mục 600 vẫn không mở được, vì bạn không có x để bước qua thư mục; quyền của cả đường dẫn đều được xét lúc open, không chỉ quyền của riêng file. Đây cũng là lý do O_CLOEXEC quan trọng: nó tự đóng fd khi exec, để một chương trình con không vô tình thừa kế quyền truy cập file mà cha đã mở.

Hệ quả thứ ba là bài học đo lường: hỏi rõ "kiểm khi nào" và "kiểm dưới danh tính nào" trước khi tin một kết quả bảo mật. Con số mang theo: nhân kiểm quyền file MỘT LẦN tại lúc open (EACCES ở syscall openat, không ở read), theo effective uid so với bit rwx owner/group/other; root (uid 0) bỏ qua mọi bit; và một fd đã mở giữ quyền đã cấp — chmod file sau đó không thu hồi fd cũ, chỉ chặn open mới. Đừng đo bảo mật khi đang là root, và đừng tưởng chmod chặn được người đã mở file.

Thử ba mươi giây

Xem quyền một file bằng ls -l (dòng như -rw-r--r-- là owner rw, group r, other r) và stat -c '%a %U %G' file cho quyền dạng số cùng chủ/nhóm. Rồi tự dựng bẫy "kiểm ở open": mở một file trong một tiến trình giữ nó (tail -f file &), chmod 000 file, và thử cat filecat (mở mới) sẽ báo Permission denied, nhưng tail -f đang chạy vẫn đọc tiếp mỗi khi file thay đổi, vì nó đã mở file trước khi bạn đổi quyền. Đó chính là "quyền kiểm một lần lúc open" mà bài này đo, hiện ra sống động: cái cổng ở lối vào đã cho nó qua, và đổi khóa cổng sau đó không đuổi được ai đã ở bên trong.