Skip to content
2775 字

基于 FA2sp 的逆向小记

参考资料:

背景

FA2sp 是为了改善红警 2 地图编辑器 FinalAlert2(下面简称 FA2)的使用体验而开发的扩展库。它通过 Syringe 注入 Hook 的方式,无需修改 FA2 本体便能享受到扩展的功能和修复。

2024年3月8日,EA 在发布 Steam 版红警 2 时,终于把 FA2 的源码放了出来,自此 FA2sp 完成了历史使命。彼时我的考研刚刚坠机,本着“用进废退”的思想[1],我打算温习下学习 408 得来的船新经验:寄组的汇编和操作系统的进程并发。于是才疏学浅的我结合自学来的粗略理解,尝试研究 FA2sp 项目作者 @secsome 留下来的逆向成果——finalalert2yr.exe.idb

复习一下寄组

NOTE

我事非科班生,对汇编的认识仅限考研 408 计算机组成原理对“指令系统”的考察。
其实我参加的是 24 考研(2023.12.23-24),但回去翻考研群已经只剩 23 考研的资料了。

FA2 显然是 Intel x86 架构的程序,恰好 24 考研主要考 x86 汇编。

寄存器

除了考研常考的通用寄存器e[abcd]x、帧指针ebp栈指针esp外, 在遇到 Fatal Error 时,我们还重点关注except.txt里的eip寄存器:

EIP: 00534096	ESP: 013A89D4	EBP: 013A89FC
EAX: 00000000	EBX: 00886240	ECX: 00886240
EDX: 003F5000	ESI: 00886230	EDI: 2A3C0000

在 FA2sp 里,这些寄存器可以通过 Syringe Hook 定义里的REGISTERS *R指针参数存取。

跳转汇编指令

IMPORTANT

考虑到王道书里介绍的多数基本运算指令在分析 FA2 中意义并不大,这里就直接跳过了。
完整版的 x86 汇编指令介绍还请移步《汇编语言程序设计》或者《汇编原理》之类的课程,恕不浪费太多时间咯。

常规:Jump 系列

分为 jmp 无条件跳转,和 jcondition 有条件跳转两种。其中条件跳转可以部分参考 pwsh 的比较:

条件跳转指令字PowerShell 比较
je (Equal ==)-eq
jne (Not Equal !=)-ne
jz (Zero == 0)-eq 0
jg (Greater than >)-gt
jge (Greater than or Equal to >=)-ge
...

在 IDA 中,jmp j...通常跟的是标签(如LABEL_20),标签用于指代某一个虚拟地址(32 位程序基址0x400000)。 跳转指令认出标签指代的地址后,将 EIP 寄存器设为该地址,CPU 从那里继续取指、间址(可能跳过)、执行、中断(可能跳过)四部曲。

特殊:函数调用

主要是callret这一对。

call lbl是父级函数去“调用”。它会把函数参数、下一指令地址压入栈,然后无条件跳转到lbl标签指代的地址(同时改变 EBP 的值,以便建立新的栈帧);

相对的,ret是子函数要“返回”。在回收子函数栈帧、还原 EBP 之后,ret指令会无条件跳转回先前执行到的位置。

INFO

回收栈帧、还原回父级函数的 EBP 这两步由leave指令完成,
相当于mov esp, ebppop ebp,详见「栈帧」。

栈帧

函数的执行是由进程的栈空间管理的(相应的,malloc new之类则从堆空间申请内存),正所谓“函数调用栈”。 栈帧通常会记录局部变量等临时用到的数据,同时也是实现函数调用的重要跳板。

设有这么两段代码:

c
int eg_sub(int x, int y) { return x * y; }
int example() {
  int a = 10;
  int b = eg_sub(a, 1024);
  return b - a;
}

又假设example()被 main 函数调用,那么栈帧可能会是这种分布:

地址...备注
0x524...
0x520main()的 EBP)example()栈帧从这里开始
0x51Cint a = 10
0x518int b
0x510(空余 8B)gcc要求栈帧大小为 16B 的整数倍
0x50C1024参数 y
0x50810参数 x,即复制 a 的值
0x504调用eg_sub()时 EIP 指向的下一指令地址亦即被call压栈、ret返回的地址;
example()栈帧到此结束
0x500example()函数的 EBP)这里是eg_sub()的栈帧了
......

调用eg_sub()前,首先把参数y x压栈(cdecl约定采取反向入栈)。 对于int b那一行语句,我们不妨拆成这样的汇编指令:

push ecx     # 设 a=10 位于 ecx
call eg_sub  # 函数调用,返回值在 eax
mov ebx, eax # 假设 b 在 ebx,把返回值赋给 b

那么执行到call指令时,EIP 指向下一条mov指令,于是call指令保存(入栈)EIP 的值,放心地跳转到eg_sub的指令地址去了。

