# CanonicoPWN Writeup — Ubuntu 26.04 内核提权（CVE-2026-98260）

> 题目：**CanonicoPWN**
> 目标：`nc <host> <port>`，无附件
> 结果：guest root，读取 `/dev/vda` 得到 flag
>
> **flag{REDACTED}**

---

## 1. 题目概览

题目名 **CanonicoPWN** 暗示考点与 Canonical/Ubuntu 内核相关——实际环境正是一台 Ubuntu 26.04 LTS（resolute）内核的 QEMU 虚拟机。

- 服务直接给出一个 QEMU 虚拟机的串口控制台（ttyS0），登录用户为 `ctf`（uid 1000，无任何 capability）
- `/init` 脚本关键内容：

```sh
#!/bin/busybox sh

/bin/busybox --install -s /bin
mount -t proc proc /proc
mount -t devtmpfs devtmpfs /dev 2>/dev/null
mount -t tmpfs -o mode=1777,nosuid,nodev tmpfs /tmp
hostname challenge
stty -echoctl rows 24 cols 80
echo 2 > /proc/sys/kernel/kptr_restrict
echo 1 > /proc/sys/kernel/dmesg_restrict
echo 4 > /proc/sys/kernel/perf_event_paranoid
echo 2 > /proc/sys/kernel/io_uring_disabled
echo 0 > /proc/sys/kernel/unprivileged_userns_clone
chmod 0400 /flag
chown 0:0 /flag
chown 1000:1000 /home/ctf
setsid cttyhack su - ctf
poweroff -f
```

- `/flag -> /dev/vda`（块设备，`br-------- root:root`，默认 `0400`）
- 内核命令行：`console=ttyS0 quiet oops=panic panic=-1`，`/proc/sys/kernel/panic_on_oops=1`
- 无网络接口（仅 `lo`），只能通过控制台交互
- 因此目标明确：**在 guest 内核中拿到 root，读取 `/dev/vda`**

加固配置封死了常见提权面：userns、unprivileged BPF、io_uring、perf 全部关闭；没有 setuid 文件、没有可写系统目录。剩下的攻击面就是内核 syscall 本身。

---

## 2. 无附件侦察

### 2.1 内核版本与来源

```text
Linux version 7.0.0-39-generic (hatch@hatch)
(gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0,
 GNU ld (GNU Binutils for Ubuntu) 2.42)
#1 SMP PREEMPT_DYNAMIC Thu Sep 24 08:42:15 UTC 2026
```

- `7.0.0-39-generic` 是 **Ubuntu 26.04 LTS（resolute）** 的内核 ABI
- 构建者是 `hatch@hatch`、`#1`（不是官方 `#39-Ubuntu`），说明是作者用 Ubuntu 源码包（`Ubuntu-7.0.0-39.39` tag）在 Ubuntu 24.04 工具链下自行编译
- `/proc/config.gz` 可读（作者额外开了 `CONFIG_IKCONFIG`）；与官方 `-39.39` 包内 config 对比，差异基本只是构建环境（gcc 13.3 vs 15.2、Rust 关闭、MODULE_SIG_ALL off、若干 `=m` 改 `=y`），内核源码看起来是原版 Ubuntu 源码

### 2.2 判定为“公开 CVE 环境”

无自定义 `/dev` 设备、`/proc/devices` 全为标准项、kallsyms 无新增 syscall 符号，结合：

- 内核版本精确为 `7.0.0-39.39`（2026-09-24 构建）
- 当时最新的公开 Ubuntu 26.04 内核本地提权是 **CVE-2026-98260**（public PoC 完整、无需 userns/BPF/io_uring）

因此按该 CVE 展开。

### 2.3 官方内核镜像分析

从 Ubuntu 源下载同版本官方包并提取：

- `linux-image-unsigned-7.0.0-39-generic_7.0.0-39.39_amd64.deb` → `bzImage`
- `linux-modules-7.0.0-39-generic_7.0.0-39.39_amd64.deb` → `System.map-7.0.0-39-generic`、`config-7.0.0-39-generic`
- 用 `vmlinux-to-elf` 从 bzImage 还原出带符号的 vmlinux，并用自写 BTF 解析脚本核对结构体偏移

