SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-09-04

SELinux cơ bản: nginx 403 dù quyền file đúng

nginx trả về 403 dù quyền file đúng? Đọc SELinux denial, sửa label bằng semanage và restorecon, rồi giữ SELinux ở chế độ enforcing thay vì tắt policy.

Vì sao nginx trả về 403 dù quyền của file đúng

nginx trả về 403 cho một file có các bit quyền chính xác thì gần như luôn là do SELinux (security-enhanced Linux) từ chối thao tác đọc. SELinux kiểm tra thêm một bộ rule sau khi kiểm tra quyền thông thường. Web server chỉ được phép đọc các file có label dành cho nội dung web. File của bạn có label khác, nên thao tác mở file thất bại và nginx không có dữ liệu để gửi.

Hãy kiểm tra label, không chỉ kiểm tra mode:

ls -ldZ /data/www /data/www/index.html
drwxr-xr-x. root root unconfined_u:object_r:default_t:s0 /data/www
-rw-r--r--. root root unconfined_u:object_r:default_t:s0 /data/www/index.html

Dấu chấm được in sau drwxr-xr-x cho biết file có SELinux label. default_t là label được gán cho một path mà policy chưa từng biết đến. Không có rule nào trong web server cho phép đọc type này. Error log hiển thị một lỗi Unix thông thường, nên vấn đề trông giống như lỗi quyền:

2026/08/11 09:14:02 [error] 1183#1183: *1 open() "/data/www/index.html" failed (13: Permission denied), client: 203.0.113.9, server: _, request: "GET / HTTP/1.1"

Kernel trả về 13: Permission denied cho cả hai kiểu từ chối: từ chối thông thường và từ chối do SELinux. Vì vậy, việc đầu tiên là xác định layer nào đã từ chối. Không bắt đầu bằng setenforce 0.

Phần của model bạn cần

SELinux là cơ chế kiểm soát truy cập bắt buộc, thường được viết là MAC. Mỗi process chạy trong một domain, chẳng hạn httpd_t đối với web server. Mỗi file và mỗi network port đều có một type, chẳng hạn httpd_sys_content_t. Policy là danh sách các tổ hợp domain, type và action được phép. Mọi tổ hợp không có trong danh sách đều bị từ chối. SELinux chạy sau bước kiểm tra Unix cổ điển, nên các permission bit trong drwxr-xr-x vẫn phải cho phép truy cập trước. Cả hai lớp đều phải cho phép.

Một context đầy đủ có 4 trường được ngăn cách bằng dấu hai chấm, như system_u:system_r:httpd_t:s0: SELinux user, role, type và level. Trên server, bạn gần như luôn làm việc với trường thứ ba, tức type. Có 2 lệnh để xem các giá trị hiện tại:

ps -eZ | grep nginx
id -Z

Các nginx worker có context kết thúc bằng httpd_t. Login shell của bạn hiển thị unconfined_u:unconfined_r:unconfined_t:s0 vì policy targeted mặc định giới hạn service nhưng không áp dụng giới hạn đó cho user tương tác. Điều này quan trọng vì SELinux không thay thế cho việc chạy service bằng user có ít quyền nhất. SELinux giới hạn những gì service có thể truy cập sau khi có người xâm nhập được vào service đó.

Ba chế độ và những image nào có SELinux

sestatus
getenforce

Enforcing chặn và ghi log. Permissive cho phép mọi thứ và ghi log những gì lẽ ra đã bị chặn. Disabled không nạp policy nào. getenforce hiển thị chế độ hiện tại. sestatus cũng hiển thị chế độ trong /etc/selinux/config, đây là chế độ sẽ được áp dụng lại sau khi reboot.