在进入eg_sub那里之后,首先建立它自己的栈帧:

push ebp
...
mov ecx, [ebp + 12]  # 假设 ecx 存 y
mov edx, [ebp + 8]   # 假设 edx 存 x
...

执行完之后保存返回值mov eax, ...,回收栈帧、还原现场leave,然后ret指令跳转回example()ret指令把执行call指令时的“下一指令地址”弹回 EIP 寄存器,然后 CPU 就若无其事地继续跑 example 函数了。

NOTE

王道计组书和视频课「过程调用的机器级表示」那一节对于call ret指令以及栈帧的介绍可能更清楚一点。 24 考研距写作日期也有半年余了,恕我没有办法准确地复述出来。

寻址方式

上面讲栈帧出现了个[ebp + 12],涉及到两种寻址:寄存器间接寻址和 EBP“相对寻址”。

IMPORTANT

注意我这“相对寻址”是打了引号的,因为并不是以 PC(或者说 IP、EIP 寄存器)为基准的相对,而是 EBP。

首先是 EBP 寻址。进程由操作系统管理,其堆栈空间在内存中开辟。既然如此,EBP 和 ESP 的值实际上就是指向内存中栈空间的地址。 比如在上面「栈帧」里举的例子,执行example()函数主体时,[EBP]=0x520,[ESP]=0x504; 进入eg_sub()函数调用后,[EBP] 则变为 0x500。

于是,我们可以对栈指针 ESP 和帧指针 EBP 做加减运算,找出函数参数、局部变量等信息。 例如上面建立eg_sub的栈帧时把函数参数从栈里读出来(不是pop出栈),就用eg_sub的 EBP 往上加。 由于两个栈帧之间总隔着一个“返回地址”,所以第一个参数并不是+4,而是+8。 而相对的,访问局部变量可以用 EBP 往下减,EBP - 4EBP - 8,之类的。

通常来说,ESP 容易受pop push指令的影响,比较“多动”;而 EBP 相比起来更“安稳”一些。
当然 ESP 寻址肯定是有的,Syringe 里的XXX_STACK就是 ESP 寻址。只是 EBP 寻址我讨论起来方便。

其次是寄存器间接寻址。对 EBP 指针做加减运算,找到参数、局部的地址之后,还需要做一次间接寻址,去内存里把真正的数据抓出来。
间接寻址不需要你操心,我只是让你注意寄存器旁边的中括号而已:

mov eax, ebx    # 把 EBX 寄存器里的值直接传给 EAX
mov eax, [ebx]  # 把 EBX 里的内存地址取出来,再读那个内存地址,把数据传进 EAX。

初探 IDA

案例

FA2 的“国家”和“所属”是靠后缀区分的,国家直接取自 Rules*.ini,所属则是在国家基础上添加了 House后缀,比如国家YuriCountry和所属YuriCountry House
默认在触发编辑器属性页里,触发所属方会截断空格,只许你选“国家”。现要求把这个碍事的截断给干掉,方便我们实现多人合作地图的“所属”关联。

逆向分析

有源码做题就是快

注意到TriggerOptionsDlg.cpp里关于“触发所属方”的事件定义:

cpp
void CTriggerOptionsDlg::OnEditchangeHouse()
{
    // ... 前面忘了

	CString newHouse;
	m_House.GetWindowText(newHouse);  // 实际是 GetWindowTextA

	// FA2 读完所属会用 CSF 本地化这些窗口控件的所属名字(但是非常鸡肋)
    // 这一步又把本地化的所属翻译回 INI 的所属 ID
	newHouse=TranslateHouse(newHouse);

	newHouse.TrimLeft();
    // 如果你英语好一点,空格 => space,你便已经找到要淦的位置了:
	TruncSpace(newHouse);

    // ... 后面忘了
}

右键对TruncSpace转到定义,可以在functions.cpp发现:

cpp
void TruncSpace(CString& str)
{
	str.TrimLeft();
	str.TrimRight();
	if(str.Find(" ")>=0) str.Delete(str.Find(" "), str.GetLength()-str.Find(" "));
}

于是确定我们要干掉的就是这个TruncSpace

当然现状是红警 2 的地图创作仍然离不开 handama/FA2sp,改源码没什么意义。