`/proc/kallsyms` 在 `kptr_restrict=2` 下地址为 0，但**符号名顺序**可读；我们还完整导出了 232029 个符号名做对比。

---

## 3. 漏洞分析：CVE-2026-98260

### 3.1 根因

`exec()` 由**非线程组长（non-leader thread）**调用时，`de_thread()` 会交换调用线程与旧 leader 的 PID 关联并 `release_task(旧 leader)`。但 POSIX CPU timer 的清理在 `begin_new_exec()` 中排在多处**可能失败**的操作之后：

```c
int begin_new_exec(struct linux_binprm *bprm)
{
    ...
    bprm->point_of_no_return = true;

    retval = de_thread(me);              // [1] TID 已交换
    ...
    retval = unshare_files();
    ...
    retval = set_mm_exe_file(...);
    ...
    retval = exec_mmap(bprm->mm);        // 可被 SIGKILL 打断 → 失败
    ...
    retval = exec_task_namespaces();
    ...
#ifdef CONFIG_POSIX_TIMERS
    spin_lock_irq(&me->sighand->siglock);
    posix_cpu_timers_exit(me);           // [2] 正常路径的清理
    spin_unlock_irq(&me->sighand->siglock);
    exit_itimers(me);
    flush_itimer_signals();
#endif
    ...
}
```

一旦 `exec_mmap()` 等失败（任务已过 `point_of_no_return` 只能终止），`[2]` 不会执行。退出路径 `exit_itimers()` 删除 timer 时：

```c
static int posix_cpu_timer_del(struct k_itimer *timer)
{
    ...
    p = timer_lock_sighand(timer, &flags);   // 内部 pid_task()
    if (!p) {
        // 任务关联已消失 → 直接跳过 timerqueue_del
        goto out;
    }
    ...
out:
    put_pid(ctmr->pid);
    timer->it_status = POSIX_TIMER_DISARMED;
    return ret;
}
```

`posixtimer_free_timer()` 用 `kfree_rcu()` 释放 `k_itimer`，但 `struct cpu_timer.node` 仍留在存活（exec 后）任务的 timerqueue 中。父进程回收僵尸时，`__exit_signal()` → `cleanup_timerqueue()` 再次删除这个已释放的节点 → UAF/内存破坏。

修复补丁 `acb03d388181`（`exec: Cleanup POSIX timers right after de_thread()`）把清理整体移到 `de_thread()` 成功之后。

### 3.2 版本确认

- Ubuntu 官方 changelog 显示，修复补丁首次出现在 **`7.0.0-41.41`**（`exec: Cleanup POSIX timers right after de_thread()` + `posix-cpu-timers: Prevent freeing a timer which is queued on the expiry list`）
- 我们的目标是 **`7.0.0-39.39`**，没有该修复

对官方 `-39` vmlinux 反汇编进一步确认：

```text
begin_new_exec:
    call de_thread
    call __io_uring_cancel
    call unshare_files
    call set_mm_exe_file
    ...
    call exec_mmap
    call exec_task_namespaces
    ...
    call posix_cpu_timers_exit   ← 仍在后面
    call exit_itimers
    call flush_itimer_signals
```

`posix_cpu_timer_del()` 也确认走 `timer_lock_sighand()`（这是 CVE-2026-64560 的修复，`920f893f735e`，`-38.38` 已合入），查找失败时跳到 `put_pid + it_status=DISARMED`，**跳过 timerqueue_del**——UAF 依然成立。

### 3.3 BTF 偏移核对

对官方 `-39` vmlinux 的 `.BTF` 解析，结果与公开 PoC 的硬编码偏移完全一致：

| 结构/字段 | 偏移 |
|---|---|
| `sizeof(struct k_itimer)` | `0x180` |
| `k_itimer.it.cpu` | `0xe0` |
| `cpu_timer.node` | `+0x00`（即对象 `0xe0`） |
| `cpu_timer.head` | `+0x20`（`0x100`） |
| `cpu_timer.elist` | `+0x30`（`0x110`） |
| `cpu_timer.handling` | `+0x48`（`0x128`） |
| `task_struct.posix_cputimers` | `0xc68` |
| CPUCLOCK_SCHED tqhead | `0xca0` |
| `task_struct.cred` | `0xd00` |

