Podman 루트리스 컨테이너와 Copy Fail 익스플로잇

1 week ago 13
  • CVE-2026-31431 Copy Fail은 로컬 비권한 사용자가 root 셸을 얻을 수 있게 하며, Podman 루트리스 컨테이너 안에서도 컨테이너 내부 root 권한 상승이 가능함
  • Podman 루트리스 컨테이너는 사용자 네임스페이스, UID 분리, Linux capabilities를 조합해 컨테이너 내부 root를 호스트의 비권한 사용자로 매핑하고 호스트 권한을 제한함
  • 테스트에서 rootless non-root 컨테이너의 foo 사용자는 Copy Fail 실행 후 컨테이너 내부 root가 될 수 있었지만, 권한은 호스트 비권한 사용자 bar가 가능한 범위로 제한됐고 호스트 root 소유 파일은 읽지 못함
  • --security-opt=no-new-privileges나 --cap-drop=all을 적용하면 Copy Fail 실행 후에도 셸이 foo와 capabilities none 상태로 유지되어 즉각적인 root 셸 획득과 capability 상승을 막을 수 있음
  • Copy Fail의 효과는 컨테이너 생명주기를 넘어 남을 수 있어 커널 패치와 재부팅이 필요하며, 읽기 전용 루트 파일시스템, cgroups 리소스 제한, 얇은 런타임 이미지, 방화벽 같은 심층 방어를 함께 적용해야 함

Copy Fail과 Podman 루트리스 컨테이너의 노출 범위

  • CVE-2026-31431은 4월 29일 copy.fail에서 공개됐으며, 공개된 Python 스크립트를 실행하면 로컬 비권한 사용자가 root 셸을 얻을 수 있음
  • Copy Fail은 Linux 컨테이너 안에서도 악용 가능하며, Podman 루트리스 컨테이너에서도 컨테이너 내부 root 셸 획득이 가능함
  • 테스트에서는 컨테이너 root가 호스트 수준에서 컨테이너를 실행한 비권한 사용자 bar의 권한 범위로 제한됨
  • Podman의 루트리스 구현은 사용자 네임스페이스, UID 분리, Linux capabilities를 조합해 컨테이너 프로세스의 호스트 권한을 제한함
  • Copy Fail은 루트리스 컨테이너도 취약점에 면역이 아니지만, Podman 설정으로 침해 후 공격 범위를 줄일 수 있음을 보여줌

