了解动态链接库与体验二进制机器码的复用

391 阅读10分钟

引言

我们都知道C语言代码编译后,能得到直接被CPU执行的机器码,对于C语言来说,每一个函数编译后就是一段机器码片段。通常情况我们只需关注代码如何编写即可,编译器在背后默默的帮助我们管理各个函数的机器码片段,数据存储访问等等,本文将实践我们手动管理一个函数的二进制代码片段,并让C程序跳转进入这个代码片段内执行并正常返回数据。

对于函数二进制代码片段管理,SO是一个很好的例子

共享库(Shared Object, SO)是Linux系统中用于代码复用和模块化的重要机制。它允许不同的程序共享相同的代码片段,从而减少内存占用和磁盘空间。共享库的文件通常以.so为后缀,例如libexample.so

如果未了解过建议看下面的视频

【底层】动态链接库(dll)是如何工作的?

为什么共享库能实现二进制机器码片段的复用?

主要与以下几点有关系

  1. 动态链接
    • 共享库在程序运行时动态加载,而不是在编译时静态链接到可执行文件中。这意味着多个程序可以共享同一个库的实例,减少内存占用。
    • 动态链接器(如ld-linux.so)负责在程序启动时加载所需的共享库,并解析符号引用。
  1. 位置无关代码(Position-Independent Code, PIC)
    • 共享库通常编译为位置无关代码,这样它们可以在内存中的任何位置加载,而不需要修改代码。这是通过使用相对地址而不是绝对地址来实现的。
    • PIC使得同一个共享库可以被多个进程共享,即使这些进程将库加载到不同的内存地址。
  1. 调用约定(Calling Convention)
    • 调用约定定义了函数调用时参数传递、返回值处理和栈管理的规则。常见的调用约定有cdeclstdcallfastcall等。
    • 在Linux x86-64架构上,通常使用System V AMD64 ABI调用约定,其中前六个整数或指针参数通过寄存器传递(RDI, RSI, RDX, RCX, R8, R9),浮点参数通过XMM0XMM7寄存器传递,剩余的参数通过栈传递。
    • 调用约定的一致性确保了不同模块之间的函数调用能够正确执行,即使这些模块是独立编译的。
  1. 符号表(Symbol Table)
    • 共享库包含一个符号表,记录了库中定义的函数和变量的名称和地址。动态链接器使用符号表来解析程序中对库函数的引用。
    • 符号表使得程序可以在运行时找到并调用共享库中的函数,而不需要在编译时知道这些函数的具体地址。

获取一个SO的二进制代码片段

准备一个简单cpp代码,将其编译成一个SO,并使用objdumpnm工具查看函数对应的偏移位置

编译一个简单的SO

代码

#include "shared.h"

extern "C" 
int add(int a, int b) {
    return a + b;
}

编译so代码,得到libshared.so

  • -fPIC:生成位置无关代码(Position-Independent Code),这是共享库所必需的。
  • -shared:生成共享库
g++ shared.cpp -shared -fPIC -o libshared.so

查看其二进制汇编码

使用objdump可以查看一个SO的机器码对于的汇编及其偏移

objdump -S libshared.so

以下是查看到的二进制代码片段,我们发现 add 函数在文件偏移 0x10f9 ~ 0x1110 部分是它的二进制机器码

...
00000000000010f0 <frame_dummy>:
    10f0:       f3 0f 1e fa             endbr64
    10f4:       e9 77 ff ff ff          jmp    1070 <register_tm_clones>

00000000000010f9 <add>:
    10f9:       f3 0f 1e fa             endbr64
    10fd:       55                      push   %rbp
    10fe:       48 89 e5                mov    %rsp,%rbp
    1101:       89 7d fc                mov    %edi,-0x4(%rbp)
    1104:       89 75 f8                mov    %esi,-0x8(%rbp)
    1107:       8b 55 fc                mov    -0x4(%rbp),%edx
    110a:       8b 45 f8                mov    -0x8(%rbp),%eax
    110d:       01 d0                   add    %edx,%eax
    110f:       5d                      pop    %rbp
    1110:       c3                      ret

Disassembly of section .fini:
...

将二进制代码载入内存并调用

设置到一块页对齐内存,并设置执行权限