Rocky Linux, AlmaLinux, Fedora và RHEL được phát hành với SELinux ở chế độ enforcing và dùng policy targeted. Mặc định chung này được kế thừa, không phải sự trùng hợp, vì cả bốn đều phát triển từ cùng dòng Red Hat, qua CentOS trước khi Rocky Linux và AlmaLinux xuất hiện. Bạn chạy bản nào trong hai bản cũng không ảnh hưởng đến nội dung trên trang này, vì chúng dùng cùng policy và cùng tool. Vì vậy, việc chọn Rocky Linux hay AlmaLinux phụ thuộc vào cam kết tương thích và khả năng hỗ trợ CPU cũ, không phải mặc định bảo mật. Ubuntu và Debian dùng AppArmor thay thế. AppArmor thực hiện cùng nhiệm vụ bằng một cơ chế khác (phần cuối cùng sẽ trình bày về cơ chế này). Vì vậy, cùng một ứng dụng có thể cài đặt bình thường trên một server nhưng trả về 403 trên server khác.

Cài các công cụ trước khi cần đến chúng

sudo dnf install -y policycoreutils-python-utils setroubleshoot-server

semanage: command not found trên một image tối giản nghĩa là đang thiếu policycoreutils-python-utils: package đó cung cấp semanage và audit2allow. setroubleshoot-server bổ sung sealert và ghi phần tóm tắt bằng tiếng Anh dễ hiểu về từng lần từ chối vào journal. Hãy cài cả hai trên server mới, vì khi bạn cần đến chúng thì thường đã có thứ gì đó bị hỏng.

Cách đọc một lần từ chối SELinux trong audit log

Mỗi lần từ chối được audit daemon ghi lại dưới dạng thông báo AVC (access vector cache):

sudo ausearch -m AVC,USER_AVC,SELINUX_ERR -ts recent
type=AVC msg=audit(1754896442.881:412): avc:  denied  { read } for  pid=1183 comm="nginx" name="index.html" dev="vda1" ino=17203 scontext=system_u:system_r:httpd_t:s0 tcontext=unconfined_u:object_r:default_t:s0 tclass=file permissive=0

Bốn trường chứa toàn bộ thông tin cần thiết. comm là chương trình bị chặn. scontext là source context, tức domain nơi tiến trình đang chạy. tcontext là target context, tức label trên đối tượng mà tiến trình cố truy cập. tclass là loại đối tượng, trong trường hợp này là file. Đọc cùng nhau: tiến trình trong httpd_t đã cố đọc một file có label default_t, và permissive=0 cho biết yêu cầu thực sự bị chặn chứ không chỉ được ghi log.

Nếu ausearch không in gì, audit daemon có thể chưa chạy. Khi đó các lần từ chối sẽ được ghi vào kernel ring buffer thay vì audit log:

sudo journalctl -k | grep -i avc

Bây giờ hãy chuyển bản ghi thành một câu tiếng Anh:

sudo ausearch -m AVC -ts recent | audit2why
sudo journalctl -t setroubleshoot --since today
sudo sealert -a /var/log/audit/audit.log

audit2why đọc các bản ghi tương tự và nêu nguyên nhân mà nó nhận diện được: một boolean đang tắt, một label không khớp với policy hoặc hoàn toàn không có rule phù hợp. sealert quét toàn bộ log và in một lệnh đề xuất cho mỗi lần từ chối. Hãy xem đề xuất này là gợi ý. Cách diễn đạt thay đổi giữa các bản phát hành, và sealert đôi khi đề xuất một custom policy module trong khi câu trả lời đúng chỉ là sửa label trên một dòng.

Còn một điều cần biết. Policy chứa các rule dontaudit dùng để ẩn những lần từ chối được xem là vô hại, vì vậy chương trình có thể hoạt động sai trong khi log vẫn trống. Bỏ ẩn chúng trong thời gian thực hiện một lần kiểm tra:

sudo semodule -DB
# reproduce the problem, then read the log again
sudo semodule -B

Sửa nhãn sai của đường dẫn bằng semanage fcontext và restorecon

