challenge.exe 详细逆向Writeup

学习 · 6 天前

目录

  - 阶段1:文件指纹与初步分析

  - 阶段2:IDA静态分析入门

  - 阶段3:主函数深度解析

  - 阶段4:加密算法逆向推导

  - 阶段5:解密脚本编写与调试

  - 阶段6:动态验证


入门题 - Bugku CTF平台

题目信息

| 项 | 值 |

| 文件名 | challenge.exe |

| 文件大小 | 79,289 字节 |

| MD5 | 7595baf148c7add9afc13492f80aa500 |

| SHA256 | 4bcaaa91b232baaaa987b62ee87a7a8c760558953cfd86c2940588542d7f83cb |

| 架构 | x86_64 (64位) |

| 编译器 | MinGW GCC |

| 难度 | 入门级 |

| 考察点 | 栈分析、XOR加密、小端序、内存重叠 |


环境准备

必备工具

  1. IDA Pro 7.x+ - 反汇编与反编译
  2. x64dbg - 动态调试(可选)
  3. Python 3.x - 编写解密脚本
  4. Windows环境 - 运行验证

IDA初始配置(新手必看)


1. 打开IDA Pro,选择"New"

2. 选择 challenge.exe

3. 弹出对话框选择"PE Executable"

4. 勾选"Load resources"、"Create FLIRT signatures"

5. 点击OK,等待自动分析完成(约10-30秒)

6. 按空格键切换到图形视图/文本视图

详细分析流程

阶段1:文件指纹与初步分析

1.1 文件类型识别


# 用file命令查看(或使用DIE/PEiD)

file challenge.exe

challenge.exe: PE32+ executable (console) x86-64, for MS Windows

1.2 字符串快速筛查


# 用strings命令提取可打印字符串

strings challenge.exe | findstr -i flag

关键字符串定位:

| 地址 | 字符串 | 含义 |

| 0x14000a000 | "Enter flag: " | 输入提示 |

| 0x14000a00d | "Wrong length.\n" | 长度错误提示 |

| 0x14000a01b | "Incorrect.\n" | 验证失败提示 |

| 0x14000a028 | "Correct! Here is your flag: %s\n" | 成功提示 |

💡 逆向技巧: 字符串是逆向分析的"路标",永远从字符串开始找交叉引用!

阶段2:IDA静态分析入门

2.1 定位主函数(3种方法)

方法1: 通过字符串交叉引用


1. Shift+F12 打开字符串窗口

2. 找到 "Enter flag: "

3. Ctrl+X 查看交叉引用(Xrefs to)

4. 双击进入引用位置 → 直接到达main函数!

方法2: 查看函数列表


1. Shift+F4 打开函数窗口

2. 按Ctrl+F搜索 "main"

3. 找到地址 0x1400014a4 的 main 函数

方法3: 从入口点跟踪


1. 按Ctrl+E跳转到入口点

2. 入口点会调用 __tmainCRTStartup

3. 最终调用 main 函数

阶段3:主函数深度解析

3.1 栈帧结构分析(关键!)

进入main函数(0x1400014a4),按F5反编译,得到伪代码。

栈内存布局:


地址          变量名        大小        用途

[rbp-B0h]    Buffer        128字节     用户输入缓冲区

[rbp-30h]    v6            30字节      加密的目标数组 ✨

[rbp-11h]    v7            1字节       临时计算变量

[rbp-10h]    v8            4字节       flag长度 = 30

[rbp-Ch]     i             4字节       循环计数器

[rbp-8h]     v10           8字节       输入字符串实际长度

3.2 逐行分析伪代码


/* 0x1400014a4 */ int __fastcall main(int argc, const char **argv, const char **envp)