루트리스 컨테이너의 동작 방식

  • 기본 예제: 비권한 사용자 bar가 HTTP 서버 실행

    • 예제 환경은 UID 1001인 비권한 사용자 bar가 Podman으로 ubuntu:latest 기반 이미지를 빌드하고, python3 -m http.server를 실행하는 구성임
    • 호스트에서 ps로 보면 python3 프로세스는 사용자 bar 소유로 실행됨
    • Podman은 fork/exec 모델을 사용하므로 컨테이너 프로세스는 podman run 프로세스의 후손이 되며, 일반적인 UID 분리로 컨테이너 프로세스를 호스트 root나 다른 사용자와 분리할 수 있음
    • 일반적인 Docker 구성에서는 비권한 사용자가 docker run을 실행해도 Docker 클라이언트가 루트 권한 데몬과 통신하고, 데몬이 최종적으로 컨테이너 프로세스를 생성하므로 호스트에서 컨테이너 프로세스가 root로 보일 수 있음
  • 루트리스 rootful

    • 컨테이너 이미지는 명시적인 USER 지시어나 --user 플래그가 없으면 보통 컨테이너 명령을 내부 root로 실행함
    • podman top 출력에서 HTTP 서버 프로세스는 호스트 사용자 1001로 매핑되지만, 컨테이너 내부 사용자로는 root로 실행됨
    • 이 구성은 호스트에서는 비권한 사용자로 실행되지만 컨테이너 내부에서는 root인 rootless rootful 상태임
  • 사용자 네임스페이스

    • Podman 루트리스 컨테이너는 사용자 네임스페이스를 사용해 컨테이너 안팎의 UID/GID를 다르게 매핑함
    • 예제에서 컨테이너 내부 UID 0인 root는 호스트의 UID 1001인 bar로 매핑됨
    • /etc/subuid의 bar:165536:65536 설정은 bar의 네임스페이스 프로세스에 할당 가능한 UID 범위를 정함
    • 예제에서는 bar의 UID 1001 외에 165536부터 231072까지의 UID가 bar 프로세스에 할당될 수 있음
    • 컨테이너 내부 사용자 www-data로 sleep을 실행하면 내부에서는 www-data지만 호스트에서는 165568로 표시됨
    • podman unshare로 사용자 네임스페이스에 들어가면, 호스트에서 bar:bar 소유인 홈 디렉터리가 네임스페이스 내부에서는 root:root로 보임
    • Docker도 사용자 네임스페이스를 지원하지만 별도 설정이 필요하고 하나의 사용자 네임스페이스만 허용되는 반면, Podman은 각 UNIX 사용자의 루트리스 컨테이너를 해당 사용자 네임스페이스에서 실행함
  • 권한 작업과 Linux capabilities

    • Podman은 Linux capabilities를 사용해 컨테이너 프로세스에 세분화된 루트 권한을 부여함
    • 이미지 빌드 중 apt install 같은 작업은 chown, dac_override, fowner, setgid, setuid, net_bind_service, sys_chroot 같은 capability 조합으로 가능해짐
    • podman build --cap-drop=all로 모든 capability를 제거하면 apt가 setgroups, setegid, seteuid, chown 등에 실패해 이미지 빌드가 실패함
    • 필요한 capability만 추가하는 방식도 가능하며, 예제에서는 CAP_SETUID,CAP_SETGID,CAP_CHOWN,CAP_DAC_OVERRIDE,CAP_FOWNER를 추가해 패키지 설치를 수행함
    • 기본 실행 상태의 HTTP 서버는 컨테이너 내부 root로 실행되며 CHOWN,DAC_OVERRIDE,FOWNER,FSETID,KILL,NET_BIND_SERVICE,SETFCAP,SETGID,SETPCAP,SETUID,SYS_CHROOT 같은 많은 effective capabilities를 가짐
    • HTTP 서버에는 이런 권한이 필요 없으므로 podman run --cap-drop=all로 모든 capability를 제거할 수 있으며, 이때 podman top에서는 effective capabilities가 none으로 표시됨
  • 루트리스 non-root

    • 컨테이너 내부에서도 비권한 사용자로 HTTP 서버를 실행하려면 기존 /etc/passwd의 사용자, 예를 들어 www-data를 쓰거나 이미지 빌드 중 전용 사용자를 만들 수 있음
    • 예제에서는 UID 1002인 foo 사용자와 그룹을 만들고 /var/www/html에 읽기 권한을 부여한 뒤 USER foo:foo를 설정함
    • 이 이미지를 --cap-drop=all로 실행하면 프로세스는 컨테이너 내부 foo, 호스트 UID 166537, effective capabilities none 상태가 됨
    • 컨테이너 프로세스는 필요한 최소 권한으로 실행해야 하며, 예를 들어 foo가 권한 포트 80에 바인딩해야 한다면 --cap-add=CAP_NET_BIND_SERVICE를 추가해야 함
    • 컨테이너 실행 방식은 네 가지로 구분됨
      • root 호스트 사용자 + 컨테이너 root: root rootful
      • root 호스트 사용자 + 컨테이너 비권한 사용자: root non-root
      • 비권한 호스트 사용자 + 컨테이너 root: rootless rootful
      • 비권한 호스트 사용자 + 컨테이너 비권한 사용자: rootless non-root
    • Podman은 rootless rootful 컨테이너 실행을 쉽게 만들며, 컨테이너 프로세스를 비권한 사용자로 실행할 수 있다면 rootless non-root 구성도 비교적 쉽게 만들 수 있음

바인드 마운트와 UID 격리

  • 호스트 디렉터리를 컨테이너에 마운트하면 호스트 root, 호스트 bar, 네임스페이스 foo가 소유한 파일의 접근 가능 여부가 UID 매핑에 따라 달라짐
  • 예제에서는 /var/lib/bar/test 디렉터리에 호스트 root 소유 root.txt, 호스트 bar 소유 bar.txt를 만들고, 컨테이너에서 /test로 읽기/쓰기 마운트함
  • 컨테이너를 foo로 실행하면 호스트 bar 소유 파일은 컨테이너 내부에서 root:root로 보이고, 호스트 root 소유 파일은 네임스페이스에 매핑되지 않아 nobody:nogroup으로 보임
  • 컨테이너 내부 foo는 bar.txt와 root.txt를 읽지 못하며, rootless non-root가 rootless rootful보다 추가 격리를 제공함
  • foo가 마운트 디렉터리에 만든 foo.txt는 호스트에서 UID 166537 소유로 표시되고, 호스트 사용자 bar는 해당 파일 내용을 읽지 못함
  • 컨테이너를 내부 root로 실행하면 네임스페이스 root는 호스트 bar 소유 파일과 foo 소유 파일을 읽을 수 있지만, 호스트 root 소유 파일은 읽지 못함
  • 내부 root로 실행하면서 --cap-drop=all을 적용하면 foo 파일도 읽지 못하고, 호스트 bar 소유 파일만 읽을 수 있음

