Repository navigation
Releases: Nu0vo1212/Rust-Frp
Release list
v0.6.6
Nfrp v0.6.6
Rust 实现的内网穿透工具,完整兼容官方 frp 协议(wire protocol v1 / v2,与 frp 0.71.0 实测互通)
本版的唯一重点:Windows 上的 VirtualNet 真的能用了,而且是真机验证过的。
一、Windows VirtualNet(Wintun)
此前 Windows 上 VirtualNet 是明确报错("只实现了 Linux")。现在完成了。
实现
基于 Wintun —— WireGuard 项目的开源 TUN 驱动(Apache-2.0 / GPLv2 双许可)。
选它而不是 TAP-Windows 的理由:
- 内核态驱动,用户态只走共享环形缓冲,吞吐远高于 TAP;
- 只做 L3(IP 包),正好是 VirtualNet 需要的;
- WireGuard for Windows 生产在用,久经考验;
- 运行时不需要静态链接,许可证也宽松。
动态加载:全部通过 LoadLibraryExW + GetProcAddress,
不在链接期依赖 wintun.dll —— 没装驱动的机器程序照常启动,
只有真正用到 VirtualNet 时才报错。
查找顺序:NFRP_WINTUN_DLL → 可执行文件目录 → 当前目录 → 系统路径。
全都找不到时报错可操作(含下载地址、架构要求、放置位置),
而不是一句"加载失败"。
怎么用
- 从 https://www.wintun.net/ 下载对应架构的
wintun.dll
(64 位程序用bin/amd64/wintun.dll) - 放到
nfrp-client.exe旁边,或设环境变量NFRP_WINTUN_DLL指向它 - 以管理员身份运行(创建虚拟网卡需要该权限)
[virtualNet]
serverPort = 7003
autoAssign = true缺少 dll 或没有管理员权限时,报错会写清缺什么、怎么办。
真机验证(三层,都跑过)
| 验证 | 证明什么 |
|---|---|
| 创建网卡 → 配地址 → 回收 | 驱动加载、适配器创建、会话启动、IP 配置、Drop 后网卡消失 |
| ping 由我们应答 | IP 包双向流动:内核 → TUN → 我们 → TUN → 内核 |
| 非法前缀拒绝 | 输入校验 |
数据面那条最有说服力:配 100.127.99.1/24,后台 ping 100.127.99.2,
Windows 内核把 ICMP Echo Request 写进 TUN,我们读出来改造成 Echo Reply 写回去,
ping 显示通了。只通一个方向(只能读或只能写)都不会通,所以这一条同时锁住两个方向。
连跑 3 次稳定通过。
踩到的三个坑(都写进代码注释了)
① netsh interface ipv4 set address 对 Wintun 无效。
实测:命令返回成功,但 show addresses 里只有 DHCP enabled: No,没有 IP。
原因是 Wintun 建的是 L3 隧道适配器,netsh 那条老路径对它不生效。
必须用 IP Helper API —— 这里经 New-NetIPAddress(就是它的封装)。
② IP 地址偏移写错(最隐蔽)。
交换源/目的地址时写成了 (4, 16) —— offset 4 是 IP ID 字段,不是源地址
(源地址在 12)。后果:ID/flags 和目的地址被对调、源地址原地不动,
内核收到直接丢。表现是"读方向通、写方向不通",没有任何报错。
是 hex dump 才看出来的:
请求 45 00 00 3c d0 0f 00 00 80 01 db af 64 7f 63 01 64 7f 63 02
应答 45 00 00 3c 64 7f 63 02 40 01 1b b0 64 7f 63 01 d0 0f 00 00
版本/len ↑ID 被写坏 TTL/proto/ck src 没交换 ↑ID 跑到这
③ "加打印就好了"是假象。
首次带诊断打印的版本"恰好"通过,因为 eprintln 拖了几秒 ——
而网卡建卡后路由表要过一会儿才生效,这期间 ping 的包根本不会进 TUN。
解法不是加打印,而是多轮重试。
平台现状
| 平台 | TUN 后端 | 要求 |
|---|---|---|
| Linux | /dev/net/tun |
root 或 CAP_NET_ADMIN |
| Windows | Wintun | 管理员权限 + wintun.dll |
| macOS | 未实现 | 会明确报错,不假装成功 |
wintun.dll不随发布包分发(第三方二进制,许可证与体积原因),请自行下载。
二、v0.6.5 的内容(本版延续)
如果从 0.6.4 或更早升级,这一版还包含 0.6.5 的重要修复:
OIDC 的 ES256 验签致命 bug
ES256/ES384 之前用 *_ASN1(期望 DER),但 JWS 的椭圆曲线签名是 64 字节裸 r‖s
(RFC 7518 §3.4)。后果:任何真实 IdP 签发的 ES256 token 全部验签失败 ——
而 ES256 正是 Keycloak / Auth0 / Azure AD 的默认算法。
还发现有一条测试向量本身是错的(DER 编码),于是"错误实现 + 错误向量"一起绿。
现已改用 RFC 7515 附录 A.3 的官方权威向量,并加了"DER 必须验不过"的反向门。
四个配置字段真正生效
| 字段 | 说明 |
|---|---|
quicBindPort |
独立 UDP 端口(不再顺带占用 bindPort/udp) |
includes |
客户端配置包含(只取 proxies/visitors,不是配置继承) |
tokenSource |
从文件 / 外部命令取令牌(exec 不经 shell、有 10 秒超时) |
enablePrometheus |
/metrics 开关(默认关闭,对齐官方) |
其他
- 完整协议状态机枚举(3 阶段 × 20 消息 = 60 组合)
- 真实 IdP 端到端测试 9 条(含密钥轮换、恶意 JWKS)
质量门
| 项 | 结果 |
|---|---|
cargo test --workspace(串行) |
✅ 814 通过 / 0 失败 |
Windows 真机测试(--ignored) |
✅ 3 / 3(连跑 3 次稳定) |
cargo fmt --all -- --check |
✅ 干净 |
cargo clippy --workspace --all-targets -D warnings |
✅ 0 告警 |
cargo audit |
✅ exit 0 零漏洞(184 crates) |
兼容性
- 协议零变更:与官方 frp 0.71.0 互通不受影响
- 两处行为变更(都向官方对齐):
/metrics默认关闭,要采集需配enablePrometheus = true- 配了
quicBindPort后不再占用bindPort/udp
- Windows VirtualNet 从"报错"变成"可用"——纯增益
-v仍输出裸0.71.0;-V输出0.6.6
补充:udpPacketSize 实现(同一版本号内追加)
在 v0.6.6 发布后又补上了 udpPacketSize(客户端 + 服务端),版本号保持不变。
udpPacketSize = 1500 # 官方默认值;本工具默认 65507它决定单个 UDP 报文最多能带多少字节,超过就截断。取值夹在 [1, 65507]。
为什么默认与官方不同:官方默认 1500,本工具此前一直用 65507(UDP 协议上限)。
改成 1500 会静默截断现有用户的大 UDP 报文(收得到包、内容少一截),
比报错难查得多。想对齐官方行为的部署显式配 1500 即可。
⚠ crates.io 上的 0.6.6 是较早的构建,不含这个字段(crates.io 不允许
重发同一版本号)。要完整功能请从本 Release 下载二进制,或用
udpPacketSize时改用 Release 产物。
其余字段的对齐状态(如实说明)
已实现:TLS 证书组(含 mTLS)、auth.tokenSource(两端)、includes、
quicBindPort、enablePrometheus、udpPacketSize(两端)
本工具不适用(概念上没有对应,不是"没做"):
pprofEnable—— Go 运行时的 CPU / 堆采样;Rust 用perf/heaptrack在进程外抓sshTunnelGateway—— 官方 frps 内嵌了一个 SSH 服务器,补它等于再写一个 SSH 服务端
仍未做:useEncryption / useCompression(需与 fatedier/golib 的
AES-128-CFB 与 snappy 流字节级对齐,做错会静默破坏与官方 frpc 的互通)、
proxyURL、dnsServer、natHoleStunServer、assetsDir(两端)、
面板 HTTPS(webServer.tls.*)、natholeAnalysisDataReserveHours、httpPlugins。
配了这些字段时启动日志会逐条列出"配了但不会生效"。
已知未解决
plugin_bridge在 Linux 上的竞态(HttpIo::watch_closed取消时丢字节):
已确认是真问题(10 次跑 5 次失败,真实 TCP 链路可复现),
但两次修复尝试都引入 100% CPU 忙等死循环,已回退并记录正确方向
(让"读"只有一个所有者,需重构所有权)。Windows 上稳定。- macOS VirtualNet 未实现(
utun每包多一层 4 字节地址族头) - TSan / Miri 未跑完(云端 nightly 下载未完成,且该机器仅 1 GB 内存)
- 其余配置字段:
useEncryption/useCompression(数据面协议级改动)、
udpPacketSize、dnsServer、proxyURL、pprofEnable、assetsDir、
sshTunnelGateway、httpPlugins、natholeAnalysisDataReserveHours
资产
| 文件 | 说明 |
|---|---|
nfrp-windows-amd64.zip |
Windows amd64(VirtualNet 需自备 wintun.dll) |
nfrp-linux-amd64.tar.gz |
Linux amd64 |
nfrp-linux-arm64.tar.gz |
Linux arm64(全静态,由 CI 交叉编译并在 qemu 下验证) |
nfrp-android-v0.6.6.zip |
Android aarch64 + x86_64 |
nfrp-src-066.tar.gz |
完整源码 |
SHA256SUMS.txt |
各包 sha256 |
SHA256SUMS.txt.sig |
Ed25519 分离签名 |
release.pub |
验签公钥(指纹 763896b5d759c6cbe1f208c3c35c9f71) |
cargo install nfrp-server nfrp-clientcrates.io 三个 crate 均已更新到 0.6.6。
完整记录
v0.6.5
Nfrp v0.6.5
Rust 实现的内网穿透工具,完整兼容官方 frp 协议(wire protocol v1 / v2,与 frp 0.71.0 实测互通)
本版有一个必须升级的理由:修掉了 OIDC 的 ES256 验签缺陷 —— 在那之前,
用 ES256 的 IdP(Keycloak / Auth0 / Azure AD 的默认算法)签发的 token 全部验不过。
另外补齐了 4 个配置字段,并把协议状态机测试从"降级探针"升级为完整枚举。
一、最重要的修复:ES256 验签用了错误的签名格式
问题
pick_algorithm 里 ES256/ES384 用的是 *_ASN1(期望 DER 编码),
但 JWS 的椭圆曲线签名不是 DER。RFC 7518 §3.4:
Concatenate the two octet sequences in the order R and then S.
The resulting 64-octet sequence is the JWS Signature value.
验证侧同样强制:
The JWS Signature value MUST be a 64-octet sequence.
影响
任何真实 IdP 签发的 ES256 token 都会验签失败 —— OIDC 认证在多数生产环境不可用。
(RS256 不受影响,所以用 RS256 的部署一直正常。)
为什么之前没发现
有一条测试向量本身是错的:VEC_EC_SIG 以 0x30 0x44 0x02 0x20 开头,
是 DER 编码 —— 当初用 openssl 生成,而 openssl 默认就输出 DER。
于是「错误实现 + 错误向量」一起错,测试全绿、线上全挂。
教训:自造测试向量只能证明"实现和向量一致",证明不了"和标准一致"。
修复
改用 ECDSA_P256_SHA256_FIXED / ECDSA_P384_SHA384_FIXED(r‖s),
并把测试向量换成 RFC 7515 附录 A.3 的官方权威向量,另加一条反向门
(DER 编码的签名必须验不过)。
二、真实 IdP 端到端测试(新增 9 条)
之前的 27 条 OIDC 测试全是纯函数级(DER 编码、base64url、JWT 分段),
从来没有用真签名跑过一遍 —— 上面的 bug 正是这么漏掉的。
现在在本机起一个最小 HTTP server 扮演 IdP,提供 discovery + JWKS,
用真实 ECDSA P-256 密钥签发 JWT,整条链路真跑:
| 覆盖 | 说明 |
|---|---|
| 主干链路 | discovery → JWKS → ring 验签 |
| 密钥轮换 | IdP 换密钥后 refresh() 能拉到新的 |
| 未知 kid | 找不到 key 必须拒绝,不能放行 |
| 签名篡改 | 改一个字符就拒 |
| 空 JWKS | {"keys":[]} 必须报错,不能当"于是全放行" |
| 畸形 JSON | 非 JSON 要报错 |
| 垃圾坐标 | x/y 非法 base64 时不 panic |
| 缺 jwks_uri | 必须报错 |
| 明文 jwks_uri | 默认拒绝(公钥走明文 = 认证可被绕过) |
三、完整的协议状态机枚举
上一轮只有"降级探针"(喂 2 条跨状态消息看服务端不崩)。现在做真正的枚举:
3 个协议阶段 × 20 条消息 = 60 个组合,每条都喂给真实服务端:
- 阶段:
PreLogin(未登录)/PostLogin(已登录未注册)/Registered(已注册) - 消息:覆盖
FrpMessage全部 20 个变体,含服务端→客户端方向的那些
(客户端发它们就是方向违例,正是要测的输入)
断言三件事:
- 不崩 —— 之后用新连接仍能正常登录并注册代理;
- 不挂住 —— 控制连接必须"要么还活、要么已被关闭",
不能既不响应也不断(那种会话会永久占住服务端槽位); - 不泄漏 —— 断开后该端口上的后端计数归零。
四、四个配置字段真正生效
之前它们只在"未实现字段"清单里告警,现在都真的能用。
quicBindPort(服务端)
配了就开独立 UDP 端口,与 bindPort 无关:
quicBindPort = 7101配了之后不再顺带占用 bindPort/udp(那是旧行为的隐式副作用)。
只依赖 transport_protocol = "quic" 的部署不受影响。
includes(客户端)
includes = ["conf.d/*.toml"]按官方语义:每项是路径或 glob;命中文件只取 proxies 与 visitors 追加,
其它字段(serverAddr/token…)一律忽略 —— 它是"批量追加代理定义",
不是配置继承/覆盖。
细节:glob 语义对齐 Go filepath.Match;相对路径以主配置所在目录为基准;
目录内容排序后加载;合并后重名明确报错。
tokenSource(客户端 + 服务端)
[auth.tokenSource]
type = "file"
[auth.tokenSource.file]
path = "/etc/nfrp/token"或 type = "exec" 跑子进程取 stdout。读到的值去首尾空白
(文件末尾的换行不 trim 就对不上)。
优先级:命令行 -t > tokenSource > token。
exec 的安全处理:不经 shell(command 与 args 分开传)、
10 秒超时后强杀(否则卡住的子进程会让客户端永远起不来)、
启动日志打出令牌来源、取到的值绝不进日志。
enablePrometheus(服务端)
enablePrometheus = true默认关闭(与官方一致)。此前 /metrics 是无条件开放的 ——
指标含业务信息(在线客户端数、代理名、流量字节),"只想开个面板"的部署
不该默认多暴露一份数据面。
/api/healthz 不受影响 —— 健康探针永远可用。
质量门
| 项 | 结果 |
|---|---|
cargo test --workspace(串行) |
✅ 811 通过 / 0 失败(上轮 785,+26) |
cargo fmt --all -- --check |
✅ 干净 |
cargo clippy --workspace --all-targets -D warnings |
✅ 0 告警 |
cargo audit |
✅ exit 0 零漏洞(184 crates) |
| 退回验证(ES256) | ✅ 退回变红 / 还原变绿 / 文件干净 |
兼容性
- 协议零变更:与官方 frp 0.71.0 互通不受影响
- OIDC 修复是纯增益:以前 ES256 完全不能用,现在能用(RS256 不受影响)
- 两处行为变更(都向官方对齐,已写进示例配置):
/metrics默认关闭,要采集需配enablePrometheus = true- 配了
quicBindPort后不再占用bindPort/udp
- 其余新配置项都是"配了才生效",不配时行为与之前完全一致
-v仍输出裸0.71.0;-V输出0.6.5
已知未解决
plugin_bridge在 Linux 上的竞态(HttpIo::watch_closed取消时丢字节):
已确认是真问题(10 次跑 5 次失败,真实 TCP 链路可复现),
但两次修复尝试都引入 100% CPU 忙等死循环,已回退并在代码里记录正确方向
(让"读"只有一个所有者,需重构所有权)。Windows 上稳定。- Windows VirtualNet 仍未实现(需 Wintun 驱动 + 管理员权限 + 真机验证)
- TSan / Miri 本轮未跑完(云端 nightly 下载未完成)
- 其余配置字段:
useEncryption/useCompression(数据面协议级改动)、
udpPacketSize、dnsServer、proxyURL、pprofEnable、assetsDir、
sshTunnelGateway、httpPlugins、natholeAnalysisDataReserveHours
资产
| 文件 | 说明 |
|---|---|
nfrp-windows-amd64.zip |
Windows amd64 |
nfrp-linux-amd64.tar.gz |
Linux amd64 |
nfrp-linux-arm64.tar.gz |
Linux arm64(全静态) |
nfrp-android-v0.6.5.zip |
Android aarch64 + x86_64 |
nfrp-src-065.tar.gz |
完整源码 |
SHA256SUMS.txt |
各包 sha256 |
SHA256SUMS.txt.sig |
Ed25519 分离签名 |
release.pub |
验签公钥(指纹 763896b5d759c6cbe1f208c3c35c9f71) |
cargo install nfrp-server nfrp-clientcrates.io 三个 crate 均已更新到 0.6.5。
完整记录
本轮详细报告:SECURITY-FIXES-ROUND15.md
v0.6.4
Nfrp v0.6.4
Rust 实现的内网穿透工具,完整兼容官方 frp 协议(wire protocol v1 / v2,与 frp 0.71.0 实测互通)
本版处理第三方渗透测试反馈的三项 HTTP 语义问题,并修掉一个长期存在的测试 flake。
无配置变更、协议零变更。
一、HTTP 语义修正(渗透测试 PT-03 / PT-05 / PT-07)
PT-03 · HEAD 不再返回响应体
修复前:HEAD / 返回 200 + 10368 字节 HTML,HEAD /api/status 返回 659 字节。
RFC 9110 §9.3.2 要求 HEAD 只回响应头、不回 body。
危害有限(不是漏洞,是协议违规),但会带来实际问题:
- 用 HEAD 做健康检查的监控会白白拉一遍完整页面
- 某些缓存 / 代理对 HEAD 的 body 处理不一致,可能污染缓存
修法:HEAD 时不写 body,但 Content-Length 仍按真实长度发送 ——
那正是 HEAD 的语义(告诉对端 GET 会拿到多少字节,但不真的发)。
PT-07 · 只读端点不再接受 POST
修复前:POST /api/status 返回 200 + 完整状态 JSON。
根因:未匹配管理路径的 POST 会落进只读分支,被当成普通路径处理返回 200。
那既违反直觉,也让只按「状态码 200 就算成功」的客户端 / 监控误判。
修法:未匹配的 POST 一律 405,并带 Allow: GET, HEAD。
用 405 而非 404 —— 路径确实存在,只是方法不对,这是 HTTP 语义要求。
PT-05 · 新增安全响应头
面板此前一个安全头都没有。现在所有响应统一带:
| 头 | 作用 |
|---|---|
X-Content-Type-Options: nosniff |
禁止浏览器嗅探 MIME |
X-Frame-Options: DENY |
挡点击劫持(把面板套进攻击者页面诱导管理员点「踢出」) |
Referrer-Policy: no-referrer |
面板 URL 不通过 Referer 泄露给外部站点 |
Content-Security-Policy |
default-src 'none' + 内联脚本样式 + frame-ancestors 'none' |
为什么不用 HSTS:面板默认是明文 HTTP。对明文响应发 Strict-Transport-Security
会被浏览器直接忽略(RFC 6797 §7.2),发了没用;更糟的是会让人误以为已经上 HTTPS。
CSP 里为什么允许 'unsafe-inline':面板 HTML 的脚本与样式都是内联的单文件。
在自包含页面上,真正有价值的是 default-src 'none'(禁止一切外域请求、图片、
字体、frame),那部分已经生效。
二、测试 flake 消除
server/tests/e2e.rs 的 free_port_both() 存在端口竞态:
let p = free_port(); // TCP listener 已 drop
if UdpSocket::bind(("127.0.0.1", p)).is_ok() { // ← 中间有空窗
return p;
}两次 bind 之间没有保护,并行测试时另一个线程可能正好抢走这个号,
64 次全撞车并 panic。
修法:TCP 与 UDP 同时持有校验,两个都绑上才算数;
校验通过后先登记再释放。
验收:cargo test --workspace -j 2 连跑 5 次全绿。
三、依赖修复:tokio 1.54 的 API 弃用
上一版把 tokio 从 1.53 升到 1.54,新版本把 AsyncFd::new 标记为 deprecated
(理由:unsound due to lack of I/O safety,tokio PR #8587)。
common/src/vnet/tun_linux.rs 用到了它。这处只在 Linux 下编译,
Windows 上的 clippy 完全看不到 —— 是典型的「本地全绿、云端红」问题。
修法:改用官方替代品 unsafe fn register,并在代码里写明所有权保证
(fd 由 OwnedFd 独占、失败时 into_parts() 取回并 close,不泄漏)。
不压 #[allow(deprecated)] —— 那样只是让编译过去,真正要修的是 I/O safety 本身。
四、一次失败的修复尝试(如实记录)
排查中在 Linux 上发现 plugin_bridge 的 6 个端到端测试间歇失败,
且真实 TCP 链路也能复现,确认是产品代码的真实竞态:
- 同一测试连跑 10 次:5 通过 / 5 次 5 秒超时
- 失败时插件收到连接,但请求从未转给上游
根因:HttpIo::watch_closed() 用 read(&mut chunk) 读进局部数组,
future 在 select! 里被取消时那批字节随之消失。丢掉的可能正是请求体开头。
两次修复尝试都失败了:
- 改成
read_buf直接读进self.buf+ 缓冲非空立即返回
⇒ 调用方的loop { select! }变成 100% CPU 忙等死循环
(云端 4 进程各烧 20 分钟 CPU,本地单进程 1288 秒) - 再在循环里加短路判断 ⇒ 仍然空转
最终处理:回退,并在代码注释里完整记录失败过程与正确方向。
判断依据:宁可保留一个只在 Linux 上间歇出现的缺陷,
也不引入一个会让 CPU 跑满的死循环 —— 后者影响面大得多。
正确方向(已写进注释,留给后续):让「读」只有一个所有者 ——
单独一个读协程独占 stream,把字节推进共享队列,等待方只等队列信号。
这需要重构 HttpIo 的所有权结构。
影响面:Windows 上 21/21 稳定通过;Linux 上仅 http2* / https2*
的端到端用例间歇失败,其余 780 个测试与 28 项冒烟全部正常。
质量门
| 项 | 结果 |
|---|---|
cargo test --workspace(串行) |
✅ 785 通过 0 失败(v0.6.3 为 766,本版 +19) |
cargo test --workspace -j 2 × 5 次 |
✅ 5/5 全绿(flake 已消除) |
cargo fmt --all -- --check |
✅ 干净 |
cargo clippy --workspace --all-targets -D warnings(Windows) |
✅ 0 告警 |
cargo clippy(Linux 云端,覆盖 Windows 编不到的代码) |
✅ 0 告警 |
cargo audit |
✅ exit 0 零漏洞(184 crates) |
cargo deny check |
✅ advisories / bans / licenses / sources 全 ok |
| 端到端冒烟 | ✅ 28 / 28 |
| 退回验证(三项修复) | ✅ 3 / 3 |
新增 6 条测试,全部走真实 socket + 真实 HTTP:HEAD 不回 body、
GET 仍回 body(对照)、只读端点 POST 返回 405、四类响应都带安全头、
401 的 WWW-Authenticate 没被挤掉、安全头常量本身合法。
兼容性
- 无配置变更:本版不涉及任何配置项
- 协议零变更:与官方 frp 0.71.0 互通不受影响
- HTTP 行为变化两处(均为修正,符合 RFC):
HEAD不再返回 body- 只读端点的
POST从 200 变成 405
- 若有用例依赖「POST /api/status 返回 200」,请改用 GET
frps -v/frpc -v仍输出裸0.71.0;-V输出0.6.4
资产
| 文件 | 说明 |
|---|---|
nfrp-windows-amd64.zip |
Windows amd64 |
nfrp-linux-amd64.tar.gz |
Linux amd64 |
nfrp-linux-arm64.tar.gz |
Linux arm64(全静态) |
nfrp-android-v0.6.4.zip |
Android aarch64 + x86_64 |
nfrp-src-064.tar.gz |
完整源码 |
SHA256SUMS.txt |
各包 sha256 |
SHA256SUMS.txt.sig |
Ed25519 分离签名 |
release.pub |
验签公钥(指纹 763896b5d759c6cbe1f208c3c35c9f71) |
校验
python scripts/release.py verify --dir .其他安装方式
cargo install nfrp-server nfrp-clientcrates.io 上三个 crate 均已更新到 0.6.4:
nfrp-common ·
nfrp-server ·
nfrp-client
完整记录
SECURITY-FIXES-ROUND14.md—— 本轮SECURITY-FIXES-ROUND4..13.md—— 前几轮SECURITY-FIXES.md—— 第一~三轮汇总
累计十轮安全审计 + 两轮外部渗透测试,146 项修复,全部带回归测试。
Nfrp v0.6.3
Nfrp v0.6.3
Rust 实现的内网穿透工具,完整兼容官方 frp 协议(wire protocol v1 / v2,与 frp 0.71.0 实测互通)
本版把 group 负载均衡 / KCP 会话上限 / 健康检查 三项特性逐条对照官方 frp 0.71.0 的实现做了对齐,
并修掉一个调度缺陷。无破坏性变更,老配置文件一个字节都不用改。
最重要的发现:官方用的是轮询,不是最少连接
官方 server/group/base.go 的 worker 把连接丢进共享 acceptCh:
acceptCh <- c // 每个代理各自 Accept() 同一个 channellistener.go 里哪个代理先 Accept() 谁拿到 —— 这就是无状态轮询。
本项目原来用的是「最少连接数」。它的推理没错(能向空闲后端倾斜),
但和官方行为不一致。本版把默认改成轮询。
一、负载均衡策略可配(默认对齐官方)
新增服务端配置项:
lb_policy = "roundrobin" # 默认| 值 | 行为 |
|---|---|
roundrobin(默认) |
严格 1/N 轮询,与官方 frp 完全一致 |
leastconn |
优先给在途连接最少的后端,多后端能力不均时更优 |
默认选轮询是刻意的:把「谁不健康」交给健康检查去处理(不健康就摘掉后端),
调度器只做无状态均分 —— 运维上更容易理解和预测。
leastconn 作为超集能力保留。调度完全发生在服务端内部,
开它不影响与官方客户端的互通。依赖旧行为的部署可显式切换。
二、修掉一个调度缺陷(系统性偏袒)
改造时发现旧 pick 有真实缺陷:
// 旧代码
let mut best_load = self.members[0].inflight(); // 固定从 0 号初始化
let start = cursor.fetch_add(1) % n;
for k in 1..n {
let i = (start + k) % n; // 从 start+1 扫,跳过 start
}
let start_load = self.members[start].inflight(); // 事后补判,且用 <=best_load 初值来自 members[0],但循环从 start+1 开始 ——
n >= 3 且 start != 0 时,部分后端被系统性偏袒。
改为从 start 起扫满一整圈、初值取 members[start]。对两种策略都正确。
三、KCP 会话上限:可配 + 安全下限
kcp_max_sessions = 1024 # 默认KCP 每会话约占 15.3 KB,这个值直接决定内存上界:值 × 15.3 KB。
| 值 | 峰值内存 | 适用 |
|---|---|---|
| 1024(默认) | ≈ 15 MB | 绝大多数部署 |
| 4096 | ≈ 62 MB | 数千客户端的单实例 |
| 16384 | ≈ 250 MB | 需确认内存充足 |
新增安全下限 64:配成 0 或极小值会被自动抬起并告警。
理由——这个值直接决定 KCP 是否可用,配成 0 等于关掉 KCP,
而进程不报错。那种静默失效比拒绝启动难排查得多。
超大规模部署的正确做法
不是无限调大这个值,而是:
- 用 TCP 或 QUIC 承载控制连接(KCP 只在两者都走不通时才需要)
- 或在前面挂负载均衡,把客户端拆到多个 frps 实例
这一点已写进 frps.toml 的注释,避免误以为「调大就好」。
四、健康检查:三处对齐官方
① HTTP 探测改为严格 2xx
官方 client/health/health.go:
if resp.StatusCode/100 != 2 {
return fmt.Errorf("... StatusCode is [%d] not 2xx", resp.StatusCode)
}我们原来是 code < 400,把 3xx / 404 也算健康。真实差别:
- 服务配了强制 HTTPS 跳转 ⇒ 探测永远拿 301 ⇒ 旧判据认为一直健康,但端口没在提供服务
- 反向代理返回 404 时同理
② 新增 httpHeaders 支持
官方 HealthCheckConfig.HTTPHeaders。此前不支持时,
需要鉴权的健康检查端点一律探测失败——表现为「服务明明活着,客户端判定不健康、把流量摘掉」。
[[proxies.healthCheck.httpHeaders]]
name = "Authorization"
value = "Bearer secret-token"③ Host 头语义对齐
官方 req.Host = monitor.header.Get("Host") —— 配置的 Host 覆盖默认值。
现在配了 Host 就用它,没配才用兜底值。
④ 顺带加固:HTTP 头注入防护
官方用 Go 的 http.Header(库会拒绝非法值)。我们是手写请求,
必须自己挡 —— 否则含 \r\n 的值能插入任意头、甚至拼出第二条请求(请求走私)。
现在头名与头值里的 CR/LF 全部剥掉。
质量门
| 项 | 结果 |
|---|---|
cargo test --workspace(串行) |
✅ 766 通过 0 失败(v0.6.1 为 757,本版 +9) |
cargo fmt --all -- --check |
✅ 干净 |
cargo clippy --workspace --all-targets -D warnings |
✅ 0 告警 |
cargo audit |
✅ exit 0 零漏洞(184 crates) |
| 端到端冒烟 | ✅ 28 / 28 |
新增测试覆盖:默认策略是轮询、最少连接确实倾斜、策略字符串解析、
KCP 下限生效、httpHeaders 解析、探测请求构造、头注入防护。
兼容性
- 无破坏性变更:所有新配置项都有默认值
- 协议零变更:与官方 frp 0.71.0 互通不受影响
- 默认行为变化一处:group 负载均衡从「最少连接」改为「轮询」,
这是向官方对齐。需要旧行为可配lb_policy = "leastconn" - 租户隔离(第六轮 D-1 高危修复)完整保留
frps -v/frpc -v仍输出裸0.71.0;-V输出0.6.3
资产
| 文件 | 说明 |
|---|---|
nfrp-windows-amd64.zip |
Windows amd64 |
nfrp-linux-amd64.tar.gz |
Linux amd64 |
nfrp-linux-arm64.tar.gz |
Linux arm64(全静态) |
nfrp-android-v0.6.3.zip |
Android aarch64 + x86_64 |
nfrp-src-v0.6.3.tar.gz |
完整源码 |
SHA256SUMS.txt |
各包 sha256 |
SHA256SUMS.txt.sig |
Ed25519 分离签名 |
release.pub |
验签公钥(指纹 763896b5d759c6cbe1f208c3c35c9f71) |
校验
python scripts/release.py verify --dir .其他安装方式
cargo install nfrp-server nfrp-clientcrates.io 上三个 crate 均已更新到 0.6.3:
nfrp-common ·
nfrp-server ·
nfrp-client
crates.io 版本与 Release 版本相互独立(crates.io 不允许重发同一版本号)。
完整记录
SECURITY-FIXES-ROUND13.md—— 本轮(特性对齐)SECURITY-FIXES-ROUND4..12.md—— 前几轮安全审计SECURITY-FIXES.md—— 第一~三轮汇总
累计十轮安全审计修复 143 项,全部带回归测试与退回验证。
Nfrp v0.6.1
Nfrp v0.6.1
Rust 实现的内网穿透工具,完整兼容官方 frp 协议(wire protocol v1 / v2,与 frp 0.71.0 实测互通)
本版由 Shannon(Keygraph 的自主 AI 渗透测试器)审计驱动。
核对它的 13 条发现后:10 条无需改动(7 条历史已修、3 条是为对齐官方协议故意保留),
3 条是真新发现,全部修复。
产品行为零变更,可直接替换二进制升级。
三条修复
1. 面板管理操作完全没有审计记录
audit::kind::ADMIN_ACTION 这个常量定义了但全仓零使用 ——
面板的增 / 删 / 踢三个操作一条审计都没记,而 PROXY_ADD / PROXY_REMOVE
只在"客户端自己注册"那条路上记录。
审计和日志是两条不同的链:日志会轮转/被删,[audit] 的 JSONL 才是事后追责用的。
踢人不可回滚(客户端所有隧道立刻失效),只留日志等于查不到谁在什么时候踢了谁。
修:add_proxy / remove_proxy / kick 三处都补 ADMIN_ACTION,
记录 client / target / detail(含操作类型与理由)。
2. forget_session 写好了但没接上
forget_session 在三层都有实现(security.rs / guard.rs / oidc.rs),
但除了一处单测,全仓没有任何生产调用点 ——
OidcVerifier::subjects(run_id -> sub 表)每个 OIDC 会话泄漏一条,直到进程重启。
修:在 ClientGuard::drop 里调用(与 registry.remove() 同一位置)。
放在 Drop 是为了覆盖所有退出路径(正常断开、被踢、panic 展开),不会漏。
3. 面板 catch 分支的 innerHTML 没转义(加固)
} catch(e) {
document.getElementById('cards').innerHTML = '<div class="empty">读取状态失败:'+e+'</div>';
}今天不可利用:e 是本地 JS 的 Error(fetch 或 JSON.parse 失败),
不是服务端返回的文本,内容由浏览器生成、攻击者控制不了。
但这是第五轮 H4(面板存储型 XSS)漏掉的路径 —— 那一轮修了 table() / card(),
唯独没覆盖这条 catch 分支。它违反了本文件"动态内容一律过 esc()"的约定,
而安全性只依赖"e 永远不含远端文本"这个隐含假设。
修:esc(e)。
为什么另外 3 条"高危"没有改
Shannon 把下面三条报成 HIGH/MEDIUM,我核对后故意保留 ——
它们不是设计缺陷,而是**"必须与官方 frp 互通"这个约束的产物**:
| 发现 | 为什么不能改 |
|---|---|
| 客户端 TLS 默认跳过服务端证书校验 | 官方 frpc 就是 InsecureSkipVerify。改成默认校验 ⇒ 官方 frpc 全部连不上,直接毁掉项目的存在意义 |
| v1 通道 PBKDF2-HMAC-SHA1 只有 64 轮 | 官方 golib v0.8.2 的固定值 |
| v1 的 token 证明用 MD5 | frp 协议规定的 privilege_key = md5(token + timestamp),这是线路格式 |
Shannon 看不到"必须与官方互通"这个约束,所以把协议兼容必需的设计当成了缺陷。
质量门
| 项 | 结果 |
|---|---|
cargo test --workspace(串行) |
✅ 753 通过 0 失败(v0.6.0 → 753,本版 +2 条测试) |
cargo fmt --all -- --check |
✅ 干净 |
cargo clippy --workspace --all-targets -D warnings |
✅ 0 告警 |
cargo clippy(Linux 云端,覆盖 Windows 编不到的代码) |
✅ 0 告警 |
cargo audit |
✅ exit 0 零漏洞(184 crates) |
| 端到端冒烟 | ✅ 28 / 28 |
| 退回验证 | ✅ 3 / 3(退掉修复后测试必须变红) |
兼容性
- 无配置文件变更
- 无协议变更
- 无行为变更(审计未启用时这些代码不产生任何输出)
frps -v/frpc -v仍输出裸0.71.0,与官方 frpc 0.71.0 互通不变- 直接替换二进制即可升级
资产
| 文件 | 说明 |
|---|---|
nfrp-windows-amd64.zip |
Windows amd64 |
nfrp-linux-amd64.tar.gz |
Linux amd64 |
nfrp-linux-arm64.tar.gz |
Linux arm64(全静态) |
nfrp-android-v0.6.1.zip |
Android aarch64 + x86_64 |
SHA256SUMS.txt |
各包 sha256 |
SHA256SUMS.txt.sig |
Ed25519 分离签名 |
release.pub |
验签公钥(指纹 763896b5d759c6cbe1f208c3c35c9f71) |
校验方式
python scripts/release.py verify --dir .完整修复记录
SECURITY-FIXES-ROUND12.md—— 本轮(Shannon 审计)SECURITY-FIXES-ROUND4..11.md—— 前几轮SECURITY-FIXES.md—— 第一~三轮汇总
累计十轮安全审计修复 140 项,全部带回归测试。
Nfrp v0.6.0
Nfrp v0.6.0
Rust 实现的内网穿透工具,完整兼容官方 frp 协议(wire protocol v1 / v2,与 frp 0.71.0 实测互通)
本次为测试健壮性版本:修复了在 Linux 上会导致 cargo test 失败的一批跨平台测试缺陷,
其中最有价值的一项是照官方 frp 的做法重构了 HTTP 管理界面的测试结构。
产品行为零变更,可直接替换二进制升级。
主要改动
1. HTTP 管理界面测试改为「纯逻辑」(照官方 frp 的做法)
原先测 client/src/web.rs 的辅助函数 handle_over_socket 会开真实 TCP socket、
tokio::spawn 服务端、客户端再 connect 过去 —— 这在 Linux 上自己造出了一个竞态:
| 跑法 | 改前 | 改后 |
|---|---|---|
web::tests 连跑 8 次(云端 Linux) |
0/6 通过 | ✅ 8/8 通过 |
| 4 个关键测试单独跑 | 全红,各卡满 5.00s | ✅ 全绿,各 0.00s |
官方怎么做的(frp-0.71.0/server/http/controller_v2_test.go):
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, req) // 直接调 handler,内存 recorder更彻底的写法(pkg/plugin/client/http_proxy_test.go)用 net.Pipe(),纯内存管道。
我们的改法:把 handle 里与 socket 无关的判定逻辑抽成纯函数 decide(),
handle 只剩「读请求 → decide → 发送」三步;测试直接调 decide()。
判定顺序与内容逐字未变:
/api/healthz 免鉴权 → Basic Auth 401 → Origin 403 → CSRF 头 403 → route。
2. logfile 测试用了 Windows 专有的错误路径
原测试用 Z:/nfrp-no-such-drive/x.log 构造"打不开的路径"。
Windows 上盘符不存在所以打不开;Linux 上 Z:/... 是合法的相对路径
(名为 Z: 的目录),create_dir_all 会真的建目录成功 ⇒ 断言失败。
改成「父路径是一个已存在的文件」—— 两个平台都必然 ENOTDIR。
3. tun_linux 测试缺 tokio runtime
#[test] 下调用 Tun::create,在有权限环境(root + /dev/net/tun)会走到
AsyncFd::new,没有 reactor 时 panic(no reactor running)。
改为 #[tokio::test]。
4. dial_server 测试的 accept 只等一次
tokio::spawn(async move { let _ = l.accept().await; }); // ← 只 accept 一次测试会拨两次号,第二次时 listener 已 drop ⇒ Connection refused(Linux 上稳定复现)。
改成常驻 accept 循环(与产品 web::run / serve.rs 一致)。
5. 附带:服务端 task 的 panic 不再被吞掉
let _ = tokio::time::timeout(2s, srv).await; 会把 JoinError(task panic)丢弃,
导致测试只报"响应为空"、看不到任何线索。现在区分「task 异常 / 超时 / 读取超时」并给出明确信息。
质量门
| 项 | 结果 |
|---|---|
cargo test --workspace(本地串行) |
✅ 751 通过 0 失败 |
cargo fmt --all -- --check |
✅ 干净 |
cargo clippy --workspace --all-targets -D warnings(Windows) |
✅ 0 告警 |
cargo clippy(Linux 云端,覆盖 Windows 编不到的代码) |
✅ 0 告警 |
cargo audit |
✅ exit 0 零漏洞(184 crates) |
| 端到端冒烟 | ✅ 28 / 28 |
云端 web::tests × 8 |
✅ 8/8 |
云端 logfile / tun_linux / dial_server |
✅ 12 / 3 / 3-of-3 |
兼容性
- 无配置文件变更
- 无协议变更
- 无行为变更(
decide只是把handle里的判定逻辑原样搬出) frps -v/frpc -v仍输出裸0.71.0,与官方 frpc 0.71.0 互通不变- 直接替换二进制即可升级
未解决(如实记录)
云端 Linux 上仍有 6 个测试偶发失败,判定为「真实 socket 依赖 + 环境敏感」:
| 测试 | 通过率(云端) |
|---|---|
plugin_bridge::端到端_http2http_… |
3/5 |
plugin_bridge::端到端_http2https_… |
1/5 |
plugin_bridge::端到端_https2https_… |
4/5 |
plugin_bridge::上游悬挂时…释放上游连接 |
3/5 |
p2p::full_p2p_over_rendezvous |
8/10 |
p2p::fake_provider_without_secret_is_rejected |
偶发 |
这些测试必须依赖真实网络:dial_upstream() 硬编码 TcpStream::connect,
p2p 打洞本质就是 UDP socket 交互 —— 官方 net.Pipe() 的思路套不上。
它们在 Windows 与本地 Linux 上稳定通过。
尝试过并失败的方案(都已回滚):mini_rendezvous 改常驻循环(A/B 对照无改善)、
全部改 multi_thread runtime、加 yield_now()。
另有一条只记录、未下结论的产品观察:p2p 打洞对 window(端口预测)参数敏感 ——
window=4 时 QUIC 8/10、KCP 3/10;window=0 时两者均 10/10。
资产
| 文件 | 说明 |
|---|---|
nfrp-windows-amd64.zip |
Windows amd64(frps.exe / frpc.exe + 示例配置 + 文档) |
nfrp-linux-amd64.tar.gz |
Linux amd64 |
nfrp-linux-arm64.tar.gz |
Linux arm64(全静态) |
nfrp-android-v0.6.0.zip |
Android aarch64 + x86_64 |
SHA256SUMS.txt |
各包 sha256 |
SHA256SUMS.txt.sig |
Ed25519 分离签名 |
release.pub |
验签公钥(指纹 763896b5d759c6cbe1f208c3c35c9f71) |
校验方式
python scripts/release.py verify --dir .或用 OpenSSL 独立复验:
python - <<'PY'
import pathlib
hexline = lambda p: [l for l in pathlib.Path(p).read_text().splitlines()
if l and not l.startswith('#')][0].strip()
pathlib.Path('release.pub.der').write_bytes(
bytes.fromhex('302a300506032b6570032100' + hexline('release.pub')))
pathlib.Path('sums.sig.bin').write_bytes(
bytes.fromhex(hexline('SHA256SUMS.txt.sig')))
PY
openssl pkeyutl -verify -pubin -inkey release.pub.der -keyform DER -rawin \
-in SHA256SUMS.txt -sigfile sums.sig.bin
# 期望输出:Signature Verified Successfully完整修复记录
SECURITY-FIXES-ROUND11.md—— 本轮(测试健壮性)SECURITY-FIXES-ROUND10.md—— 第十轮(全量复扫 + 体积性能实验)SECURITY-FIXES-ROUND9.md—— 第九轮(ACL 三条缺口 / KCP 会话表)SECURITY-FIXES.md—— 第一~三轮汇总SECURITY-FIXES-ROUND4..8.md—— 第四~八轮
累计九轮安全审计修复 137 项,全部带回归测试。
Nfrp v0.5.10
NFrp v0.5.10
全量复扫 + 优化版:对 v0.5.9 的正式发布产物做全量安全复扫(第四~九轮全部电池逐项重跑 + 质量门),
0 条新增漏洞;8 个曾变红的点位全部分诊为审计探针/编排器自身缺陷并修复(产品代码不受影响)。
体积侧:12 变体的最大无损候选可缩 −8.6%,但均有行为代价 —— 遵照"不得影响任何功能"的红线不采用;
性能侧:数据面补行为中性的 TCP_NODELAY,基准与 v0.5.9 持平。
安全复扫(第十轮)
- 复扫对象 = v0.5.9 发布产物(与发布包逐字节对应的二进制快照);约 100 项电池逐项重跑 + 质量门全绿。
- 0 条新增产品漏洞;v0.5.9 的 9 条修复 + 1 条加固在复扫中全部保持生效。
- 8 个曾变红的点位全部分诊为探针/编排器缺陷并修复:清理规则误杀主脚本、e1 两组判据校准、
e8 哨兵独立化、c03 判据翻转 + 新增会话键判据、r6-e2e 编排器重写(真 KCP 端到端)、
r5-h1 / r5-h3 元验证锚点更新与退出码强化。完整记录见SECURITY-FIXES-ROUND10.md。 - ★ 工具层面的附带发现:历轮 KCP 测试的"基线客户端连上"实际只建立了套接字(未完成登录)——
本轮新编排器完成真正的 KCP 端到端(登录 → 代理注册 → 1020B 回显,6/6)。产品行为正确。 - 元验证脚本补上严格退出码:此前"没抓到缺陷"也返回 0(静默假绿),现在未抓到即非零退出。
体积
- 12 变体实验(独立源码副本,绝不触碰本体):最大无损候选 combo2 = −8.62% / −8.41%,
但均有行为代价(日志"模块定向语法"失效 / CLI 无彩色与拼写建议)——遵照红线全部不采用。 - ⇒ 0.5.10 与 0.5.9 的二进制尺寸完全相同(Windows:server 3,439,104 B / client 3,494,400 B)。
- 未采用项参考:envstub2 −7.90%/−7.71%、clapmin −0.60%、lld −0.12%;
hot3 / hotc 反而 +8~10%(体积换不到性能);buf512 ±0 且无稳定收益。
性能
- 数据面 accept 入口补
TCP_NODELAY(行为中性;与 vhost / vnet 等入口口径一致),
受益场景为请求-响应式小包流量(HTTP API、RPC 等),对批量吞吐无副作用。 - 候选实验(未采用):hot3 / hotc / buf512 在交错复测下无稳定收益(背景负载 ~35%、噪声 ±20%)
⇒ 不采用 —— 不涨体积、不增内存。 - 终版同机对照(0.5.9 发布件 vs 0.5.10,交错 4 轮 × 4 迭代):中位吞吐 453 vs 482 MB/s、16B 往返延迟 p50 均在 0.13
0.22 ms —— 差异处于本机噪声带(±2040%)内,无可靠差异;该改动为行为中性选项,不构成收益承诺。
兼容性
- 无配置变更、无行为变更(除 TCP 套接字选项);
frps -v/frpc -v仍输出裸0.71.0。 - 与官方 frpc 0.71.0 互通实测继续通过(复扫电池含 interop 项)。
质量
cargo test --workspace:732 通过 0 失败(7 个 target;本版零测试改动)。cargo fmt --check干净;clippy -D warnings0 告警(含云端 Linux 侧对cfg(linux)代码的检查)。cargo audit:exit 0 零漏洞。- 全量复扫电池约 100 项全绿;Windows / Linux amd64 / Linux arm64 / Android 四类构建全部通过。
校验
SHA256SUMS.txt—— 各包 sha256SHA256SUMS.txt.sig—— Ed25519 分离签名(注释行 + hex 签名)release.pub—— 验签公钥(注释行 + hex 公钥;指纹763896b5d759c6cbe1f208c3c35c9f71)
用仓库自带的零依赖工具验(推荐,只需 Python 3):
python scripts/release.py verify --dir .需要用 OpenSSL 独立复验的话,先把公钥与签名转成 OpenSSL 格式
(两者都是「注释行 + hex」的紧凑格式,OpenSSL 不能直接读):
python - <<'PY'
import pathlib
hexline = lambda p: [l for l in pathlib.Path(p).read_text().splitlines()
if l and not l.startswith('#')][0].strip()
# 公钥小 hex -> SPKI DER
pathlib.Path('release.pub.der').write_bytes(
bytes.fromhex('302a300506032b6570032100' + hexline('release.pub')))
# 签名 hex -> 64 字节原始签名
pathlib.Path('sums.sig.bin').write_bytes(
bytes.fromhex(hexline('SHA256SUMS.txt.sig')))
PY
openssl pkeyutl -verify -pubin -inkey release.pub.der -keyform DER -rawin \
-in SHA256SUMS.txt -sigfile sums.sig.bin
# 期望输出:Signature Verified SuccessfullyNfrp v0.5.9
NFrp v0.5.9
第九轮安全审计的修复版本,共 9 条(4 中危 + 1 中危·功能回归 + 4 低危)
- 1 条预防性加固。任务书是 v0.5.8 发版总结自己写下的
"仍未系统审计"清单:[acl]未覆盖的三条路径(UDP 代理端口、xtcp 牵线 UDP、
管理面板)与资源耗尽/并发/传输层/客户端入口六面 —— 本轮六面全部系统覆盖。
中危
1. UDP 代理端口不受 [acl] 约束(E9-0)
UDP 代理没有别的防线(无连接、无需认证),ACL 是唯一的来源管控。
实测:同一条 ACL 让控制面登录被拒(证明 ACL 在起作用),而从被 deny 的地址
发 UDP 却被照常转发(内网服务收包 1 → 2,唯一变量是源地址)。
修法:recv_from 后、转发前 check_ip,拒绝时静默丢弃(且不记入回写白名单)。
12 / 12 判据全过。
2. KCP 会话表无上限 —— 25 字节换 15 KB 内存(放大 312×)(C9-1)
对任意 UDP 源地址无条件建会话、每条 spawn 一个 task,且无条数上限;
原注释"1:1 占用、非放大"只算了会话里堆的数据,漏掉会话本身的开销。
实测:757 KB 上行把内存推到 238 MB(严格线性、无拐点)。
修法:MAX_KCP_SESSIONS = 1024,超限丢弃新会话(不动已有会话、
不按源 IP 限以免误伤 NAT 后多客户端);丢弃日志走折叠(防未认证对手刷日志)。
元验证:灌 964 KB(比修复前更多) ⇒ 峰值 238 → 23.7 MB(放大 312× → 17×)。
(前提:需显式启用 kcpBindPort。)
3. 未认证 yamux 凭空开流 —— 12 字节换 9.5 KB(放大 ≈800×)(C9-3)
未认证连接对每条 mux 流无条件 spawn,任务在 spawn 那一刻即分配 ~9 KB;
yamux 没有"每会话流上限"(>512 只回 GoAway、不关会话)。
实测:22 会话 11030 流 ⇒ +100 MB(9504 B/流),攻击者成本仅 129 KB。
修法:全局未认证握手闸门(2048 名额 / 5s 排队)前移到 spawn 之前
- mux 通道 64 → 8 形成反压。★ 修复被元验证打红两次(闸门位置放错、
None双关错杀合法流量),终版三态PreauthSlot通过 9/9。
并行且独立: 停止攻击后内存立即回收(+1.9 MB)。
4. 未认证连接的时间上界被单字节绕过(C9-4)
只发一个字节 0x00(yamux)/ 0x16 / 0x17(TLS)然后静默:
t=50s 仍然存活(没有任何超时路径会碰它);500 条囤积 33s
句柄 +515 / 内存 +6.4 MB 无一被清理。
修法:TLS 握手 30s 超时(两个分支都包)+ yamux 会话空闲看门狗
(零活跃流 ≥30s 才关 —— 合法 frpc 控制流全程活着,不误杀)。
元验证 15/15。
5. stcp/xtcp visitor 默认配置被误拒(A9-5,v0.5.7 引入的功能回归)
user 本来就是可选项:双方都不配时,官方 frps 走
"空 allow_users ⇒ 只允许 provider 自己的 user"("" == "" 放行)。
v0.5.7(第六轮 D-2)加的一行空身份拒绝把这条合法默认路径一并拒了
⇒ 默认配置下 stcp/xtcp visitor 完全不可用(配对成功、数据永远不通)。
修法:恢复官方语义;空身份仍不能命中 * / 具名白名单(D-2 安全语义不变)。
元验证 11/11。
★ 给 v0.5.7 / v0.5.8 用户:当时为绕过此问题给双方硬配过 user 的话,
升级后可以去掉(两边一致时保留也可以)。
低危
6. xtcp 牵线 UDP 不受 [acl](E9-1,纵深防御缺失)
牵线唯一的来源判定是第八轮 B-1 的"与控制连接同源"(拦冒充、不读 ACL)。
攻击者进不来控制通道就拿不到 sid,故非当下可打;缺口是"将来某条路径
漏了 sid,ACL 不会兜底"。修法:run_rendezvous 在解析报文之前判 ACL。
7. 管理面板不受 [acl](E9-2,纵深防御缺失,非认证绕过)
面板有独立 Basic Auth(无/错凭据一律 401),但 [acl] 文档语义是
"谁能连上来、在认证之前执行",没有把面板排除在外。
修法:accept 后判 ACL、拒绝即断开(/api/healthz 一视同仁 ——
来源管控不该有端点例外)。
8. 审计日志无轮转(A9-3,仅 [audit] enable=true 时)
1000 条/s 即可无上界增长(37 MB/h 起)并淹没 1000 条环形缓冲。
修法:maxFileMB(默认 64,0 = 不轮转)+ 单备份 <path>.1;
先释放文件锁再轮转(防死锁);written 计入既有文件大小(重启不绕过阈值)。
元验证 20/20(备份末行保持完整 JSON,轮转不切碎行)。
9. Windows 上 uds 插件配置错误仅 DEBUG 可见(D9-7)
unix_domain_socket 插件在 Windows 上配置错误时默认级别完全静默。
修法:客户端启动时逐条预检插件配置,错误立即 WARN 点名(含原因)。
元验证 6/6。
预防性加固(当前不是漏洞)
C9-2 quic::server_config 的缓存忽略后续传入的 ALPN —— 当前两处调用恰好
都传同一 ALPN、xtcp 走独立缓存 ⇒ 靠巧合安全。加固:缓存命中时校验 ALPN
一致性,不一致直接报错(防止将来复用该缓存时静默串 ALPN、症状极难排查)。
兼容性
官方 frpc 0.71.0 互通已实测通过(登录 + 注册 + 转发,v0.5.9 改动全部生效后复测),
frps -v / frpc -v 仍输出裸 0.71.0。
有意收紧的行为:
[acl]覆盖面补齐到 7 条路径全铺(新增:UDP 代理端口、xtcp 牵线 UDP、
管理面板)—— 白名单模式下请为这三条路径放行合法来源(原先它们不受 ACL 管)。- 未认证连接全局闸门:2048 名额 / 排队 5 s —— 常规流量零感知;满饱和时新的
未认证握手(含合法客户端重新登录)可能排队超时被拒,已有会话及其工作流
不受影响。 - 审计日志轮转默认 64 MB(
maxFileMB = 0可关)。 - KCP 会话上限 1024(仅启用
kcpBindPort的部署)。
功能恢复:v0.5.7 / v0.5.8 中 stcp/xtcp visitor 需硬配 user 才能用;
本版起不配 user 即按官方语义工作。
质量
- 732 个自动化测试,0 失败(v0.5.8 基线 725,本轮净 +7)
clippy -D warnings0 告警、cargo audit零漏洞(184 crates)- 端到端冒烟 28 / 28
- 元验证:9 条成立项全部重跑全绿(c9_3 9/9、c9_4 15/15、a9_3b 20/20、
a9_5 11/11、a9_7 10/10、d9_7b 6/6、E9-0 12/12、E9-1 6/6、E9-2 8/8;
C9-1 238 → 23.7 MB) - 退回验证:11 个点位全部"退掉 → 红、还原 → 绿"(含 1 处测试挂死的独立证据)
- ★ 元验证两次打红"已完成"的修复本身(C9-3 闸门位置、
None双关)——
如实收录:修复写完 ≠ 修复完成
本轮覆盖与残余边界
v0.5.8 声明未审计的 6 个面本轮全部系统覆盖:[acl] 三条缺口(UDP 代理 /
xtcp 牵线 / 管理面板)+ 资源耗尽 / 并发 / 传输层 / 客户端入口。
另有 9 条线索实测排除(A9-1、A9-2、A9-6、B9-1、D9-1~D9-3、W9-1、Q9-1,
全部有实测反证,详见报告)。
如实记录的残余边界:
- 半开语义:真实目标应用在 EOF 后不主动关闭连接时,服务端按正直半开语义
保持中继(≈147 KB/条,上界 ≈ yamux 会话流上限,会话重连即整体清零)——
与官方 frp 行为一致,非服务端缺陷。 - 满饱和排队窗口:见上文"兼容性"。
- KCP 修复前提:需显式启用
kcpBindPort;未启用 KCP 的部署不受影响。
详见仓库内 SECURITY-FIXES-ROUND9.md
(含每个发现的实测数据、修复前后对照与两次"元验证打醒修复本身"的完整记录)。
校验
SHA256SUMS.txt—— 各包 sha256SHA256SUMS.txt.sig—— Ed25519 分离签名(注释行 + hex 签名)release.pub—— 验签公钥(注释行 + hex 公钥;指纹763896b5d759c6cbe1f208c3c35c9f71)
用仓库自带的零依赖工具验(推荐,只需 Python 3):
python scripts/release.py verify --dir .需要用 OpenSSL 独立复验的话,先把公钥与签名转成 OpenSSL 格式
(两者都是「注释行 + hex」的紧凑格式,OpenSSL 不能直接读):
python - <<'PY'
import pathlib
hexline = lambda p: [l for l in pathlib.Path(p).read_text().splitlines()
if l and not l.startswith('#')][0].strip()
# 公钥小 hex -> SPKI DER
pathlib.Path('release.pub.der').write_bytes(
bytes.fromhex('302a300506032b6570032100' + hexline('release.pub')))
# 签名 hex -> 64 字节原始签名
pathlib.Path('sums.sig.bin').write_bytes(
bytes.fromhex(hexline('SHA256SUMS.txt.sig')))
PY
openssl pkeyutl -verify -pubin -inkey release.pub.der -keyform DER -rawin \
-in SHA256SUMS.txt -sigfile sums.sig.bin
# 期望输出:Signature Verified SuccessfullyNfrp v0.5.8
NFrp v0.5.8
第七、八两轮安全审计的合并修复版本,共修复 18 条(3 高危 / 7 中危 / 8 低危):
第七轮 13 条(打前六轮没碰过的面:HTTP 响应侧、VNet、空闲池、日志、ACL 覆盖面)
- 第八轮 5 条(B-1 收口、面板鉴权矩阵、TLS 入站插件数据面)。
高危
1. xtcp 牵线可被劫持(B-1)—— 无鉴权 HELLO + 固定应答口令
牵线 HELLO 是明文 UDP,链路上看到一次就能拿到 sid;旧代码对 HELLO 零校验,
任何人可冒充 provider 拿到 visitor 地址,并用固定 8 字节 RTNL-OK 应答完成握手。
实测全部打通:用户明文数据落到攻击者手里、伪造回包被接受、真实后端 0 字节,
而 visitor 日志写的是"P2P 直连已建立"。
修法用两层独立防线:服务端把 HELLO 来源 IP 绑到对应侧控制连接(不同 IP 直接
拒绝登记);应答改成由 secret_key + sid 派生的 64 字节口令(域分离、定长比较),
不匹配即回退中继。同 NAT 观察者能登记、但拿不到数据 —— 两种攻击矩阵各自实测
拦截 12/12、11/11。
2. 响应走私(D7-1)—— 响应侧 Transfer-Encoding 判定用废弃写法
请求侧已改用 RFC 口径,响应侧还留着 contains("chunked")。实测两个域名同连接
pipeline 时,Bob 的响应体混进 Alice 的响应。修法:统一 RFC 判定,
不支持的 TE ⇒ 安全失败(Connection: close)。
3. 远程下发的回环白名单无条件放行 localhost(E-1)
is_loopback_host() 在 IP 解析前放行字面量 localhost(解析到哪个地址取决于
本机 hosts/DNS),同仓库另一处实现显式拒绝它。实测 localhost:P 被下发并生效
(打通本机 v6 管理面),store 恢复路径同样漏 —— 服务端可控的回环 SSRF。
修法:不再放行 localhost,客户端侧同步修正。
中危
4. 未认证日志放大(A-1) —— 一句畸形请求可产生 125 倍日志,
磁盘可被远程打满。修法:折叠 + 令牌桶(125× → 1.2×)。
5. verifyServerCert = true 静默失效(B-2) —— 用户以为开了证书校验,
链路实际是明文。修法:别名生效 + 启动 WARN 点明。
6. VNet 转发键用显示名而非会话键(C-1) —— 转发全死 + 告警失真。
7. 空闲工作连接池无上界(C-2) —— 句柄/内存被存量式占满,新增配额闸。
8. 响应侧完全没有 framing 校验(D7-2) —— 非法 framing 不再被转发,
按 502 安全失败。
9. local_addr_host() 判据串 ≠ 实际连接串(E-2) —— check-vs-use 分歧,
判据与实际连接统一。
10. [acl] 未覆盖数据面(F7-1) —— IP 白/黑名单只铺在控制连接上,
真正跑用户流量的公网 TCP 端口、vhost 全部敞开(管理员以为封了、实际全敞)。
修法:TCP 代理 accept 与 vhost accept(HTTP/HTTPS/tcpmux)加 ACL 判定,
失败关闭。覆盖面边界见文末"未覆盖的面"。
低危
11. 响应侧逐跳头漏剥 Proxy-Connection(D7-3)
12. static_file 可读 NTFS 备用数据流(D7-4) —— file:stream 形式被拒。
13. +Forwarded 未剥离(E-3) —— 剥离集合与官方一致化。
14. 空 local_addr 假绿灯(E7) —— 远程下发时拒绝。
15. 面板鉴权:请求体里的 Authorization 行参与鉴权(F8-L1) ——
解析只看头区,与上游反代/WAF 口径一致。
16. 面板删除:按列表全名删代理 502 + 两端状态分裂(F8-L2) ——
服务端已摘、客户端还挂着(端口/域名泄漏)。修法:两端都能收敛到本地名,
滚动升级兼容。
17. dashboard_user 注释与实际行为不符(F8-L3) —— 只改注释:
逐字比对、不 trim(与官方一致,行为不变)。
18. 插件悬挂上游不随访问方撤离释放(F8T-1) —— https2http/https2https
下"连接 → 发请求 → 撤离"可反复留下 ESTABLISHED 上游连接。修法:
撤离即收尾 + 上游连接/握手 10s 超时(等响应头阶段故意无超时,
长轮询/SSE 不受影响)。
兼容性
官方 frpc 0.71.0 互通已实测通过(登录 + 注册 + 转发),
frps -v / frpc -v 仍输出裸 0.71.0。
有意收紧了四处行为:
- xtcp 混合版本:新旧版本任意组合 P2P 握手会失败,8 秒打洞窗口后
自动回退 stcp 中继 —— 功能不受影响,但直连不再建立;同版本两端无影响。 [acl]现在也管公网 TCP 代理与 vhost(管理员配置原本就该有的效果)。- 远程下发
localhost与空local_addr被拒 —— 官方 frpc 不受影响。 - 面板删除代理时,全名与本地名都会被正确收敛(此前会 502 且两端状态分裂)。
其余行为与 v0.5.7 相同;F8-L3 属注释修正,无行为变更。
质量
- 725 个自动化测试,0 失败(第七、八轮合计新增 26 条)
clippy -D warnings0 告警、cargo audit零漏洞- 端到端冒烟 28 / 28
- 元验证:B-1 攻击脚本重跑 12/12 拦截(同 IP 11/11)、F8 13/13、
悬挂 17/17;退回验证:B-1 退回后 12/12 重现漏洞 - 交叉验证:子代理原始套件重跑全绿(t3 插件套件 53/53,修复前 51/53)
本轮没有覆盖的面
第七轮说明列的未覆盖面(资源耗尽与 DoS、并发与竞态、KCP/QUIC/WebSocket
数据面、客户端本地界面/插件/store 的全部入口)本版仍未系统审计。
[acl] 的覆盖面本版已如实标注:控制连接 + VNet 注册 + TCP 代理 accept +
vhost accept(HTTP/HTTPS/tcpmux);UDP 代理端口、xtcp 牵线 UDP、管理面板
不在内(面板有独立登录认证;UDP 侧有 0.5.6 起的反射防护,但不是来源管控)。
详见仓库内 SECURITY-FIXES-ROUND7.md
与 SECURITY-FIXES-ROUND8.md。
校验
SHA256SUMS.txt—— 各包 sha256SHA256SUMS.txt.sig—— Ed25519 分离签名(注释行 + hex 签名)release.pub—— 验签公钥(注释行 + hex 公钥;指纹763896b5d759c6cbe1f208c3c35c9f71)
用仓库自带的零依赖工具验(推荐,只需 Python 3):
python scripts/release.py verify --dir .需要用 OpenSSL 独立复验的话,先把公钥与签名转成 OpenSSL 格式
(两者都是「注释行 + hex」的紧凑格式,OpenSSL 不能直接读):
python - <<'PY'
import pathlib
hexline = lambda p: [l for l in pathlib.Path(p).read_text().splitlines()
if l and not l.startswith('#')][0].strip()
# 公钥小 hex -> SPKI DER
pathlib.Path('release.pub.der').write_bytes(
bytes.fromhex('302a300506032b6570032100' + hexline('release.pub')))
# 签名 hex -> 64 字节原始签名
pathlib.Path('sums.sig.bin').write_bytes(
bytes.fromhex(hexline('SHA256SUMS.txt.sig')))
PY
openssl pkeyutl -verify -pubin -inkey release.pub.der -keyform DER -rawin \
-in SHA256SUMS.txt -sigfile sums.sig.bin
# 期望输出:Signature Verified SuccessfullyNfrp v0.5.7
NFrp v0.5.7
第六轮安全审计的修复版本。这轮只打前五轮没碰过的面,找到 8 条问题,全部已修。
高危
1. 凭证重放 —— 时间窗口只铺了 1 条路径
v0.5.6 给主登录加了 ±300 秒时间戳窗口防重放,但同样的凭证在另外几条路径上
也能用,那些路径一处都没有这个检查。实际数了一遍,凭证入口共 6 个,
只有主登录有防护。
实测一年前的报文仍被接受。把工作连接那条打通后是完整双向中间人:
用户的明文落到攻击者手里、回包由攻击者伪造、受害者的服务什么都没有收到。
嗅探门槛很低 —— v1 下工作连接的报文(含凭证)是明文传输的。
修法:不再"到处补一次调用",而是让"校验凭证"和"查新鲜性"成为同一件事。
2. group 负载均衡可跨租户劫持他人公网端口流量
端口组里没有"归属租户"的概念,加入只看组名和密钥。
而官方的 groupKey 是可选的 —— 双方都不配时,组名就是唯一凭据、不需要密钥。
实测:攻击者知道组名后,12 条用户连接里 6 条落到了他的内网服务上
(用户的请求、Cookie、Authorization 全被看走)。服务端日志把这次加入
记成正常的负载均衡扩容,没有任何告警。
修法:端口组记住归属租户,跨租户一律拒绝。按租户判而不是按 run_id
(后者每次重连都变,会把合法的负载均衡打死)。
3. 配了 [[roles]] 也挡不住伪造身份
角色按客户端自报的 user 匹配,服务端不验。而 frp 的 token 是
服务端级共享凭据 —— [[roles]] 存在的理由恰恰是"在共享 token 之上再分权",
所以这个机制完全无法落地。
实测:冒充者拿到了别人的端口段、注册进别人的命名空间、
并以对方身份穿过了 allow_users 白名单。
修法:本版先做诚实告警(启动时明说"这不是租户隔离")。
直接改成按凭据匹配会让所有既有配置静默失效,属独立的架构改动。
假安全比不配更危险,至少先让人知道。
中危
4. visitor 隧道名可被未认证枚举 —— 两条拒绝路径的文案结构不同,
攻击者据此能 100% 准确地判断某个隧道名是否存在,且不需要登录。
5. 匿名连接可把数据送进他人的 stcp 隧道 —— run_id 留空时
"会话存在吗"的检查被整条短路,身份退化成空串,恰好命中了白名单里的 "*"。
6. 配置预检有假绿灯 —— frps --check 放过了 5/7 条"启动必然失败"的配置
(非法 CIDR、非法端口范围…),frpc verify 也放过了"管理界面裸奔"。
被漏掉的恰恰是安全检查。本项目此前已因"假绿灯"栽过一次。
7. http_proxy 插件配了用户名密码就完全不可用 —— 头的值被连同头名
一起小写化,而下游用大小写敏感的 Basic 匹配,永远比不中,认证恒失败。
低危
8. 域名冲突文案泄露他人的代理名 —— 同轮的端口冲突文案已经统一成
不含占用者,这条漏了。
兼容性
官方 frpc 0.71.0 互通已实测通过(登录 + 注册 + 转发),
frps -v / frpc -v 仍输出裸 0.71.0。
三处有意的行为收紧:
- 凭证新鲜性校验铺满全部入口 —— 两端时钟偏差超过
maxClockSkewSecs
(默认 5 分钟)时登录会被拒,错误里会写明偏差多少秒。官方 frpc 零影响。 - 跨租户不能用同组名共享端口 —— 同一租户的多个后端照常成组,
官方"一个运维跑多个冗余后端"的用法不受影响。 - visitor 的
run_id不能留空 —— 官方 frpc 一定带,零影响。
质量
- 699 个自动化测试,0 失败(本轮 +7)
clippy -D warnings0 告警、cargo audit零漏洞- 端到端冒烟 28 / 28
- 元验证:8 条发现全部重新构建后重跑原始攻击脚本 —— 全部打不通
- 退回验证 5/5:把修复退掉,回归测试必须变红
其中退回验证抓到一个覆盖缺口:某条修复有两层,第 1 层让第 2 层不可达,
导致第 2 层没有独立测试 —— 补测后才通过。
本轮没有覆盖的面
7 个审计分工组里 6 组在实证阶段中断,以下面尚未系统审计,
请不要当成"已通过":资源耗尽与 DoS、传输层数据面(KCP/QUIC/WebSocket/p2p/vnet)、
并发与竞态、HTTP/tcpmux/SNI/响应拆分、客户端(本地界面/插件/store)、
ACL 与面板鉴权矩阵。
另有 13 条线索经复核后排除(附反证),包括 v4-mapped IPv6 绕过 ACL、
static_file 路径穿越、v2 跳过握手降级、僵尸连接配额泄漏等。
详见仓库内 SECURITY-FIXES-ROUND6.md。
校验
SHA256SUMS.txt—— 各包 sha256SHA256SUMS.txt.sig—— Ed25519 分离签名release.pub—— 验签公钥(指纹763896b5d759c6cbe1f208c3c35c9f71)
openssl pkeyutl -verify -pubin -inkey release.pub -rawin \
-in SHA256SUMS.txt -sigfile SHA256SUMS.txt.sig