【9】最完整的二进制插桩理论介绍和实例分析:指令流分析器+二进制文件脱壳器

需要完整代码的小伙伴评论区Q我,将附上完整代码!

目录

1.什么是二进制插桩

2.静态二进制插桩

2.1. int3方法

用int3解决多字节跳转问题

2.2 跳板方法

3.动态二进制插桩

3.1pin引擎

3.2使用Pin进行分析

3.2.1命令行选项和数据结构

3.2.2 初始化Pin

3.2.3 注册插桩例程

3.2.4 注册系统调用入口函数

3.2.5 注册fini函数

3.2.6启动应用程序

3.2.7 测试Profiler

3.3用 Pin自动对二进制文件脱壳

3.3.1可执行文件加壳器简介


1.什么是二进制插桩

二进制插桩是指在现有二进制程序中的任意位置插入新代码,并以某种方式来观察或修改二进制程序的行为。添加新代码的位置成为插桩点,添加的代码则称为插桩代码。

目前有两类插桩平台:静态插桩(SBI)和动态插桩(DBI),它们采用不同的方法解决插入和重定位代码的问题:

  • SBI使用二进制重写方法永久修改磁盘上的二进制文件;
  • DBI不会修改磁盘上的二进制程序,而是监视二进制程序的执行状态,并在其运行时将新指令插入指令流中,这种方法的优点是避免了代码重定位问题。插桩代码仅被注入指令流中,而不是被注入内存的二进制代码段中,因此不会破坏引用。然而,DBI缺点是运行时插桩计算成本高,导致其速度相较于SBI更慢。

例如,对二进制程序中的所有call指令进行插桩,统计二进制程序中调用最频繁的函数,该例仅仅观察二进制程序的行为,也可以对其进行修改实现更多功能,例如可以在所有间接的控制转移指令处插入代码,来检查控制流转移目标是否属于预期目标集合,如果不属于的话则中断程序执行并发出警告,从而提高二进制程序抵御控制流劫持攻击的能力,这种抵御控制流劫持攻击的方法称为控制流完整性(Control-Flow Intergrity,CFI)

2.静态二进制插桩

SBI对二进制程序进行反汇编,然后按需添加插桩代码并将更新的二进制程序存入磁盘。SBI平台包括PEBIL和Dyninst,都是研究工具,没有详细的文档。SBI主要挑战是,在不破坏任何现有代码和数据引用的前提下,添加插桩代码并重写二进制程序。目前有两种流行的解决方法:

  • int3方法;
  • 跳板(trampoline)方法;

2.1. int3方法

int3方法的名称来自x86架构的int3指令,调试器用来实现软件断点。

针对静态二进制插桩,一种朴素的想法是使用jmp指令覆盖插桩点处的指令,然后将控制流转移到插桩代码,该插桩代码存储在一个独立的位置,如一个新的代码段或者共享库,因为现有代码段没有存放任意数量的新代码的空间。如下图所示:

【9】最完整的二进制插桩理论介绍和实例分析:指令流分析器+二进制文件脱壳器_第1张图片
问题在于jmp指令会占用多字节跳转到插桩代码,通常需要一个5字节的长度,当对短指令插桩时,指向插桩代码的jmp指令可能比它替换的指令长,会覆盖并破坏下一条指令。

基于以上问题,需要用到int3指令,int3指令可以用来对不适用于多字节跳转的简短指令进行插桩。

用int3解决多字节跳转问题

在x86架构中,int3指令会生成一个软中断,用户空间的程序能够通过操作系统提供的SIGTRAP信号捕获中断。int3的关键在于它的长度只有1字节,可以覆盖任何指令。

从SBI的角度,使用int3对指令进行插桩,用0xcc覆盖该指令的第一字节。党SIGTRAP信号产生时,可以用linux系统的ptrace API找出中断发生的地址,从而获取插桩点地址,然后根据插桩点位置调用相应的插桩代码。

缺点:int3软中断速度很慢,导致插桩后的应用程序的运行开销过大。

2.2 跳板方法

跳板方法不会直接对原始代码进行插桩,相反,它创建一个原始代码的副本并只对这个副本进行插桩。

