ollvm--混淆

这里用不知道哪里找到的ollvm17混淆, https://github.com/DreamSoule/ollvm17 不算很new,主要用于研究一些好玩的

一些基础知识

关系支配树

定义:如果从函数入口到基本块 B 的所有路径都必须经过基本块 A,则称:A支配B

1
2
3
4
5
6
7
8
  entry
|
v
check
/ \
A B
\ /
merge

例如这里check就支配A,B和merge

clang 编译器的编译流程

clang是一种用llvm 作为后端的编译器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
C/C++ 源代码
↓
预处理
↓
词法分析、语法分析、语义分析
↓
Clang AST
↓
LLVM IR .ll
↓
LLVM 优化 / OLLVM 混淆 Pass
↓
目标相关机器指令
↓
汇编或目标文件 .o
↓
链接
↓
可执行文件或 .so

编译大致流程如上,我们这里主要研究IR层的内容

pass和new pass manager

有个叫opt的可执行文件,负责加载和管理pass.当然pass 的调用方式有很多种.等下再讲吧,没看明白.不过opt可以加载现成的elf文件, 我们可以写一个自己的pass然后用opt测试.

什么是pass

pass是对中间层IR代码进行处理的程序. pass的处理根据颗粒度(似乎找不到更合适的描述了,就这么说吧)其实根据的是一个程序的不同细致层次 pass主要能够分析和改变一个IR

  1. 分析 Analysis:收集信息但不修改 IR,例如支配树、循环信息、别名分析。
  2. 变换 Transform:修改 IR,例如常量折叠、内联、控制流平坦化、虚假控制流。

中间层的处理层次

依次往下

Module

Module:一个模块.其实就是一个cpp源文件生成出来的一个.ll

Call Graph Pass 主要是GCSCC

call Graph pass 操作的是调用图中的强连同分量(SCCS). 主要是操纵的函数之间调用的关系 构成一个强连同分量就是这里 比如一个程序如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void C() {}

void B() {
C();
}

void A() {
B();
C();
}

int main() {
A();
}

这里的函数调用图是

1
2
3
4
5
6
7
8
9
10
main
│
▼
A
┌┴──────┐
▼ ▼
B C
│
▼
C

这个函数调用图没有任何构成了一个非平凡的强连通调用图.那么我们就可以这么分

1
2
3
4
CGSCC 1 = {C}
CGSCC 2 = {B}
CGSCC 3 = {A}
CGSCC 4 = {main}

那么什么是强连通调用图呢

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void B();
void D();

void A() {
B();
}

void B() {
A(); // A 和 B 相互调用
}

void C() {
D();
}

void D() {
C(); // C 和 D 相互调用
}

int main() {
A();
C();
}

调用图如下 这是ida的图的可能有点难看,就是这样 所以这里构成了两个循环, 有向图中一个尽可能大的节点集合,其中任意两个节点都能够互相到达。 因此强连通分量划分是:

1
2
3
SCC 1 = {A, B}
SCC 2 = {C, D}
SCC 3 = {main}
Functions Pass

一次处理一个函数.后面我们遇到的大多数都是操作的这里

loop pass

主要处理函数内的一个循环,他只特定处理循环内的内容

怎么编写编译pass

这里我们以两个pass作为示例.第一个优化过程中去掉没有实际意义的加零指令

1
%a= add i32 %x ,0

这种操作一般是由于优化代码.我们可以写一个简单的pass

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
#include "llvm/ADT/SmallVector.h"
#include "llvm/Config/llvm-config.h"
#include "llvm/IR/Function.h"
#include "llvm/IR/Instructions.h"
#include "llvm/IR/PassManager.h"
#include "llvm/IR/PatternMatch.h"
#include "llvm/Passes/PassBuilder.h"
#include "llvm/Passes/PassPlugin.h"
#include "llvm/Support/raw_ostream.h"

using namespace llvm;
using namespace llvm::PatternMatch;

namespace {

class RemoveAddZeroPass
: public PassInfoMixin<RemoveAddZeroPass> {
public:
PreservedAnalyses run(Function &F,
FunctionAnalysisManager &AM) {
// 函数声明没有函数体,不需要处理。判断是否只有函数声明
if (F.isDeclaration())
return PreservedAnalyses::all();

// 暂存需要删除的指令,避免遍历过程中直接删除。
SmallVector<Instruction *, 8> ToErase;

for (BasicBlock &BB : F) {
for (Instruction &I : BB) {
// 判断当前指令是不是二元运算指令。
auto *BO = dyn_cast<BinaryOperator>(&I);

if (!BO)
continue;

// 只处理 add 指令。
if (BO->getOpcode() != Instruction::Add)
continue;

Value *Replacement = nullptr;

// 匹配:x + 0
if (match(BO->getOperand(1), m_Zero())) {
Replacement = BO->getOperand(0);
}
// 匹配:0 + x
else if (match(BO->getOperand(0), m_Zero())) {
Replacement = BO->getOperand(1);
}

if (!Replacement)
continue;

// 将所有对 add 结果的使用替换成原始操作数。
BO->replaceAllUsesWith(Replacement);

// 稍后删除原 add 指令。
ToErase.push_back(BO);
}
}

for (Instruction *I : ToErase)
I->eraseFromParent();

if (ToErase.empty())
return PreservedAnalyses::all();

errs() << "[remove-add-zero] function "
<< F.getName()
<< ": removed "
<< ToErase.size()
<< " instruction(s)\n";

// 修改了 IR,保守地声明旧分析全部失效。
return PreservedAnalyses::none();
}
};

} // namespace

extern "C" LLVM_ATTRIBUTE_WEAK
PassPluginLibraryInfo llvmGetPassPluginInfo() {
return {
LLVM_PLUGIN_API_VERSION,
"RemoveAddZeroPass",
LLVM_VERSION_STRING,

[](PassBuilder &PB) {
PB.registerPipelineParsingCallback(
[](StringRef Name,
FunctionPassManager &FPM,
ArrayRef<PassBuilder::PipelineElement>) {
if (Name == "remove-add-zero") {
FPM.addPass(RemoveAddZeroPass());
return true;
}

return false;
});
}};
}//LLVM new PM注册函数

这个结构比较简单. 我们可以一次解释一下顺带可以解释一些cpp上面的东西

1
2
class RemoveAddZeroPass
: public PassInfoMixin<RemoveAddZeroPass>

这是new pass manager的写法.查询资料,这里是进行了一个CRTP:Curiously Recurring Template Pattern,奇异递归模板模式 即: 派生类把“自己”作为模板参数,传给基类,然后再继承这个基类。 似乎作用类似于虚函数?查找后发现可以简单介绍一下 动态多态和静态多态的概念后续我会在新开的cpp章节里面补充

  • 动态多态:也叫运行时多态,一般利用虚函数和继承还有重写实现
  • 静态动态:编译期多态,一般用模板,函数重载,泛型实现

具体后面讲cpp多态的时候再补充.这里passinfoMixin只是给信息的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename DerivedT> struct PassInfoMixin {
/// Gets the name of the pass we are mixed into.
static StringRef name() {
static_assert(std::is_base_of<PassInfoMixin, DerivedT>::value,
"Must pass the derived type as the template argument!");
StringRef Name = getTypeName<DerivedT>();
Name.consume_front("llvm::");
return Name;
}

void printPipeline(raw_ostream &OS,
function_ref<StringRef(StringRef)> MapClassName2PassName) {
StringRef ClassName = DerivedT::name();
auto PassName = MapClassName2PassName(ClassName);
OS << PassName;
}
};

然后

1
2
PreservedAnalyses run(Function &F,
FunctionAnalysisManager &AM)

run函数返回值PreservedAnalyses决定旧分析缓存 一般来说只要修改了IR 的内容,就return PreservedAnalyses::none(); 表示所有的分析全部失效.如果后面是return PreservedAnalyses::all();则表示保留

1
2
3
4
5
6
Function &F
当前正在处理的函数

FunctionAnalysisManager &AM
函数级分析管理器

FunctionAnalysisManager 能够获取支配树,循环等内容,不过我看没怎么用到.先不多讲了 之后再创建一个vector保留要删除的地方 之后就是遍历函数的指令,然后再用dyn_cast判断指令的类型

使用 dyn_cast 判断指令类型
1
auto *BO = dyn_cast<BinaryOperator>(&I);

含义是:

1
2
如果 I 是 BinaryOperator,返回对应指针;
否则返回 nullptr。

类似用法:

1
2
3
4
5
isa<CallInst>(&I);
dyn_cast<BranchInst>(&I);
dyn_cast<LoadInst>(&I);
dyn_cast<StoreInst>(&I);
dyn_cast<PHINode>(&I);
判断操作码
1
2
if (BO->getOpcode() != Instruction::Add)
continue;

这里只处理 add。其他常见操作码包括:

1
2
3
4
5
6
7
8
Instruction::Sub
Instruction::Mul
Instruction::Xor
Instruction::And
Instruction::Or
Instruction::Shl
Instruction::LShr
Instruction::AShr
获取操作数

对于:

1
%a = add i32 %x, 0
1
BO->getOperand(0)

得到 %x;

1
BO->getOperand(1)

得到 0。

使用 PatternMatch 匹配零
1
match(BO->getOperand(1), m_Zero())

匹配:

1
add i32 %x, 0

而:

1
match(BO->getOperand(0), m_Zero())

匹配:

1
add i32 0, %x
替换所有使用
1
BO->replaceAllUsesWith(Replacement);

原来:

1
2
%a = add i32 %x, 0(这个表示BO)
%b = mul i32 %a, 2

替换后:

1
2
%a = add i32 %x, 0
%b = mul i32 %x, 2

这一步只修改使用关系,原 add 指令暂时还存在。

删除指令
1
I->eraseFromParent();

将指令从所属 BasicBlock 删除。 这里我们需要暂存BO是因为在遍历 IR 的同时修改 IR, 迭代器会失效,导致漏处理、重复处理,甚至崩溃 所以我们就先把要改的对象存储下来

如何加载 Pass

LLVM 加载 Pass 有 4 种方式:

1. New PM 动态插件

把 Pass 编译成 .so,再让 opt 或 clang 动态加载。当前 mypass 使用的就是这种方式。

