CVEReports
CVEReports

Automated vulnerability intelligence platform. Comprehensive reports for high-severity CVEs generated by AI.

Product

  • Home
  • Sitemap
  • RSS Feed

Company

  • About
  • Contact
  • Privacy Policy
  • Terms of Service

© 2026 CVEReports. All rights reserved.

Made with love by Amit Schendel & Alon Barad



CVE-2026-27590
8.9

Lost in Translation: Unicode Path Confusion in Caddy & FrankenPHP

Alon Barad
Alon Barad
Software Engineer

Feb 24, 2026·7 min read·3 visits

PoC Available

Executive Summary (TL;DR)

Caddy and FrankenPHP contain a 'Path Confusion' vulnerability due to unsafe Unicode lowercasing in the FastCGI module. An attacker can upload a file with a safe extension (e.g., .png) and request it with a specially crafted URL containing Unicode characters (like 'ẞ'). The server miscalculates the file path, executing the image as a PHP script. Immediate patching to v2.11.1 is required.

A critical logic flaw in Caddy's FastCGI transport layer allows for Remote Code Execution (RCE) via Unicode case-folding collisions. By exploiting how Go's `strings.ToLower` handles specific Unicode characters, attackers can desynchronize path parsing, tricking the server into executing arbitrary files (like images) as PHP scripts. This affects Caddy and the popular FrankenPHP application server.

The Hook: When Safe Languages Bite

We often laud Go for its memory safety. It saves us from the buffer overflows and dangling pointers that plague C and C++. But memory safety doesn't save you from logic safety, and it certainly doesn't save you from the absolute nightmare that is Unicode handling.

Enter CVE-2026-27590. This isn't a complex heap feng-shui attack. It's a classic case of "the developer assumed X, but the spec says Y." In this instance, the affected component is the FastCGI transport in Caddy (and by extension, FrankenPHP). FastCGI is the protocol that allows web servers to talk to application backends like PHP-FPM.

To make this communication work, the server needs to split a request URL—say /blog/index.php/article/1—into two parts: the script to execute (/blog/index.php) and the path info to pass to that script (/article/1). Caddy does this by looking for the script extension (usually .php). To be helpful and user-friendly, Caddy tries to find this extension case-insensitively. And that is exactly where the wheels fall off.

The Flaw: A Tale of Two Lengths

The root cause of this vulnerability is a discrepancy between the length of a string in its original form and its length after being lowercased. In ASCII, A and a are both 1 byte. The world is simple. But in the Unicode standard, this guarantee vanishes.

Consider the character ẞ (Latin Capital Letter Sharp S, \u1E9E). In UTF-8, this character requires 3 bytes. When you run Go's strings.ToLower() on it, it transforms into ß (Latin Small Letter Sharp S, \u00DF), which requires only 2 bytes.

The Logical Fallacy: Caddy's logic proceeded in three fatal steps:

  1. Clone & Lower: It created a lowercased version of the request path to search for .php.
  2. Locate: It found the index of .php inside this lowercased string.
  3. Slice: It applied that index to the original, mixed-case string.

Because the lowercasing operation changed the byte length of the string (shrinking or expanding it depending on the characters used), the index found in step 2 is invalid for the string in step 3. It points to the wrong byte offset. It's like measuring a distance on a map, folding the map in half, and then trying to use that same measurement on the real terrain. You're going to end up in the wrong place.

The Code: The Smoking Gun

Let's look at a reconstruction of the vulnerable logic to understand exactly what happened. This is a pattern I call "Index Confusion."

// Vulnerable Logic Concept
func splitFastCGIPath(path string) (script, info string) {
    // 1. Create a lowercased copy to find extensions case-insensitively
    lowerPath := strings.ToLower(path)
 
 
    // 2. Find where ".php" starts in the lowercased version
    splitIndex := strings.Index(lowerPath, ".php")
    if splitIndex == -1 {
        return path, ""
    }
 
 
    // 3. CRITICAL FAIL: Using the index from 'lowerPath' on 'path'
    // If len(lowerPath) != len(path), this cut is garbage.
    scriptName := path[:splitIndex+4] // +4 for ".php"
    pathInfo := path[splitIndex+4:]
 
 
    return scriptName, pathInfo
}

The Patch: The fix in Caddy v2.11.1 is straightforward but essential: do not mix indices across different string representations. If you need to search case-insensitively, you must track the byte offsets in the original string, or perform the check on the original string using a custom case-insensitive comparison that doesn't alter the buffer length.

> [!NOTE] > Developer Lesson: If you mutate a string (lowercase, normalize, trim), all previous indices into that string are arguably void. Never apply an index from Mutation(A) back to A unless you can mathematically guarantee 1:1 byte mapping.

The Exploit: From Image to Shell

How do we weaponize a byte offset mismatch? We use it to trick the server into agreeing to execute a file it normally wouldn't. The goal is to upload a malicious payload (hidden in an image) and force Caddy to feed it to the PHP interpreter.

