绕过seccomp沙箱限制利用GNU IFUNC强制绑定在constructor前执行resolver
IFUNC 的强制绑定可在 constructor 安装 seccomp 前执行 resolver,规则必须由宿主在 dlopen 前落地。
IFUNC 的强制绑定可在 constructor 安装 seccomp 前执行 resolver,规则必须由宿主在
dlopen前落地。
把 seccomp 放进插件的 constructor,表面上很合理:动态加载器把插件装进进程,constructor 安装规则,业务函数从此受限。这个顺序有一个比 main 更早的入口。只要对象里存在 GNU IFUNC,解析器就可能在重定位阶段取得控制权;constructor 的返回值再漂亮,也改变不了已经发生过的那次执行。
这不是把 dlopen、seccomp、IFUNC 三个名词拼在一起。测试对象只拒绝 getpid,而且 resolver 与被选中的实现都使用同一条 syscall 指令。这样,成功的 PID 与 EPERM 不会被 libc 缓存、printf 或 API 封装遮住。要追的是一条很具体的时序:哪一次 getpid 发生在规则落地之前,哪一次已经落进规则之后。
# 确认参与构建的编译器与运行库。
cc --version | sed -n '1p'
ldd --version | sed -n '1p'
constructor 安装的是规则,不是时间机器
插件没有通过 libc 包装调用 getpid。raw_getpid 把 SYS_getpid 放入 rax,执行 syscall,再原样返回内核结果。resolver 把一次返回值写入 resolver_getpid_result,并返回 selected_impl 的地址;最终实现也调用同一个 raw_getpid。constructor 只装入一条窄规则:命中 SYS_getpid 时返回 SECCOMP_RET_ERRNO | EPERM,其余系统调用放行。
ifunc_plugin.c
#define _GNU_SOURCE
#include <errno.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
/* 供宿主在 dlopen 返回后读取 resolver 的实际返回值。 */
volatile long resolver_getpid_result;
/* 记录插件 constructor 安装过滤器的系统调用结果。 */
volatile int constructor_seccomp_result;
/* 绕过 libc 包装,直接保留内核对 SYS_getpid 的返回值。 */
static long
raw_getpid(void)
{
long result;
__asm__ volatile ("syscall"
: "=a" (result)
: "a" (SYS_getpid)
: "rcx", "r11", "memory");
return result;
}
/* resolver 返回此实现地址;调用实现时再次执行同一条系统调用。 */
static int
selected_impl(void)
{
return (int) raw_getpid();
}
/* 动态加载器解析 selected 时调用此函数,并接收实现函数地址。 */
static int (*
selected_resolver(void))(void)
{
resolver_getpid_result = raw_getpid();
return selected_impl;
}
/* 将导出符号声明为 GNU IFUNC,而非普通 FUNC。 */
int selected(void) __attribute__((ifunc("selected_resolver")));
#ifdef EAGER_SLOT
/* 静态函数指针使对象内部保留对 selected 的重定位引用。 */
static int (*selected_slot)(void) = selected;
#endif
/* 该 constructor 只在对象初始化阶段安装一条窄 seccomp 规则。 */
__attribute__((constructor))
static void
install_getpid_errno_filter(void)
{
struct sock_filter instructions[] = {
/* 读取 seccomp_data 中的系统调用号。 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
(uint32_t) offsetof(struct seccomp_data, nr)),
/* 命中 SYS_getpid 时跳到拒绝动作。 */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_getpid, 0, 1),
/* 将被拒绝调用伪装为返回 EPERM。 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
/* 其余系统调用不受此演示规则影响。 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog program = {
.len = (unsigned short) (sizeof(instructions) / sizeof(instructions[0])),
.filter = instructions,
};
/* 内核要求安装 unprivileged seccomp 前先设置 no_new_privs。 */
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) {
constructor_seccomp_result = -errno;
return;
}
if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &program) != 0) {
constructor_seccomp_result = -errno;
return;
}
constructor_seccomp_result = 0;
}
/* 宿主只通过此导出函数触发 IFUNC 选择后的实现。 */
int
call_selected(void)
{
#ifdef EAGER_SLOT
return selected_slot();
#else
return selected();
#endif
}这里有两个不能混在一起的动作。selected_resolver 选择地址,selected_impl 执行业务调用;两者都执行 raw_getpid,所以 resolver 的一次成功不代表随后实现还能成功。constructor 的 constructor_seccomp_result 只说明 BPF 程序是否装入内核;它不为先前已经完成的 resolver 补一条限制。
# 构建带对象内强制绑定的插件。
cc -O2 -g -fPIC -Wall -Wextra -Werror -shared \
-Wl,-z,relro -Wl,-z,now -DEAGER_SLOT \
-o libifunc_eager.so ifunc_plugin.c
printf 'eager_build=%s\n' "$?"
# 构建未写入 BIND_NOW 的 lazy 插件。
cc -O2 -g -fPIC -Wall -Wextra -Werror -shared \
-Wl,-z,relro \
-o libifunc_lazy.so ifunc_plugin.c
printf 'lazy_build=%s\n' "$?"
两份库来自同一份 C 文件。分开的不是 resolver,也不是 BPF 规则,而是动态绑定条件。这个约束很重要:后面的 PID、0 与 -1 不可归因于两套业务逻辑。
selected 已经不是一段普通函数代码
符号类型与实际引用方式需要从 ELF 本身读取。
# 读取 eager 插件的 IFUNC 符号与状态变量。
readelf -Ws libifunc_eager.so \
| grep -E 'selected|resolver_getpid_result|constructor_seccomp_result'
第 11 个动态符号的类型确实是 IFUNC,值为 0x1200;selected_impl 则是 0x11f0 的局部 FUNC。同名入口没有直接指向最终实现,动态加载器必须先经过 resolver。resolver_getpid_result 与 constructor_seccomp_result 是动态可见对象,宿主用 dlsym 取得它们:0 是尚未写入,正数是 syscall 返回的 PID,-1 是 seccomp 的 EPERM。
JUMP_SLOT 是这条路径的第二个落点。0x3fc8 的重定位不是给 selected_impl,而是给 selected()。动态加载器处理这里时,不能直接把 0x1200 填进去后就结束;IFUNC 的语义要求先调用 resolver,再把 resolver 返回的实现地址写入相应调用槽位。
# 读取 eager 插件中引用 selected 的重定位。
readelf -rW libifunc_eager.so \
| grep -E 'selected|JUMP_SLOT|RELATIVE'
这里不能把 JUMP_SLOT 理解成“稍后某次函数调用才会处理”。是否立刻处理,取决于绑定方式。glibc 对 IFUNC 的说明把这件事说得很直白:非 lazy 引用在重定位处理期间执行 resolver;延迟 PLT 调用则可以把 resolver 推迟到第一次调用。glibc Indirect Functions
BIND_NOW 把调用时机收走了
动态段决定对象是否要求立即绑定,先读取两份库的实际标记。
# 读取 eager 插件的动态绑定标记。
readelf -dW libifunc_eager.so \
| grep -E 'BIND_NOW|FLAGS|FLAGS_1'
# 读取 lazy 插件的同类动态标记。
readelf -dW libifunc_lazy.so \
| grep -E 'BIND_NOW|FLAGS|FLAGS_1' || true
eager 插件出现 BIND_NOW 与 NOW,lazy 插件的筛选结果为空。空输出不是“库没有动态段”,也不是编译失败;它只排除了这份库自行要求立即绑定的条件。调用者仍然可以在 dlopen 时传入 RTLD_NOW,运行环境也可以借 LD_BIND_NOW=1 把本应延后的 PLT 绑定提前。绑定时机有多处入口,不能只在 Makefile 里搜一次 -z now 就停止。
图里的“成功”不是解释器特权,也不是 seccomp 失效。它只发生在规则安装之前。constructor 把过滤器装入当前测试进程之后,selected_impl 的同一条 syscall 已经返回 -1。这也是为什么只看业务调用的返回值,会漏掉 resolver 留下的第一条 PID。
lazy 路径让 constructor 抢到一次机会
宿主不在开始阶段安装策略。它根据第二个参数选择 RTLD_NOW 或 RTLD_LAZY,随后通过 dlsym 读取插件导出的三项对象:resolver 写入结果、constructor 的返回值、以及真正会触发 IFUNC 的 call_selected。resolver_before_call 放在 call_selected 前面,正好把 dlopen 后与首次调用后的状态分开。
host.c
#define _GNU_SOURCE
#include <dlfcn.h>
#include <errno.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
/* 宿主的 prepolicy 路径复用同一条规则,但调用位置在 dlopen 前。 */
static int
install_getpid_errno_filter(void)
{
struct sock_filter instructions[] = {
/* 读取 seccomp_data 中的系统调用号。 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
(uint32_t) offsetof(struct seccomp_data, nr)),
/* 命中 SYS_getpid 时跳到拒绝动作。 */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_getpid, 0, 1),
/* 将被拒绝调用伪装为返回 EPERM。 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
/* 其余系统调用不受此演示规则影响。 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog program = {
.len = (unsigned short) (sizeof(instructions) / sizeof(instructions[0])),
.filter = instructions,
};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0)
return -errno;
if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &program) != 0)
return -errno;
return 0;
}
int
main(int argc, char **argv)
{
/* 参数只选择测试对象、加载模式和是否先由宿主装入规则。 */
const char *path = argc > 1 ? argv[1] : "./libifunc_eager.so";
int lazy = argc > 2 && argv[2][0] == 'l';
int prepolicy = argc > 3 && argv[3][0] == 'p';
int mode = lazy ? RTLD_LAZY : RTLD_NOW;
/* 此分支必须位于 dlopen 前,才能覆盖加载期代码。 */
if (prepolicy)
printf("host_seccomp_result=%d\n", install_getpid_errno_filter());
void *handle = dlopen(path, mode | RTLD_LOCAL);
if (handle == NULL) {
puts(dlerror());
return 2;
}
/* 取得插件的两个观测变量与唯一的 IFUNC 触发入口。 */
long *resolver_result = dlsym(handle, "resolver_getpid_result");
int *filter_result = dlsym(handle, "constructor_seccomp_result");
int (*call_selected)(void) = dlsym(handle, "call_selected");
if (resolver_result == NULL || filter_result == NULL || call_selected == NULL) {
puts(dlerror());
return 3;
}
/* 在首次触发入口前后各读取一次 resolver 写入位置。 */
printf("resolver_before_call=%ld\n", *resolver_result);
printf("constructor_seccomp_result=%d\n", *filter_result);
printf("call_selected_result=%d\n", call_selected());
printf("resolver_after_call=%ld\n", *resolver_result);
return 0;
}先以 RTLD_LAZY 装入不带 BIND_NOW 的插件,观察 dlopen 返回与首次调用前后的两个状态。
# 以 RTLD_LAZY 装入未带 BIND_NOW 的插件。
timeout 5s ./host ./libifunc_lazy.so lazy
printf 'lazy_run_status=%s\n' "$?"
0 不是 PID,也不是失败码。它来自变量的零初始化,说明 dlopen 与 constructor 完成时 resolver 还没有执行。constructor_seccomp_result=0 说明规则已装入;resolver_after_call=-1 则把首次调用后的状态固定下来。若只保留 call_selected_result=-1,只能知道实现调用受限;前后两次状态才说明 resolver 自己也没能从规则外通过。
RTLD_NOW 让第一次 syscall 提前到 dlopen 里面
同一份 eager 插件改由 RTLD_NOW 装入,记录 dlopen 返回时的状态。
# 以 RTLD_NOW 装入带 BIND_NOW 的插件。
timeout 5s ./host ./libifunc_eager.so
printf 'eager_now_status=%s\n' "$?"
这四行把顺序压得很紧:PID 已经写进变量,constructor 才报告 0;业务调用被拒绝,旧 PID 保持不变。把“constructor 已成功”写成“插件没有在规则前执行代码”,在这组记录面前站不住。
宿主再改用 RTLD_LAZY,观察插件自身的动态标记是否仍会改变结果。
# 用 RTLD_LAZY 装入仍带 BIND_NOW 的 eager 插件。
timeout 5s ./host ./libifunc_eager.so lazy
printf 'eager_lazy_status=%s\n' "$?"
到这里,RTLD_LAZY 不是安全策略,它只是宿主交给动态加载器的一个请求。对象的 DF_BIND_NOW 仍然生效。真正需要追的是最终对象有没有 NOW 标记、宿主调用 dlopen 时采用了什么模式、进程启动时有没有引入 LD_BIND_NOW。
主程序还没进入 main,resolver 已经跑完一次
dlopen 不是唯一需要防备的入口。主程序依赖的共享库也能在进程启动期发生同样的时序。启动期插件没有 constructor,只导出 startup_selected 和状态变量。主程序的 constructor 安装完全相同的 getpid 规则,main 再读取 resolver 结果并调用 startup_selected。
startup_plugin.c
#include <sys/syscall.h>
/* 让主程序能在 constructor 与首次调用之间读取 resolver 状态。 */
volatile long startup_resolver_getpid_result;
/* 绕过 libc 包装,直接保留内核对 SYS_getpid 的返回值。 */
static long
raw_getpid(void)
{
long result;
__asm__ volatile ("syscall"
: "=a" (result)
: "a" (SYS_getpid)
: "rcx", "r11", "memory");
return result;
}
/* resolver 返回此实现地址;实现再次调用同一系统调用。 */
static int
startup_impl(void)
{
return (int) raw_getpid();
}
/* 启动重定位处理 startup_selected 时会调用此 resolver。 */
static int (*
startup_resolver(void))(void)
{
startup_resolver_getpid_result = raw_getpid();
return startup_impl;
}
/* 让依赖库导出 GNU IFUNC 符号。 */
int startup_selected(void) __attribute__((ifunc("startup_resolver")));startup_host.c
#define _GNU_SOURCE
#include <errno.h>
#include <linux/filter.h>
#include <linux/seccomp.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <sys/prctl.h>
#include <sys/syscall.h>
#include <unistd.h>
/* 依赖库导出的 IFUNC 入口与 resolver 观测变量。 */
extern int startup_selected(void);
extern volatile long startup_resolver_getpid_result;
/* 记录主程序 constructor 装入规则的返回值。 */
volatile int startup_constructor_seccomp_result;
static int
install_getpid_errno_filter(void)
{
struct sock_filter instructions[] = {
/* 读取 seccomp_data 中的系统调用号。 */
BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
(uint32_t) offsetof(struct seccomp_data, nr)),
/* 命中 SYS_getpid 时跳到拒绝动作。 */
BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_getpid, 0, 1),
/* 将被拒绝调用伪装为返回 EPERM。 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM),
/* 其余系统调用不受此演示规则影响。 */
BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
};
struct sock_fprog program = {
.len = (unsigned short) (sizeof(instructions) / sizeof(instructions[0])),
.filter = instructions,
};
if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0)
return -errno;
if (syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &program) != 0)
return -errno;
return 0;
}
/* 主程序 constructor 位于 main 前,但不等同于依赖库的重定位前。 */
__attribute__((constructor))
static void
install_policy_before_main(void)
{
startup_constructor_seccomp_result = install_getpid_errno_filter();
}
int
main(void)
{
/* 在首次调用 startup_selected 前后保留 resolver 的写入状态。 */
printf("startup_resolver_getpid_result=%ld\n", startup_resolver_getpid_result);
printf("startup_constructor_seccomp_result=%d\n", startup_constructor_seccomp_result);
printf("startup_selected_result=%d\n", startup_selected());
printf("startup_resolver_after_call=%ld\n", startup_resolver_getpid_result);
return 0;
}两份主程序对 startup_selected 留下相同的 JUMP_SLOT,但动态标记不同。一份携带 BIND_NOW 和 NOW PIE;另一份只有 PIE。符号引用没有变,决定 resolver 先后的只是绑定条件。
# 读取两个启动期主程序对 startup_selected 的引用。
readelf -rW startup_host_now startup_host_lazy \
| grep 'startup_selected'
# 读取启动期 NOW 主程序的动态标记。
readelf -dW startup_host_now \
| grep -E 'BIND_NOW|FLAGS|FLAGS_1'
# 读取启动期 lazy 主程序的动态标记。
readelf -dW startup_host_lazy \
| grep -E 'BIND_NOW|FLAGS|FLAGS_1'
PIE 不等于立即绑定。两份主程序都是 PIE,但只有 NOW 版本把 IFUNC 解析拉到 constructor 之前。把 -fPIE -pie、RELRO 与 -z now 一起写进加固规则时,后三者也不该被当作纯粹的内存保护参数;它们会改变 resolver 获得控制权的时间。
NOW 主程序的运行结果需要与 lazy 主程序并列读取。
# 启动带 BIND_NOW 的主程序。
timeout 5s ./startup_host_now
printf 'startup_now_status=%s\n' "$?"
去掉 NOW 后,读取同一依赖在主程序 constructor 前后的状态。
# 启动 lazy 主程序。
timeout 5s ./startup_host_lazy
printf 'startup_lazy_status=%s\n' "$?"
这不是“lazy 更安全”的普遍结论。lazy 只把第一次解析推后,是否推过策略安装点完全取决于调用链。对一个不含 IFUNC 的普通 PLT 槽,推迟的是目标地址绑定;对 IFUNC,推迟的是会执行代码的 resolver。两者在 seccomp 的时间线上不是同一件事。
环境变量没有改 ELF,也改掉了结果
不改动 lazy 二进制,只向测试子进程传入 LD_BIND_NOW=1,观察运行环境能否改变解析时点。
# 在不改动 lazy 二进制的条件下强制立即绑定。
timeout 5s env LD_BIND_NOW=1 ./startup_host_lazy
printf 'startup_bindnow_status=%s\n' "$?"
LD_BIND_NOW=1 没有在文件里新增任何动态项,却让 lazy 主程序走到 NOW 版本相同的位置。部署脚本、启动器、调试环境与服务管理器继承的环境变量,都可能把静态检查眼里的 lazy 路径变成启动期解析。
strace 把两次 syscall 拆开
程序状态之外,还要读取内核实际返回的 getpid 轨迹。
# 追踪 eager 插件的两次 getpid。
rm -f eager_trace.*
timeout 5s strace -ff -qq -e trace=getpid -o eager_trace \
./host ./libifunc_eager.so
printf 'eager_trace_status=%s\n' "$?"
cat eager_trace.*
lazy 对照没有那条成功记录。两个 getpid 都是 EPERM:一次来自首次 PLT 调用触发的 resolver,一次来自 resolver 返回地址后执行的实现。单靠 resolver_before_call=0 已经很有说服力,内核轨迹让这个判断不再依赖自定义变量。
# 追踪 lazy 插件首次调用时的两次 getpid。
rm -f lazy_trace.*
timeout 5s strace -ff -qq -e trace=getpid -o lazy_trace \
./host ./libifunc_lazy.so lazy
printf 'lazy_trace_status=%s\n' "$?"
cat lazy_trace.*
这两组记录还排除了另一个错误解释:不是 constructor 安装失败造成 eager 与 lazy 不同。两组都有 constructor_seccomp_result=0;不同只在于 resolver 进入进程的时点。
规则必须在 dlopen 前就存在
最后把相同规则移到宿主的 dlopen 之前,观察 eager resolver 的首次返回。
# 宿主先安装规则,再以 NOW 装入 eager 插件。
timeout 5s ./host ./libifunc_eager.so now prepolicy
printf 'prepolicy_status=%s\n' "$?"
最后一组轨迹把修复落到了内核层。两条 getpid 都被拒绝,已经没有 resolver 在规则外取得 PID 的窗口。与 eager 原始路径相比,库文件、STT_GNU_IFUNC、JUMP_SLOT、BIND_NOW 都没有变;唯一前移的是规则安装的位置。
# 追踪宿主提前安装策略后的 eager 路径。
rm -f prepolicy_trace.*
timeout 5s strace -ff -qq -e trace=getpid -o prepolicy_trace \
./host ./libifunc_eager.so now prepolicy
printf 'prepolicy_trace_status=%s\n' "$?"
cat prepolicy_trace.*
规则要在加载器进入对象前落地
排查带 seccomp 的插件宿主时,先在目标 DSO 查 STT_GNU_IFUNC,再回到引用它的对象找 JUMP_SLOT。两项同时出现后,动态段的 BIND_NOW、宿主传入的 RTLD_NOW 与启动环境的 LD_BIND_NOW 都是实际的时序输入。当前记录里,libifunc_eager.so 带 BIND_NOW,即使宿主请求 RTLD_LAZY,resolver_before_call 仍是 50700;startup_host_lazy 的文件没有 NOW 标记,加入 LD_BIND_NOW=1 后仍在主程序 constructor 前写入 52048。只检查插件源码里有没有 constructor,得不出这条顺序。
修复点只有一个:生成 BPF 并调用 PR_SET_NO_NEW_PRIVS、SECCOMP_SET_MODE_FILTER 的代码,必须位于宿主调用 dlopen 之前。当前 prepolicy 对照把这两步前移后,eager resolver 的首次返回变为 -1,strace 中两条 getpid 都是 EPERM。把 RTLD_NOW 改为 RTLD_LAZY 不是替代方案;对象的 DF_BIND_NOW 和环境变量仍能让 resolver 提前执行。
resolver 自身只应保留动态加载器允许的极短逻辑。glibc 明确限制 resolver 可依赖的运行环境,不应在其中调用 malloc、dlopen、dlsym 或依赖初始化次序的库接口。glibc Indirect Functions 若业务隔离依赖 constructor 建立状态,就把 IFUNC 视为 constructor 之前的可执行路径;策略安装点必须跨过这条路径,而不是在路径之后补救。