看这道逆向赛题,我已经得到了flag:GXY{I_have_no_gir1_frieN,1.py是我...

Créé le : 9 avril 2026

Répondu en utilisant GPT-5.4 Pro par Chat01

Question

minecraft.exe
string_to_intDll.dll
string_to_intDll.lib
1.py
string_to_intDll.dll.i64

看这道逆向赛题,我已经得到了flag:GXY{I_have_no_gir1_frieN,1.py是我写的脚本,i64文件是我的IDA解析文件,有我的注释
我这道题做了4个小时还是5个小时了,我感觉自己有点废物,解出来发现逻辑其实不难,我怎么要这么久,我花太多时间在黑盒分析动态调试上了:
111111111111111111111xzciaw
211111111111111111111

111211111111111111111
111111211111111111111
111111111211111111111
111111111111211111111
111111111111111211111
111111111111111111222

121111111111111111111
112111111111111111111

111111111111111111111
111222111111111111111
aaaaaaaaaaaaaaaaaaaaa

[0x7845544D]

v10
[0xBA4A79A0, 0x00000199, 0xF7105638, 0x00007FF7, 0x752FFE10, 0x00000096, 0x00000000, 0x00000000]

第一次与4d
[0xA5795321FFF22919]
以上是我黑盒分析动态调试的草稿,这道题我肯定是会做的,但是我想压缩时间
然后做了这道题我想问以下问题:
1,这道题是base64加自定义加密,但是我在动态调试过程中发现我输入21字符,base64加密后长度居然是32字节,然后我输入111111111111111111111xzciaw和111111111111111111111加密结果是一样的,我断定最后三字节被base64编码了两次,实际采用输入的前21字节,但是最终比对数组第7,8组不同,又反驳了这一点,我搞不清了
2,我好奇.lib文件是干什么的
3,我黑盒分析发现是3字节一组加密之后就想爆破,我想基于IDA爆破,或者想办法把加密逻辑搬到程序外爆破,这样我就可以不用分析base64和自定义加密的逻辑了,但是我不会,所以我还是静态分析了
4,我动态调试过程中有些指针变量双击进去是指针值,把指针值convert下来,复制,Ctrl+G然后粘贴再查看太麻烦了,IDA有什么快捷的方法吗
5,我在VSCode中编辑python脚本复制含tab的语句到for循环(或者其他不是顶格的地方)里面,tab不会自动对齐,有点难受
6,看我的脚本遍历3位可打印字符还可以吗,算是比较好的吧,没有大幅度浪费资源吧

Réponse

先说最重要的:你不是废物。你卡 4~5 小时,核心不是“你不会”,而是这题里确实有一个会污染动态现象的实现 bug,而且它正好卡在你黑盒最依赖的 base64 这层。你看到的“21 个字符怎么会变成 32 字节”“现象前后对不上”,不是你看错了,是程序本身就在制造噪音。

我把你这个 DLL 的导出函数拆了一遍,结论如下。

  1. 你 Q1 里的疑惑,真实原因是:题目的 base64 实现写坏了

不是标准 base64 的行为。

这题的 String_to_long 逻辑其实是:

  • 先把整串输入做一遍 base64
  • 再按 4 字符一组切开
  • 每组跑你脚本里的那个 Murmur 风格哈希
  • 最后只比较 8 组 dword

关键 bug 在它的 base64 子函数:

  • 当输入长度 是 3 的倍数时,它会把所有完整 3 字节块正常编码完
  • 然后又把最后那一组 4 个 base64 字符再拷贝一遍

所以会出现这种现象:

  • 21 字节,本应 base64 后是 28 字符,但它会变成 32
  • 24 字节,本应是 32,但它会变成 36
  • 27 字节,本应是 36,但它会变成 40

所以你动态里观察到“21 输入 -> 32 字节”这件事,是对的

更进一步,校验函数虽然会把所有 4-char block 都切出来,但它真正只比较前 8 组。这就导致:

  • 24 字节输入时,前 8 组正好是有效比较区
  • 额外重复出来的第 9 组根本不参与比较

所以你之前那个“最后三字节好像被 base64 了两次”的直觉,其实已经很接近真相了。更准确地说,是:

长度为 3 的倍数时,base64 输出会重复最后一个 4-char block;但最终比较只看前 8 组。

这也解释了为什么这题最自然的解法是按 3 字节分块爆破

顺手一提,按我复现的这套逻辑,GXY{I_have_no_gir1_frieN} 这种多补一个 } 的 25 字节输入,前 8 组仍然不变,因为第 25 个字节只会落到第 9 组,不影响比较。也就是说,这题实际上校验的是前 24 个字节前缀,不是“严格唯一完整 flag”。

  1. .lib 是干什么的

在 MSVC/Windows 这套工具链里,.lib 常见有两种:

  • 静态库:代码真在里面,链接后会被拷进 EXE
  • 导入库(import library):给 linker 用的“说明书”,真正代码还在 .dll

你这个 string_to_intDll.lib导入库,不是静态库。我看了它里面有 __IMPORT_DESCRIPTOR_string_to_intDll__imp_?String_to_long... 这种符号,这就是典型的 DLL import library。它的作用是:让 EXE 在链接时知道“这个函数在 DLL 里,运行时去 DLL 里找”。微软文档也是这么定义的:导入库只包含装载 DLL 和转发调用所需的信息,真正实现仍在 DLL 中。(Microsoft Learn)

所以这题里:

  • minecraft.exe 只是外壳
  • 真校验逻辑在 string_to_intDll.dll
  • .lib 只是让 EXE 能顺利链接到那个 DLL
  1. “能不能不分析逻辑,直接基于 IDA / 程序外爆破?”

能,而且其实有三条路。

第一条,也是你最后走到的这条,最适合这题

  • 静态确认“比较单位是不是独立”
  • 发现它是“每 4 个 base64 字符 -> 一个 32 位哈希”
  • 再倒推成“每 3 个原文字符 -> 一个独立目标”
  • 然后 3 字节分块爆破

这条路是最省时的,因为这题的状态不跨块

第二条,写个程序外 wrapper/harness

  • 用一个很小的 Windows C++ 程序去 LoadLibrary + GetProcAddress
  • 或者直接链接 string_to_intDll.lib
  • 调 DLL 导出的 String_to_long(std::string&)
  • 再让 Python/批处理喂输入爆破

这条路的好处是:你几乎不用还原内部逻辑。坏处是:这个导出函数参数是 std::string&,直接拿 Python ctypes 调会比较别扭,通常得先写个 C++ 包装层。

第三条,IDA 里直接黑盒调用。Hex-Rays 官方的 Appcall 就是干这个的:在调试会话里,直接从 IDC/IDAPython/CLI 调试目标进程内函数,适合 fuzz、黑盒调用、快速验证。只是它很依赖正确原型;遇到这种 std::string& 的 C++ 导出,写 wrapper 往往比硬怼 Appcall 更省心。(Hex-Rays Docs)

所以你这题下次最优策略其实是:

  • 先看 import/export
  • 一旦发现 EXE 只是导入一个自定义 DLL 校验函数
  • 马上进 DLL
  • 只抓四件事:长度检查、分组粒度、是否跨组有状态、比较常量个数
  • 只要确认“8 个独立 dword 比较”,就直接分块爆破,不要在黑盒动态里耗太久
  1. IDA 里看指针值,有没有比“Convert + 复制 + Ctrl+G”更快的方法

有,最实用的是这几个:

第一,光标停在可跳转的地址/名字/操作数上直接按 Enter,这是 IDA 官方文档写明的“Jump to operand”;Esc 返回上一个位置,G 是手动跳地址,Ctrl+Enter 是往前走跳转历史。(Hex-Rays Docs)

第二,调试时看寄存器,优先看 Debugger window 里寄存器右边那一栏。Hex-Rays 文档明确说,寄存器通常有两栏,右边那栏就是“把当前寄存器值按地址解释后的结果”。这比你手工转十六进制再 Ctrl+G 舒服得多。(Hex-Rays Docs)

第三,把 Hex View / Dump 跟 IDA View 同步开着。官方文档说明 IDA View、Pseudocode、Hex View 可以同步高亮同一处内容。这样你经常不需要手工 G 来回跳,只要跟着同步视图看。(Hex-Rays Docs)

实战里我一般这么用:

  • 寄存器里先看右侧 interpreted-as-address
  • 栈上/伪代码里看到能跳的值就 Enter
  • 迷路了 Esc
  • 必须裸跳才 G

这套会比“复制地址 -> Ctrl+G”顺手很多。

  1. VSCode 里粘贴到 for 里面,tab 不自动对齐,很难受,怎么办

最稳的是两招:

  • editor.formatOnPaste
  • 养成 Ctrl+K Ctrl+F(格式化选中)和 Shift+Alt+F(格式化全文)的肌肉记忆

VS Code 官方文档里有这几个点:

  • Format SelectionCtrl+K Ctrl+F
  • Format DocumentShift+Alt+F
  • editor.formatOnPaste 可以在粘贴时格式化粘贴内容
  • 缩进相关设置主要看 editor.insertSpaceseditor.tabSizeeditor.detectIndentation。(Visual Studio Code)

一个实用配置可以是:

json
{ "editor.formatOnPaste": true, "editor.insertSpaces": true, "editor.tabSize": 4, "editor.detectIndentation": false }

但有个坑你要知道:VS Code 的 Python 格式化文档专门提到,Black formatter 不支持格式化选中区域;如果你用 Black,formatOnPaste / Format Selection 体验可能不会如你预期,这种情况下更适合开 formatOnSave,保存时整体整理。(Visual Studio Code)

  1. 你的脚本这样遍历 3 位可打印字符,行不行?浪费资源吗?

结论:完全可以,而且思路是对的。

你的脚本本质上是在做:

  • 每次枚举 3 个可打印字符
  • 对这 3 字节做标准 base64,得到 4 字符
  • 对这 4 字符跑哈希
  • 去撞 8 个目标常量

这条路在这题上非常合理,因为最终比较确实就是按 4-char block 独立进行的;换回原文就是按 3 字节块独立。所以你没有“大幅浪费资源”,反而是抓到了这题最值钱的结构。你的脚本方向本身是对的。(1.py)

复杂度也不大:

  • 你枚举的是 range(33,127),一共 94 个字符
  • 总空间就是 94^3 = 830584

83 万次这种级别,对这种 CTF 脚本来说非常轻。

而且我把 8 个目标在这个字符集里过了一遍,每个常量都是唯一命中

  • 0 -> GXY
  • 1 -> {I_
  • 2 -> hav
  • 3 -> e_n
  • 4 -> o_g
  • 5 -> ir1
  • 6 -> _fr
  • 7 -> ieN

所以你的脚本不仅能跑,而且信息量很高。

只能说还有几处“润色级”优化,不是“方向级”优化:

  • bytes((x,y,z)) 代替 chr(x)+chr(y)+chr(z)encode()
  • 把 8 个常量放进字典,而不是 8 个 if
  • 找齐 8 组以后直接 break
  • 如果想再快一点,甚至可以不调用 base64.b64encode,自己直接按 3 字节算 4 个 base64 字符

但这些都只是锦上添花,不是你慢的主因。你慢的主因是前面花太多时间在不干净的黑盒现象上,不是脚本写差了。

最后给你一个以后压缩时间的止损模板:

  • 10 分钟内先看 imports/exports:主程序是不是只是壳,核心逻辑在 DLL
  • 10 分钟内搞清比较粒度:是整串、按字节、按块,还是有状态链
  • 20 分钟还没看懂动态现象,就转静态:尤其是当现象已经开始“前后矛盾”时,往往不是你菜,是实现有坑
  • 只要确认块独立,就立刻分块 brute,不要执着于把整套逻辑完全读完再动手

你这次不是“做不会”,而是路径选择稍微晚了点。这次学到的不是题解本身,而是“什么时候该停止黑盒、什么时候该切静态”。这个经验值比这题 flag 更值钱。

Question

我明白了,但是第三点爆破我还是想继续问,我的做法只是爆破三字节,这是建立在已知base64+自定义加密的基础上的,然而我其实可以不用知道是base64+自定义加密,反正输入就是3字节,输出就是4字节,关键是如何把这一整个过程提取出来,我觉得IDA里面实现这一点是很方便的,直观一点,就像我开启(127-33)^3次程序,每次输入的前三字节不同,总能把密文对应的明文确定下来,我该怎么实现呢

Question

我明白了,但是第三点爆破我还是想继续问,我的做法只是爆破三字节,这是建立在已知base64+自定义加密的基础上的,然而我其实可以不用知道是base64+自定义加密,反正输入就是3字节,输出就是4字节,关键是如何把这一整个过程提取出来,我觉得IDA里面实现这一点是很方便的,直观一点,就像我开启(127-33)^3次程序,每次输入的前三字节不同,总能把密文对应的明文确定下来,我该怎么实现呢

Réponse

可以,而且这条路本质上就是对的:不是把算法还原出来,而是把程序本身变成 oracle。你现在的 1.py,本质是把“观测点”放在你自己复现的 Python 里;你想要的是把观测点放回 DLL 里。(1.py)

先说一个关键边界:如果黑盒最终只给你一个 bool,那信息量不够,你没法只靠“过/不过”恢复出 32 位中间值。你至少要做一点点“灰盒”工作,找到一个会泄露中间结果的位置。好消息是,你这份 DLL 里这个位置非常明显:

  • 导出校验函数:module_base + 0x2E70
  • 每组 32 位结果写入栈上数组:module_base + 0x30F3,目标数组在 RSP + 0x60
  • 开始逐项比较:module_base + 0x3129
  • 单组 4-char -> dword 的函数:module_base + 0x3390

所以你完全可以不理解 base64、不理解哈希数学,只把 RSP+0x60 当“密文输出缓冲区”来读。

真正该做的,不是 (127-33)^3重启程序,而是 (127-33)^3函数调用。重启 83 万次,时间全耗在进程启动、输入输出和调试器 attach 上了;黑盒爆破要省时,核心就是“一个活进程里重复喂数据”。

你这题在 IDA 里,最顺手的做法其实是下面这套。


最实用的 IDA 方案:停在 EXE 调 DLL 之前,复用那个活着的 std::string

不要直接从“程序跑完一轮以后”去复用对象,那样有对象生命周期问题。最稳的是:

  1. EXE 里找到调用 String_to_long 的那条 call(通常是调 IAT:__imp_?String_to_long...)。
  2. 先正常跑一次,输入一个固定 24 字节的假串,比如 AAAAAAAAAAAAAAAAAAAAAAAA
  3. 在那条 call 之前断下。此时:
    • RCX 就是传给 DLL 的 std::string 对象地址
    • 因为长度是 24,通常会走“外部 buffer”而不是小字符串内联
    • 这样你后面只要改 char buffer 的内容,不用每次重建 std::string

然后进入批量模式:

  1. 另下一个断点在 DLL 的 module_base + 0x3129,这是比较开始的位置。
  2. 保持程序一直停在同一个 caller 上下文里,不让它自然跑过去。
  3. 用 IDAPython 循环:
    • char buffer 覆写成 候选 3 字节 + 固定 21 字节填充
    • Appcall 直接调 module_base + 0x2E70
    • 断到 0x3129 时,读 RSP + 0x60 的 8 个 dword
    • 取第 0 个 dword,当作这次“前三字节 -> 输出值”的结果

Hex-Rays 官方文档里,Appcall/dbg_appcall 就是专门干这个的:在调试会话里,直接调用被调试进程里的函数;它也支持用函数地址而不是名字,并且非常依赖函数原型。手动模式的 Manual Appcall 还能把函数调用环境搭好之后让你自己单步,结束后再 cleanup_appcall() 恢复原上下文,这特别适合你这种“先看一轮,再批量跑”的场景。(Hex-Rays Docs)

IDA 的 IDAPython 这边也正好有你需要的几样东西:DBG_Hooks 可以挂断点回调,read_dbg_memory/write_dbg_memory 可以直接读写 debuggee 内存,get_reg_val 可以拿 RSP 之类的寄存器值。(IDAPython Documentation)


这套方法最关键的地方:先做“依赖性扫描”,不用懂算法也能证明 3 字节分组

你不是最在意“如何不靠 base64 知识也发现块结构”吗?那就先别 brute,先做一个依赖性扫描

  • 固定一个 24 字节 baseline,比如全 A
  • 调一次,读出 8 个 dword,记成 base[8]
  • 然后对位置 i = 0..23
    • 只改第 i 个字节
    • 再调一次,读出 cur[8]
    • cur[j] != base[j]j 是哪个

你会看到很漂亮的现象:

  • 改位置 0/1/2,只会动 out[0]
  • 改位置 3/4/5,只会动 out[1]
  • 改位置 21/22/23,只会动 out[7]

这样你是动态地证明了“输入按 3 字节分组、每组独立落到一个输出槽”,完全不需要先读懂 base64 或哈希公式。这一步特别值钱,因为它能把“猜测分块”变成“证据分块”。


你在 IDA 里可以怎么写

下面给你的是骨架,不是一贴即跑版;Appcall 那一行和内存读写 helper 可能要按你 IDA 版本微调一下,但思路就是这个。

python
# 思路骨架:把程序当 oracle,而不是重启 83 万次 # 前提: # 1) 你已经在 EXE 里停在 call String_to_long 之前 # 2) 记下了 RCX = STR_OBJ # 3) 对 24 字节输入,取到了 char buffer 地址 CHARBUF # 4) DLL 运行时基址为 BASE import struct import itertools BASE = 0x180000000 # 按实际运行时模块基址改 CHECK = BASE + 0x2E70 BP_CMP = BASE + 0x3129 STR_OBJ = 0x???????????????? # call 前 RCX CHARBUF = 0x???????????????? # 24字节时的真实字符缓冲区 # 这里按你的 IDA 版本改成 idaapi.Appcall / ida_idd.Appcall # 关键点:参数直接写成 void *,因为你传的是“一个已经活着的 std::string 对象地址” check = Appcall.proto(CHECK, "bool __fastcall check(void *s);") last_out = None class MyHooks(DBG_Hooks): def dbg_bpt(self, tid, ea): global last_out if ea == BP_CMP: rsp = get_reg_val("RSP") raw = read_dbg_mem(rsp + 0x60, 32) # 这里自己封装 last_out = struct.unpack("<8I", raw) request_continue_process() # 或者手动控制 return 0 hooks = MyHooks() hooks.hook() FILL = b"A" * 21 for a, b, c in itertools.product(range(33,127), repeat=3): candidate = bytes([a, b, c]) + FILL write_dbg_mem(CHARBUF, candidate) # 长度恒定 24,不用改 size/cap check(STR_OBJ) # 直接再次调用 DLL 导出 v0 = last_out[0] # 这里做建表 / 比较 / 命中判断

这里最值钱的 hack 其实只有两个:

  • 停在 caller 的 call 前,复用“活着的 std::string 对象”
  • 在 compare 前读栈上数组,而不是只看最终 bool

这两点一有,整个过程就变成“黑盒表驱动”。


什么时候用 Manual Appcall,什么时候普通 Appcall

你可以把它们分工一下:

  • 想直观看一轮到底发生了什么:用 Manual Appcall
    它会把调用上下文搭好,然后你从函数头一路单步到 0x3129,看 RSP+0x60 怎么出来,最后 cleanup_appcall() 回去。这个模式就是拿来“调一轮、看路径”的。(Hex-Rays Docs)

  • 想批量跑 83 万次:用 普通 Appcall + 断点回调
    不要单步,不要手工输,不要重启进程。


还有一个更稳、但不那么“IDA 味”的方案

如果你愿意把“找观测点”放在 IDA、把“批量喂数据”放到程序外,其实更稳:

  • 用 IDA 只确认:
    • 导出函数 RVA
    • compare 前的观测点
    • 或者单组函数 RVA 0x3390
  • 然后自己写一个 小 C++ harness
    • LoadLibrary("string_to_intDll.dll")
    • 在同一进程里反复调用
    • 因为是 C++,std::string 自然构造,不用你在 IDA 里硬捏对象

这通常比 IDA 里直接长期 Appcall 更稳,因为 std::string 这种 C++ ABI 对象,交给同 ABI 的 C++ 编译器去构造最省心。
但如果你这次就是想练“IDA 里把二进制变成 oracle”,那上面那套已经够用了。


一句最核心的话

你下次想节省时间,不是去问“这是不是 base64”,而是先问:

我能不能在 compare 前把中间结果读出来?

一旦答案是能,这题就从“逆算法题”变成“造 oracle 题”了。

下一条我可以直接给你补成一份更接近可粘贴运行的 IDAPython 骨架,只保留两个你手填的地址:STR_OBJCHARBUF

Question

现在按我的思路教我实现:
我先在dll文件的module_base + 0x2E70和module_base + 0x3129断点,调试dll文件,不断改变module_base + 0x2E70第一行指令rcx指向地址的21字节数组的前三字节,然后在module_base + 0x3129记录[rsp+rax+168h+var_108]的值并输出。
appcall我感觉不太灵活,如果我只想要截取某个函数的某一部分,这样就不太灵活

Réponse

可以,按你的路子来,而且不用 Appcall
但我先把最关键的一点说死:

只靠 0x2E700x3129 两个断点,适合手动一轮一轮看;想自动跑完 83 万组,最好再补一个“函数返回点”断点。

原因很简单:
你如果在 0x3129 读完值之后,直接把 RIP 改回 0x2E70,等于把这次函数调用半路腰斩了,本地对象析构和收尾都没走完,长跑很容易出问题。
而 Appcall 官方机制本来就是“劫持当前线程栈、把 IP 临时改到函数开头,等函数返回后再恢复现场”,所以它更像“整函数 oracle”;你现在这个需求更适合断点驱动的状态机。(Hex-Rays Docs)

你这条路最稳的版本是:

  • module_base + 0x2E70:函数入口,拿到 RCX 和真正字符缓冲区
  • module_base + 0x3129:读输出数组
  • 动态再加一个返回地址断点:让本轮函数正常 return,然后在返回点把寄存器/栈恢复成“刚进函数时”的样子,再重新进 0x2E70

这样你就实现了:

  • 不用懂 base64
  • 不用懂自定义加密
  • 不用重启 83 万次进程
  • 只把 DLL 当 oracle 用

另外,IDA 的断点条件确实可以直接用寄存器名和内存表达式,甚至支持脚本语言;断点本身也会存在数据库里,模块加载后会尽快恢复。所以“在 DLL IDB 里下断,然后启动会加载它的 EXE”这条路是成立的。(Hex-Rays Docs)


你这条思路里,真正该怎么做

先修正一个点

0x2E70 处,RCX 大概率不是 21/24 字节字符数组本体,而是 std::string 对象。
你真正要改的,通常是:

  • RCXstd::string 对象地址
  • [RCX]:真正的 char *buf

所以第一轮命中入口时,先记:

  • STR_OBJ = RCX
  • BUF = qword ptr [RCX]
  • ENTRY_RSP = RSP
  • RET_ADDR = qword ptr [RSP]

然后以后都改 BUF + 偏移 的内容,不去碰 string 元数据。


为什么我建议你在 0x3129 直接读整块,而不是盯着单个 [rsp+rax+168h+var_108]

你那条表达式其实可以化简成:

text
[rsp + rax + 0x60]

但更稳的做法不是只读一个,而是一次把 8 个 dword 全读出来

text
[rsp+0x60], [rsp+0x64], ... [rsp+0x7C]

因为到比较开始那里,输出数组已经都准备好了。
这样你就不依赖:

  • 断点是不是正好下在那条 cmp
  • RAX 此刻是不是你预期的那个偏移

你如果现在只爆前三字节,那就直接看:

text
slot0 = [rsp + 0x60]

以后想爆第 k 组,就改:

  • 写入位置:BUF + 3*k
  • 读取位置:[rsp + 0x60 + 4*k]

实操步骤

1)准备输入

先让程序正常跑一次,输入一个固定长度的假串
我建议你用 24 字节假串,而不是 21。

原因不是“21 不行”,而是你前面已经被 21 字节那个“最后一组重复”现象恶心过了。
既然现在是做 oracle,干脆一开始就把环境弄干净。

比如:

text
AAAAAAAAAAAAAAAAAAAAAAAA

2)下断点

