前言
该文章内容涉及CTF中三种常见文件的动态调试
简介
1.动态调试和静态调试:
静态调试:是一种调试方法,通过自动检查源代码来完成,而无需执行程序。简单来说就是通过肉眼来观察分析代码逻辑。
动态调试:该调试方法是指操作人利用集成环境自带的调试器跟踪软件的运行,来协助检查软件的错误。该方法在CTF中的RE,可以用来验证自己在静态分析时的猜想或者进行一些解密,例如SMC。
2.怎么动态调试呢
一般可以使用ida,ollydbg,x64dbg,x32dbg,这是针对于EXE文件,而APK文件相对来说要复杂些,需要的工具多一些。
3.下面我将演示如何进行调试以及一些小技巧和相关例题
开始
一.EXE文件动态调试和反动态调试的绕过
1.这是一道本人自己出的一道例题,为了演示具体如何动态调试和反动态调试的绕过, 没有加壳,直接用ida64打开,
ida64分析结束,直接F5拿到伪代码,
可以看到有两个反动态调试,同时我们查看fun1函数,
很简单,就是将传入的字符串按其16进制形式输出。但其实这道题是可以进行直接逆向的,但为了我们学习动态调试和反动态调试的绕过,我们选择进行调试最后让程序自行对flag解密输出.
首先,我们在第一个反动态调试的if语句处下断点
然后在ida的上方的菜单栏选择Local Windows debugger
然后点击旁边的小三角形,就可以进行动态调试了,程序就会断在我们下断点的地方,
ida就会显示程序的流程图,我们也正好就根据流程图进行操作,因为流程图比其他的展现形式更加形象,可以清楚知道程序下一次跳转跳到哪里去,同时我们这里需要知道四个动态调试的快捷键
1.F7:步入,就是如果我们按F7,我们就会进到每个函数的内部。
2.F8:步过,会直接执行下一个指令,不会进入函数内部。
3.F9:运行,程序直接运行,知道有输入或者断点的地方。
4.F2:下断点,也可以手动下断点。
接着,我们这里按F8,直到运行到.text:0000000000401688 jz loc_401747这一行,我们可以看到该框图下方的两个箭头,有一个箭头在跳动,意味着如果我们再按一次F8或者F7,就会跟着该箭头所指框图跳转。而这就是反调试,因为如果我们不做任何操作,继续运行程序,就会结束程序,这里我们介绍最简单的一种反调试绕过的方法,修改标志寄存器,因为不管是jz跳转,还是jnz跳转,都是根据标志寄存器的值来进行判断跳转的方向,该标志寄存器的值就是ZF,我们可以在ida的左上角进行查看和修改,
可以看到此时ZF寄存器的值是1,我们双击该值,
然后修改为0,此时我们再去看跳动的箭头变成另一个了,就意味着我们修改成功了,成功绕过第一个反调试。然后我们继续F8步过,然后程序就会断在scanf函数处,需要我们进行输入,程序才能执行下一步,
可以看到终端窗口的提示,说输入1可以给flag。那么其实这里如果我们输入1的话,就还得再绕过一次反调试,如果输入1的话,程序并不会提供flag,会直接结束了,但如果我们输入其他内容,就直接绕过了。那么这里我们就不输入1了,随便输入点什么,然后回车,就成功绕过第二个,继续F8步过,下一个绕过和上述方法一致。如果我们不知道或者不确定现在程序运行到了哪里,又看不懂汇编,我们也可以先按F5,看到伪代码,然后再按TAB键,又可以会到流程图这里。
最后,当我们绕过最后一个反调试后,继续F8步过,在程序快结束的时候,我们打开终端窗口,就可以看到程序输出的flag
拿到flag。
二.EXE文件附加进程动态调试
1.工具:ida8.3 32bit
2.例题:[DASCTF] Strangeprograme
3.开始操作:
拿到附件,先查壳,没有壳,32bit的,那就直接丢到ida64里分析,分析结束,在函数窗口并没有找到main函数,
然后我们按shift+F12查找字符串,看看有没有有用的信息,
可以看到有判断正误的语句,双击,ctrl+x交叉引用,看看哪个函数引用了这些字符串,
可以看到逻辑就是让我们输入flag,然后似乎就直接进行比对了,那么就怀疑是j_memcmp函数有问题,但是双击查看,里面并没有什么有用的信息,那没办法,只能进行动态调试试试看,当我第一次下断点在这个函数这里时,然后进行F7步入查看该函数,可以看到该函数是一个虚函数,里面的逻辑就是依次把我们输入的字符串的每个字符和那个假flag进行相减,最后对结果进行判断输出,仔细分析可以发现如果正确的加密函数是这个的话,那么是不可能有正确答案的。那么就意味着有其他的反调试手段,我们就需要附加进程来调试,就可以进入正确的加密函数,下面演示如何进行附加进程调试。
4.附加进程
首先,先在终端把该程序运行起来,但是我们不输入任何东西,
然后,
打开ida,在j_memcmp函数处下断点,再在ida工具栏中选择Debuger->Attach to process,然后可以选择此时我们的电脑正在运行的线程,找到正在运行的该程序,然后就可以看到
接着,shift+F12,在最上面,找到关键字符串
双击跟进,找到哪个函数引用了它,就可以看到
然后在终端随便输入点什么,回车,接着就是F7步入,进入到正确的j_memcmp函数,
__int64 __cdecl sub_41D250(char *Str)
{
__int64 v1; // rax
__int64 v3; // [esp-8h] [ebp-24Ch]
int j; // [esp+D0h] [ebp-174h]
size_t i; // [esp+F4h] [ebp-150h]
char *v6; // [esp+100h] [ebp-144h]
int v7; // [esp+124h] [ebp-120h] BYREF
int v8; // [esp+128h] [ebp-11Ch]
int v9; // [esp+12Ch] [ebp-118h]
int v10; // [esp+130h] [ebp-114h]
char v11[260]; // [esp+13Ch] [ebp-108h] BYREF
int savedregs; // [esp+244h] [ebp+0h] BYREF
sub_4114D8(&unk_4250F3);
v11[0] = -7;
v11[1] = 77;
v11[2] = 43;
v11[3] = -68;
v11[4] = 19;
v11[5] = -35;
v11[6] = 19;
v11[7] = 98;
v11[8] = -55;
v11[9] = -4;
v11[10] = -1;
v11[11] = -119;
v11[12] = 125;
v11[13] = 79;
v11[14] = -55;
v11[15] = 15;
v11[16] = 99;
v11[17] = 29;
v11[18] = 109;
v11[19] = 82;
v11[20] = 80;
v11[21] = -3;
v11[22] = 65;
v11[23] = -29;
v11[24] = 51;
v11[25] = 118;
v11[26] = 40;
v11[27] = -105;
v11[28] = 56;
v11[29] = 54;
v11[30] = -7;
v11[31] = 107;
v11[32] = -112;
v11[33] = 57;
v11[34] = 20;
v11[35] = -125;
v11[36] = 44;
v11[37] = -30;
v11[38] = 44;
v11[39] = 31;
memset(&v11[40], 0, 216);
v7 = 0;
v8 = 0;
v9 = 0;
v10 = 0;
if ( j_strlen(Str) == 40 )
{
v6 = Str + 4;
v7 = *Str;
v8 = *(Str + 1);
sub_411541(&v7, &unk_422100);
*Str = v7;
*(Str + 1) = v8;
for ( i = 2; i < j_strlen(Str) >> 2; i += 2 )
{
sub_411541(&v7, &unk_422100);
*Str = v7;
*v6 = v8;
*&Str[4 * i] ^= *Str;
*&Str[4 * i + 4] ^= *v6;
}
for ( j = 0; j < 40; ++j )
{
HIDWORD(v1) = j;
if ( Str[j] != v11[j] )
{
LODWORD(v1) = 1;
goto LABEL_12;
}
}
LODWORD(v1) = 0;
}
else
{
LODWORD(v1) = 1;
}
LABEL_12:
v3 = v1;
sub_41130C(&savedregs, &unk_41D5CC);
return v3;
}
分析代码,就是一个魔改的tea加密和异或,可以直接写脚本,这里引用Mini-Venom战队的脚本
#include <stdio.h>
#include <stdint.h>
#include <string.h>
void decrypt(unsigned int *v, unsigned int *k) {
unsigned int v0 = v[0], v1 = v[1], sum = 0;
unsigned int k0 = k[0], k1 = k[1], k2 = k[2], k3 = k[3];
for (int i = 0; i < 16; i++) {
sum -= 0x61C88647;
}
for (int i = 0; i < 16; i++) {
sum += 0x61C88647;
v1 -= ((v0 << 4) + k2) ^ (v0 + sum) ^ ((v0 >> 5) + k3);
v0 -= ((v1 << 4) + k0) ^ (v1 + sum) ^ ((v1 >> 5) + k1);
}
v[0] = v0;
v[1] = v1;
}
int main() {
size_t i;
int v9;
int v10;
char savedregs; // [esp+244h] [ebp+0h] BYREF
unsigned char Str[41] = {
0xF9, 0x4D, 0x2B, 0xBC, 0x13, 0xDD, 0x13, 0x62, 0xC9, 0xFC,
0xFF, 0x89, 0x7D, 0x4F, 0xC9, 0x0F, 0x63, 0x1D, 0x6D, 0x52,
0x50, 0xFD, 0x41, 0xE3, 0x33, 0x76, 0x28, 0x97, 0x38, 0x36,
0xF9, 0x6B, 0x90, 0x39, 0x14, 0x83, 0x2C, 0xE2, 0x2C, 0x1F,
0x00
};
unsigned char key[16] = {
0x78, 0x56, 0x34, 0x12, 0x12, 0x11, 0x10, 0x09,
0x16, 0x15, 0x14, 0x13, 0x18, 0x17, 0x16, 0x15
};
unsigned int *Str1 = (unsigned int *)Str;
for (i = 2; i < 10; i += 2) {
*(unsigned int *)&Str[4 * (10 - i) + 4] ^= Str1[1];
*(unsigned int *)&Str[4 * (10 - i)] ^= Str1[0];
decrypt(Str1, (unsigned int *)key);
}
decrypt(Str1, (unsigned int *)key);
printf("%s\n", Str);
return 0;
}
flag: DASCTF{I4TH0ok_I5S0ooFunny_Isnotit?????}
三.ELF文件动态调试
1.首先我们得知道,.ELF文件是Linux虚拟机的可执行文件,所以我们在动态调试该文件时,用的是远程调试。我们打开ida的下载目录,找到dbgsrv文件,然后找到里面的linux_server和linux_server64文件,安装到我们所使用的虚拟机上。第一个文件是动态调试32位的文件的时候用的,第二个就是64位的。
如果调试64位文件,就在虚拟机中打开终端,输入以下指令(也可以只输入第二个指令)
chmod +x linux_server64
./linux_server64
如果是32位的文件,就修改一下文件名就好。
这里我们还需知道一个指令:ifconfig -a(我使用的是kail)
红色方框里就是我们虚拟机的IP地址,待会在ida里配置需要用到。
2.本次测试使用的是HZNUCTF初赛的re方向的一道赛题:[HZNUCTF2024] 运动的elf
首先,拿到题,题目名称是运动的elf,那么我们就现在kail上运行一下,
看到提示,说明这题是真的如果会动态调试ELF文件,就会很简单。那么接下来先查壳,
可以看到是64位的,没有壳,那就直接丢到ida64里分析,ida分析完,直接F5拿到伪代码,
int __fastcall main(int argc, const char **argv, const char **envp)
{
__int64 v3; // rdx
char *v4; // rax
__int64 v5; // rax
__int64 v7; // rax
char *v8; // rax
int v9; // eax
bool v11; // bl
__int64 v12; // rax
int i; // [rsp+10h] [rbp-130h]
int j; // [rsp+14h] [rbp-12Ch]
int k; // [rsp+18h] [rbp-128h]
char v17[32]; // [rsp+20h] [rbp-120h] BYREF
char v18[32]; // [rsp+40h] [rbp-100h] BYREF
char v19[32]; // [rsp+60h] [rbp-E0h] BYREF
char v20[32]; // [rsp+80h] [rbp-C0h] BYREF
char v21[32]; // [rsp+A0h] [rbp-A0h] BYREF
char v22[32]; // [rsp+C0h] [rbp-80h] BYREF
char v23[32]; // [rsp+E0h] [rbp-60h] BYREF
char v24[40]; // [rsp+100h] [rbp-40h] BYREF
unsigned __int64 v25; // [rsp+128h] [rbp-18h]
v25 = __readfsqword(0x28u);
std::string::basic_string(v17, argv, envp);
std::string::basic_string(v18, argv, v3);
puts("If you know dynamic debugging routine, this becomes very simple");
printf("please input your flag: ");
std::operator>><char>(&std::cin, v17);
for ( i = 0; i <= 7; ++i )
{
v4 = (char *)std::string::operator[](v17, i);
std::string::operator+=(v18, (unsigned int)*v4);
}
if ( (unsigned __int8)std::operator!=<char>(v18, "HZNUCTF{")
|| (v5 = std::string::length(v17), *(_BYTE *)std::string::operator[](v17, v5 - 1) != 125)
|| std::string::length(v17) != 73 )
{
v7 = std::operator<<<std::char_traits<char>>(&std::cout, "error!!!");
std::ostream::operator<<(v7, &std::endl<char,std::char_traits<char>>);
}
else
{
std::string::operator=(v18, &unk_A00C);
for ( j = 8; j < (unsigned __int64)(std::string::length(v17) - 1); ++j )
{
v8 = (char *)std::string::operator[](v17, j);
std::string::operator+=(v18, (unsigned int)*v8);
}
StoH[abi:cxx11](v19);
std::string::operator=(&h_pwd[abi:cxx11], v19);
std::string::~string(v19);
std::string::basic_string(v24, &h_pwd[abi:cxx11]);
HtoB(v20, v24);
std::string::operator=(&b_pwd[abi:cxx11], v20);
std::string::~string(v20);
std::string::~string(v24);
l = std::string::length(&b_pwd[abi:cxx11]);
for ( ::k = 0; (l + 1 + ::k) % 512 != 448; ++::k )
;
std::to_string((std::__cxx11 *)v24, l);
DtoB(v21, v24);
std::string::~string(v24);
while ( (std::string::length(v21) & 0x3F) != 0 )
{
std::operator+<char>(v22, 48LL, v21);
std::string::operator=(v21, v22);
std::string::~string(v22);
}
std::string::operator+=(&b_pwd[abi:cxx11], 49LL);
v9 = l + ::k + 65;
if ( v9 < 0 )
v9 = l + ::k + 576;
n = v9 >> 9;
while ( ::k-- )
std::string::operator+=(&b_pwd[abi:cxx11], 48LL);
std::string::operator+=(&b_pwd[abi:cxx11], v21);
std::string::basic_string(v24, &v[abi:cxx11]);
HtoB(v23, v24);
std::string::operator=(&v[abi:cxx11], v23);
std::string::~string(v23);
std::string::~string(v24);
for ( k = 0; k < n; ++k )
{
cf[abi:cxx11](v22, (unsigned int)(k << 9));
std::string::basic_string(v23, &v[abi:cxx11]);
Xor(v24, v23, v22);
std::string::operator=(&v[abi:cxx11], v24);
std::string::~string(v24);
std::string::~string(v23);
std::string::~string(v22);
}
std::string::basic_string(v24, v18);
v11 = (unsigned int)func1(v24) != 0;
std::string::~string(v24);
if ( v11 )
v12 = std::operator<<<std::char_traits<char>>(&std::cout, "right!!!");
else
v12 = std::operator<<<std::char_traits<char>>(&std::cout, "error!!!");
std::ostream::operator<<(v12, &std::endl<char,std::char_traits<char>>);
std::string::~string(v21);
}
std::string::~string(v18);
std::string::~string(v17);
return 0;
}
c++写的,很冗杂,但是我们知道如果这题会动态调试就会很简单,那么一般就是两种情况,第一种就是如果我们输入的内容是正确的,那么程序会自行运行解密出flag,再一种情况就是如果从、我们输入的内容是正确的,那么程序会自行解密出flag,但不会输出,而是保存在内存中。而这题就属于后者。这题要做的话是可以直接逆的,是一个没有优化的SM3加密。但我们这里选择一种非预期的做法,首先,我们看到,最后程序进行判断正误时的依据是v11的值,如果是1就说明正确,那么接着我们再看看v11的值是怎么来的,看到fun1函数,
__int64 __fastcall func1(__int64 a1, __int64 a2, __int64 a3)
{
__int64 v3; // rdx
_BYTE *v4; // rax
int v6; // [rsp+18h] [rbp-D8h]
int v7; // [rsp+1Ch] [rbp-D4h]
int i; // [rsp+20h] [rbp-D0h]
int j; // [rsp+24h] [rbp-CCh]
int k; // [rsp+28h] [rbp-C8h]
char v11[32]; // [rsp+30h] [rbp-C0h] BYREF
char v12[32]; // [rsp+50h] [rbp-A0h] BYREF
char v13[32]; // [rsp+70h] [rbp-80h] BYREF
char v14[32]; // [rsp+90h] [rbp-60h] BYREF
char v15[40]; // [rsp+B0h] [rbp-40h] BYREF
unsigned __int64 v16; // [rsp+D8h] [rbp-18h]
v16 = __readfsqword(0x28u);
std::string::basic_string(v11, a2, a3);
std::string::basic_string(v12, a2, v3);
v6 = 1;
v7 = 1;
std::string::basic_string(v15, &v[abi:cxx11]);
BtoH(v13, v15);
std::string::operator=(&v[abi:cxx11], v13);
std::string::~string(v13);
std::string::~string(v15);
for ( i = 0; i < (unsigned __int64)std::string::length(&v[abi:cxx11]); ++i )
{
v4 = (_BYTE *)std::string::operator[](a1, i);
std::string::operator+=(v11, (unsigned int)(char)(*v4 ^ 5));
std::string::operator+=(v11, "#");
}
std::string::basic_string(v15, &v[abi:cxx11]);
std::string::basic_string(v13, v11);
Xor(v14, v13, v15);
std::string::operator=(v11, v14);
std::string::~string(v14);
std::string::~string(v13);
std::string::~string(v15);
std::string::basic_string(v14, &v[abi:cxx11]);
std::string::basic_string(v13, a1);
Xor(v15, v13, v14);
std::string::operator=(v12, v15);
std::string::~string(v15);
std::string::~string(v13);
std::string::~string(v14);
for ( j = 0; j < (unsigned __int64)std::string::length(&v[abi:cxx11]); ++j )
{
if ( *(_BYTE *)std::string::operator[](v12, j) != 48 )
{
v7 = 0;
break;
}
}
for ( k = 0; k < (unsigned __int64)std::string::length(&v[abi:cxx11]); ++k )
{
if ( *(_BYTE *)std::string::operator[](v11, k) != 48 )
{
v6 = 0;
break;
}
}
std::string::~string(v12);
std::string::~string(v11);
return v7 ^ (unsigned int)v6;
}
看到一个异或,具体的逻辑也不是很清楚,那就只能再回到main函数,可以看到在main函数的开始,需要检查输入的flag的格式,一定得是HZNUCTF{},并且长度必须是73,那么这么计算下来,中间内容的长度就是64位了,我们按shift+F12看看,在最下方可以看到一个64位很可疑的字符串
那么我们就把这个字符串做成我们的输入内容,HZNUCTF{7380166f4914b2b9172442d7da8a0600a96f30bc163138aae38dee4db0fb0e4e},然后我们开始动态调试。
在kail打开终端,输入,/.linux_server64,可以看到,
这就说明虚拟机正在等待连接,回到ida,我们在要求输入的下一行下一个断点,
接着我们选择Remote Linux debugger,再在菜单栏中选择Debugger,打开Process options
接着在Hostname输入我们虚拟机的IP地址,然后在Password处输入虚拟机的开机密码,
点击ok,然后点击那个小三角,
弹出这个Warning是不影响,ida会自动检索虚拟机上的文件,
然后我们便成功连接上虚拟机,可以进行远程调试了。
点击F5看反编译的伪代码,回到虚拟机,我们就可以看到程序提示我们输入flag了
把我们先前预设计好的flag输入,然后我们不断通过下断点,F9,快速通过for循环,while循环,一直到最后进行判断输出正误的if语句处,
打开插件Findcrypt,查看有哪些加密方式,它是根据有哪些字符类型来进行查找的,然后我们可以借此找到在内存中的flag
拿到flag。
四.APK文件动态调试
1.首先我们知道动态调试APK文件,也就是安卓文件我们需要哪些工具:
1.adb:起到调试桥的作用,是一个客户端-服务器端程序,其中客户端是用来操作的电脑,服务器就是手机或者模拟器。
2.模拟器或者手机:这里我测试使用的是雷电模拟器9,安卓9(需要root)
3.jadx:用于控制程序运行与暂停以及静态分析函数逻辑
这里需要提示的是,雷电9自带的adb有问题,需要我们自行另外安装(只需要在雷电模拟器的下载陌路里搜索找到adb.exe文件,然后将我们下载进行替换,同时,还需要添加至系统环境的用户变量中的Path中),最后,还需把雷电9从平板模式改成手机模式。
2.这里用于演示的是XYCTF中re的一道赛题,[XYCTF]DebugMe。
3.我们先把附件安装到模拟器上运行一下,
点击并没什么用,同时我们使用jadx打开,看看函数的逻辑,部分截图
似乎还有一些混淆,基本不太可能直接解密,并且运行时程序也提示我们需要动态调试,那么我们便开始动态调试,首先,我们先用MT管理器查看该app的主清单,看看是否具备可调试的属性,没有的话我们就需要添加,同样也是在模拟器上安装MT模拟器,然后点击红色方框所框住的,选择安装包提取,找到我们该app,
然后提取安装包,并定位,找到app,双击
点击查看,再进入红色方框所选的,
选择反编译,看到application,发现并没有可调式的属性,那么我们便手动加入,android:debuggable="true"
然后点击保存
并选择自动签名,然后退出该页面后,再次点击该安装包,这次我们选择安装,把我们修改后的apk文件安装好,接着,我们打开设置,进入开发者选项,然后找到调试栏目,打开USB调试,再找到选择调试应用,选择我们所需要调试的应用,如果刚才属性添加没问题,那么这里就可以找得到,否则是找不到的,此刻我们再次运行app,就可以看到
然后回到电脑端,打开终端,输入adb devices,查看此时adb连接的设备,
接着我们打开jadx,
点击该小甲虫,就可以看到,
选择我们要调试的进程,就是第一个,com.xyctf.ezapk(此时,我们的模拟器并未关闭,同时运行app,使其处于等待调试的状态),双击进程,就可以看到
然后点击那个小三角(点击两次),运行
然后点击Click me!就可以拿到flag
这道题是只要程序检测到和调试器挂上,然后点击Click me!就会出flag
结语
CTF中的动态调试还远不如此,还有很多工具,插件的应用,以及各种文件的不同,本文章只演示了一些比较常见且较为基础的部分。如有疏漏之处,请指正。