场景

自建 new-api 站点开放注册赠送额度(QuotaForNewUser)后,被脚本批量注册账号、白拿额度并转卖或自用。站点同时暴露在公网,攻击者可能绕过 nginx 直打应用端口。

症状

  • 用户数在一天内异常增长(本例一天 +177)
  • 用户名呈现明显的机器生成特征:英文名 + 英文姓 + 数字(DonaldWatson2004、RussellOrtiz161)
  • 这些账号的注册时间间隔高度规律(本例每 20–25 秒一个)

排查路径

1. 先看注册时间分布,确认是异常而非正常增长

select date(created_at,'unixepoch','+8 hours') d, count(*) from users group by d order by d;

2. audit_logs 表是定位攻击手法最快的地方

new-api 的审计日志记录了登录、建 key、改配置等动作。批量刷号的脚本会留下固定的三步曲:

select created_at, username, ip, action, content from audit_logs order by id desc limit 30;

典型模式:同一个 IP,每个新号在约 5 秒内依次发出

  1. POST /api/user/login — 登录
  2. POST /api/token/ — 建 API key,名字是脚本自动生成的(本例 rt-<epoch秒>)
  3. POST /api/token/batch/keys — 把 key 明文取走

三条记录的 user_agent 都只写 Mozilla/5.0(脚本默认值)。

3. 注册请求本身在 journald,不在 audit_logs

journalctl -u new-api --since "2026-09-23 00:00" --until "2026-09-24 00:00" | grep "user/register"

看三样东西:来源 IP、User-Agent、HTTP 版本。脚本常用 HTTP/1.0(浏览器用 1.1)。

4. nginx 访问日志用来区分「走 nginx」还是「直连应用端口」

grep "user/register" /www/wwwlogs/<域名>.log

某 IP 出现在 nginx 日志里 = 走了 nginx;只看得到应用日志、nginx 里没有 = 直连了应用端口。

5. 算清损失

-- 每个新号送了多少
select value from options where key='QuotaForNewUser';
-- 实际被消耗了多少(type=2 是消耗流水)
select sum(quota) from logs where type=2;
-- 烧在哪些模型上
select model_name, count(*), sum(quota) from logs where type=2 group by model_name order by 3 desc;

额度换钱:$1 = QuotaPerUnit = 500000。本例 sum(quota)=238577256 → $476.76。

处置(按有效性排序)

措施作用
配 SMTP 并开 EmailVerificationEnabled最有效。每个号需要真实可用邮箱,把成本从"零"抬到"一个邮箱"
降/关 QuotaForNewUser直接降低单个号的收益
EmailDomainRestrictionEnabled + EmailDomainWhitelist白名单机制,临时邮箱天然被挡。只放行名单内域名,攻击者换新临时域名也进不来——比维护"临时邮箱黑名单"强
EmailAliasRestrictionEnabled堵掉 Gmail/Outlook 的 + 别名一邮多号;代价是连 john.smith@ 这种带点的正常地址也会被挡
nginx deny <IP>;挡已知攻击 IP,快但被动
nginx UA 风控挡"懒得改 UA"的脚本,是减速带不是墙
Cloudflare Turnstile(TurnstileCheckEnabled)注册前加人机验证,需要 CF 的 Site Key / Secret Key

nginx UA 风控写法(放在 server 块内)

    # 风控:拦截脚本默认 UA(真实浏览器 UA 都带 "(系统…)" 等后缀)
    location ~ ^/api/(user/register|verification)$ {
        if ($http_user_agent = "Mozilla/5.0") { return 403; }
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;
    }

accept 用精确相等匹配 $http_user_agent = "Mozilla/5.0",浏览器完整的 UA 串不会命中。if + return 是 nginx 里少数安全的 if 用法。

删除批量账号(对齐 new-api HardDelete 语义)

先备份库,再删。HardDelete 删的是用户 + 认证数据,logs 保留:

delete from two_fa_backup_codes where user_id in (...);
delete from two_fas              where user_id in (...);
delete from user_sessions        where user_id in (...);
delete from auth_flows           where user_id in (...);
delete from passkey_credentials  where user_id in (...);
delete from tokens               where user_id in (...);
delete from user_oauth_bindings  where user_id in (...);
delete from users                where id in (...);

(源码:model/user.go 的 HardDelete + deleteUserAuthenticationData。)

坑

1. ApiError 返回的是 HTTP 200,日志里的 200 不代表成功