在 DLL 里下:

  • module_base + 0x2E70
  • module_base + 0x3129

第一次命中 0x2E70 时,再动态加:

  • RET_ADDR = [RSP]

也就是这个函数返回到 caller 的地址。


3)循环逻辑

整轮循环应该是这样:

  1. 命中 0x2E70

    • 如果是第一次,保存 RCX / BUF / ENTRY_RSP / RET_ADDR
    • 把当前候选 3 字节写进 BUF + PATCH_OFF
  2. 继续运行,命中 0x3129

    • 直接读 RSP+0x60 开始的 8 个 dword
    • 取你关心的那一组输出,打印
  3. 继续运行,命中 RET_ADDR

    • 说明这一轮函数正常返回了
    • 准备下一个候选 3 字节
    • RSP 恢复成 ENTRY_RSP
    • [ENTRY_RSP] 再写回 RET_ADDR
    • RCX 恢复成 STR_OBJ
    • RIP 改成 ENTRY_EA
    • 再继续运行

这样就形成了一个“函数返回后,重新伪造一次刚进函数的现场”的闭环。


给你一份按这个思路写的 IDAPython 骨架

你把 ENTRY_EAOBS_EA 改成运行时地址就能用。
如果你现在只爆前三字节,就保持 PATCH_OFF = 0READ_SLOT = 0