{

  /* 0x1400014af */ _main(argc, argv, envp);  // GCC初始化

  // ⚠️  数据初始化:这里是第一个坑!

  // 第一行:复制24字节到 v6[0] - v6[23]

  /* 0x1400014be */ qmemcpy(v6, "35>0$/2#2/,6+08*>=282>,&", 24);

  // ⚠️  超级大坑在这里!!

  // 第二行:从 v6[22] 开始写入8字节!!

  // 这意味着 v6[22], v6[23] 会被覆盖!!

  /* 0x1400014e8 */ *(_QWORD *)&v6[22] = 0xDA626F696F38262CuLL;

  /* 0x1400014ec */ v8 = 30;  // flag长度固定为30!

  /* 0x1400014f6 */ printf("Enter flag: ");

  // 读取用户输入

  /* 0x14000150e */ v3 = __acrt_iob_func(0);  // stdin

  /* 0x140001525 */ if (!fgets_0(Buffer, 128, v3))

  /* 0x14000152f */     return 0;

  // 处理换行符

  /* 0x140001548 */ v10 = strlen(Buffer);

  /* 0x140001551 */ if (v10)

  {

    // 去掉fgets读入的换行符

    /* 0x140001565 */ if (Buffer[v10 - 1] == '\n')

    /* 0x14000156f */       Buffer[--v10] = 0;

  }

  // 第一重验证:长度检查

  /* 0x140001580 */ if (v8 == v10)  // 必须等于30

  {

    // 第二重验证:逐字节加密比较

    /* 0x14000159b */ for (i = 0; i < v8; ++i)

    {

      /* 0x1400015b1 */ v7 = Buffer[i];       // 取输入字符

      /* 0x1400015b4 */ v7 += 3;              // 第一步:字符 + 3

      /* 0x1400015b8 */ v7 ^= 0x5Au;          // 第二步:异或 0x5A

      /* 0x1400015c9 */ if (v7 != v6[i])      // 比较

      {

        /* 0x1400015d5 */ printf("Incorrect.\n");

        /* 0x1400015df */ return 0;

      }

    }

    /* 0x1400015ed */ printf("Correct! Here is your flag: %s\n", Buffer);

    /* 0x140001606 */ return 0;

  }

  else

  {

    /* 0x14000158f */ printf("Wrong length.\n");

    /* 0x140001594 */ return 0;

  }

}

3.3 内存覆盖可视化

🎯 最关键的发现! 90%的新手会卡在这里!

初始化前的v6数组(30字节):


索引:  0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29

      └──────────────────────────────┘

      qmemcpy复制的24字节 (0-23)

执行第二条赋值后(注意!):


索引:  0  1  2  3 ... 20 21 22 23 24 25 26 27 28 29

                     └───────────────────────────┘

                     这里写入了8字节的QWORD数据!

                     覆盖了原来的22,23位置!!

错误做法: 直接把24字节字符串解密

正确做法: 解密前必须先处理覆盖!


阶段4:加密算法逆向推导

4.1 正向加密流程


          输入字符 Buffer[i]

              │

              ▼

          + 3 (0x1400015b4)

              │

              ▼

        XOR 0x5A (0x1400015b8)

              │

              ▼

        与 v6[i] 比较

数学表达式:


v6[i] = (Buffer[i] + 3) XOR 0x5A

4.2 逆向推导(代数法)

已知:C = (P + 3) ^ K,其中 K = 0x5A

求P(明文):


步骤1: 两边同时 XOR K

C ^ K = [(P + 3) ^ K] ^ K

C ^ K = P + 3          (因为 X ^ K ^ K = X)

  

步骤2: 两边减3

(C ^ K) - 3 = P

  

最终解密公式:

P = (C ^ 0x5A) - 3
💡 数学技巧: XOR是自逆运算,异或两次回到原值!

阶段5:解密脚本编写与调试

5.1 小端序处理(第二个坑!)

还记得这个赋值吗?


*(_QWORD *)&v6[22] = 0xDA626F696F38262CuLL;

x86/x64是小端序架构! 数据在内存中是反过来存的!


数值: 0x DA 62 6F 69 6F 38 26 2C

        │  │  │  │  │  │  │  │

        │  │  │  │  │  │  │  └── 最低字节 → 内存最低地址 v6[22]

        │  │  │  │  │  │  └───── v6[23]

        │  │  │  │  │  └──────── v6[24]

        │  │  │  │  └─────────── v6[25]

        │  │  │  └────────────── v6[26]

        │  │  └───────────────── v6[27]

        │  └──────────────────── v6[28]

        └─────────────────────── 最高字节 → 内存最高地址 v6[29]

内存中实际字节顺序:


地址:    v6[22] v6[23] v6[24] v6[25] v6[26] v6[27] v6[28] v6[29]

值:      0x2C   0x26   0x38   0x6F   0x69   0x6F   0x62   0xDA

5.2 完整解密脚本


