CVE-2026-45692

CVE-2026-45692

Area
Web / AppSec
Credit
Public Attribution
Credit Note
CVSS
5.4
CVSS Source
CNA / Vendor
CVSS Vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N
CWE
CWE-187 · CWE-863
Impact
Authorization Bypass
Key Finding
권한 검사는 routes/01을 1번 문자열로 보지만 실제 탐색은 index 1로 정규화해 허가되지 않은 route에 접근했습니다.
Published
May 13, 2026
Score Note
Severity
Medium
Status
Public
Target
Caddy · Admin /config API
클라이언트 인증서에는 첫 번째 route만 허용됐지만, routes/01을 요청하면 두 번째 route를 읽고 수정할 수 있었습니다. 권한 검사와 실제 객체 탐색을 따로 추적해 이 불일치를 확인한 과정을 정리했습니다.
Taegu Ha · Caddy · CWE-187 · CWE-863 · CVSS 5.4
2026년 5월 13일 공개 · Caddy v2.11.3에서 수정 · 공식 advisory

한눈에 보기

🔎
Caddy admin API의 authorization은 /routes/01을 문자열 segment 01로 보았지만 config traversal은 이를 숫자 index 1로 해석했습니다. /routes/0만 허용된 mTLS client가 route 1을 읽고 수정할 수 있었습니다.
구분
확인 내용
프로젝트·컴포넌트
Caddy admin API config path authorization
공격 입력
leading-zero array index를 포함한 /routes/01
필요 조건
제한된 admin API path만 허용된 authenticated mTLS client
취약 지점
authorization의 문자열 path와 traversal의 numeric index canonicalization 불일치
검증 결과
/routes/0 권한만으로 route 1 read·PATCH 성공
영향
admin API authorization bypass 및 unauthorized config modification
수정 원칙
array index를 한 번 canonicalize하고 policy와 traversal이 같은 표현 사용

공격 흐름

검증 결론: 같은 path가 authorization과 실제 object traversal에서 다르게 해석되어, 인증된 제한 client의 권한 범위를 넘어가는 것을 HTTP 관찰로 확인했습니다.

취약점 개요

Caddy의 원격 관리 기능은 mTLS로 클라이언트 신원을 확인한 뒤, 인증서마다 허용된 HTTP method와 설정 경로를 제한할 수 있습니다. 재현 환경에서는 한 인증서에GETPATCH, 그리고 다음 경로 하나만 허용했습니다.
/config/apps/http/servers/srv/routes/0
그러나 routes/01을 요청하면 권한 검사는 이를 routes/0으로 시작하는 문자열이라고 판단했습니다. 그 다음 설정 탐색기는 01을 정수로 변환해 배열의 1번 원소, 즉 routes[1]을 선택했습니다.
인증된 권한
GET, PATCH /.../routes/0
공격 요청
GET, PATCH /.../routes/01
권한 계층의 해석
".../01"".../0"으로 시작하므로 허용
탐색 계층의 해석
strconv.Atoi("01") == 1, 따라서 routes[1] 선택
결과
허용되지 않은 설정 객체의 조회와 변경

원인 분석

처음에는 단순한 prefix matching 문제처럼 보였습니다. 하지만 Caddy 문서상 하위 경로를 허용하는 동작과 실제 취약점을 구분하려면, 승인된 문자열이 최종적으로 어느 설정 객체를 선택하는지까지 확인해야 했습니다.

권한 검사

RemoteAdmin.enforceAccessControls()는 클라이언트 공개키와 method를 확인한 뒤, 요청 경로가 허용 경로로 시작하는지만 검사했습니다.
admin.go · 조사 기준 커밋 df65455b
715 pathFound := accessPerm.Paths == nil 716 for _, allowedPath := range accessPerm.Paths { 717 if strings.HasPrefix(r.URL.Path, allowedPath) { 718 pathFound = true 719 break 720 } 721 }

설정 객체 탐색

같은 요청은 권한 검사를 통과한 뒤 unsyncedConfigAccess()로 전달됩니다. 이 함수는 URL 구성요소가 배열 위치에 도달하면 문자열을 정수로 변환했습니다.
admin.go · map에서 배열로 진입하는 경로와 중첩 배열 경로
1201 idxStr := parts[len(parts)-1] 1202 idx, err = strconv.Atoi(idxStr) 1203 if err != nil { ... } 1310 case []any: 1311 partInt, err := strconv.Atoi(part) 1312 if err != nil { ... } 1313 ptr = v[partInt]
하나의 요청을 두 계층이 다르게 해석한 과정
단계
판정
입력
/routes/01
권한 검사
HasPrefix(..., "/routes/0")
true
설정 탐색
Atoi("01")
1
실제 대상
routes[1]
route one
중요한 차이는 객체 의미입니다. routes/0/handle은 0번 route의 하위 객체지만,routes/01은 0번의 하위 경로가 아닙니다. 숫자로 해석되는 순간 완전히 다른 배열 원소가 됩니다.

