Feb 24, 2026·7 min read·3 visits
Caddy and FrankenPHP contain a 'Path Confusion' vulnerability due to unsafe Unicode lowercasing in the FastCGI module. An attacker can upload a file with a safe extension (e.g., .png) and request it with a specially crafted URL containing Unicode characters (like 'ẞ'). The server miscalculates the file path, executing the image as a PHP script. Immediate patching to v2.11.1 is required.
A critical logic flaw in Caddy's FastCGI transport layer allows for Remote Code Execution (RCE) via Unicode case-folding collisions. By exploiting how Go's `strings.ToLower` handles specific Unicode characters, attackers can desynchronize path parsing, tricking the server into executing arbitrary files (like images) as PHP scripts. This affects Caddy and the popular FrankenPHP application server.
We often laud Go for its memory safety. It saves us from the buffer overflows and dangling pointers that plague C and C++. But memory safety doesn't save you from logic safety, and it certainly doesn't save you from the absolute nightmare that is Unicode handling.
Enter CVE-2026-27590. This isn't a complex heap feng-shui attack. It's a classic case of "the developer assumed X, but the spec says Y." In this instance, the affected component is the FastCGI transport in Caddy (and by extension, FrankenPHP). FastCGI is the protocol that allows web servers to talk to application backends like PHP-FPM.
To make this communication work, the server needs to split a request URL—say /blog/index.php/article/1—into two parts: the script to execute (/blog/index.php) and the path info to pass to that script (/article/1). Caddy does this by looking for the script extension (usually .php). To be helpful and user-friendly, Caddy tries to find this extension case-insensitively. And that is exactly where the wheels fall off.
The root cause of this vulnerability is a discrepancy between the length of a string in its original form and its length after being lowercased. In ASCII, A and a are both 1 byte. The world is simple. But in the Unicode standard, this guarantee vanishes.
Consider the character ẞ (Latin Capital Letter Sharp S, \u1E9E). In UTF-8, this character requires 3 bytes. When you run Go's strings.ToLower() on it, it transforms into ß (Latin Small Letter Sharp S, \u00DF), which requires only 2 bytes.
The Logical Fallacy: Caddy's logic proceeded in three fatal steps:
.php..php inside this lowercased string.Because the lowercasing operation changed the byte length of the string (shrinking or expanding it depending on the characters used), the index found in step 2 is invalid for the string in step 3. It points to the wrong byte offset. It's like measuring a distance on a map, folding the map in half, and then trying to use that same measurement on the real terrain. You're going to end up in the wrong place.
Let's look at a reconstruction of the vulnerable logic to understand exactly what happened. This is a pattern I call "Index Confusion."
// Vulnerable Logic Concept
func splitFastCGIPath(path string) (script, info string) {
// 1. Create a lowercased copy to find extensions case-insensitively
lowerPath := strings.ToLower(path)
// 2. Find where ".php" starts in the lowercased version
splitIndex := strings.Index(lowerPath, ".php")
if splitIndex == -1 {
return path, ""
}
// 3. CRITICAL FAIL: Using the index from 'lowerPath' on 'path'
// If len(lowerPath) != len(path), this cut is garbage.
scriptName := path[:splitIndex+4] // +4 for ".php"
pathInfo := path[splitIndex+4:]
return scriptName, pathInfo
}The Patch: The fix in Caddy v2.11.1 is straightforward but essential: do not mix indices across different string representations. If you need to search case-insensitively, you must track the byte offsets in the original string, or perform the check on the original string using a custom case-insensitive comparison that doesn't alter the buffer length.
> [!NOTE]
> Developer Lesson: If you mutate a string (lowercase, normalize, trim), all previous indices into that string are arguably void. Never apply an index from Mutation(A) back to A unless you can mathematically guarantee 1:1 byte mapping.
How do we weaponize a byte offset mismatch? We use it to trick the server into agreeing to execute a file it normally wouldn't. The goal is to upload a malicious payload (hidden in an image) and force Caddy to feed it to the PHP interpreter.
Step 1: The Setup
We upload a file named avatar.png. Inside this valid PNG image, we embed a PHP webshell in the metadata comments:
<?php system($_GET['cmd']); ?>.
Step 2: The Attack Vector We need to construct a URL that meets two criteria:
.php (so Caddy triggers the FastCGI transport).ẞ (or similar) to trigger the index drift.Request: GET /uploads/avatar.png/fooẞ/bar.php
Step 3: The Execution Let's trace the execution flow:
/uploads/avatar.png/foo + ẞ (3 bytes) + /bar.php/uploads/avatar.png/foo + ß (2 bytes) + /bar.phpIn the Lower Path, the string has shrunk by 1 byte (3 bytes became 2). The .php extension appears 1 byte earlier than it does in the Original Path.
When Caddy calculates the split index based on Lower Path, it gets index N. When it slices Original Path[:N], it cuts the string 1 byte short of the actual .php location (relative to the content). Depending on the exact positioning and padding, this allows the attacker to manipulate exactly where the SCRIPT_FILENAME ends.
In a successful exploit scenario, the desynchronization causes Caddy to sanitize the path incorrectly or identify the split point such that the PATH_INFO is swallowed, and the SCRIPT_FILENAME passed to the backend becomes /uploads/avatar.png. The PHP-FPM backend, blindly trusting the web server, executes the PNG as PHP. Boom. RCE.
This vulnerability scores an 8.9 (High) on the CVSS scale for good reason. It is an unauthenticated Remote Code Execution flaw. If you are running Caddy with PHP (or FrankenPHP) and you allow users to upload files—avatars, documents, attachments—you are likely vulnerable.
The Blast Radius:
*.php in the URL might miss this if they aren't normalizing Unicode exactly the same way Go does. The request looks like a path traversing past an image, which is often valid behavior for image processing scripts.Once code execution is achieved, the attacker has the privileges of the www-data user (or whoever runs Caddy). From there, they can read database credentials, modify source code, or pivot to the internal network. It is a game-over scenario.
Remediation is binary: you are either patched or you are exposed.
1. Update Immediately
2. Mitigation Strategies (If you can't update)
If you are stuck on an old version (why?), you can mitigate this by strictly validating request paths at the reverse proxy level before they hit Caddy, blocking any request containing the sequence .php that also contains high-byte Unicode characters. However, this is brittle.
Another robust defense is ensuring that your upload directories are outside the web root, or explicitly instructing the web server to never execute PHP files in upload directories, regardless of what the FastCGI transport thinks. In Caddy, this can be done with a respond directive for specific paths.
# Caddyfile Mitigation Example
@uploads {
path /uploads/*
}
respond @uploads 403This simple block ensures that even if the path confusion occurs, the server refuses to serve the request from that directory.
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 |
|---|---|---|
Caddy CaddyServer | < 2.11.1 | 2.11.1 |
FrankenPHP Dunglas | All versions using Caddy < 2.11.1 | Latest |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-180 (Incorrect Behavior Order) |
| CVSS v4.0 | 8.9 (High) |
| Attack Vector | Network |
| Attack Complexity | Low |
| Privileges Required | None |
| Exploit Status | Proof-of-Concept Available |
The software performs an operation (lowercasing) that changes the length of the data, but uses the resulting indices to process the original data, leading to incorrect parsing.