使用LLVM(Low Level Virtual Machine) PASS可以通过编译器后端自动插入花指令, 而不需要手动在源代码里内联汇编。
1. 花指令简介
简单来说就是一块无意义的汇编指令, 用于干扰反汇编器工作。
无论是做CTF题, 还是实战破解, 偶尔也会遇到一些花指令。
传统的花指令可以通过内联汇编进行编写, 例如:
int main(int argc, const char* argv[])
{
__asm
{
push eax
jz TMP1
jnz TMP1
push eax
TMP0:
add esp, 4
jmp END
TMP1:
call TMP0
pop eax
END:
pop eax
}
//user code
return 0;
}
以上汇编毫无意义, 有没有都一样, jmp来jmp去最后还是运行到了用户代码, 但是可以在一定程度上影响反汇编器的正常工作。
但是这样写有两个问题:
-
微软的MSVC编译器只支持x86的内联汇编, 不支持x64的 -
这样写需要在函数中手动添加花指令, 首先会破坏源代码的可读性, 其次也很麻烦
那么就需要一种不影响源代码的, 不需要手动插入的方法 ——— LLVM PASS是一个不错的选择。
2. LLVM简介
2.1 LLVM
广义上的llvm指的是包括clang前端+llvm后端, 狭义上的llvm仅指代后端
一个编译器需要包含:
-
Frontend前端: 词法分析、语法分析、语义分析、生成中间代码 -
Optimizer优化器: 进行中间代码优化 -
Backend后端: 将优化后的中间代码生成目标机器码
下图所示是传统的编译器架构:
LLVM也是一款编译器, 它的架构也是三段式, 但其并没有将这三段耦合起来:
LLVM采用统一的中间语言IR, 也就是比如C++代码, 经过clang进行语法分析后将高级语言转为了IR中间语言,
然后使用llvm opt将IR进行了一系列优化, 最后再把优化后的IR编译成机器码, 如下图所示:
可以看到IR经过一层层Pass最后到了Backend进行编译, llvm提供了一系列的接口提供给开发者编写pass对IR进行操作。
你甚至可以写一个pass, 把函数全部删掉都可以, 所以在IR中插入一系列指令也是完全可以。
那么IR这种中间语言是怎么表示高级语言的呢?
2.1 LLVM IR
首先看一下llvm对IR的组织结构:
module代表一个编译模块(就是一个.cpp文件), Function就是一个函数, BasickBlock指的是可连续执行的指令块(就是没有跳转等中断的指令集合), 更详细的内容可以去查看官方文档
在Pass中可以选一个维度进行操作, 比如可以遍历Function对每个Function进行操作
我们直接编译一个printf看看是怎么用IR表示的:
//test.c
#include <stdio.h>
int main(int argc, const char* argv[])
{
printf("your argc:%dn", argc);
return 0;
}
使用以下命令行:
clang -emit-llvm test.c -o test.ll -S
-S表示生成方便人类阅读的文本形式.ll(类似于反汇编), 默认生成的是机器处理的字节码.bc(类似于汇编):
; ModuleID = 'test.c'
@.str = private unnamed_addr constant [14 x i8] c"your argc:%d A 0", align 1
; Function Attrs: mustprogress noinline norecurse optnone uwtable
define dso_local noundef i32 @main(i32 noundef %0, ptr noundef %1) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca ptr, align 8
store i32 0, ptr %3, align 4
store i32 %0, ptr %4, align 4
store ptr %1, ptr %5, align 8
%6 = load i32, ptr %4, align 4
%7 = call i32 (ptr, ...) @printf(ptr noundef @.str, i32 noundef %6)
ret i32 0
}
declare i32 @printf(ptr noundef, ...) #1
上面的IR只展示了关键代码, 像一些datalayout/triple等信息表示的是目标平台和编译信息啥的, 不需要关心
以上IR猜也能猜个大概, %0代表的是argc, 然后alloca在栈上分配一块空间(类似于malloc), %4指向该空间, 然后store %0, ptr %4, 把argc存储到%4指针里, 然后%6 = load ptr %4, 从%4指针里取数据到%6中, 然后call printf(@.str, %6), 参数是 .str(就是c"your argc:%d A 0"字符串), 和argc
关于更多的IR结构和语法, 可以去看官方文档, 或者看雪上这篇文章(https://bbs.kanxue.com/thread-279624.html)也不错
2.3 LLVM PASS
新版PASS的官方文档:
https://llvm.org/docs/WritingAnLLVMNewPMPass.html
官方示例可以查看: llvm源代码目录/llvm/examples/Bye/Bye.cpp
其中LegacyBye是旧版写法, struct Bye : PassInfoMixin<Bye> 是新写法
pass中需要实现llvm::PassPluginLibraryInfo getByePluginInfo()用于返回插件信息
并需要导出函数:
extern "C" LLVM_ATTRIBUTE_WEAK ::llvm::PassPluginLibraryInfo
llvmGetPassPluginInfo() {
return getByePluginInfo();// <-这个函数是上面你实现的返回插件信息的函数
}
官方说明上表示可以①将编写的PASS直接集成到llvm代码中 ②也可以以插件的形式, 动态加载pass
第二种方法是比较好的, 就不用每次都重新编译了
3. 编写JunkCode Pass
下面将说明如何编写一个名为"JunkCode"的Pass, 用于插入花指令
3.1 配置llvm开发环境
我建议不要使用windows版本, 在windows下编译完后死活加载不上动态插件pass(也有可能是我的问题)
而且如果只是为了简单编写, 不需要debug的话, 我建议就不要自己编译了, 直接下载prebuilt的就好了。
去llvm的github官方仓库找'clang+llvm-18.1.4-x86_64-linux-gnu-ubuntu-18.04.tar.xz'这种下载即可
3.2 编写pass
关键代码:
bool InsertJunkCode::runOnFunction(Function &F) {
bool InsertedAtLeastOne = false;
int bb_index = 0;
for (auto &BB : F)
{
Instruction *beginInst = &*BB.getFirstInsertionPt();
IRBuilder<> builder(beginInst);
// Assemble the inline assembly code
std::string asmCode = llvm::formatv(
"pushq %raxn"
"callq {0}fn"
"movq $$0xe4, %raxn"
"{0}:n"
"popq %raxn"
"popq %raxn"
, bb_index);
// Create InlineAsm object
InlineAsm *asmInst = InlineAsm::get(
FunctionType::get(builder.getVoidTy(), false),
asmCode,
"",
true
);
// Insert the inline assembly instruction before the terminator instruction
builder.CreateCall(asmInst, {});
bb_index += 1;
InsertedAtLeastOne = true;
}
return InsertedAtLeastOne;
}
以上代码中, 内联汇编的语法和正常的有点不同, 具体要查看llvm官方文档(https://llvm.org/docs/LangRef.html#inline-assembler-expressions), 比如要表示立即数的话要用$$0xe4, 因为$另有他用。
逻辑是首先遍历每个Function, 其中'for (auto &BB : F)'是在遍历Function中的基本块, 然后拿到每个基本块的第一个可插入点'getFirstInsertionPt()', 最后插入花指令内联汇编(AT&T风格)
其中llvm::formatv是llvm提供的格式化字符串方法, 所以{0}指代的是bb_index.
即此pass会在每个基本块开头插入:
push rax ;压入rax
call TMP ;压入ret地址 并jmp到TMP
mov rax, 0xE4 ;无意义
TMP:
pop rax ;pop完rax=ret地址
pop rax ;pop完rax=原rax
callq {0}f ({0}会被替换成bb_index的值)是因为一个function内有多个basicblock, 所以标签名不能重复, 通过bb_index递增标签名
3.3 使用cmake编译
cmake和llvm有联动, 可以自动搜索llvm的库和头文件并设置pass编译设置, 还是比较方便的, 写一个CMakeLists.txt:
#cmake最低版本
cmake_minimum_required(VERSION 3.20)
#以下应全改为你的llvm路径
set(CMAKE_C_COMPILER "~/llvm18/prebuilt/bin/clang") # 指定C编译器路径
set(CMAKE_CXX_COMPILER "~/llvm18/prebuilt/bin/clang++") # 指定C++编译器路径
set(CMAKE_LINKER "~/llvm18/prebuilt/bin/lld") # 指定链接器
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g") # 带上-g 生成的pass .so会有调试信息 崩溃时容易定位
project(JunkCode) #项目名称
set(CMAKE_CXX_STANDARD 17) #C++17标准
#设置LLVM安装路径
if(NOT DEFINED ENV{LLVM_HOME})
message(WARNING "Auto Set LLVM_HOME...")
set(ENV{LLVM_HOME} "~/llvm18/prebuilt")
endif()
message(STATUS "LLVM_HOME = [$ENV{LLVM_HOME}]")
set(ENV{LLVM_DIR} "$ENV{LLVM_HOME}/lib/cmake/llvm") # Default llvm config file path
find_package(LLVM REQUIRED CONFIG) # 查找并加载LLVM的CMake模块
# 将LLVM的CMake模块路径添加到CMake模块路径中
list(APPEND CMAKE_MODULE_PATH "${LLVM_CMAKE_DIR}")
#包括AddLLVM.cmake模块
set(result "")
include(AddLLVM RESULT_VARIABLE result)
include_directories(${LLVM_INCLUDE_DIRS}) # 添加LLVM的头文件路径
link_directories(${LLVM_LIBRARY_DIRS}) # 添加LLVM的库路径
add_definitions(${LLVM_DEFINITIONS}) # 添加LLVM的依赖库
# 添加源代码 并设置插件
add_llvm_pass_plugin(${PROJECT_NAME} src/main.cpp)
然后cmake -B build && cd build && make即可
3.4 验证效果
使用以下代码测试:
//test.c
#include <stdio.h>
int main(int argc, char const *argv[])
{
if (argc != 2)
puts("argc not 2");
else
puts("arhc is 2");
return 0;
}
使用
clang -emit-llvm test.c -o test.ll -S
生成test.ll文件:
@.str = private unnamed_addr constant [11 x i8] c"argc not 2 0", align 1
@.str.1 = private unnamed_addr constant [10 x i8] c"arhc is 2 0", align 1
; Function Attrs: noinline nounwind optnone uwtable
define dso_local i32 @main(i32 noundef %0, ptr noundef %1) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca ptr, align 8
store i32 0, ptr %3, align 4
store i32 %0, ptr %4, align 4
store ptr %1, ptr %5, align 8
%6 = load i32, ptr %4, align 4
%7 = icmp ne i32 %6, 2
br i1 %7, label %8, label %10
8: ; preds = %2
%9 = call i32 @puts(ptr noundef @.str)
br label %12
10: ; preds = %2
%11 = call i32 @puts(ptr noundef @.str.1)
br label %12
12: ; preds = %10, %8
ret i32 0
}
declare i32 @puts(ptr noundef) #1
使用opt加载pass:
opt -load-pass-plugin JunkCode.so -passes="insert-junk-code" test.ll -o test_pass.ll -S
生成test_pass.ll:
define dso_local i32 @main(i32 noundef %0, ptr noundef %1) #0 {
call void asm sideeffect "pushq %rax Acallq 0f Amovq $$0xe4, %rax A0: Apopq %rax Apopq %rax A", ""()
%3 = alloca i32, align 4
%4 = alloca i32, align 4
%5 = alloca ptr, align 8
store i32 0, ptr %3, align 4
store i32 %0, ptr %4, align 4
store ptr %1, ptr %5, align 8
%6 = load i32, ptr %4, align 4
%7 = icmp ne i32 %6, 2
br i1 %7, label %8, label %10
8: ; preds = %2
call void asm sideeffect "pushq %rax Acallq 1f Amovq $$0xe4, %rax A1: Apopq %rax Apopq %rax A", ""()
%9 = call i32 @puts(ptr noundef @.str)
br label %12
10: ; preds = %2
call void asm sideeffect "pushq %rax Acallq 2f Amovq $$0xe4, %rax A2: Apopq %rax Apopq %rax A", ""()
%11 = call i32 @puts(ptr noundef @.str.1)
br label %12
12: ; preds = %10, %8
call void asm sideeffect "pushq %rax Acallq 3f Amovq $$0xe4, %rax A3: Apopq %rax Apopq %rax A", ""()
ret i32 0
}
可以看到确实在每个基本块的开头插入了'call void asm sideeffect'内联汇编指令
然后分别编译一下这两个.ll对比一下效果:
原文始发于微信公众号(山石网科安全技术研究院):使用底层虚拟机LLVM PASS插入花指令