↓Skip to main content

从 Cache of Castaways 学 cross-cache

·6058 words·13 mins·
CTF PWN Kernel SLUB Source Code
Table of Contents
Kernel Pwn 笔记 - This article is part of a series.
Part 3: This Article
本文由 AI 辅助编写,内容经人工校对。

pwn.college Kernel Exercise Collection 的第二题 Cache of Castaways,原题是 corCTF 2022 的同名题(作者 FizzBuzz101 / willsroot)。这题的套路是 cross-cache 溢出,本文就从这题出发,把 cross-cache 讲明白。

靶机内核 Linux 5.18.3,x86_64,单核,开了 SMEP/SMAP、KPTI、KASLR、SLAB_FREELIST_RANDOM、SLAB_FREELIST_HARDENED、HARDENED_USERCOPY,关了 slab merge 和 CONFIG_SYSVIPC。下面所有源码引用和数字都以这个版本为准。

题目分析
#

驱动有两个 ioctl:ALLOC 从自己的 cache 分配一个 512 字节对象,EDIT 往对象里 memcpy 最多 512 字节。对象定义如下:

#define OVERFLOW_SZ 0x6
#define CHUNK_SIZE  512

typedef struct { char pad[OVERFLOW_SZ]; char buf[]; } castaway_t;   // buf 在 +0x6
struct castaway_cache { char buf[CHUNK_SIZE]; };                    // 对象 512 字节

castaway_cachep = KMEM_CACHE(castaway_cache, SLAB_PANIC | SLAB_ACCOUNT);

EDIT 的写入:

memcpy(castaway_arr[idx]->buf, temp, size);   // size <= 512,但 buf 从 +0x6 开始

buf 从对象 +0x6 开始,却允许写满 512 字节,最后 6 字节越过对象末尾。是个线性堆溢出,但溢出半径只有 6 字节,而且这 6 字节大概率只落在同类对象上。

页、slab、cache 的关系
#

内核要动态内存,通常走两级分配器。下面是 buddy 分配整页、SLUB 切成对象:

buddy      以页为单位,按 order 分配 2^order 个连续页(order-0 = 1 页 = 4KB)
  |  __get_free_pages / alloc_pages
SLUB       向 buddy 申请整页,切成固定大小的对象
  |  kmem_cache_alloc / kmalloc
object     kmalloc 拿到的那一小块

三个名词最容易混:kmem_cache(也叫 slab cache)是一类对象的池子,有名字有固定大小;slab 是这个 cache 从 buddy 要来的一页或几页,切成等长的槽;object 是真正分配出去的那一块。这里的 cache 是对象池,下文一律写 kmem_cache 或 slab cache。

order 是什么
#

order 是连续页数量的 2 的指数。order-0 是 1 页(4KB),order-1 是 2 页,order-10 是 1024 页。申请 order-k 时,buddy 从 order-k 链表取;没有就从更高阶分裂,这由 expand() 完成:

// mm/page_alloc.c  expand()
while (high > low) {
        high--;
        size >>= 1;
        ...
        add_to_free_list(&page[size], zone, high, migratetype);   // 高地址那半挂到低阶链表
        set_buddy_order(&page[size], high);
}

它把请求的那块留在低地址,每一层的高地址那一半挂回对应低阶链表。于是从同一块大页里连续分裂出来的页,页号是升序连续的。这给了两个利用上的性质:同一批分配能拿到物理连续的页,所以能造出“页 i 和页 i+1 相邻”;但物理相邻不等于地址可知,KASLR 开着也拿不到页的物理地址,而 cross-cache 只需要“相邻”,不需要“地址已知”。

一页装几个对象
#

castaway 对象 512 字节,为什么每页正好 8 个(order-0)?看 calculate_order():

// mm/slub.c  calculate_order()
min_objects = slub_min_objects;
if (!min_objects) {
        nr_cpus = num_present_cpus();
        if (nr_cpus <= 1)
                nr_cpus = nr_cpu_ids;
        min_objects = 4 * (fls(nr_cpus) + 1);   // 1 CPU -> 4*(1+1) = 8
}

默认希望一个 slab 至少装 8 个对象。512 字节一页正好 8 个,order-0。靶机 root shell 里 cat /proc/slabinfo 能直接看到这些数字:

# name          <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab>
castaway_cache     240    240    512    8    1      <- 512B,每页 8 个,order-0
cred_jar           480    480    128   32    1      <- 128B,每页 32 个,order-0
signal_cache       448    448    960    8    2      <- 960B 一页放不下 8 个,order-1
task_struct        451    451   2752   11    8      <- order-3

