Rocky나 AlmaLinux에 올린 서비스가 파일을 못 읽는다고 Permission denied를 뱉는데, ls -l로 봐도 소유자와 퍼미션이 다 맞는 경우가 있다. 파일 권한을 아무리 손봐도 그대로다. 이럴 때 범인은 거의 SELinux다. 예전에 avc denied를 읽고 정책 모듈 만드는 글을 한 번 쓴 적이 있는데, 이번에는 "권한은 맞는데 왜 막히지"라는 그 당황스러운 순간을 어떻게 진단하고 어떤 순서로 푸는지에 초점을 맞춘다.
결론부터 말하자면, 리눅스 퍼미션(DAC)이 통과해도 SELinux(MAC)가 별도로 한 번 더 막는다. 먼저 getenforce로 Enforcing인지 보고, ausearch와 sealert로 denial을 읽는다. 해결은 boolean 조정 -> 레이블 교정(semanage fcontext) -> 포트 등록(semanage port) -> 그래도 안 되면 audit2allow 커스텀 모듈 순서로 간다. audit2allow부터 꺼내지 않는 게 핵심이다.
1. SELinux가 뭘 막는가
SELinux는 커널에 들어가는 강제 접근 제어(MAC) 계층이다. 프로세스에는 httpd_t 같은 도메인(타입)이, 파일에는 httpd_sys_content_t 같은 타입이 붙는다. 정책은 "어떤 도메인이 어떤 타입에 무슨 동작을 해도 되는지"를 규칙으로 갖고 있고, 규칙에 없는 접근은 리눅스 퍼미션과 무관하게 막는다.
막힌 사건 하나하나를 AVC(Access Vector Cache) denial이라고 부른다. 이게 감사 로그에 쌓인다. 그래서 퍼미션이 아무리 맞아도, 도메인과 타입 조합이 정책에 없으면 Permission denied가 난다.
예를 들어 Nginx를 기본 /usr/share/nginx/html이 아니라 /srv/app/public에서 서비스하면, 디렉터리 퍼미션이 755라도 404나 403이 나면서 로그에 denial이 찍힌다. 파일 타입이 기본값인 default_t라 httpd_t 도메인이 읽을 수 없기 때문이다.
2. 정말 SELinux 때문인지부터 확인한다
현재 모드를 본다.
$ getenforce Enforcing $ sestatus SELinux status: enabled SELinuxfs mount: /sys/fs/selinux SELinux root directory: /etc/selinux Loaded policy name: targeted Current mode: enforcing Mode from config file: enforcing Policy MLS status: enabled Policy deny_unknown status: allowed Memory protection checking: actual (secure) Max kernel policy version: 33
빠른 진단법은 잠깐 Permissive로 내려 재현해 보는 것이다. Permissive는 막지 않고 로그만 남기는 모드다.
$ sudo setenforce 0 $ getenforce Permissive # 서비스를 다시 호출해 본다
Permissive로 바꾼 뒤 동작이 된다면 원인은 SELinux가 맞다. 확인이 끝나면 바로 되돌린다.
$ sudo setenforce 1
주의: setenforce 0은 진단용이다. 이걸 켠 채로 두거나 /etc/selinux/config를 SELINUX=disabled로 바꿔 서비스를 "해결"하는 건 보안 계층을 통째로 끄는 것이다. 원인을 찾았으면 정책으로 풀고 Enforcing으로 돌아온다.
3. ausearch로 denial을 읽는다
denial은 /var/log/audit/audit.log에 쌓인다. 직접 grep해도 되지만 ausearch가 메시지 타입과 시간으로 걸러 준다.
$ sudo ausearch -m AVC,USER_AVC,SELINUX_ERR -ts recent
----
time->Wed Oct 8 10:21:14 2026
type=AVC msg=audit(1759900874.123:456): avc: denied { read } for
pid=2314 comm="nginx" name="index.html" dev="dm-0" ino=104
scontext=system_u:system_r:httpd_t:s0
tcontext=unconfined_u:object_r:default_t:s0 tclass=file permissive=0
-ts recent는 최근 10분, -ts today나 -ts boot도 쓴다. 한 줄을 뜯어 보면 이렇다.
denied { read }: 읽기가 막혔다.comm="nginx": 막힌 프로세스.scontext=...:httpd_t: 접근을 시도한 도메인.tcontext=...:default_t: 대상 파일의 타입. 여기가httpd_sys_content_t가 아니라default_t인 게 문제다.tclass=file: 대상이 파일.
auditd가 안 돌면 저널과 커널 메시지에서 본다.
$ journalctl -t setroubleshoot $ sudo dmesg | grep -i -e type=1300 -e type=1400
4. sealert로 사람 말로 풀어서 본다
sealert는 setroubleshoot-server 패키지에 들어 있고, denial을 해석해 원인과 권장 조치까지 보여준다. 설치돼 있지 않으면 먼저 넣는다.
$ sudo dnf install -y setroubleshoot-server $ sudo sealert -l "*" SELinux is preventing nginx from read access on the file index.html. ***** Plugin restorecon (92.2 confidence) suggests ************************ If you want to fix the label. /srv/app/public/index.html default label should be httpd_sys_content_t. Then you can run restorecon. The access attempt may have been stopped due to insufficient permissions to access a parent directory. Do # /sbin/restorecon -v /srv/app/public/index.html ... Source Context system_u:system_r:httpd_t:s0 Target Context unconfined_u:object_r:default_t:s0 Target Objects index.html [ file ] Source nginx
confidence가 높은 제안이 대개 정답에 가깝다. 다만 sealert는 레이블 교정을 넘어 audit2allow로 통째로 허용하라는 제안도 같이 내놓는데, 그 제안을 생각 없이 복사하지는 않는다. 아래 순서대로 가장 좁은 해결부터 고른다.
5. 먼저 boolean으로 풀리는지 본다
SELinux boolean은 정책에 미리 들어 있는 on/off 스위치다. "Apache가 DB로 네트워크 연결해도 되는가", "홈 디렉터리를 서비스해도 되는가" 같은 흔한 요구가 boolean으로 준비돼 있다. 커스텀 모듈 만들 필요 없이 켜기만 하면 되니 제일 먼저 확인한다.
$ getsebool -a | grep httpd httpd_can_network_connect --> off httpd_can_network_connect_db --> off httpd_enable_homedirs --> off ... $ sudo setsebool -P httpd_can_network_connect on
-P는 재부팅 후에도 유지하는 옵션이다. 이걸 빼면 재부팅하면 풀린다. 설명까지 보려면 semanage boolean -l을 쓴다.
앞의 Nginx 읽기 denial은 boolean으로 풀리는 문제가 아니다. 네트워크 연결이나 홈 디렉터리 같은 "준비된 요구"가 아니라 그냥 파일 타입이 틀린 경우라, 다음 단계인 레이블 교정으로 간다.
6. 레이블 문제면 semanage fcontext로 고친다
denial의 tcontext가 default_t, var_t처럼 서비스가 기대하는 타입이 아니면 레이블 문제다. 파일 컨텍스트를 확인한다.
$ ls -Z /srv/app/public/index.html unconfined_u:object_r:default_t:s0 /srv/app/public/index.html
httpd_sys_content_t가 붙어야 한다. 한 번만 바꾸려면 restorecon으로도 되지만, 기본 규칙이 없는 경로라 그냥 restorecon하면 다시 default_t로 돌아간다. 그래서 규칙을 먼저 등록하고 restorecon한다.
$ sudo semanage fcontext -a -t httpd_sys_content_t "/srv/app/public(/.*)?" $ sudo restorecon -R -v /srv/app/public Relabeled /srv/app/public/index.html from unconfined_u:object_r:default_t:s0 to unconfined_u:object_r:httpd_sys_content_t:s0
semanage fcontext -a로 영구 규칙을 넣고, restorecon으로 실제 파일에 적용한다. 정규식 (/.*)?는 디렉터리 자신과 하위 전부를 뜻한다. 이렇게 하면 재부팅이나 재라벨링 후에도 타입이 유지된다.
여기서 한 번 걸리기 쉽다. restorecon만 하고 끝내면 regen이나 백업 복원 때 레이블이 날아간다. 규칙 등록(semanage fcontext)이 빠지면 안 된다.
7. 포트가 막히면 semanage port로 등록한다
서비스를 표준이 아닌 포트에 올리면 bind 단계에서 denial이 난다. Nginx를 8089로 올렸다고 하면 denial의 tclass=tcp_socket, { name_bind } 형태로 찍힌다.
$ sudo semanage port -l | grep http_port_t http_port_t tcp 80, 81, 443, 488, 8008, 8009, 8443, 9000 $ sudo semanage port -a -t http_port_t -p tcp 8089
이미 다른 타입에 등록된 포트면 -a(추가)가 아니라 -m(수정)을 써야 한다. 등록된 걸 다시 추가하려 하면 "already defined" 오류가 난다.
8. 그래도 안 되면 audit2allow로 커스텀 모듈
boolean도 없고, 레이블, 포트로도 설명이 안 되는 진짜 새로운 접근이라면 그때 커스텀 정책을 만든다. audit2allow는 denial 로그를 읽어 "이걸 허용하는 규칙"을 자동 생성한다.
바로 모듈을 만들기 전에, 왜 막혔는지 번역부터 본다.
$ sudo ausearch -m AVC -ts recent | audit2allow -w
type=AVC msg=audit(1759900874.123:789): avc: denied { name_connect } ...
Was caused by:
The boolean httpd_can_network_connect was set incorrectly.
Description:
Allow httpd to can network connect
Allow access by executing:
# setsebool -P httpd_can_network_connect 1
-w가 "사실 이건 boolean으로 풀 수 있다"고 알려주는 경우가 많다. 이러면 모듈 만들지 말고 boolean을 켜면 된다. 번역에서 마땅한 대안이 없을 때만 규칙을 만든다.
먼저 -m으로 어떤 규칙이 생길지 눈으로 확인한다. 이 단계는 파일을 만들지 않고 화면에만 출력한다.
$ sudo ausearch -m AVC -ts recent | audit2allow -m myapp
module myapp 1.0;
require {
type httpd_t;
type default_t;
class file { read open getattr };
}
#============= httpd_t ==============
allow httpd_t default_t:file { read open getattr };
여기서 생성 규칙을 반드시 읽는다. 위 예처럼 httpd_t가 default_t 전체를 읽게 허용하는 규칙이 나오면, 그건 레이블을 고쳐야 할 문제를 억지로 뚫는 나쁜 정책이다. 이런 건 채택하지 않고 6단계로 돌아간다. 규칙이 타당할 때만 -M으로 실제 모듈 패키지를 만든다.
$ sudo ausearch -m AVC -ts recent | audit2allow -M myapp ******************** IMPORTANT *********************** To make this policy package active, execute: semodule -i myapp.pp $ ls myapp.pp myapp.te $ sudo semodule -i myapp.pp
-M은 사람이 읽는 .te 소스와 커널에 넣는 .pp 패키지를 함께 만든다. semodule -i로 설치하면 적용된다. 설치된 커스텀 모듈은 이렇게 확인하고, 필요하면 지운다.
$ sudo semodule -l | grep myapp myapp $ sudo semodule -r myapp # 제거
배포 자동화에 올릴 거면 .te 파일을 형상 관리에 넣고 리뷰를 거친다. audit2allow -R은 reference 정책 인터페이스에 맞춰 규칙을 생성해 더 깔끔하지만, 매칭이 안 될 때도 있어 출력은 항상 확인한다.
9. 한 도메인만 임시로 열어 두기
규칙을 다 못 만들었는데 서비스는 당장 띄워야 하는 상황이면, 시스템 전체를 Permissive로 내리는 대신 해당 도메인만 Permissive로 둘 수 있다.
$ sudo semanage permissive -a httpd_t $ sudo semanage permissive -l | grep httpd httpd_t # 정책을 다 만든 뒤 되돌린다 $ sudo semanage permissive -d httpd_t
이러면 httpd_t만 막지 않고 로그를 남기므로, 나머지 시스템은 Enforcing을 유지하면서 denial을 모아 한 번에 정책으로 만들 수 있다. 임시 조치라는 걸 잊지 말고 반드시 되돌린다.
10. 정리
퍼미션이 맞는데도 Permission denied가 나면 SELinux를 의심한다. setenforce 0으로 원인만 확인하고 바로 켠 뒤, ausearch와 sealert로 denial을 읽는다. 해결은 가장 좁은 것부터, 즉 boolean, 레이블(semanage fcontext), 포트(semanage port)로 풀고, 그걸로 안 되는 진짜 새 접근만 audit2allow 모듈로 만든다. audit2allow가 뱉은 규칙은 설치 전에 반드시 읽어, 레이블 문제를 뚫는 과도한 허용이 아닌지 확인한다. 버전 번호와 포트 목록은 정책 상태에 따라 다를 수 있다.