Hình dung an ninh một toà nhà. "Root" không phải một chiếc chìa khoá vạn năng — nó là một chùm 41 chìa riêng biệt (capability), mỗi chìa mở một loại cửa, và Docker chỉ đưa bạn 14 chiếc. seccomp là một lớp khác hẳn: không hỏi bạn cầm chìa nào, mà là một người gác đứng ở mọi cửa, đối chiếu chính hành động bạn định làm với một danh sách nước-đi-bị-cấm — có chìa cũng không được làm cái trong danh sách. no-new-privileges thì cấm bạn tự đổi thẻ ra vào lấy một thẻ xịn hơn giữa chừng. Và --privileged là trao cả chùm chìa, cho người gác về, tắt luôn camera — ba lớp bảo vệ biến mất cùng lúc, không phải một. Root trong container không phải root đầy đủ: nhân Linux chia quyền của root thành 41 mảnh gọi là capability, và Docker chỉ cấp cho bạn 14.

Đọc thẳng từ nhân thay vì tin công cụ:

Chế độ CapEff Số quyền
Mặc định 00000000a80425fb 14
--privileged 000001ffffffffff 41
--cap-drop=ALL 0000000000000000 0

Tôi định dùng capsh --print cho dễ đọc, nhưng nó in Current: =ep với --privileged — một cách viết tắt nghĩa là "tất cả", và script đếm dấu phẩy của tôi trả về 0 quyền. /proc/self/status đưa ra một số hex, đếm bit là xong, không có định dạng nào để hiểu nhầm.

Bỏ sạch quyền rồi thử từng việc

docker run --cap-drop=ALL ...
Việc Mặc định (14 quyền) --cap-drop=ALL
ping 127.0.0.1 OK OK
Bind cổng 80 OK OK
chown một tệp OK hỏng
Đọc tệp chmod 000 OK hỏng
mknod OK hỏng
chroot OK hỏng
su sang người dùng khác OK hỏng

Hai dòng đầu đi ngược hẳn với những gì tài liệu về capability vẫn dạy.

ping không cần NET_RAW nữa

Kiến thức phổ biến là ping cần CAP_NET_RAW để mở raw socket. Nhưng:

ping_group_range = 0    2147483647

Nhân cho phép mọi nhóm tạo ICMP datagram socket, một loại socket không cần đặc quyền, có từ Linux 3.0 và bật sẵn trong image Docker. ping hiện đại dùng đường đó trước. Lời khuyên "thêm --cap-add=NET_RAW để ping được" đã cũ.

Cổng 80 không cần NET_BIND_SERVICE nữa

ip_unprivileged_port_start = 0

Docker đặt sysctl này bằng 0 trong mọi container. Ngưỡng "cổng dưới 1024 là đặc quyền" bị hạ xuống 0, nên mọi cổng đều bind được. Tôi kiểm lại bằng ca khắt khe nhất — người dùng 1000, bỏ sạch quyền — vẫn bind được cổng 80.

Đây là tin tốt cho việc chạy không root (phần 12): bạn không phải chọn giữa "cổng đẹp" và "không phải root".

Thêm lại từng quyền một

Ánh xạ một-đối-một, sạch sẽ:

Việc drop ALL drop ALL + quyền
chown hỏng CHOWN → OK
Đọc tệp chmod 000 hỏng DAC_OVERRIDE → OK
mknod hỏng MKNOD → OK
chroot hỏng SYS_CHROOT → OK
su hỏng SETUID → vẫn hỏng
su hỏng SETUID + SETGID → OK

Dòng cuối là lý do nên thử từng bước: su đổi cả uid lẫn gid, thiếu một trong hai là hỏng, và thông báo lỗi không nói cho bạn biết thiếu cái nào.

nginx chỉ cần 3 trong 14 quyền

Cấu hình Trạng thái HTTP
Mặc định (14 quyền) running 200
--cap-drop=ALL exited –
--cap-drop=ALL + CHOWN + SETUID + SETGID running 200

Ba quyền thay vì mười bốn. Cách tìm ra là bỏ hết rồi đọc lỗi, thêm đúng cái nó kêu, lặp lại — cùng phương pháp với --read-only ở phần 30.

docker run -d --cap-drop=ALL \
  --cap-add=CHOWN --cap-add=SETUID --cap-add=SETGID \
  nginx:1.27-alpine

seccomp là lớp thứ hai, và nó hoàn toàn khác

Capability quyết định bạn được làm việc gì. seccomp quyết định bạn được gọi lời gọi hệ thống nào. Docker gắn sẵn một bộ lọc:

mac dinh:      Seccomp: 2   (dang loc)
unconfined:    Seccomp: 0   (tat)

Để đo riêng tác dụng của bộ lọc, tôi gọi thẳng syscall bằng ctypes và so mã lỗi:

Syscall seccomp mặc định seccomp tắt
keyctl EPERM EINVAL
perf_event_open EPERM EFAULT
bpf EPERM EINVAL
unshare(CLONE_NEWUSER) EPERM thành công
pivot_root EPERM EPERM
mount EPERM EFAULT
reboot EPERM EPERM
clock_adjtime EFAULT EFAULT

Cách đọc bảng: cột phải là mã lỗi tự nhiên của nhân khi tôi gọi syscall với tham số rác — EINVAL hoặc EFAULT nghĩa là lời gọi đã tới được nhân. Cột trái toàn EPERM nghĩa là bộ lọc chặn từ trước.

Dòng unshare đáng chú ý nhất: không có seccomp, container tạo được user namespace mới — viên gạch đầu tiên của nhiều chuỗi leo quyền. Dòng clock_adjtime cho thấy bộ lọc không chặn tất cả; nó là một danh sách cụ thể chứ không phải chặn tràn lan.