Cần dùng hai lệnh, và thứ tự rất quan trọng. semanage fcontext -a ghi lại nhãn mà một đường dẫn phải có. restorecon áp dụng giá trị mặc định đã ghi đó cho các file trên disk.

sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?"
sudo restorecon -Rv /data/www
ls -ldZ /data/www/index.html

Đường dẫn là một regular expression. (/.*)? áp dụng cho chính directory đó và mọi thứ bên dưới nó, đúng với nhu cầu của document root. Hãy xem trước những gì sẽ thay đổi: sudo restorecon -Rvn /data/www in ra các lần relabel dự kiến, vì -n có nghĩa là không thực hiện thay đổi. Sau khi chạy restorecon thật, nhãn hiển thị là httpd_sys_content_t và lỗi 403 biến mất mà không cần restart service.

Chỉ dùng chcon để test. chcon -t httpd_sys_content_t index.html đặt nhãn trực tiếp, nhưng lần restorecon tiếp theo, một package update hoặc lần relabel toàn hệ thống sẽ reset nhãn đó, vì policy vẫn quy định đường dẫn phải có nhãn khác. Trên máy có dnf-automatic tự động áp dụng các bản cập nhật bảo mật theo lịch, việc reset sẽ xảy ra theo lịch riêng thay vì khi bạn đang ngồi trước máy, nên site có thể hỏng vài giờ sau lần cuối bạn thay đổi. semanage fcontext là phiên bản giữ nguyên sau các lần reset. Liệt kê những gì bạn đã ghi lại bằng sudo semanage fcontext -l | grep '^/data'.

Nội dung mà service cần ghi vào phải dùng type khác. Dùng httpd_sys_rw_content_t cho directory upload hoặc cache, và chỉ áp dụng type này cho những đường dẫn đó: một site chỉ đọc nhưng dùng type cho phép ghi sẽ cấp nhiều quyền hơn mức application cần.

Tại sao nhãn lại sai? Gần như luôn là do cách các file được đưa vào hệ thống. mv giữ nguyên nhãn hiện có của file, nên site được chuyển ra khỏi /root sẽ giữ nhãn admin_home_t và tiếp tục mang nhãn đó. Một cp thông thường sẽ gán cho file mới nhãn mặc định của directory đích, đây thường là điều bạn muốn, trong khi cp -a và rsync -X sao chép nhãn của source cùng với file. Một git clone vào directory top-level mới sẽ tạo ra default_t. Khi một page tải bình thường từ /usr/share/nginx/html nhưng lỗi khi tải từ directory riêng của bạn, đây là nguyên nhân.

Sửa một nhóm hành vi bằng boolean

Một số lỗi không liên quan đến label. Reverse proxy trên máy Rocky hoặc AlmaLinux mới cài trả về 502, và error log ghi:

2026/08/11 10:02:55 [crit] 1183#1183: *3 connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstream

Upstream của bạn vẫn hoạt động bình thường. Domain httpd_t mặc định không được phép mở kết nối mạng outbound, nên lời gọi connect() bị từ chối trước khi đến loopback interface. Một switch kiểm soát toàn bộ hành vi này:

getsebool -a | grep httpd_can_network
sudo setsebool -P httpd_can_network_connect on

-P là flag cần dùng: nó ghi giá trị xuống disk. Nếu thiếu -P, thay đổi sẽ mất sau lần reboot tiếp theo. Khi đó service chỉ hoạt động cho đến khi máy khởi động lại. Xác nhận bằng semanage boolean -l | grep httpd_can_network_connect. Lệnh này in giá trị đang chạy cạnh giá trị đã lưu.

Khi có boolean tương ứng, hãy ưu tiên dùng boolean thay vì tự viết rule. Boolean đi kèm policy của distribution, nên được duy trì, có tài liệu và dễ tìm để người tiếp quản sử dụng. getsebool -a liệt kê tất cả boolean trên hệ thống.

Cho service lắng nghe trên cổng không tiêu chuẩn

Các cổng cũng được gắn nhãn. Chuyển nginx sang 8081 thì service không khởi động:

