控制流劫持攻击是一种通过构造特定攻击载体来非法篡改进程中的控制数据、从而改变进程的控制流程并执行特定恶意代码的攻击方式。在CTF各方向中,一般是PWN方向的题目中常包含的知识点,入门类型的PWN题目中一般是通过栈溢出修改函数的返回地址。然而,简单的栈溢出实现控制流劫持攻击的方式渐渐淡出PWN的题目范围,Reverse方向的创新融合又将其以一种新方式放进了题目中:自控制流劫持混淆——程序自己修改自己函数的返回地址来对自身控制流进行“劫持”,从而达到混淆程序的目的,一定程度上加大了对程序的逆向成本。
通过栈溢出实现的控制流劫持攻击
控制流劫持攻击通常利用缓冲区溢出等软件漏洞达到攻击目的,很经典的控制流劫持攻击就是通过栈溢出修改函数的返回地址。函数栈帧的结构大致如下:
+----------+ <-+ rsp
| ... |
+----------+
| Stack... |
+----------+ <-+ rbp
qword ptr [rbp] | Old rbp |
+----------+
qword ptr [rbp+8] | Ret Addr |
+----------+
我们知道call指令相当于先将函数的返回地址压入栈、接着跳转到函数地址执行,那么函数栈帧最底层应该为该函数执行完的返回地址(Ret Addr)。而在函数开始执行时,会将上一层函数的栈帧基址指针指向的地址压入栈中(push rbp
),并将当前栈基址设置为栈指针指向的地址(mov rbp, rsp
),然后正式开始执行函数的主体内容。故函数栈帧第二层为上一层函数的rbp,而第三层往后是函数自己的栈空间。在程序开启Canary保护是,第三层通常是针对栈溢出进行检测和防护的Canary,这里不是后面讨论的重点,便不详细展开。
如果Canary保护未开启,通过栈溢出可以很简单地从函数自己的栈空间中一路溢出到Old RBP甚至Ret Addr,当能用攻击数据覆盖Ret Addr时,就可以实现控制流劫持,让函数在return时返回到攻击数据中构造的地址。
RE中的自控制流劫持混淆
当控制流劫持攻击来到Reverse方向中,就成为了函数自己实现控制流劫持从而达到控制流混淆效果的一种软件保护方式。这类题目通常有如下汇编语句:
qword ptr [rbp+8]
进行赋值的汇编语句,联系前面提到的函数栈帧结构可以看到,qword ptr [rbp+8]
在64位程序中指的就是Ret Addr这一层,这里实现了函数自己修改Ret Addr的效果,从而“劫持”自身的控制流,让函数在return时跳转到新地址。例如在这里,该函数执行完以后,会跳转到0x402AC6继续执行。这类题目通过人工跟踪控制流固然可行,但当控制流很长的时候人工跟踪就会比较费时费精力。接下来通过一道例题简单介绍其反混淆的思路(抛砖引玉)。
例题解析
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
int i; // [rsp+0h] [rbp-10h]
puts("Please input your flag:");
__isoc99_scanf("%42s", input);
encrypt();
for ( i = 0; input[i] == cip[i] && i <= 41; ++i )
;
if ( i == 42 )
puts("Correct!");
else
puts("Wrong flag.");
exit(0);
}
input
后调用了encrypt()
,接着对比input
和cip
,如果全部相同那么输出Correct!
,即拿到了flag。encrypt()
开始,后续的每个函数基本都使用了自控制流劫持混淆的保护。流程比较短,可以直接人工跟踪,流程图如下:func201
会出现分叉点,如果x != 0x2A
,那么跳回encrypt
重新执行;反之则跳回主函数中encrypt
执行后的地址。这里通过函数自劫持控制流的方式实现了一个小循环。; encrypt 花指令可无视
mov [rbp+var_9], 0C3h
lea rax, [rbp+var_9]
call rax
; func53
mov eax, cs:x
mov edx, eax
lea rax, input
movzx edx, byte ptr [rdx+rax]
mov eax, cs:x
mov ecx, edx
xor ecx, 53h
mov edx, eax
lea rax, input
mov [rdx+rax], cl
; func111
mov eax, cs:x
mov edx, eax
lea rax, input
movzx eax, byte ptr [rdx+rax]
mov ecx, cs:x
lea edx, [rax+11h]
mov ecx, ecx
lea rax, input
mov [rcx+rax], dl
; func200
mov eax, cs:x
add eax, 1
mov cs:x, eax
; func201
mov eax, cs:x
cmp eax, 2Ah ; '*'
jnz 0x40D187
jmp 0x40D22C
手动反编译成C即:
for (x = 0; x < 0x2A; x++) {
input[x] ^= 0x53;
input[x] += 0x11;
}
那么题目很简单,就是反过来计算input即可。解题脚本:
cip = [0x46, 0x50, 0x43, 0x45, 0x39, 0x7C, 0x75, 0x76, 0x48, 0x7B, 0x73, 0x41, 0x41, 0x8F, 0x43, 0x46, 0x78, 0x73, 0x8F, 0x77, 0x41, 0x76, 0x48, 0x8F, 0x42, 0x43, 0x73, 0x42, 0x8F, 0x71, 0x46, 0x7C, 0x73, 0x7B, 0x76, 0x43, 0x48, 0x74, 0x77, 0x41, 0x7C, 0x3F]
flag = []
for x in cip:
flag.append(((x-17)&0xFF) ^ 0x53)
print(bytes(flag))
# b'flag{876d91cc-af41-5c6d-ba1b-3f8196ad05c8}'
反混淆初探
-
将各函数的关键汇编语句相连成同一个函数,直接反编译该函数进行分析。
-
将对
qword ptr [rbp+8]
的赋值语句patch成jmp 地址
+nops的形式,将原本的函数拆解成代码块,通过IDA free重新分析让这些代码块整合到同一个函数中。
qword ptr [rbp+8]
进行赋值的语句直接patch成jmp 对应地址
,其余字节nop掉,这样就能很简单地得出反编译结果,也不用对偏移细节做调整。缺点也是显而易见的:程序中的无效指令(nop
)很多,降低了程序对代码的空间利用率;如果在对Ret Addr修改后还进行了一些操作,那么这些操作也会因为前面的直接跳转而导致被舍弃,与原程序的运行结果可能会有所不同。import lief
from capstone import *
from keystone import *
import re
FUNC = [('endbr64', '', 4),
('push', 'rbp', 1),
('mov', 'rbp, rsp', 3),
('sub', 'rsp, 0x10', 4),
('mov', 'rax, qword ptr fs:[0x28]', 9),
('mov', 'qword ptr [rbp - 8], rax', 4),
('xor', 'eax, eax', 2),
# ......
('mov', 'rax, qword ptr [rbp - 8]', 4),
('xor', 'rax, qword ptr fs:[0x28]', 9),
('je', '0x05', 2),
('call', '0x401080', 5),
('leave', '', 1),
('ret', '', 1)
]
FUNC_SZ = (7, 6)
MASK = [-1, 0xff, 0xffff, -1, 0xffffffff, -1, -1, -1, 0xffffffffffffffff]
class binFix():
def __init__(self, fn):
self.cs = Cs(CS_ARCH_X86, CS_MODE_64)
self.ks = Ks(KS_ARCH_X86, KS_MODE_64)
self.fn = fn
self.bin = lief.parse(fn)
self.funcMap = {}
def get_asm(self):
binary = self.bin
for f in binary.functions:
if f.size == 0:
continue
fb = binary.get_content_from_virtual_address(f.address, f.size).tobytes()
asms = list(self.cs.disasm(fb, 0))
asmTp = list(zip([x.mnemonic for x in asms], [x.op_str for x in asms], [x.size for x in asms]))
if len(asms) <= 13 or asmTp[:FUNC_SZ[0]] != FUNC[:FUNC_SZ[0]] or asmTp[-2:] != FUNC[-2:] or asmTp[-FUNC_SZ[1]:-4] != FUNC[-FUNC_SZ[1]:-4]:
continue
retn = [-1] * 8
for asm in asms:
addr = asm.address
size = asm.size
opcode = asm.mnemonic
operand = asm.op_str
reobj = None
curAddr = f.address + addr
if (f.name.startswith("func") or f.name == "encrypt") and (
(opcode, operand, size) in FUNC or
(opcode == "call" and operand.startswith("0x") and (int(operand, 16)+f.address) & MASK[4] == 0x401080) or
(opcode == "je" and size == 2 and (int(operand, 16)+f.address) & MASK[1] == 0x5)):
self.bin.patch_address(curAddr, [0x90] * size) # nop's opcode
continue
reobj = re.match(r'byte ptr [rbp + (0x[0-9a-f]*|[0-9])], (0x[0-9a-f]*|[0-9])', operand)
if reobj is not None:
t = reobj.groups()
offset, value = [int(x, 16) for x in t]
# assert retn_address set in 3 consecutive instructions
retn[offset-8] = value
if -1 not in retn:
nextAddr = int.from_bytes(bytes(retn), 'little')
curAddr -= 1
jmp, _ = self.ks.asm("jmpt"+hex(nextAddr), curAddr)
self.bin.patch_address(curAddr, list(jmp))
retn[:3] = [-1] * 3
if hex(f.address) in self.funcMap.keys():
self.funcMap[hex(f.address)].append(hex(nextAddr))
else:
self.funcMap.update({hex(f.address): [hex(nextAddr)]})
else:
self.bin.patch_address(curAddr, [0x90] * size)
return
def draw_call_table(self, start=None):
from graphviz import Digraph
dg = Digraph(format="png")
def func_name_node(addrHex):
try:
name = next(x.name for x in self.bin.functions if x.address == int(addrHex, 16))
dg.node(addrHex, name)
except StopIteration:
try:
name = next(x.name for x in self.bin.functions if x.address < int(addrHex, 16) < x.address + x.size)
dg.node(addrHex, f"{name}: {addrHex}")
except StopIteration:
dg.node(addrHex, addrHex)
return
if start is None:
for k, v in self.funcMap.items():
func_name_node(k)
for x in v:
func_name_node(x)
dg.edge(k, x)
else:
visited = []
def rec_draw(cur):
visited.append(cur)
if cur in self.funcMap.keys():
for nxt in self.funcMap[cur]:
func_name_node(nxt)
dg.edge(cur, nxt)
if nxt not in visited:
rec_draw(nxt)
func_name_node(start)
rec_draw(start)
dg.view()
return
def fix_binary(self, newfn=None):
if newfn is None:
newfn = self.fn + "_fixed"
# clear functions' symbols
for tmpsym in [i for i in self.bin.symbols if i.name.startswith("func") or i.name == "encrypt"]:
tmpsym.name, tmpsym.size, tmpsym.value = "", 0, 0
self.bin.write(newfn)
if __name__ == '__main__':
bf = binFix("ropmaster")
bf.get_asm()
bf.draw_call_table("0x40d187") # encrypt()'s address
# drawing must be done before fixing, or symbols will gone and draw none.
bf.fix_binary()
-
扫描汇编,如扫描到形如
mov byte ptr [rbp+8], 0xC6
的多条汇编语句,将其连接计算跳转后的新地址并将多余字节nop掉。 -
以某地址为起点画出调用关系图,也可以画全部该类带混淆的函数的调用关系图,但本题中干扰函数较多,直接画以
encrypt
为起点的调用关系图,帮助分析。 -
将二进制的符号表剔除(让反编译软件在分析的时候不将它们分析为函数,反而分析成代码块),重新保存二进制。
原文始发于微信公众号(山石网科安全技术研究院):二进制安全之自控制流劫持混淆及其恢复研究