Skip to content

Releases: Nu0vo1212/Rust-Frp

v0.6.6

Choose a tag to compare

@Nu0vo1212 Nu0vo1212 released this 24 Oct 16:39

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 → 可执行文件目录 → 当前目录 → 系统路径。
全都找不到时报错可操作(含下载地址、架构要求、放置位置),
而不是一句"加载失败"。

怎么用

  1. 从 https://www.wintun.net/ 下载对应架构的 wintun.dll
    (64 位程序用 bin/amd64/wintun.dll)
  2. 放到 nfrp-client.exe 旁边,或设环境变量 NFRP_WINTUN_DLL 指向它
  3. 以管理员身份运行(创建虚拟网卡需要该权限)
[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 互通不受影响
  • 两处行为变更(都向官方对齐):
    1. /metrics 默认关闭,要采集需配 enablePrometheus = true
    2. 配了 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-client

crates.io 三个 crate 均已更新到 0.6.6。


完整记录

SECURITY-FIXES-ROUND15.md

v0.6.5

Choose a tag to compare

@Nu0vo1212 Nu0vo1212 released this 24 Oct 15:15

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 个变体,含服务端→客户端方向的那些
    (客户端发它们就是方向违例,正是要测的输入)

断言三件事:

  1. 不崩 —— 之后用新连接仍能正常登录并注册代理;
  2. 不挂住 —— 控制连接必须"要么还活、要么已被关闭",
    不能既不响应也不断(那种会话会永久占住服务端槽位);
  3. 不泄漏 —— 断开后该端口上的后端计数归零。

四、四个配置字段真正生效

之前它们只在"未实现字段"清单里告警,现在都真的能用。

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 不受影响)
  • 两处行为变更(都向官方对齐,已写进示例配置):
    1. /metrics 默认关闭,要采集需配 enablePrometheus = true
    2. 配了 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-client

crates.io 三个 crate 均已更新到 0.6.5。


完整记录

本轮详细报告:SECURITY-FIXES-ROUND15.md

v0.6.4

Choose a tag to compare

@Nu0vo1212 Nu0vo1212 released this 24 Oct 12:11

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! 里被取消时那批字节随之消失。丢掉的可能正是请求体开头。

两次修复尝试都失败了:

  1. 改成 read_buf 直接读进 self.buf + 缓冲非空立即返回
    ⇒ 调用方的 loop { select! } 变成 100% CPU 忙等死循环
    (云端 4 进程各烧 20 分钟 CPU,本地单进程 1288 秒)
  2. 再在循环里加短路判断 ⇒ 仍然空转

最终处理:回退,并在代码注释里完整记录失败过程与正确方向。
判断依据:宁可保留一个只在 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-client

crates.io 上三个 crate 均已更新到 0.6.4:
nfrp-common ·
nfrp-server ·
nfrp-client


完整记录

累计十轮安全审计 + 两轮外部渗透测试,146 项修复,全部带回归测试。

Nfrp v0.6.3

Choose a tag to compare

@Nu0vo1212 Nu0vo1212 released this 21 Oct 11:22

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() 同一个 channel

listener.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,
而进程不报错。那种静默失效比拒绝启动难排查得多。

超大规模部署的正确做法

不是无限调大这个值,而是:

  1. 用 TCP 或 QUIC 承载控制连接(KCP 只在两者都走不通时才需要)
  2. 或在前面挂负载均衡,把客户端拆到多个 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-client

crates.io 上三个 crate 均已更新到 0.6.3:
nfrp-common ·
nfrp-server ·
nfrp-client

crates.io 版本与 Release 版本相互独立(crates.io 不允许重发同一版本号)。


完整记录

累计十轮安全审计修复 143 项,全部带回归测试与退回验证。

Nfrp v0.6.1

Choose a tag to compare

@Nu0vo1212 Nu0vo1212 released this 20 Oct 12:05

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 .

完整修复记录

累计十轮安全审计修复 140 项,全部带回归测试。

Nfrp v0.6.0

Choose a tag to compare

@Nu0vo1212 Nu0vo1212 released this 18 Oct 12:58

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

完整修复记录

累计九轮安全审计修复 137 项,全部带回归测试。

Nfrp v0.5.10

Choose a tag to compare

@Nu0vo1212 Nu0vo1212 released this 17 Oct 15:46

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.130.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 warnings 0 告警(含云端 Linux 侧对 cfg(linux) 代码的检查)。
  • cargo audit:exit 0 零漏洞。
  • 全量复扫电池约 100 项全绿;Windows / Linux amd64 / Linux arm64 / Android 四类构建全部通过。

校验

  • SHA256SUMS.txt —— 各包 sha256
  • SHA256SUMS.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 Successfully

Nfrp v0.5.9

Choose a tag to compare

@Nu0vo1212 Nu0vo1212 released this 17 Oct 09:47

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 warnings 0 告警、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 —— 各包 sha256
  • SHA256SUMS.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 Successfully

Nfrp v0.5.8

Choose a tag to compare

@Nu0vo1212 Nu0vo1212 released this 16 Oct 19:20

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 warnings 0 告警、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 —— 各包 sha256
  • SHA256SUMS.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 Successfully

Nfrp v0.5.7

Choose a tag to compare

@Nu0vo1212 Nu0vo1212 released this 16 Oct 11:05

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 warnings 0 告警、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 —— 各包 sha256
  • SHA256SUMS.txt.sig —— Ed25519 分离签名
  • release.pub —— 验签公钥(指纹 763896b5d759c6cbe1f208c3c35c9f71)
openssl pkeyutl -verify -pubin -inkey release.pub -rawin \
  -in SHA256SUMS.txt -sigfile SHA256SUMS.txt.sig