注册:New PM Pass 需要实现 PassInfoMixin,并通过 llvmGetPassPluginInfo() 导出插件入口:

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
class RemoveAddZeroPass : public PassInfoMixin<RemoveAddZeroPass> {
public:
PreservedAnalyses run(Function &F, FunctionAnalysisManager &AM) {
// Pass 逻辑
}
};

extern "C" LLVM_ATTRIBUTE_WEAK
PassPluginLibraryInfo llvmGetPassPluginInfo() {
return {
LLVM_PLUGIN_API_VERSION,
"RemoveAddZeroPass",
LLVM_VERSION_STRING,
[](PassBuilder &PB) {
PB.registerPipelineParsingCallback(
[](StringRef Name, FunctionPassManager &FPM,
ArrayRef<PassBuilder::PipelineElement>) {
if (Name == "remove-add-zero") {
FPM.addPass(RemoveAddZeroPass());
return true;
}
return false;
});
}};
}

编译:

1
2
cmake -S mypass -B mypass/build -DLLVM_DIR=<你的LLVM_DIR>
cmake --build mypass/build

调用:

1
2
opt -load-pass-plugin=./mypass/build/MyFirstPass.so -passes=remove-add-zero -S test.ll -o test.opt.ll
clang -fpass-plugin=./mypass/build/MyFirstPass.so -O1 test.c -o test

注意:-fpass-plugin 要生效,插件通常还需要注册 pipeline 扩展点,不能只注册 registerPipelineParsingCallback()。

2. Legacy PM 动态插件

老式 FunctionPass 编译成 .so,再用 -load 加载。

注册:Legacy Pass 需要继承 FunctionPass,实现 runOnFunction(),并用 INITIALIZE_PASS 注册命令行名字:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct MyLegacyPass : public FunctionPass {
static char ID;
MyLegacyPass() : FunctionPass(ID) {}
bool runOnFunction(Function &F) override {
// Pass 逻辑
return false;
}
};

char MyLegacyPass::ID = 0;

INITIALIZE_PASS(MyLegacyPass,
"my-legacy-pass",
"My Legacy Pass",
false,
false)

编译:

1
2
3
4
5
add_llvm_library(MyLegacyPass
MODULE
MyLegacyPass.cpp
PLUGIN_TOOL opt
)
1
cmake --build build

调用:

1
opt -load ./MyLegacyPass.so -my-legacy-pass ...
1
clang -Xclang -load -Xclang ./MyLegacyPass.so ...

LLVM 17 的 opt 同时支持:

1
2
-load             旧 Pass Manager 插件
-load-pass-plugin 新 Pass Manager 插件
3. 静态内置 Pass

把 Pass 源码编进 LLVM,并在 PassBuilder.cpp 里注册。

注册:需要把源码加入 LLVM 的 CMake,并在 PassBuilder.cpp 中注册命令行开关和 pipeline:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// PassBuilder.cpp
#include "Obfuscation/Flattening.h"

static cl::opt<bool> s_obf_fla("fla",
cl::init(false),
cl::desc("Flattening"));

PassBuilder::PassBuilder(...) {
this->registerPipelineStartEPCallback(
[](ModulePassManager &MPM, OptimizationLevel Level) {
FunctionPassManager FPM;
FPM.addPass(FlatteningPass(s_obf_fla));
MPM.addPass(createModuleToFunctionPassAdaptor(std::move(FPM)));
});
}

编译:需要重新构建 LLVM 工具链:

1
cmake --build llvm-build-17.0.6-obf --target opt clang

调用:

1
2
clang -mllvm -fla -c test.c -o test.o
opt -passes=flattening ...

OLLVM 的 fla、bcf、sub 都属于这种方式,不需要加载 .so。

4. PassBuilder 回调自动注册

Pass 可以不通过命令行名字调用,而是注册到 pipeline 扩展点,让 clang/opt 在指定阶段自动运行。

注册:动态插件或静态内置都可以使用回调注册。动态插件写法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extern "C" LLVM_ATTRIBUTE_WEAK
PassPluginLibraryInfo llvmGetPassPluginInfo() {
return {
LLVM_PLUGIN_API_VERSION,
"RemoveAddZeroPass",
LLVM_VERSION_STRING,
[](PassBuilder &PB) {
PB.registerPipelineStartEPCallback(
[](ModulePassManager &MPM, OptimizationLevel Level) {
FunctionPassManager FPM;
FPM.addPass(RemoveAddZeroPass());
MPM.addPass(createModuleToFunctionPassAdaptor(std::move(FPM)));
});
}};
}

编译:动态插件用 add_llvm_pass_plugin,静态内置用 cmake --build ... --target opt clang。

调用:不需要 -passes=name,让 clang 或 opt 自动执行:

1
clang -fpass-plugin=./mypass/build/MyFirstPass.so -O1 test.c -o test

或者在静态内置情况下直接正常编译:

1
clang -O1 test.c -o test

常用扩展点:

  • registerPipelineStartEPCallback():pipeline 开始。
  • registerPipelineEarlySimplificationEPCallback():早期简化。
  • registerOptimizerEarlyEPCallback():优化早期。
  • registerOptimizerLastEPCallback():优化最后。

IR

表示Intermediate Representation,中间代码 使用作为后端的llvm 编译器编译的各种语言,都可以 转化成IR代码,之后再转化成机器码 IR的结构如下

1
2
3
4
5
6
7
8
9
10
11
12
Module
├── 全局变量 GlobalVariable
├── 函数 Function
│ ├── 参数 Argument
│ └── 基本块 BasicBlock(包含入口块)
│ ├── PHI 指令(根据“在当前 block 之前执行的是哪一个 predecessor(前任) block”来得到相应的值)
│ ├── 普通指令
│ └── 终结指令 Terminator
├── 全局别名 GlobalAlias
├── 间接函数 GlobalIFunc
├── Metadata
└── Module Flags、Comdat 等辅助信息

上面表示了IR的基本结构

静态单赋值(SSA)

what is:编译的中间层IR,每个变量只能赋值一次.很容易理解对吧 之后我们可以引申一下phi指令了

1
2
3
4
5
6
7
8
9
10
NOINLINE int test_if_else(int x) {
int y;

if (x > 0)
y = 10;
else
y = 20;

return y;
}

比如这个,不开优化编译llvm,选择o2 clang++ -S -emit-llvm -O0 -Xclang -disable-O0-optnone test_control_flow.cpp -o /tmp/noopt.ll opt -passes=mem2reg -S /tmp/noopt.ll -o /tmp/phi.ll 那么就可以看到Phi

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
define dso_local noundef i32 @_Z12test_if_elsei(i32 noundef %x) #0 {
entry:
%cmp = icmp sgt i32 %x, 0
br i1 %cmp, label %if.then, label %if.else

if.then: ; preds = %entry
br label %if.end

if.else: ; preds = %entry
br label %if.end

if.end: ; preds = %if.else, %if.then
%y.0 = phi i32 [ 10, %if.then ], [ 20, %if.else ]
ret i32 %y.0
}

可以发现这里用phi函数,来选择把前面哪个块的值作为%y.0,根据前面调用的是哪个块来判断

LLVM IR指令

  • 终结指令 Terminator Instructions

基本块的最后一个指令,通常为跳转指令或返回指令。
跳转到下一个基本快进行执行或从当前执行的函数中返回。例如 ret或者br

  • 二元运算 Binary Operations

add sub 这种

  • 按位二元运算 Bitwise Binary Operations

shl 左移右移,xor这种

  • 内存访问和寻址操作 Memory Access and Addressing Operations

alloca:类似于malloc

  • 类型转换操作 Conversion Operations
  • 其他操作 Other Operations

phi select 和call

各种混淆

后续示例demo代码

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include "tea.h"
#include <iostream>
#include <string>
#include <cstring>
#include <cstdint>


int main()
{
uint32_t key[4] = {
0x10345678,
0x9abcdef0,
0x11223344,
0x55667788
};
TEA tea(key);


static const unsigned char cipher[24] = {
0x22, 0xec, 0x42, 0x47, 0xfe, 0xf0, 0xbf, 0x40,
0x45, 0x49, 0xf7, 0x4b, 0x5e, 0x65, 0x66, 0x27,
0x23, 0x2e, 0xbf, 0x09, 0x89, 0xf0, 0x60, 0xba
};
constexpr std::size_t FLAG_LEN = 22;

std::string input;
std::cout << "Enter the flag: ";
std::getline(std::cin, input);

if (input.size() != FLAG_LEN) {
std::cout << "Wrong flag!" << std::endl;
return 1;
}

// 拷贝输入并 padding 到 24 字节 (0x00 填充)
unsigned char buf[24] = {0};
std::memcpy(buf, input.c_str(), FLAG_LEN);

// 分块 TEA 加密
for (int i = 0; i < 3; i++) {
uint32_t v0, v1;
std::memcpy(&v0, buf + i * 8, 4);
std::memcpy(&v1, buf + i * 8 + 4, 4);
tea.encrypt(v0, v1);
std::memcpy(buf + i * 8, &v0, 4);
std::memcpy(buf + i * 8 + 4, &v1, 4);
}

// 与密文比对
if (std::memcmp(buf, cipher, sizeof(cipher)) == 0) {
std::cout << "Correct! flag is accepted." << std::endl;
return 0;
}

std::cout << "Wrong flag!" << std::endl;
return 1;
}

虚假控制流(BogusControlFlow)

虚假控制流指,通过向正常控制流中插入若干不可达基本块(永远不会被执行的基本块)和由不透明谓词(指一个表达式,它的值在执行到某处时,对程序员而言必然是已知的,但是由于某种原因,编译器或者说静态分析器无法推断出这个值,只能在运行时确定)造成的虚假跳转,以产生大量垃圾代码干扰攻击者分析的混淆。 比如这里一个简单的hello world 混淆后,这里之加入了1级 就变成了这样 这里因为体量比较小,如果体量较大的话.会导致文件体量膨胀,毕竟把一个块增生了很多东西 可以看看他的结构是怎样的,让ai翻译了一下注释 虚假控制流的操作单位是基本块 他的实际执行流程是basicBlock->originalBB->originalBBpart2 原理其实非常简单.正如我们前面所说的写一个恒真的条件然后跳转到我们制定的位置.差不多就是这样. 重点在与如何绕过呢

之后我们在

这是原本的结构 混淆之后 ok现在变成大粪了.之后我们试试这些方法

D810

