在C语言中调用一个未命名的函数

两名学生试图写出一个能通过学校自动评分器所有练习的单一解法。要让任意代码运行,就得在健全性检查的眼皮底下调用 C 函数,而诀窍在于不写出它们的名字就能调用到它们。

中文
复制

2026 年 10 月 5 日

我们学校有一套受控的远程代码执行环境,用来自动批改我们提交的代码。系统会检查执行情况、验证格式是否正确,当然还有一些不向用户展示的检查(比如代码抄袭检测工具 1)。界面会报告结果不匹配和编译错误。

我和一个朋友开始寻找一种能解出所有习题的通用解法。朝这个方向迈出的第一步,是搞到一个能让我们执行任意 shell 代码的原语。我们当时以为,评分 VM 上会有一个 sh 二进制文件,这样我们直接调用 execve 就行了。

那么……搞定了?你猜到了,并没有。

还记得我说过评分系统还有别的检查吗?其中之一就是检查你只用了习题允许的函数。而 execve 从来不在允许之列,所以你的代码还没运行,提交就已经挂了。从我们的经验看,这套系统比单纯对源码做一次 grep 要复杂一些。我猜它会用 clang 对代码做预处理和解析,然后拿生成的 AST 去做检查。

问题的核心就在这里。我们需要调用 execve,但不能写出这个符号的名字。那该怎么办?

把视角拉远一点:我们知道 C 代码最终会被编译成二进制文件,而调用一个函数本质上就是跳转到内存中的某个位置。那我们能不能拿到 execve 在内存中的地址,把它硬编码进程序里,然后直接调用?

在现代笔记本电脑的软件里,内存地址往往取决于执行环境。这是因为现代内核实现了地址空间布局随机化(ASLR),它会随机化你的二进制文件、它依赖的库、堆和栈被放置的地址。如今 C 代码默认使用 -pie 标志编译,这会生成一个位置无关可执行文件,也就意味着你的二进制文件确实能享受到 ASLR 的好处。我们可以用 -no-pie 这个 C 编译器标志关掉这种行为,强制二进制文件里的函数使用固定地址。但这没用,因为大多数情况下我们控制不了构建过程。其次,execve 函数并不直接属于我们的可执行文件,而是动态加载的,所以它仍然位于一个随机的位置。

静态地址行不通。但 ASLR 只作用于各段的映射,段的内容原封不动。在一个段内部,函数的排列顺序依然不变。只要知道两个函数 fA 和 fB 的内存地址之间的静态偏移,拿到 fA 的地址就能推出 fB 的地址,反之亦然。

这个偏移在不同机器上会变,原因可能是 C 库版本不同,也可能是架构不同,或者别的什么。在目标机器上获取两个函数之间固定偏移的办法有好几种。其中一种是:如果练习里允许你给 fA 和 fB 两个符号命名,直接把偏移泄露出来就行。

我们的目标函数仍然是 execve,它属于 libc。假设我们始终能访问 printf。我们要做的只是在 VM 上泄露这两个函数之间的偏移。先在本地测一下:

printf("%td", (ptrdiff_t)((size_t)execve - (size_t)printf));

多跑几次程序,结果每次都不一样。刚才不是说偏移是固定的吗?打开二进制文件看看。

$ objdump --dynamic-syms ./offset | grep -E "\s(execve|printf)"
0000000000000000      DF *UND*	0000000000000000 (GLIBC_2.2.5) execve
000000000004dd64  w   DF .text	0000000000000005  Base        printf

objdump 对任何类型的 object 文件分析都很有用。--dynamic-syms 选项会给出每个将在运行时加载的符号的信息。第六列告诉我们这个符号来自哪里。execve 确实来自 glibc,也就是我笔记本上那份 C 标准库实现。但 printf 似乎不是来自 glibc。其实 objdump 的原始输出里有一堆 __interceptor_* 符号,这已经能说明问题了。

从一开始,我编译所有代码都带着 ASan(也就是 -fsanitize=address),为的是尽早抓出内存错误。ASan 的做法是替换掉一大批代码,包括内存读写,换成一大堆慢速函数调用,逐个检查每次操作是否正确。它还会替换 C 标准库里的一批函数,比如 malloc 或 printf。这样一来,这些函数就不再位于 glibc 这个 object 里,而没被替换的函数(在我们的例子里是 execve)仍然在里面。自动评分系统恰好也会检查内存错误,并且用 ASan 编译。

