ghost-lock 学习

前段时间ghost-lock非常火,网上也有很多各机型的poc用来做全绿root,虽然一点pwn不会,但好在ai读得懂poc

这个漏洞出现在内核rtmutex / Futex PI组件, 函数rt_mutex_start_proxy_lock调用了错误task对象的remove_waiter,导致该被清理的pi_blocked_on对象没有被正确清理,从而在syscall返回后在内核中产生了一个稳定的栈UAF,具体的组件位于kernel/locking/rtmutex.c

怎么看都像是写昏头了

这个洞只要求内核开启CONFIG_FUTEX_PI=y,可以执行native默认权限用户进程

漏洞分析

构造死锁逻辑

漏洞所在的代码是一个优先级为环状时的fallback模块,因此要先构造死锁的等待逻辑
先构造3个线程 W,O,R和3个锁C,T,Q
先让W对C上锁
然后再让W等待Q,让W先睡
然后让O对T上锁
接下来再让O对C上锁,这个时候产生依赖了, O -> C -> W,O得等W释放C
让R执行requeue操作,让W去等待T
然后就产生环状依赖了

1
2
3
4
5
6
7
8
    waits
┌─────────────┐
│ ▼
W ───────────→T ───────→ O
▲ │
│ │
│ ▼
└──────── C ←────────────┘

这个时候就会进入漏洞流程了,注意到这里是R通过proxy去修改W,所以会出来最后W线程的waiter指针没被删掉的情况

制造UAF

1
2
3
4
5
6
requeuer/current                  waiter task
│ │
│ FUTEX_CMP_REQUEUE_PI │ FUTEX_WAIT_REQUEUE_PI
│ │
└──── proxy-lock waiter ───────┘

正常场景中,执行的是右边这条路径,此时执行remove_waiter的线程就是当前线程,所以回收currentwaiter是没问题的,但是在左侧这个场景中,是由一个requeuer线程去执行这个操作,但漏洞代码用了一样的实现,导致requeuerwaiter指针被清掉了,而该被清掉的目标线程的waiter还留着,然后当syscall返回时,这个waiter所在的栈帧已经被回收了,这里就产生了一个稳定的内核栈UAF

然后这个时候,让waiter没被清掉的线程立刻进入内核,此时kernelstack是不会重置的,也就是说只要想办法让下一次和这个waiter有关的调用正好再指向同一块区域,就可以把用户填进去的数据当作waiter结构体使用

用户写数据

这个时候,要找一个syscall,能正好往这块UAF里写数据
在pixel 10/Android 17这个场景中,公开研究选择pselect这个syscall,这个syscall会把用户提供的fd_set参数直接复制到内核栈中,然后恰好可以覆盖到之前的野指针

有些机型这个syscall的栈深度不够,覆盖不到,就得换

这个伪造的waiter结构体大概长这样

1
2
3
4
5
6
7
8
9
struct rt_mutex_waiter {
struct rt_waiter_node tree;
struct rt_waiter_node pi_tree;

struct task_struct *task;
struct rt_mutex_base *lock;

...
};

这里比较关键的是这个红黑树节点tree

1
2
3
  A
/
B

假设要删除根节点A,通常的做法是root = b
这里其实构造了一次内核写
也就是说我们要想办法构造A的红黑树节点地址,让他正好等于我要写的地址也就是构造waiters.rb_root.rb_node,这里的waiter正好是之前UAF构造的,root和node都可以自己填
因为实际上红黑树还要做排序操作,所以现实情况中这是一次受限写操作,必须构造很特殊rb_node结构payload才能正常完成写入

获取基址

内核函数的偏移是好拿的,但是基址不好拿,所以下一步是突破KASLR
研究者选的是/proc/sys/kernel/random/boot_id这个路径,其有一个.data指针指向内核中的一个uuid bytes,然后这个是用户态可读的
通过我们之前能拿到的weak write,我们可以把一个已知内核偏移的符号指针写进去,这样用户态就能读到,减去偏移就能拿到内核基址了

拓展到任意写

因为安卓有CFI防护,所以实际上不能想调用什么就调用什么
ghostlock中选用了ashmem(Android shared memory)configfs来实现受限写到任意写
ashmem自己有一张函数表ashmem_fops,用于绑定各种操作需要的handler
选这两个模块的原因是他们恰好共用同一套布局兼容但是部分字段解释不同的数据结构

1
2
3
4
5
6
7
同一个 struct file

├── f_op
│ └── 决定“调用哪个模块的 handler”

└── private_data
└── 模块自己决定“把它解释成什么结构”

然后他们的函数签名也是兼容的,可以绕过CFI保护

用户可以通过ioctl接口影响ashmemprivate_data里面的数据,然后再通过之前的受限写,把f_op中的部分handler换成configfs的,因为数据结构兼容,所以可以执行,但是configfs的handler此时就会错误解释private_data中的数据,这时就构造出一条用户态往内核写数据的路径,因为用户调用pwrite之类的,都会走ashmem的handler,但此时的handler已经被我们调包了
具体来说,ashmem_area结构体中的name字段有一个11位固定前缀dev/ashmem/,在这之后的所有数据都是用户可控制的
然后configfs_buffer这个结构体正好有一个bin_buffer字段,用于指示写操作的目标的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct configfs_buffer {
size_t count;
loff_t pos;

char *page;

...

char *bin_buffer;
int bin_buffer_size;
int cb_max_size;

...
};

同理控制buffer.page时,我们可以用户读内核

pipebuffer

此时我们已经有了读写的能力,但是还不够完全,目前这套能力还受限于ashmem对象和handler的处理方式,不适合读写大量的数据,因此我们需要pipe_buffer作为代理

1
2
3
4
5
6
7
8
9
10
struct pipe_buffer {
struct page *page;
unsigned int offset;
unsigned int len;

const struct pipe_buf_operations *ops;

unsigned int flags;
unsigned long private;
};

只需要自己创建一个pipe,然后用之前那套读写思路修改page,offset和len这三个元数据,然后再正常调用这个pipe,就真正实现了内核任意读写

未完待续

Author

SGSG

Posted on

2026-08-24

Updated on

2026-08-24

Licensed under