D810(又称 Deobfuscator-810)是一个用于 IDA Pro 的强大代码去混淆(Deobfuscation)插件。它主要通过在反编译时修改 IDA 的微代码(Microcode),将复杂、难以阅读的混淆代码还原为清晰、直观的逻辑 微代码,就类似于IR. 如何使用? 建立规则文件.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from d810.mba.dsl import Var, Const, DynamicConst
from d810.mba.rules._base import VerifiableRule

x = Var("x_0") # 模式变量:匹配任意子表达式
ONE = Const("1", 1) # 具体常量(名字, 值)
ZERO_VAL = DynamicConst("val_0", lambda ctx: 0) # 替换常量(自动适配位宽)
_ALL_MATURITIES = [2, 3, 4, 5] # 触发阶段:抢在 IDA 优化器前,表示ida优化的从汇编到伪代码的各个阶段

class MyPredOddConsecutive(VerifiableRule):
maturities = _ALL_MATURITIES # 不写则默认 [3,4,5]
PATTERN = ((x - ONE) * x) & ONE # ← 要匹配的表达式树(微码形态)
REPLACEMENT = ZERO_VAL # ← 替换成什么
DESCRIPTION = "((x-1)*x)&1 -> 0" # 界面里显示的名字
REFERENCE = "main_bcf OLLVM BCF" # 出处/参考

这里是让ai写的然后我们加载我们的json文件

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
{
"description": "[Step 1: BCF] OLLVM 虚假控制流去混淆:折叠恒真谓词 + 清除假循环 + 拉直恒真跳转",
"_notes": {
"整体说明": "BCF(虚假控制流)去混淆。OLLVM -bcf 会生成:①恒真/恒假谓词 ②空转假循环 ③等价复制块。本配置三件套齐活:ins_rules 折叠谓词算术、BadWhileLoop 清假循环、JumpFixer 拉直恒真跳转。",
"注释位置说明": "每条规则的注释放在 config._comment 里(唯一合法的内联位置)。规则条目 name 同级加字段会崩(cls(**data)),顶层 _notes 是整体注释。",
"blk_rules_说明": "4 条块级规则处理控制流结构。其中 Unflattener 是给 FLA(平坦化)预留的,纯 BCF 时基本不触发(无害兜底)。"
},
"ins_rules": [
{
"name": "MyPredOddConsecutive",
"is_activated": true,
"config": {
"_comment": "匹配 ((x-1)*x)&1(clang 编译顺序,(x-1) 在前)→ 折叠成 0"
}
},
{
"name": "MyPredOddConsecutive2",
"is_activated": true,
"config": {
"_comment": "匹配 (x*(x+1))&1(另一种连续整数变体)→ 折叠成 0"
}
},
{
"name": "MyPredOddNotZero",
"is_activated": true,
"config": {
"_comment": "匹配 (((x-1)*x)&1) != 0(setnz 比较结果形态)→ 折叠成 0"
}
},
{
"name": "MyPredOddConsecutive3",
"is_activated": true,
"config": {
"_comment": "匹配 (x*(x-1))&1(gcc 编译顺序,x 在前)→ 折叠成 0"
}
},
{
"name": "MyXduPredOdd",
"is_activated": true,
"config": {
"_comment": "匹配 Zext(((x-1)*x)&1, 32)==0(-O3 的 xdu 包裹形态)→ 折叠成 1(恒真)"
}
}
],
"blk_rules": [
{
"name": "MbaStatePreconditioner",
"is_activated": true,
"config": {
"_comment": "反混淆前热身:对谓词/分发器算术做 1 轮局部化简(保守),让后续规则好识别",
"max_optimize_local_rounds": 1
}
},
{
"name": "BadWhileLoop",
"is_activated": true,
"config": {
"_comment": "清除 BCF 产生的空转假循环 while(...); 结构"
}
},
{
"name": "Unflattener",
"is_activated": true,
"config": {
"_comment": "OLLVM FLA 反平坦化:检测并拆除 dispatcher 分发器(纯 BCF 时基本不触发,预留)",
"max_passes": 5,
"min_dispatcher_internal_block": 2,
"min_dispatcher_exit_block": 2,
"min_dispatcher_comparison_value": 2,
"max_calls_entry_preds": 24,
"max_calls_exit_blocks": 24,
"defer_calls_on_conditional_entry_father": true,
"log_calls_layout_signals": true,
"min_entropy": 0.0,
"max_entropy": 1.0
}
},
{
"name": "JumpFixer",
"is_activated": true,
"config": {
"_comment": "拉直恒真/恒假条件跳转;enabled_rules 白名单控制启用哪些子规则",
"enabled_rules": [
"CompareConstantRule1",
"CompareConstantRule2",
"CompareConstantRule3",
"JaeRule1",
"JbRule1",
"JnzRule1",
"JnzRule2",
"JnzRule3",
"JnzRule4",
"JnzRule5",
"JnzRule6",
"JnzRule7",
"JnzRule8",
"JmpRuleZ3Const",
"MyOllvmXduJz"
],
"dump_intermediate_microcode": false
}
}
]
}

放在这里就行了./home/f0x/.idapro/plugins/d810-ng/src/d810/conf/ 之后我们加载这个文件,还有就是按需去混淆,不然如果什么去混淆都同时加上去效果可能没那么理想

按下f5,选择对应的json文件

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
void __fastcall TEA::encrypt(TEA *this, uint32_t **p_v0, uint32_t **p_v1)
{
uint32_t **v3; // rsi
int *v4; // rdi
int *v5; // r11
uint32_t **v6; // rcx
_DWORD *v7; // r9
uint32_t *v8; // rdx
int *v9; // rax
int *v10; // r8
int *v11; // r10
int v12; // ebx
int v13; // ebx
int v14; // ebx
int v15; // ebx
int v16; // r10d
uint32_t v17; // eax
uint32_t **v18; // rsi
uint32_t *v19; // rdx
uint32_t ***v20; // r9
uint32_t **v21; // rcx
int *v22; // r8
uint32_t v23; // r10d
int *v24; // rax
uint32_t v25; // eax
uint32_t **v26; // [rsp+0h] [rbp-90h] BYREF
uint32_t **p_v0a; // [rsp+10h] [rbp-80h]
uint32_t **p_v1a; // [rsp+18h] [rbp-78h]
uint32_t *p_sum; // [rsp+20h] [rbp-70h]
uint32_t ***i; // [rsp+28h] [rbp-68h]
int *p_t; // [rsp+30h] [rbp-60h]
int *p_shl; // [rsp+38h] [rbp-58h]
int *p_shr; // [rsp+40h] [rbp-50h]
int *p_shl2; // [rsp+48h] [rbp-48h]
int *p_shr2; // [rsp+50h] [rbp-40h]
uint32_t **v37; // [rsp+58h] [rbp-38h]
TEA *v38; // [rsp+60h] [rbp-30h]
uint32_t **v39; // [rsp+68h] [rbp-28h]
uint32_t **v40; // [rsp+70h] [rbp-20h]

v38 = this;
v39 = p_v0;
v40 = p_v1;
p_v0a = (uint32_t **)(&v26 - 2);
p_v1a = (uint32_t **)(&v26 - 2);
p_sum = (uint32_t *)(&v26 - 2);
i = &v26 - 2;
p_t = (int *)(&v26 - 2);
p_shl = (int *)(&v26 - 2);
p_shr = (int *)(&v26 - 2);
p_shl2 = (int *)(&v26 - 2);
p_shr2 = (int *)(&v26 - 2);
v26 = p_v1;
v37 = p_v1;
*(_DWORD *)p_v1 = *((_DWORD *)p_v1 + 3);
*(_DWORD *)v26 ^= *((_DWORD *)p_v1 + 2) ^ *((_DWORD *)p_v1 + 1);
LODWORD(v26) = 0;
while ( *(_DWORD *)i < 64 )
{
v3 = p_v1a;
v4 = p_shr2;
v5 = p_shl2;
v6 = v37;
v7 = i;
v8 = p_sum;
v9 = p_shr;
v10 = p_shl;
v11 = p_t;
*p_sum -= 2048144777;
*v11 = *v7 & 1;
v12 = 3;
if ( *v11 )
v12 = 5;
*v10 = v12;
v13 = 5;
if ( *v11 )
v13 = 3;
*v9 = v13;
v14 = 5;
if ( *v11 )
v14 = 3;
*v5 = v14;
v15 = *v11;
v16 = 3;
if ( v15 )
v16 = 5;
*v4 = v16;
v17 = `anonymous namespace'::mix(
**v3,
*v8,
*((_DWORD *)v6 + (*v7 & 3)),
*((_DWORD *)v6 + (((unsigned __int8)*v7 + 1) & 3)),
*v10,
(int *)(unsigned int)*v9);
v18 = p_v0a;
v19 = p_sum;
v20 = i;
v21 = v37;
v22 = p_shl2;
v23 = v17;
v24 = p_shr2;
**p_v0a += v23;
v25 = `anonymous namespace'::mix(
**v18,
*v19,
*((_DWORD *)v21 + (((unsigned __int8)*(_DWORD *)v20 + 2) & 3)),
*((_DWORD *)v21 + (((unsigned __int8)*(_DWORD *)v20 + 3) & 3)),
*v22,
(int *)(unsigned int)*v24);
**p_v1a += v25;
++*(_DWORD *)i;
}
}

很明显还是有点丑陋,不过勉强能看了.块结构已经明晰了.

修改 不透明谓词的全局变量

把上面的x添加值就行,不过我改了没用,hyw,没找到具体原因放在这里吧

模拟执行

虽然感觉用在这里有点大炮打蚊子了,写起来还是比较麻烦的,这里就当作学习如何使用了,比较适合特别复杂的情况 什么是模拟执行:,来解释和运行代码的技术,而不需要在真实的物理硬件上直接执行.这里我们以qiling为示例.

