ctf 逆向 回顾与总结

APK的另外讨论。

基本功

内存

在这里插入图片描述

编译过程

  • 编译,高级语言(.c)转汇编语言(.s)。注释信息丢失。
  • 汇编,汇编语言(.s)转机器码(.o)。label信息(指令的起始位置)丢失。
  • 链接,多个机器码(.o)合成一个有入口的可执行程序。函数名和变量类型信息丢失。
    反汇编易,反编译难(因编译优化)。

反汇编

可能将数据识别为代码,或者将代码识别为数据。难点是判断函数入口。
除了线性扫描和递归下降,还有超集二进制代码重写反汇编(multiverse)(capstone中已实现),和基于概率的反汇编算法(David Brumley的BAP已实现)。

IDA

有庞大的函数签名库,通过FLIRT算法,识别函数首尾的字节特征,将机器码翻译成静态库函数的调用,这意味着丢失的函数名信息被找回来了一部分。(动态库函数直接看PE的导入表就能知道)。所以拿着7.0版本的,可以手动导入一下新的函数签名(操作步骤可参考《加密与解密》3.3)。7.0版本的另一个问题是无法本地调试32位。

PE和ELF

PEELF
.exeelf可执行文件
.dll.so
.lib.a

它们的结构都是类似的,我认为区别只在后续链接。so文件在链接时指定入口的话也可以执行。
具体怎么分节的,可以在用工具编辑的过程中熟悉。
在运行时,可执行文件的各节会映射到内存段。段有权限,越权会导致段错误。
在这里插入图片描述
共享库和动态库是一个意思。

段、块表、PE文件结构

.idata,存放外部函数地址。
.rodata,只读数据。
.rdata,资源数据段。
.data,全局变量。
静态变量static和全局变量extern存储位置是一样的,不过他们的作用范围不同,前者更小,限于文件。后者跨文件。
块表的一个例子如下图。voffset和vsize是内存映射时的,roffset和rsize是未加载文件时的。两者的区别,加载时各节之间会给一些空隙,以对齐内存。这就导致换算不那么直观,有时需要依靠动态调试时的偏移量来计算文件偏移。关系如下:
相对内存偏移(RVA) = 内存地址 - 加载基址
节偏移 = Voffset(该节在内存中的偏移量) - Roffset(该节在文件中的偏移量)
文件偏移 = 相对内存偏移(RVA) - 节偏移
在这里插入图片描述
下图可以帮助理解两种偏移,以及回顾PE文件结构。
在这里插入图片描述
PE文件头中几个重要的量:

  • entrypoint,也就是oep,程序入口
  • imagebase,加载基址
  • sizeofimage,内存映像大小
  • baseofcode,代码段rva
  • baseofdata,数据段rva

程序入口

编译器会加头和尾,中间调用main。

位置无关代码

编译动态库时,必须带上参数-fpic,表示生成位置无关代码。这样无论动态库在内存中的什么位置,都可以正常执行。-fpic生成的代码中,对于同一个模块而言,指令和数据之间的偏移是固定的,指令对数据的引用可以是用相对地址而不是绝对地址。

got和plt

现代操作系统不允许修改代码段,只能修改数据段,所以需要把动态地址存在代码段以外的专门的地方,GOT表与PLT表应运而生。
以scanf为例,其代码从动态库中加载。
跳转顺序:call scanf —> scanf的plt表 —>scanf的got表
Procedure Linkage Table,Global Offset Table。
got表中存放scanf的真实位置。
在函数第一次调用时,其地址才通过连接器动态解析并加载到.got.plt中,之后got表中就存了真实位置。这叫延时加载。

静态编译的程序没有这两个段。在pwn中开启debug信息会有这样的提示:xxx is statically linked, skipping GOT/PLT symbols。此时无法从plt中获取库函数地址,直接去text段找就行。

动态分析

常用手法包括跟踪程序变量和插桩。
按分析者和被分析者的关系,可分为两大类方法。

  • 分析引擎和被分析软件共享内存空间
  • 在隔离环境中运行并记录执行迹

符号执行

指定find和avoid,获得到达目标位置的约束,求解约束获得输入的具体值。
angr非常值得研究,我认为其通用性恐怕超过时间盲注。

插桩

主要分为二进制插桩和源码插桩。
源码插桩之后需要重新编译链接。
二进制插桩则可分为静态和动态,前者直接编辑原来的二进制文件,后者则不修改。
CTF中使用较多的是二进制动态插桩。如果题目混淆了控制流,可以利用插桩记录执行过的基本块。另一个思路是,flag接近正确时会发生更多的运算,所以可以通过统计执行次数来逼近正确flag。

值得留意的汇编指令

x86 CPU上的NOP指令实质上是XCHG EAX, EAX,机器码0x90。

INT3,机器码CC。栈一般是用0xcc初始化,而堆一般使用0xcd初始化。而0xcc对应汉字编码为烫,int3指令机器码也为0xcc。而这并非偶然,当发生指针栈溢出时eip会指向初始化时填充的0xcc从而引发int3断点异常,使程序中断。在这里插入图片描述

xor eax,eax,机器码31 C0。
RDTSC,用于计时。
rol,循环左移。
ret相当于pop eip,call相当于push eip+4和jmp。
enter相当于push ebp、mov ebp,esp和sub esp,leave相当于mov esp,ebp和pop ebp。
jmp,E9(16), EA(32), EB(8)。
rep movsd,si移动到di。
setz,setnz,将zf的值放进指定的通用寄存器里。
下面这个是字典序的机器码key-指令value查询,正好用于逆向。
https://www.cnblogs.com/marklove/p/15311785.html
浮点数相关指令可以参考:
https://www.felixcloutier.com/x86/vpcmpb:vpcmpub

repne scasb,检查rdi所指字符串的长度。长度会放在ecx。ecx的初始值如果为全F,则对ecx取反后再减一就是字符个数。

bswap,字节序取反。

