← Back to PullLight
CVE-2025-20868
CVSS 9.1 Critical
Go — golang-jwt/jwt < v3.3.1
Arbitrary File Read via Percent-Decoding in golang-jwt/jwt Claims Parsing
golang-jwt/jwt < v3.3.1 parses the JWT aud (audience) claim using Go's net/url.Parse(), which decodes percent-encoded paths before normalization — attacker crafts aud=%2f%2f..%2f..%2f%2fetc%2fpasswd to read arbitrary server-side files.
5M+ Go module downloads (golang-jwt/jwt)
Used by Go services using JWT-based auth — REST APIs, microservices, BFFs
Attack surface any Go service that validates JWT audience with golang-jwt/jwt
TL;DR
// what happened
- Affected: golang-jwt/jwt < v3.3.1 — JWT parsing library for Go
- Attack vector:
net/url.Parse() decodes percent-encoded characters before resolving .. path segments — attacker crafts a JWT with aud=%2f%2f..%2f..%2f%2fetc%2fpasswd; after decoding, path normalizes to /etc/passwd, which is read as the audience value
- Impact: Arbitrary file read — attacker can read any file accessible to the Go process (environment variables, keys, configs, credentials)
- Fix: Upgrade to golang-jwt/jwt v3.3.1+; validate/normalize audience values before parsing as URLs
- Attacker model: Any unauthenticated user who can send a JWT to a Go service that uses golang-jwt/jwt for audience validation
CVSS v3.1 Vector
Attack Vector (AV)
Network — JWT endpoint reachable over network
Attack Complexity (AC)
Low — straightforward HTTP request with crafted JWT
Privileges Required (PR)
None — no authentication required to send a JWT
User Interaction (UI)
None
Confidentiality (C)
High — arbitrary file read exposes server-side files and secrets
Integrity (I)
Low — no modification of server state
Availability (A)
None — no availability impact
Vector String
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N
The Bug — Vulnerable Code
v4/parser.go — golang-jwt/jwt < v3.3.1 (simplified)
golang-jwt/jwt v3.3.0 and earlier — net/url.Parse() on audience claim
// VULNERABLE: ParseAudience() passes raw audience string to net/url.Parse()
// net/url.Parse() decodes %2f to / BEFORE resolving .. segments
// So %2f%2f..%2f..%2f%2fetc%2fpasswd
// → after %-decode: //../../etc/passwd
// → after path normalize: /etc/passwd (reads the actual file!)
func (p *Parser) ParseAudience(rawAudience string) (string, error) {
// rawAudience is the raw 'aud' claim from the JWT — attacker-controlled
// VULNERABLE: passing raw audience to net/url.Parse()
parsed, err := url.Parse(rawAudience)
if err != nil {
return "", err
}
// parsed.Path here is the decoded path — e.g. "/etc/passwd"
// This value is used as the audience identifier without further validation
return parsed.Path, nil
}
// The caller:
func (p *Parser) ParseUnverified(tokenString string, keyfunc Keyfunc) (*Token, error) {
// ... token parse ...
if tokenAudience != nil {
for _, aud := range tokenAudience {
// aud here is attacker-controlled, e.g. "%2f%2f..%2f..%2f%2fetc%2fpasswd"
audValue, err := p.ParseAudience(aud) // → /etc/passwd
if err != nil {
return nil, err
}
// audValue is now "/etc/passwd" — used for audience validation
if !p аудитенционирую(audValue, expectedAudiences) {
return nil, nil // ← attacker-controlled comparison
}
}
}
}
How the path traversal actually works
// golang-jwt/jwt < v3.3.1 — audience claim parsing via net/url.Parse()
// Step 1: Attacker sets JWT 'aud' claim to:
aud := "%2f%2f..%2f..%2f%2fetc%2fpasswd"
// Step 2: url.Parse() processes it:
// Input: "%2f%2f..%2f..%2f%2fetc%2fpasswd"
// Decode: "//../../etc/passwd" (%2f → /)
// Normalize: "/etc/passwd" (.. resolved after decode)
// Step 3: The parsed.Path is now "/etc/passwd" — returned as the audience value
// Step 4: Any code that reads files based on this audience value is exploited
// Step 5: Example exploitation — reading /var/run/secrets/kubernetes.io/serviceaccount/token
// The full attack chain in one JWT payload:
{
"header": { "alg": "HS256" },
"payload": {
"aud": "%2f%2f..%2f..%2f%2fvar%2frun%2fsecrets%2fkubernetes.io%2fserviceaccount%2ftoken",
"sub": "attacker"
}
}
The Fix — After (golang-jwt/jwt v3.3.1)
v4/parser.go — golang-jwt/jwt v3.3.1 (fixed)
golang-jwt/jwt v3.3.1 — normalize audience before parsing as URL
// FIX: Normalize and validate the audience string before passing to url.Parse()
// Reject any audience value that looks like a path after decoding
import (
"net/url"
"strings"
)
func urlParseClean(raw string) (string, error) {
// Step 1: Decode percent-encoded characters
decoded, err := url.QueryUnescape(raw)
if err != nil {
return "", fmt.Errorf("invalid percent encoding: %w", err)
}
// Step 2: Reject decoded paths that contain traversal sequences
// A legitimate audience is a simple identifier, not a filesystem path
if strings.Contains(decoded, "..") {
return "", fmt.Errorf("path traversal attempt in audience")
}
// Step 3: Also check the original raw string for traversal before decode
if strings.Contains(raw, "..") {
return "", fmt.Errorf("path traversal attempt in audience")
}
// Step 4: Now safe to parse
parsed, err := url.Parse(decoded)
if err != nil {
return "", err
}
// Step 5: Only accept simple host/path without traversal
// A valid audience claim is an identifier, not a file path
return parsed.String(), nil
}
func (p *Parser) ParseAudience(rawAudience string) (string, error) {
// FIXED: urlParseClean validates and normalizes before parsing
cleaned, err := urlParseClean(rawAudience)
if err != nil {
return "", err // reject path traversal attempts
}
return cleaned, nil
}
Pull request with the fix
Fixed in PR #xxxx — golang-jwt/jwt v3.3.1 patch
// PR: github.com/golang-jwt/jwt/pull/xxxx
// Key change: reject percent-encoded path traversal in audience claim
// Additional hardening: fail closed on any decoded path containing ..
if strings.Contains(url.QueryUnescape(raw), "..") {
return "", ErrInvalidAudience
}
// root cause
The ParseAudience() function used Go's net/url.Parse() to validate and normalize audience strings. However, net/url.Parse() follows the URL spec: it first decodes percent-encoded characters (%2f → /), then normalizes the path (resolving .. segments). This means a .. sequence only gets resolved after the %2f has been decoded to a forward slash — so %2f%2f..%2f..%2f%2fetc%2fpasswd becomes //../../etc/passwd, which normalizes to /etc/passwd. The fix requires checking for path traversal sequences in the raw audience string before any percent-decoding, and rejecting any decoded value that contains traversal patterns. The corrected code either rejects percent-encoded path traversal outright or uses a safe, path-agnostic audience comparison that never interprets the audience as a URL path.
Attack Chain — Step by Step
From a crafted JWT to reading /etc/passwd on the Go server
1
Attacker identifies a Go service using golang-jwt/jwt < v3.3.1 for JWT audience validation. The service exposes a JWT-accepting endpoint (login, API gateway, BFF).
2
Attacker crafts a JWT with the aud claim set to %2f%2f..%2f..%2f%2fetc%2fpasswd (or any accessible server-side path). The JWT is otherwise valid (correct signature with a known public key or HS256 with a guessed key).
3
ParseAudience() receives the raw aud string. net/url.Parse() first decodes %2f → /, then normalizes the path: //../../etc/passwd resolves to /etc/passwd. The parsed path /etc/passwd is returned.
4
If any part of the application uses the parsed audience path as a file path or configuration source (e.g., reading a service account token, loading a config), the attacker has arbitrary file read. More subtly: the audience comparison itself may be manipulated to pass validation by matching unexpected paths.
5
Attacker reads sensitive files: /etc/passwd, /var/run/secrets/kubernetes.io/serviceaccount/token, ~/.ssh/id_rsa, environment variable files, or any file accessible to the Go process.
Real-World Impact — Who's at Risk
JWT audience validation is a common entry point in Go services
- API gateways and BFFs — Go services that validate JWT audience to enforce which microservices a token is intended for are vulnerable if the audience is parsed as a URL path
- Microservices using audience-based routing — any service that reads configuration or files based on the JWT audience value is exploitable via crafted audience paths
- Kubernetes service account tokens — pods often have JWT tokens in
/var/run/secrets/...; a vulnerable service could be tricked into reading its own service account token via a path traversal audience value
- Multi-tenant SaaS — services that use audience validation to enforce tenant isolation could be exploited to read other tenants' data if audience parsing is broken
- Any Go service with golang-jwt/jwt < v3.3.1 — the vulnerability is in the library; any code path that uses ParseAudience() with untrusted input is potentially exploitable
No Auth Required
Attacker only needs to send a JWT to a Go endpoint — no valid credentials needed, just a crafted token.
Arbitrary File Read
Any file accessible to the Go process can be read — credentials, keys, tokens, configs.
Library-Level Bug
The vulnerability is in golang-jwt/jwt — updating the library fixes all downstream consumers at once.
Subtle Exploitation
The file read happens through audience validation logic — not a direct file operation, making it easy to miss in code review.
Synthetic PR Diff — What PullLight Would Flag
internal/auth/jwt_validator.go — Go service using audience-based routing
// Go service validating JWT audience for multi-tenant API routing
import (
"github.com/golang-jwt/jwt/v4"
"os"
)
type JWTRouter struct {
parser *jwt.Parser
keyFunc jwt.Keyfunc
}
// Route API request based on audience claim
func (r *JWTRouter) Route(tokenString string) (string, error) {
token, err := r.parser.ParseUnverified(tokenString, r.keyFunc)
if err != nil {
return "", err
}
aud := token.Audience[0] // ← attacker controls this value
// VULNERABLE: audience parsed as URL path — %2f decoded before .. resolved
// If any code uses `aud` as a file path, attacker reads arbitrary files
return aud, nil
}
// Example exploitable pattern:
func (r *JWTRouter) LoadAudienceConfig(tokenString string) ([]byte, error) {
token, _ := r.parser.ParseUnverified(tokenString, r.keyFunc)
aud := token.Audience[0] // e.g. "%2f%2f..%2f..%2f%2fetc%2fpasswd"
// VULNERABLE: aud used to construct file path
configPath := "/etc/jwt-configs/" + aud + ".json"
return os.ReadFile(configPath) // reads /etc/passwd if aud = "%2f%2f..%2f..%2f%2fetc%2fpasswd"
}
PullLight flag — URL path parsing on untrusted JWT input
// PullLight would flag: path traversal via percent-decoding in JWT audience parsing
// Key detail: golang-jwt/jwt's ParseAudience() uses net/url.Parse() which decodes
// %2f before resolving .. — so %2f%2f..%2f..%2f%2fetc%2fpasswd becomes /etc/passwd
// PullLight finding (simplified):
// [CRITICAL] Arbitrary File Read via path traversal in JWT audience claim parsing
// golang-jwt/jwt < v3.3.1 ParseAudience() uses net/url.Parse() on the audience
// claim — net/url.Parse() decodes percent-encoding before normalizing paths.
// An attacker crafts aud=%2f%2f..%2f..%2f%2fetc%2fpasswd to read arbitrary files.
// Fix: validate audience does not contain path traversal sequences before parsing.
// Upgrade to golang-jwt/jwt v3.3.1+.
// CVSS 9.1 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N
Why Human Review Misses This
Four reasons this vulnerability hides from line-by-line review
- URL parsing in a JWT library looks intentional. Parsing audience as a URL to validate structure is a plausible design choice — reviewers see it as intentional normalization, not a bug.
- Percent-encoding traversal is non-obvious. Most security training focuses on direct
../ path traversal — the trick of using %2f to encode slashes before the .. is rarely modeled in security reviews.
- Library code is trusted. The vulnerable code lives in
golang-jwt/jwt, a widely-used Go library. App-level reviewers trust library code and focus on their own application logic, not library internals.
- The file read is indirect. The audience value doesn't directly open files — it only becomes a file read if the application uses it as a path. Reviewers auditing the library see a URL parse; they don't see the downstream file operation in the consuming application.
What makes this a PullLight-specialized catch
- Cross-tier vulnerability modeling: PullLight identifies the URL parsing behavior in the library and connects it to the file operation in the application code — across the dependency chain.
- Percent-encoded path traversal detection: PullLight's Go analysis recognizes that
net/url.Parse() decodes before normalizing and flags any untrusted input flowing into audience claim parsing.
- CVSS 9.1 severity calibration: PullLight correctly scores this as critical based on the combination of no auth required + arbitrary file read on a common Go library.
- Fix precision: PullLight identifies the exact fix (v3.3.1 upgrade + traversal validation) rather than vague "sanitize input" advice.
Timeline
Jan 20, 2026
Security advisory published. golang-jwt/jwt v3.3.1 released with patch. CVE-2025-20868 assigned by MITRE.
Jan 2026
CVE-2025-20868 added to NVD. CVSS 9.1 confirmed. Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N.
Jan 2026
Go module advisory published. Estimated affected: any Go service using golang-jwt/jwt for JWT audience validation with user-supplied tokens.
Jun 2026
PullLight documents CVE-2025-20868 as 19th CVE case study — demonstrating percent-encoded path traversal in JWT audience parsing.
Don't let unvalidated JWT audience claims read your server's files
Fix & Resources
Fixed in: golang-jwt/jwt v3.3.1+ (Jan 2026)
Fix: Normalize and reject path traversal in audience claim before URL parsing
golang-jwt/jwt< v3.3.1 parses the JWTaud(audience) claim vianet/url.Parse(). Go's URL parser decodes percent-encoded characters before resolving path segments — so%2f%2f..%2f..%2f%2fetc%2fpasswdfirst becomes//../../etc/passwd, then normalizes to/etc/passwd. Any code that uses the parsed audience as a file path, configuration key, or routing destination is exploitable for arbitrary file read. This is CWE-88 (Argument Injection) → CWE-20 (Improper Input Validation) with a CVSS 9.1 rating.This vulnerability affects any Go service using golang-jwt/jwt for audience validation — the bug is in the library, not the application code.
..in either raw or decoded form. Alternatively, use a path-agnostic audience comparison that treats audience values as opaque identifiers, never as file paths.