Step 1: The Setup We upload a file named avatar.png. Inside this valid PNG image, we embed a PHP webshell in the metadata comments: <?php system($_GET['cmd']); ?>.

Step 2: The Attack Vector We need to construct a URL that meets two criteria:

  1. It contains .php (so Caddy triggers the FastCGI transport).
  2. It contains the Unicode character ẞ (or similar) to trigger the index drift.

Request: GET /uploads/avatar.png/fooẞ/bar.php

Step 3: The Execution Let's trace the execution flow:

  • Original Path: /uploads/avatar.png/foo + ẞ (3 bytes) + /bar.php
  • Lower Path: /uploads/avatar.png/foo + ß (2 bytes) + /bar.php

In the Lower Path, the string has shrunk by 1 byte (3 bytes became 2). The .php extension appears 1 byte earlier than it does in the Original Path.

When Caddy calculates the split index based on Lower Path, it gets index N. When it slices Original Path[:N], it cuts the string 1 byte short of the actual .php location (relative to the content). Depending on the exact positioning and padding, this allows the attacker to manipulate exactly where the SCRIPT_FILENAME ends.

In a successful exploit scenario, the desynchronization causes Caddy to sanitize the path incorrectly or identify the split point such that the PATH_INFO is swallowed, and the SCRIPT_FILENAME passed to the backend becomes /uploads/avatar.png. The PHP-FPM backend, blindly trusting the web server, executes the PNG as PHP. Boom. RCE.

The Impact: Why You Should Panic

This vulnerability scores an 8.9 (High) on the CVSS scale for good reason. It is an unauthenticated Remote Code Execution flaw. If you are running Caddy with PHP (or FrankenPHP) and you allow users to upload files—avatars, documents, attachments—you are likely vulnerable.

The Blast Radius:

  • FrankenPHP Users: Since FrankenPHP is built on top of Caddy and promotes itself as a modern PHP app server, this is a core vulnerability for them. It turns a standard "upload profile picture" feature into a "upload rootkit" feature.
  • WAF Bypass: Most WAFs looking for *.php in the URL might miss this if they aren't normalizing Unicode exactly the same way Go does. The request looks like a path traversing past an image, which is often valid behavior for image processing scripts.

Once code execution is achieved, the attacker has the privileges of the www-data user (or whoever runs Caddy). From there, they can read database credentials, modify source code, or pivot to the internal network. It is a game-over scenario.

The Fix: Stopping the Bleeding

Remediation is binary: you are either patched or you are exposed.

1. Update Immediately

  • Caddy: Upgrade to version v2.11.1 or higher.
  • FrankenPHP: Check for the latest release that bundles Caddy v2.11.1 logic.

2. Mitigation Strategies (If you can't update) If you are stuck on an old version (why?), you can mitigate this by strictly validating request paths at the reverse proxy level before they hit Caddy, blocking any request containing the sequence .php that also contains high-byte Unicode characters. However, this is brittle.

Another robust defense is ensuring that your upload directories are outside the web root, or explicitly instructing the web server to never execute PHP files in upload directories, regardless of what the FastCGI transport thinks. In Caddy, this can be done with a respond directive for specific paths.

# Caddyfile Mitigation Example
@uploads {
    path /uploads/*
}
respond @uploads 403

This simple block ensures that even if the path confusion occurs, the server refuses to serve the request from that directory.

Official Patches

CaddyCaddy v2.11.1 Release Notes
FrankenPHPFrankenPHP Advisory

Fix Analysis (1)

Technical Appendix

CVSS Score
8.9/ 10
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

Affected Systems

Caddy Web Server (< v2.11.1)FrankenPHPAny Go application using `strings.ToLower` for path offsets

Affected Versions Detail

Product
Affected Versions
Fixed Version
Caddy
CaddyServer
< 2.11.12.11.1
FrankenPHP
Dunglas
All versions using Caddy < 2.11.1Latest
AttributeDetail
CWE IDCWE-180 (Incorrect Behavior Order)
CVSS v4.08.9 (High)
Attack VectorNetwork
Attack ComplexityLow
Privileges RequiredNone
Exploit StatusProof-of-Concept Available

MITRE ATT&CK Mapping

T1190Exploit Public-Facing Application
Initial Access
T1036Masquerading
Defense Evasion
CWE-180
Validate Before Canonicalize

The software performs an operation (lowercasing) that changes the length of the data, but uses the resulting indices to process the original data, leading to incorrect parsing.

Known Exploits & Detection

GitHub Security AdvisoryProof of Concept involving Unicode character desynchronization

Vulnerability Timeline

Disclosed by @AbdrrahimDahmani
2026-02-24
Caddy v2.11.1 Released with Fix
2026-02-24
GHSA Advisory Published
2026-02-24

References & Sources

  • [1]Caddy GHSA Advisory
  • [2]FrankenPHP GHSA Advisory

Attack Flow Diagram

Press enter or space to select a node. You can then use the arrow keys to move the node around. Press delete to remove it and escape to cancel.
Press enter or space to select an edge. You can then press delete to remove it or escape to cancel.