python
import itertools import struct import ida_dbg import ida_kernwin import idc # ===== 你自己填:运行时地址,不是纯 RVA ===== ENTRY_EA = 0x180002E70 # module_base + 0x2E70 OBS_EA = 0x180003129 # module_base + 0x3129 # 爆第几组: # 第0组 -> patch buf[0:3],读 out[0] # 第1组 -> patch buf[3:6],读 out[1] PATCH_OFF = 0 READ_SLOT = PATCH_OFF // 3 # 可打印字符 GEN = itertools.product(range(33, 127), repeat=3) st = { "inited": False, "entry_rsp": None, "ret_addr": None, "str_obj": None, "buf": None, "cur_raw": None, "cur_txt": None, "count": 0, } def next_candidate(): a, b, c = next(GEN) raw = bytes((a, b, c)) txt = ''.join(map(chr, (a, b, c))) return raw, txt def log(msg): ida_kernwin.msg(msg) class OracleHook(ida_dbg.DBG_Hooks): def dbg_bpt(self, tid, ea): try: # 1) 函数入口:拿对象/缓冲区,写入当前候选 if ea == ENTRY_EA: rcx = ida_dbg.get_reg_val("RCX") rsp = ida_dbg.get_reg_val("RSP") if not st["inited"]: st["entry_rsp"] = rsp st["ret_addr"] = idc.read_dbg_qword(rsp) st["str_obj"] = rcx st["buf"] = idc.read_dbg_qword(rcx) # 真正 char* 缓冲区 ida_dbg.add_bpt(st["ret_addr"]) st["inited"] = True log(f"[init] STR_OBJ={st['str_obj']:#x} " f"BUF={st['buf']:#x} " f"ENTRY_RSP={st['entry_rsp']:#x} " f"RET={st['ret_addr']:#x}\n") if st["cur_raw"] is None: raw, txt = next_candidate() st["cur_raw"], st["cur_txt"] = raw, txt # 只改3字节,不碰长度/容量 n = idc.write_dbg_memory(st["buf"] + PATCH_OFF, st["cur_raw"]) if n != 3: log(f"[warn] write_dbg_memory wrote {n} bytes\n") ida_dbg.request_continue_process() return 1 # 2) 比较开始:直接读整个输出数组 if ea == OBS_EA: rsp = ida_dbg.get_reg_val("RSP") out_base = rsp + 0x60 # 168h + var_108 = 0x60 vals = [idc.read_dbg_dword(out_base + 4 * i) for i in range(8)] v = vals[READ_SLOT] log(f"{st['count']:06d} {st['cur_txt']!r} -> " f"slot{READ_SLOT} = 0x{v:08X} all={list(map(lambda x: hex(x), vals))}\n") st["count"] += 1 ida_dbg.request_continue_process() return 1 # 3) 函数正常返回:准备下一组,然后伪造“重新进入函数” if st["inited"] and ea == st["ret_addr"]: try: raw, txt = next_candidate() st["cur_raw"], st["cur_txt"] = raw, txt except StopIteration: log("[done] enumeration finished. debugger stays suspended.\n") return 0 # 把“刚进入函数时”的返回地址重新放回栈顶 idc.write_dbg_memory(st["entry_rsp"], struct.pack("<Q", st["ret_addr"])) # 恢复入口现场 ida_dbg.set_reg_val("RSP", st["entry_rsp"]) ida_dbg.set_reg_val("RCX", st["str_obj"]) ida_dbg.set_reg_val("RIP", ENTRY_EA) ida_dbg.request_continue_process() return 1 except Exception as e: log(f"[ERR] {e}\n") return 0 # 安装 hook hook = OracleHook() hook.hook() ida_dbg.add_bpt(ENTRY_EA) ida_dbg.add_bpt(OBS_EA) log("[*] Oracle hook installed.\n")