nginx: [emerg] bind() to 0.0.0.0:8081 failed (13: Permission denied)

httpd_t có thể bind các cổng được gắn nhãn http_port_t, còn 8081 không thuộc nhóm đó. Thêm nhãn cho cổng:

sudo semanage port -l | grep -w http_port_t
sudo semanage port -a -t http_port_t -p tcp 8081

Trước tiên, hãy kiểm tra danh sách. Một số cổng cao đã được cho phép, bao gồm 8008 và 8443, và thêm một cổng hai lần sẽ gây lỗi ValueError: Port tcp/8081 already defined. Nếu cổng đã thuộc một type khác, hãy đổi type bằng semanage port -m -t http_port_t -p tcp 8081 thay vì thêm type mới.

Lệnh tương tự cũng cần dùng để SSH hoạt động sau khi đổi cổng. Bind to port 2222 on 0.0.0.0 failed: Permission denied trong journalctl -u sshd cho biết 2222 chưa có trong ssh_port_t, vì vậy hãy chạy sudo semanage port -a -t ssh_port_t -p tcp 2222 trước khi restart daemon và đóng session. Đây là bước nhiều người bỏ qua khi làm theo hướng dẫn chung về tăng cường bảo mật SSH trên VPS chạy image thuộc họ Red Hat. SELinux cũng không phải firewall, nên cổng vẫn phải được mở: dùng sudo firewall-cmd --permanent --add-port=8081/tcp && sudo firewall-cmd --reload ở đây, hoặc ufw trên image Debian hoặc Ubuntu. Flag --permanent có cùng rủi ro quên sau reboot như -P khi dùng với boolean, và bạn nên đọc một lần về các zone quyết định rule áp dụng cho interface nào trong kiến thức cơ bản về firewalld cho VPS Rocky hoặc AlmaLinux.

Khi không có boolean và cũng không có label để thay đổi

Trường hợp này hiếm gặp trên server thông thường và là lúc người dùng dễ gây hỏng hệ thống. audit2allow có thể tạo policy module từ các lần từ chối trong log:

sudo ausearch -m AVC -ts recent -c nginx | audit2allow -M nginx_local
cat nginx_local.te
sudo semodule -i nginx_local.pp

Đọc nginx_local.te trước khi cài đặt. Có hai thói quen giúp giữ an toàn. Lọc input chỉ còn chương trình bạn đang sửa bằng -c, vì pipe cả một tuần các lần từ chối không liên quan vào audit2allow sẽ cấp tất cả các quyền đó cùng lúc. Không bao giờ cài module được tạo từ một lần từ chối mà bạn không thể giải thích: một rule cho phép httpd_t đọc mọi file trên máy rất dễ tạo nhưng khó phát hiện vài tháng sau. Xóa module bằng sudo semodule -r nginx_local.

Permissive là chế độ chẩn đoán, không phải cách khắc phục

sudo setenforce 0
# reproduce the problem once, all the way through
sudo ausearch -m AVC -ts recent
sudo setenforce 1

Chế độ permissive cho phép truy cập và ghi log. Giá trị thực sự của nó là tính đầy đủ. Ở chế độ enforcing, service dừng tại lần từ chối đầu tiên. Bạn khắc phục lỗi đó, khởi động lại, rồi gặp lần từ chối thứ hai. Ở chế độ permissive, tiến trình tiếp tục chạy và log thu thập mọi lần từ chối trong một lượt duy nhất. Sau đó bạn chuyển lại enforcing và khắc phục tất cả cùng lúc.

setenforce không thay đổi /etc/selinux/config, vì vậy reboot sẽ đưa máy về enforcing. Đây là một lớp bảo vệ. Đây cũng là lý do một "cách khắc phục" chỉ gồm setenforce 0 sẽ xuất hiện lại vào thời điểm tệ nhất. Nếu một service cần được nới quyền trong lúc bạn xử lý, hãy đánh dấu domain đó thay vì áp dụng cho toàn bộ máy: sudo semanage permissive -a httpd_t giữ mọi thành phần khác ở enforcing, còn sudo semanage permissive -d httpd_t hoàn tác thay đổi đó.