逆向中的SEH

structured exception handling。
个人理解,回调函数是一个作为参数的函数。上游交给下游一个任务,同时给下游一个解决方案(也就是回调函数)。下游在完成这个任务的时候,会使用这个解决方案。知乎上的一个例子是,客人提前告知酒店要提供何种叫醒服务。
线程出错的时候,操作系统会执行用户定义的回调函数。
一个值得注意的地方是,ida中,在汇编视图下,函数的开头会有except_handler的标记,而在伪代码视图中则没有。如果在用户函数附近看见了这个标记,多半是有SEH的考点。
SEH的套路:

  • 程序故意创造异常(比如xor eax,eax;jmp eax),然后将真正的处理逻辑放在SEH里,以实现关键代码隐藏。因为SEH是没有上游调用的,所以反汇编看不见相应函数。

主要分为压缩壳和加密壳。前者如UPX,后者如ASProtect。
虚拟机壳也可独做一类,如VMProtect。
CTF中一般没有加密壳,原因是难度过大。
UPXShell是UPX自带的脱壳,但加壳时的过程可能遭修改,所以直接用可能不行,需要动态脱壳。
壳程序与原来的代码一般在不同的区段,相隔较远。
壳会在开始的时候保护现场,结束的时候恢复现场。
OEP,程序入口点,程序最开始执行的地方。
IAT,导入地址表,描述的是导入信息函数地址,在文件中是一个RVA数组,在内存中是一个函数地址数组,因此dump之后需要将函数地址数组转为RVA。不论是压缩壳还是加密壳,在脱壳过程中都需要修复IAT。

漏洞的生命周期

  • 挖掘。二进制多用fuzz,web漏洞因人机交互多为手工挖掘。静态分析(抽象解释,符号执行,污点分析,特征分类),动态分析(代码流,数据流),机器学习方法(克隆检测方法可以用于漏洞挖掘)。有大量的商用漏洞扫描工具(比如web的OpenVAS,fuzz的AFL)。
  • 分析,复现别人发布的POC(proof of concept),补丁对比。
  • 利用
    从crash中找漏洞,从漏洞中找能利用的,然后利用。

调用约定和返回值

32位四种约定,cdecl(cdecl是c declaration的缩写,见于x86 c代码),stdcall(见于windows api),thiscall,fastcall
都是从右往左依次入栈。
区别在于调用者清理/被调用者清理栈,参数是否放入寄存器。
在这里插入图片描述

64位两种约定,x86-64(windows)和systemV64(linux, macOS)。前者使用四个寄存器,后者使用六个寄存器。前者使用顺序是rcx,rdx,r8,r9。浮点用xmm。后者前 6 个参数是从左至右依次存放于 RDI,RSI,RDX,RCX,R8,R9。

return语句的变量会放在eax,如果存不下,高位放在edx。

call analysis failed

在IDA中,如果出现call analysis failed,可能是因为调用约定识别错了,导致栈不平衡。可以尝试修改函数的原型声明。
我个人在做题过程中遇到了非常邪门的情况,f5反编译报call analysis failed,正准备解决,忽然发现自己好了。我的操作是,点进了报错的函数,再退出来。

栈帧,函数原语和sp analysis failed

局部变量
老EBP
返回地址
参数

所以说,参数是ebp+x,局部变量是ebp-x,返回地址是ebp+4。
注意,未必使用ebp来寻址局部变量,优化后用esp来寻址局部变量。IDA同样支持后者,但有可能会出现sp analysis failed,此时需要用户来检查IDA对ESP的推导过程,粗暴的解决方法是alt+k在报错的前一个位置改成报错位置的值。
call xxx相当于push eip+4和jmp xxx,ret相当于pop eip。
另外,可以用push reg和pop reg来实现对esp的加减,指令长度更短。如果没在函数调用前后看见sub esp可能是这种情况。

stackframe is too big

本来可以tab,去掉花指令、改函数名、动调之后忽然不让tab了,报stackframe is too big。
报错位置是函数一开始。发现函数一开始的数据声明有个地方很怪,改成data再改成code之后,就可以正常tab了。可能是有什么bug吧。

SHT table size or offset is invalid

SHT是section header table的缩写。从内存中dump出来的SO没有section header table,需要手动修复。

如何找main函数

在这里插入图片描述

库函数识别

除了更新函数签名库,还有一些其它方法来识别静态库函数:

  • 看字符串。静态库的报错信息常常也在二进制文件中,用IDA可以看见。
  • 手工比对。具体做法是,搞清楚具体使用的库,然后获得一份同样使用该库的静态编译二进制文件,然后用BinDiff(可作为IDA插件)这样的工具来比对。如果相似度很高,可以推测是同一个函数。

保护与混淆

针对静态分析的花指令总结如下:

  • 相互抵消,比如push-add-nop-sub-pop
  • 在指令之间插入脏字节,比如E8是call的首字节。会有反复横跳的jmp来跳过脏字节。或者一个不可能运行的条件跳转跳到E8数据上,导致数据被识别为代码。
  • 指令替换,比如call和push-ret等效,jmp改为call-add esp 4。
  • 代码自修改,其实压缩壳也算是一种代码自修改。一个特征是在IDA中,发现有数据被转为函数指针。可以dump内存中真正的代码然后IDA。也可以尝试直接阅读自修改的过程。
  • 加密,主要是以VMProtect为代表的加密壳。先获取指令集(正道是完整获取,邪道是打log),然后写反汇编代码(本质是字符串处理函数)将字节码转为指令,然后整理成C语言(此时的结果很长,很多goto,难以阅读),再用C编译器重新编译(编译优化会大大缩短代码),然后IDA。
  • 控制流混淆,如ollvm,整个程序被扁平化为一个巨大的switch语句块。永真跳转我想也属于此。angr可以通解此类问题。
  • 代码藏匿,这个名字不太严谨,自己起的。比如把代码藏在.rodata。藏了的题目的rodata段的起止:4F4000,51DF04。没藏的普通程序的rodata段的起止:4000,4646。

