前段时间在考虑一个问题:如何给 lua 锁定 so,让虚拟机拒绝加载不是我编译出来的动态库。
灵感来得很早。当时看 lua 源码,注意到 luaL_checkversion,就想如果在这里塞一个 TOKEN,检查对不上就打不开。后来真正要做这件事时才发现不对——是否调用 luaL_checkversion,是写 luaclib 的人决定的,宿主拦不住。
换一条路:编译期用 -D 注入宏 TOKEN,每个 so 都编进一个导出函数返回这个 TOKEN;宿主在打开库的入口处 dlsym 拿到这个函数,比对不过就直接拒绝。
原理
核心就一句话:信任建立在“同一次构建”上,而不是运行时配置。
lua / skynet 加载动态库的流程大致是:
require("xxx") / skynet 打开 c 服务
│
▼
dlopen(xxx.so)
│
▼
dlsym 找 luaopen_xxx / xxx_create
│
▼
调用入口,模块生效
普通流程里,只要 so 能被 dlopen,符号对得上,就能跑。我们要拦的是中间这一环:库已经打开了,但还没把入口交出去之前,先问一句“你是不是我编出来的”。
所以校验点必须放在宿主侧、且每次加载都会经过的地方——lua 是 loadlib.c 的 lookforfunc,skynet 是 skynet_module.c 的 _try_open。两边都是 dlopen 之后、真正使用模块之前。
TOKEN 本身不做成配置文件,也不做成环境变量。一旦运行时可改,攻击面就大了。做法是编译期宏:
-DMY_SECURITY_TOKEN="\"a1b2c3...\""
预处理器把它展开成字符串字面量,直接编进二进制。宿主里的 MY_SECURITY_TOKEN,和 so 里 get_plugin_identity_token() 返回的,本质是两份各自编译进去的常量;同一次 make 生成同一份随机串,两边才能对上。
校验时也不是比某个全局变量,而是:
dlsym(handle, "get_plugin_identity_token")找导出函数- 调一下拿到插件侧 TOKEN
- 和宿主自己的
MY_SECURITY_TOKEN做strcmp
缺符号、TOKEN 不对,都立刻 dlclose / unload,拒绝加载。
为什么要单独搞一个 auth_stub.o,而不是每个 .c 里手写一遍?因为业务代码不该关心这件事;链接阶段统一塞进去,漏链就缺符号,天然失败。同时 -fPIC 编成可重定位对象,才能链进 .so。
整条信任链可以看成:
openssl 随机 TOKEN
│
▼
-D 注入宿主 + lua + 所有 so
│
▼
auth_stub.o 链进每个 so(导出 get_plugin_identity_token)
│
▼
运行时 dlopen 后比对 TOKEN
│
┌────┴────┐
通过 拒绝
继续用库 unload / 报错
注意:这不是密码学意义上的签名防篡改。能拿到同一次构建产物、或者自己按同样 TOKEN 重编 so 的人,仍然能过。它防的是“随便丢一个外来 so 进来就被加载”——对内网私有部署、限制热插拔第三方库,够用了。
编译
编译部分要做两件事:生成 TOKEN,并保证宿主、lua、所有动态库用的是同一份。
_MEM_TOKEN := $(shell openssl rand -hex 16)
TOKEN_FLAGS = -DMY_SECURITY_TOKEN="\"$(_MEM_TOKEN)\""
CFLAGS = -g -O2 -Wall -I$(LUA_INC) $(MYCFLAGS) $(TOKEN_FLAGS)
$(shell ...) 在 Makefile 解析时执行一次,整次构建里 _MEM_TOKEN 固定。TOKEN_FLAGS 里那层转义,是为了让编译器最终看到:
#define MY_SECURITY_TOKEN "a1b2c3..."
skynet 主程序走 CFLAGS,自然带上 TOKEN。lua 是单独进 3rd/lua 编的,要把 flags 显式传进去,否则 loadlib.c 里根本没有这个宏:
$(LUA_STATICLIB) :
cd 3rd/lua && $(MAKE) CC='$(CC) -std=gnu99 $(TOKEN_FLAGS)' $(PLAT)
认证桩单独编译,带 -fPIC:
// auth_stub.c
#ifndef _WIN32
__attribute__((visibility("default")))
#else
__declspec(dllexport)
#endif
const char* get_plugin_identity_token(void) {
return MY_SECURITY_TOKEN;
}
AUTH_OBJ = auth_stub.o
$(AUTH_OBJ) : skynet-src/auth_stub.c
$(CC) $(CFLAGS) -fPIC -c $< -o $@
visibility("default") / dllexport 保证符号能被 dlsym 看见;若开了 -fvisibility=hidden,漏掉这层导出,运行时会变成“缺授权接口”。
然后所有 luaclib、cservice 链接时都带上 AUTH_OBJ:
define CSERVICE_TEMP
$$(CSERVICE_PATH)/$(1).so : service-src/service_$(1).c $$(AUTH_OBJ) | $$(CSERVICE_PATH)
$$(CC) $$(CFLAGS) $$(SHARED) $$^ -o $$@ -Iskynet-src
endef
$(foreach v, $(CSERVICE), $(eval $(call CSERVICE_TEMP,$(v))))
$(LUA_CLIB_PATH)/skynet.so : $(addprefix lualib-src/,$(LUA_CLIB_SKYNET)) $(AUTH_OBJ) | $(LUA_CLIB_PATH)
$(CC) $(CFLAGS) $(SHARED) $^ -o $@ -Iskynet-src ...
$(LUA_CLIB_PATH)/bson.so : lualib-src/lua-bson.c $(AUTH_OBJ) | $(LUA_CLIB_PATH)
$(CC) $(CFLAGS) $(SHARED) -Iskynet-src $^ -o $@
这里有几个容易踩坑的点:
- 同一次构建:宿主、
liblua.a、各个.so必须吃到同一个_MEM_TOKEN。只改 so、不重编 lua / skynet,或反过来,都会对不上。 - clean 后再编:
make每次重新openssl rand,TOKEN 会变;旧 so 不能和新宿主混用,发布时要整包替换。 - 新增 luaclib 别忘链
AUTH_OBJ:业务.c不用改,但 Makefile 规则漏了就会加载失败。 - lua 的 TOKEN 不能省:校验逻辑在
loadlib.c里,它必须和 so 侧宏一致;只给 so 加 TOKEN、宿主 / lua 没加,等于没有锁。
运行时校验
lua 侧改 lookforfunc,只在库第一次被加载时检查(checkclib 未命中、刚 lsys_load 完):
static int lookforfunc (lua_State *L, const char *path, const char *sym) {
void *reg = checkclib(L, path);
if (reg == NULL) {
reg = lsys_load(L, path, *sym == '*');
if (reg == NULL) return ERRLIB;
lua_CFunction f_auth = lsys_sym(L, reg, "get_plugin_identity_token");
if (f_auth == NULL) {
lua_pop(L, 1);
lsys_unloadlib(reg);
lua_pushstring(L, "Security Error: Missing authorization interface.");
return ERRLIB;
}
const char* (*get_token)(void) = (const char* (*)(void))f_auth;
if (strcmp(get_token(), MY_SECURITY_TOKEN) != 0) {
lsys_unloadlib(reg);
lua_pushstring(L, "Security Error: Unauthorized plugin token mismatch!");
return ERRLIB;
}
addtoclib(L, path, reg);
}
// ...
}
skynet 的 c 服务同理,在 _try_open 里拦一层:
static void *
_try_open(struct modules *m, const char * name) {
// ... dlopen ...
const char* (*get_token)(void) = dlsym(dl, "get_plugin_identity_token");
if (get_token == NULL || strcmp(get_token(), MY_SECURITY_TOKEN) != 0) {
dlclose(dl);
fprintf(stderr, "Security Alert: Blocked unauthorized C-service [%s]!\n", name);
return NULL;
}
return dl;
}
两边入口一封,不是同一次构建编出来的 so,就进不来。