我们可以试着用一个不同于 printf 的符号,让它和 execve 落在同一个段里,但这个符号还得是在大多数情况下都被允许使用的那种。这类符号大多位于 ASan 替换后的段里,那就选它吧。也许 execve 并不是唯一的办法。继续在这个段里翻找其他有用的符号时,mmap 引起了我的注意。

mmap 为什么特殊?因为 W^X(write-xor-execute) 安全策略的存在,不存在同时可写又可执行的内存区域。所以我们既不能把裸 x86 指令写进缓冲区再像调用函数一样跳过去,也不能用自己的代码覆盖已有函数。但 mmap 能解决这个问题,因为我们可以直接让它给我们一个同时具有写和执行权限的页。

不过别急。我们可以通过另一个练习在 VM 上泄漏 printf 与 mmap 之间的偏移,在那个练习里这两个符号都能被命名。要用这个偏移,可以用一堆强制类型转换,但我们交给评分系统的代码必须能用 -pedantic 编译,而它不允许从函数指针类型转换、转换到函数指针类型,也不允许函数指针类型之间的转换。函数指针归根结底还是指针,所以我们可以试着在编译器察觉不到的情况下修改它。我一开始基于未优化的栈槽来改内存 2,但后来想起在 C 里,不安全本身就是一种特性,所以其实直接用 union 就行。

union notmmap_build {
  int (*basefn)(const char *);
  size_t addr;
  void *(*fn)(void *addr, size_t length, int prot, int flags, int fd,
              long offset);
};

union notmmap_build notmmap = {.basefn = printf};
notmmap.addr += OFFSET; // whatever offset you leaked earlier

我们可以写一段糟糕的 x86 汇编例程,让我们像调用 C 函数一样发起 Linux 系统调用。这个例程允许我们调用任何系统调用,最多带 3 个参数。

000000000040106a <notsyscall>:
  40106a:	48 89 f8             	mov    %rdi,%rax
  40106d:	48 89 f7             	mov    %rsi,%rdi
  401070:	48 89 d6             	mov    %rdx,%rsi
  401073:	48 89 ca             	mov    %rcx,%rdx
  401076:	0f 05                	syscall
  401078:	c3                   	ret

把这些字节复制到一个新映射的可写可执行页中。

unsigned char instructions[] = {
  0x48, 0x89, 0xf8, 0x48, 0x89, 0xf7, 0x48, 0x89,
  0xd6, 0x48, 0x89, 0xca, 0x0f, 0x05, 0xc3,
};

char *code =
  notmmap.fn(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC,
             MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

然后像什么都没发生一样调用我们的例程。Linux 期望的参数可以查 chromium syscall 文档。

char *argv[] = {
    "/bin/sh",
    "-c",
    "echo 'Hello, World!'",
    NULL,
};

union notsyscall_build {
  char *ptr;
  // execve is syscall 59 on x86-64 Linux
  int (*notexecve)(int sys, char *path, char **argv, char **envp);
};

union notsyscall_build notsyscall = {.ptr = code};

notsyscall.notexecve(59, argv[0], argv, NULL);

在目标 VM 上运行,成功打印出 Hello, World!。

完整的 proof-of-concept 在 GitHub 上。

结论

因为我们知道代码是用 ASan 编译的,所以我们选择泄漏 printf(一个始终被允许的函数)与 mmap(一个能让我们执行任意汇编代码的函数)之间的偏移。这样就能绕过学校的检查,调用任意系统调用,在我们的场景中是 execve,从而拿到 shell。

我们没有进一步调查,只是把这个可能的问题报告给了学校。

我看不出有什么简单的修补办法。正如我们所见,ASLR 在这里帮不上忙。一个可能的思路是每次执行时以不同的顺序重新链接 C 标准库,让函数的顺序每次都随机化(OpenBSD 在启动时就是这么做的,见这里)。但别忘了,ASan 仍然会用自己的函数替换掉原有函数(所以在这里它并不能缓解什么)。

这个 exploit 不算复杂,但靠自己把整个过程弄明白挺有意思的。正是在这种时刻,你之前钻过的那些牛角尖终于串到一起,推着你往前走。另外,这个 exploit 之所以简单,是因为我们本来就有相当宽松的远程代码执行(作为一项功能)。不过它仍然涉及 ASLR 绕过,也需要对正在发生的事情有相当程度的理解。


你可以观看一个讲座(法语),来自学校的研究实验室。

请欣赏这个 snippet。

© 2024-2026 Milo Moisson

发现错别字?编辑此页 ✎

来源: wiro.world← 返回首页