리눅스 capabilities로 root 권한 쪼개기 — setcap, capsh, ambient 집합

80번 포트를 열어야 한다는 이유 하나로 데몬 전체를 root로 띄우거나, 바이너리에 setuid root를 붙이는 경우가 흔하다. 문제는 그 순간 프로세스가 얻는 것이 “포트 바인딩 권한”이 아니라 커널이 정의한 41개 권한 전부라는 점이다. 리눅스 capabilities는 이 root 권한을 기능 단위로 쪼개서 필요한 조각만 건네주는 메커니즘이다. 이 글에서는 Ubuntu 24.04에서 실제로 capability를 붙였다 떼면서 setuid와의 차이, bounding set과 ambient 집합의 동작을 확인한다.

프로세스가 들고 다니는 다섯 개 집합

capability는 프로세스마다 다섯 개의 비트마스크로 관리되고, /proc/<pid>/status에 그대로 노출된다.

집합status 필드역할
PermittedCapPrm이 프로세스가 가질 수 있는 권한의 상한
EffectiveCapEff커널이 권한 검사 시 실제로 들여다보는 집합
InheritableCapInhexecve 시 파일의 inheritable 집합과 교집합으로만 넘어가는 후보
BoundingCapBnd이 프로세스와 모든 자손이 넘을 수 없는 천장, 되돌릴 수 없음
AmbientCapAmb파일 capability가 없는 바이너리를 exec해도 그대로 따라가는 집합

자기 자신의 집합을 그대로 찍는 프로그램으로 시작한다.

#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(void)
{
    char line[256];
    FILE *fp = fopen("/proc/self/status", "r");

    printf("uid=%d euid=%d\n", getuid(), geteuid());
    while (fgets(line, sizeof(line), fp)) {
        if (!strncmp(line, "Cap", 3))
            fputs(line, stdout);
    }
    fclose(fp);
    return 0;
}

setuid root와 파일 capability의 차이

같은 바이너리를 세 가지 상태로 두고 실행해 비교한다. 하나는 그대로, 하나는 setuid root, 하나는 capability 두 개만 부여한 사본이다.

gcc -O2 -o showcap showcap.c
cp showcap showcap_setuid && sudo chown root:root showcap_setuid && sudo chmod u+s showcap_setuid
cp showcap showcap_cap && sudo setcap cap_net_bind_service,cap_net_raw=ep ./showcap_cap
$ ./showcap
uid=1000 euid=1000
CapPrm:	0000000000000000
CapEff:	0000000000000000
CapBnd:	000001ffffffffff

$ ./showcap_setuid
uid=1000 euid=0
CapPrm:	000001ffffffffff
CapEff:	000001ffffffffff
CapBnd:	000001ffffffffff

$ ./showcap_cap
uid=1000 euid=1000
CapPrm:	0000000000002400
CapEff:	0000000000002400
CapBnd:	000001ffffffffff

setuid는 euid를 0으로 바꾸면서 permitted에 41비트를 통째로 채우는 반면, 파일 capability 쪽은 uid를 그대로 둔 채 두 비트만 세운다. 비트마스크는 capsh --decode로 이름을 확인할 수 있다.

$ capsh --decode=0000000000002400
0x0000000000002400=cap_net_bind_service,cap_net_raw

$ capsh --decode=000001ffffffffff
0x000001ffffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,
cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,...

setcap으로 필요한 권한만 붙이기

1024번 미만 포트 바인딩을 시도하는 최소 프로그램으로 부여 전후를 비교한다.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include <sys/socket.h>

int main(int argc, char **argv)
{
    int port = (argc > 1) ? atoi(argv[1]) : 80;
    struct sockaddr_in addr;
    int fd = socket(AF_INET, SOCK_STREAM, 0);

    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(port);

    if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        fprintf(stderr, "bind(%d) failed: %m\n", port);
        return 1;
    }
    printf("bind(%d) ok (uid=%d euid=%d)\n", port, getuid(), geteuid());
    close(fd);
    return 0;
}
$ ./bindport 80
bind(80) failed: Permission denied

$ sudo setcap cap_net_bind_service=+ep ./bindport
$ getcap ./bindport
./bindport cap_net_bind_service=ep

$ ./bindport 80
bind(80) ok (uid=1000 euid=1000)

setcap 문법의 =ep는 파일의 permitted 집합에 해당 capability를 넣고(p) effective 비트를 켜라(e)는 뜻이다. 시스템에 이미 붙어 있는 것들은 getcap -r로 훑을 수 있다.

$ getcap -r /usr/bin /usr/sbin /usr/lib 2>/dev/null
/usr/bin/ping cap_net_raw=ep
/usr/bin/mtr-packet cap_net_raw=ep
/usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,
  cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin,cap_sys_resource=p
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin,cap_sys_nice=ep

ping이 대표적인 전환 사례다. 예전에는 raw 소켓 때문에 setuid root였지만 지금은 cap_net_raw 하나만 들고 있다.

bounding set이 막으면 exec 자체가 실패한다

bounding set은 파일 capability의 천장 역할을 한다. capsh --drop으로 cap_net_bind_service를 천장에서 걷어내고 같은 바이너리를 실행하면 bind가 실패하는 게 아니라 실행 자체가 거부된다.

$ sudo capsh --drop=cap_net_bind_service --user=noble -- -c 'grep CapBnd /proc/self/status; ./bindport 80'
CapBnd:	000001fffffffbff
/bin/bash: line 1: ./bindport: Operation not permitted