common/gin.go:

func ApiError(c *gin.Context, err error) {
    c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
}

GIN 访问日志里 POST /api/user/register ... 200 可能只是"参数错误"。判断成败必须看响应体的 success 字段或响应长度。

2. EmailDomainWhitelist 是整个域名精确匹配

service/email_binding.go 用 slices.Contains(common.EmailDomainWhitelist, parts[1])。写 outlook 不等于 outlook.com,等于永远不放行。域名必须写全。

3. 改 options 表后要等同步周期(默认 60 秒)

common/init.go 的 SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)。改完立刻测会失败,日志里能看到 [SYS] ... | syncing options from database。

4. new-api 默认监听所有网卡,端口一旦暴露,nginx 上的所有防护全部失效

main.go:

srv := &http.Server{ Addr: ":" + port, Handler: server }

Addr 是 ":" + port,绑定 0.0.0.0/::。只要这个端口在 NAT/安全组里开着,攻击者就能 http://<IP>:<PORT>/api/user/register 直接打,绕过 nginx 的 IP 黑名单和 UA 风控。

所以排查刷号时,必须先回答一个问题:应用端口有没有对公网开着?

5. 本机开着 Clash/代理时,/dev/tcp 端口测试全部是假阳性

Clash 的 TUN 模式会在本地先把 TCP 连接接住,再决定怎么路由。结果是测试任何 IP 的任何端口都"连接成功"——连明确关闭的端口也"通"。

本例实测:本机测 IP:3000 和 IP:12345 都显示"通",而 :12345 根本没有任何服务。

判据:看连接来源地址,如果是 198.18.0.1 这类保留段,说明流量被 TUN 接管,结论作废。

正确做法:从一台干净的第三方机器测(本例用 ssh tx 执行 curl --noproxy '*'),或者先用一个已知关闭的端口做校准。

验证方法

# 1) 脚本 UA 应被挡
curl -s -k -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0" \
     -H "Host: <域名>" https://127.0.0.1/api/user/register          # 期望 403

# 2) 浏览器 UA 必须放行
curl -s -k -o /dev/null -w "%{http_code}\n" \
     -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36" \
     -H "Host: <域名>" https://127.0.0.1/api/user/register          # 期望非 403

# 3) 其它接口不受影响
curl -s -k -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0" \
     -H "Host: <域名>" https://127.0.0.1/api/status                 # 期望 200

# 4) 改 nginx 后
nginx -t && nginx -s reload

改完 UA 规则后,还要确认换任何 UA 应用返回都一样,排除"规则误伤":

for ua in "curl/8.16.0" "python-requests/2.31" "Go-http-client/1.1" "<浏览器UA>"; do
  curl -s -k -o /dev/null -w "$ua -> %{http_code}\n" -A "$ua" -H "Host: <域名>" \
       -X POST https://127.0.0.1/api/user/register
done   # 应当全一致

适用边界

  • 针对 new-api v1.0.0-rc.40(QuantumNous/new-api);HardDelete 覆盖的表和选项名可能随版本变化。
  • nginx 部分适用于宝塔面板管理的 nginx(配置目录 /www/server/panel/vhost/nginx/,重载用 /www/server/nginx/sbin/nginx -s reload,systemctl reload nginx 在这类机器上常不生效)。
  • UA 精确匹配只能挡固定 UA 的脚本;攻击者改一个字母就绕过。真正的门槛是邮箱验证与人机验证。

本次实例(6767 API / la,2026-09-23 攻击、2026-09-24 处置)

  • 攻击:8.219.150.152 等 IP 用脚本刷 POST /api/user/register,一天 1470 次尝试,建成 177 个号;每个号自动领 $5,共白送 **$885;实际跑掉 $476.76**(烧在 gemini-pro-agent $376、claude-opus-4-6-thinking $60)。
  • 处置:配 QQ 邮箱 SMTP + 开邮箱验证;QuotaForNewUser 从 2500000 降到 250000(后按用户要求恢复为 2500000);block_ips.conf 加 deny;两个 vhost 加 UA 风控;删除 176 个批量号(含 172 个 token、174 个会话),库备份在 /root/one-api.db.bak-before-purge-*。
  • 未解:服务商 NAT 面板里 3000 端口的映射现状需人工确认(从 tx 实测 3000 对外不通);EmailAliasRestrictionEnabled 与 TurnstileCheckEnabled 仍未启用。

关联


本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/AI apps