记录一下比赛中遇到的各种C/C++的反逆向技巧(主要是Windows API)
反调试技巧
NtQueryInformationProcess() / ZwQueryInformationProcess()
原理
两个都是Windows中检查并获取进程信息的函数 区别在于调用方式和调用权限 Zw需要在内核态调用 Nt在用户态调用 主要起到反调试作用的是第二个参数ProcessInformationClass
当该值为ProcessDebugPort(0x07)
时返回缓冲区为-1(调试状态), 0(非调试状态)
当该值为ProcessDebugObjectHandle(0x1E)
时返回缓冲区为一个调试对象句柄!=NULL(调试状态), NULL(非调试状态)
当该值为ProcessDebugFlags(0x1F)
时返回缓冲区为0(调试状态), 1(非调试状态)
例子
[LineCTF BrownFlagChecker]
其中有一个函数如下:
1 | bool anti_debug() |
反制措施
直接patch掉整个函数的调用或者修改控制流使得用于检测调试的信息失效
NtSetInformationThread() / ZwSetInformationThread()
两者的分别与上述差不多 功能是设置信息而不是检查信息 当第二个参数为ThreadHideFromDebugger(0x11)
时将附加的调试器取消
例子
[XCTF Destination]
其中一个预处理函数如下:
1 | void *__thiscall sub_413750(void *this) |
在创建线程处下断点调试到此处 可以看到aS
数组储存的实际上是ZwSetInformationThread
而且第二个参数是0x11 这时在线程启动时调试器会被立刻取消
反制措施
将第二个参数patch为0
虚表 hook
C++类中各种成员在内存中的分布
以以下代码编译出的二进制文件为例:
1 |
|
非虚成员函数
和普通函数一样没有区别 在.text
段实现 不被除了调用处以外的地方引用:
虚成员函数
在.text
段实现 不会被调用处引用外且排列在VMT
即虚函数表中 在调用该函数时通过查表调用:
虚函数表相当于一个特殊的类成员变量 初始化时被赋值给实例的类对象偏移为0的位置:
继承类的虚函数
新建一张虚函数表 对已经实现的虚函数进行替换 没有实现的虚函数则引用原虚函数表中的对应函数 如果继承类没有自己的构造函数则调用基类的构造函数中将基类的VMT赋给偏移为0的内存 再在构造函数后用新的VMT覆盖:
成员变量
就像作为成员变量的VMT一样在构造函数中被依次赋值给偏移为n * sizeof(void *)
的内存:
Hook虚函数
由于虚函数的调用都是间接的 而且VMT的地址和类的地址是绑定的 只需要将VMT中要hook的虚函数的地址替换为用户函数就能轻松实现hook 用以下代码编译出的二进制文件为例(编译时启用-fpermissive
选项):
1 |
|
执行结果:
1 | .\test.exe |
特征
要使用VMT hook绕不开的一点就是改变某段内存的权限 因为VMT所在的数据段没有写入的权限 这时候在Windows系统上要使用VirtualProtect()
进行提权 Linux上使用mprotect()
进行提权
[2024 DASCTF八月开学季] ezcpp
以这题为例 它的VMT hook就非常的明显:
两次hook了v7的VMT中的第一个虚函数
C++异常处理
try{…}catch(…){…}
最简单的一种异常处理类型 IDA可以轻松识别出try{}
块和对应的catch(){}
块 以以下代码编译出的二进制文件为例:
1 |
|
但是IDA的伪代码构造过程并不会将异常处理的部分进行反汇编并构造try-catch
块 只会构造try的部分:
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
应对的方法很简单 将throw
关键字对应的汇编片段直接patch为jmp catch_block
即可:
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
但是这种通过异常处理隐藏控制流的方法局限性是很大的 第一点就是用throw
关键字抛出异常这个特征太明显 而try-catch
结构只能捕捉到throw
关键字抛出的异常 其他的诸如内存错误和除零错误等是不会进行异常处理的 这就要提到下面这种异常处理方式
__try{…}__except(…){…} SEH
这是一种基于TEB
(线程结构)的异常处理方式 具体可以参考结构化异常SEH处理机制详细介绍 可以捕获到出现的所有异常 以以下代码编译出的二进制文件为例:
1 |
|
安装Microsoft C++扩展工具后 用__except
关键字可以指定发生异常时的异常处理函数 当__try
块中发生异常时就会进入这个异常处理函数并将发生的异常号传入pExInfo->ExceptionRecord->ExceptionCode
和上面的异常处理一样 这种方法可以隐藏程序的控制流:
1 | __int64 __fastcall main() |
而且也和上面的异常处理一样在发生异常时即使是步入调试也会直接运行到下一个断点或程序结束 这时候要恢复控制流就需要识别哪部分是一定会发生异常的 并将那部分patch为调用异常处理函数 和跳转到__except
块的jmp指令 但是这样做有明显的弊端:
- 不一定能确定那一部分会发生异常
- 会发生异常的部分不一定每次都发生异常
- 原来会触发异常的部分不一定有足够的空间用来patch成两条指令
所以目前我能想到比较好的处理方式是在异常处理函数的起始地址下断点来动态调试分析 而IDA是可以轻松识别出__except(handler())
中的handler()
的
最后附上所有Windows异常状态码
Debug-Blocker
实际上Debugblocker的思想非常简单 同时特征也十分明显 就是检测让程序启动并调试一个和自己一样的线程 这时候虽然两个线程运行的是同一个程序 但是主进程和子进程因为调试器附加判断(IsDebuggerPresent()
)执行的是完全不同的程序 而通过获取上下文 主进程对子进程的控制流是完全控制的 而子进程又可以通过触发异常来交由主进程处理的方式形成主进程的控制流 最后达成隐藏控制流的目的 同时因为子进程已经被附加了主进程这个调试器 正常来说是不可能再附加调试器了 从而进一步增加了逆向难度
用以下代码编译出的二进制文件为例:
1 |
|
启动线程时会直接触发一个CREATE_PROCESS_DEBUG_EVENT(0x3)的事件 当子进程触发除零异常时handler()
就会获取当时的上下文并根据RAX(除数)来进行对应的处理 并在处理完后更改上下文中的RAX, RIP并设置:
运行结果:
动态调试子线程的方法也很简单 Cheat Engine有一个DBVM功能 简单来说就是一个极简化的虚拟机 在里面运行Windows程序可以让CE的调试器附加到任意线程上 且不影响原程序的执行
[2024 LineCTF] BrownFlagChecker
直接看伪代码:
1 | void __noreturn child() |
这一题就是如果不调试子进程就会非常困难的例子 本题的校验和加密全部在child()
中 handler()
只起到接收数据和初始化一些数据的功能 而正常来说Windows进程有自己独立的一块虚拟内存 不同进程之间不允许访问对方内存 所以这题通过一个驱动程序(.sys)直接得到所有数据的物理地址并进过一系列处理再传给child()
中的vm()
(太长不放) 当作AES加密的密钥和iv 而内核对内存资源的管理和使用要经过虚拟内存转物理内存再根据四级内存页来获取内存固定的最后几位进行组合 之后才能操作用户进程的资源 而且内核调试的前置条件比较多 所以选择用CE调试
开启DBVM需要先开启CPU虚拟化 这里用CE的Lua引擎使用CE的API来Hook AES密钥扩展步骤来获取密钥和iv
1 | function hexstr(bt) |
CE脚本相较于IDA脚本更容易编写 因为Lua引擎在导入脚本时自动加入了包括寄存器的全局变量可以直接使用
C-style float
众所周知C中的浮点数按照IEEE754规则存储在内存中 这里不介绍各种神必的数学上的加密方式 只介绍一些C浮点数的性质
-0.0
当一个浮点数在内存中存放的全部字节都是b'\x00'
时 按照IEEE754规则这个浮点数就是2^-126
但是因为显示精度有限所以通常就认为是0.0 同理当符号位是1时这个值就是-0.0 这些都是显而易见的
但是当0.0和-0.0之间进行运算时结果就超出常理了
用以下代码编译的程序验证除了除法运算之外的结果:
1 |
|
得到结果:
1 | 0.0 + 0.0 = 0.0 |
将其中的-0.0
和0.0
分别替换为1和0 就能将浮点数的加减乘运算转为布尔运算:
1 | A + B ==> A & B |
利用这一点以及目前的反汇编器难以表现出这类浮点数之间的运算就能实现对某些加密算法的隐藏
[WWCTF floats]
题目要求从命令行传入长度为0x20的flag 其中每0x10 bytes的flag会被转为__int128
然后进行一系列处理 首先是根据每一位来创建一个浮点数 如果这一位是0那么这个浮点数就是-0.0
否则为0.0
:
1 | for ( i = 0; i <= 127; ++i ) |
然后对这些1位进行了16轮某种处理:
最后对结果中的位相互运算得到结果:
显然直接进行逆运算得到正确flag十分困难 先看看汇编:
可以看到除了移动指令之外只用到了加, 减和异或运算 按照上面的结论直接模拟程序的运行来用z3解
先得到两个check函数的汇编:
1 | from idc import * |
进行初步处理得到方便执行的指令:
1 | with open("DisAsm_cheak2().txt", "r") as f: |
模拟执行后用z3解:
1 | def NewPlus(A, B): |
最后才发现既然都要模拟执行了为什么不用Angr呢(