针对动态分析的反调试方法总结如下:

  • 利用windows ProcessEnvironmentBlock的being debugged字段(偏移量002)或NtGlobalFlag字段(偏移量068)判断是否处于调试。windows api类型的反调试都可以通过hook来绕过,自动hook插件如ScyllaHide。
  • 内存校验可以发现软件断点,内存校验代码书中有个例子。一种轻量的绕过是把断点下在函数里面(如函数末尾)。
  • linux下调用ptrace使父进程为唯一调试器。
  • 偶尔会有针对具体调试器的检测,比如窗口名、进程名或是特有特征。
  • 32/64架构切换,本意不是反调试,但windows下很多用户态调试器无法对架构切换后的代码进行调试。winDbg是内核态调试器,可以搞定这种情况。
  • 直接破坏可执行文件结构,让程序无法直接运行。
  • 我的揣测。使用比较小众的方法来编译,使得“可以直接运行,但gdb单步调试会报错”。我在网上看见的两种情况。第一种是,new得到的堆内存溢出使用(new到N个int却使用了N+2个int),使用的时候并不会报错,delete[]的时候GDB或CLR都会检查堆栈溢出,从而发现问题而报错。第二种是,send失败后默认发出SIGPIPE的信号,虽然在程序中有忽略这个信号,但是gdb默认是没有忽略这个信号,所以gdb先于进程发现这个信息就会终止进程。总结这两种情况就是,gdb自己的“过敏”。难怪windows的调试器都会有“忽略指定异常”的功能,想来就是解决这种情况。感觉本意不是反调试,但确实搞心态。

代码反调试的位置总结如下。需要注意的是,可能有两处,甚至多处反调试。

  • 主动抛异常,在SEH中做反调试操作。可以配置调试器来忽视这种异常,确保由程序本身处理该异常。
  • TLS线程本地存储,是解决多个线程同时访问全局变量的机制,TLS回调函数(函数名常常为tls callback)将先于entrypoint调用,有时会将反调试代码放在这里。
  • main函数开头。

windows的一些碎片知识

dword,32位无符号整数。
windows三个主要子系统,kernel(进程/线程、内存、文件访问),user(鼠标键盘、窗口菜单),gdi(显示器、打印机)。
MessageBoxA的A指的是ANSI,与之并列的是W,widechar,宽字节,两字节一个字符,也就是unicode。
hwnd,wnd疑似window的缩写,hwnd表示窗口句柄。
lp,long pointer,长指针。LPCTSTR,c表示constant,T表示按宏适配A或W。
wcs前缀的函数用来处理宽字节字符串。
WOW64,windows-on-windows 64-bit,是64位系统的子系统,确保32位程序能够运行。所以说,system32中的是64位文件,wow64中的是32位文件。
HMODULE表示模块句柄。代表应用程序载入的模块,win32系统下通常是被载入模块的线性地址。

网友说,调用Windows提供的API后编译器都会安排一段__chkesp,另外在"直接调用地址"(本人注,指将数据类型转换为函数指针后进行调用)返回后,也会被插入这段代码。

windows钩子

Windows 窗口程序是靠消息驱动的,一切过程皆是消息,原本的消息传递过程是:系统接收到消息-将消息放入系统消息队列-将消息放入线程消息队列-线程中处理该消息,而钩子函数SetWindowsHookEx的作用就是拦截消息,用自定义的函数处理消息。

HHOOK WINAPI SetWindowsHookEx(
int idHook,
HOOKPROC lpfn,
HINSTANCE hMod,
DWORD dwThreadId);
/*
idHook:钩子的类型,即它处理的消息类型
lpfn:指向dll中的函数或者指向当前进程中的一段代码
hMod:dll的句柄
dwThreadId:线程ID,当此参数为0时表示设置全局钩子
*/

idHook的各种取值可以查windows文档,比如键盘钩子的id为13。
lpfn(int code, WPARAM wParam, LPARAM lParam),对于键盘钩子来说code的取值较少(记得只有0和3,0是action,3是noleave,noleave表示消息不离开队列),值得注意的是code小于0会直接交给下一个钩子。每个键盘按键对应的号码可以在windows doc查到,比如题目中遇到的:

v4 = *(_DWORD *)lParam;
if ( v4 == 'W' || v4 == 'S' || v4 == 'A' || v4 == 'D' ) {
	another_func();
}

表示每按下wasd四个键,都会调用一次another_func。
键盘钩子函数可以避免scanf一类的函数的调用,影响数据流的分析。不知道还有没有其它深意。

Debugbreak()

应用程序执行到DebugBreak()函数后,打开Debug(调试)窗口,并在其中显示当前上下文信息。从开发的角度看,可以理解为在感兴趣的位置加了一句print。
本质上,debugbreak()的核心代码就是int3。
执行到此处时,正确的应对是让程序自行处理。int3的流程中自己会检查是否存在debugger,建议单步跟进去动调修改eax,而不是patch掉整个debugbreak过程,否则会影响程序逻辑(因为重要的代码就在debugbreak过程中)。
debugbreak可以与SEH考点结合,用于代码隐藏。

__attribute__((constructor))

__attribute__((deconstructor))原理类似,但出于出题考虑一般看ctor。
__attribute__((constructor))修饰的函数可以在main之前执行,用于隐藏代码。
可以在ida中ctrl s看init array段,能看见这类函数的地址。
可以gcc编译以下示例代码后查看。

#include <stdio.h>
#include <stdlib.h>

static void before(void) __attribute__((constructor));
static void after(void) __attribute__((destructor));

static void before(){
    printf("before main\n");
}

static void after(void){
    printf("after main\n");
}

int main(){
    printf("main\n");
    return 0;
}

virtualProtect和mprotect