qiling的组成
组件 作用
Unicorn 引擎 模拟 CPU(指令、寄存器、内存)
Qiling 加载器 解析 ELF、PIE 重定位、模拟动态链接
Qiling 系统调用层 模拟 read/write/futex 等系统调用
rootfs 模拟器里的"文件系统根"(动态库从这读
hook
级别 API 触发时机 本教程用途
指令级 hook_code(cb, begin, end) 每条指令执行前 阶段 1:恢复加密代码
基本块级 hook_block(cb, begin, end) 每个基本块入口 阶段 3:动态轨迹
地址级 hook_address(cb, addr) RIP 命中指定地址 阶段 2:取数 / -f 强制相等
指令类型级 hook_insn(cb, 类型) 特定指令(如 syscall/cpuid) —
中断级 hook_intr / hook_intno 中断触发 —
内存级 hook_mem_read/write/fetch 及 _invalid/_unmapped 变体 内存访问 / 访问非法内存 可做反调试、内存追踪
函数级 ql.os.set_api("函数名", cb, intercept) 按名字拦截库函数(经 PLT/GOT) 可替代 -f:set_api("memcmp", cb, EXIT) 改返回值
系统调用级 ql.os.set_syscall("read", cb) 系统调用 观察 stdin 读取时机
管理 hook_del(hret) 删除已注册的 hook —

回调里可读寄存器(ql.arch.regs.rdi)、读内存(ql.mem.read)、改寄存器。 示例

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#!/usr/bin/env python3
"""
step0_minimal.py — Qiling 零基础最小示例(逐行注释)。

作用:把 tea/build/main_bcf 完整模拟跑起来,
在"主程序自己的代码"上挂一个指令级 hook,打印前 20 条指令,
然后让它跑完,看看 stdout 是什么。

用法:
/home/f0x/anaconda3/envs/frida/bin/python step0_minimal.py

和 trace.py 的区别:这只是教学示例,trace.py 才是真正干活的。
"""
import io

from qiling import Qiling

# ========== 1. 构造模拟器 ==========
# Qiling(要执行的程序, rootfs目录, ...)
# - rootfs: 模拟器里的"文件系统根目录"。程序要加载的
# /lib/x86_64-linux-gnu/libstdc++.so.6 等动态库都从这里面读。
# - multithread=True: 必须开!这个程序是 C++ 写的,libstdc++ 初始化
# 会调用 futex 系统调用,不开多线程支持会直接崩。
# - verbose=0: 别打印一堆 qiling 自己的调试信息。
ql = Qiling(["../build/main_bcf"], "./ubuntu24_rootfs",
multithread=True, verbose=0)

# ========== 2. 设置程序的 stdin / stdout ==========
# 程序用 read(0) 读输入、write(1) 写输出。
# 注意:必须挂在 ql.os.stdin / ql.os.stdout 上!
# 如果写成 ql.stdin = ...,qiling 的 Linux 系统调用层根本看不到,
# read(0) 会读到宿主进程自己的 stdin(脚本化运行时 = 立刻 EOF)。
# 这里喂一个 22 字节的错误 flag(长度对,但内容错 → 走 Wrong flag 分支)。
ql.os.stdin = io.BytesIO(b"flag{aaaaaaaaaaaaaa}")
out = io.BytesIO()
ql.os.stdout = out

# ========== 3. 找到"主程序自己的镜像"在内存里的范围 ==========
# 这个二进制是 PIE(地址随机化程序),qiling 把它加载在 0x555555554000。
# 动态库 ld-linux/libc/libstdc++ 加载在 0x7ffff7dd5000 等别的地方。
# 我们只关心主程序的指令(0x555555554000 ~ 0x555555563000),
# 不然 hook_code 会把 libc 里每条指令都回调,又慢又没意义。
main_img = None
for img in ql.loader.images:
if "main_bcf" in img.path:
main_img = img
break
lo, hi = main_img.base, main_img.end
print(f"主镜像: {main_img.path}")
print(f" 加载地址 base = {hex(lo)} 结束 end = {hex(hi)}")

# ========== 4. hook_code: 每条指令执行前回调 ==========
# ql.hook_code(回调函数) → 每执行一条指令就调用一次回调(ql, address, size)。
# 我们只在地址落在主镜像范围内时打印,并限制条数。
count = 0
MAX = 20

def on_insn(ql, address, size):
global count
if lo <= address < hi and count < MAX:
print(f" 执行到指令 @ {hex(address)}")
count += 1

ql.hook_code(on_insn)#每次执行一条机器指令的时候执行里面的回调函数

# ========== 5. 开跑 ==========
print("\n开始模拟执行...")
ql.run()
print(f"\n退出码: {ql.os.exit_code}")
print(f"程序输出: {out.getvalue()!r}")
print(f"\n小结: 程序被完整模拟跑了 {count}+ 条主程序指令,")
print(" read(0) 读到了我们喂的输入, write(1) 输出了结果。")
print(" → 这就是'模拟执行':CPU、系统调用、动态链接全部被 Qiling 模拟了。")

这里就是遍历执行过的每条指令.如果要去bcf我们该怎么做呢.显然我们可以执行并判断我们走过了哪些块.我们需要判断两部分可能 1.验证flag 正确 2. 验证flag错误,有着两条路. 我们这里需要匹配判断指令的位置并hook那个,并修改判断逻辑. 我们就能走完两条路线的所有block

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#!/usr/bin/env python3
"""
trace.py — 用 Qiling 跑 main_bcf, 记录"真正执行过的基本块"。

去混淆原理:
OLLVM BCF 会在基本块之间插入不透明谓词 + 垃圾块 (bogus block)。
不透明谓词在运行时恒真/恒假, 所以垃圾块永远不会被执行
(或者只走其中一个分支)。
因此: 动态执行轨迹里出现过的基本块 = 真实控制流;
静态 CFG 里有、但从未执行的块 = BCF 垃圾块候选。

重要: 本脚本【不需要知道 flag】!
垃圾块与输入内容无关——无论喂什么输入, 垃圾块都不会执行。
默认喂 22 个 'A' (任意输入) 就能拿到真实路径的轨迹。
多喂几组不同输入只是为了覆盖更多分支 (如 Wrong flag 分支),
让后续 analyze 找垃圾块找得更全, 与 flag 是否正确无关。

用法:
/home/f0x/anaconda3/envs/frida/bin/python trace.py [选项] [输入文件] [输出前缀, 默认 executed]
不带参数 = 喂 22 个 'A' (任意输入), 输出 executed.txt
带输入文件 = 喂该文件内容, 输出 <前缀>.txt
-f/--force-correct = hook memcmp 强制相等, 覆盖 "Correct" 分支轨迹
(不需要知道 flag; 不知道 flag 也能覆盖所有分支)
多次运行后合并: cat executed_a.txt executed_b.txt | sort -u > executed.txt

输出:
<前缀>_blocks.json {运行时地址: 执行次数}
<前缀>_edges.json [[from, to], ...] 执行过的控制流边
<前缀>.txt 主程序镜像内执行过的块地址 (运行时地址, 已排序)
"""
import io
import json
import sys

from qiling import Qiling

# 任意 22 字节输入即可: 轨迹不依赖 flag 内容, 只依赖"程序走了哪条路径"
DEFAULT_PAYLOAD = b"A" * 22

# main 里 "call memcmp" 的 RVA (比较加密结果 vs 内置密文)
MEMCMP_CALL_OFF = 0x2c08


def main():
import argparse
ap = argparse.ArgumentParser(description="Qiling 基本块级轨迹 (不需要知道 flag)")
ap.add_argument("input", nargs="?", default=None,
help="输入文件; 缺省 = 22 个 'A' (任意输入)")
ap.add_argument("prefix", nargs="?", default="executed",
help="输出前缀, 默认 executed")
ap.add_argument("-f", "--force-correct", action="store_true",
help="hook memcmp 强制相等 → 程序走 'Correct' 分支, "
"覆盖正确路径的轨迹 (不需要知道 flag)")
a = ap.parse_args()

payload = open(a.input, "rb").read() if a.input else DEFAULT_PAYLOAD
prefix = a.prefix

out = io.BytesIO()
ql = Qiling(["../build/main_bcf"], "./ubuntu24_rootfs",
multithread=True, verbose=0)
ql.os.stdin = io.BytesIO(payload)
ql.os.stdout = out

# 主程序镜像的地址范围 (排除 libc / ld-linux 的噪音)
main_img = None
for img in ql.loader.images:
if "main_bcf" in img.path:
main_img = img
break
lo, hi = main_img.base, main_img.end
print(f"main image: {main_img.path} {hex(lo)} - {hex(hi)}")

if a.force_correct:
def force_memcmp_eq(ql):
# "call memcmp" 还没执行, 寄存器里还是实参: rdi=算出的密文, rsi=内置密文。
# 把 rdi 改成 rsi → memcmp(cipher, cipher) 必返回 0 → 走 Correct 分支。
# 效果等价于"我知道 flag 且 flag 正确", 但这里根本不需要 flag 内容。
ql.arch.regs.rdi = ql.arch.regs.rsi
ql.hook_address(force_memcmp_eq, lo + MEMCMP_CALL_OFF)
print("[force-correct] memcmp 已被强制相等, 将覆盖 Correct 分支")

blocks = {} # addr -> count
edges = [] # (from, to)
last = None

def on_block(ql, address, size):
nonlocal last
if lo <= address < hi:
blocks[address] = blocks.get(address, 0) + 1
if last is not None and last != address:
edges.append((last, address))
last = address

ql.hook_block(on_block)

ql.run()
print("exit code:", ql.os.exit_code)
print("stdout:", out.getvalue())

# 保存结果
with open(f"{prefix}_blocks.json", "w") as f:
json.dump({hex(k): v for k, v in sorted(blocks.items())}, f, indent=1)
with open(f"{prefix}_edges.json", "w") as f:
json.dump([[hex(a), hex(b)] for a, b in edges], f, indent=1)
with open(f"{prefix}.txt", "w") as f:
for a in sorted(blocks):
f.write(f"{a:016x}\n")

print(f"\nexecuted {len(blocks)} unique blocks in main image")
print(f"results saved: {prefix}_blocks.json, {prefix}_edges.json, {prefix}.txt")


if __name__ == "__main__":
main()

这里我们把判断wrong和correct的两个文件合并一下合并一下

1
2
3
4
5
6
$PY trace.py                     # 单次运行 → 写入 executed.txt
$PY trace.py -f # 再次运行 → 覆盖 executed.txt
$PY trace.py wrong_input.txt wrong # → wrong.txt
cat executed.txt wrong.txt | sort -u > executed_all.txt # 合并 → 临时名
cp executed_all.txt executed.txt # ① 关键: 覆盖回约定名
$PY analyze.py # ② 读到的就是合并后的 327 块

执行的analyze.py如下,遍历出所有的所有存在的块,减去执行经过的块,最后得到 无效的块并nop掉

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
#!/usr/bin/env python3
"""
analyze.py — 静态 CFG vs 动态轨迹, 找出 BCF 垃圾块 (按函数分析)。

用法:
/home/f0x/anaconda3/envs/frida/bin/python analyze.py [rva_of_function]

原理:
1. 从 ELF 符号表拿函数边界 (main_bcf 未 strip, 有符号);
2. 对每个"被动态执行过"的函数, 用递归下降反汇编重建静态基本块;
3. 静态块 - 动态执行块 = 该函数内的 BCF 垃圾块候选。

注意:
- 整个函数都没被执行过 (如 TEA::decrypt, main 没调用它) 会被单列为
"dead function", 它们不是 BCF 垃圾, 只是这次输入没走到。
- 想覆盖更多分支 (如 Wrong flag 分支), 用不同输入多跑几次 trace.py,
然后把多个 executed.txt 合并后再分析 (见 merge 说明)。

输出:
junk_rva.txt / junk_blocks.txt 垃圾块地址 (RVA / 运行时地址)
real_blocks.txt 真实执行过的块
"""
import json
import sys

from capstone import Cs, CS_ARCH_X86, CS_MODE_64
from elftools.elf.elffile import ELFFile

BINARY = "../build/main_bcf"
RUNTIME_BASE = 0x555555554000


def get_functions():
"""从符号表提取 {名称: (起始RVA, )}。"""
funcs = {}
with open(BINARY, "rb") as f:
elf = ELFFile(f)
for sym in elf.get_section_by_name(".symtab").iter_symbols():
if sym.entry.st_info.type == "STT_FUNC" and sym.entry.st_size > 0:
name = sym.name or f"sub_{sym.entry.st_value:x}"
funcs[name] = (sym.entry.st_value, sym.entry.st_value + sym.entry.st_size)
return funcs


def load_text():
with open(BINARY, "rb") as f:
elf = ELFFile(f)
sec = elf.get_section_by_name(".text")
return sec.data(), sec["sh_addr"]


def static_blocks_in(func_start, func_end, code, text_vaddr):
"""
递归下降反汇编 [func_start, func_end), 返回静态基本块入口集合。
从函数入口开始, 沿 jcc 两条边 / jmp / 顺序流向下走, 遇到 ret 停。
"""
md = Cs(CS_ARCH_X86, CS_MODE_64)
md.detail = True

def rva_to_off(rva):
return rva - text_vaddr

visited_insn = set()
blocks = set()
worklist = [func_start]

while worklist:
pc = worklist.pop()
off = rva_to_off(pc)
if not (0 <= off < len(code)) or pc >= func_end:
continue
# 一个基本块: 从 pc 顺序反汇编到第一个分支指令
block_start = pc
while True:
if pc in visited_insn or pc >= func_end:
break
visited_insn.add(pc)
o = rva_to_off(pc)
insn = next(md.disasm(code[o:o + 15], pc), None)
if insn is None:
break
pc += insn.size

if insn.mnemonic in ("ret", "retq", "jmp", "jb", "jae", "je", "jne",
"jg", "jge", "jl", "jle", "ja", "jbe", "js",
"jns", "jo", "jno", "jz", "jnz", "jecxz", "loop"):
# 无条件跳: 只有目标; 条件跳: 目标 + 顺序下一条
if len(insn.operands) > 0 and insn.operands[0].type == 2: # IMM
worklist.append(insn.operands[0].imm)
if insn.mnemonic != "jmp": # 条件跳/loop 的 fall-through
worklist.append(pc)
break
# call 视为顺序执行 (目标不展开, 那是另一个函数)
blocks.add(block_start)

return blocks


def block_extents_in(func_start, func_end, code, text_vaddr, blocks):
"""
给一组块入口补上结束地址: 每个块的 [start, next_start) 或到函数末尾。
返回 {start: end}。用于 patch.py 把垃圾块 NOP 掉。
"""
starts = sorted(b for b in blocks if func_start <= b < func_end)
ext = {}
for i, s in enumerate(starts):
e = starts[i + 1] if i + 1 < len(starts) else func_end
ext[s] = e
return ext


def main():
funcs = get_functions()

# 动态执行过的块
executed = set()
try:
with open("executed.txt") as f:
executed = {int(l.strip(), 16) for l in f if l.strip()}
except FileNotFoundError:
print("先运行 trace.py 生成 executed.txt")
return

code, text_vaddr = load_text()

print(f"{'function':<36} {'static':>7} {'exec':>6} {'junk':>6}")
all_junk = set()
junk_extents = {} # {start_rva: end_rva}
dead_funcs = []
for name, (s, e) in sorted(funcs.items(), key=lambda kv: kv[1][0]):
func_exec = [a for a in executed if s + RUNTIME_BASE <= a < e + RUNTIME_BASE]
if not func_exec:
dead_funcs.append((name, s, e))
continue
sblocks = static_blocks_in(s, e, code, text_vaddr)
sblocks_r = {a + RUNTIME_BASE for a in sblocks}
junk = sblocks_r - executed
all_junk |= junk
extents = block_extents_in(s, e, code, text_vaddr, sblocks)
for j in junk:
junk_extents[j - RUNTIME_BASE] = extents[j - RUNTIME_BASE]
print(f"{name:<36} {len(sblocks_r):>7} {len(func_exec):>6} {len(junk):>6}")

print(f"\ndead functions (never entered this run):")
for name, s, e in dead_funcs:
print(f" {name} [{s:08x}, {e:08x})")

with open("junk_rva.txt", "w") as f:
for a in sorted(all_junk):
f.write(f"{a - RUNTIME_BASE:08x}\n")
with open("junk_blocks.txt", "w") as f:
for a in sorted(all_junk):
f.write(f"{a:016x}\n")
with open("junk_extents.json", "w") as f:
json.dump({f"{s:08x}": f"{e:08x}" for s, e in sorted(junk_extents.items())},
f, indent=1)
print(f"\ntotal junk blocks: {len(all_junk)} -> junk_rva.txt / junk_blocks.txt / junk_extents.json")


if __name__ == "__main__":
main()

之后我们执行patch.py nop掉所有无用的块就行

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#!/usr/bin/env python3
"""
patch.py — 把 BCF 垃圾块 NOP 掉, 生成去混淆副本。

用法:
/home/f0x/anaconda3/envs/frida/bin/python patch.py

前置: 先跑 trace.py (喂多个输入合并) + analyze.py 生成 junk_extents.json

安全依据:
垃圾块 = 静态可达、动态从未执行。真实代码的不透明谓词分支永远
不往垃圾块跳, 所以把它们整块替换成 0x90 (NOP) 不影响运行。
补丁后会用 Qiling 重新跑一遍验证行为一致。

输出:
../build/main_bcf_patched 去混淆后的二进制副本 (NOP 填满垃圾块)
"""
import json
import os
import shutil

BINARY = "../build/main_bcf"
PATCHED = "../build/main_bcf_patched"
TEXT_VADDR = 0x10D0 # .text 的 vaddr == file offset (段对齐一致)

NOP = b"\x90"


def main():
with open("junk_extents.json") as f:
extents = {int(k, 16): int(v, 16) for k, v in json.load(f).items()}

shutil.copy(BINARY, PATCHED)
total = 0
with open(PATCHED, "r+b") as f:
for start, end in sorted(extents.items()):
# RVA -> 文件偏移 (.text 段 vaddr==offset)
off, size = start, end - start
if size <= 0:
continue
f.seek(off)
f.write(NOP * size)
total += size

print(f"patched {len(extents)} junk blocks, {total} bytes NOPed -> {PATCHED}")


if __name__ == "__main__":
main()

SUB:Instruction Substitution,指令替换混淆

示例

1
%r = add i32 %a, %b

替换后(恒等):

1
2
%neg = sub i32 0, %b            ; -b
%r = sub i32 %a, %neg ; a - (-b) == a + b

替换成更难看的恒等式子,不过肯定不会只有这么简单一般 这个ollvm 17替换规则如下

原运算 等价替换(本 fork 实现的变体)
a + b a - (-b);-((-a) + (-b));((a + r) + b) - r;((a - r) + b) + r(r 为随机常量)
a - b a + (-b);(a + r) - (b + r);(a - r) - (b - r)
a & b (a ^ ~b) & a;随机常量变体
a| b (a & b) | (a ^ b);随机常量变体
a ^ b (~a & b) | (a & ~b);随机常量变体
如果只有一层,一般ida 自己就优化了.这里我们示例开的是三层sub_loop
不过完全可以用一个例如GAMBA等工具去解决.这里就不详细说了

sobf-- String Obfuscation(字符串混淆)

字符串加密解密函数有时候放在.init_array 里面,有时候放在放在.text通过call调用 这里是第二种形式 比如这里.就是unk_406存放明文, &unk_2054是密文+key,调用解密函数sub_14C0进行解密,之后再输出解密函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
__int64 __fastcall sub_14C0(__int64 a1, __int64 a2)
{
__int64 result; // rax
int v3; // [rsp+20h] [rbp-4h]

result = 0LL;
v3 = 0;
if ( dword_4080 != 1 )
{
do
{
*(_BYTE *)(a1 + v3) = *(_BYTE *)(a2 + v3 % 22u) ^ *(_BYTE *)(a2 + 22 + v3);
result = (unsigned int)++v3;
}
while ( v3 != 17 );
dword_4080 = 1;
}
return result;
}

其实就是xor.调试什么的都行,或者模拟执行把解密后的都保存下来.

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#!/usr/bin/env python3
"""
sobf_decrypt_dump.py — 针对 sobf(字符串加密) 的模拟执行解密 dump。

原理:
sobf 把 C 字符串加密存 .rodata, 运行时由 goron_decrypt_string_* 解密,
把明文写入 .bss/.data 里的"解密后变量"。所以:
静态看 .rodata = 密文 (strings 看不到明文)
动态跑完后 .bss/.data = 明文
用 Qiling 模拟执行, 跑完后扫描主镜像内存的 ASCII 串,
就能拿到所有被 sobf 解密出来的原始字符串。

(这个方案 "只 dump 不改 ELF", 比 hook 解密函数更简单可靠——不需要定位
或解析解密函数, 明文解密后自然出现在可写段里。)

用法:
/home/f0x/anaconda3/envs/frida/bin/python sobf_decrypt_dump.py <目标二进制> [输入文件]
"""
import importlib.util
import io
import re
import sys

from elftools.elf.elffile import ELFFile
from qiling import Qiling


def find_main_image(ql, name_hint):
"""在 ql.loader.images 里找主程序镜像。"""
for img in ql.loader.images:
if name_hint in img.path:
return img
return ql.loader.images[0]


def extract_ascii(data: bytes, min_len=4):
"""从内存字节里提取所有可打印 ASCII 字符串。"""
return [m.decode() for m in re.findall(rb'[\x20-\x7e]{%d,}' % min_len, data)]


def main():
if len(sys.argv) < 2:
print("用法: python sobf_decrypt_dump.py <目标二进制> [输入文件]")
return
binary = sys.argv[1]
payload = open(sys.argv[2], "rb").read() if len(sys.argv) > 2 else b"A" * 22

ql = Qiling([binary], "/", multithread=True, verbose=0)
ql.os.stdin = io.BytesIO(payload)
ql.os.stdout = io.BytesIO()

out_buf = bytearray()

def on_write(ql):
nonlocal out_buf
fd, buf, count = ql.arch.regs.rdi, ql.arch.regs.rsi, ql.arch.regs.rdx
if fd == 1: # stdout: C++ cout 输出, 解密后的字符串
out_buf += bytes(ql.mem.read(buf, count))

ql.os.set_syscall("write", on_write)
ql.run()

base = ql.loader.load_address

# sobf 解密后明文写入 .data/.bss(可写段); .rodata 是密文(垃圾)。
# 逐个读这些段的运行时内容, 提取 ASCII 串即可拿到解密明文。
elf = ELFFile(open(binary, "rb"))
plaintext = []
for sec in elf.iter_sections():
if sec.name not in (".data", ".bss") or not sec["sh_addr"]:
continue
try:
seg_data = bytes(ql.mem.read(sec["sh_addr"] + base, sec["sh_size"]))
except Exception:
continue
plaintext.extend(extract_ascii(seg_data, min_len=4))
# C++ cout 的明文在 stdout(输出流), 不在 .bss 全局变量 -> 用 write hook 补上
plaintext.extend(extract_ascii(bytes(out_buf), min_len=4))

# 去噪: 去掉库/GNU/编译期符号
noise = ("GCC", "GNU", "GLIBC", "GLIBCXX", "crtstuff", "_Z", "@GLIBC",
"std::", "libc", "libstd", "libm", "libgcc", "/lib64", "ld-linux")
seen, uniq = set(), []
for s in plaintext:
if not any(t in s for t in noise) and s not in seen:
seen.add(s)
uniq.append(s)

print(f"主镜像: {binary}\n运行时: {hex(base)}\n")
print("=== sobf 解密后, 拿到的明文字符串 ===")
for s in uniq:
print(f" {s!r}")


if __name__ == "__main__":
main()

这里还是ai写的,我们hook所有的系统调用write 来输出

split —— Basic Block Splitting(基本块分割)

把一个大的基本块分割成为N个小基本块,不过一般会被ida优化,建议搭配 fla或者 bcf食用

ibr —— Indirect Branch(间接分支)

关于简介调用,打开了我们可能就只能看到这个样子因为有分支就会被 打断.具体是怎样的 比如遇到了类似与这种与到了条件跳转比如jz之类的指令就会有两个分支或者多个分支

1
2
3
4
if (x > 10)
foo();
else
bar();

这种,就会把 就会被把跳转的块记录为在表中并加密. 之后就变成了这样 rcx控制跳转哪个块,作为偏移,在

1
mov     rax, [rax+rcx*8+8]

这里 之后

1
mov     rcx, 870FF5D0531B4286h

这里作为key,add key之后就恢复为之前的块地址,再无条件挑战就行. 同样,模拟执行也可以去除间接分支

去间接分支

我认为其实在执行过程中就能找到真正的执行的,因为key是一样的所以每次得到的地址都是一样的我们只需要把后面的jmp rax 换成真正的地址就能解决.这样也不用管key是静态还是动态的.反正地址是固定的就行.这里我们还是用模拟执行为例

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225

#!/usr/bin/env python3只处理主镜像里的指令
# -*- coding: utf-8 -*-
"""
deobf_ibr_emul.py — 用 Qiling 模拟执行, 取出 -ibr (间接分支) 的真实跳转目标
================================================================================

【这是什么】
OLLVM 的 -ibr pass 会把"条件分支"改成:
查加密表 -> +密钥 V 解密 -> jmp rax
这样指令里看不到目标地址, 静态分析(IDA)会在 jmp rax 处断头。

【为什么模拟执行能解】
"藏"只对静态生效: 程序真正执行时, 目标地址一定会被算出来放进寄存器。
所以只要:
① 用 Qiling 把二进制完整模拟跑起来;
② 在每条指令执行【前】hook 一次;
③ 遇到 jmp/call <寄存器> 时, 直接读那个寄存器的值;
拿到的就是"程序自己算出的真实目标"——不需要找密钥 V、不需要解表,
对每次编译随机密钥、甚至动态密钥(MySecret != 0) 都天然免疫。
(顺带记录 'call reg' = -icall 间接调用的目标, 同一个套路。)

【用法】
uniconre/venv/bin/python deobf_ibr_emul.py [选项]

选项:
-b, --binary 目标二进制 (默认 ../build/main_ibr)
-i, --input 输入文件, 可重复传; 不传则自动跑默认输入组
-f, --force-correct
hook 掉 memcmp 强制相等 -> 走 "Correct" 分支
(不需要知道 flag 就能覆盖正确路径)
-m, --memcmp-off memcmp 调用的 RVA (默认 0x13e5, 当前 main_ibr 的
间接调用点; 重新编译后需用 objdump 重新确认)
-o, --out 输出文件名前缀 (默认 deobf_ibr_emul)

【输出】
控制台 : 每个 (jmp/call reg) 站点 -> 执行到的目标 (均为 RVA)
<out>_sites.json : {"站点RVA": ["目标RVA", ...], ...}

【依赖】
Qiling 1.4.6 (uniconre/venv 内已装), capstone, readelf
"""

import io
import json
import os
import argparse

from capstone import Cs, CS_ARCH_X86, CS_MODE_64
from qiling import Qiling

# ======================================================================
# 1. 常量配置区 —— 路径与默认值
# ======================================================================

# 本脚本所在目录: 用它推导 rootfs / 二进制的相对位置, 不依赖当前工作目录
HERE = os.path.dirname(os.path.abspath(__file__))

# Qiling 的 Linux 文件系统根: 直接用 uniconre 里准备好的 ubuntu24_rootfs
ROOTFS = os.path.join(HERE, "ubuntu24_rootfs")

# 默认目标二进制: uniconre 的上一级目录 build/main_ibr
DEFAULT_BIN = os.path.abspath(os.path.join(HERE, "../build/main_ibr"))

# memcmp 被调用的位置 (RVA)。
# 当前 main_ibr 里 memcmp 被 -icall 改成 "call rax" 于 0x13e5;
# 若重新编译, 用 `objdump -d ... | grep -B1 memcmp` 重新找。
DEFAULT_MEMCMP_CALL_OFF = 0x13e5

# 我们关心的"间接跳转/调用"所允许的目标寄存器集合。
# op_str 精确等于这些名字, 才能排除:
# 直接跳转 jmp 0x1234 (op_str = "0x1234")
# 内存间接 jmp qword [rax] (op_str = "[rax]")
# 条件跳转 jne 0x1234 (mnemonic 不是 jmp)
JMP_REGS = set("rax rbx rcx rdx rsi rdi r8 r9 r10 r11 r12 r13 r14 r15".split())

# x86-64 反汇编器句柄: 把内存里的字节翻译成指令 (mnemonic / op_str)。
# CS_MODE_64 = 64 位模式; 15 = x86-64 单条指令的最大长度。
md = Cs(CS_ARCH_X86, CS_MODE_64)
MAX_INSN_LEN = 15


# ======================================================================
# 2. 单次模拟运行 —— 核心探针
# ======================================================================

def run_once(ql, label, lo, hi, out):
"""
用给定的 qiling 实例跑完整个程序, 记录本镜像内所有 jmp/call 寄存器的目标。

参数:
ql : 已构造好的 Qiling 实例 (输入已设置)
label : 本次运行的说明文字 (用于打印日志)
lo,hi : 主镜像的地址范围 [base, end), 只在这范围内 hook, 过滤 libc 噪音
out : 跨多次运行共享的汇总字典 {站点RVA: [目标RVA, ...]}
"""
sites = {} # 本次运行内: {站点RVA: [目标RVA, ...]}

def hook(ql, addr, size):
"""
指令级探针: 每条指令【执行前】被调用一次 (Qiling/Unicorn 语义)。

三步:
① 范围过滤: (libc/libstdc++ 的每条指令
也会触发回调, 不滤掉会慢一个数量级且结果全是噪音);
② 反汇编: 读当前 PC 处的字节, 用 capstone 解出这一条指令;
③ 识别 + 记录: 若是 "jmp/call <寄存器>", 执行前读该寄存器的值
= 程序刚算出的真实目标 (ibr/icall 隐藏的东西), 记下 (站点->目标)。
"""
# ---- ① 范围过滤 ----
if not (lo <= addr < hi):
return

# ---- ② 反汇编 ----
try:
code = ql.mem.read(addr, MAX_INSN_LEN) # 读模拟内存里的原始字节
except Exception:
return # 读到未映射页尾等: 跳过
ins = next(md.disasm(code, addr), None) # 解出第一条指令
if ins is None:
return

# ---- ③ 识别并记录 ----
if ins.mnemonic in ("jmp", "call") and ins.op_str in JMP_REGS:
tgt = getattr(ql.arch.regs, ins.op_str) # 执行前寄存器 = 目标(绝对地址)
site = addr - lo # 站点归一成 RVA
tgt -= lo # 目标归一成 RVA (跨运行可比)
sites.setdefault(site, []).append(tgt)

# 挂上探针, 开跑
ql.hook_code(hook)
ql.run()

# 本次结果去重后并入跨运行汇总表
for s, ts in sites.items():
out.setdefault(s, []).extend(t for t in set(ts))

# 打印本次运行的简明摘要
joined = ", ".join(
f"0x{s:x}->0x{t:x}"
for s in sorted(sites)
for t in sorted(set(sites[s]))
)
print(f" [{label}] 抓到的点: {joined}")


# ======================================================================
# 3. 主流程: 参数解析 -> 组合输入 -> 逐次模拟 -> 汇总输出
# ======================================================================

def main():
ap = argparse.ArgumentParser(
description="Qiling 模拟执行, 取出 -ibr 的真实跳转目标")
ap.add_argument("-b", "--binary", default=DEFAULT_BIN,
help="目标二进制")
ap.add_argument("-i", "--input", action="append", default=None,
help="输入文件(可重复); 不传则自动跑默认输入组")
ap.add_argument("-f", "--force-correct", action="store_true",
help="hook memcmp 强制相等, 覆盖 Correct 分支")
ap.add_argument("-m", "--memcmp-off",
type=lambda x: int(x, 0), default=DEFAULT_MEMCMP_CALL_OFF,
help="memcmp 调用点的 RVA")
ap.add_argument("-o", "--out", default="deobf_ibr_emul",
help="输出文件名前缀")
a = ap.parse_args()

# ---- 输入组合: 决定这次能覆盖哪些分支路径 ----
payloads = []
if a.input:
payloads = [(f"输入文件 {p}", open(p, "rb").read()) for p in a.input]
else:
# 长度错误 -> 走 if1 的 Wrong 分支
payloads.append(("长度错误", b"x\n"))
# 22 个 'A' -> 走 if1 继续 + for 循环 + if2 的 Wrong 分支
payloads.append(("22个A", b"A" * 22 + b"\n"))
if a.force_correct:
# 同一输入 + memcmp 强制相等 -> 再覆盖 if2 的 Correct 分支
payloads.append(("22个A+forceCorrect", b"A" * 22 + b"\n"))

print(f"目标: {a.binary}")
all_sites = {} # 所有运行汇总: {站点RVA: [目标RVA, ...]}

# ---- 逐组输入, 各起一个干净的 Qiling 实例 ----
for label, payload in payloads:
ql = Qiling([a.binary], ROOTFS,
multithread=True, # C++ 二进制必需: libstdc++ 初始化用到 futex
verbose=0)
ql.os.stdin = io.BytesIO(payload) # 注入 stdin (直接 ql.stdin= 不会进 fd 表)
ql.os.stdout = io.BytesIO() # 捕获程序输出

# 定位主镜像, 拿到 [base, end) 用于探针范围过滤
img = next(i for i in ql.loader.images
if os.path.basename(i.path) == os.path.basename(a.binary))
lo, hi = img.base, img.end

# force-correct: 在 "call memcmp" 那条指令处, 把实参 rdi 改成 rsi
# -> memcmp(自己, 自己) 必返回 0 -> 走 Correct 分支 (无需知道 flag)
if a.force_correct and "forceCorrect" in label:
def force_eq(ql):
ql.arch.regs.rdi = ql.arch.regs.rsi
ql.hook_address(force_eq, lo + a.memcmp_off)

print(f" 镜像基址={hex(lo)} 范围={hex(lo)}-{hex(hi)} "
f"(.text 起始 RVA ≈ 0x10e0)")
run_once(ql, label, lo, hi, all_sites)
print(f" exit={ql.os.exit_code} stdout={ql.os.stdout.getvalue()!r}")

# ---- 汇总输出 ----
print()
print("=== 汇总: 所有站点 -> 目标 (RVA) ===")
for s in sorted(all_sites):
print(f" 0x{s:x} -> " + ", ".join(
f"0x{t:x}" for t in sorted(set(all_sites[s]))))

with open(f"{a.out}_sites.json", "w") as f:
json.dump(
{f"0x{k:x}": [f"0x{v:x}" for v in sorted(set(all_sites[k]))]
for k in sorted(all_sites)},
f, indent=1)
print(f"已保存 {a.out}_sites.json")


if __name__ == "__main__":
main()

这里我们只是先得到了,一个 分支 两种可能的地址.之后我们再patch 修改elf 之后我们patch这样修改

1
2
3
jcc <真目标>      ; 条件成立 → 目标A   (6字节)
jmp <假目标> ; 不成立 → 目标B (5字节)
NOP × n ; 抹掉查表链
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
patch_from_json.py — 全自动闭环: emul JSON + 静态推导 -> 直接 patch ELF

输入: deobf_ibr_emul.py 输出的 sites JSON (站点RVA -> [目标RVA,...])
中间: 对每个站点, 从二进制静态推导 patch 所需的两个关键信息:
① 条件语义 : 谁是真目标/假目标 (由 setXX/cmovXX 语义 + 表解密确定)
② 补丁锚点 : 条件标志还活着的位置 (setXX/cmov 指令处)
输出: 补丁后的新 ELF (jcc 真目标; jmp 假目标; 死代码 NOP)

用法:
python3 patch_from_json.py <ELF> <sites.json> [输出ELF]
"""
import subprocess, re, struct, json, sys, os

HERE = os.path.dirname(os.path.abspath(__file__))

# 条件后缀 -> 跳转 opcode (setXX 置1的条件 == jCC 跳转的条件)
JCC = {'e': 0x0f84, 'ne': 0x0f85, 'ge': 0x0f8d, 'l': 0x0f8c,
'le': 0x0f8e, 'g': 0x0f8f, 'a': 0x0f87, 'b': 0x0f82,
's': 0x0f88, 'ns': 0x0f89, 'o': 0x0f80, 'no': 0x0f81}

RX_SET = re.compile(r'set([a-z]{1,3})\s+al')
RX_CMOV = re.compile(r'cmov([a-z]{1,2})\s+r\w+,\s*r\w+')
RX_LEA = re.compile(r'lea\s+r\w+,\s*\[rip\s*\+\s*(0x[0-9a-f]+)\]')
RX_L8 = re.compile(r'mov\s+r\w+,\s*qword \[\s*r\w+\s*\+\s*r\w+\*8(?:\s*\+\s*(0x[0-9a-f]+|\d+))?\]')
RX_LS = re.compile(r'mov\s+r\w+,\s*qword \[\s*r\w+\s*\+\s*r\w+(?:\s*\+\s*(0x[0-9a-f]+|\d+))?\]')
RX_SHL = re.compile(r'shl\s+r\w+,\s*(\d+)')
RX_MOV = re.compile(r'movabs\s+r\w+,\s*(0x[0-9a-f]+)')

def ripabs(a, s, d):
m = RX_LEA.search(d)
return (a + s + int(m.group(1), 16)) & 0xffff if m else None

def main():
src, js = sys.argv[1], sys.argv[2]
dst = sys.argv[3] if len(sys.argv) > 3 else os.path.join(HERE, '../build/main_ibr_auto_patched')
emul = {int(k, 16): sorted(int(v, 16) for v in vs)
for k, vs in json.load(open(js)).items()}

# ---- 反汇编 + 段表 ----
ro = subprocess.run(['readelf', '-S', '-W', src], capture_output=True, text=True).stdout
tv = toff = dv = dfo = 0
for line in ro.splitlines():
m = re.match(r'\s*\[\s*\d+\]\s+(\.text|\.data)\s+\S+\s+([0-9a-f]+)\s+([0-9a-f]+)', line)
if m:
if m.group(1) == '.text': tv, toff = int(m.group(2), 16), int(m.group(3), 16)
else: dv, dfo = int(m.group(2), 16), int(m.group(3), 16)
raw = subprocess.run(['r2', '-q', '-e', 'scr.color=0',
'-c', f'pdj 900 @ {tv}', src],
capture_output=True, text=True).stdout
insns = [(int(i['addr']), int(i['size']), (i.get('disasm') or i.get('opcode', '')))
for i in json.loads(raw)]
blob = bytearray(open(src, 'rb').read())
def qword(va): return struct.unpack('<Q', blob[dfo + (va - dv): dfo + (va - dv) + 8])[0]

n_ok = 0
for site, tgts in sorted(emul.items()):
if len(tgts) != 2:
print(f'跳过 0x{site:x}: 目标数={len(tgts)} (非两路 ibr)'); continue
idx = next((i for i, (a, s, d) in enumerate(insns) if a == site), None)
if idx is None or not re.search(r'jmp\s+r\w+', insns[idx][2]):
print(f'跳过 0x{site:x}: 非 jmp reg 站点'); continue

# ---- 回看: 收集 V / 取表形态 / 条件锚点 / lea 基址 (扫满窗口, 不提前 break) ----
V = mode = jcc = anchor = None
off = shl = 0
leas = []
for j in range(idx - 1, max(idx - 17, -1), -1):
a, s, d = insns[j]
m = RX_MOV.search(d)
if m and V is None: V = int(m.group(1), 16)
if anchor is None: # 条件锚点: setXX / cmovXX
m = RX_SET.search(d)
if m: jcc, anchor = JCC[m.group(1)], a
else:
m = RX_CMOV.search(d)
if m:
jcc, anchor = JCC[m.group(1)], a
if mode is None: mode = 'cmov'
b = ripabs(a, s, d)
if b is not None: leas.append(b)
if mode is None: # 取表形态: *8 或 shl+[r+r]
m = RX_L8.search(d)
if m: mode, off = 'load8', int(m.group(1), 0) if m.group(1) else 0
else:
m = RX_LS.search(d)
if m: mode, off = 'shl', int(m.group(1), 0) if m.group(1) else 0
m = RX_SHL.search(d)
if m: shl = int(m.group(1))
if not (mode and V is not None and anchor is not None and jcc is not None):
print(f'跳过 0x{site:x}: 推导失败(mode={mode}, jcc={jcc})'); continue

# ---- 由表解密推导 真/假 目标 ----
if mode == 'cmov':
if len(leas) < 2:
print(f'跳过 0x{site:x}: cmov 缺基址'); continue
t_f = qword(leas[-2]) + V & 0xffffffffffffffff # cmov 未取 (rax 基址)
t_t = qword(leas[-1]) + V & 0xffffffffffffffff # cmov 取 (rcx 基址)
else:
if not leas:
print(f'跳过 0x{site:x}: 缺表基址'); continue
base = leas[0] # 离 load 最近的 lea = 表基址
i0 = off // 8
stride = (1 << shl) // 8 if mode == 'shl' else 1
t_f = qword(base + 8 * i0) + V & 0xffffffffffffffff # 条件假(cond=0)
t_t = qword(base + 8 * (i0 + stride)) + V & 0xffffffffffffffff # 条件真(cond=1)
if sorted([t_f, t_t]) != tgts:
print(f'跳过 0x{site:x}: 推导 {[hex(x) for x in [t_f,t_t]]} != emul {[hex(x) for x in tgts]}'); continue
if t_f == t_t:
print(f'跳过 0x{site:x}: 两目标相同'); continue

# ---- 写补丁: 锚点处 jcc 真目标; jmp 假目标; NOP 到 jmp 结束 ----
end = site + 2
fb = anchor - (tv - toff)
n = end - anchor
if n < 11:
print(f'跳过 0x{site:x}: 区域太小({n}B)'); continue
blob[fb:fb + 6] = struct.pack('>H', jcc) + struct.pack('<i', t_t - (anchor + 6))
blob[fb + 6:fb + 11] = b'\xe9' + struct.pack('<i', t_f - (anchor + 11))
blob[fb + 11:fb + n] = b'\x90' * (n - 11)
n_ok += 1
print(f'PATCH 0x{site:x}: 锚点0x{anchor:x} jcc->0x{t_t:x}, jmp->0x{t_f:x}, NOPx{n-11}')

open(dst, 'wb').write(bytes(blob))
os.chmod(dst, os.stat(src).st_mode)
print(f'\n完成: {dst} (成功 {n_ok}/{len(emul)} 站点)')

if __name__ == '__main__':
main()

icall —— Indirect Call(间接调用)

间接调用,非常常见的一个问题. 其实就是把

1
call fun

改成

1
2
mov rax fun
call rax

这种形式.处理方法其实就和前面的ibr差不多用模拟执行批量处理

fla(控制流平坦化)

个人感觉这应该是最难的一部分,因为他塞了很多东西,我们可以先讲一讲加了fla后cfg的结构 ok上面的图相信能够看出被混淆后大概的结构,原本的我们也只有真实块这部分 控制流平坦化其实就是把 原来的 比如

1
2
3
4
5
6
7
8
9
10
11
int foo(int x)
{
int y;

if (x > 10)
y = x + 1;
else
y = x - 1;

return y * 2;
}

CFG是

1
2
3
4
5
6
7
8
9
10
11
12
     Entry
|
x > 10
/ \
/ \
A B
y = x+1 y = x-1
\ /
\ /
C
return

而平坦化之后, 会带来一个dispatcher(分发器) 他会把你的结构变成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
          Prologue
|
state = STATE_ENTRY
|
v
+-------------+
| Dispatcher |
+------+------+
|
+---------+---------+
| | |
v v v
A B C
| | |
| | |
改 state 改 state return
| |
+----+----+
|
v
Dispatcher

这里更改state 后其实就是会根据a 之后是什么又跳到其他 块,这就是一个状态的形式了 前面只是一个引入.后面我们来了解一下控制流平坦化后的组成

  • 序言块:函数入口那部分块
  • 状态变量:你可以看作 switch(state)的操控case到哪里的值
  • 主分发器:控制真实块的执行路径 比如这里,他会根据判断,一步步往下走,也就是下面的子分发器.不过其实我觉得就是一步步往下推 用sub eax case_value来判断,是就到对应的块,不是就继续往下执行.
  • 真实块:原本的业务逻辑 不过你需要注意.原先的业务逻辑块一般有三种情况 比如对于一个块A,他的后继,也决定了复杂程度
  1. 没有后继块,就直接retn了
  2. 有1个后继块:A-> B,把state 修改成B对应的case 的value就行
  3. 有两个后继块,对应条件跳转. 这里就根据判断选择 value就行,实现的话就用select
  • 预分发块 jmp回分发器

  • Return Block 程序退出

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
qiling_fla_encrypt.py —— 用 Qiling 模拟执行,去除 TEA::encrypt 的控制流平坦化(FLA)

目标: tea/build/main_fla (OLLVM -fla 混淆的 C++ TEA 程序)

FLA 把函数摊成状态机:
真实块 D --(写下一个状态 S')--> 跳回分发器 --> 分发器读状态 S --> 跳到块 D(S)
所以"藏"的不是"执行了哪些块",而是"块与块之间的先后/依赖关系(CFG)"。

本脚本思路(3 步):
① 在分发器头读状态槽 -> 得到当前状态 S,记下"上一个真实块写出的下一个状态就是 S" -> b2s[prev].add(S)
② 在分发器比较链里找"K == S"的那条 sub/cmp $K; je D -> 状态 S 分派到真实块 D -> s2b[S].add(D)
③ 合成: 块 B 的后继 = { s2b[S'] for S' in b2s[B] },即真实 CFG

TEA::encrypt 的循环自然覆盖两条出边(i<64 进循环体 / i>=64 退出),所以单次运行即可还原。

用法:
/home/f0x/anaconda3/envs/frida/bin/python qiling_fla_encrypt.py
(或任何装了 qiling + capstone 的 python)
"""
import io
import os

from capstone import Cs, CS_ARCH_X86, CS_MODE_64
from qiling import Qiling

# ============ 配置 ============
HERE = os.path.dirname(os.path.abspath(__file__))
BIN = os.path.abspath(os.path.join(HERE, "../build/main_fla"))
ROOTFS = os.path.join(HERE, "ubuntu24_rootfs")

# TEA::encrypt 的 FLA 参数(RVA 来自 objdump)
ENC_BASE = 0x1590 # 函数入口
ENC_NEXT = 0x1780 # 下一个函数的入口(=本函数结束)
ENC_SLOT = -0x44 # 状态变量相对 rbp 的偏移(movl $0x6b8b4567,-0x44(%rbp))
ENC_HEAD = 0x15ec # 分发器头(mov -0x44(%rbp),%eax)

# 条件跳转助记符
COND_JUMPS = {"je","jne","jz","jnz","jg","jge","jl","jle",
"ja","jae","jb","jbe","js","jns","jo","jno","jp","jnp","jc","jnc"}

md = Cs(CS_ARCH_X86, CS_MODE_64)
MAX_INSN = 15


def parse_last_imm(op_str):
"""从 'eax, 0x327b23c6' 取最后一个立即数(int),取不到返回 None"""
tok = op_str.split(",")[-1].strip()
try:
return int(tok, 0) if tok.lower().startswith("0x") else int(tok, 16)
except ValueError:
return None


def parse_jump_target(op_str):
"""从 '0x1659' 取跳转目标(int)"""
tok = op_str.strip()
try:
return int(tok, 0) if tok.lower().startswith("0x") else int(tok, 16)
except ValueError:
return None


def main():
# 22 字节输入让 main 走完 3 次 encrypt(内容无所谓,只要长度对)
ql = Qiling([BIN], ROOTFS, multithread=True, verbose=0)
ql.os.stdin = io.BytesIO(b"A" * 22 + b"\n")
ql.os.stdout = io.BytesIO()

base = ql.loader.load_address # PIE 基址

head_abs = base + ENC_HEAD
entry_abs = base + ENC_BASE
end_abs = base + ENC_NEXT

# 运行期状态
state = {"cur_block": None, "waiting": False, "comparing_k": None,
"pending_state": None, "rbp": 0}
s2b = {} # 状态 -> 分派到的真实块集合
b2s = {} # 真实块 -> 写出的下一个状态集合

def on_code(ql, addr, size):
# 只关心 encrypt 函数体内
if not (entry_abs <= addr < end_abs):
return
rva = addr - base

# 姿态0: 函数入口 -> 重置本轮机器状态(防跨调用残留)
if addr == entry_abs:
state["cur_block"] = None
state["waiting"] = False
state["comparing_k"] = None
return

# 姿态1: 分发器头 -> 读状态 S
if addr == head_abs:
state["rbp"] = ql.arch.regs.rbp
S = int.from_bytes(ql.mem.read(state["rbp"] + ENC_SLOT, 4), "little")
prev = state["cur_block"]
if prev is not None:
b2s.setdefault(prev, set()).add(S) # prev 写出的下一个状态 = S
state["pending_state"] = S
state["waiting"] = True
state["comparing_k"] = None
state["cur_block"] = None
return

# 姿态2: 在分发器比较链里, 用 "K == 状态" 找 taken 的那条 je
if not state["waiting"]:
return
ins = next(md.disasm(ql.mem.read(addr, MAX_INSN), addr), None)
if ins is None:
return
if ins.mnemonic in ("sub", "cmp"):
k = parse_last_imm(ins.op_str)
if k is not None:
state["comparing_k"] = k
return
if ins.mnemonic in COND_JUMPS:
if state["comparing_k"] == state["pending_state"]:
tgt = parse_jump_target(ins.op_str)
if tgt is not None:
blk = tgt - base
state["waiting"] = False
state["cur_block"] = blk
s2b.setdefault(state["pending_state"], set()).add(blk)
return

ql.hook_code(on_code)
ql.run()

# ============ 输出 ============
print(f"镜像基址 = {hex(base)}\n")
print("== 分发表: 状态常量 -> 真实块 ==")
for s in sorted(s2b):
print(f" 0x{s:08x} -> " + ", ".join(f"0x{b:x}" for b in sorted(s2b[s])))
print("\n== 块转移: 真实块 -> 写出的下一个状态 ==")
for b in sorted(b2s):
print(f" 0x{b:x} -> " + ", ".join(f"0x{s:08x}" for s in sorted(b2s[b])))

# 合成 CFG
cfg = {}
for b, states in b2s.items():
succ = set()
for s in states:
succ.update(s2b.get(s, set()))
cfg[b] = sorted(succ)

print("\n== 还原出的真实控制流(CFG) ==")
print(" 入口(白化+sum=0+i=0) -> 0x1640(循环判断)")
for b in sorted(cfg):
succ = cfg[b]
label = {0x1640: "循环判断 i<64", 0x1659: "轮主体 sum+=DELTA,mix",
0x175d: "i++", 0x1772: "返回"}.get(b, "?")
if succ:
print(f" 0x{b:x}({label}) -> " + ", ".join(f"0x{x:x}" for x in succ))
else:
print(f" 0x{b:x}({label}) -> (返回)")

print("\n== 对应源码 tea.cpp 的 encrypt ==")
print(" v0^=k0^k3; v1^=k1^k2; sum=0; // 入口白化")
print(" for(i=0;i<64;i++){ // 0x1640 循环判断")
print(" sum+=DELTA; v0+=mix; v1+=mix; // 0x1659 轮主体")
print(" } // 0x175d i++ 回到判断")
print(" return; // 0x1772")


if __name__ == "__main__":
main()

这里我们只对encrypt做了恢复,具体流程见代码和注释,还是比较好理解的.

分享: 微博 QQ Telegram X
2026-09-03

⬆︎TOP