---

## 4. 公开 PoC 与利用思路

使用的是 V4bel/InfinityCore 仓库的 timer 分支（`timer/CVE-2026-98260-ubuntu.c`，注明在 Ubuntu 26.04 `7.0.0-30-generic` 上测试通过）。核心链路：

1. **制造 UAF**
   - 非 leader 线程用 `CLOCK_THREAD_CPUTIME_ID` 创建一个远期 POSIX CPU timer 并 `execve("/proc/self/exe")`
   - 同一 `mm` 下用 50000 个单页 VMA + 3 个 `CLONE_VM` helper 反复 `mprotect` 争用 `mmap_lock`
   - killer 轮询 `tgkill(tgid, tid, 0)`，一旦返回 `ESRCH`（说明 `de_thread()` 已完成 TID 交换）就 `SIGKILL`
   - `exec_mmap()` 的 `mmap_read_lock_killable` 被打断 → `exec()` 在 point of no return 后失败 → timer 节点遗留在任务队列

2. **转移 stale node + 页回收**
   - 父进程 `waitid(WNOWAIT)` 观察僵尸但不回收
   - 创建/删除定时器填充 RCU sheaf：279 个 filler + 392 个 oracle worker 候选 timer
   - 回收僵尸时 `cleanup_timerqueue()` 删除节点并置 `ctmr->head = NULL`，节点残留在某个 worker 的 timerqueue 中
   - 释放候选项/填充对象清空 slab，PCP 预热，再用 64000 匿名页 + 64000 AIO ring 页回收该物理页

3. **worker oracle 与地址泄露**
   - worker 消耗 CPU 时间触发 `collect_posix_cputimers()`
   - 内核在伪造页写入 `ctmr->handling = current`（`object+0x128`）
   - worker 在伪造 `it_lock` 上自旋（用户可观测的“可控停顿点”）
   - 用户扫描页内的内核指针，拿到 worker 的 `task_struct` 地址
   - 释放锁后内核 `list_del_init(&ctmr->elist)` 把 `elist.next/prev` 写成 direct map 地址 → `kernel_object = elist - 0x110`

4. **伪造 RB 删除覆盖 cred**
   - 在页内构造 A/C/M 三个伪造节点：

```text
A.parent = C        A.right = NULL       A.left = S (task+0xd00)   A.head = H (task+0xca0)
C.parent = M        C.right = A          C.left = NULL
M.parent = NULL     M.right = NULL       M.left = C+8              M.head = H
```

   - 第二次 `collect_timerqueue()` 处理 A 时，`timerqueue_del(H, A)` 的 `rb_erase` 执行
     `tmp->__rb_parent_color = pc`，其中 `tmp = A.left = &task->cred`、`pc = C = fake_cred_addr`
     → **`task->cred = fake_cred`**
   - 同一轮继续处理 M，第二次删除把 `C+8` 写 0，即把伪造 cred 的 `uid/gid` 清零
   - 伪造 cred 的 `euid/fsuid/egid/fsgid` 为 0、capability 全 1，`security` 指向伪造 AppArmor label，`user_ns` 指向伪造 user_namespace

5. **提权落地**
   - worker 使用伪造 cred（fsuid 0）在暂存目录创建 root 所有的文件并 `chmod 4755`
   - 预先 fork 的 launcher（保留合法 cred）`execve` 该 setuid 文件 → euid 0
   - payload `setresuid(0,0,0)` 后读取 `/dev/vda`

---

## 5. 移植到本环境的关键适配

### 5.1 5 级页表（LA57）——此前阶段 3 全部漏判的根因

guest CPU 暴露 `la57`，内核 `CONFIG_X86_5LEVEL=y` 且 cmdline 无 `no5lvl`，因此 direct map 位于 `ff11...` 而非 `ffff...`。PoC 的判定：

```c
static int is_kernel_pointer(uint64_t value)
{
    return (value >> 48) == 0xffff && !(value & 7);
}
```