Vì sao tắt SELinux tốn kém hơn sửa label

Đặt SELINUX=disabled trong /etc/selinux/config sẽ đánh đổi một lần sửa label trên một dòng lệnh để lấy một máy chủ yếu hơn vĩnh viễn. Khác biệt này sẽ lộ rõ vào ngày một web application bị xâm nhập. Khi ở chế độ enforcing, mã của kẻ tấn công chạy trong httpd_t, nên có thể đọc nội dung web, nhưng việc đọc /etc/shadow hoặc ghi một systemd unit sẽ bị policy từ chối, bất kể user Unix được cấp quyền gì. Khi không load policy, đoạn mã đó sẽ có toàn bộ quyền mà service account có.

Việc tắt SELinux cũng tạo ra chi phí phải xử lý về sau. Trong thời gian không load policy, file mới được tạo không có label, khiến trạng thái của filesystem lệch khỏi policy. Khi bật lại SELinux, bạn phải relabel toàn bộ filesystem, nếu không hàng loạt service có thể fail cùng lúc:

sudo fixfiles -F onboot
sudo reboot

Lệnh đó ghi /.autorelabel và relabel mọi filesystem trong lần boot tiếp theo. Với disk lớn, quá trình này mất nhiều thời gian và console có thể trông như bị treo, vì vậy hãy chạy khi bạn có thể chờ. Vì máy sẽ phải down, nên kiểm tra trước xem còn những gì đã được xếp hàng để restart. Đây là việc mà needs-restarting báo cáo sau khi dnf update để lại kernel và library cũ trong bộ nhớ. Trên Rocky Linux và AlmaLinux 9, file cấu hình không còn tự tắt phần kernel nữa. Cách được tài liệu hướng dẫn để tắt hoàn toàn SELinux là dùng kernel argument (sudo grubby --update-kernel ALL --args selinux=0). Biết lệnh này hữu ích khi bạn tiếp quản server của người khác. Đây không phải cách sửa lỗi 403.

Container thêm một nhãn

Trên máy chủ thuộc họ Red Hat, các tiến trình container chạy trong container_t và chỉ có thể đọc những file được gắn nhãn container_file_t. Bind mount từ máy chủ sẽ lỗi với Permission denied bên trong container, trong khi ls -l trên máy chủ trông hoàn toàn bình thường. Hậu tố :Z yêu cầu runtime gắn nhãn lại mount:

docker run -d -v /data/appdata:/var/lib/app:Z myimage

:Z gắn nhãn thư mục chỉ cho container này. :z gắn nhãn thư mục để chia sẻ giữa các container. Nếu trỏ :Z vào một thư mục đang được các service khác sử dụng, lệnh này sẽ gắn nhãn lại đệ quy cho thư mục đó và làm các service kia lỗi, vì vậy hãy cấp cho container các đường dẫn riêng. Nếu engine chưa được cài trên máy, hãy lưu ý rằng lệnh docker trên các bản phân phối này thường là podman nhưng được gọi bằng tên đó; phần này được xử lý trong các bước cài đặt Rocky và AlmaLinux trước khi bạn gặp vấn đề này. Các phần còn lại của thiết lập giống với mọi image khác, được trình bày trong chạy Docker trên VPS.

Ubuntu và Debian dùng AppArmor

Cùng mục đích, nhưng thiết kế khác nhau. AppArmor giới hạn một chương trình dựa trên đường dẫn đến file thực thi của chương trình đó, bằng profile trong /etc/apparmor.d/, thay vì gắn nhãn cho các file trên đĩa. Bạn không cần relabel và không có restorecon. Bắt đầu như sau:

sudo aa-status
sudo journalctl -k | grep -i apparmor