多核机器上同一个 cache 的 order 会变大(对象更多、减少锁竞争),这正是很多 exp 要 sched_setaffinity 绑核的原因。

通用 cache 与专用 cache
#

kmem_cache 分两类。通用 cache 是 kmalloc-8/16/.../512/1k/...,kmalloc(200) 会从 kmalloc-256 切一块。专用 cache 是某个子系统用 kmem_cache_create 单独建的,比如 cred_jar(存 struct cred)、task_struct、还有本题驱动建的 castaway_cache。另外带 SLAB_ACCOUNT / GFP_KERNEL_ACCOUNT 的分配走的是单独一套 kmalloc-cg-*(被 memcg 登记),和 kmalloc-* 并存。

pcp 与 LIFO
#

order-0 页的分配释放有一条快车道:buddy 下面给每个 CPU 挂了一小堆空闲页(per-CPU pageset,pcp),拿页还页先走这里,避开 zone 锁。释放走 free_unref_page_commit(),头插进 pcp:

// mm/page_alloc.c  free_unref_page_commit()
pcp = this_cpu_ptr(zone->per_cpu_pageset);
pindex = order_to_pindex(migratetype, order);
list_add(&page->lru, &pcp->lists[pindex]);   // 头插,LIFO
pcp->count += 1 << order;

分配时从头部取,所以最近释放的页最先被复用(LIFO)。但 pcp 不能无限涨:它有个上限 high(pcp->high,由 nr_pcp_high() 按 zone 大小和在线 CPU 数算出,可用 vm.percpu_pagelist_high_fraction 调,cat /proc/zoneinfo 能看每个 CPU 的实际 high/batch;注意这是 pcp 自己的上限,跟 zone 的 min/low/high 水位——那套管 kswapd / 内存回收——不是一回事)。一旦 pcp->count >= high,就调 free_pcppages_bulk() 从尾部(最老的那头)一次拿走 batch 张还给 buddy:

// mm/page_alloc.c  free_pcppages_bulk()
page = list_last_entry(list, struct page, lru);   // 老页还给 buddy

结论:order-0 页的分配释放,就是每个 CPU、每种 migratetype 一条有上限的 LIFO 队列——没满时按 LIFO 复用,满了就把最老的一批挤回 buddy。本题 exp 的关键假设建在前半句上:先释放奇数页、再释放偶数页,最后释放的那批还在 pcp 头部、按 LIFO 被下一批分配拿走。

migratetype:为什么喷页用内核对象而不是 mmap
#

buddy 还按“能不能搬走”给页分类,pageblock 粒度管理。slab 页来自 Unmovable;用户匿名页、page cache 是 Movable,mmap 拿到的是后者。不同 migratetype 各有空闲链表,所以用 mmap/malloc 的用户页去喷,它们大概率和 slab 页不在同一批空闲链里,做不成邻居。本题用 PACKET_TX_RING(内核走 __get_free_pages(GFP_KERNEL),是 Unmovable)就是为了精确控制 Unmovable 页的生与死。

为什么只能 cross-cache
#

改 freelist 走不通。一个 slab 里空闲对象串成单链表,指针存哪由 calculate_sizes() 决定:

// mm/slub.c  calculate_sizes()
} else {
        /*
         * Store freelist pointer near middle of object to keep
         * it away from the edges of the object to avoid small
         * sized over/underflows from neighboring allocations.
         */
        s->offset = ALIGN_DOWN(s->object_size / 2, sizeof(void *));
}

512 字节对象的 freelist 指针在偏移 256,对象正中,不在开头。这是 5.7 为了防小范围越界专门挪的位置,越界 6 字节根本碰不到。就算碰到了,SLAB_FREELIST_HARDENED 还做了混淆,freelist_ptr():

// mm/slub.c  freelist_ptr()
return (void *)((unsigned long)ptr ^ s->random ^
                swab((unsigned long)kasan_reset_tag((void *)ptr_addr)));

要伪造合法值得同时知道 per-cache 随机数、指针存放地址、下一个空闲对象地址。加上 SLAB_FREELIST_RANDOM 把初始空闲顺序打乱,这条路在这个内核上基本死了。

溢出到好用的对象也走不通。内核关了 CONFIG_SYSVIPC,没有 msg_msg。

更关键的是对象根本不和别人共享 cache。建 cache 时 kmem_cache_create 先调 find_mergeable() 看能不能复用现成的同规格 cache,但这题合并被禁了:

