Ushen Blog
Published on

如何给lua锁定so文件

前段时间在考虑一个问题:如何给 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.clookforfunc,skynet 是 skynet_module.c_try_open。两边都是 dlopen 之后、真正使用模块之前。

TOKEN 本身不做成配置文件,也不做成环境变量。一旦运行时可改,攻击面就大了。做法是编译期宏:

-DMY_SECURITY_TOKEN="\"a1b2c3...\""

预处理器把它展开成字符串字面量,直接编进二进制。宿主里的 MY_SECURITY_TOKEN,和 so 里 get_plugin_identity_token() 返回的,本质是两份各自编译进去的常量;同一次 make 生成同一份随机串,两边才能对上。

校验时也不是比某个全局变量,而是:

  1. dlsym(handle, "get_plugin_identity_token") 找导出函数
  2. 调一下拿到插件侧 TOKEN
  3. 和宿主自己的 MY_SECURITY_TOKENstrcmp

缺符号、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,就进不来。