재현 환경

조사 기준은 v2.11.2-3-gdf65455b였습니다. 로컬 Caddy 저장소에서 내부 CA로 서버 인증서를 발급하고, 별도로 만든 클라이언트 인증서의 공개키에 제한된 원격 관리 권한을 연결했습니다. 실제 개인키는 이 페이지에 포함하지 않았습니다.
  • 로컬 admin endpoint: 127.0.0.1:2029
  • mTLS remote admin endpoint: 127.0.0.1:2031
  • 설정 배열: 본문이 각각 route zero, route one인 두 route
  • 인증서 권한: routes/0에 대한 GET, PATCH
$ go run ./cmd/caddy run --config /tmp/caddy-config-index-repro.json
당시 repro.json 내려받기

대조군 확인

routes/01에서 200 응답 하나만 확인하면 단순 라우팅 별칭일 가능성을 배제하기 어렵습니다. 그래서 정상 접근, 비정규 인덱스 접근, 범위 밖 인덱스, 쓰기, 쓰기 결과 확인을 분리했습니다.
검사
요청
관찰
확인한 내용
정상 대조군
GET routes/0
200 · route zero
인증서와 기본 설정이 정상
비정규 인덱스
GET routes/01
200 · route one
다른 객체의 읽기 성공
범위 대조군
GET routes/02
400 · index out of bounds
02도 숫자로 해석됨
변경 시도
PATCH routes/01
200
읽기 전용 문제가 아님
변경 확인
GET routes/01
patched route one
routes[1] 변경이 지속됨
공개 advisory에 보존된 요청·응답 발췌
> GET /config/apps/http/servers/srv/routes/0 HTTP/1.1 < HTTP/1.1 200 OK {"handle":[{"body":"route zero",...}]} > GET /config/apps/http/servers/srv/routes/01 HTTP/1.1 < HTTP/1.1 200 OK {"handle":[{"body":"route one",...}]} > GET /config/apps/http/servers/srv/routes/02 HTTP/1.1 < HTTP/1.1 400 Bad Request {"error":"[...] array index out of bounds: 02"} > PATCH /config/apps/http/servers/srv/routes/01 HTTP/1.1 < HTTP/1.1 200 OK > GET /config/apps/http/servers/srv/routes/01 HTTP/1.1 < HTTP/1.1 200 OK {"handle":[{"body":"patched route one",...}]}
요청·응답 기록 내려받기

판단 변화

Prefix 검사

처음에는 strings.HasPrefix() 한 줄이 문제의 전부처럼 보였습니다. 하지만routes/0/handle처럼 허용된 객체의 하위 경로까지 접근시키려는 정책이라면 prefix 검사는 의도된 동작일 수 있습니다. 이 코드만으로 취약점을 주장하는 접근은 부족했습니다. 그래서 권한 검사를 통과한 문자열이 설정 탐색 단계에서 최종적으로 어떤 객체를 선택하는지 추적했습니다.

객체 식별

GET routes/01의 200 응답만으로는 01이 단순히 0번 객체의 별칭인지, 실제로 다른 객체를 선택했는지 구분하기 어려웠습니다. 정상 대조군 routes/0의 본문과 비교해 routes/01route one을 반환하는 것을 확인하고,routes/02가 배열 범위 오류를 내는 것까지 확인했습니다. 이때 판단이 “경로 prefix 문제”에서 “문자열 권한과 숫자형 객체 식별자의 불일치”로 바뀌었습니다.

읽기와 변경

다른 객체를 읽을 수 있다는 결과만으로는 정보 노출 범위를 넘어설 수 없었습니다. 같은 인증서와 경로로 PATCH routes/01을 실행하고, 다시 GET을 보내patched route one이 유지되는 것을 확인했습니다. 이 단계에서 영향 판단을 허용되지 않은 설정의 조회에서 조회와 변경으로 확장했습니다.

인증과 인가

처음에는 mTLS 처리까지 함께 의심할 수 있었지만, Caddy 로그에는 secure: trueverified_chains: 1이 기록돼 있었습니다. 모든 요청도 브라우저가 아니라curl로 관리 API에 직접 보냈습니다. 따라서 인증 우회 가설은 제외하고, 정상 인증을 마친 제한된 원격 관리자가 다른 설정 객체에 접근하는 authorization 문제로 범위를 좁혔습니다. 인증서가 없는 공격자나 운영체제 권한 상승은 결론에 포함하지 않았습니다.

수정 범위

