0
0
0

Go 服务端约定

2026-09-13
2026-09-13
文章摘要
|

来源:intranet-tunnel/docs/AGENTS-ARCHIVE.md## 两步验证(2FA)从「形同虚设」到真正生效(v1.0.6,2026-09-13)(原文 3695 字符)

两步验证(2FA)从「形同虚设」到真正生效(v1.0.6,2026-09-13)

缺陷是什么(先独立验证,不采信文档)

docs/mobile-api-contract.md 把它记为「既有缺陷」,实测确认:

# SettingAuth2FAEnabled 全仓库只有「写」没有「读」
Get-ChildItem server -Recurse -File -Include *.go |
  Select-String -Pattern 'SettingAuth2FAEnabled'
#   settings.go:573  "true"    ← 绑定确认时写
#   settings.go:606  "false"   ← 关闭时写
#   manager.go:182             ← 首次启动的默认值
#   ↑ 没有任何一处读取它

handleLogin 在校验口令通过后直接 issueTokens,中间没有任何 TOTP 分支。 结论:设置页能把 2FA 绑上、开关也显示「已启用」,但登录完全不校验—— 开了 2FA 没有任何防护效果。

为什么不只修移动端入口

原契约写「这样 App 有完整的 2FA 能力,而 Web 端行为一字不改」。实施时判定这句话 做不到也不该做:只保护 /api/mobile/* 的话,拿到口令的人直接调 /api/auth/login 就能绕过 2FA,缺陷只修了一半;而 Web 端要能过第二步, 登录页就必须加输入步骤,谈不上「一字不改」。

最终实现(用户确认后按此执行):

位置改动
api/handler.gohandleLogin 在签发令牌前插入第二因子分支;抽出 completeLogin 供两条出口复用
api/mfa.go(新增)twoFactorRequired / startTwoFactorChallenge / handle2FALoginVerify / acceptTOTPStep
auth/mfa.go(新增)TicketStore(内存、一次性、TTL、错误次数上限)+ ReplayGuard(TOTP 时间步去重)
settings/security.go新增 VerifyTOTPStepVerifyTOTP 改为它的薄包装(判定必须一致,已加等价性测试)
路由/api/auth/2fa/verify/api/mobile/auth/2fa/verify复用同一处理器
auth_sms.go/api/auth/config 增加 two_factor_required
设置页两个 2FA 接口改用 VerifyTOTPStep + 同一个重放防护
web/src/views/LoginView.vue识别 mfa_required、切到第二步、失败可返回重登

票据为什么不用 JWT 承载:JWT 无状态、签发后无法作废,而票据的核心要求是 「用过即废」;否则被截获的票据可以在有效期内反复试 6 位验证码。改用 服务端内存里的随机 id + 载荷:天然一次性、无字段可篡改。

重放防护为什么必要VerifyTOTPStep 允许 ±1 个时间步,一个验证码实际 有效窗口长达 90 秒。没有这层判断,任何看到过该验证码的人都能在此期间复用它。 ReplayGuard 记「每个账号最近接受的步」,要求严格递增。 所有消耗验证码的地方都走它(登录第二阶段、绑定确认、关闭 2FA)—— 只加在登录上会留下「先用于登录、再用于关闭 2FA」的窄缝。

⚠️ 丢了认证器怎么恢复(设置页关不掉)

「关闭 2FA」本身也要验证码,所以丢失认证器后无法从界面关闭。恢复只能改库:

-- 云上数据库是 postgres,容器名 tunnel-postgres
update system_settings set setting_value='false' where setting_key='auth.2fa_confirmed';
update system_settings set setting_value='false' where setting_key='auth.2fa_enabled';
update system_settings set setting_value=''      where setting_key='auth.2fa_secret';
cd /home/docker/intranet-tunnel && docker compose restart tunnel-server

三个必须注意的点(都实测过):

  1. 改库后必须重启进程:设置管理器把整表读进内存缓存(NewManager 里一次

reload),直接写库绕过了缓存,不重启不会生效。

  1. .envAUTH_2FA_ENABLED=false 没用defaults() 只在首次初始化

写默认值,已有的行不会被覆盖。这与 PANEL_OUTSIDE_ACCESS 那个坑是同一类。

  1. 不要用 SQL 手工写 auth.2fa_secret:它在敏感键名单里,配了

CONFIG_ENCRYPT_KEY 时是密文存储,明文写进去会被当成密文解密失败。 置空是安全的(空值不加密),重新绑定请走设置页。

验证(40 项断言,全部打真实 HTTP 路径)

环境:H:\Works\.recover\mfa-e2e\(独立端口 + sqlite + MOBILE_ENABLED=true + AUTH_CAPTCHA_ENABLED=false),脚本 verify.js

关键设计:验证码由独立的 Node 实现算出(base32 + HMAC-SHA1 + 动态截断), 不复用 Go 侧任何代码——否则「服务端接受了它」不能证明任何事。

覆盖到的断言(节选):

  • 未开 2FA 时口令登录直发令牌、不返回 mfa_required(回归:老行为不能变);
  • 开 2FA 后登录返回 mfa_required + mfa_ticket且没有任何令牌
  • 绑定、登录第二阶段、移动端第二阶段都能用独立实现的验证码通过;
  • 同一张票据不能用第二次(一次性);
  • 同一验证码不能重放(含「登录用过的码不能接着用于关闭 2FA」);
  • 错误验证码 / 伪造票据 / 空票据一律 401;关闭 2FA 后登录恢复直发令牌;
  • 内嵌前端确为新版:从二进制里取出 SPA,沿入口 chunk 找到懒加载的

LoginView-*.js,断言它含 mfa_ticket 与「返回重新登录」。

踩到的两个坑(都在测试脚本侧,值得一提)

  1. 字段名是 token 不是 access_tokentokenPair.AccessToken 的 json tag)。

第一版脚本因此把「登录成功」误判成失败,进而 401 连锁失败。

  1. 重放防护会让「同一窗口内连续两次消费」失败——第一版脚本复用同一个

totp(secret) 去测移动端,被正确拒绝。这不是缺陷而是设计:脚本必须 等到新的 30 秒窗口再取码(现在用 nextCode() 等待窗口推进)。 ⚠️ 同一用户 30 秒内在两台设备登录时,第二台会看到「该验证码已被使用」, 属预期行为(RFC 6238 §5.2 建议的严格去重)。

时间步单调性的一个隐含要求

ReplayGuard 要求 step 严格递增,而 VerifyTOTPStep 返回的是匹配到的那个步 (可能是 now-1)。因此「先接受了 now-1,再接受 now」没问题,反向则会拒绝—— 这在正常时钟下不会发生,但服务器时钟被回拨时会出现「验证码正确却提示已被使用」。 判据:出现该提示时先核对服务器时间(timedatectl),而不是怀疑认证器。



来源:intranet-tunnel/docs/AGENTS-ARCHIVE.md## 并发写数据库导致 500 的两个缺陷(v1.0.7,2026-09-13 修复)(原文 4686 字符)

并发写数据库导致 500 的两个缺陷(v1.0.7,2026-09-13 修复)

同一类根因的两个缺陷:「事务内先查后改」的加锁方式在并发下出错。 两者都只在并发写时暴露,单次点按不会遇到——所以它们能长期潜伏。

缺陷一:SQLite 报 database is locked (5) (SQLITE_BUSY)

症状DB_DRIVER=sqlite,也就是 .env.example 的默认值):

level=DEBUG msg="store/setting.go:112 database is locked (5) (SQLITE_BUSY)"
POST /api/settings/2fa/setup → 500 {"error":"保存 2FA 密钥失败"}

如何发现的:本地 2FA e2e 里「登录成功后紧接着写设置」稳定复现—— 登录会 UPDATE last_login_at,紧接着的设置写入就撞上写锁。

⚠️⚠️ 根因更正:不是「连接池里的连接没带上 busy_timeout」

本条最早给出的根因是错的,显式更正(错误结论会把人引向改连接池, 而那是绕过而非修复):

✗ 旧结论:DB_MAX_OPEN_CONNS 默认 50,busy_timeout 是每连接参数, 池里后续连接未必带上它,所以第二个写者立即拿到 SQLITE_BUSY。

实测推翻:glebarez/go-sqlite 的 applyQueryParamssqlite.go:873)在 每个新连接上都会执行 pragma BUSY_TIMEOUT(5000)——驱动自带这个默认值, 之后才逐条执行 DSN 里的 _pragma。验证方式:同时持有 6 个物理连接逐个读 pragma, 6/6 都是 busy_timeout=5000journal_mode=wal

真实根因:DEFERRED 事务的锁升级。GORM 的「先查后改」 (UpsertSettings:先 FirstUpdates)默认以读事务开始、写时再升级; 若此时快照已被其它连接修改,SQLite 返回 SQLITE_BUSY_SNAPSHOT 且不调用 busy handler——因为等待也解决不了问题,所以它根本不等待busy_timeout 在这条路径上形同虚设,这才是 783ms 就返回的原因。

对照实验(同一个库,8 并发 × 20 轮「事务内先查后改」)

配置失败次数耗时
默认 DEFERRED + busy_timeout 5s140/140783ms(根本没等)
_txlock=immediate + 5s1~6s(确实在等)
_txlock=immediate + 10s0
SetMaxOpenConns(1)(原候选修法)06.0s(把并发读也串行化了)

⚠️ 实验还纠正了一条:纯写-写并发本身不失败(默认配置下 0 错误), 失败只出现在「先读后写」的升级路径上。所以只压 UPDATE 是复现不出来的。

修法

server/internal/store/store.go 的 SQLite DSN 改为: _pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_txlock=immediate

_txlock 只影响显式事务,自动提交的纯 SELECT 不受影响,因此读性能不变; 全仓库只有两个显式事务(UpsertSettingsUpdateClientToken),都不是纯读。

缺陷二:postgres 报 deadlock detected

发现方式:修完 SQLite 后在容器烟测里加了一条「postgres 下并发保存设置」 断言,6 并发里 2 例 500,pg 日志给出真因:

ERROR:  deadlock detected        ← 对应请求耗时正好 1.006s

1.006s 正好是 postgres 默认的 deadlock_timeout(1s),这是识别死锁最快的判据 ——普通行锁等待不会恰好是整数秒。

根因settings.Manager.SetMany 的入参是 Go mapfor key, value := range changes 的顺序随机;两个并发事务于是可能以 相反顺序锁同一批行(如 auth.2fa_secretauth.2fa_confirmed), 互相等待成环。顺序随机是关键——同一份代码在单请求下永远正常。

修法store.UpsertSettings 落库前按 setting_key 升序排序(在副本上排序, 不改调用方切片)。它是唯一的落库入口,覆盖全部调用者 (Manager.SetManyManager.EnsureDefaults)。

⚠️ 这条缺陷在 SQLite 下测不出来_txlock=immediate 已把写事务串行化, 死锁失去了前提。所以它的回归验证只能靠 postgres 集成测试(容器烟测); 单测只能锁定「排序确实发生了」这一事实。

验证记录(v1.0.7)

方法结果
单元server/internal/store/sqlite_test.go(4 项)全通过;并验证过「能失败」:临时改回 DEFERRED → 53/60 失败且复现原始报错
端到端(SQLite,连接池恢复默认 50)96 次并发「登录 + 设置写」1.0.6:23 个 500;1.0.7:0 个非 2xx,服务端日志 SQLITE_BUSY 计数 0
端到端(回归)mfa-e2e/verify.js(2FA 两阶段全套)40/40 通过,确认改事务模式无回归
容器(postgres,server:1.0.7 镜像)12 项烟测12/12 通过;死锁日志:修复前 3 次(17:24:52–17:25:13),修复后容器 17:26:25 启动,之后 0 次
生产(公网 https://tunnel.sushike.cloud,postgres)19 项验收(含真实并发写)19/19 通过;并发 8 次写设置全部 200、用时 85ms、无 5xx;.env 升级前后 sha256 完全相同(未被改动)

⚠️ 搭本地容器烟测环境的三个坑(v1.0.7 实测)

  1. bind mount 的目标不存在时,Docker Desktop 会把它建成一个目录。

首次 upserver.env 尚未生成,挂载失败并在宿主机创建了同名目录; 之后即便文件生成好了也会持续报 not a directory: Are you trying to mount a directory onto a file。 判据:先 Test-Path <path> -PathType Container 确认它是不是被建成了目录。

  1. compose 服务名必须与 .env 里的 DB_HOST 一致。

为隔离把服务起名为 ctr107-postgres,而模板里是 DB_HOST=postgres, 容器内解析不到,报 lookup postgres on 127.0.0.11:53: server misbehaving ——这个报错指向 DNS,很容易误判成网络问题,实际是服务名不匹配。 隔离请用 container_name,服务名保持 postgres

  1. 改了服务名之后 docker compose down 找不到旧容器

down 是按当前 compose 文件里的服务名找容器的,旧名字的容器成了孤儿, 下一次 up 直接报 container name is already in use。 只能 docker rm -f <容器名> 手工清理(网络与卷同理)。

⚠️ 注意 .env.docker 模板并不列出所有配置项AUTH_CAPTCHA_ENABLED(默认 true)与 MOBILE_ENABLED(默认 false)就不在其中。 搭烟测环境时若只按模板改,会被这两个默认值挡住(登录要验证码、移动端路由 404), 最后以看似无关的断言失败收场。用脚本生成时应当「存在则替换、不存在则追加」, 并在写完后回读核对。

上线记录(2026-09-13,1.0.6 → 1.0.7 增量升级)

载体:/root/intranet-tunnel-server-upgrade-1.0.7.tar(13,494,784 字节)
      sha256 本机与服务器一致(02a8b88a…ba5ac),传完即核对,不靠"上传成功"的提示
步骤:备份 compose 与 .env → docker load → sed 改 tag → compose up -d tunnel-server
结果:容器 healthy(5s),/healthz version=1.0.7,compose tag 与运行镜像一致
回滚:旧镜像 1.0.0~1.0.6 全部保留在服务器上,改回 tag 再 up 即可

⚠️ 两个必须守住的操作纪律(本次都做了):

  1. .env 必须逐字节还原:生产验证需要临时关图形验证码(AUTH_CAPTCHA_ENABLED=true→false),

脚本先记 sha256 再改,验证完还原后再比对一次——本次升级前、验证前、恢复后三次 sha256 全为 d1d0e659…aae2,证明这一轮没有留下任何配置漂移。

  1. 升级脚本只 recreate tunnel-server:这台机器的 80/443 由宝塔接管,

docker compose up -d(不带服务名)会连带启动 compose 里的 tunnel-nginx, 而它缺 nginx-compose.conf 会报 not a directory 失败。



来源:intranet-tunnel/docs/security-audit.md(整篇)(原文 9941 字符)

安全审计与 DDoS 防护报告

审计对象:Intranet Tunnel 内网穿透系统(服务端 / 客户端 / Nginx 网关) 审计方式:白盒代码审计 + 黑盒渗透测试(自研工具,对自有部署实例发起受控攻击) 测试环境:Docker Desktop(Linux 容器)+ PostgreSQL 16 + 服务端 intranet-tunnel/server:1.0.0 报告日期:随仓库版本维护


一、结论摘要

维度结论
功能类漏洞(认证/授权/注入/路径穿越)未发现可利用漏洞(19 项用例全部通过)
DDoS 防护有效性8 项攻击用例全部被有效防护,攻击期间服务延迟稳定在 1-3 ms
审计中发现并修复的缺陷4 个(1 个 CRITICAL 配置风险、1 个 HIGH 协议层内存放大、2 个 LOW 加固项)
残余风险3 项,均需在目标环境结合实际拓扑确认(见第六节)

二、攻击面分析(威胁模型)

系统对外暴露三个端口,按风险从高到低:

端口暴露范围主要威胁
47800 控制连接公网连接耗尽、慢速握手、协议层内存放大、凭据爆破
47801 管理 API仅回环(Nginx 代理)凭据爆破、JWT 伪造、SQL 注入、越权
48080 数据面仅回环(Nginx 代理)慢速 HTTP(Slowloris)、连接洪水、由 Nginx 转发的攻击流量
48081-48100 TCP 隧道池公网连接洪水、端口扫描

关键风险点识别(审计前的代码审查结论):

  1. 协议帧内存放大ReadMessage 若按帧头声明的长度预分配缓冲,攻击者用 4 字节帧头即可让服务端为每条连接预留 1 MiB,形成 1:N 的内存放大;
  2. bcrypt CPU 放大:口令哈希「故意昂贵」(cost=12 约数百毫秒),若不限制并发,少量请求即可让 CPU 满载;
  3. 慢速握手 / 慢速 HTTP:超时过长时,攻击者可用极低带宽长期占用连接与协程;
  4. 单一隧道打满拖垮全站:缺少隧道级并发上限时,一条高流量隧道会耗尽服务端资源;
  5. 跟踪表膨胀:基于 IP 的限流若不设容量上限与回收,会被海量伪造源 IP 撑爆内存;
  6. NAT 环境下 IP 维度失效:经 Docker/Nginx 转发后所有来源折叠为网关地址,单 IP 限流失去区分度,必须依赖全局上限兜底。

三、DDoS 防护架构

新增 server/internal/guard 包,实现七层防护。三个入口(控制面、数据面、管理 API)共用同一套封禁状态——因此爆破管理后台的来源会同时失去建立隧道连接的资格。

                     ┌─────────────────────────────────────────┐
  控制连接 47800 ───▶│ 1. 全局并发上限(NAT 场景兜底)          │
  数据连接 48080 ───▶│ 2. 单 IP 并发上限                        │
  TCP 池 48081+  ───▶│ 3. 单 IP 连接建立速率(令牌桶)          │
                     │ 4. 认证失败自动封禁(fail2ban 式)        │
  管理 API  47801 ───▶│ 5. 登录限速 + bcrypt 并发上限            │
                     │ 6. 在线客户端总数上限                    │
                     │ 7. 单隧道并发上限 / 工作连接创建速率      │
                     └─────────────────────────────────────────┘
                                        │
                                        ▼
                      shared/protocol:帧分块读取(消除内存放大)
                                     + 握手超时(消除慢速握手)

3.1 关键实现要点

机制实现位置说明
连接准入control.Manager.handleIncoming读取任何数据之前完成判断,被拒连接不分配缓冲与协程
握手超时GUARD_HANDSHAKE_TIMEOUT(默认 5s)独立于心跳超时;未完成登录的连接只允许短暂存在
帧分块读取shared/protocol.readBodyChunked内存占用与实际到达的数据量成正比,4 KiB 一块
登录双闸guard.AllowLogin + loginSem 信号量前者限速率,后者限制并发 bcrypt 数量(按 CPU 核数缩放,封顶 8)
自动封禁guard.RecordAuthFailure失败窗口内达阈值即封禁;认证成功会清零计数,避免误封
跟踪表保护MaxTrackedIPs + 定期空闲回收防止海量伪造源 IP 撑爆内存
白名单GUARD_WHITELIST白名单来源不受任何限制(放行 Nginx、监控、办公网)
运维接口GET /api/guardPOST /api/guard/ban查看按原因分类的拒绝统计;手动封禁并立即断开该来源的在线连接

3.2 修复的协议层缺陷(HIGH)

shared/protocol/message.go 原先按帧头声明的长度一次性分配缓冲:

body := make([]byte, size)   // size 最大 1 MiB
io.ReadFull(r, body)

攻击者只需发送 4 字节帧头(声明 1 MiB)而不发送任何载荷,服务端就会为每条连接分配 1 MiB 并阻塞在读取上。1 万条半开连接即可占用约 10 GiB 内存。

修复:改为 4 KiB 分块读取(readBodyChunked),内存占用与实际到达的数据量成正比——攻击者要消耗 1 MiB 就必须真的传输 1 MiB。配套回归测试见 shared/protocol/conn_test.go

3.3 Nginx 与应用层防护的分工

负责局限
Nginx请求级限流(limit_req)、连接数限制(limit_conn)、慢速攻击超时、头部大小限制、方法白名单、IP 黑名单只能看到经它代理的 HTTP 流量;TCP 隧道端口只能力度有限地限制并发
应用层 guard控制端口准入、认证失败封禁、协议层防护、隧道级并发、bcrypt 并发经 NAT 后源 IP 可能被折叠成网关地址
fail2ban + iptables把应用层识别出的恶意来源推到内核层持续阻断需要额外部署;容器日志转义需注意
内核 sysctlSYN flood、连接跟踪表、端口/文件描述符耗尽属兜底,不替代应用层识别

四、渗透测试结果

工具:tools/pentest(自研,Go 实现,可复用于安全回归)

cd tools/pentest
go run . -mode all -pass '<管理员口令>' -domain app.example.com -concurrency 150

4.1 信息泄露与加固项

编号用例结果
INFO-01安全响应头(X-Frame-Options / X-Content-Type-Options)PASS(修复后)
INFO-02是否暴露 Server 版本PASS:响应中不含 Server 头
INFO-03错误响应是否泄露堆栈/SQL/文件路径PASS:5 类错误路径均未泄露内部细节
INFO-04静态文件服务路径穿越PASS:无法读取 /etc/passwd
INFO-05健康检查信息面OBSERVED:仅暴露版本/运行时长/在线数
INFO-06危险 HTTP 方法(TRACE)PASS(修复后)
AUTH-008 个受保护接口的未授权访问PASS:无令牌时全部 401

4.2 认证与授权

编号用例结果
AUTH-01畸形令牌(空、非 JWT、两段、空签名、随机签名)PASS:5 种全部拒绝
AUTH-02alg=none 算法绕过PASS:未签名令牌返回 401
AUTH-03JWT 签名篡改PASS:篡改后返回 401
AUTH-04JWT 载荷篡改(提权 uid/role)PASS:返回 401
AUTH-05常见弱密钥伪造(11 个字典项)PASS(修复后):改用 32 字节随机密钥
AUTH-06refresh 令牌越权当访问令牌PASS:返回 401(TokenType 校验有效)
AUTH-07过期令牌PASS:返回 401
AUTH-08登录响应能否区分账号是否存在PASS:文案一致,耗时差异约 1%
AUTH-09接口是否泄露客户端凭据PASS:列表不含 token / token_hash

AUTH-08 的实现依据:账号不存在与口令错误返回完全相同的文案,且两种情况都执行一次等价的 bcrypt 比对,把时序差异压到约 1%(实测)。

4.3 注入类

编号用例结果
INJ-01过滤参数 SQL 注入(7 个 payload)PASS:未触发 SQL 错误或 5xx
INJ-02写入接口注入(以 payload 作为 client_id/name)PASS:均以 4xx 正常拒绝
INJ-03隧道 local_addr 的 SSRF 面MANUAL(见残余风险 R1)
INJ-04CRLF 响应头注入PASS:客户端协议栈即拒绝,服务端未回显
INJ-05响应反射未转义脚本(XSS)PASS:payload 未被原样回显
INJ-06畸形输入触发 5xx(5 类边界)PASS:均以 4xx 拒绝

SQL 注入未被利用的根因:全部查询经 GORM 参数化;路径参数在使用前先做 strconv.ParseUint 校验,非法输入在到达 ORM 之前即被拒绝。

4.4 DDoS 防护实测(并发 150,单模块 8s)

编号攻击类型结果实测数据
DOS-01控制端口连接耗尽PASS150 条并发冲击:服务端接受 20 / 拒绝 130;服务延迟 2 ms
DOS-01c攻击期间正常业务可用性PASS正常隧道路由请求成功,耗时 3 ms
DOS-02协议帧内存放大(150 条半开连接,各声明 1 MiB)PASS理论放大 150 MiB 下服务延迟 2 ms
DOS-03慢速握手(150 条,每 300 ms 发 1 字节)PASS150 条全部在握手超时内被关闭,平均存活 389 ms
DOS-04慢速 HTTP / Slowloris(100 条)PASS100 条全部被回收,平均存活 4.70 s(= 5 s 请求头超时)
DOS-05数据面连接洪水(150 条)PASS服务延迟 1 ms
DOS-06登录爆破(60 次错误口令)PASS4 次起被限流,其后 57 次全部 429;限流期内正确口令同样被拒绝
DOS-07全部攻击后服务可用性PASS健康检查通过,延迟 1 ms

服务端防护运行指标GET /api/guard,一轮完整测试后):

{
  "metrics": {
    "active_control_conns": 1,
    "tracked_ips": 1,
    "banned_ips": 0,
    "total_rejected": 1986,
    "auth_failures": 19,
    "rejected_by_reason": {
      "conn_rate": 1702,
      "login_rate": 196,
      "per_ip_data_conns": 88
    }
  }
}

观察:auto_bans = 0。原因是限流先于封禁生效——被限流的请求在进入认证逻辑之前就被拒绝,失败计数累积速度受限,因此短时间内表现为令牌桶限流(每分钟自动恢复部分配额)。这是设计取舍:限流对正常用户的误伤更小,持续攻击才会逐步触发封禁。若需要立即阻断某来源,使用 POST /api/guard/ban 手动封禁(会同时断开其在线连接)。

4.5 测试工具自身的一个坑(记录备查)

渗透测试工具在验证「攻击期间正常业务可用性」时一度误报 VULNERABLE,排查发现是工具缺陷:Go 的 http.RequestHost 必须赋值给 req.Host 字段,写进 Header["Host"] 会被静默忽略,导致请求打到了默认 Host 上、命中 404。修复后该用例通过。

记录此事的价值:攻击工具本身也会出错,误报与漏报都要在结论前交叉验证(本例中 404 响应体明确写着「没有匹配的隧道」,与"防护误伤"的假设不符,因此转向排查工具)。


五、审计中发现并修复的问题

#严重级别问题修复
1CRITICALJWT_SECRET 仍是示例值 change_me_to_a_random_string_at_least_32_chars,可被直接用于伪造管理员令牌更换为 32 字节随机值;README 与检查清单强调 openssl rand -hex 32
2HIGH协议帧按声明长度预分配缓冲,存在 1:N 内存放大(4 字节换 1 MiB)readBodyChunked 分块读取 + 回归测试
3LOW静态资源响应缺少 X-Content-Type-Options: nosniff(仅 JSON 响应设置了)移入全局 secureHeadersMiddleware,覆盖全部响应
4LOWSPA 兜底路由匹配任意方法,TRACE 请求返回 200新增 methodAllowlistMiddleware,非白名单方法返回 405

同时补齐的防护能力(属"审计驱动的加固",非缺陷):

  • 控制面连接准入、握手超时、登录限速、认证失败自动封禁;
  • 数据面连接准入、单隧道并发上限、请求头读取超时(消除 Slowloris);
  • bcrypt 并发信号量(消除 CPU 耗尽放大);
  • IP 跟踪表容量上限与空闲回收;
  • Nginx 侧收紧超时、细化限流、方法白名单、黑名单 include;
  • sysctl 抗 SYN flood / 连接跟踪表 / 端口耗尽参数;
  • fail2ban filter 与 jail(应用层识别 → 内核层阻断)。

六、残余风险与建议

#风险级别建议
R1隧道 local_addr 的 SSRF 面:持有客户端 Token 的主体可把公网流量转发到该客户端可达的任意内网地址,借此探测内网MEDIUM① 客户端 Token 按最小权限分发并定期轮换;② 若需强约束,可增加 local_addr 允许网段校验(服务端)或客户端侧目标白名单;③ 内网侧部署出向访问控制
R2NAT 场景下 IP 维度限流失效:经 Docker 端口映射或 Nginx stream 转发后,服务端看到的是网关地址,所有来源被折叠为同一 IPMEDIUM① 控制端口 47800 尽量直接暴露(Linux 下 iptables DNAT 会保留真实源 IP);② 若必须经 Nginx 转发,请看清 deploy/nginx/conf.d/stream/tunnel-stream.conf 中关于 limit_conn 与放宽 GUARD_MAX_CONTROL_CONNS_PER_IP 的说明;③ 云环境优先启用厂商 DDoS 高防
R3单机防护无法抵御大流量带宽型攻击HIGH应用层防护解决的是「连接耗尽」与「应用层洪水」;TB 级流量型攻击必须依赖云厂商高防 IP / CDN / 清洗中心,本项目不替代该能力

其他建议

  1. 管理后台收敛:用 API_ALLOWED_CIDRS 限制到办公网出口,并在 Nginx 层加 allow/deny
  2. TLS 严格校验:生产环境把服务端证书分发给客户端并用 TLS_CA_CERT 校验,避免长期使用 TLS_INSECURE=true
  3. 告警对接:对 GET /api/guardtotal_rejectedbanned_ips 设置阈值告警;Nginx 的 429/444 也应纳入监控;
  4. fail2ban 白名单ignoreip 必须包含 Nginx、监控探针与办公网出口,否则一次误判会导致整个办公网无法访问;
  5. 定期回归:每次版本发布前运行 go run ./tools/pentest -mode all,把安全回归纳入 CI。

七、复现方法

# 1) 部署被测实例(含防护)
docker compose up -d --build

# 2) 准备数据面(创建客户端与 http 隧道,并启动客户端)
#    详见 README「快速开始」

# 3) 运行完整渗透测试
cd tools/pentest
go run . -mode all \
  -api http://127.0.0.1:47801 \
  -control 127.0.0.1:47800 \
  -data 127.0.0.1:48080 \
  -domain app.example.com \
  -user admin -pass '<管理员口令>' \
  -concurrency 150 -duration 10s

# 仅做 DDoS 防护验证(会触发登录限流)
go run . -mode dos -pass '<管理员口令>' -domain app.example.com

# 输出 JSON 便于接入 CI
go run . -mode all -pass '<口令>' -json

退出码:0 表示未发现确认漏洞;2 表示存在确认漏洞(可直接用于流水线卡点)。

注意-mode dos 中的登录爆破用例会耗尽本机来源的登录配额,并对该来源产生限流/封禁。测试后如需立即恢复,可等待 GUARD_BAN_DURATION,或从白名单来源执行,或调用 POST /api/guard/unban


八、防护参数速查

参数默认值作用
GUARD_ENABLEtrue总开关
GUARD_MAX_CONTROL_CONNS4096全局并发控制连接上限(NAT 场景的兜底)
GUARD_MAX_CONTROL_CONNS_PER_IP32单 IP 并发控制连接上限
GUARD_CONTROL_CONN_RATE120/min单 IP 连接建立速率
GUARD_LOGIN_RATE20/min单 IP 登录尝试速率(应用层)
GUARD_HANDSHAKE_TIMEOUT5s未完成登录的连接存活上限;也是数据面请求头读取超时
GUARD_BAN_THRESHOLD / GUARD_BAN_DURATION10 次 / 30min失败窗口内达阈值即封禁
GUARD_MAX_DATA_CONNS / _PER_IP8192 / 128数据面并发连接上限
GUARD_MAX_CONNS_PER_TUNNEL256单隧道并发上限
GUARD_MAX_CLIENTS1000在线客户端总数上限
GUARD_WORK_CONN_RATE600/min单客户端建立工作连接的速率
GUARD_WHITELIST127.0.0.1/32,::1/128不受限制的来源

调参原则:先把全局上限按机器规格定好,再按业务特征收紧单 IP 维度,最后用 GUARD_WHITELIST 放行可信来源。参数过低会误伤正常用户——渗透测试中的 DOS-01c(攻击期间正常业务可用性)就是用来守住这条底线的用例。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论