{"id":"CVE-2026-24895","aliases":["GHSA-g966-83w7-6w38","GO-2026-4486"],"url":"https://o3.security/vulnerability/CVE-2026-24895","summary":"FrankenPHP affected by Path Confusion via Unicode casing in CGI path splitting allows execution of arbitrary files","details":"### Summary\n\nFrankenPHP’s CGI path splitting logic improperly handles Unicode characters during case conversion. The logic computes the split index (for finding `.php`) on a lowercased copy of the request path but applies that byte index to the original path.\n\nBecause `strings.ToLower()` in Go can increase the byte length of certain UTF-8 characters (e.g., `Ⱥ` expands when lowercased), the computed index may not align with the correct position in the original string. This results in an incorrect `SCRIPT_NAME` and `SCRIPT_FILENAME`, potentially causing FrankenPHP to execute a file other than the one intended by the URI.\n\n### **Details**\n\nThe vulnerability resides in the `splitPos()` function and its usage within `splitCgiPath()`. The logic attempts to find the script extension (e.g., `.php`) in a case-insensitive manner by lowercasing the path:\n\n```go\nlowerPath := strings.ToLower(path)\nidx := strings.Index(lowerPath, strings.ToLower(split))\nreturn idx + len(split)\n```\n\nThe issue is that the returned `idx` represents a byte offset within `lowerPath`. However, `splitCgiPath()` uses this index to slice the **original** `path`:\n\n```go\nfc.docURI = path[:splitPos]\nfc.pathInfo = path[splitPos:]\nfc.scriptName = strings.TrimSuffix(path, fc.pathInfo)\nfc.scriptFilename = sanitizedPathJoin(fc.documentRoot, fc.scriptName)\n```\n\nThis logic relies on the assumption that `len(strings.ToLower(path)) == len(path)`. This assumption is false for certain Unicode characters. For example, the character `Ⱥ` (U+023A) requires 2 bytes in UTF-8 (`0xC8 0xBA`), but its lowercase equivalent `ⱥ` (U+2C65) requires 3 bytes (`0xE2 0xB1 0xA5`).\n\nIf the path contains such characters before the `.php` extension, the index calculated on `lowerPath` will be larger than the corresponding visual point in the original `path`. When applied to the original path, the split occurs at the wrong byte offset. This can cause the server to treat a larger portion of the path as the script name, effectively allowing an attacker to manipulate `SCRIPT_FILENAME`.\n\n### **PoC**\n\nThe following Go program demonstrates the discrepancy between the byte index in the lowercased string versus the original string.\n\n1. Save the following as `poc.go`:\n\n```go\npackage main\n\nimport (\n    \"fmt\"\n    \"strings\"\n)\n\nfunc splitPos(path string, split string) int {\n    lowerPath := strings.ToLower(path)\n    idx := strings.Index(lowerPath, strings.ToLower(split))\n    if idx < 0 {\n        return -1\n    }\n    return idx + len(split)\n}\n\nfunc main() {\n    // U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.\n    // We construct a path where the byte expansion shifts the index.\n    path := \"/ȺȺȺȺshell.php.txt.php\"\n    split := \".php\"\n\n    pos := splitPos(path, split)\n\n    fmt.Printf(\"orig bytes=%d\\n\", len(path))\n    fmt.Printf(\"lower bytes=%d\\n\", len(strings.ToLower(path)))\n    fmt.Printf(\"splitPos=%d\\n\", pos)\n\n    // Current Unsafe Behavior:\n    fmt.Printf(\"orig[:pos] (Calculated Script)=%q\\n\", path[:pos])\n    fmt.Printf(\"orig[pos:] (Calculated PathInfo)=%q\\n\", path[pos:])\n\n    // Expected Safe Behavior:\n    want := strings.Index(path, split) + len(split)\n    fmt.Printf(\"expected splitPos=%d\\n\", want)\n    fmt.Printf(\"expected orig[:]=%q\\n\", path[:want])\n}\n```\n\n2. Run the PoC:\n\n```console\ngo run poc.go\n```\n\n3. **Output:**\n\n```text\norig bytes=26\nlower bytes=30\nsplitPos=22\norig[:pos]=\"/ȺȺȺȺshell.php.txt\"\norig[pos:]=\".php\"\nexpected splitPos=18\nexpected orig[:]=\"/ȺȺȺȺshell.php\"\n```\n\nIn this example, FrankenPHP would identify `/ȺȺȺȺshell.php.txt` as the PHP script to execute, ignoring the fact that the actual file extension in the file system might be `.txt`.\n\n### Impact*\n\nThis is a **Security Boundary Bypass** and **Path Confusion** vulnerability.\n\nIn setups where users can upload files (e.g., avatars, text files) that are stored within the document root or a reachable path, an attacker can upload a file containing malicious PHP code with a safe extension (e.g., `payload.txt`). By crafting a request with specific Unicode characters, the attacker can force FrankenPHP to calculate the `SCRIPT_FILENAME` as ending in `payload.txt`, while the request appears to contain `.php` to the internal router logic.\n\nThis results in the execution of non-PHP files as PHP scripts, leading to **Remote Code Execution (RCE)**.\n\n### **Patched Versions**\n\n* This issue is fixed in FrankenPHP version **1.11.2**.\n\n### **Workarounds**\n\n* Ensure that user-uploaded files are stored outside of the public document root.\n* Implement strict WAF rules to reject requests containing specific multi-byte Unicode characters in the URL path if an upgrade is not immediately possible.","published":"2026-02-12T19:16:06.618Z","modified":"2026-08-12T03:51:33.307147757Z","cvss":null,"epss":{"score":0.00573,"percentile":0.44174,"asOf":"2026-08-08"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Go","name":"github.com/dunglas/frankenphp","fixedVersion":"1.11.2"}],"fix":{"url":"https://github.com/php/frankenphp/commit/04fdc0c1e8fde94e2c1ad86217e962c88d27c53e","label":"php/frankenphp@04fdc0c"},"references":[{"type":"WEB","url":"https://github.com/php/frankenphp/releases/tag/v1.11.2"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/24xxx/CVE-2026-24895.json"},{"type":"ADVISORY","url":"https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-24895"},{"type":"FIX","url":"https://github.com/php/frankenphp/commit/04fdc0c1e8fde94e2c1ad86217e962c88d27c53e"},{"type":"PACKAGE","url":"https://github.com/php/frankenphp"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:33.307147757Z"}}