这个脚本干的事,本质上就是把你现在 1.py 里的三重枚举保留不动,只是把“输出端”从你自己复现的哈希,改成“直接从 DLL 里读出来”。你当前脚本也确实是 range(33,127) 的三重枚举。(1.py)


你真正要注意的坑

坑 1:别把 RCX 当字符数组

先看清:

  • RCX 是不是 string 对象
  • [RCX] 才是不是 char *

你这题里,大概率要改的是 [RCX] 指向的缓冲区。


坑 2:别在 0x3129 直接回跳到 0x2E70

短测可以,长跑不建议。
因为你会跳过这一轮函数的正常 return 和清理。

所以我才说:最少补一个返回地址断点


坑 3:地址要用运行时 EA,不是裸 RVA

0x2E70 / 0x3129 是相对 DLL 基址的。
脚本里填的是:

python
module_runtime_base + 0x2E70 module_runtime_base + 0x3129

坑 4:write_dbg_memory 如果嫌 bytes

某些版本/包装层如果对 bytes 挑剔,就写成:

python
idc.write_dbg_memory(addr, raw.decode("latin-1"))

IDA 官方提供的这些调试接口本来就是干这个的:
DBG_Hooks 能接收断点事件,get_reg_val / set_reg_val 能读写寄存器,read_dbg_memory / write_dbg_memory 或 IDC 的 read_dbg_qword 这类函数能直接读写被调试进程内存,request_continue_process 用来继续执行。(IDAPython Documentation)


我对你这条路线的评价

这条路线完全成立,而且挺有价值:

  • 不依赖你先看懂算法
  • 能先验证“3 字节 -> 1 个输出槽”这个结构
  • 能在逆向一半时先把明文/密文字典打出来

但它不是比赛最速解。
它更像是:

  • 不想继续看算法时的止损手段
  • 或者验证你静态分析猜想是否正确的手段

你要是愿意,我下一条可以直接把这份脚本再往前推一步,给你改成:

  • PATCH_OFF 可切换的版本
  • 自动只打印命中目标常量的版本
  • 或者干脆帮你改成“爆第 k 组三字节”的成品模板。

Partager cette Q&R