时间旅行调试(TTD)是一个非常有意思的topic,传统的软件调试不同,TTD会录制程序的整个运行状态,调试者可以像时间旅行一样穿梭于整个运行状态,包括浏览任意时刻内存,浏览正向/反向数据流流向等。将TTD应用到二进制分析领域可以实现一些非常Hacking的功能,比如常见的字符串加解密混淆,只要有解密逻辑,那么在某段时间内存中必定会出现明文字符串,解密过程与明文字符串都会暴露在TTD的内存分析模块下。本文讨论如何实现一个TTD, 相关代码已开源至GitHub
时间旅行调试(Time Travel Debug,后文简称TTD)是一个非常有意思的topic,与传统的软件调试不同,TTD会录制程序的整个运行状态,调试者可以像时间旅行一样穿梭于整个运行状态,包括浏览任意时刻内存,浏览正向/反向数据流流向等。WinDBG提供了TTD扩展,但是并不开源,而且我觉得这个扩展也不是为了二进制分析(Binary Analysis)这个研究领域做的,更像是为软件开发者定位软件BUG做的。由此就可以引出本文的内容,TTD应用到二进制分析领域的工程实践经验。
将TTD应用到二进制分析领域可以实现一些非常Hacking的功能,比如常见的字符串加解密混淆,只要有解密逻辑,那么在某段时间内存中必定会出现明文字符串,并且,在某段时间中,必然会有寄存器指向明文字符串的起始地址;再比如VMProtect的授权系统,VMP会加密机器码,使用到机器码时动态解密,那么在录制完程序的运行状态后,可以尝试在运行状态中搜索明文机器码,因为只要存在解密逻辑,那么内存中一定会出现明文机器码,再使用反向污点分析或者反向程序切片等技术,就能定位到机器码的解密逻辑,实现动态Patch机器码过验证。
要实现以上提到的Hacking功能,至少需要以下几点能力:
接下来,本文会依次介绍我的TTD是如何实现内存建模,二进制程序分析引擎,以及TRACE的,同时,我的TTD的核心部分已经开源至GitHub。
程序运行时会产生一系列内存状态。以下面3条指令为例,如果在TRACE中遇见第1条指令,我们可以知晓0x100000内存地址的DWORD值,由此就产生了一个内存状态,遇见第2条指令时,又可以知晓0x200000内存地址的DWORD值,产生了第二个内存状态,遇见第3条指令时,可以知晓0x100004内存地址的DWORD值,在我的TTD TRACE中,遇见内存写入指令,都会记录目标内存块写入前和写入后的值,由此产生了两个内存状态,分别对应写入前与写入后。因此,下面3条指令共计产生了4个内存状态。
在TTD TRACE时,我们没有资源去记录程序每个内存状态的所有内存,只能在发生内存访问时,记录内存访问的相关信息,如目标内存地址,访问前后的内存块内容。内存建模算法的输入就是这些内存访问信息。
内存建模时,内存以块为单位进行组织,结构体如下,block_addr是内存块的起始地址,block_memory是内存块的内容,一个内存块最长为4096字节,len是内存块的实际长度。使用std::vector<MemBlock *>储存一系列内存块,后文简称blocks。blocks中MemBlock元素顺序按照block_addr字段升序排序,升序排序的目的是查找和插入新的内存块时可以使用二分法查找,这样时间复杂度是O(logN)。
blocks对应程序运行时的一个内存状态,当遇见内存访问指令时,需要更新blocks也就是更新内存状态。我的实现使用了一个小trick,每次更新blocks时都以字节为单位,如果是DWORD长度的内存访问,就将它拆解为4次字节长度的内存访问,这样可以减少很多复杂的blocks调整操作。对字节长度的blocks更新步骤如下:
到目前为止,我们有一个blocks,blocks中以内存块为单位记录着内存状态中的已知内存。每次遇见内存访问指令时,都需要更新blocks。还是以上文提出的3条内存指令为例,3条内存指令产生了4个内存状态,假设0x100000内存地址的DWORD为0x11223344, 0x200000内存地址的DWORD为0x55667788, 写入前0x100004内存地址的DWORD为0x22334455, 写入后0x100004内存地址DWORD为0x55667788,4个内存状态的blocks更新操作如下所示,其中update_byte(addr, byte)表示更新当前内存状态中addr处的字节为byte。注意第3条指令有两个内存状态,分别对应指令执行前与执行后的内存状态。
接下来我们讨论如何搜索程序的所有内存状态,注意我们刚刚提到的内存状态更新策略中有update_byte原语,那么要搜索一段HEX很简单,只要在每次update_byte时,在被更新的内存地址附近搜索HEX即可。我的实现非常简单,首先是根据TTD TRACE不断更新内存状态,然后实现了一个回调机制,每次更新内存状态时都会调用回调函数检查被更新的内存地址附近是否出现HEX。
这个内存建模的算法效率是比较高的,我实测可以轻松处理亿条指令级的TTD TRACE。
在本章节中,我们讨论一下如何集成一个污点分析引擎进入TTD以及实际应用时的各种问题。
污点分析是一种数据流分析算法,它由3个部分组成,污点源,污点传播规则,检查点。污点源指先为一些数据标记污点标签,比如为用户输入标记污点标签;污点传播规则表示根据程序指令语义建模污点标签的传播规则,比如add a, b指令的语义是 a = a + b, 那么它的污点传播规则就是 La = La ∪ Lb,表示a的污点标签是a与b的并集;检查点表示在程序执行的某个状态节点检查污点标签是否符合预期,比如在send函数处检查是否有污点数据被通过网络发送,以及在函数结尾处检查栈返回地址是否有污点数据。通过调整污点标签的粒度,比如比特位级,字节级,DWORD级等,污点分析可以在效率和准确率进行取舍,一般最常使用的是字节级的污点分析。
污点分析的理论非常有潜力,但是实际应用的时候还是存在各种问题,问题的根源在于从分析一条指令的语义到分析语句块的语义存在GAP,这种GAP会导致过污染与欠污染问题,本质上也是false positive与false negative问题。举个很常见的例子,用一上午大概学习下就能熟悉常见的汇编指令的语义,但是实际逆向时要理解一段反汇编代码的语义仍然具有一定难度。在实际应用时,污点分析存在许多失效的情况,简单归纳如下:
在定义污点传播规则时,常常会选择偷懒,少实现一些指令的传播策略,或者简化传播规则。比如cmp a, b指令严格意义上来说需要将污点标签传播到zf, sf等标志位上,但是实现时通常会选择偷懒不处理这些问题。从对抗的角度来看,可以从这方面入手构造各种会导致过污染与欠污染的代码混淆方法
举个最简单的例子,MBA表达式,一个很复杂的表达式,但是它的计算值恒等于某个常数。从单条指令的角度来看,它进行了一系列运算,涉及到许多污点传播过程,但是从语句块语义的角度来看,它的意义就是产生一个常数。从对抗的角度来看,从这方面入手可以轻松构造各种混淆方法使污点分析的结果完全不可用
污点分析实现时的基本范式如下,实际上就是将各种污点传播规则用代码实现
我选择使用插桩框架实现TRACE,插桩是很老的topic了,互联网上也有很多教程。TRACE需要完成指令TRACE,对于内存访问指令,还要记录访问目标地址,访问长度,如果是内存读取,记录读取值,如果是内存写入,记录写入前内存值与写入值
开源代码见GitHub, https://github.com/ddddhm1234/TTDEngine
1.
mov eax, dword ptr [
0x100000
]
2.
mov ebx, dword ptr [
0x200000
]
3.
mov dword ptr [
0x100004
], ebx
1.
mov eax, dword ptr [
0x100000
]
2.
mov ebx, dword ptr [
0x200000
]
3.
mov dword ptr [
0x100004
], ebx
struct
MemBlock {
uint64_t block_addr;
uint8_t block_memory[4096];
uint64_t len;
};
std::vector<MemBlock *> blocks;
struct
MemBlock {
uint64_t block_addr;
uint8_t block_memory[4096];
uint64_t len;
};
std::vector<MemBlock *> blocks;
Q: 为什么不约定block_addr对齐
4096
, 按照系统内存分页机制建模, 这样就不需要内存块合并了
A: 这样做需要额外的字段记录内存块中的
*
*
实际已知内存
*
*
,比如block_memory是
4096
字节的数组,但是数组中并不是所有字节都是有效的,有一些内存在当前内存状态中就是未知的,需要额外的字段去记录block_memory中的有效字节
Q: 为什么不约定block_addr对齐
4096
, 按照系统内存分页机制建模, 这样就不需要内存块合并了
A: 这样做需要额外的字段记录内存块中的
*
*
实际已知内存
*
*
,比如block_memory是
4096
字节的数组,但是数组中并不是所有字节都是有效的,有一些内存在当前内存状态中就是未知的,需要额外的字段去记录block_memory中的有效字节
update_byte(0x100000, 0x11)
update_byte(0x100001, 0x22)
update_byte(0x100002, 0x33)
update_byte(0x100003, 0x44)
update_byte(0x200000, 0x55)
update_byte(0x200001, 0x66)
update_byte(0x200002, 0x77)
update_byte(0x200003, 0x88)
update_byte(0x100004, 0x22)
update_byte(0x100005, 0x33)
update_byte(0x100006, 0x44)
update_byte(0x100007, 0x55)
update_byte(0x100004, 0x55)
update_byte(0x100005, 0x66)
update_byte(0x100006, 0x77)
update_byte(0x100007, 0x88)
update_byte(0x100000, 0x11)
update_byte(0x100001, 0x22)
update_byte(0x100002, 0x33)
update_byte(0x100003, 0x44)
update_byte(0x200000, 0x55)
update_byte(0x200001, 0x66)
update_byte(0x200002, 0x77)
update_byte(0x200003, 0x88)
update_byte(0x100004, 0x22)
update_byte(0x100005, 0x33)
update_byte(0x100006, 0x44)
update_byte(0x100007, 0x55)
update_byte(0x100004, 0x55)
update_byte(0x100005, 0x66)
update_byte(0x100006, 0x77)
update_byte(0x100007, 0x88)
void
MemoryView::search_mem(
const
void
*pattern, uint64_t len, std::vector<MemSearchResult> &results) {
MemoryModel mm;
MemorySearchArgsPass sap = {&mm,
reinterpret_cast
<MemRecord *>(mem),
reinterpret_cast
<
const
uint8_t *>(pattern), len, results};
mm.apply_with_callback(mem,
this
->len, search_mem_func, &sap);
}
void
MemoryView::search_mem(
const
void
*pattern, uint64_t len, std::vector<MemSearchResult> &results) {
MemoryModel mm;
MemorySearchArgsPass sap = {&mm,
reinterpret_cast
<MemRecord *>(mem),
reinterpret_cast
<
const
uint8_t *>(pattern), len, results};
mm.apply_with_callback(mem,
this
->len, search_mem_func, &sap);
}
static
bool
taint_step(cs_insn *insn, TaintState &ts,
const
TraceAnalysisAMD64::InstRecord *inst) {
cs_x86_op *ops = insn->detail->x86.operands;
uint8_t op_count = insn->detail->x86.op_count;
bool
tainted =
false
;
TaintValue tv, tv2, tv3, tv4;
uint64_t immop;
switch
(insn->id) {
case
X86_INS_AND:
if
(ops[1].type == X86_OP_IMM) {
tv = get_op_tv(ops[0], ts, inst);
if
(tv.none()) {
break
;
tainted =
false
;
}
immop = ops[1].imm;
uint64_t mask = get_mask(8);
for
(uint64_t i = 0; i < ops[0].size; i++) {
if
((immop & mask) == 0) {
tv[i] = 0;
}
mask <<= 8;
}
set_op_tv(ops[0], ts, inst, tv);
}
else
{
tainted = union_taint_op(inst, ts, ops[0], ops[1], ops[0]);
}
break
;
case
X86_INS_OR:
static
bool
taint_step(cs_insn *insn, TaintState &ts,
const
TraceAnalysisAMD64::InstRecord *inst) {
cs_x86_op *ops = insn->detail->x86.operands;
uint8_t op_count = insn->detail->x86.op_count;
bool
tainted =
false
;
TaintValue tv, tv2, tv3, tv4;
uint64_t immop;
switch
(insn->id) {
case
X86_INS_AND:
if
(ops[1].type == X86_OP_IMM) {
tv = get_op_tv(ops[0], ts, inst);
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课