BOOL VirtualProtect(
  LPVOID lpAddress, //需要修改的起始地址
  SIZE_T dwSize, //长度
  DWORD  flNewProtect, //新的权限
  PDWORD lpflOldProtect //看了但不太懂
);

通常text段中只能拥有读/写中的一种,调用这个API能调整text段的权限。比如说,允许运行过程中对代码段进行解密。这是一个windows API。0x4表示读写,0x40表示读写执行。

在linux中mprotect有类似的作用,不过mprotect没有第四个参数。
PROT_READ:可写,值为 1
PROT_WRITE:可读, 值为 2
PROT_EXEC:可执行,值为 4
PROT_NONE:不允许访问,值为 0

函数名修饰与UnDecorateSymbolName

c++在编译代码时,会对函数名进行变换(因为函数可以重载,光靠函数名无法区分调用的函数,所以需要添加返回值类型和参数顺序及类型,所以需要“函数签名”),称为C++ decorated name(特征是以问号开头),undname.exe的作用就是为我们还原函数签名。当我们release一个程序后,出现崩溃时,我们只能从map中分析是哪一个函数出的问题,有了undname.exe就可以迅速的找到函数了。
这张图展示了undname的输入和输出(本质是这个API的输入和输出)。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

工具使用

IDA使用

临时设置点option,全局设置改ida.cfg。

  • 空格进行流图和代码的切换
  • 用户可以修正IDA给的数据类型和函数类型
  • 使用G能跳转到指定位置,ctrl+s能跳到指定区段
  • File菜单下可以做python相关操作
  • view菜单下的字符串窗口非常常用(shift f12)
  • F5生成伪代码
  • n,修改标识符
  • 可以切换数据显示格式,比如字符/十六进制/十进制。注意将十六进制转为字符串的时候,有字节序的问题,需要将字符串倒置。
  • 如果遇到连续的数据定义,改为数组。y,修改c语言变量类型。a,将一个个的byte转为字符串,方便复制(最好的复制方法是右键然后jump in a hex window)。整数数组可以edit-array。结构体和枚举题目里很少遇到,详见《加密与解密》3.3。
  • 花指令的题目,c转为代码,d转为数据。如果f5提示你要在函数内操作,说明当前代码没有识别为函数,需要在函数起始位置edit-function-createfunction。另一种情况是F5不报错、没反应,option general把stack pointer打开,如果左侧没有显示栈偏移,说明当前所在的位置没有被识别为函数,不能F5。这时大概有两个思路,1-在没有栈偏移的范围内寻找红色字体,解决错误;2-直接往上翻,直到显示栈偏移,在可识别和不可识别的交界处创建函数,这时候会在output window报错,会暴露问题位置。
  • 看伪码的时候,选中多行,右键,可以折叠。

IDA伪码复制出来的注意事项

IDA目录下的plugins目录下,有一个defs.h,有ROL、byteN等操作。挺全的,能减少伪码在clion中的修改。
LOWORD()得到一个32bit数的低16bit
HIWORD()得到一个32bit数的高16bit
LOBYTE()得到一个16bit数最低(最右边)那个字节
HIBYTE()得到一个16bit数最高(最左边)那个字节

z3_solver解约束

支持等式,不等式。支持整数,有理数。如果要用位运算,声明BitVecs。需要注意的是,打印顺序未必是声明顺序。
pip install z3_solver

from z3 import *

a, s, d = Ints('a s d')
# a, b, c, d = BitVecs('a b c d', 64)
#X = IntVector('x', 5)
#Y = RealVector('y', 5)
#P = BoolVector('p', 5)
x = Solver()
x.add(a-d == 18)
x.add(a+s == 12)
x.add(s-d == 20)
check = x.check()
print(check)
model = x.model()
print(model)
#for var in model:
#	print('var',var,'=')
#    print(hex(model[var].as_long()))
# 可以求出所有解
#while x.check() == sat:
#  print (x.model())
#  x.add(Or(a != s.model()[a], b != s.model()[b]))

https://ericpony.github.io/z3py-tutorial/guide-examples.htm

第一个断点下在哪

CFFexplorer是一个PE编辑器,常用于关闭动态基址(NT header-characteristic-勾选strip relocation info),但其hex editor功能也极其好用,可以按各种形式复制出来,甚至可以直接按C array复制出来。非常爽。不过CFF只能用于windows可执行文件。
linux下可以直接b main。
如果不能b main的话,先让程序处于运行状态,然后ps -aux | grep yourelf,查看进程号。
然后,cat /proc/43865/maps,第一个数字就是基址,加上ida里的地址即可。

linux下aslr的临时关闭如下,不过并不能帮助找到第一个断点的位置。

sudo sysctl -w kernel.randomize_va_space=0

dnSpy

能进行源码编辑,然后重编译。简直就跟自己写得一样。
unity游戏,游戏逻辑都在Managed/Assembly-CSharp.dll内。

x64dbg的三种断点

硬件断点的原理是将断点地址存放在调试寄存器DR0-DR3中,所以只能同时存在四个。
内存断点的原理是将指定内存页设PAGE_NOACCESS,触发内存访问异常。
软件断点的原理是将内存中指定指令修改为INT 3。
更详细的论述可以看《加密与解密》第二章第一节。

x64dbg堆栈平衡法UPX/nspack/aspack脱壳

上来直接F9,会从DLL模块跳到exe模块。
跳转之后的第一条,就是pushad。F8,然后栈顶下访问断点。
F9(可能需要两次F9),来到恢复现场的popad,然后继续F8,择机F4运行到光标位置。
popad之后的第一次跳转很有可能就是原始OEP。如AE7513跳转到AE155D。
用scylla插件dump,然后fix dump。
脱壳之后就可以在IDA中分析了,但由于ASLR不能运行。
CFF explorer可以修改Nt header,让程序不ASLR,从而可以运行。
以上步骤在有的题目中曾经完全成功过,也曾在其他题目中在最后一步失败,原因暂不明。