以下图右侧添加插桩代码的图为例说明:

  • 假设刚刚调用了原始的f1函数,一旦f1被调用,跳板就跳到f1_copy,即f1的插桩版本;
  • SBI引擎会在f1_copy中每个可能的插桩点插入几个nop指令,这样SBI引擎可以简单地用指向插桩代码的jmp或者call指令覆盖该插桩点处的nop指令来进行插桩;
  • 由于新插入的指令会导致代码移位,为了保持相对跳转的正确性,SBI引擎会修补所有相对jmp指令的偏移,此外,会替换所有8位偏移的2字节长度的相对jmp指令,取而代之的是具有32位偏移的、5字节长度的指令;
  • SBI引擎重写直接调用,如调用f2函数,使它们指向已插桩的函数而不是原始函数
  • 在每个原始函数的开头需要跳板,原因是它们必须兼容间接调用

【9】最完整的二进制插桩理论介绍和实例分析:指令流分析器+二进制文件脱壳器_第2张图片

  • 对于间接调用,跳板方法使得间接控制流转移指令把控制流转移到原始的未插桩的代码,使用放置在原始代码中的跳板拦截控制流并将其重定向回插桩后的代码,如下图所示的间接函数调用和间接跳转。

【9】最完整的二进制插桩理论介绍和实例分析:指令流分析器+二进制文件脱壳器_第3张图片

3.动态二进制插桩

DBI引擎通过监视和控制所有执行的指令来动态地插桩进程。DBI引擎公开了API接口,允许用户编写自定义的DBI工具,指定应该插桩哪些代码以及如何插桩。

3.1pin引擎

作为最流行的DBI平台之一,Intel Pin是一个频繁更新、免费使用且有详细文档的平台,提供了相对容易使用的API套件。

Pin的DBI引擎和Pintool都运行于用户空间,因此只能插桩用户空间进程。

实现一个Pintool需要两种不同的函数:插桩例程和分析例程。DBI引擎Pin启动主程序后,开始监视指令流,插桩例程告诉pin要添加的插桩代码和位置,插桩例程安装指向分析例程的回调,分析例程包含了实际的插桩代码。

3.2使用Pin进行分析

3.2.1命令行选项和数据结构

Pintool可以实现特定工具的命令行选项,这些选项在Pin术语中称为开关(knob),PinAPI中包括一个专用的KNOB类,用于创建命令行选项。在下图代码中,有两个布尔选项(KNOB),分别是ProfileCalls和ProfileSyscalls,可通过将-c标志传递给Pintool来启用ProfileCalls选项,并通过传递-s标志启用ProfileSyscalls选项。

Profiler使用多个std::map数据结构和计数器来跟踪程序的运行时统计信息。

3.2.2 初始化Pin

从main函数开始,调用的第一个Pin函数是PIN_InitSymbols,该函数表示Pin读取应用程序的符号表,接下来调用PIN_Init函数来初始化Pin

3.2.3 注册插桩例程

Profiler需要注册3个插桩例程,其中第一个是parse_funcsyms,进行img粒度的插桩,另外两个为instrument_trace和instrument_insn,分别进行踪迹和指令粒度的插桩。

3.2.4 注册系统调用入口函数

Profiler使用PIN_AddSyscallEntryFunction函数注册一个名为log_syscall的函数

3.2.5 注册fini函数

Profiler注册的最后一个回调函数是fini函数,该函数在应用程序退出时或者Pin从程序分离时被调用

3.2.6启动应用程序

每个Pintool初始化的最后一步都是调用了PIN_StartProgram函数来启动应用程序。

#include 
#include 
#include 
#include 

#include "pin.H"

KNOB ProfileCalls(KNOB_MODE_WRITEONCE, "pintool", "c", "0", "Profile function calls");
KNOB ProfileSyscalls(KNOB_MODE_WRITEONCE, "pintool", "s", "0", "Profile syscalls");

std::map > cflows;
std::map > calls;
std::map syscalls;
std::map funcnames;

unsigned long insn_count    = 0;
unsigned long cflow_count   = 0;
unsigned long call_count    = 0;
unsigned long syscall_count = 0; 

int
main(int argc, char *argv[])
{
  PIN_InitSymbols();
  if(PIN_Init(argc,argv)) {
    print_usage();
    return 1;
  }

  IMG_AddInstrumentFunction(parse_funcsyms, NULL);
  INS_AddInstrumentFunction(instrument_insn, NULL);
  TRACE_AddInstrumentFunction(instrument_trace, NULL);
  if(ProfileSyscalls.Value()) {
    PIN_AddSyscallEntryFunction(log_syscall, NULL);
  }
  PIN_AddFiniFunction(print_results, NULL);

  /* Never returns */
  PIN_StartProgram();
    
  return 0;
}

