# Caddy: Unicode case-folding length expansion causes incorrect split_path index in FastCGI transport

Caddy's FastCGI splitPos computes the .php split index on a lowercased path, but Unicode case-folding expands byte length, shifting the slice and causing path confusion that can execute non-.php files.

- Payout: Research
- Severity: high
- Program: Caddy
- Found by: dunglas, AbdrrahimDahmani
- Disclosed: 2026-02-24
- CVE: CVE-2026-27590
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-5r3v-vc8m-m96g
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-5r3v-vc8m-m96g
- Tags: area:oss domain:oss impact:integrity impact:rce kind:research platform:ghsa sev:high tech:go tech:php technique:unicode-normalization vuln:misconfiguration vuln:path-traversal

**Target:** Caddy FastCGI transport (splitPos/buildEnv) resolving SCRIPT_NAME/PATH_INFO
**Root cause:** splitPos() computes the split index in the byte space of strings.ToLower(path), but buildEnv() applies that index to the original path. Unicode case mappings like U+023A expand UTF-8 byte length when lowercased, so the index no longer aligns with the original string.
**Steps:**
1. Craft a path where a character expands in byte length when lowercased, e.g. `/ȺȺȺȺshell.php.txt.php` (U+023A lowercases to a 3-byte char).
2. splitPos lowercases the path, finds `.php` at a later byte offset, and returns `idx + len(split)` computed in lowercased byte space.
3. buildEnv slices the original path at that index: `docURI = path[:splitPos]`, `pathInfo = path[pos:]`, yielding `orig[:pos]="/ȺȺȺȺshell.php.txt"` and `orig[pos:]=".php"` instead of splitting after the first `.php`.
4. Resulting SCRIPT_FILENAME resolves to `shell.php.txt` while PATH_INFO is `.php`, causing PHP to execute a non-.php file.
**How found:** Reporter noticed splitPos computes the index on a lowercased copy but buildEnv slices the original path, then hypothesized that Unicode case-folding length changes would desynchronize the byte offsets; verified with a Go PoC showing orig bytes=26 vs lower bytes=30 and splitPos=22 vs expected 18.
**Exploitation:** Attacker sends a crafted URL with Unicode case-expanding characters so Caddy derives SCRIPT_FILENAME pointing at an attacker-writable non-.php file (e.g. an uploaded file) while PATH_INFO ends in .php, causing FPM to execute that file's contents as PHP. Requires attacker-controlled file write in the document root.
**Escalation:** Chains path confusion with an upload/writable-directory primitive to achieve unintended PHP execution, potentially RCE.
**Insight:** When code lowercases/normalizes a string to find an index but slices the original, any Unicode case mapping that changes UTF-8 byte length (e.g. U+023A Ⱥ -> 3-byte ⱥ) desynchronizes offsets; test such index computations with expanding characters, and check copied/adapted code across sibling projects (FrankenPHP -> Caddy).

## Proof of concept (copied exactly from the original report)

**Go PoC reproducing splitPos index mismatch**

```
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])
}
```

**Vulnerable splitPos implementation**

```
lowerPath := strings.ToLower(path)
idx := strings.Index(lowerPath, strings.ToLower(split))
return idx + len(split)
```

