Lack of data validation In github.com/dunglas/frankenphp
Description
FrankenPHP: Unsafe Unicode Handling in CGI Path Splitting Allows Execution of Non-PHP Files
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 {...
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"...
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...
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"...
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.
Mitigation
Update Impact
Minimal update. May introduce new vulnerabilities or breaking changes.
Ecosystem | Package | Affected version | Patched versions |
|---|---|---|---|
go | 1.12.3 |
Aliases
References