所使用的关键API

  1. sysconf(_SC_PAGE_SIZE)
    • 作用:获取系统的内存页大小(通常是4096字节)。
    • 返回值:返回当前系统的内存页大小(以字节为单位)。
    • 用途:内存管理函数(如mprotect)通常要求内存地址按页对齐,因此需要知道页大小。
  1. posix_memalign
    • 作用:申请一块按指定对齐方式对齐的内存。

函数原型

    • int posix_memalign(void **memptr, size_t alignment, size_t size);
    • 参数
      • memptr:指向分配内存的指针的地址。
      • alignment:对齐方式,必须是2的幂次方,并且是sizeof(void*)的倍数。
      • size:要分配的内存大小。
    • 返回值:成功时返回0,失败时返回错误码。
    • 用途:确保分配的内存按页对齐,以便后续使用mprotect设置内存权限。
  1. mprotect
    • 作用:设置内存区域的访问权限(如可读、可写、可执行)。

函数原型

    • int mprotect(void *addr, size_t len, int prot);
    • 参数
      • addr:内存区域的起始地址,必须是页对齐的。
      • len:内存区域的大小,必须是页大小的整数倍。
      • prot:权限标志,可以是以下值的组合:
        • PROT_READ:可读。
        • PROT_WRITE:可写。
        • PROT_EXEC:可执行。
    • 返回值:成功时返回0,失败时返回-1,并设置errno
    • 用途:将内存区域设置为可执行,以便在其中运行二进制代码。

为什么需要页对齐?

  1. mprotect的要求
    • mprotect函数要求内存地址和大小必须是页对齐的。如果地址或大小不符合要求,mprotect会失败并返回-1
    • 页对齐的目的是确保内存管理的粒度与操作系统的内存管理单元(MMU)一致。
  1. 内存管理的效率
    • 操作系统以页为单位管理内存。如果内存地址和大小不是页对齐的,操作系统需要额外处理,这会降低效率。
    • 页对齐的内存可以直接映射到物理内存,减少内存碎片和管理的复杂性。
  1. 安全性
    • 页对齐可以防止内存越界访问。如果内存区域不是页对齐的,可能会意外修改相邻内存区域的内容,导致程序崩溃或安全漏洞。
#include "errno.h"
#include "fcntl.h"
#include <sys/stat.h>
#include <unistd.h>
#include "sys/mman.h"
#include <cstring>
#include <fstream>
using namespace std;
/**
 * 将二进制代码设置到一块可执行的内存区域中
 * 
*/
void* set_code_for_exec() {
    fstream so_file("libshared.so", ios::in);
    
    if (!so_file.is_open()) {
        throw runtime_error("打开文件失败");
    }

    // 获取内存页大小
    int page_size = sysconf(_SC_PAGE_SIZE); // 4096

    // 按页对齐申请内存,为了方便直接申请 4096 * 16 大小,够用!
    char* exec_code = nullptr;
    if (posix_memalign((void**) &exec_code, page_size, page_size * 16) != 0) {
        throw std::runtime_error("申请内存失败");
    }

    if (so_file.read(exec_code, page_size * 16).fail() && !so_file.eof()) {
        throw std::runtime_error("读取so文件失败");
    }

    // 设置内存片段为可读可执行
    if (mprotect((void*)exec_code, page_size * 16, PROT_EXEC | PROT_READ) != 0) {
        throw std::runtime_error ("设置可执行权限失败" + std::string(std::strerror(errno)));
    } 

    // 将add函数二进制码开头的指针返回
    return exec_code + 0x10f9; 
}

执行代码

将二进制代码地址,强行转成一个函数指针,并直接调用

#include <iostream>

// 函数类型取别名
using func = int (*)(int, int);

/**
 * 执行 add 函数二进制代码片段
 * 
*/
void do_exec_code(void* exec_code) {

    func add_func = (func) exec_code;

    int ret = add_func(123, 234);
    cout << "调用add函数结果: " << ret << endl;
}

主函数

int main() {
    void* exec_code = set_code_for_exec();
    do_exec_code(exec_code);

    return 0;
}

执行结果

调用add函数结果: 357

更复杂的例子

这次我们要操作的二进制代码,调用了printf函数