nspack,北斗压缩壳,流程基本一致。aspack也是上面这个流程。
有upx的elf我只遇到过一次,用upx -d pack.exe -o unpack.exe解的。

从解题角度考虑,一般会先尝试upx -d和upxfix等修复工具。如果考点就是在魔改upx上的话,那么文件头、段名、特征码等等特征都会被有意修改,导致查壳工具查不出来,也无法用upx -d和upxfix。不仅如此,堆栈平衡法中关键的pushad也可以被换成nop,解决方法是在刚进exe模块时在栈顶下硬件访问断点,后续步骤与上面无异。

OD

SFX自动脱壳

虽然不知道原理是什么,不过我试了确实有用。设置为“字节方式跟踪”,能解aspack,其它压缩壳暂时没试过。刚跑出来的时候会分不清数据和代码,右键Analysis code就可以了。
在这里插入图片描述

API调用查看

可以查找当前模块中的所有API调用。然后搜索感兴趣的API调用。

黑话

HE是指硬件断点,可以通过执行表达式he addr来下断点。
BP,应该跟点一下左侧红点是一样的效果。
od可以通过上面两个指令直接对指定windows api下断点。

run trace和hit trace

据说可以记录执行过的指令、判断哪一部分执行了哪一部分没执行。
更详细的论述可以看《加密与解密》第二章第一节。
x64dbg中,来到“跟踪”选项卡,右键即可设置输出文件,开始trace。
配合断点使用,不能f9,长按f8即可。遇到断点的话,左侧的地址会显示黑色而非灰色,底栏也会提醒。

直接修改EIP

感觉这是一个非常有用的功能。

dll/so的脱壳与动态调试

Dll脱壳的最关键的步骤在于使用LordPE修改其Dll的标志,重命名后缀exe,然后可以用OD打开。dump之后改回来即可。但我注意到,x64dbg和OD其实都能打开dll,所以这个条目的必要性有待考证。
python的ctypes可以调用dll中的函数。如此看来,dll的调试比exe更简单,因为可以直接进行函数级的调试。

import ctypes

def baopo(i):
    dll = ctypes.cdll.LoadLibrary("Interface.dll") #dll文件目录
    print(i)
    dll.GameObject(i) #调用GameObject()这个函数,输入参数为i。如果不止一个参数或者参数范围很大,可能得另想办法

for i in range(0,100):#从0~99开始爆破
    baopo(i)

import ctypes
so = ctypes.CDLL('./sum.so')
print "so.sum(50) = %d" % so.sum(50)
# 1275

ida远程调试elf

dbgsrv目录下找linux server64,在虚拟机中运行。ida这边选remote linux debugger。
process option设置好host、port。如果积极拒绝就换桥接模式。
option里记得把该勾的勾上,至少把入口点断勾上。

GDB

linux很少有需要crack的软件,因为gdb不如OD、x64dbg好用而生气的时候要多多理解。网上对GDB的使用,一般是正经开发时的调试。一般的过程如下:

gdb
file 文件名
start 开始调试,仅限有main的情况。如果无main,只能b *0x入口点地址。如果有随机化,b __libc_start_main。
r 运行参数,即可带参数运行。
n步过,s步入。与其它调试器一样,箭头所指表示即将执行。enter能重复上一次的命令。
x/16xb addr,第一个/16表示展示数量,第二个x表示十六进制,第三个表示db。x/16c addr,即可以字符形式查看。
finish,运行到当前函数返回。
如果想看更多汇编,display /20i $pc。可以在pc基础上减。
info b可查看断点,del 断点编号可删除断点。
gdb也有硬件断点,hbreak。还有内存断点(读写均可断),watch。还可以catch syscall。catch syscall write非常好用,但会停在syscall触发的下一句话。
在0x400522打断点:b *0x400522

.net