// mm/slab_common.c
static bool slab_nomerge = !IS_ENABLED(CONFIG_SLAB_MERGE_DEFAULT);   // 没开就是 true

int slab_unmergeable(struct kmem_cache *s) {
        if (slab_nomerge || (s->flags & SLAB_NEVER_MERGE))
                return 1;
        ...
        if (s->usersize)   // kmalloc cache 带 usersize(hardened usercopy),永不被合并
                return 1;
        ...
}

CONFIG_SLAB_MERGE_DEFAULT 没开,find_mergeable() 直接返回 NULL;加上 SLAB_ACCOUNT 和 castaway_cache 本身属性对不上,它是一个独立的 kmem_cache,同一张 slab 页里全是 castaway 对象。溢出 6 字节只会踩到同类下一个对象的 pad,没有意义。

唯一有价值的落点,是一张 slab 页里最后一个对象的溢出。那 6 字节越过页边界,落到下一张物理页的开头。cross-cache 就从这里开始:既然 cache 内部没活了,就让溢出跨过 cache 的边界,落到另一个 cache 的页上。

cross-cache 的原理
#

把溢出按落点分两种。溢出对象不是本页最后一个时,落点是同页下一个对象,还在本 cache 内,本题里就是 8 个对象的前 7 个,踩到同类 pad,无害。溢出对象是本页最后一个、且下一张物理页属于另一个 cache 时,落点是下一页开头,这才是 cross-cache:

对象级(普通 heap pwn)           页级(cross-cache)
[ A | A | A | A ]                [  cache A 的页  |  cache B 的页  ]
越界踩到同类邻居                  A 页最后一个对象越界 -> 命中 B 页开头的对象

所以 cross-cache 不是新的漏洞原语,就是普通堆溢出加一个布置好的物理页邻接关系。漏洞提供越过页边界的那几个字节,利用者负责把页边界另一边换成一个有价值的受害者对象。难点全在后半句:怎么让受害者 cache 的页稳稳落在溢出 cache 页的正上方(地址 +0x1000 处)。这就是页风水。

页风水
#

目标是造出 [castaway 页][cred 页] 这样物理相邻、且 cred 页在高地址侧的一对,分为以下四步。

第一步,找一个非特权能稳定、大量、可单独释放 order-0 页的原语。本题用 setsockopt(PACKET_TX_RING),走到 alloc_pg_vec 再到 alloc_one_pg_vec_page:

// net/packet/af_packet.c  alloc_one_pg_vec_page
buffer = (char *) __get_free_pages(gfp_flags, order);   // 每个 socket 吃掉整页

对应用户态:

int s = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
int version = TPACKET_V1;
setsockopt(s, SOL_PACKET, PACKET_VERSION, &version, sizeof(version));
struct tpacket_req req = { .tp_block_size = 4096, .tp_block_nr = 1,
                           .tp_frame_size = 4096, .tp_frame_nr = 1 };
setsockopt(s, SOL_PACKET, PACKET_TX_RING, &req, sizeof(req));   // 申请 1 张 order-0 页
// close(s) 即释放

PACKET_TX_RING 需要 CAP_NET_RAW,所以喷页逻辑放在 unshare(CLONE_NEWUSER | CLONE_NEWNET) 的子进程里,空 userns 不映射 uid_map 也能在新 net namespace 里拿到这个 cap。

第二步,隔一个释放一个。喷 1000 页后释放所有奇数下标的页:

spray :  [p0][p1][p2][p3][p4][p5] ...   全是我们控制的页
free odd:[p0][  ][p2][  ][p4][  ] ...   奇数页还给 buddy/pcp

这里必须隔一释放。连续释放一整段,buddy 的伙伴空闲会把它们合并回 order-1/2,页就“消失”了,下一批分配从大块重新分裂,相邻关系不再可控。隔一释放留下占位页,伙伴不空闲、无法合并,每张空闲页都是稳定的 order-0,等着被别的 cache 精确插进来。

第三步,让受害者 cache 因为手头没有空闲对象、被迫向 buddy 要新页,捡走刚释放的奇数页。这里有个前置动作:SLUB 优先用当前 slab 和 partial slab 里的空闲对象,如果 cred_jar 还有半满的页,新 cred 会填到老页上,根本不来要新页,页风水就白做了。所以先 fork 100 个睡眠子进程把已有 cred_jar slab 填满。受害者 cache 是 cred_jar:

