Summary
Caddy's FastCGI path splitting logic computes the split index on a lowercased copy of the request path and then uses that byte index to slice the original path. This is unsafe for Unicode because strings.ToLower() can change UTF-8 byte length for some characters. As a result, Caddy can derive an incorrect SCRIPT_NAME/SCRIPT_FILENAME and PATH_INFO, potentially causing a request that contains .php to execute a different on-disk file than intended (path confusion). In setups where an attacker can control file contents (e.g., upload features), this can lead to unintended PHP execution of non-.php files (potential RCE depending on deployment).
Details
The issue is in github.com/caddyserver/caddy/modules/caddyhttp/fastcgi.Trasnport.splitPos() (and the subsequent slicing in buildEnv()):
lowerPath := strings.ToLower(path)
idx := strings.Index(lowerPath, strings.ToLower(split))
return idx + len(split)
The returned index is computed in the byte space of lowerPath, but buildEnv() applies it to the original path:
docURI = path[:splitPos]
pathInfo = path[splitPos:]
scriptName = strings.TrimSuffix(path, fc.pathInfo)
scriptFilename = caddyhttp.SanitizedPathJoin(fc.documentRoot, fc.scriptName)
This assumes lowerPath and path have identical byte lengths and identical byte offsets, which is not true for some Unicode case mappings. Certain characters expand when lowercased (UTF-8 byte length increases), shifting the computed index. This creates a mismatch where .php is found in the lowercased string at an offset that does not correspond to the same position in the original string, causing the split point to land later/earlier than intended.
PoC
Create a small Go program that reproduces Caddy's splitPos() behavior (compute the .php split point on a lowercased path, then use that byte index on the original path):
- Save this as
poc.go:
package main
import (
"fmt"
"strings"
)
func splitPos(path string, split string) int {
lowerPath := strings.ToLower(path)
idx := strings.Index(lowerPath, strings.ToLower(split))
if idx < 0 {
return -1
}
return idx + len(split)
}
func main() {
// U+023A: Ⱥ (UTF-8: C8 BA). Lowercase is ⱥ (UTF-8: E2 B1 A5), longer in bytes.
path := "/ȺȺȺȺshell.php.txt.php"
split := ".php"
pos := splitPos(path, split)
fmt.Printf("orig bytes=%d\n", len(path))
fmt.Printf("lower bytes=%d\n", len(strings.ToLower(path)))
fmt.Printf("splitPos=%d\n", pos)
fmt.Printf("orig[:pos]=%q\n", path[:pos])
fmt.Printf("orig[pos:]=%q\n", path[pos:])
// Expected split: right after the first ".php" in the original string
want := strings.Index(path, split) + len(split)
fmt.Printf("expected splitPos=%d\n", want)
fmt.Printf("expected orig[:]=%q\n", path[:want])
}
- Run it:
Output on my side:
orig bytes=26
lower bytes=30
splitPos=22
orig[:pos]="/ȺȺȺȺshell.php.txt"
orig[pos:]=".php"
expected splitPos=18
expected orig[:]="/ȺȺȺȺshell.php"
Expected split is right after the first .php (/ȺȺȺȺshell.php). Instead, the computed split lands later and cuts the original path after shell.php.txt, leaving .php as the remainder.
Impact
Security boundary bypass/path confusion in script resolution.
In typical deployments, .php extension boundaries are relied on to decide what is executed by PHP. This bug can cause Caddy/FPM to execute a different file than intended by confusing SCRIPT_NAME/SCRIPT_FILENAME. If an attacker can place attacker-controlled content into a file that can be resolved as SCRIPT_FILENAME (common in web apps with uploads or writable directories), this can lead to unintended PHP execution of non-.php files and potentially remote code execution. Severity depends on deployment and presence of attacker-controlled file writes, but the primitive itself is remotely triggerable via crafted URLs.
This vulnerability was initially reported to FrankenPHP (GHSA-g966-83w7-6w38) by @AbdrrahimDahmani. The affected code has been copied/adapted from Caddy, which, according to research, is also affected.
The patch is a port of the FrankenPHP patch.
References
Summary
Caddy's FastCGI path splitting logic computes the split index on a lowercased copy of the request path and then uses that byte index to slice the original path. This is unsafe for Unicode because
strings.ToLower()can change UTF-8 byte length for some characters. As a result, Caddy can derive an incorrectSCRIPT_NAME/SCRIPT_FILENAMEandPATH_INFO, potentially causing a request that contains.phpto execute a different on-disk file than intended (path confusion). In setups where an attacker can control file contents (e.g., upload features), this can lead to unintended PHP execution of non-.php files (potential RCE depending on deployment).Details
The issue is in
github.com/caddyserver/caddy/modules/caddyhttp/fastcgi.Trasnport.splitPos()(and the subsequent slicing inbuildEnv()):The returned index is computed in the byte space of lowerPath, but
buildEnv()applies it to the original path:docURI = path[:splitPos]pathInfo = path[splitPos:]scriptName = strings.TrimSuffix(path, fc.pathInfo)scriptFilename = caddyhttp.SanitizedPathJoin(fc.documentRoot, fc.scriptName)This assumes
lowerPathandpathhave identical byte lengths and identical byte offsets, which is not true for some Unicode case mappings. Certain characters expand when lowercased (UTF-8 byte length increases), shifting the computed index. This creates a mismatch where.phpis found in the lowercased string at an offset that does not correspond to the same position in the original string, causing the split point to land later/earlier than intended.PoC
Create a small Go program that reproduces Caddy's
splitPos()behavior (compute the.phpsplit point on a lowercased path, then use that byte index on the original path):poc.go:go run poc.goOutput on my side:
Expected split is right after the first
.php(/ȺȺȺȺshell.php). Instead, the computed split lands later and cuts the original path aftershell.php.txt, leaving.phpas the remainder.Impact
Security boundary bypass/path confusion in script resolution.
In typical deployments,
.phpextension boundaries are relied on to decide what is executed by PHP. This bug can cause Caddy/FPM to execute a different file than intended by confusingSCRIPT_NAME/SCRIPT_FILENAME. If an attacker can place attacker-controlled content into a file that can be resolved asSCRIPT_FILENAME(common in web apps with uploads or writable directories), this can lead to unintended PHP execution of non-.php files and potentially remote code execution. Severity depends on deployment and presence of attacker-controlled file writes, but the primitive itself is remotely triggerable via crafted URLs.This vulnerability was initially reported to FrankenPHP (GHSA-g966-83w7-6w38) by @AbdrrahimDahmani. The affected code has been copied/adapted from Caddy, which, according to research, is also affected.
The patch is a port of the FrankenPHP patch.
References