引言
我们都知道C语言代码编译后,能得到直接被CPU执行的机器码,对于C语言来说,每一个函数编译后就是一段机器码片段。通常情况我们只需关注代码如何编写即可,编译器在背后默默的帮助我们管理各个函数的机器码片段,数据存储访问等等,本文将实践我们手动管理一个函数的二进制代码片段,并让C程序跳转进入这个代码片段内执行并正常返回数据。
对于函数二进制代码片段管理,SO是一个很好的例子
共享库(Shared Object, SO)是Linux系统中用于代码复用和模块化的重要机制。它允许不同的程序共享相同的代码片段,从而减少内存占用和磁盘空间。共享库的文件通常以.so为后缀,例如libexample.so。
如果未了解过建议看下面的视频
为什么共享库能实现二进制机器码片段的复用?
主要与以下几点有关系
- 动态链接:
-
- 共享库在程序运行时动态加载,而不是在编译时静态链接到可执行文件中。这意味着多个程序可以共享同一个库的实例,减少内存占用。
- 动态链接器(如
ld-linux.so)负责在程序启动时加载所需的共享库,并解析符号引用。
- 位置无关代码(Position-Independent Code, PIC) :
-
- 共享库通常编译为位置无关代码,这样它们可以在内存中的任何位置加载,而不需要修改代码。这是通过使用相对地址而不是绝对地址来实现的。
- PIC使得同一个共享库可以被多个进程共享,即使这些进程将库加载到不同的内存地址。
- 调用约定(Calling Convention) :
-
- 调用约定定义了函数调用时参数传递、返回值处理和栈管理的规则。常见的调用约定有
cdecl、stdcall、fastcall等。 - 在Linux x86-64架构上,通常使用
System V AMD64 ABI调用约定,其中前六个整数或指针参数通过寄存器传递(RDI,RSI,RDX,RCX,R8,R9),浮点参数通过XMM0到XMM7寄存器传递,剩余的参数通过栈传递。 - 调用约定的一致性确保了不同模块之间的函数调用能够正确执行,即使这些模块是独立编译的。
- 调用约定定义了函数调用时参数传递、返回值处理和栈管理的规则。常见的调用约定有
- 符号表(Symbol Table) :
-
- 共享库包含一个符号表,记录了库中定义的函数和变量的名称和地址。动态链接器使用符号表来解析程序中对库函数的引用。
- 符号表使得程序可以在运行时找到并调用共享库中的函数,而不需要在编译时知道这些函数的具体地址。
获取一个SO的二进制代码片段
准备一个简单cpp代码,将其编译成一个SO,并使用objdump或nm工具查看函数对应的偏移位置
编译一个简单的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
sysconf(_SC_PAGE_SIZE)
-
- 作用:获取系统的内存页大小(通常是4096字节)。
- 返回值:返回当前系统的内存页大小(以字节为单位)。
- 用途:内存管理函数(如
mprotect)通常要求内存地址按页对齐,因此需要知道页大小。
posix_memalign
-
- 作用:申请一块按指定对齐方式对齐的内存。
函数原型:
-
int posix_memalign(void **memptr, size_t alignment, size_t size);- 参数:
-
-
memptr:指向分配内存的指针的地址。alignment:对齐方式,必须是2的幂次方,并且是sizeof(void*)的倍数。size:要分配的内存大小。
-
-
- 返回值:成功时返回0,失败时返回错误码。
- 用途:确保分配的内存按页对齐,以便后续使用
mprotect设置内存权限。
mprotect
-
- 作用:设置内存区域的访问权限(如可读、可写、可执行)。
函数原型:
-
int mprotect(void *addr, size_t len, int prot);- 参数:
-
-
addr:内存区域的起始地址,必须是页对齐的。len:内存区域的大小,必须是页大小的整数倍。prot:权限标志,可以是以下值的组合:
-
-
-
-
PROT_READ:可读。PROT_WRITE:可写。PROT_EXEC:可执行。
-
-
-
- 返回值:成功时返回0,失败时返回-1,并设置
errno。 - 用途:将内存区域设置为可执行,以便在其中运行二进制代码。
- 返回值:成功时返回0,失败时返回-1,并设置
为什么需要页对齐?
mprotect的要求:
-
mprotect函数要求内存地址和大小必须是页对齐的。如果地址或大小不符合要求,mprotect会失败并返回-1。- 页对齐的目的是确保内存管理的粒度与操作系统的内存管理单元(MMU)一致。
- 内存管理的效率:
-
- 操作系统以页为单位管理内存。如果内存地址和大小不是页对齐的,操作系统需要额外处理,这会降低效率。
- 页对齐的内存可以直接映射到物理内存,减少内存碎片和管理的复杂性。
- 安全性:
-
- 页对齐可以防止内存越界访问。如果内存区域不是页对齐的,可能会意外修改相邻内存区域的内容,导致程序崩溃或安全漏洞。
#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