// kernel/cred.c
cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred), 0,
                SLAB_HWCACHE_ALIGN | SLAB_PANIC | SLAB_ACCOUNT, NULL);

new = kmem_cache_zalloc(cred_jar, GFP_KERNEL);   // 每次 fork/clone 走这里

然后 clone 320 个子进程,它们的 cred 落在奇数页上。fork 一次会触发十几个 cache 的分配(task_struct、内核栈、vm_area_struct、files/fs/sighand/signal cache、pid……),噪声大,用 clone 加 flag 砍掉大部分:

#define CLONE_FLAGS (CLONE_FILES | CLONE_FS | CLONE_VM | CLONE_SIGHAND)

代价是 CLONE_VM 让子进程和父进程共享地址空间,所以子进程里不能随便用自己的栈和全局变量,本题的等待与提权逻辑是纯内联汇编加全局缓冲区实现的,就是为了防止共享地址空间被破坏造成segfault。

第四步,释放所有偶数页,再用 castaway slab 把它们取回来。偶数页 i 和奇数页 i+1 物理相邻,布局就成了:

偶数页 i   (castaway)   第 8 个对象向上溢出 6 字节
奇数页 i+1 (cred)       页首是一个 struct cred

隔行(odd/even)的意义就是让溢出页和受害者页交替相邻。

受害者:cred
#

struct cred 128 字节(SLAB_HWCACHE_ALIGN 把 sizeof(struct cred) 向上对齐到 cache line),每页 32 个,页开头就是对象开头,所以溢出永远精确命中下一页第一个 cred 的 +0x0。看 struct cred 开头(本题关了 CONFIG_DEBUG_CREDENTIALS 和 RANDSTRUCT,所以 usage 真的在 +0x0):

struct cred {
        atomic_t usage;   // +0x0
        kuid_t   uid;     // +0x4  <- 要清零的
        kgid_t   gid;     // +0x8
        ...
};

6 字节正好是 usage(4) 加 uid 的低 16 位(2)。溢出数据:

char evil[512] = {0};
*(uint32_t *)&evil[512 - 6] = 0x100;   // 伪造 usage = 0x100,别让引用计数归零
// evil[512-2 .. 512-1] 保持 0  ->  uid 低 16 位清零,uid 1000(0x3e8) -> 0

uid 变成 0 之后不需要泄露 KASLR,也不用改 caps:

setresuid(0, 0, 0);   // 无 CAP_SETUID 时,目标 uid == cred->uid 才合法

内核认为进程本来就是 uid 0,这个 setresuid 合法通过,euid/fsuid 一起变 0。读文件权限看的是 fsuid 和 caps,不是 uid,fsuid == 0 且文件属主是 root,就能读 0400 的 /flag。

从这题可以总结出挑受害者的三条标准:

  1. 头部就有高价值字段,因为溢出只精确落在页首对象的开头,cred 的 usage/uid 恰好在最前面
  2. 能被非特权大量可控分配,fork/clone 要多少 cred 有多少
  3. 改完能 leakless 变现,uid 清零加 setresuid 这条链不依赖泄露。

cred 之外常见的受害者还有 struct file、pipe_buffer、页表项(配合 Dirty Pagetable)。

完整利用
#

把上面串起来就是 exp 的六步:

[1] drain    fork 100 个睡眠进程,填满已有 cred_jar slab
[2] spray    TX_RING 喷 1000 页(user+net ns 子进程里),释放奇数页
[3] cred     clone 320 个子进程,cred 落在奇数页;子进程阻塞在 read(rootfd)
[4] reclaim  释放偶数页
[5] overflow 30 页 castaway(240 个对象 <= 驱动上限 400),每个溢出 6 字节
[6] wake     写 rootfd 唤醒子进程;uid==0 的那个 setresuid(0,0,0) 读 /flag

方法二:cross-cache UAF / 页复用(本题没用到)
#

前面整篇讲的是 cross-cache 溢出:bug 是越界写,靠页风水把两张不同的页排成物理相邻,越界字节跨过页边界打到邻居。但 cross-cache 还有另一条主路,用的 bug 类型和 buddy 性质都不一样,很多 UAF 提权(典型是 DirtyCred)走的是这条。

原语不同:UAF,而不是 OOB
#

方法二的 bug 是 use-after-free / double-free:手里有一个指向 cache A 对象的悬垂指针。单纯在 A 里重新分配(把对象 free 掉再 alloc 一个 A 对象)只会让悬垂指针压在一个同类 A 对象上,还在 A 内部,谈不上 cross。要跨到 B,必须让这块物理内存整个离开 cache A、回到 buddy,再被 cache B 抢走。

