Feb 12, 2026·6 min read·12 visits
FrankenPHP < 1.11.2 mishandles Unicode case conversion in URLs. Because some characters change byte length when lowercased (e.g., 'Ⱥ' -> 'ⱥ'), the server miscalculates where the file path ends. Attackers can use this to execute arbitrary files (RCE). Update to 1.11.2 immediately.
A critical path confusion vulnerability in FrankenPHP's CGI handler allows attackers to execute arbitrary files as PHP scripts. The flaw stems from a mismatch in byte length when converting Unicode characters to lowercase, causing the server to miscalculate the boundary between the script path and path info. By injecting specific characters like 'Ⱥ', attackers can offset the internal pointer and trick the server into executing malicious uploads (e.g., images or text files) as code.
FrankenPHP is a modern, high-performance application server for PHP, built on top of the Caddy web server and written in Go. It’s a beautiful amalgamation of technologies designed to modernize the PHP ecosystem. But as with any complex machinery, the devil is in the details—specifically, in how it handles the Common Gateway Interface (CGI) logic.
To serve a PHP request, the server needs to know two things: which file is the script (SCRIPT_FILENAME) and what comes after it (PATH_INFO). This is usually determined by splitting the request URL at the file extension (e.g., .php). It sounds simple, like cutting a sandwich in half.
However, in CVE-2026-24895, we discover that FrankenPHP was using a rusty knife. By assuming that a string's length remains constant when converted to lowercase, the developers inadvertently opened a door for attackers to slide malicious payloads right past the bouncer. This isn't just a parsing error; it's a logic flaw that turns a harmless file upload mechanism into a full-blown Remote Code Execution (RCE) vector.
The root cause of this vulnerability is a classic "byte vs. character" confusion in Go. The vulnerable code attempted to find the file extension in a URL by first converting the entire path to lowercase and then searching for the extension index. The fatal flaw? Case folding in UTF-8 is not length-invariant.
Consider the character Latin Capital Letter A with Stroke ('Ⱥ', U+023A). In UTF-8, this character takes up 2 bytes (0xC8 0xBA). However, when you run this through Go's strings.ToLower(), it converts to Latin Small Letter A with Stroke ('ⱥ', U+2C65). Here's the kicker: the lowercase version takes up 3 bytes (0xE2 0xB1 0xA5).
For every 'Ⱥ' in the URL, the lowercased string grows by 1 byte. FrankenPHP calculated the split index based on this longer lowercased string but applied that index to the shorter original string. It’s like measuring a distance on a zoomed-in map and then walking that distance in the real world—you’re going to overshoot your destination.
Let's look at the logic that caused the meltdown. The function splitPos was responsible for finding where the script name ends.
The Vulnerable Logic (Simplified):
// BAD: Lowercase the whole path first
lowerPath := strings.ToLower(path)
// Find ".php" in the lowercased version
index := strings.Index(lowerPath, ".php")
// Use that index to slice the ORIGINAL path
scriptName := path[:index]If path is /Ⱥ.php, lowerPath is /ⱥ.php. The index of .php in the lowercased string is shifted because ⱥ is longer. When that shifted index is applied to the original string, it slices at the wrong byte.
The Fix (Commit 04fdc0c1):
The patch completely removes the global strings.ToLower call. Instead, it iterates through the string byte-by-byte. If it sees ASCII, it does a cheap case-insensitive check. If it sees Unicode (bytes >= utf8.RuneSelf), it switches to the golang.org/x/text/search package, which correctly handles offsets without distorting the source string length.
// GOOD: Iterate and handle ASCII/Unicode separately
for i := 0; i < len(path); i++ {
// ... ASCII checks ...
if c >= utf8.RuneSelf {
// Use a matcher that respects original indices
start, _ := matcher.IndexString(path[i:])
// ...
}
}To exploit this, an attacker needs to perform a "Path Confusion" attack. The goal is to upload a file (e.g., shell.txt containing PHP code) and then access it via a URL that tricks FrankenPHP into executing it as a PHP script.
The Setup:
shell.txt containing <?php system($_GET['cmd']); ?> to /uploads/..txt files as PHP.The Attack:
The attacker requests: GET /ȺȺȺȺshell.php.txt.php
Here is the math of the madness:
/ȺȺȺȺshell.php.txt.php
ȺȺȺȺ: 4 chars * 2 bytes = 8 bytes./ⱥⱥⱥⱥshell.php.txt.php
ⱥⱥⱥⱥ: 4 chars * 3 bytes = 12 bytes.FrankenPHP searches the lowercased string for .php. It finds it at the end. Because of the expansion, the index returned is 4 bytes larger than it would be in the original string. When the server applies this index to the original string, the slice point moves forward by 4 bytes, effectively "skipping" characters.
By carefully aligning the number of 'Ⱥ' characters, the attacker shifts the split point so that SCRIPT_FILENAME resolves to /uploads/shell.txt (or similar), while the routing logic—confused by the extension check—hands it off to the PHP engine. The engine executes the text file, and you have RCE.
This is a High Severity (CVSS 8.9) vulnerability for a reason. It requires no authentication, no special privileges, and no user interaction. It purely relies on the server's configuration allowing file uploads or having existing files that can be misinterpreted.
If you are running FrankenPHP in a Docker container or a standard deployment exposed to the web, an attacker can potentially:
It effectively turns any file upload feature—even one that strictly validates extensions like .png or .txt—into a backdoor.
If you are running FrankenPHP, check your version immediately. If it is less than 1.11.2, you are vulnerable.
Remediation:
v1.11.2 or later).
docker pull dunglas/frankenphp%C8%BA) or other expanding Unicode characters in the URL path. However, be aware that many Unicode characters can trigger expansion or contraction, so a blacklist is fragile.This vulnerability serves as a stark reminder: never rely on string transformations (like lowercasing) for indexing if the transformation isn't length-preserving. Always validate indices against the original source of truth.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P| Product | Affected Versions | Fixed Version |
|---|---|---|
FrankenPHP php | < 1.11.2 | 1.11.2 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-180 |
| Attack Vector | Network |
| CVSS Score | 8.9 (High) |
| Impact | Remote Code Execution (RCE) |
| Exploit Status | PoC Available |
| Bug Class | Path Confusion / Byte Index Misalignment |
Incorrect Behavior Order: Validate Before Canonicalize