.NET是框架,运行于操作系统之上,又独立于操作系统,可以理解为一套虚拟机,实现跨平台(不同的操作系统都行)、跨语言(C#、C++、VB都行,最终都会被翻译为MSIL微软中间语言。
IL和元数据共同构成了.net程序的基本要素。《加密与解密》第24章给了一个demo来说明IL语言“基于栈”的特点,并给了常用指令表。PEBrowseDbg是IL调试器。
强名称是.net的代码完整性保护机制。有名为strong name remove的工具,逻辑类似安卓的重打包、重签名。
无任何处理的情况下,.NET近乎开源。Visual Studio会捆绑安装Dotfuscator。名称混淆的基本原理是改变#String流中的字符串。

解题

解题流程

题目一般是给一个可执行文件。

  • 如果是exe的话,可以尝试在cmd中运行,这样就不会一闪而过了。
  • 先die查壳,然后看段名称有无异常(比如最经典的upx,或者自定义名称),然后放进IDA。
  • 判断语言,然后使用对应的辅助脚本/分析工具。
语言做法
c++类内函数加大了分析难度。ida_medigate插件利用c++的RTTI,能让虚函数调用更易读。
from ida_medigate.rtti_parser import GccRTTIParser
GccRTTIParser.init_parser() GccRTTIParser.build_all()
rust题目给exe,IDA打开后会标记main,用户实际写的main函数的地址会包含于其中
go通过观察字符串可以判断出go语言,IDA中运行go parser
unity/c#de4dot脱壳,dnSpy打开assembly-csharp.dll,如果没找到关键就看有没有dllimport
import的dll可能会在data/plugins下
如果有需要可以用菜鸟的在线c#
pythonPyinstaller这个库本⾝的打包原理⼤概就是先将py编译成pyc,然后部分压缩成pyz。
应对pyinstaller,python pyinstxtractor.py your.exe,将baselibrary中pyc第一行插入到原文件头后改pyc
应对pyc,pip install uncompyle,uncompyle6 attachment.pyc > attachment.py
Javaclass文件可以用Intellij idea直接打开
jar用Java decompiler,或者用IDEA创建一个新的java项目,然后创建一个lib文件夹将jar包放入,右键选择lib文件夹的“Add as Library…”,将lib文件夹添加进项目依赖。成功添加后可以看到Jar包中反编译后的源代码。
perlperl也是脚本语言,也可以打包为exe,只能动态调试看自解压源码。
在第一句用户代码运行之前会自解压,动调时搜索script下断点然后F8即可
brainfuckbf是brainfuck语言反编译器插件
pascal函数会很多,主函数应该在start附近,并且长度比较长
delphi专门的工具Dede
mfcresource hacker和xspy
  • 字符串view,一般会获得一些提示。如果什么都没找到,先试一下动态调试。如果题目没有输入,可能是读取文件。如果无法调试,或者连入手点都没找到,可能是因为静态的时候没显示全,这时候只能看汇编了。
  • 先静态,去除反调试,然后编辑PE关闭ASLR,再动态分析,直接到感兴趣的地方下断点。
  • 粉色的是库函数,一般不用看。重点关注IDA中用户写的函数,看不懂的,可以对照动态分析再看,如果题目做不到想执行哪就执行哪,可以把子函数的伪码直接复制出来执行。我推荐C语言,只需要很少的修改,ida export data的时候也比较方便。python则需要改代码和手动截断(移位运算的时候)。
  • 只用看text段的函数。如果无法判断是不是重要的函数,或是根本没找到重要函数,看看xref to,一团乱麻的反而是不重要的。
  • 如果不能快速解出,至少先确认flag的长度,以及输入的字符集,比如以下输入处理函数暗示输入应当是0-9和a-f:
def func(byte):
    if byte < 97:
        return byte - 48
    else:
        return byte - 87
  • 用python写C的时候,可以用ctypes中的C语言数据类型,可以避免很多麻烦。

题目成百上千,最传统的题型当属encrypt和check函数。check函数最好是从后往前看。encrypt优先猜常见算法。

隐藏提示性字符串的手法总结

有的题不按套路出牌,根本就没有用户交互;
有的题做GUI,用图片等形式提供用户交互;
有的题会先对提示字符串做解密再显示;
有的题混淆引用字符串附近的代码(不影响xref)。
有的题给output,需要你还原当时的输入,这种需要你手工比对你的输出和预期输出。这类题不需要依赖提示性字符串。

特殊用户交互手法总结

读文件,会使用getfile这样的api。
读env,会使用getenv这样的api。环境变量不一定是可见字符。export CTF=“$(printf “\x0a\xda\xf2\x4f”)”。使用这样的交互,可能就是因为想输入不可见字符。

c++解题心得

题目中往往有大量的v36[4]和(*this+4)-4,语义难以理解。一定要足够抽象,看不懂的就当不存在。
如果静态编译导致函数数量很大,且函数名为可读的修饰名,可以使用filter功能找关键函数(如下第一张图,如果不用filter功能的话,很容易在第二张图这样的东西里迷失):
在这里插入图片描述
在这里插入图片描述

lambda函数,以及带operator的,是关键函数。以下图为例,虽然代码行数多、字数多,但真正要看的其实不多。
下图中关键函数只有四个,第三行的lambda,depart,depart下面的lambda,和再下一行的lambda。
在这里插入图片描述

.net解题心得

dnSpy最强的功能应该就是源码编辑了。这使得.net的逆向题做起来和js一样方便。想看什么变量,直接print。

python pyc字节码补充说明

下面这篇文章是认真讨论了“肉眼阅读字节码”的问题,常见的范式如循环、dict的使用等。
https://www.cnblogs.com/yinguohai/p/11158492.html
uncompyle可以应付各个版本的完整pyc(python3.10似乎不支持,py3.10可以用在线网站),但如果pyc被文本形式贴出来,可以借助python自带的dis模块,或者肉眼看。
说实话,最大的难点恐怕是“发现这是python字节码”。此处我贴一个python字节码的demo供后续参考。

  9         113 LOAD_NAME                2 (raw_input)
            116 LOAD_CONST              29 ('please input your flag:')
            119 CALL_FUNCTION            1
            122 STORE_NAME               3 (flag)

题目所给可执行文件无法运行

曾在正赛中遇到动态调试打开闪退的问题,当时直接放弃了题目,误以为是有高深的反调试。
后来又遇到了这种情况,闪退其实是因为无法正常运行,比如缺dll之类的。
die能看见编译器的具体版本。考虑到网上的解决方案乱七八糟而且未必可逆,我觉得一个不错的解决方案是直接安装对应编译器。我安装VS2013后确实解决了打不开2013编译的题目程序的问题。一个月后我还老老实实装了vs2017。

自加密/自修改

感觉难点在于发现题目是自加密。在注意到“将数据转为函数指针后执行”这一操作后,可以下个内存断点。

如果动调的时候出现illegal instruction,其中一种可能就是自修改并没有执行,或者执行了多次(比如说,在动调一次之后,我在解密代码处create function,ida记住了实际执行的代码,后来再动调发现报privilege instruction)。如果实在不行,可以退出ida并放弃先前数据库,像初次拿到题目一样重新分析。

动态调试,可以用gdb然后x/,复制出来解密后的代码。这适用于就地加密的简单情况。如果不知道复制出来的代码往哪粘贴怎么办?在gdb中,dump binary memory my_binary_file.bin 0x22fd8a 0x22fd8a+450,粘贴到新的二进制文件里。
word里,用^p替换换行符,用^w替换连续空格。
然后调用下面的脚本,大范围patch。

a = """55 48 89"""
a=a.split(" ")
start=0x402219
for i in range(len(a)):
    patch_byte(start+i, int(a[i],16))
print('done', time.time())
from idaapi import get_bytes
start=0x401000
end=0x432000
for i in range(start, end+1):
    patch_byte(i, int.from_bytes(get_bytes(i, 1), byteorder='little')^0x23)
print('done', time.time())

AES和SM4每个分组16个字节。DES则是每个分组8字节。Findcrypt插件可以解决这类问题,edit-plugins。如果是自加密的话,需要找到IV(16字节)和key(16/24/32字节)。工作模式和填充可能只能多试几遍了。

脱壳实践

exe用动态调试器如od的trace,elf用ida remote linux debugger的trace。
在trace的结果中,除了关注pushad popad,还应当关注jmp(大跳,E9而非EB。x64中直接右键-搜索-匹配特征-E9,ida中search-text-jmp)。
ida在动态调试页面ctrl s可以看见与静态分析时不同的段,x32/x64则是在“内存布局”标签页查看。
dump内存的脚本:

import idaapi
data = idaapi.dbg_read_memory(start_address, data_length)
fp = open('d:\\dump', 'wb')
fp.write(data)
fp.close()

angr与控制流混淆的总结

较为深入详细的angr教程似乎只看见一个,https://bluesadi.github.io/0x401RevTrain-Tools/angr。
目前见过两类控制流混淆,一种是虚拟机,另一种是扁平化。
虚拟机题目,会逐位读取存储的指令序列,然后switch来执行。关键是要找到内存中的指令序列,然后逐句翻译出实际执行的代码。
扁平化题目,如果代码长度不用翻页,可以判断各switch的作用,对各case进行粗分类。多半能猜出数据流和具体处理手段。代码很长(经典症状就是一大堆while 1嵌套)就得用后面的脚本预处理了。
angr的本质是树遍历。现代的符号执行引擎基本上都是动态符号执行(或者叫混合执行)。以angr为例,默认情况下,只有从标准输入流中读取的数据会被符号化,其他数据都是具有实际值的。在angr中,无论是具体值还是符号量都有相同的类型——claripy.ast.bv.BV,也就是BitVector。

>>> claripy.BVV(666, 32)        # 创建一个32位的有具体值的BV,666=0x29a
<BV32 0x29a>
>>> claripy.BVS('sym_var', 32)  # 创建一个32位的符号值BV
<BV32 sym_var_97_32>
import angr
import claripy

proj = angr.Project('example-1')  # 载入二进制文件
sym_flag = claripy.BVS('flag', 100 * 8)     # BV的大小得设大一点,不然跑不出来,原因未知
state = proj.factory.entry_state(stdin=sym_flag)  # 创建起始状态
simgr = proj.factory.simulation_manager(state)  # 创建符号执行引擎
# simgr.step() # 单步执行
simgr.explore(find=0x40138F, avoid=0x80485A8) # 执行到find,避免avoid。加avoid会快很多。如果不止一个地址,可以把一个入参为state的函数传给find和avoid。
found = simgr.found[0]
# print(found.posix.dumps(0))  # 求能走出这条路径的标准输入
solver = simgr.found[0].solver
solver.add(simgr.found[0].regs.eax == 0)  # 可以在路径求解约束中添加自己的约束
print(solver.eval(sym_flag, cast_to=bytes))

如果是gui,我想就难办了。如果想直接find correct,会很慢。实际解题的时候,一般是deflat和去除冗余代码块做预处理,然后才是经典解题流程。deflat 800行到100行需要几分钟,会显示实时进度。

# deflat.py来自https://github.com/cq674350529/deflat,需要angr环境
python deflat.py -f "C:\Users\ysnb\Downloads\attachment (4)" --addr 0x400620
# addr是需要deflat的函数地址

实际使用的过程中发现,deflat的结果可能会有错,还原的代码出现了死循环,并且没有与“right”用户交互语句相关的代码了。或者是所有的代码变成了nop。

go总结

运行go_parser之后,重点关注main开头的函数,可能在函数列表最下面。
go语言中字符串不以杠0结尾,而是以地址-长度的形式存储在rodata。ida f12看不见。str[1]就是str的长度,*str就是语义str。
fmt_print的第三个参数就是输出,点一层能看见地址和长度,点两层能看见字符串内容。
fmt_scan以空格分隔读入,fmt_scanf只读入符合格式的输入,fmt_scanln读入行。
fmt_scan的第三个参数就是输入。
总结一下就是,fmt的前两个参数不用管。

morestack_noctxt用于栈扩大,不用管。
go的数组长度不能改变,为了灵活,常常使用slice。

类型总结

目前常见的题目,一般是内置答案和用户输入双向奔赴,然后字符串比对。
因此一个基本的手法是,给用户输入的内存下断点,以追踪实际处理用户输入的代码。
如果重点是自定义的字符串处理函数,思维上可以采用函数中心视角。

为了能在一道题中设置多个考点,往往采用多段式的解谜。需要注意“continue”等字样。最终的答案可能是每关的password用下划线连接。

如果找不到关键代码,也可以先找关键数据。ida中用ctrl s可以跳转到rodata,关键的内容往往在rodata开头。

感悟

做rev题不要纠结具体逻辑。那是随题目而变的细枝末节。能穷举就穷举。做题时没有逆向工程,只有正向工程。写逆过程就是上了出题人的当了。尽量少用python,多复制ida里的c代码。

从做题角度来说,上面的确实是不错的方法,但伪代码还是要大致看懂的。我曾错误判断flag的长度,就是因为没有细看伪代码,本来是两位两位进行处理,误以为是一位一位处理,自然也就无法做对了。

一般来说一个子函数只做一件事,但未必。比如说,想进行大小写转换然后b64编码,自然的写法是在主函数中先后调用两个子函数。但其实也可以在b64编码函数的开头加一个大小写转换,主函数中只调用b64编码函数,也是一样的效果。所以如果感觉漏了什么关键函数,就检查一下是否存在这种一个子函数做了好几件事的情况。

如果编辑代码有困难,可以试试编辑堆栈/寄存器。难得感受到一次动态调试的魅力。

异构的题反而不难。

函数特别少的,比如三个的,直接angr。亲测windows exe也可以这样做。

爆破法

很不rev的一种做法是写脚本反复调用可执行文件,然后判断输出里是否包含特定文字,这种方法把整个可执行文件当黑盒,啥都不管。
更多时候需要读代码,提炼出判断条件。关键是看位与位之间是否有耦合关系,以判断一次穷举的位数,最理想的情况是每次穷举一位,复杂一点的可能需要一次穷举2位或者4位。
如果不得不传入整个字符串而不是一个字符的话,我的经验是从前往后穷举(做buu soullike的时候非常奇怪,感觉这是玄学经验)。详细来说,先初始化为0,穷举第一位,此时相当于传入长度为1的字符串(因为第二位以及后面的都是0)。得到第一位后,穷举第二位之前的初始化时把第一位加上。同理,穷举第三位前的初始化应该把字符串前两位赋值。这就是从前往后穷举的含义。

哈希算法的windows api

CryptCreateHash
md5 8003
sha1 8004

常用ascii

flag{}66 6c 61 67 7b 7d102 108 97 103 123 125

mips逆向

从题目名字或者ida打开时能知道是mips。mips的题有时ida会报“is not decompilable”的错,jeb官网的demo版可以分析mips和mips64(不能用剪贴板,每次分析有限时,不过做题应该够用)。
在这里插入图片描述
file能查看更多信息。关键是LSB(qemu-mipsel)和MSB(qemu-mips)、动态链接还是静态链接。
在这里插入图片描述
qemu的安装:apt-get install qemu
qemu的基本使用: qemu-mips -g 666 mipsfile
-g指定remote gdb连接的端口,-L指定动态链接库。

解mips的题目时遇到了ida cannot decompile的情况。经查询,安装了ida的mips插件。leafblower和mipslocalvars。
想动调得配qemu。QEMU User Mode可以直接跑异构程序,System Mode可以自定义内核和虚拟磁盘。
例题:
https://www.cnblogs.com/L1B0/p/8987288.html

固件逆向

大致记录一下操作步骤。

  • binwalk -e
  • binwalk -A
  • firmware-mod-kit可以解压cpio、cramfs、squashfs格式的文件系统。前置依赖和下载地址见https://blog.csdn.net/ldwj2016/article/details/80712566。
  • ghidra相较IDA更适合固件分析,至少拖进来就能看见函数。

文件对比

linux中,一般用cmp命令比较二进制文件,diff命令比较文本文件。

无能狂怒

逆向的坏处在于,可以在无自动混淆、无任何反调试保护、不修改编译后产生的文件、较少的代码行数/用户写的函数的情况下,设计一个算法正面锤烂你。开局一个唯一确定解,剩下全靠编。如果不放心题目的合法性,还可以利用出题方与解题方时间不等,提前用穷举法验证。如果我来设计,可以这样做:

  • 设置一个假的长度判断,满足假长度的话就调用一些无用的加密、编码算法。不限制flag长度,而是要求输出最短flag确保唯一性。
  • 添加一些参与了运算但其实没什么用的常数数组
  • 写一些内容一样的函数,就可以将多次调用的函数改为只用一次
  • 把局部变量改成只用一次的全局变量
  • 调库(比如printf)前做一次封装,加一些有实际作用的用户代码
  • 用一些无意义的malloc,将部分中间结果多存一份副本,也可以再对副本做一些无意义的运算
  • 让随机数参与运算但条件永真(比如生成5个0-5、5个6-10的随机数,要求前五个的和小于后五个的和)。
  • 使用奇怪的逻辑,比如把迷宫题的迷宫做一个加密、分开存放,也不使用上下左右的交互,而是多个按键对应同一个操作。
  • 把循环展开。同样的操作做二十次,那就把除了偏移量以外完全相同的代码粘贴二十次。如果发生了编译优化,那就阻止这种优化。
  • 为确保答案唯一,约束数量可以无限多(甚至可以是等效的约束,增加约束数量本意是增加if的嵌套层数),可以超过确定唯一解需要的约束数量。约束数列中前一半元素和后三分之二元素(正常的逻辑应该以整个数列为单位,并且循环之间一般不会部分重叠)的最值/四分之一分位点/加权平均,权值可以magic小数的形式分散在代码里,而不是连续存储。要求向量距离在两个浮点数之间。将第1、5、3、8个字符依次加入一个set,判断有没有重复。
  • 打乱这些约束的代码块的顺序,让最终代码是条件一第一句-条件二第一句-条件三第一句…。
  • 出题人花费大量的时间,设计不重复的静态花指令,安放在不同的函数里,强迫解题者一个一个解。或者故意使用重复的静态花指令引诱解题者使用脚本,使得脚本破坏正常代码。
  • 总结一下就是,让一道题只能用angr来做(故意浪费时空资源,故意破坏源代码可读性),却又不能在24小时内用angr解出(较长的flag位数,位与位之间的强耦合,浮点数)。让人拿到源码都说不清代码做了什么,那逆向必然也做不出来。

ida插件总结

找加密算法常数的,findcrypt(我的ida版本好像预装了)
c++的,ida_medigate
go的,go_parser(啥都不用配,ida里run一个py文件就行)
反混淆的,d810
重命名的,renamer
mips的,leafblower和mipslocalvars。
keypatch(一般是预装的)
函数符号识别,finger,阿里云的,需要联网
另外有个lumina功能,在线查函数的,remote certificate好像要钱。

安装过程可能会涉及到idapythonrc.py,这个文件的示例在ida目录-python-examples-core下,作用是Setting IDAPython’s module search path。
通过idaapi.get_user_idadir()可以获取appdata的位置,然后把idapythonrc.py放进去。
idapythonrc.py中这样写:
import ida_idaapi
idaapi.require(‘ida_medigate’)
将源代码放在ida目录的python目录的Lib\site-packages(上面的步骤不知道是否必要,这一行方案之后就不再报module not found了)。

  • 20
    点赞
  • 86
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值