발견에 사용한 01만 차단하면 002, +1, -0처럼 같은 문제가 다른 표현으로 남을 수 있습니다. 최종 수정 기준은 특정 문자열의 차단이 아니라, 정수로 변환한 뒤 다시 만든 문자열이 원본과 같은 canonical index만 허용하는 것으로 정리됐습니다.

수정 및 회귀 테스트

직접 수정 커밋은 18ab0f955fc1075d7727c7658dbfb734c673a5c9입니다. 숫자를 변환한 뒤 다시 문자열로 만들었을 때 원본과 같아야만 정규형 인덱스로 인정합니다.0, 1, 10은 유지하고, 01, 002+1, -0은 거부합니다.
수정 커밋: 18ab0f955fc1075d7727c7658dbfb734c673a5c9
admin.go · canonical array index 검사
+ func parseCanonicalArrayIndex(idx string) (int, error) { + if idx == "" { return 0, fmt.Errorf("empty index") } + i, err := strconv.Atoi(idx) + if err != nil { return 0, err } + if strconv.Itoa(i) != idx { + return 0, fmt.Errorf("non-canonical array index") + } + return i, nil + } - idx, err = strconv.Atoi(idxStr) + idx, err = parseCanonicalArrayIndex(idxStr)
패치에는 변환 함수만 추가된 것이 아니라 정상값과 우회 형태를 함께 고정하는 회귀 테스트가 포함됐습니다. 이 테스트는 문제가 발견된 한 문자열 01만 막지 않고, 같은 의미를 여러 문자열로 표현하는 다른 입력도 차단합니다.
허용
거부
0, 1, 10
01, 002, +1, -0
공식 패치 전체 내려받기

결과

Caddy는 이 문제를 CVE-2026-45692, GHSA-x5w9-xh9r-mvfc로 공개했고 reporter로 Amemoyoi를 기록했습니다. 영향 버전은 v2.4.0 이상, 수정 버전은 v2.11.3이며 CVSS 3.1 점수는 5.4입니다.
이 사례에서 핵심 분석은 위험한 함수 하나를 찾는 일이 아니었습니다. 권한을 결정하는 문자열 표현과 실제 객체를 선택하는 숫자 표현 사이의 경계를 따라가고, 정상 읽기·범위 오류·쓰기·쓰기 결과 확인을 조합해 두 계층이 다른 보안 대상을 보고 있음을 증명한 것입니다.

분석 기준과 증거 보존

항목
최종 감사 결과
소스 기준
Caddy checkout df65455b1f0d230a34d4bd7111213fe6917abe98
원본 검증
mTLS policy, leading-zero request, HTTP read·PATCH 관찰, 취약·수정 source와 patch 비교
GitHub 보존 상태
Public 저장소 main의 감사 commit c04cacf; evidence 7개와 root manifest.json에 size·SHA-256 고정
무결성
사례별 SHA256SUMS 검증 통과, root manifest의 모든 file hash와 실제 파일 일치, README 상대 링크 확인 완료
보존 경계·제약
재현용 TLS private key와 원본 session은 제외
감사 완료
2026-08-23 · Docker/로컬 원본 대조 후 GitHub push 완료

관련 파일

2026-08-23 최종 감사

아래 파일은 로컬 컨테이너에 남아 있던 조사 기준 소스와 재현 설정, 공개 advisory 및 공식 수정 커밋을 기준으로 정리했습니다. 클라이언트 개인키는 포함하지 않았습니다.
  • repro.json — 당시 사용한 원격 관리·2개 route 구성. 공개키만 포함하고 개인키는 제외했습니다.
  • admin-vulnerable-extract.go — 조사 기준 커밋에서 권한 검사와 배열 탐색 부분만 발췌한 파일

느낀 점

이 사례에서 가장 인상적이었던 점은 각 코드가 따로 보면 자연스러워 보였다는 것입니다. 하위 경로를 허용하는 prefix 검사와 배열 위치를 읽는 정수 변환은 각각 설명할 수 있었지만, 두 계층이 같은 문자열을 서로 다른 객체로 해석하면서 권한 경계가 무너졌습니다. 이후 authorization 코드를 검토할 때는 검사 함수만 보는 대신, 검사를 통과한 값이 마지막에 어떤 객체를 선택하는지까지 따라가야 한다고 생각하게 됐습니다.
또한 200 응답 하나로 결론을 내리지 않고 정상 객체, 범위 밖 인덱스, 쓰기와 재조회를 차례로 대조한 과정이 중요했습니다. 취약한 입력 하나를 막는 패치보다 서로 같은 의미를 갖는 입력을 하나의 canonical form으로 제한하는 편이 더 오래 유지되는 수정이라는 점도 배웠습니다.