OpenResty 的 Lua 脚本审计往往不是从代码审查开始的,而是从事故现场倒推。当你发现某台服务器的 Nginx Worker 进程内存占用持续走高,或者某个接口的延迟偶尔出现尖刺,第一反应不该是去查系统日志,而应该直接怀疑是不是哪段 Lua 脚本在非阻塞调用上出了问题。最常见的坑是开发者在 init_by_lua 或 init_worker_by_lua 阶段执行了需要网络 I/O 的操作,比如用 luasocket 去请求一个外部配置中心。这类操作在初始化阶段会阻塞整个进程的启动,如果外部服务不可达,所有 Worker 都会卡住,直到超时。审计这类问题的办法很直接:全局搜索所有 *_by_lua 指令对应的脚本,逐一检查是否存在 os.execute、io.popen、luasocket 或任何没有使用 ngx.location.capture 子请求的 TCP 连接。
连接池泄漏是性能审计的硬指标OpenResty 提供了 cosocket 的连接池机制,但很多脚本在获取连接后没有正确释放。典型的反模式是:用 tcpsock:connect() 建立连接后,只在正常逻辑里调用了 setkeepalive(),却在错误处理分支里直接 return 了。一旦外部服务抖动,大量连接会处于半开状态,既不归还池子也不释放,最终耗尽 Worker 的文件描述符。审计时不能只看代码逻辑,要直接跑一段针对性的压测,用 ss -antp 观察 ESTABLISHED 状态到目标端口的连接数。如果连接数持续上升且不回落,说明 setkeepalive 的调用路径存在盲区。更隐蔽的泄漏发生在 ngx.timer 回调里,定时器内部如果创建了 cosocket 却因为异常退出,连接永远不会被回收,因为定时器的上下文没有请求生命周期来兜底。
全局变量污染和跨请求数据串扰Lua 模块级的全局变量在 OpenResty 中是持久化在 Worker 进程内存里的,这意味着一个请求修改了某个全局表,后续所有请求都能读到这个修改。这种跨请求的数据串扰极难复现,往往只在特定并发顺序下才会暴露。审计的切入点是检查所有没有使用 local 声明的变量,尤其是那些在模块顶层定义的 table。一个容易被忽略的细节是:require 返回的表如果被直接修改,也会成为事实上的全局变量。正确的做法是要求所有模块只暴露函数,不暴露可变状态。如果确实需要进程级缓存,必须显式使用 ngx.shared.DICT 或者用 lrucache 库,并且在代码注释里明确标注数据共享的意图和失效策略。
正则表达式灾难性回溯的静默攻击OpenResty 的 Lua 环境同时支持 PCRE 和 Lua 原生的模式匹配,但很多开发者习惯直接用 ngx.re.match 处理用户输入,却忽略了正则的回溯风险。一个精心构造的字符串可以让简单的正则表达式消耗数秒 CPU 时间,由于 Nginx 是单线程处理请求的,这意味着这一个请求就能阻塞整个 Worker 的所有其他请求。审计的重点不是禁止使用正则,而是检查所有正则表达式是否允许用户控制输入长度。对于必须接受长文本的场景,必须设置 ngx.re.match 的 o 选项来启用编译缓存,并且加上 j 选项启用 JIT 编译,同时限制输入的最大长度。更彻底的方案是用 ngx.re.find 替代 match,因为 find 在找到第一个匹配后就会停止,不会尝试所有可能的匹配路径。
JSON 解析的隐蔽性能陷阱cjson 库是 OpenResty 生态里使用最频繁的组件,但它的安全审计往往被忽视。cjson.decode 对于畸形 JSON 的容错性很差,遇到未转义的控制字符会直接抛出错误。问题在于,很多脚本没有对 decode 做 pcall 保护,导致一个坏请求就能让整个请求处理流程中断,返回 500 错误。更严重的是 cjson.encode 的稀疏数组处理,Lua 表中如果存在 nil 值,encode 会将其编码为 JSON null,而如果 nil 出现在数组中间,会导致数组被意外截断。审计时要重点检查所有 cjson 调用点,确保 encode 和 decode 都有错误处理包裹,并且对于需要严格数组结构的场景,使用 cjson.empty_array 来标记空数组,避免 nil 导致的歧义。
ngx.location.capture 子请求的连环嵌套子请求是 OpenResty 实现内部调用的核心机制,但滥用会导致请求处理时间呈指数级增长。最常见的问题是循环子请求:A 服务通过子请求调用 B,B 又通过子请求调用 A,在特定条件下形成死循环。即使没有死循环,多层子请求的嵌套也会让每个请求的协程切换开销累积到不可接受的程度。审计工具是检查 nginx.conf 中所有 internal 指令标记的 location,绘制出子请求的调用图谱。如果图谱深度超过三层,就需要考虑重构为异步消息队列或者合并接口。另外要注意子请求默认会继承父请求的所有请求头,包括 Host 和 Cookie,这在微服务架构中可能导致意外的鉴权绕过。
共享内存字典的锁竞争和容量规划ngx.shared.DICT 是进程间共享数据的唯一官方途径,但它不是免费的。每个字典操作都会加锁,在高并发写入场景下,锁竞争会严重拖累吞吐量。审计时要统计所有对共享字典的 add、set、incr 操作频率,如果发现某个字典的写入 QPS 超过万级别,就需要考虑用 shdict:flush_expired 定期清理过期数据来减少锁持有时间,或者将热点数据拆分到多个字典中分散锁压力。容量方面,共享字典的内存是预先分配的,不会动态扩容,一旦写满就会导致 set 操作返回 nil 和 no memory 错误。必须检查所有 set 调用的返回值处理,很多脚本直接忽略了写入失败的情况,导致缓存穿透到后端数据库。
定时器的数量失控和回调异常ngx.timer.at 和 ngx.timer.every 创建的定时器运行在独立的伪请求上下文中,每个定时器回调都会消耗一个虚拟请求的资源。如果某个脚本在请求处理中动态创建定时器却没有上限控制,恶意攻击者可以通过高频请求创建海量定时器,最终耗尽 Nginx 的定时器池。审计时要统计所有创建定时器的代码路径,确认是否存在基于用户输入创建定时器的逻辑。另外定时器回调中的异常不会被上层捕获,一旦回调函数抛出错误,该定时器就会静默终止,后续的定时任务不会再执行。所有定时器回调函数内部必须有自己的 pcall 包裹,并且将错误记录到 ngx.log 的 ERR 级别。
第三方库的供应链风险OpenResty 项目通常会引入大量的 lua-resty-* 第三方库,这些库的质量参差不齐。审计不能只盯着自己写的业务代码,必须把第三方库也纳入扫描范围。重点关注库中对 cosocket 的使用是否规范,是否存在前面提到的连接泄漏问题。一个实用的审计方法是检查所有 lua_package_path 和 lua_package_cpath 指向的目录,用 grep 搜索 tcpsock:connect、tcpsock:sslhandshake 等关键字,然后逐一审查其错误处理路径。对于不再维护的第三方库,如果存在安全隐患,应该将其核心逻辑提取出来重写,而不是继续依赖外部源。
日志注入和敏感信息泄露ngx.log 是调试利器,但也是信息泄露的重灾区。很多开发者在调试阶段会把完整的请求体、JWT Token、甚至数据库查询语句原样输出到 error.log。这些日志文件通常权限较宽松,一旦被内部非授权人员获取,后果严重。审计要覆盖所有 ngx.log 调用,检查日志级别和输出内容。生产环境中,ngx.log 的级别应该控制在 WARN 以上,并且绝对不能输出请求体、Cookie、Authorization 头等敏感数据。如果需要记录请求上下文用于问题排查,应该只记录脱敏后的请求 ID 和关键业务参数。
代码执行和文件操作的权限边界OpenResty 默认禁用了 os.execute 和 io.popen,但很多运维为了方便,会在编译时打开这些选项,或者在 init_by_lua 中使用这些功能来执行外部脚本。这相当于给 Nginx Worker 进程赋予了执行系统命令的能力,一旦 Lua 脚本存在注入漏洞,攻击者就能直接拿到服务器的 Shell。审计时要确认 Nginx 配置中是否设置了 lua_check_client_abort 和 lua_code_cache 等安全相关指令,并且检查所有文件操作是否使用了 ngx 提供的安全 API 而不是 Lua 标准库。对于必须执行外部命令的场景,应该用专门的守护进程通过 Unix Socket 接收指令,而不是在 Nginx 进程内直接 fork。
最终,OpenResty Lua 脚本审计的核心不是找 bug,而是建立一套可重复的检查清单,让每一次代码变更都能被这套规则快速扫描。把上述这些检查点做成自动化脚本,集成到 CI 流程里,远比依赖人工审查可靠得多。