在 5 级页表下 `ff11... >> 48 == 0xff11`，**所有真实内核指针都被拒绝**。修改为通用内核地址判定：

```c
static int is_kernel_pointer(uint64_t value)
{
    return (value >> 56) == 0xff && !(value & 7);  // 4/5 级页表通用
}
```

（用户地址高位为 0，不受影响；其余伪造指针均由 `alias_delta = kernel_anchor - user_anchor` 动态换算，天然适配。）

诊断证据：修复前内核确实在处理我们回收的伪造页（CPU 在 `poc` 线程上持续 soft lockup，即卡在伪造的 `it_lock`），但扫描端看不到任务指针。

### 5.2 暂存目录与权限——stage2 真正的最后一公里

原 PoC 在 `/var/tmp`、`/dev/shm`、`/tmp` 中选择**非 nosuid** 且可写的目录。本环境：

- `/tmp` 是 `tmpfs ... nosuid`（setuid 会失效）
- `/var/tmp`、`/dev/shm` 不存在
- 唯一非 nosuid 可写位置是 rootfs 上的 `/home/ctf`，但它初始为 `755 ctf:ctf`

最初把暂存目录改成 `/home/ctf` 后，即使 cred 覆盖成功，`openat(O_CREAT)` 也会 `EACCES`：`cap_capable()` 的语义决定了伪造 cred 的能力**不能**作用于 init userns 拥有的资源（`cred->user_ns` 的 level 为 0 时对 init 直接返回 `-EPERM`）。

解决：运行前由 ctf 本人把自己拥有的目录改成世界可写：

```sh
chmod 777 /home/ctf
```

rootfs 不是 nosuid，setuid 位仍然有效。这一修复后 stage2 立即成功。

### 5.3 加速 128k 页喷射

PoC 对每个 AIO ring 调用一次 `mremap(..., MAP_FIXED, arena)`，64000 次在目标机上耗时 >5 分钟。我们直接使用 `io_setup` 返回的 ring 地址（内核 `do_mmap` 确认 `MAP_SHARED`，内核 direct map 与用户映射共享同一物理页），跳过 arena 与 mremap：

- 喷射时间从 >5 分钟降到约 25 秒
- 页内容、共享性与利用语义不变

### 5.4 静态 musl 构建

guest 只有 busybox，没有 libc/编译器。用 musl.cc 交叉工具链静态编译（`-O2 -static -pthread`，strip 后约 71KB），通过控制台上传。

### 5.5 串口控制台传输协议（本环境最大的工程障碍）

平台控制台的特点：

- 突发写入会丢字节（578KB 一次性 here-doc 上传曾出现 `gzip: invalid magic`）
- tty 初始 `cols 80`，busybox ash 行编辑器对超长单行有约 1024 字节的限制/折行异常，会把 shell 卡在 PS2 续行
- `stty -echo` 后 shell 提示符与命令输出会打印在同一行（`~ $ POC_00 <hash>`）

最终协议：

1. 每个上传会话先执行 `stty -echo cols 4000; chmod 777 /home/ctf; m=POC; echo ${m}_SETUP`
2. 数据按 **76 字符短行** 组织，每 26 行（约 2KB）一个 `cat > /home/ctf/pNN <<'__E9__' ... __E9__` here-doc 块
3. 每块之后执行 `m=POC; echo ${m}_NN $(md5sum /home/ctf/pNN | cut -c1-32)`，与控制端预计算的块 MD5 比对，失败则恢复 shell 后重传该块
4. 标记用变量拼接生成（输入中不含明文标记），匹配时允许出现在任意 token 位置（解决提示符合行）
5. shell 卡住时用“闭合引号 + Ctrl-C + 换行”恢复
6. 全部完成后 `cat /home/ctf/p[0-9][0-9] > b64; base64 -d b64 | gzip -dc > poc; md5sum poc`

修复后 29 个块约 20 秒完成上传。

### 5.6 平台生命周期与自动化