Một lần từ chối truy cập sẽ xuất hiện dưới dạng apparmor="DENIED" operation="open" profile="/usr/sbin/nginx" name="/data/www/index.html" requested_mask="r". Quy trình vẫn tương tự: đọc denial, tìm profile, rồi sửa rule. sudo apt install apparmor-utils cung cấp aa-complain (permissive cho một profile) và aa-enforce để bật enforcement lại. Ubuntu giới hạn một nhóm service đã được đóng gói và để các service còn lại không bị giới hạn, vì vậy hãy đọc aa-status để biết chính xác những gì đang active thay vì tự giả định.

Một thói quen áp dụng cho cả hai hệ thống. Khi một service báo Permission denied đối với một thành phần có vẻ đúng, hãy đọc security log trước khi thay đổi permission. Các bit permission hiếm khi là nguyên nhân nếu lỗi đã xảy ra lần thứ hai.

FAQ

Vì sao nginx trả về 403 dù quyền file đúng?

Vì SELinux từ chối thao tác đọc, không phải do các bit quyền. Web server chạy trong domain httpd_t và chỉ có thể đọc các file được gắn nhãn dành cho nội dung web. Vì vậy, file có nhãn default_t hoặc admin_home_t sẽ bị từ chối và nginx không có nội dung để phục vụ. Xác nhận bằng sudo ausearch -m AVC -ts recent. Lệnh này hiển thị scontext kết thúc bằng httpd_t, còn tcontext đang chứa type sai. Sau đó ghi lại nhãn đúng và áp dụng bằng: sudo semanage fcontext -a -t httpd_sys_content_t "/data/www(/.*)?" tiếp theo là sudo restorecon -Rv /data/www.

Chạy setenforce 0 để service hoạt động có an toàn không?

setenforce 0 chỉ là bước chẩn đoán, không phải cách sửa lỗi. Dùng nó để tái hiện lỗi một lần, để log ghi nhận mọi lần từ chối trong một lượt. Đọc các sự kiện đó bằng sudo ausearch -m AVC -ts recent, sau đó chạy sudo setenforce 1 và sửa nguyên nhân. Server để ở chế độ permissive sẽ ghi log mọi lần từ chối nhưng không chặn lần nào. Vì vậy, bạn vẫn phải xử lý nhiều log không cần thiết và đã mất lớp bảo vệ. Nếu một service cần được nới quyền trong lúc bạn xử lý, chạy sudo semanage permissive -a httpd_t để các phần còn lại của máy vẫn ở chế độ enforcing.

Làm cách nào để chạy service trên cổng không tiêu chuẩn khi SELinux đang enforcing?

Thêm cổng vào type mà service đó được phép bind. Với web server chạy trên cổng 8081: sudo semanage port -a -t http_port_t -p tcp 8081. Với SSH chạy trên cổng 2222: sudo semanage port -a -t ssh_port_t -p tcp 2222. Trước tiên, kiểm tra danh sách hiện tại bằng sudo semanage port -l | grep -w http_port_t, vì lệnh sẽ lỗi với ValueError: Port tcp/8081 already defined nếu cổng đã có trong danh sách. Nếu bỏ qua bước này, daemon sẽ thoát khi khởi động với bind() ... Permission denied dù không có process nào khác đang giữ cổng.

Ubuntu có SELinux không?

Không. Ubuntu và Debian đi kèm AppArmor. AppArmor áp dụng profile gắn với đường dẫn của executable, thay vì dùng nhãn trên file. Kiểm tra bằng sudo aa-status và tìm các dòng apparmor="DENIED" trong sudo journalctl -k. Ubuntu chỉ giới hạn một nhóm service được đóng gói sẵn, nên nhiều chương trình mặc định vẫn chạy unconfined. Rocky Linux và AlmaLinux là các bản phân phối bạn thường gặp SELinux ở chế độ enforcing ngay sau khi cài đặt, cùng với Fedora và RHEL.