接下来,就上述TRACE_AddInstrumentFunction插桩例程为例进行介绍,该插桩例程的第一个函数instrument_trace代表Profiler注册的踪迹粒度trace的插桩例程。以下是详细代码:

  • 首先,instrument_trace函数使用路径的地址调用IMG_FindByAddress函数查找踪迹所属的IMG;
  • 接下来,验证IMG是否有效且检查路径是否为主应用程序,若不是,则不插桩。因为当评测应用程序时,通常希望只计算应用程序内部的代码,而不是共享库或者动态加载器中的代码;
  • 如果trace是有效的并且为主应用程序,那么instrument_trace循环遍历路径中的所有基本快BBL,并对每个BBL调用instrument_bb函数,该函数对BBL执行实际的插桩;
  • instrument_bb函数通过调用BBL_InsertCall函数对给定的BBL进行插桩
  • BBL_InsertCall使用分析例程来插桩基本块的Pin API函数,需接收3个必需的参数:待插桩的基本块(本例中是bb)、IPOINT_ANYWHERE)及指向待添加的分析例程的函数指针(count_bb_insns)
static void
instrument_trace(TRACE trace, void *v)
{
  IMG img = IMG_FindByAddress(TRACE_Address(trace));
  if(!IMG_Valid(img) || !IMG_IsMainExecutable(img)) return;

  for(BBL bb = TRACE_BblHead(trace); BBL_Valid(bb); bb = BBL_Next(bb)) {
    instrument_bb(bb);
  }
}

static void
instrument_bb(BBL bb)
{
  BBL_InsertCall(
    bb, IPOINT_ANYWHERE, (AFUNPTR)count_bb_insns,
    IARG_UINT32, BBL_NumIns(bb),
    IARG_END
  );
}
static void
count_bb_insns(UINT32 n)
{
  insn_count += n;
}

最终结果是,Pin用指向count_bb_insns的回调对主应用程序的实际执行的bb块进行插桩,count_bb_insns为profiler的指令计数器加上每个基本块中指令的数量。

3.2.7 测试Profiler

命令行中的-c -s 用于打开函数调用和进行系统调用分析

Profiler输出关于执行指令的数量、控制转移、函数调用和系统调用的统计分析

【9】最完整的二进制插桩理论介绍和实例分析:指令流分析器+二进制文件脱壳器_第4张图片

3.3用 Pin自动对二进制文件脱壳

3.3.1可执行文件加壳器简介及脱壳

可执行文件加壳器,简称加壳器,将二进制文件作为输入并将二进制代码和数据段’打包‘到一个压缩或者加密的数据区域中,然后生成一个新的、加壳的可执行文件。

如下图所示,当使用加壳器处理二进制文件时,它会生成一个新的二进制文件,其中所有的原始代码和数据都被压缩或加密到加壳区域,此外,加壳器插入一个包含引导代码的新代码段,并将二进制文件的入口点重定向到引导代码。

【9】最完整的二进制插桩理论介绍和实例分析:指令流分析器+二进制文件脱壳器_第5张图片

关于脱壳,当加载并执行加壳的二进制文件时,引导代码首先将原始代码和数据提取到内存中,然后把控制权交给二进制文件的原始入口点(Original Entry Point),从而恢复程序的正常运行。因此,自动脱壳Pintool的关键是检测引导代码将控制流转移到OEP,然后Pintool将脱壳的代码和数据转储到磁盘。

3.3.2脱壳器的配置代码及其使用的数据结构

脱壳器的main函数首先初始化Pin,接下来打开日志文件,并注册名为instrument_mem_cflow的指令粒度的插桩例程和结束函数,最后运行加壳的应用程序。

数据结构的介绍稍后补充!!!

#include 
#include 
#include 
#include 
#include 
#include 
#include 

#include "pin.H"

typedef struct mem_access {
  mem_access()                                  : w(false), x(false), val(0) {}
  mem_access(bool ww, bool xx, unsigned char v) : w(ww)   , x(xx)   , val(v) {}
  bool w;
  bool x;
  unsigned char val;
} mem_access_t;