def decrypt_flag():

    # 初始化30字节数组

    encrypted = [0] * 30

    # 第一步:先复制前24字节

    # 来源: qmemcpy(v6, "35>0$/2#2/,6+08*>=282>,&", 24);

    first_part = b"35>0$/2#2/,6+08*>=282>,&"

    for i in range(24):

        encrypted[i] = first_part[i]

    print("第一次初始化后的22-23字节:")

    print(f"  [22] = 0x{encrypted[22]:02X} = {chr(encrypted[22])}")

    print(f"  [23] = 0x{encrypted[23]:02X} = {chr(encrypted[23])}")

    print()

    # 第二步:处理QWORD覆盖!(从索引22开始,共8字节)

    # 0xDA626F696F38262C 小端序 → 字节顺序反过来

    overwrite = [0x2C, 0x26, 0x38, 0x6F, 0x69, 0x6F, 0x62, 0xDA]

    for i in range(8):

        encrypted[22 + i] = overwrite[i]

    print("QWORD覆盖后的22-29字节:")

    for i in range(8):

        print(f"  [{22+i}] = 0x{encrypted[22+i]:02X}")

    print()

    # 解密每个字符

    flag = []

    for i, c in enumerate(encrypted):

        plain = (c ^ 0x5A) - 3

        flag.append(chr(plain))

        print(f"[{i:2d}] cipher=0x{c:02X} → (0x{c:02X} ^ 0x5A) - 3 = 0x{plain:02X} = '{chr(plain)}'")

    print()

    result = ''.join(flag)

    print(f"FLAG: {result}")

    print(f"LENGTH: {len(result)}")

    return result

  

if __name__ == "__main__":

    decrypt_flag()

5.3 运行脚本并验证输出


第一次初始化后的22-23字节:

  [22] = 0x26 = &

  [23] = 0x00 =

  

QWORD覆盖后的22-29字节:

  [22] = 0x2C

  [23] = 0x26

  [24] = 0x38

  [25] = 0x6F

  [26] = 0x69

  [27] = 0x6F

  [28] = 0x62

  [29] = 0xDA

  

[ 0] cipher=0x33 → (0x33 ^ 0x5A) - 3 = 0x66 = 'f'

[ 1] cipher=0x35 → (0x35 ^ 0x5A) - 3 = 0x6C = 'l'

[ 2] cipher=0x3E → (0x3E ^ 0x5A) - 3 = 0x61 = 'a'

[ 3] cipher=0x30 → (0x30 ^ 0x5A) - 3 = 0x67 = 'g'

...

[29] cipher=0xDA → (0xDA ^ 0x5A) - 3 = 0x7D = '}'

  

FLAG: flag{reversing_made_easy_2025}

LENGTH: 30

阶段6:动态验证

6.1 命令行验证


Write-Output "flag{reversing_made_easy_2025}" | .\challenge.exe

预期输出:


Enter flag: Correct! Here is your flag: flag{reversing_made_easy_2025}

6.2 x64dbg动态调试验证(进阶)


1. 用x64dbg打开challenge.exe

2. 按Ctrl+G,输入 0x1400015C9 (比较指令处)

3. F9运行

4. 输入flag,程序断在比较处

5. 查看AL(v7)和v6[i]是否相等

6. 确认每个字符都通过验证

常见坑点与避坑指南

| 坑点 | 中招表现 | 如何避免 |

| 内存重叠覆盖 | 解密到最后4个字符乱码 | 仔细看反编译,注意指针偏移 |

| 小端序搞反 | 后半段完全乱码 | 牢记x86/x64都是小端! |

| 顺序搞反:先减3再XOR | 全乱码 | 代数推导,不要猜顺序! |

| 忘记去掉换行符 | 提示Wrong length | fgets会读入换行符! |

典型错误解密: flag{reversing_made_easysy_202 (未处理覆盖)

正确解密结果: flag{reversing_made_easy_2025}


进阶技巧总结

1. 栈分析技巧


IDA快捷键:

  Ctrl+K → 查看栈帧

  Alt+M → 内存映射

  D → 切换数据显示格式

2. 快速解密思路

遇到逐字符加密比较的程序:


1. 下断点在比较处 (0x1400015C9)

2. 随便输入30个字符

3. 运行,每次断下时看AL寄存器的值

4. AL就是正确的加密后的值

5. 收集30字节后统一解密

→ 这叫"爆破断点法",不用分析算法也能解!

3. 内存特征识别


凡是看到:

for (i=0; i<len; i++) {

    buf[i] ^= CONSTANT;

    buf[i] += / -= x;

}

→ 100% 是简单替换加密,直接逆运算即可

最终FLAG


flag{reversing_made_easy_2025}

后记

这是一道非常经典的CTF逆向入门题,麻雀虽小五脏俱全,涵盖了:

  • ✓ IDA基本操作
  • ✓ 栈帧分析
  • ✓ 简单密码学
  • ✓ 内存模型理解
  • ✓ 大小端序

强烈建议新手反复做3遍,把每个细节吃透!