简介
没看过的请看之前的第三篇。
本篇是第四篇,讲述怎么计算函数参数
函数参数识别
我们必须要知道一个事实是, 在没PDB之前,是没有一个准确的函数识别办法的,现如今所有的办法都是启发式的办法,这也是为什么逆向工具在遇到混淆的时候会拉闸。我们这边不介绍复杂的启发算法,只说一个简单的: 堆栈遍历+模式匹配 (只说X64的)
Windows x64调用约定
在讨论参数识别之前,我们需要了解Windows x64调用约定:
前4个参数通过寄存器传递:RCX, RDX, R8, R9
超过4个的参数通过栈传递,从RSP+0x20开始
即使参数少于4个,编译器也会在栈上为它们预留空间(称为”shadow space”)
以下是参数在栈上的布局:
RSP+0x08: 第1个参数的影子空间 (RCX)
RSP+0x10: 第2个参数的影子空间 (RDX)
RSP+0x18: 第3个参数的影子空间 (R8)
RSP+0x20: 第4个参数的影子空间 (R9)
RSP+0x28: 第5个参数(第一个栈参数)
RSP+0x30: 第6个参数(第二个栈参数)
以此类推…
实际案例分析:8参数函数
让我们通过一个具体例子来理解参数识别过程。以下是一个接受8个整型参数的函数的C代码和反汇编:
int __fastcall sub_140012510(int a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8)
{
j___CheckForDebuggerJustMyCode(byte_140023029);
return j_printf("wtf: %d n", (unsigned int)(a8 + a7 + a6 + a5 + a4 + a3 + a2 + a1));
}
关键汇编指令:
.text:0000000140012510 44 89 4C 24 20 mov [rsp-8+arg_18], r9d ; 保存第4个参数(a4)到影子空间
.text:0000000140012515 44 89 44 24 18 mov [rsp-8+arg_10], r8d ; 保存第3个参数(a3)到影子空间
.text:000000014001251A 89 54 24 10 mov [rsp-8+arg_8], edx ; 保存第2个参数(a2)到影子空间
.text:000000014001251E 89 4C 24 08 mov [rsp-8+arg_0], ecx ; 保存第1个参数(a1)到影子空间
.text:0000000140012522 55 push rbp ; 函数序言开始
.text:0000000140012523 57 push rdi
.text:0000000140012524 48 81 EC E8 00 00 00 sub rsp, 0E8h ; 分配栈空间
.text:000000014001252B 48 8D 6C 24 20 lea rbp, [rsp+20h] ; 设置帧指针
...
.text:000000014001253C 8B 85 E8 00 00 00 mov eax, [rbp+0D0h+arg_8] ; 访问a2
.text:0000000140012542 8B 8D E0 00 00 00 mov ecx, [rbp+0D0h+arg_0] ; 访问a1
.text:0000000140012548 03 C8 add ecx, eax ; a1 + a2
.text:000000014001254A 8B C1 mov eax, ecx
.text:000000014001254C 03 85 F0 00 00 00 add eax, [rbp+0D0h+arg_10] ; + a3
.text:0000000140012552 03 85 F8 00 00 00 add eax, [rbp+0D0h+arg_18] ; + a4
.text:0000000140012558 03 85 00 01 00 00 add eax, [rbp+0D0h+arg_20] ; + a5
.text:000000014001255E 03 85 08 01 00 00 add eax, [rbp+0D0h+arg_28] ; + a6
.text:0000000140012564 03 85 10 01 00 00 add eax, [rbp+0D0h+arg_30] ; + a7
.text:000000014001256A 03 85 18 01 00 00 add eax, [rbp+0D0h+arg_38] ; + a8
“启发式”参数识别过程
原理
简单来说我给每个寄存器访问的偏移搜集了一次,然后做置信度,一旦置信度超过X我们就认为是一个参数了.
分析函数序言
函数序言是识别栈帧结构的关键.通过跟踪PUSH RBP和SUB RSP, xxx等指令,我们可以计算栈调整大小并确定序言何时结束。
if (llil_ins->type == LLIL::LLILInstruction::ARITHMETIC &&
llil_ins->op == LLIL::LLILInstruction::PUSH) {
func->stackAdjustment += 8;
if (!llil_ins->operands.empty() &&
llil_ins->operands[0]->type == LLIL::LLILOperand::REGISTER &&
llil_ins->operands[0]->value == "rbp") {
func->foundPushRbp = true;
}
} else if (llil_ins->type == LLIL::LLILInstruction::ARITHMETIC &&
llil_ins->op == LLIL::LLILInstruction::SUB &&
llil_ins->operands.size() >= 2 &&
llil_ins->operands[0]->type == LLIL::LLILOperand::REGISTER &&
llil_ins->operands[0]->value == "rsp" &&
llil_ins->operands[1]->type == LLIL::LLILOperand::IMMEDIATE) {
func->stackAdjustment += llil_ins->operands[1]->immediate;
func->inPrologue = false;
}
匹配全部栈读写的操作
首先,我们遍历函数的所有指令,收集对栈的读写操作:
我们跟踪每个内存操作,区分读取和写入,并记录指令类型和位置。尤其重要的是,我们识别出是否在函数序言(prologue)之前发生了访问,这通常表明是在访问参数。
// 记录栈访问
auto processMemOperand = [&](const LLIL::LLILOperand op, bool isDest) {
if (op.type != LLIL::LLILOperand::MEMORY) return;
int64_t offset;
if (op.base == "rsp") {
offset = op.offset;
} else if (op.base == "rbp") {
if (func->inPrologue) {
offset = func->rbpRspOffset + op.offset;
} else {
offset = op.offset; // 直接使用相对rbp的偏移
}
} else {
return;
}
StackAccess access;
access.isRead = !isDest;
access.isLea = llil_ins->type == LLIL::LLILInstruction::LEA;
access.insIndex = insIndex;
access.isBeforePrologue = func->inPrologue;
access.ins = llil_ins.get();
stackAccesses[offset].push_back(access);
};
以我们的汇编为例,我们搜集到了12处栈访问的信息:
实际上IDA识别出来是有8个:
仔细看我们前四个的搜集的栈访问参数位置:
其实都是因为X64的传参造成的栈访问,这前四个很快就会被接下里的可信度计算过滤了:
观察剩下的0xe0的栈访问
实际上他就是一个”参数”
参数识别和置信度计算
收集完栈访问信息后,我们分析每个偏移处的访问模式:
for (const auto& [offset, accesses] : stackAccesses) {
if (offset < 0x8 || (offset % 8) != 0) continue;
if (handledOffsets.find(offset) != handledOffsets.end()) continue;
bool hasRealAccess = false;
bool onlyLea = true;
bool usedBeforePrologue = false;
uint64_t firstAccessAddr = 0;
for (const auto& access : accesses) {
if (!access.isLea) {
hasRealAccess = true;
onlyLea = false;
}
if (access.isBeforePrologue) {
usedBeforePrologue = true;
}
if (firstAccessAddr == 0) {
firstAccessAddr = access.ins->address;
}
}
我们根据以下几点计算参数的置信度:
-
是否在序言之前被访问(增加置信度) -
是否只有LEA指令访问(可能只是局部变量) -
对于栈参数,是否有写操作(参数通常是只读的)
auto info = std::make_shared<ParamInfo>();
info->offset = offset;
info->confidence = 0.6f; // 基础置信度
info->insMemAddress = firstAccessAddr;
if (usedBeforePrologue) info->confidence += 0.1f;
// 对于栈参数,检查是否有写操作
if (offset >= 0x28) { // 栈参数
bool hasWrite = false;
for (const auto& access : accesses) {
if (!access.isRead && !access.isLea) {
hasWrite = true;
break;
}
}
// 如果没有写操作,增加置信度
if (!hasWrite) info->confidence += 0.1f;
}
这确保了参数按照预期的顺序排列:先是寄存器参数(RCX, RDX, R8, R9),然后是栈参数。
一个简单的函数识别就OK了,更复杂一点的,我们可以看
-
call这个函数之前的模式匹配,结合函数内的匹配会很精准 -
在(1)的基础上,多次匹配其他的call的地方.
最终结果
那么,我们来看看我们的最终结果吧:
最后,我们看到,函数参数有8个,跟IDA的结果是一样的:
我们也正确的显示了参数列表:
后续问题
如您所见,这并不是最优解,比如在某些情况下,会多一个参数或者少一个参数
这并不是最优解,最优解是我们还需要根据函数的调用方去分析一次参数,然后两者比对.这个我们会在后面会说
原文始发于微信公众号(冲鸭安全):IDA原理入门(四): 函数参数识别
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论