Monitor vulnerabilities like this one.
Sign up free to get alerted when software you use is affected.
8.9
Caddy: Unicode case-folding length expansion causes incorrect split_path index in FastCGI transport
CVE-2026-27590
GHSA-5r3v-vc8m-m96g
Summary
### Summary
Caddy's FastCGI path splitting logic computes the split index on a lowercased copy of the request path and then uses that byte index to slice the original path. This is unsafe for Unicode because `strings.ToLower()` can change UTF-8 byte length for some characters. As a result, Caddy ca...
What to do
- Update github.com caddyserver to version 2.11.1.
Affected software
| Vendor | Product | Affected versions | Fix available |
|---|---|---|---|
| github.com | caddyserver | <= 2.11.1 | 2.11.1 |
| caddyserver | caddy | <= 2.11.1 | – |
Original title
Caddy: Unicode case-folding length expansion causes incorrect split_path index in FastCGI transport
Original description
### Summary
Caddy's FastCGI path splitting logic computes the split index on a lowercased copy of the request path and then uses that byte index to slice the original path. This is unsafe for Unicode because `strings.ToLower()` can change UTF-8 byte length for some characters. As a result, Caddy can derive an incorrect `SCRIPT_NAME`/`SCRIPT_FILENAME` and `PATH_INFO`, potentially causing a request that contains `.php` to execute a different on-disk file than intended (path confusion). In setups where an attacker can control file contents (e.g., upload features), this can lead to unintended PHP execution of non-.php files (potential RCE depending on deployment).
### Details
The issue is in `github.com/caddyserver/caddy/modules/caddyhttp/fastcgi.Trasnport.splitPos()` (and the subsequent slicing in `buildEnv()`):
```
lowerPath := strings.ToLower(path)
idx := strings.Index(lowerPath, strings.ToLower(split))
return idx + len(split)
```
The returned index is computed in the byte space of lowerPath, but `buildEnv()` applies it to the original path:
- `docURI = path[:splitPos]`
- `pathInfo = path[splitPos:]`
- `scriptName = strings.TrimSuffix(path, fc.pathInfo)`
- `scriptFilename = caddyhttp.SanitizedPathJoin(fc.documentRoot, fc.scriptName)`
This assumes `lowerPath` and `path` have identical byte lengths and identical byte offsets, which is not true for some Unicode case mappings. Certain characters expand when lowercased (UTF-8 byte length increases), shifting the computed index. This creates a mismatch where `.php` is found in the lowercased string at an offset that does not correspond to the same position in the original string, causing the split point to land later/earlier than intended.
### PoC
Create a small Go program that reproduces Caddy's `splitPos()` behavior (compute the `.php` split point on a lowercased path, then use that byte index on the original path):
1. Save this as `poc.go`:
```go
package main
import (
"fmt"
"strings"
)
func splitPos(path string, split string) int {
lowerPath := strings.ToLower(path)
idx := strings.Index(lowerPath, strings.ToLower(split))
if idx < 0 {
return -1
}
return idx + len(split)
}
func main() {
// U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.
path := "/ȺȺȺȺshell.php.txt.php"
split := ".php"
pos := splitPos(path, split)
fmt.Printf("orig bytes=%d\n", len(path))
fmt.Printf("lower bytes=%d\n", len(strings.ToLower(path)))
fmt.Printf("splitPos=%d\n", pos)
fmt.Printf("orig[:pos]=%q\n", path[:pos])
fmt.Printf("orig[pos:]=%q\n", path[pos:])
// Expected split: right after the first ".php" in the original string
want := strings.Index(path, split) + len(split)
fmt.Printf("expected splitPos=%d\n", want)
fmt.Printf("expected orig[:]=%q\n", path[:want])
}
```
2. Run it:
```console
go run poc.go
```
Output on my side:
```
orig bytes=26
lower bytes=30
splitPos=22
orig[:pos]="/ȺȺȺȺshell.php.txt"
orig[pos:]=".php"
expected splitPos=18
expected orig[:]="/ȺȺȺȺshell.php"
```
Expected split is right after the first `.php` (`/ȺȺȺȺshell.php`). Instead, the computed split lands later and cuts the original path after `shell.php.txt`, leaving `.php` as the remainder.
### Impact
Security boundary bypass/path confusion in script resolution.
In typical deployments, `.php` extension boundaries are relied on to decide what is executed by PHP. This bug can cause Caddy/FPM to execute a different file than intended by confusing `SCRIPT_NAME`/`SCRIPT_FILENAME`. If an attacker can place attacker-controlled content into a file that can be resolved as `SCRIPT_FILENAME` (common in web apps with uploads or writable directories), this can lead to unintended PHP execution of non-.php files and potentially remote code execution. Severity depends on deployment and presence of attacker-controlled file writes, but the primitive itself is remotely triggerable via crafted URLs.
This vulnerability was initially reported to FrankenPHP (https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38) by @AbdrrahimDahmani. The affected code has been copied/adapted from Caddy, which, according to research, is also affected.
The patch is a port of the FrankenPHP patch.
Caddy's FastCGI path splitting logic computes the split index on a lowercased copy of the request path and then uses that byte index to slice the original path. This is unsafe for Unicode because `strings.ToLower()` can change UTF-8 byte length for some characters. As a result, Caddy can derive an incorrect `SCRIPT_NAME`/`SCRIPT_FILENAME` and `PATH_INFO`, potentially causing a request that contains `.php` to execute a different on-disk file than intended (path confusion). In setups where an attacker can control file contents (e.g., upload features), this can lead to unintended PHP execution of non-.php files (potential RCE depending on deployment).
### Details
The issue is in `github.com/caddyserver/caddy/modules/caddyhttp/fastcgi.Trasnport.splitPos()` (and the subsequent slicing in `buildEnv()`):
```
lowerPath := strings.ToLower(path)
idx := strings.Index(lowerPath, strings.ToLower(split))
return idx + len(split)
```
The returned index is computed in the byte space of lowerPath, but `buildEnv()` applies it to the original path:
- `docURI = path[:splitPos]`
- `pathInfo = path[splitPos:]`
- `scriptName = strings.TrimSuffix(path, fc.pathInfo)`
- `scriptFilename = caddyhttp.SanitizedPathJoin(fc.documentRoot, fc.scriptName)`
This assumes `lowerPath` and `path` have identical byte lengths and identical byte offsets, which is not true for some Unicode case mappings. Certain characters expand when lowercased (UTF-8 byte length increases), shifting the computed index. This creates a mismatch where `.php` is found in the lowercased string at an offset that does not correspond to the same position in the original string, causing the split point to land later/earlier than intended.
### PoC
Create a small Go program that reproduces Caddy's `splitPos()` behavior (compute the `.php` split point on a lowercased path, then use that byte index on the original path):
1. Save this as `poc.go`:
```go
package main
import (
"fmt"
"strings"
)
func splitPos(path string, split string) int {
lowerPath := strings.ToLower(path)
idx := strings.Index(lowerPath, strings.ToLower(split))
if idx < 0 {
return -1
}
return idx + len(split)
}
func main() {
// U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.
path := "/ȺȺȺȺshell.php.txt.php"
split := ".php"
pos := splitPos(path, split)
fmt.Printf("orig bytes=%d\n", len(path))
fmt.Printf("lower bytes=%d\n", len(strings.ToLower(path)))
fmt.Printf("splitPos=%d\n", pos)
fmt.Printf("orig[:pos]=%q\n", path[:pos])
fmt.Printf("orig[pos:]=%q\n", path[pos:])
// Expected split: right after the first ".php" in the original string
want := strings.Index(path, split) + len(split)
fmt.Printf("expected splitPos=%d\n", want)
fmt.Printf("expected orig[:]=%q\n", path[:want])
}
```
2. Run it:
```console
go run poc.go
```
Output on my side:
```
orig bytes=26
lower bytes=30
splitPos=22
orig[:pos]="/ȺȺȺȺshell.php.txt"
orig[pos:]=".php"
expected splitPos=18
expected orig[:]="/ȺȺȺȺshell.php"
```
Expected split is right after the first `.php` (`/ȺȺȺȺshell.php`). Instead, the computed split lands later and cuts the original path after `shell.php.txt`, leaving `.php` as the remainder.
### Impact
Security boundary bypass/path confusion in script resolution.
In typical deployments, `.php` extension boundaries are relied on to decide what is executed by PHP. This bug can cause Caddy/FPM to execute a different file than intended by confusing `SCRIPT_NAME`/`SCRIPT_FILENAME`. If an attacker can place attacker-controlled content into a file that can be resolved as `SCRIPT_FILENAME` (common in web apps with uploads or writable directories), this can lead to unintended PHP execution of non-.php files and potentially remote code execution. Severity depends on deployment and presence of attacker-controlled file writes, but the primitive itself is remotely triggerable via crafted URLs.
This vulnerability was initially reported to FrankenPHP (https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38) by @AbdrrahimDahmani. The affected code has been copied/adapted from Caddy, which, according to research, is also affected.
The patch is a port of the FrankenPHP patch.
nvd CVSS3.1
9.8
nvd CVSS4.0
8.9
Vulnerability type
CWE-20
Improper Input Validation
CWE-180
- https://nvd.nist.gov/vuln/detail/CVE-2026-27590
- https://pkg.go.dev/vuln/GO-2026-4536
- https://github.com/advisories/GHSA-5r3v-vc8m-m96g
- https://github.com/caddyserver/caddy/releases/tag/v2.11.1 Release Notes
- https://github.com/caddyserver/caddy/security/advisories/GHSA-5r3v-vc8m-m96g Exploit Vendor Advisory
- https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38 Third Party Advisory
Published: 24 Feb 2026 · Updated: 12 Mar 2026 · First seen: 6 Mar 2026