typedef struct mem_cluster {
  mem_cluster()                                             : base(0), size(0), w(false), x(false) {}
  mem_cluster(ADDRINT b, unsigned long s, bool ww, bool xx) : base(b), size(s), w(ww), x(xx)       {}
  ADDRINT       base;
  unsigned long size;
  bool          w;
  bool          x;
} mem_cluster_t;

FILE *logfile;
std::map shadow_mem;
std::vector clusters;
ADDRINT saved_addr;

KNOB KnobLogFile(KNOB_MODE_WRITEONCE, "pintool", "l", "unpacker.log", "log file");

static void
fini(INT32 code, void *v)
{
  print_clusters();
  fprintf(logfile, "------- unpacking complete -------\n");
  fclose(logfile);
}

int
main(int argc, char *argv[])
{
  if(PIN_Init(argc, argv) != 0) {
    fprintf(stderr, "PIN_Init failed\n");
    return 1;
  }

  logfile = fopen(KnobLogFile.Value().c_str(), "a");
  if(!logfile) {
    fprintf(stderr, "failed to open '%s'\n", KnobLogFile.Value().c_str());
    return 1;
  }
  fprintf(logfile, "------- unpacking binary -------\n");

  INS_AddInstrumentFunction(instrument_mem_cflow, NULL);
  PIN_AddFiniFunction(fini, NULL);

  PIN_StartProgram();
    
  return 1;
}
3.3.3对内存写入插桩
static void
instrument_mem_cflow(INS ins, void *v)
{
  if(INS_IsMemoryWrite(ins) && INS_hasKnownMemorySize(ins)) {
    INS_InsertPredicatedCall(
      ins, IPOINT_BEFORE, (AFUNPTR)queue_memwrite, 
      IARG_MEMORYWRITE_EA,
      IARG_END
    );
    if(INS_HasFallThrough(ins)) {
      INS_InsertPredicatedCall(
        ins, IPOINT_AFTER, (AFUNPTR)log_memwrite, 
        IARG_MEMORYWRITE_SIZE, 
        IARG_END
      );
    }
    if(INS_IsBranchOrCall(ins)) {
      INS_InsertPredicatedCall(
        ins, IPOINT_TAKEN_BRANCH, (AFUNPTR)log_memwrite, 
        IARG_MEMORYWRITE_SIZE,
        IARG_END
      );
    }
  }
  if(INS_IsIndirectBranchOrCall(ins) && INS_OperandCount(ins) > 0) {
    INS_InsertCall(
      ins, IPOINT_BEFORE, (AFUNPTR)check_indirect_ctransfer, 
      IARG_INST_PTR, IARG_BRANCH_TARGET_ADDR,  
      IARG_END
    );
  }
}
3.3.4插桩控制流指令

脱壳器的目标是检测到控制流转移到原始入口点的时刻,然后脱壳器将脱壳的二进制文件转储到磁盘,为此instrument_mem_cflow函数通过回调check_indirect_ctransfer函数来插桩间接分支指令和调用指令。

3.3.5跟踪内存写入
static void
queue_memwrite(ADDRINT addr)
{
  saved_addr = addr;
}


static void
log_memwrite(UINT32 size)
{
  ADDRINT addr = saved_addr;
  for(ADDRINT i = addr; i < addr+size; i++) {
    shadow_mem[i].w = true;
    PIN_SafeCopy(&shadow_mem[i].val, (const void*)i, 1);
  }
}
3.3.6检测原始入口点并转储脱壳二进制文件

脱壳器的最终目标是检测到向OEP的跳转并转储脱壳的代码

static void
check_indirect_ctransfer(ADDRINT ip, ADDRINT target)
{
  mem_cluster_t c;

  shadow_mem[target].x = true;
  if(shadow_mem[target].w && !in_cluster(target)) {
    /* control transfer to a once-writable memory region, suspected transfer
     * to original entry point of an unpacked binary */
    set_cluster(target, &c);
    clusters.push_back(c);
    /* dump the new cluster containing the unpacked region to file */
    mem_to_file(&c, target);
    /* we don't stop here because there might be multiple unpacking stages */
  }
}
3.3.7测试脱壳器

【9】最完整的二进制插桩理论介绍和实例分析:指令流分析器+二进制文件脱壳器_第6张图片

你可能感兴趣的:(二进制分析实战,linux,python)