文章总结: 本文阐述了构建IDA式控制流图(CFG)的原理,通过BFS算法识别基本块起止点及跳转关系。文章详解了基本块分解步骤、WindowsCFG保护机制的处理及跳转目标解析,最终实现了与IDA一致的块构建效果,为后续程序提升奠定基础。 综合评分: 90 文章分类: 逆向分析,二进制安全,安全工具
IDA原理入门(三): 控制流追踪与CFG Blocks构建
原创
huoji
冲鸭安全
2025年2月26日 10:00 北京
IDA原理入门(三): 控制流追踪与CFG Block构建
简介
没看过的请看之前的
IDA背后的原理入门(一): 简介&函数识别
IDA背后的原理入门(二): 函数大小计算
本篇是第三篇
#
CFG 分析的基本原理
控制流图是程序分析的核心,它将代码分解为基本块(Basic Blocks),并表示这些块之间的控制转移关系。我们的实现主要分为以下几个步骤:
- 识别函数入口点
- 分解函数为基本块
- 建立基本块之间的连接关系
- 处理特殊情况(如 CFG 保护机制)
回忆一下之前的
如何从零开始实现类似 IDA 的 CFG 功能.但是在此之前,我们再回忆一下前几篇说的内容,因为之前说的有点碎
函数入口点识别
首先,我们需要找到所有可能的函数入口点。这里采用了一种启发式方法 —— 扫描整个代码段,寻找 call 指令的目标地址:
for (size_t index = 0; index < disasmCount; index++) {
const auto code = &insn[index];
if (code->detail == nullptr) {
continue;
}
if (isCall(code)) {
uint64_t targetAddress = 0;
if (code->detail->x86.operands[0].type == X86_OP_IMM) {
targetAddress = code->detail->x86.operands[0].imm;
} else if (code->detail->x86.operands[0].type == X86_OP_MEM &&
code->detail->x86.operands[0].mem.base == X86_REG_RIP) {
uint64_t nextInstructionAddr = code->address + code->size;
targetAddress = *(uint64_t*)(code->detail->x86.operands[0].mem.disp + nextInstructionAddr);
} else {
continue;
}
// 检查地址有效性和是否是第三方节
const auto [addressValid, isThirdPartySection, realAddressInMemory] =
IsThirdPartySection(targetAddress, textSection, peBuffer, FileImageBase, peSize, symbolInfo);
// ...保存有效的函数入口点
}
}
处理 Windows CFG 控制流保护
Windows CFG(Control Flow Guard)是一种安全机制,它会将间接调用重定向到特殊的检查函数。这给我们的分析带来了挑战,因为很多跳转不再直接指向目标函数。 我通过 IsThirdPartySection 函数来处理这种情况:
inline auto IsThirdPartySection(uint64_t addressInMemory,
IMAGE_SECTION_HEADER* textSection,
uint8_t* peBuffer, uint64_t FileImageBase,
uint64_t peSize, SymbolInfo symbolInfo)
-> std::tuple<bool, bool, uint64_t> {
bool isThirdPartySection = false;
bool addressValid = false;
uint64_t targetFunctionAddress = 0;
do {
// 检查符号表
auto symBol = symbolInfo.addressToType.find(addressInMemory);
if (symBol != symbolInfo.addressToType.end()) {
break;
}
// 检查地址范围
if (addressInMemory < (uint64_t)peBuffer ||
addressInMemory > (uint64_t)peBuffer + peSize) {
break;
}
addressValid = true;
// 检查是否在文本段之外(可能是 CFG 检查函数)
if (addressInMemory >= (uint64_t)peBuffer + textSection->VirtualAddress &&
addressInMemory <= (uint64_t)(peBuffer + textSection->VirtualAddress + textSection->Misc.VirtualSize)) {
break;
}
// 不在文本段,可能指向了 00cfg 这类地址
isThirdPartySection = true;
targetFunctionAddress = addressInMemory;
} while (false);
return {addressValid, isThirdPartySection, targetFunctionAddress};
}
另外,我还专门实现了一个函数来识别调试跳转表(这常见于 CFG 实现中):
inline auto CheckIsDebugJmpFunction(uint64_t addressInMemory,
IMAGE_SECTION_HEADER* textSection,
uint8_t* peBuffer, uint64_t FileImageBase,
uint64_t peSize, SymbolInfo symbolInfo)
-> std::pair<bool, uint64_t> {
// ...检查是否是调试跳转函数
// 如果是,返回 {true, 实际目标地址}
}
至此,回忆结束,让我们进入下一个部分: CFG块构建
基本块的本质与控制流分析
在编译理论和静态分析领域,基本块(Basic Block)是程序分析的基础单元。一个基本块是满足以下条件的最大连续指令序列:
- 只有一个入口点(第一条指令)
- 只有一个出口点(最后一条指令)
- 如果执行了第一条指令,那么块内的所有指令都会按顺序执行,没有分支、跳转或者提前终止
基本块的这种特性使得我们可以将复杂的程序分解为更易于分析的单元,进而构建控制流图(CFG)。
如图所示,这个就是IDA的每个块的直观表示:
函数分解为基本块的详细步骤
为了实现块分析,我们需要两步走
第一步:识别所有基本块的起始地址
这一步的核心思想是通过广度优先搜索(BFS)来识别所有可能的基本块起始点。我们从函数入口开始,在遇到控制流改变的指令时(如跳转、条件分支、返回等),我们认为当前基本块结束,并且标记潜在的新基本块起始点:
std::set<uint64_t> blockStarts{addressInFile}; // 初始化集合,包含函数的入口地址
std::set<uint64_t> processedAddrs; // 已处理地址集合
std::queue<uint64_t> workList; // 工作队列
workList.push(addressInFile);
// 第一轮:找出所有基本块的起始地址
while (!workList.empty()) {
uint64_t currentAddr = workList.front();
workList.pop();
// 避免重复处理
if (processedAddrs.count(currentAddr) > 0) {
continue;
}
processedAddrs.insert(currentAddr);
// 反汇编当前地址处的指令
cs_insn* insn;
size_t offset = currentAddr - addressInFile;
if (offset >= size) continue;
size_t count = cs_disasm(handle, addressInMemory + offset,
size - offset, currentAddr, 0, &insn);
if (count == 0) continue;
// 遍历反汇编出的指令
for (size_t i = 0; i < count; i++) {
auto theInsn = &insn[i];
auto termType = GetBlockTerminatorType(theInsn);
// 如果是块终止指令(如跳转、返回等)
if (termType != BlockTerminatorType::kNone) {
auto targets = GetBlockTargets(theInsn);
auto isCondJmp = is_cond_jump(theInsn);
// 处理跳转目标
for (auto target : targets) {
if (target >= addressInFile && target < addressInFile + size) {
blockStarts.insert(target);
workList.push(target);
}
}
// 对于条件跳转,下一条指令也是新块的开始
if (isCondJmp && i + 1 < count) {
blockStarts.insert(insn[i + 1].address);
workList.push(insn[i + 1].address);
}
// 非返回且非条件跳转情况下,也考虑后续指令
if (termType != BlockTerminatorType::kRet && !isCondJmp) {
if (i + 1 < count) {
blockStarts.insert(insn[i + 1].address);
workList.push(insn[i + 1].address);
}
}
break; // 当前基本块结束
}
}
cs_free(insn, count);
}
对于跳转指令(如JMP、JE等),跳转目标地址成为新基本块的起始点 对于条件跳转,不仅跳转目标是新基本块,跳转指令之后的下一条指令也是新基本块 对于返回指令(RET),没有新的基本块起始点
这个过程使用工作队列(workList)实现,以确保我们能够处理所有可达的代码路径,即使存在复杂的跳转关系。
第二步:创建基本块对象
在这一步中,我们根据第一步识别的所有基本块起始地址,构建完整的基本块对象。对于每个起始地址,我们:
- 创建一个新的基本块对象
- 设置起始地址(文件中和内存中)
- 反汇编指令,直到找到一个终止指令或下一个已知的基本块起始点
- 记录终止类型和后继块地址
- 设置结束地址
- 将完整的基本块添加到列表中
这种方法可以确保我们不会漏掉任何执行路径,并且正确识别基本块的边界。
// 第二轮:根据起始地址创建实际的基本块对象
for (auto blockStart : blockStarts) {
Block currentBlock;
currentBlock.startAddressInFile = blockStart;
currentBlock.startAddressInMemory = func.address + (blockStart - addressInFile);
size_t offset = blockStart - addressInFile;
if (offset >= size) continue;
cs_insn* insn;
size_t count = cs_disasm(handle, addressInMemory + offset,
size - offset, blockStart, 0, &insn);
if (count == 0) continue;
for (size_t i = 0; i < count; i++) {
auto termType = GetBlockTerminatorType(&insn[i]);
// 如果是终止指令
if (termType != BlockTerminatorType::kNone) {
auto isCondJmp = is_cond_jump(&insn[i]);
currentBlock.terminatorType = termType;
currentBlock.nextBlockAddresses = GetBlockTargets(&insn[i]);
currentBlock.endAddressInFile = insn[i].address + insn[i].size;
currentBlock.endAddressInMemory = func.address + (insn[i].address - addressInFile) + insn[i].size;
// 对于条件跳转,添加顺序执行的下一个块地址
if (isCondJmp && i + 1 < count) {
currentBlock.nextBlockAddresses.push_back(insn[i + 1].address);
}
// 检查目标的有效性
bool hasValidTarget = false;
for (auto target : currentBlock.nextBlockAddresses) {
if (target >= addressInFile && target < addressInFile + size) {
hasValidTarget = true;
break;
}
}
if (hasValidTarget || termType == BlockTerminatorType::kRet) {
blocks.push_back(std::make_shared<Block>(currentBlock));
}
break;
}
// 如果下一条指令是已知的新块起始点
if (i + 1 < count && blockStarts.count(insn[i + 1].address) > 0) {
currentBlock.endAddressInFile = insn[i].address + insn[i].size;
currentBlock.terminatorType = BlockTerminatorType::kNone;
currentBlock.endAddressInMemory = func.address + ((insn[i].address + insn[i].size) - addressInFile);
blocks.push_back(std::make_shared<Block>(currentBlock));
break;
}
}
cs_free(insn, count);
}
细节1: 判断块终止符类型
我这边简单的识别了各种类型的返回指令和跳转指令,并将它们映射到对应的终止符类型。值得注意的是,CALL指令通常不会结束基本块,因为调用完成后控制流会继续。
auto GetBlockTerminatorType(const cs_insn* insn) -> BlockTerminatorType {
if (insn->id == X86_INS_RET) return BlockTerminatorType::kRet;
if (insn->id == X86_INS_JMP) return BlockTerminatorType::kJmp;
if (insn->id == X86_INS_JE) return BlockTerminatorType::kJz;
if (insn->id == X86_INS_JNE) return BlockTerminatorType::kJnz;
if (insn->id == X86_INS_JBE) return BlockTerminatorType::kJbe;
if (insn->id == X86_INS_JB) return BlockTerminatorType::kJb;
if (insn->id == X86_INS_JS) return BlockTerminatorType::kJs;
if (insn->id == X86_INS_JO) return BlockTerminatorType::kJ0;
if (insn->id == X86_INS_JP) return BlockTerminatorType::kJp;
if (insn->id == X86_INS_JAE) return BlockTerminatorType::kJnb;
// if (insn->id == X86_INS_CALL) return BlockTerminatorType::kCall;
return BlockTerminatorType::kNone;
}
细节2: 确定跳转目标
我们同时也需要确定跳转指令的目标地址。它处理了两种常见情况: 立即数操作数(如JMP 0x1000)- 直接提取立即数值作为目标地址 基于RIP的内存操作数(如JMP [RIP+0x100])- 计算有效地址 此外,对于终止指令,还需要添加下一条指令的地址作为”fall-through”路径。
auto GetBlockTargets(const cs_insn* insn) -> std::vector<uint64_t> {
std::vector<uint64_t> targets;
// 检查指令是否有操作数
if (insn->detail == nullptr || insn->detail->x86.op_count == 0) {
return targets;
}
// 从立即数或内存操作数获取目标地址
const auto& operand = insn->detail->x86.operands[0];
if (operand.type == X86_OP_IMM) {
targets.push_back(operand.imm);
} else if (operand.type == X86_OP_MEM && operand.mem.base == X86_REG_RIP) {
uint64_t nextInstructionAddr = insn->address + insn->size;
uint64_t target = nextInstructionAddr + operand.mem.disp;
targets.push_back(target);
}
// 对于条件跳转,也添加下一条指令地址
auto termType = GetBlockTerminatorType(insn);
if (termType != BlockTerminatorType::kNone) {
targets.push_back(insn->address + insn->size);
}
return targets;
}
细节3: 判断跳转条件
我们需要一个函数区分条件跳转和无条件跳转。它首先排除无条件跳转(JMP),然后检查指令是否属于跳转组。这对于正确处理控制流至关重要,因为条件跳转会产生两条可能的执行路径。
inline auto is_cond_jump(cs_insn* ins) -> bool {
// 如果是无条件跳转,返回false
if (ins->id == X86_INS_JMP) return false;
// 遍历指令组
for (int i = 0; i < ins->detail->groups_count; i++) {
if (ins->detail->groups[i] == X86_GRP_JUMP) return true;
}
return false;
}
基本块之间的关系建立
在构建完所有基本块后,我们需要建立它们之间的关系。这是通过记录每个块的后继块地址实现的:
currentBlock.nextBlockAddresses = GetBlockTargets(&insn[i]);
// 对于条件跳转,添加顺序执行的下一个块地址
if (isCondJmp && i + 1 < count) {
currentBlock.nextBlockAddresses.push_back(insn[i + 1].address);
}
这样,我们就建立了基本块之间的控制流关系,为完整的控制流图(CFG)奠定了基础。
效果展示
这个是这个算法与IDA的块对比截图,基本上1:1还原了IDA的块构建:
其他待解决问题
目前来说,我们还需要处理:
- 表驱动跳转:使用跳转表(jump table)实现switch语句
- 内联汇编:可能包含非标准的控制流转移
- 异常处理:try-catch结构创建的隐式控制流
- 间接跳转:通过寄存器或内存中的地址进行跳转 这几个也是现代混淆引擎最喜欢操作的部分,属于混淆-反混淆对抗阶段.如果这个系列反响还不错,我会单独出一期介绍一些主流的方法
未完待续
下一章,我们将介绍如何做程序的提升(low level lift).当文章阅读过1000就马上更新!
公众号下一期更新:
这里加个投票,看看公众号下一期更新什么
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:冲鸭安全 huoji《IDA原理入门(三): 控制流追踪与CFG Blocks构建》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论