难点:怎么让整张 slab 掉回 buddy
#

SLUB 只要 slab 里还有一个对象在用就不放页;而且就算整张清空,页还可能被 per-CPU 缓存住不还。看 __slab_free():

kfree 掉 slab 上最后一个在用对象
  └─ __slab_free(): inuse == 0            // 整张 slab 空了
       ├─ 它是当前 c->slab(活跃 slab)         -> 留着当快车道,不还    ← 坑
       ├─ 进 c->partial 且没超 per-cpu 上限     -> 当半满缓存留着        ← 坑
       └─ 否则 remove_partial -> discard_slab
            └─ free_slab -> __free_pages() -> order-0 进 pcp -> 回 buddy ✅

所以方法二不能只 free 一个对象,得批量:喷大量 A 对象,让它们铺满若干张全新的 slab;再把这些 slab 整张整张全部 free,使空 slab 的数量超过 per-cpu partial(slub_cpu_partial())和 node partial(s->min_partial)的缓存上限——多出来的空页才会被 discard_slab() 真正交还 buddy。

buddy 性质不同:要的是「同页 LIFO 复用」,不是「相邻」
#

页一旦回到 buddy,order-0 先落在 pcp 头部(LIFO,见前面「pcp 与 LIFO」)。紧接着立刻逼 cache B 要新页:

free 满整张 A 的 slab    ->  页进 pcp 头部(LIFO)
立刻耗尽 B 的 current + partial,再 alloc 一个 B
  -> new_slab -> allocate_slab -> alloc_pages 拿到 pcp 头 = 刚释放那张页
  -> B 把它重切成 B 对象
  -> 悬垂的 A 指针现在压在一个 B 对象上 = 跨 cache 类型混淆

方法二只依赖 buddy 的两个性质:

  1. order-0 走 pcp、最近释放的最先发出(LIFO),保证“刚还的页被 B 立刻捡走”;
  2. 上面那张 slab 的 discard_slab 释放条件,保证页真回到了 buddy,而不是被 SLUB 藏在 per-cpu/partial 里。

拿到类型混淆之后
#

悬垂 A 指针与 B 对象重叠后,就回到常规 UAF 套路:用 A 的接口读 B 对象 = 跨类型泄露(泄 KASLR / 指针),用 A 的接口写 B 对象 = 跨类型改字段。DirtyCred 是最有名的落地:UAF/double-free 一个 struct file 或 cred,把整页释放回收后换成一个权限不同的同类对象(只读 fd 换可写 fd、低权 cred 换高权 cred),不碰指针也能提权——和本题“只改数据字段”的无泄露思路异曲同工,只是跨 cache 的桥是“页复用”而非“页相邻”。

防御措施
#

cross-cache 成为主流,是因为内核把 cache 内部的路一条条堵上了。SLAB_FREELIST_RANDOM / _HARDENED 加上 freelist 指针挪到对象中间,封死了 cache 内改 freelist,cross-cache 直接绕过,根本不碰 freelist。

防御也在跟进。CONFIG_RANDOM_KMALLOC_CACHES(5.19+,本题内核没有)把 kmalloc-N 拆成多个随机 cache,增加通用 cache 内部碰撞难度,但对 cred_jar 这种专用 cache 的 cross-cache 没有直接影响。社区提案的 SLAB_VIRTUAL 给每个 slab 的虚拟地址做隔离、让释放的 slab 虚拟地址不被别的 cache 立即复用,直接打击“释放页、受害者立刻捡走”这一步,是目前最对症的方向。grsecurity 的 AUTOSLAB 走的也是削弱 pcp/buddy 可预测性的思路。

但只要物理页还在 cache 之间回收复用、pcp 还近似 LIFO,页风水的前提就还在。cross-cache 现在更费劲但依然可行。

参考资料
#

BeaCox
Author
BeaCox
Stay humble, remain critical.
Kernel Pwn 笔记 - This article is part of a series.
Part 3: This Article

Related

从 RWCTF2022 Digging into kernel 1 & 2 学内核提权方法
·2984 words·6 mins
CTF PWN Kernel Source Code ROP UAF
Kernel 提权路径总结
·6198 words·13 mins
CTF PWN Kernel ROP Paper
UIUCTF 2024 PWN Writeup
·6286 words·13 mins
CTF PWN