GHSA-5R3V-VC8M-M96G

Vulnerability from github – Published: 2026-02-24 20:39 – Updated: 2026-02-24 20:39
VLAI?
Summary
Caddy: Unicode case-folding length expansion causes incorrect split_path index in FastCGI transport
Details

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):

  1. 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])
}
  1. Run it:
go run poc.go

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 (https://github.com/php/frankenphp/security/advisories/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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Go",
        "name": "github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy/fastcgi"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.11.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-27590"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-20"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-02-24T20:39:08Z",
    "nvd_published_at": "2026-02-24T17:29:04Z",
    "severity": "HIGH"
  },
  "details": "### Summary\n\nCaddy\u0027s 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).\n\n### Details\n\nThe issue is in `github.com/caddyserver/caddy/modules/caddyhttp/fastcgi.Trasnport.splitPos()` (and the subsequent slicing in `buildEnv()`):\n\n```\nlowerPath := strings.ToLower(path)\nidx := strings.Index(lowerPath, strings.ToLower(split))\nreturn idx + len(split)\n```\n\nThe returned index is computed in the byte space of lowerPath, but `buildEnv()` applies it to the original path:\n\n- `docURI = path[:splitPos]`\n- `pathInfo = path[splitPos:]`\n- `scriptName = strings.TrimSuffix(path, fc.pathInfo)`\n- `scriptFilename = caddyhttp.SanitizedPathJoin(fc.documentRoot, fc.scriptName)`\n\nThis 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.\n\n### PoC\n\nCreate a small Go program that reproduces Caddy\u0027s `splitPos()` behavior (compute the `.php` split point on a lowercased path, then use that byte index on the original path):\n\n1. Save this as `poc.go`:\n\n```go\npackage main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nfunc splitPos(path string, split string) int {\n\tlowerPath := strings.ToLower(path)\n\tidx := strings.Index(lowerPath, strings.ToLower(split))\n\tif idx \u003c 0 {\n\t\treturn -1\n\t}\n\treturn idx + len(split)\n}\n\nfunc main() {\n\t// U+023A: \u023a (UTF-8: C8 BA). Lowercase is \u2c65 (UTF-8: E2 B1 A5), longer in bytes.\n\tpath := \"/\u023a\u023a\u023a\u023ashell.php.txt.php\"\n\tsplit := \".php\"\n\n\tpos := splitPos(path, split)\n\n\tfmt.Printf(\"orig bytes=%d\\n\", len(path))\n\tfmt.Printf(\"lower bytes=%d\\n\", len(strings.ToLower(path)))\n\tfmt.Printf(\"splitPos=%d\\n\", pos)\n\n\tfmt.Printf(\"orig[:pos]=%q\\n\", path[:pos])\n\tfmt.Printf(\"orig[pos:]=%q\\n\", path[pos:])\n\n\t// Expected split: right after the first \".php\" in the original string\n\twant := strings.Index(path, split) + len(split)\n\tfmt.Printf(\"expected splitPos=%d\\n\", want)\n\tfmt.Printf(\"expected orig[:]=%q\\n\", path[:want])\n}\n```\n\n2. Run it:\n\n```console\ngo run poc.go\n```\n\nOutput on my side:\n\n```\norig bytes=26\nlower bytes=30\nsplitPos=22\norig[:pos]=\"/\u023a\u023a\u023a\u023ashell.php.txt\"\norig[pos:]=\".php\"\nexpected splitPos=18\nexpected orig[:]=\"/\u023a\u023a\u023a\u023ashell.php\"\n```\n\nExpected split is right after the first `.php` (`/\u023a\u023a\u023a\u023ashell.php`). Instead, the computed split lands later and cuts the original path after `shell.php.txt`, leaving `.php` as the remainder.\n\n### Impact\n\nSecurity boundary bypass/path confusion in script resolution.\nIn 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.\n\nThis vulnerability was initially reported to FrankenPHP (https://github.com/php/frankenphp/security/advisories/GHSA-g966-83w7-6w38) by @AbdrrahimDahmani. The affected code has been copied/adapted from Caddy, which, according to research, is also affected.\n\nThe patch is a port of the FrankenPHP patch.",
  "id": "GHSA-5r3v-vc8m-m96g",
  "modified": "2026-02-24T20:39:08Z",
  "published": "2026-02-24T20:39:08Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/caddyserver/caddy/security/advisories/GHSA-5r3v-vc8m-m96g"
    },
    {
      "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-27590"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/caddyserver/caddy"
    },
    {
      "type": "WEB",
      "url": "https://github.com/caddyserver/caddy/releases/tag/v2.11.1"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "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",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Caddy: Unicode case-folding length expansion causes incorrect split_path index in FastCGI transport"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.


Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…