看这道逆向赛题,我已经得到了flag:GXY{I_have_no_gir1_frieN,1.py是我...
बनाया गया: 9 अप्रैल 2026
का उपयोग करके उत्तर दिया गया GPT-5.4 Pro द्वारा Chat01
बनाया गया: 9 अप्रैल 2026
का उपयोग करके उत्तर दिया गया GPT-5.4 Pro द्वारा Chat01
看这道逆向赛题,我已经得到了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位可打印字符还可以吗,算是比较好的吧,没有大幅度浪费资源吧
先说最重要的:你不是废物。你卡 4~5 小时,核心不是“你不会”,而是这题里确实有一个会污染动态现象的实现 bug,而且它正好卡在你黑盒最依赖的 base64 这层。你看到的“21 个字符怎么会变成 32 字节”“现象前后对不上”,不是你看错了,是程序本身就在制造噪音。
我把你这个 DLL 的导出函数拆了一遍,结论如下。
不是标准 base64 的行为。
这题的 String_to_long 逻辑其实是:
关键 bug 在它的 base64 子函数:
所以会出现这种现象:
所以你动态里观察到“21 输入 -> 32 字节”这件事,是对的。
更进一步,校验函数虽然会把所有 4-char block 都切出来,但它真正只比较前 8 组。这就导致:
所以你之前那个“最后三字节好像被 base64 了两次”的直觉,其实已经很接近真相了。更准确地说,是:
长度为 3 的倍数时,base64 输出会重复最后一个 4-char block;但最终比较只看前 8 组。
这也解释了为什么这题最自然的解法是按 3 字节分块爆破。
顺手一提,按我复现的这套逻辑,GXY{I_have_no_gir1_frieN} 这种多补一个 } 的 25 字节输入,前 8 组仍然不变,因为第 25 个字节只会落到第 9 组,不影响比较。也就是说,这题实际上校验的是前 24 个字节前缀,不是“严格唯一完整 flag”。
.lib 是干什么的在 MSVC/Windows 这套工具链里,.lib 常见有两种:
.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能,而且其实有三条路。
第一条,也是你最后走到的这条,最适合这题:
这条路是最省时的,因为这题的状态不跨块。
第二条,写个程序外 wrapper/harness:
LoadLibrary + GetProcAddressstring_to_intDll.libString_to_long(std::string&)这条路的好处是:你几乎不用还原内部逻辑。坏处是:这个导出函数参数是 std::string&,直接拿 Python ctypes 调会比较别扭,通常得先写个 C++ 包装层。
第三条,IDA 里直接黑盒调用。Hex-Rays 官方的 Appcall 就是干这个的:在调试会话里,直接从 IDC/IDAPython/CLI 调试目标进程内函数,适合 fuzz、黑盒调用、快速验证。只是它很依赖正确原型;遇到这种 std::string& 的 C++ 导出,写 wrapper 往往比硬怼 Appcall 更省心。(Hex-Rays Docs)
所以你这题下次最优策略其实是:
有,最实用的是这几个:
第一,光标停在可跳转的地址/名字/操作数上直接按 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)
实战里我一般这么用:
EnterEscG这套会比“复制地址 -> Ctrl+G”顺手很多。
for 里面,tab 不自动对齐,很难受,怎么办最稳的是两招:
editor.formatOnPasteCtrl+K Ctrl+F(格式化选中)和 Shift+Alt+F(格式化全文)的肌肉记忆VS Code 官方文档里有这几个点:
Format Selection 是 Ctrl+K Ctrl+FFormat Document 是 Shift+Alt+Feditor.formatOnPaste 可以在粘贴时格式化粘贴内容editor.insertSpaces、editor.tabSize、editor.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)
结论:完全可以,而且思路是对的。
你的脚本本质上是在做:
这条路在这题上非常合理,因为最终比较确实就是按 4-char block 独立进行的;换回原文就是按 3 字节块独立。所以你没有“大幅浪费资源”,反而是抓到了这题最值钱的结构。你的脚本方向本身是对的。(1.py)
复杂度也不大:
range(33,127),一共 94 个字符94^3 = 83058483 万次这种级别,对这种 CTF 脚本来说非常轻。
而且我把 8 个目标在这个字符集里过了一遍,每个常量都是唯一命中:
GXY{I_have_no_gir1_frieN所以你的脚本不仅能跑,而且信息量很高。
只能说还有几处“润色级”优化,不是“方向级”优化:
bytes((x,y,z)) 代替 chr(x)+chr(y)+chr(z) 再 encode()ifbreakbase64.b64encode,自己直接按 3 字节算 4 个 base64 字符但这些都只是锦上添花,不是你慢的主因。你慢的主因是前面花太多时间在不干净的黑盒现象上,不是脚本写差了。
最后给你一个以后压缩时间的止损模板:
你这次不是“做不会”,而是路径选择稍微晚了点。这次学到的不是题解本身,而是“什么时候该停止黑盒、什么时候该切静态”。这个经验值比这题 flag 更值钱。
我明白了,但是第三点爆破我还是想继续问,我的做法只是爆破三字节,这是建立在已知base64+自定义加密的基础上的,然而我其实可以不用知道是base64+自定义加密,反正输入就是3字节,输出就是4字节,关键是如何把这一整个过程提取出来,我觉得IDA里面实现这一点是很方便的,直观一点,就像我开启(127-33)^3次程序,每次输入的前三字节不同,总能把密文对应的明文确定下来,我该怎么实现呢
我明白了,但是第三点爆破我还是想继续问,我的做法只是爆破三字节,这是建立在已知base64+自定义加密的基础上的,然而我其实可以不用知道是base64+自定义加密,反正输入就是3字节,输出就是4字节,关键是如何把这一整个过程提取出来,我觉得IDA里面实现这一点是很方便的,直观一点,就像我开启(127-33)^3次程序,每次输入的前三字节不同,总能把密文对应的明文确定下来,我该怎么实现呢
可以,而且这条路本质上就是对的:不是把算法还原出来,而是把程序本身变成 oracle。你现在的 1.py,本质是把“观测点”放在你自己复现的 Python 里;你想要的是把观测点放回 DLL 里。(1.py)
先说一个关键边界:如果黑盒最终只给你一个 bool,那信息量不够,你没法只靠“过/不过”恢复出 32 位中间值。你至少要做一点点“灰盒”工作,找到一个会泄露中间结果的位置。好消息是,你这份 DLL 里这个位置非常明显:
module_base + 0x2E70module_base + 0x30F3,目标数组在 RSP + 0x60module_base + 0x3129module_base + 0x3390所以你完全可以不理解 base64、不理解哈希数学,只把 RSP+0x60 当“密文输出缓冲区”来读。
真正该做的,不是 (127-33)^3 次重启程序,而是 (127-33)^3 次函数调用。重启 83 万次,时间全耗在进程启动、输入输出和调试器 attach 上了;黑盒爆破要省时,核心就是“一个活进程里重复喂数据”。
你这题在 IDA 里,最顺手的做法其实是下面这套。
std::string不要直接从“程序跑完一轮以后”去复用对象,那样有对象生命周期问题。最稳的是:
String_to_long 的那条 call(通常是调 IAT:__imp_?String_to_long...)。AAAAAAAAAAAAAAAAAAAAAAAA。call 之前断下。此时:
RCX 就是传给 DLL 的 std::string 对象地址std::string然后进入批量模式:
module_base + 0x3129,这是比较开始的位置。char buffer 覆写成 候选 3 字节 + 固定 21 字节填充Appcall 直接调 module_base + 0x2E700x3129 时,读 RSP + 0x60 的 8 个 dwordHex-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)
你不是最在意“如何不靠 base64 知识也发现块结构”吗?那就先别 brute,先做一个依赖性扫描:
Abase[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 或哈希公式。这一步特别值钱,因为它能把“猜测分块”变成“证据分块”。
下面给你的是骨架,不是一贴即跑版;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 其实只有两个:
std::string 对象”bool这两点一有,整个过程就变成“黑盒表驱动”。
你可以把它们分工一下:
想直观看一轮到底发生了什么:用 Manual Appcall
它会把调用上下文搭好,然后你从函数头一路单步到 0x3129,看 RSP+0x60 怎么出来,最后 cleanup_appcall() 回去。这个模式就是拿来“调一轮、看路径”的。(Hex-Rays Docs)
想批量跑 83 万次:用 普通 Appcall + 断点回调
不要单步,不要手工输,不要重启进程。
如果你愿意把“找观测点”放在 IDA、把“批量喂数据”放到程序外,其实更稳:
0x3390LoadLibrary("string_to_intDll.dll")std::string 自然构造,不用你在 IDA 里硬捏对象这通常比 IDA 里直接长期 Appcall 更稳,因为 std::string 这种 C++ ABI 对象,交给同 ABI 的 C++ 编译器去构造最省心。
但如果你这次就是想练“IDA 里把二进制变成 oracle”,那上面那套已经够用了。
你下次想节省时间,不是去问“这是不是 base64”,而是先问:
我能不能在 compare 前把中间结果读出来?
一旦答案是能,这题就从“逆算法题”变成“造 oracle 题”了。
下一条我可以直接给你补成一份更接近可粘贴运行的 IDAPython 骨架,只保留两个你手填的地址:STR_OBJ 和 CHARBUF。
现在按我的思路教我实现:
我先在dll文件的module_base + 0x2E70和module_base + 0x3129断点,调试dll文件,不断改变module_base + 0x2E70第一行指令rcx指向地址的21字节数组的前三字节,然后在module_base + 0x3129记录[rsp+rax+168h+var_108]的值并输出。
appcall我感觉不太灵活,如果我只想要截取某个函数的某一部分,这样就不太灵活
可以,按你的路子来,而且不用 Appcall。
但我先把最关键的一点说死:
只靠
0x2E70和0x3129两个断点,适合手动一轮一轮看;想自动跑完 83 万组,最好再补一个“函数返回点”断点。
原因很简单:
你如果在 0x3129 读完值之后,直接把 RIP 改回 0x2E70,等于把这次函数调用半路腰斩了,本地对象析构和收尾都没走完,长跑很容易出问题。
而 Appcall 官方机制本来就是“劫持当前线程栈、把 IP 临时改到函数开头,等函数返回后再恢复现场”,所以它更像“整函数 oracle”;你现在这个需求更适合断点驱动的状态机。(Hex-Rays Docs)
你这条路最稳的版本是:
module_base + 0x2E70:函数入口,拿到 RCX 和真正字符缓冲区module_base + 0x3129:读输出数组0x2E70这样你就实现了:
另外,IDA 的断点条件确实可以直接用寄存器名和内存表达式,甚至支持脚本语言;断点本身也会存在数据库里,模块加载后会尽快恢复。所以“在 DLL IDB 里下断,然后启动会加载它的 EXE”这条路是成立的。(Hex-Rays Docs)
在 0x2E70 处,RCX 大概率不是 21/24 字节字符数组本体,而是 std::string 对象。
你真正要改的,通常是:
RCX:std::string 对象地址[RCX]:真正的 char *buf所以第一轮命中入口时,先记:
STR_OBJ = RCXBUF = qword ptr [RCX]ENTRY_RSP = RSPRET_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]
因为到比较开始那里,输出数组已经都准备好了。
这样你就不依赖:
cmpRAX 此刻是不是你预期的那个偏移你如果现在只爆前三字节,那就直接看:
textslot0 = [rsp + 0x60]
以后想爆第 k 组,就改:
BUF + 3*k[rsp + 0x60 + 4*k]先让程序正常跑一次,输入一个固定长度的假串。
我建议你用 24 字节假串,而不是 21。
原因不是“21 不行”,而是你前面已经被 21 字节那个“最后一组重复”现象恶心过了。
既然现在是做 oracle,干脆一开始就把环境弄干净。
比如:
textAAAAAAAAAAAAAAAAAAAAAAAA
在 DLL 里下:
module_base + 0x2E70module_base + 0x3129第一次命中 0x2E70 时,再动态加:
RET_ADDR = [RSP]也就是这个函数返回到 caller 的地址。
整轮循环应该是这样:
命中 0x2E70
RCX / BUF / ENTRY_RSP / RET_ADDRBUF + PATCH_OFF继续运行,命中 0x3129
RSP+0x60 开始的 8 个 dword继续运行,命中 RET_ADDR
RSP 恢复成 ENTRY_RSP[ENTRY_RSP] 再写回 RET_ADDRRCX 恢复成 STR_OBJRIP 改成 ENTRY_EA这样就形成了一个“函数返回后,重新伪造一次刚进函数的现场”的闭环。
你把 ENTRY_EA 和 OBS_EA 改成运行时地址就能用。
如果你现在只爆前三字节,就保持 PATCH_OFF = 0、READ_SLOT = 0。
pythonimport 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)
RCX 当字符数组先看清:
RCX 是不是 string 对象[RCX] 才是不是 char *你这题里,大概率要改的是 [RCX] 指向的缓冲区。
0x3129 直接回跳到 0x2E70短测可以,长跑不建议。
因为你会跳过这一轮函数的正常 return 和清理。
所以我才说:最少补一个返回地址断点。
0x2E70 / 0x3129 是相对 DLL 基址的。
脚本里填的是:
pythonmodule_runtime_base + 0x2E70 module_runtime_base + 0x3129
write_dbg_memory 如果嫌 bytes某些版本/包装层如果对 bytes 挑剔,就写成:
pythonidc.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)
这条路线完全成立,而且挺有价值:
但它不是比赛最速解。
它更像是:
你要是愿意,我下一条可以直接把这份脚本再往前推一步,给你改成:
PATCH_OFF 可切换的版本