开始之前赞美一下书伸,书门!(
没有书伸的成果,我不可能很快找出待修改函数的虚拟地址。

在 32 位 IDA 里新建一个反编译项目,打开 FA2 的主程序。我们案例要淦的函(方)数(法)位于0x501D90,在菜单栏Jump里找到Jump to address,把这个地址复制进去确认。
默认它会切换为 Graph View,你需要右键改为 Text View:

IDA 默认的图表模式

往下翻到.text:00501E58,注意到GetWindowTextA这个 WinAPI。如果你翻看了上面的源码,就会发现我们离目标不远了。

TIP

引用的 API,比如说 WinAPI 或者 CString 类的 API,地址通常都比较靠后。 在 Text View 里双击那个GetWindowTextA,可以发现地址跑到0x553134去力(瞄完可以用工具栏上的左箭头返回我们正文看的位置)。 所以接下来不要认错函数调用咯。

借着上面的提示,同屏GetWindowTextA后面只剩两个怀疑对象:sub_43C3C0sub_43EA90

找出附近的函数调用

接下来看看这两个嫌疑函数的特征。直接菜单栏ViewOpen subviewsGenerate pseudocode (F5)生成反汇编代码,于是我们得到案例方法的 C 式伪代码:

辨认嫌疑伸

由上面的源码可得,截断空格的函数TruncSpace只有一个参数,至此我们确定是sub_43EA90背锅。

编写 Hook

目前我已知两种 Hook 用法,我们这里写的 Hook 是第二种用途:

  • 在原函数里新增内容实现扩展(return 0
  • 绕过(或覆盖)原函数的执行流程(return到目标地址)

背景芝士:Syringe

Syringe.h提供了定义 Hook 的宏:

cpp
#define EXPORT_FUNC(name) extern "C" __declspec(dllexport) DWORD __cdecl name (REGISTERS *R)

#define DEFINE_HOOK(hook, funcname, size) \
declhook(hook, funcname, size) \
EXPORT_FUNC(funcname)

更详细的介绍可以翻 Zero Fanker 的 Ares Wiki。这里只需要知道,写 Hook 靠DEFINE_HOOK准没错。
然后解释一下DEFINE_HOOK这个宏要补的三个参数:

  • hook:即你要灌注(覆盖)的地址。

毕竟你外部定义的 Hook 不可能凭空插入原程序里,肯定需要遮掉原有的一部分指令机器码,才有机会跳转到你的 Hook。

  • funcname:即你的 Hook 名字。

WARNING

虽然 Hook 名字实际上就是 DLL 导出的函数名字,但并不推荐随性的命名。最好还是讲清楚你淦的原函数叫什么,或者你写这个 Hook 要做什么。

  • size:即 Hook 覆盖多少字节的原函数指令码(bixv >= 5B)

简单提一嘴 Syringe 如何“灌注”Hook:

完整版可以参考 Thomas 写的高阶知识:Syringe 的工作原理

浓缩版就是,向hook的地址那里写入jmp无条件跳转指令。由于jmp指令码本身占 1B,后面跟的虚拟地址总是占 4B,故size至少得是 5。 那倘若要覆盖超过 5B 的机器代码呢?答案是多余部分用nop(空指令,什么也不做)填充。

西瓜猫猫头

注意事项

我们这里针对的是函数调用,需要注意 C++ 的函数执行完成后会触发栈区局部变量的析构函数(通常是空间回收),因此并不建议把传参的汇编指令也给覆盖掉。

就这个例子而言,只需覆盖call指令:

TIP

在 IDA 选项(Options > General)里,右上角勾选Stack Pointer,把Number of opcode bytes改为 8,确认即可看到机器码视图。
然后你就会发现call指令刚好 5 个字节。

call 指令的机器码

实战

都有现成项目 FA2sp 了,你不会想着要白手起家吧?

在 FA2sp 项目里依次打开FA2sp\Ext\CTriggerOption,在Hooks.cpp里添一个 Hook:

cpp
DEFINE_HOOK(501EAD, CTriggerOption_OnCBHouseChanged, 5)
{
  // 这里什么都不用做,我们只是跳过 FA2 截断空格那一步而已。
  return 0x501EB2;  
}

由于declhook宏设置 Hook 位置时已经标了0x(可以在 Visual Studio 里把鼠标移到宏上面预览展开的代码), 这里DEFINE_HOOK后面设置的地址就不需要再补0x了。

补充

REGISTERS 寄存器类

在上面的「背景芝士」中,注意到导出的 Hook 函数只有一个REGISTERS类的指针参数 R。
有时我们会需要获取原函数的实参、局部变量等信息,并加以修改,这时就要靠 R 指针获取了:

进阶知识:Hook 函数的用法

具体的例子还要结合已有的idb逆向成果自行意会。虽然函数调用的基本原理在计组那一块已有涉及, 但一个函数叫什么名字、里面什么寄存器对应什么变量,这些都是前辈们自行逆向出来的结论。对此,咱还是保留点最起码的尊重罢。


  1. 我学习的一大原动力就是应用。我要用什么,所以我学什么。反过来也差不多——有些知识太久没用到了,也就淡忘了。也算是种实用主义? ↩︎

更新于:

© 2026 SilverAg.L 版权所有
使用 VitePress & Miracle
Ag's Playground 已存在 34 天