使用底层虚拟机LLVM PASS插入花指令

AI 3个月前 admin
80 0 0

使用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去最后还是运行到了用户代码, 但是可以在一定程度上影响反汇编器的正常工作。

但是这样写有两个问题:

  1. 微软的MSVC编译器只支持x86的内联汇编, 不支持x64的
  2. 这样写需要在函数中手动添加花指令, 首先会破坏源代码的可读性, 其次也很麻烦

那么就需要一种不影响源代码的, 不需要手动插入的方法 ——— LLVM PASS是一个不错的选择。

2. LLVM简介

2.1 LLVM

广义上的llvm指的是包括clang前端+llvm后端, 狭义上的llvm仅指代后端

一个编译器需要包含:

  1. Frontend前端: 词法分析、语法分析、语义分析、生成中间代码
  2. Optimizer优化器: 进行中间代码优化
  3. Backend后端: 将优化后的中间代码生成目标机器码

下图所示是传统的编译器架构:

使用底层虚拟机LLVM PASS插入花指令

LLVM也是一款编译器, 它的架构也是三段式, 但其并没有将这三段耦合起来:

使用底层虚拟机LLVM PASS插入花指令

LLVM采用统一的中间语言IR, 也就是比如C++代码, 经过clang进行语法分析后将高级语言转为了IR中间语言,
然后使用llvm opt将IR进行了一系列优化, 最后再把优化后的IR编译成机器码, 如下图所示:

使用底层虚拟机LLVM PASS插入花指令

可以看到IR经过一层层Pass最后到了Backend进行编译, llvm提供了一系列的接口提供给开发者编写pass对IR进行操作。
你甚至可以写一个pass, 把函数全部删掉都可以, 所以在IR中插入一系列指令也是完全可以。
那么IR这种中间语言是怎么表示高级语言的呢?

2.1 LLVM IR

首先看一下llvm对IR的组织结构:

使用底层虚拟机LLVM PASS插入花指令

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:%dA0", 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:%dA0"字符串), 和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 20", align 1
@.str.1 = private unnamed_addr constant [10 x i8] c"arhc is 20", 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 %raxAcallq 0fAmovq $$0xe4, %raxA0:Apopq %raxApopq %raxA", ""()
%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 %raxAcallq 1fAmovq $$0xe4, %raxA1:Apopq %raxApopq %raxA", ""()
%9 = call i32 @puts(ptr noundef @.str)
br label %12

10: ; preds = %2
call void asm sideeffect "pushq %raxAcallq 2fAmovq $$0xe4, %raxA2:Apopq %raxApopq %raxA", ""()
%11 = call i32 @puts(ptr noundef @.str.1)
br label %12

12: ; preds = %10, %8
call void asm sideeffect "pushq %raxAcallq 3fAmovq $$0xe4, %raxA3:Apopq %raxApopq %raxA", ""()
ret i32 0
}

可以看到确实在每个基本块的开头插入了'call void asm sideeffect'内联汇编指令
然后分别编译一下这两个.ll对比一下效果:

使用底层虚拟机LLVM PASS插入花指令

左边是test_pass.out, 可以看到sp-analysis  failed了, 反编译也就会失败了。

原文始发于微信公众号(山石网科安全技术研究院):使用底层虚拟机LLVM PASS插入花指令

版权声明:admin 发表于 2024年6月13日 下午1:37。
转载请注明:使用底层虚拟机LLVM PASS插入花指令 | CTF导航

相关文章