篡改函数开头的控制流

在逆向 pvz 的时候,有这样的一个射击函数,它必须在存在有效目标的情况下才能继续执行:

1
2
3
4
5
6
7
8
9
10
11
12
char __userpurge Plant::FindTargetAndFire(
eax = Plant * this,
int theRow,
PlantWeapon thePlantWeapon
) {
TargetZombie = Plant::FindTargetZombie(...);
// ↓ 这里进行了目标判断
if ( !TargetZombie )
return 0;
// 下边是正常的射击逻辑
...
}

但我其实想的是不管有没有目标,都能进行射击。话不多说看一眼控制流:

很显然需要走右边

绿色的部分是 IDA 自动标注的 Plant::FindTargetZombie(...) 调用汇编指令,需要注意的是这个函数为 thiscall 调用约定。

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
0x0045EF10 ; 函数序言开始
0x0045EF10 push ecx ; 保存非易失性寄存器(函数序言)
0x0045EF11 mov ecx, [esp+4+8] ; 将 this 存入 ecx(函数调用)
0x0045EF15 push ebx ; 保存非易失性寄存器(函数序言)
0x0045EF16 push ebp ; 保存非易失性寄存器(函数序言)
0x0045EF17 push esi ; 保存非易失性寄存器(函数序言)
0x0045EF18 mov esi, eax ; eax 存了传给这个函数的 this,毕竟是 userpurge
0x0045EF1A mov eax, [esp+10h+4] ; 压栈参数(函数调用)
0x0045EF1E push edi ; 压栈参数(函数调用)
0x0045EF1F push eax ; 压栈参数(函数调用)
0x0045EF20 push esi ; 压栈参数(函数调用)
0x0045EF21 call Plant__FindTargetZombie ; 调用函数(函数调用)
0x0045EF21
0x0045EF26 test eax, eax ; 处理返回值
0x0045EF28 mov [esp+14h-4], eax ; 处理返回值
0x0045EF2C jnz short loc_45EF38 ; if 判断
0x0045EF2C
0x0045EF2E xor al, al ; 函数尾声
0x0045EF30 pop edi ; 函数尾声
0x0045EF31 pop esi ; 函数尾声
0x0045EF32 pop ebp ; 函数尾声
0x0045EF33 pop ebx ; 函数尾声
0x0045EF34 pop ecx ; 函数尾声
0x0045EF35 retn 8 ; 函数尾声
0x0045EF35
0x0045EF38 ; --------------------------------
0x0045EF38
0x0045EF38 loc_45EF38:
0x0045EF38 ; 真正的射击函数

这段碰巧在函数最开头。也就是说,通过简单复原函数序言,就可以直接跳到这个 jnz 后边运行下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#[unsafe(naked)]
pub extern "thiscall" fn FireWithoutTarget(
this: *mut Plant,
theRow: i32,
thePlantWeapon: i32
) {
naked_asm!(
"mov eax, ecx", // 把 ecx 放进函数想要的 eax
"push ecx",
"push ebx",
"push ebp",
"push esi",
"mov esi, eax",
"push edi",
"mov edx, 0x0045EF38",
"jmp edx", // 直接跳到条件判断后边
)
}

这个函数除了复原函数序言,以及额外的 ecx -> eax,根本不用管其他参数,反正是按照调用约定压栈的,一切就交给原本的函数吧!

顺带一提这个裸函数最后也没有 ret,是因为调用这个函数时 call 压栈的返回地址会被原函数的 ret 使用,于是变相成功返回。


篡改函数开头的控制流
https://mygo.plus/articles/fake-prologue/
作者
Peter Shen
发布于
2025年12月31日
许可协议