逆向工程中的动态调试
1484321987948696 发表于 浙江 CTF 1421浏览 · 2024-08-13 07:20

前言

该文章内容涉及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_serverlinux_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中的动态调试还远不如此,还有很多工具,插件的应用,以及各种文件的不同,本文章只演示了一些比较常见且较为基础的部分。如有疏漏之处,请指正。

附件:
  • [DASCTF]Strangeprograme.zip 下载
  • Trustme.zip 下载
  • EXE文件动态调试和反动态调试的绕过.zip 下载
  • 运动的elf.zip 下载
1 条评论
某人
表情
可输入 255