- 每台 VM 被平台在约 5–8 分钟后 SIGTERM 终止（`panic_on_oops=1` 下任何内核 oops 也会致命）
- 平台对每个地址只允许单个实例，冷却期后重新分配（期间连接 `ConnectionRefused`）
- 因此实现了：
  - `broker.py`：持续重连、持有 VM shell、通过 Unix socket 转发命令、记录全部控制台输出
  - `go.py`：自动等待 shell → 加宽终端/chmod → 分块上传 → 运行 exploit → 监控 `ROOT euid`/`VDASIZE`/`FLAGMATCH`/panic
  - 攻击循环：上传+运行失败后自动等下一台 VM 重试

### 5.7 诊断插桩

为确认 stage2 的内核行为，在 `run_stage1()` 的 stage2 等待循环里轮询伪造页字段：

```text
POC:DIAG_EXP object=... fake=...
  expect p=fake_cred l=task+0xd00 hd=task+0xca0
POC:DIAG:0      h=0 p=fake_cred ... c8=node
POC:DIAG:213    h=0 p=... l=task+0xd00 hd=0 c8=0
```

`c8` 从 A 节点地址变为 0、`hd` 变为 0，直接证明两次伪造 RB 删除都执行了；此后 `s1_done/s1_ok`。

---

## 6. 最终结果

成功时的控制台输出：

```text
POC:1793425633:s1_start
POC:1793425646:s1_object
POC:1793425646:s1_winner
POC:1793425646:s1_stack_ok
POC:1793425646:s1_stage2_ready
POC:DIAG:213 h=0 ... hd=0 c8=0 ...
POC:1793425646:s1_done
POC:1793425646:s1_ok

=== ROOT euid=0 ===
VDASIZE=512 READ=512
HEADHEX=<REDACTED>
FLAGMATCH: flag{REDACTED}
=== exec /bin/sh as root ===
/home/ctf # id
uid=0(root) gid=0(root) groups=1000(ctf)
```

---

## 7. 踩坑与时间线（简）

1. 平台服务“接受后立即 FIN/拒绝”，实为单实例 + 冷却期；首次连接会静默启动 VM
2. 无附件 → 用官方同版本内核包 + BTF + kallsyms 反推目标行为
3. 阶段 3 超时 → 定位到 LA57 指针判定
4. 上传损坏/超时 → 串口丢字节、80 列行编辑器、1024 字节单行限制、`-echo` 提示符合行
5. stage2 超时 → 暂存目录从世界可写目录改到 `/home/ctf` 后，缺少 `chmod 777`，`cap_capable` 语义导致文件创建 EACCES
6. 平台约 5 分钟 VM 生命周期 → 自动化在 shell 就绪后立即上传并执行

---

## 8. 参考

- CVE-2026-98260: <https://ubuntu.com/security/CVE-2026-98260>
- 公开 PoC/分析（V4bel/InfinityCore, timer 分支）: <https://github.com/V4bel/InfinityCore>
- 修复提交 `acb03d388181`（`exec: Cleanup POSIX timers right after de_thread()`），Ubuntu `7.0.0-41.41` changelog
- CVE-2026-64560 修复提交 `920f893f735e`（`posix-cpu-timers: Prevent UAF caused by non-leader exec() race`）
- 定时器回调 bool 化提交 `6fdb2677a594`（`posix-timers: Expand timer_[re]arm() callbacks with a boolean return value`）
- Ubuntu 7.0.0-39/43 changelog: <https://changelogs.ubuntu.com/>

## 附录：工作区文件

| 文件 | 说明 |
|---|---|
| `exploit/poc.c` | 移植后的 CVE-2026-98260 exploit（LA57 修复、诊断、root payload） |
| `exploit/poc_musl` | 71KB 静态 musl 二进制 |
| `go.py` | 自动上传/运行/监控客户端（分块协议、校验、恢复） |
| `broker.py` | 持续重连并持有 VM shell 的 broker |
| `btf_dump.py` | 自写 BTF 解析脚本（核对结构体偏移） |
| `ubuntu-kernel/` | 官方 7.0.0-39.39 bzImage/System.map/config 与还原的 vmlinux |
| `kallsyms_names.txt` | 从 VM 导出的完整符号名列表 |
| `session.log` | 全部控制台交互记录（含成功输出） |