printf 其实是libc 动态链接库的一个函数

#include "shared.h"
#include "stdio.h"


extern "C" 
int add(int a, int b) {
    printf("正在执行加法,a: %d, b: %d", a, b);
    return a + b;
}

如果我们直接使用上面的步骤操作,那会得到一个段错误

segmentation fault (core dumped)

这其实是因为add函数编译的时候并不知道printf函数的具体位置,而是运行时确定的(具体自行查询 so .plt.got 段相关内容) , 接下来我们要手动指定printf的位置,来跑通这段代码

查看汇编追踪printf的调用

通过objdump -S libshared.so查看汇编代码

通过以下汇编代码,我们知道调用printf时,CPU跳转到0x1050处执行,我们接下来我们去那个地方看看

0000000000001119 <add>:
    1119:       f3 0f 1e fa             endbr64
    111d:       55                      push   %rbp
    111e:       48 89 e5                mov    %rsp,%rbp
    1121:       48 83 ec 10             sub    $0x10,%rsp
    1125:       89 7d fc                mov    %edi,-0x4(%rbp)
    1128:       89 75 f8                mov    %esi,-0x8(%rbp)
    112b:       8b 55 f8                mov    -0x8(%rbp),%edx
    112e:       8b 45 fc                mov    -0x4(%rbp),%eax
    1131:       89 c6                   mov    %eax,%esi
    1133:       48 8d 05 c6 0e 00 00    lea    0xec6(%rip),%rax        # 2000 <_fini+0xeac>
    113a:       48 89 c7                mov    %rax,%rdi
    113d:       b8 00 00 00 00          mov    $0x0,%eax
    1142:       e8 09 ff ff ff          call   1050 <printf@plt> # 这里就是调用printf, 去到了0x1050偏移处
    1147:       8b 55 fc                mov    -0x4(%rbp),%edx
    114a:       8b 45 f8                mov    -0x8(%rbp),%eax
    114d:       01 d0                   add    %edx,%eax
    114f:       c9                      leave
    1150:       c3                      ret

这里就是文件偏移0x1050处汇编代码,我们注意看这个jmp指令,它让CPU跳转到 0x1054(当前指令地址) + 0x2fa6 = 0x4000 处所指的地址去执行,所以我们只需要修改0x4000内存区域,让它指向printf的函数,即可让它正常运行

0000000000001050 <printf@plt>:
    1050:       f3 0f 1e fa             endbr64
    1054:       ff 25 a6 2f 00 00       jmp    *0x2fa6(%rip)        # 4000 <printf@GLIBC_2.2.5>
    105a:       66 0f 1f 44 00 00       nopw   0x0(%rax,%rax,1)

修改代码

/**
 * 将二进制代码设置到一块可执行的内存区域中
 * 
*/
void* set_code_for_exec() {
    fstream so_file("libshared.so", ios::in);
    
    if (!so_file.is_open()) {
        throw runtime_error("打开文件失败");
    }

    // 获取内存页大小
    int page_size = sysconf(_SC_PAGE_SIZE); // 4096

    // 按页对齐申请内存,为了方便直接申请 4096 * 16 大小,够用!
    char* exec_code = nullptr;
    if (posix_memalign((void**) &exec_code, page_size, page_size * 16) != 0) {
        throw std::runtime_error("申请内存失败");
    }

    if (so_file.read(exec_code, page_size * 16).fail() && !so_file.eof()) {
        throw std::runtime_error("读取so文件失败");
    }

    // 新增代码:设置0x4000偏移处,为printf的地址
    *((int64_t*)(exec_code + 0x4000)) = (int64_t)printf;
    
    // 设置内存片段为可读可执行
    if (mprotect((void*)exec_code, page_size * 16, PROT_EXEC | PROT_READ) != 0) {
        throw std::runtime_error ("设置可执行权限失败" + std::string(std::strerror(errno)));
    } 

    return exec_code + 0x1119; // 修改代码:0x1119 是 add 函数的开头
}

运行结果

正在执行加法,a: 123, b: 234调用add函数结果: 357

参考

【底层】动态链接库(dll)是如何工作的?

CPU眼里的:调用约定

深入浅出 PLT/GOT Hook与原理实践