Copy Fail 테스트

  • 테스트 조건

    • Copy Fail 테스트에는 원래 공개된 커밋 8e918b5의 익스플로잇 버전이 사용됨
    • 예제 컨테이너 이미지는 기존 HTTP 서버 이미지에 curl을 추가해 컨테이너 안에서 익스플로잇 스크립트를 내려받을 수 있게 함
    • 이미지 이름은 copyfail로 빌드됨
    • 테스트 커널은 Debian의 6.12.74+deb13+1-amd64이며, Debian 기준 최근 버전 중 6.12.85 미만이면 아직 패치되지 않은 커널로 사용 가능하다고 봄
    • 일반적으로 비권한 사용자 foo가 su를 호출하면 root 비밀번호를 요구함
    • 각 테스트에서는 컨테이너 사용자가 /tmp에 Copy Fail 스크립트를 다운로드하고 실행한 뒤, root 셸을 얻으면 sleep을 호출함
    • Copy Fail은 컨테이너 생명주기를 넘어 지속되므로 각 테스트 전에 VM을 재부팅함
  • 루트리스 rootful에서의 결과

    • --user=root로 컨테이너를 실행하면 컨테이너 내부 프로세스는 이미 root임
    • 이 상태에서 Copy Fail 스크립트를 실행하고 su를 호출하면 uid=0(root) 셸을 얻지만, root 사용자는 원래 비밀번호 없이 su로 다른 root 셸을 열 수 있으므로 Copy Fail이 실질적으로 추가하는 바는 없음
    • podman top에서는 /bin/bash, python3 copy_fail_exp.py, su, sleep 모두 컨테이너 내부 root, 호스트 사용자 1001로 표시됨
    • 동일한 capability 집합이 유지되며 CHOWN,DAC_OVERRIDE,FOWNER,FSETID,KILL,NET_BIND_SERVICE,SETFCAP,SETGID,SETPCAP,SETUID,SYS_CHROOT가 보임
    • 내부 root는 마운트된 /test에서 bar.txt와 foo.txt는 읽지만, 호스트 root 소유 root.txt는 읽지 못함
  • 루트리스 non-root에서의 결과

    • 컨테이너를 foo로 실행한 뒤 Copy Fail 스크립트를 실행하고 su를 호출하면 컨테이너 내부 root로 권한 상승됨
    • 결과 셸의 id는 uid=0(root) gid=1002(foo) groups=1002(foo)로 표시됨
    • podman top에서는 초기 /bin/bash, 익스플로잇 실행 프로세스, su 호출은 호스트 UID 166537, 컨테이너 사용자 foo, capabilities none 상태로 보임
    • 권한 상승 후 [sh]와 sleep은 호스트 사용자 1001, 컨테이너 사용자 root로 표시되고, rootless rootful과 같은 capability 집합을 얻음
    • 권한 상승된 컨테이너 root도 호스트 root 소유 root.txt는 읽지 못함
    • 이 상태에서는 컨테이너가 침해됐지만, 공격 범위는 컨테이너와 호스트 비권한 사용자 bar가 가능한 범위로 제한됨
  • no-new-privileges 적용 시 결과

    • Podman은 --security-opt=no-new-privileges로 컨테이너 프로세스가 시작 시점보다 더 많은 권한을 얻지 못하게 할 수 있음
    • rootless non-root 컨테이너에 이 옵션을 적용하고 Copy Fail을 실행하면 셸은 열리지만 여전히 uid=1002(foo) 상태임
    • podman top에서도 모든 프로세스가 호스트 UID 166537, 컨테이너 사용자 foo, capabilities none으로 유지됨
    • 마운트된 /test에서도 foo는 자기 파일만 읽을 수 있고 bar.txt와 root.txt는 읽지 못함
    • 컨테이너는 침해됐지만, 내부 비권한 사용자 foo와 capability 없는 상태로 제한됨
  • --cap-drop=all 적용 시 결과

    • rootless non-root 컨테이너를 --cap-drop=all로 실행해도 foo는 원래 capability가 없음
    • 이 상태에서 Copy Fail을 실행하고 su를 호출하면 열린 셸은 uid=1002(foo)로 유지됨
    • podman top에서도 /bin/bash, 익스플로잇 실행, su, 셸, sleep 모두 foo와 capabilities none 상태임
    • 익스플로잇은 root 셸 획득에는 실패하고, foo는 /test에서 자기 파일만 읽을 수 있음
    • 이 결과는 no-new-privileges 테스트와 유사하며, 두 조치는 함께 사용해 capability 노출을 효과적으로 줄일 수 있음
  • 익스플로잇의 지속성

    • 즉각적인 root 셸과 capability 획득은 no-new-privileges나 --cap-drop=all로 막을 수 있었지만, 익스플로잇 자체의 효과는 남음
    • 이후 capability 제한 없이 새 컨테이너를 실행하면 비권한 컨테이너 사용자 foo가 su만 호출해도 컨테이너 root가 될 수 있음
    • 따라서 커널 패치와 재부팅이 여전히 필요함

