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
VendorProductAffected versionsFix 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.
nvd CVSS3.1 9.8
nvd CVSS4.0 8.9
Vulnerability type
CWE-20 Improper Input Validation
CWE-180
Published: 24 Feb 2026 · Updated: 12 Mar 2026 · First seen: 6 Mar 2026