Sai lầm khi đo: --privileged tắt luôn seccomp

Lần đầu tôi chạy cả hai bên với --privileged để "loại bỏ ảnh hưởng của capability", và hai cột ra giống hệt nhau. Tôi tưởng bộ lọc không có tác dụng gì.

mac dinh    : Seccomp: 2
--privileged: Seccomp: 0

--privileged không chỉ cấp đủ 41 quyền — nó tắt luôn seccomp (và AppArmor). Phép đo của tôi đang so seccomp-tắt với seccomp-tắt.

Đó cũng là lý do --privileged nguy hiểm hơn nhiều so với --cap-add=ALL. Chúng không tương đương: cờ thứ nhất gỡ ba lớp phòng thủ, cờ thứ hai chỉ gỡ một.

Một cái bẫy khi chẩn đoán

Cả seccomp lẫn thiếu capability đều trả về EPERM. Ứng dụng của bạn báo Operation not permitted và bạn không có cách nào biết đó là quyền hay bộ lọc. Cách phân biệt là thử lần lượt:

docker run --cap-add=ALL ...                       # con hong -> khong phai do quyen
docker run --security-opt seccomp=unconfined ...   # het hong -> la do seccomp

Đừng dừng ở --privileged, vì nó đổi cả hai cùng lúc và bạn không học được gì.

no-new-privileges chặn leo quyền qua bit setuid

Cờ này chặn tiến trình tăng quyền sau khi đã chạy. Tôi biên dịch một chương trình C in ra uid và euid, gắn bit setuid-root, rồi chạy bằng người dùng 1000:

Kết quả
Không đặt cờ uid=1000 euid=0
--security-opt no-new-privileges uid=1000 euid=1000

Không có cờ, tệp setuid biến người dùng thường thành root ngay trong container. Có cờ, nó bị chặn.

(Lần đầu tôi thử bằng busybox id và không thấy khác biệt gì. busybox in ra uid=1000 mà không nhắc tới euid, nên phép đo im lặng vô nghĩa. Một chương trình C bốn dòng gọi thẳng geteuid() cho câu trả lời dứt khoát. Bài học lặp lại: khi công cụ tóm tắt hộ bạn, nó cũng giấu hộ bạn.)

Muốn tự soi container của mình đang cầm bao nhiêu quyền rồi thử cắt, chạy hai dòng:

docker exec <ten> grep CapEff /proc/self/status
docker run --rm --cap-drop=ALL <anh-cua-ban> 2>&1 | head -20

Bộ ba đáng dán vào mọi service không cần đặc quyền:

services:
  web:
    image: nginx:1.27-alpine
    cap_drop: [ALL]
    cap_add: [CHOWN, SETUID, SETGID]
    security_opt:
      - no-new-privileges:true
    read_only: true
    tmpfs: [/tmp, /var/cache/nginx, /var/run]

Mẫu số chung

Bài học đầu tiên, đọc thẳng từ "nginx chỉ cần 3 trong 14": một quyền "được-tất" thật ra là một bó những quyền độc lập — chia nó ra thì mới trao được đúng cái tối thiểu cần thiết. Nguyên tắc đặc quyền tối thiểu chỉ có nghĩa khi đặc quyền chia nhỏ được; "root" gộp làm một khối thì lựa chọn duy nhất là tất-cả-hoặc-không, còn 41 capability rời cho phép cấp đúng ba cái nginx cần và bỏ 38 cái nó không đụng tới. Cùng ý ấy ở khắp nơi trong phân quyền: OAuth scope thay cho một token thần thánh, GRANT theo từng bảng thay cho tài khoản superuser, chính sách IAM chi tiết thay cho vai admin, từng bit mode của tệp. Nguyên tắc: muốn trao ít quyền nhất, trước hết phải có một hệ quyền xé lẻ được rồi bỏ hết, thêm lại đúng cái bị kêu thiếu — và cách tìm ra "đúng cái tối thiểu" luôn là bỏ sạch, đọc lỗi, thêm từng cái một, chứ không phải đoán.

Điều thứ hai, về phòng thủ nhiều lớp: capability, seccomp, no-new-privileges, read-only là những lớp độc lập trả lời các câu hỏi khác nhau — nên một cái công tắc tiện tay gỡ nhiều lớp cùng lúc nguy hiểm hơn nó trông thấy nhiều. --privileged không chỉ cấp đủ 41 quyền mà còn tắt cả seccomp lẫn AppArmor; nó không tương đương --cap-add=ALL (cái sau chỉ gỡ một lớp). Và vì các lớp độc lập lại cùng trả về một triệu chứng — EPERM/Operation not permitted — nên khi vấp, phải thử tách từng lớp (nới capability riêng, nới seccomp riêng) mới biết mình đụng lớp nào; dừng ở --privileged thì đổi cả ba cùng lúc và chẳng học được gì. Cùng bài học ở mọi hệ nhiều tầng bảo vệ: WAF + kiểm đầu vào + truy vấn tham số hoá là ba lớp riêng, một cờ "tắt hết kiểm tra cho dễ debug" là con dao hai lưỡi, và một lỗi chung chung có thể đến từ bất kỳ tầng nào. Nguyên tắc: giữ các lớp phòng thủ tách bạch, cảnh giác với mọi công tắc hạ nhiều lớp một lúc, và khi chẩn đoán thì gỡ từng lớp chứ đừng gỡ hết — vì bảo mật mất đi theo bó thì nhanh, mà dựng lại thì phải từng viên.

Phần sau dựng Docker rootless và đo xem chạy không root tốn thêm bao nhiêu.