심층 방어 전략

  • 읽기 전용 이미지

    • podman run에 --read-only를 추가하면 컨테이너 루트 파일시스템이 읽기 전용으로 마운트됨
    • Podman은 기본적으로 /tmp, /run, /var/tmp 같은 일부 디렉터리를 쓰기 가능하게 마운트하므로, 완전히 읽기 전용으로 만들려면 --read-only-tmpfs=false도 추가해야 함
    • 읽기 전용 컨테이너가 침해되면 시스템에 쓰기 작업이 허용되지 않아 익스플로잇 이후 일부 공격을 제한할 수 있음
    • 다만 curl 출력을 python3로 파이프할 수 있으므로 읽기 전용 설정만으로 익스플로잇 실행 자체를 막지는 못함
    • 예제의 python3 HTTP 서버는 파일시스템 쓰기가 필요 없어 이 옵션을 안전하게 사용할 수 있음
    • 많은 사전 빌드 이미지는 특정 디렉터리에 쓰기 접근을 전제로 하므로 읽기 전용 루트 파일시스템에서 제대로 동작하지 않을 수 있음
    • 읽기 전용 루트 파일시스템은 컨테이너에 연결된 쓰기 가능 볼륨과 독립적이며, 침해 시 해당 마운트 디렉터리에는 여전히 쓸 수 있음
  • 리소스 제한

    • Docker와 Podman은 cgroups를 사용해 컨테이너에 제공되는 리소스를 제한할 수 있음
    • 컨테이너에는 무제한 메모리, CPU, PID가 필요하지 않음
    • podman stats로 컨테이너 리소스 사용량을 확인한 뒤 그에 맞춰 제한을 적용할 수 있음
  • 사용 가능한 바이너리 제한

    • 예제는 단순화를 위해 ubuntu 이미지를 사용했지만, ubuntu 이미지는 침해 시 공격자가 사용할 수 있는 많은 바이너리를 포함함
    • HTTP 서버 실행에는 이런 바이너리 대부분이 필요하지 않음
    • 런타임 이미지는 가능한 한 얇게 구성하는 편이 좋음
    • 멀티스테이지 빌드를 사용해 빌드 타임 환경과 런타임 환경을 분리할 수 있음
    • python3 같은 목적별 이미지, Debian의 -slim 변형, alpine 같은 더 작은 배포판을 기반으로 사용할 수 있음
    • 컨테이너 프로세스와 호환된다면 distroless images나 scratch를 사용해 셸, 패키지 관리자, 시스템 유틸리티가 없는 런타임을 만들 수 있음
  • 방화벽

    • iptables나 nftables를 사용해 컨테이너 프로세스를 방화벽으로 제한할 수 있음
    • 컨테이너 프로세스에 꼭 필요한 수신·송신 연결만 허용해야 함
    • HTTP 서버 예제에서는 DNS나 로컬·원격 서버로의 연결이 필요하지 않으므로, 성립된 수신 연결에서 오는 tcp 패킷만 허용하는 식으로 제한할 수 있음

운영상 의미

  • 표준 Podman 루트리스 컨테이너는 표준 Docker 컨테이너 구성보다 더 나은 격리 수단을 기본적으로 제공함
  • Docker도 rootless 실행비권한 사용자 네임스페이스 사용이 가능하지만, Podman보다 더 많은 설정 노력이 필요하며 아키텍처 차이도 영향을 줌
  • Docker는 여전히 널리 사용되며 Dokku, Kamal, Coolify, Dokploy 같은 셀프호스팅 도구도 기본적으로 Docker를 사용함
  • Docker Hub 이미지를 충분히 검토하지 않고 실행하거나 잠금 조치를 적용하지 않으면 필요한 것보다 넓은 공격 표면으로 서비스가 실행될 수 있음
  • 컨테이너 이미지의 구현 세부사항을 이해해야 함
    • 어떤 사용자 또는 사용자들이 컨테이너 프로세스를 실행하는지 알아야 함
    • 컨테이너 프로세스가 루트 파일시스템의 어떤 디렉터리에 의존하는지 알아야 함
    • 필요한 Linux capabilities와 필요하지 않은 capabilities를 구분해야 함
  • Podman과 컨테이너가 제공하는 여러 메커니즘을 조합하면 컨테이너를 강화하고 침해 시 폭발 반경을 줄일 수 있음
  • 워크로드에 따라 컨테이너를 유일한 보안 경계로 의존해서는 안 됨
  • 컨테이너와 별도 물리·가상 머신을 함께 사용하면 효과적으로 분리할 수 있음
  • Podman은 같은 호스트 안에서도 각 워크로드를 별도의 비권한 사용자와 자체 사용자 네임스페이스로 실행해 격리하는 방법을 제공함

추가 자료

Read Entire Article