effective 비트가 켜진 파일은 libcap API를 쓰지 않는 “capability-dumb” 프로그램으로 간주되므로, 커널은 파일 permitted 집합을 전부 주지 못할 때 조용히 권한을 깎는 대신 execve를 EPERM으로 실패시킨다. effective 비트를 뺀 =p로 바꿔 보면 차이가 분명해진다.

$ sudo setcap cap_net_bind_service=p ./showcap_p
$ ./showcap_p
CapPrm:	0000000000000400
CapEff:	0000000000000000

$ sudo capsh --drop=cap_net_bind_service --user=noble -- -c './showcap_p; echo exit=$?'
CapPrm:	0000000000000000
CapEff:	0000000000000000
exit=0
파일 설정bounding set 정상bounding set에서 제거됨
=eppermitted + effective에 반영execve가 EPERM으로 실패
=ppermitted에만 반영, 코드가 직접 effective로 올려야 함실행은 되고 permitted가 빈 상태

ambient 집합 — 스크립트에 권한 넘기기

파일 capability는 실행 파일의 확장 속성이라 셸 스크립트나 파이썬 스크립트에는 붙일 수 없다. 인터프리터에 붙이면 그 인터프리터로 실행하는 모든 코드가 권한을 얻으므로 답이 아니다. 이 공백을 메우는 것이 커널 4.3에서 들어온 ambient 집합이다.

import socket, os

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
    s.bind(("0.0.0.0", 80))
    print(f"bind(80) ok (uid={os.getuid()})")
except PermissionError as e:
    print(f"bind(80) failed: {e}")
$ python3 bind80.py
bind(80) failed: [Errno 13] Permission denied

$ sudo capsh --keep=1 --user=noble --inh=cap_net_bind_service \
      --addamb=cap_net_bind_service -- -c 'grep -E "CapInh|CapAmb|CapEff" /proc/self/status; python3 bind80.py'
CapInh:	0000000000000400
CapEff:	0000000000000400
CapAmb:	0000000000000400
bind(80) ok (uid=1000)

ambient에 넣으려면 같은 capability가 inheritable에도 있어야 하므로 --inh--addamb을 함께 준다. 서비스로 돌릴 때는 systemd가 같은 일을 해준다.

[Service]
Type=oneshot
User=noble
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ExecStart=/usr/bin/python3 /home/noble/captest/bind80.py
# AmbientCapabilities 없이
$ sudo systemctl start captest.service && journalctl -u captest.service -n 1 -o cat
bind(80) failed: [Errno 13] Permission denied

# AmbientCapabilities=CAP_NET_BIND_SERVICE 추가 후
$ sudo systemctl start captest.service && journalctl -u captest.service -n 1 -o cat
bind(80) ok (uid=1000)

CapabilityBoundingSet까지 같이 지정하면 이 서비스와 자손은 그 한 개 외에는 어떤 권한도 얻을 수 없는 천장 아래에서 돌게 된다.

주의사항

  • capability는 파일 복사로 따라오지 않는다. security.capability 확장 속성이라 cp, 빌드 산출물 재배치, 컨테이너 이미지 레이어 사이에서 조용히 사라진다. 실제로 cp bindport bindport_copy 후 사본은 getcap에 아무것도 뜨지 않고 bind가 Permission denied로 떨어졌다. 패키징 시점에 setcap을 다시 실행하는 후처리가 필요하다.
  • nosuid 마운트와 확장 속성 미지원 파일시스템에서는 무시된다. nosuid tmpfs에 올린 바이너리는 getcapcap_net_bind_service=ep가 그대로 보이는데도 실행하면 권한을 못 받는다. 속성이 붙어 있다는 사실이 동작을 보장하지 않는다.
  • NoNewPrivileges가 켜지면 파일 capability도 함께 죽는다. setpriv --no-new-privs ./bindport 80은 EPERM 없이 실행되지만 CapPrm이 0이다. systemd 유닛에서 NoNewPrivileges=yes와 파일 capability를 함께 기대하면 안 되고, 이때는 AmbientCapabilities를 써야 한다.
  • 일부 capability는 사실상 root와 동급이다. cap_dac_read_search 하나만 붙인 cat 사본으로 /etc/shadow가 그대로 읽힌다. cap_sys_admin, cap_sys_ptrace, cap_setfcap, cap_dac_override도 같은 부류라, 이런 것을 붙이면서 “최소 권한으로 줄였다”고 말할 수는 없다.
  • bounding set에서 뺀 capability는 되돌릴 수 없다. 되돌리려면 프로세스를 다시 띄우는 수밖에 없으므로, 서비스 초기화 단계에서 한 번만 좁히는 방식으로 설계한다.

마무리

capability는 setuid root를 대체하는 스위치 하나가 아니라, 파일 속성(permitted/effective 비트)과 프로세스 집합(bounding/inheritable/ambient)이 exec 시점에 맞물려 결과가 정해지는 구조다. 이번 실습에서 =ep가 bounding set에 걸리면 실행 자체가 EPERM으로 죽고, NoNewPrivilegesnosuid 아래에서는 속성이 붙어 있어도 조용히 무시된다는 점이 그 예다. 데몬을 root에서 떼어낼 때는 getpcaps <pid>로 실제로 어떤 집합이 실려 있는지 확인하고 넘어가는 편이 안전하다.

참고

답글 남기기