클라이언트 인증서에는 첫 번째 route만 허용됐지만,
routes/01을 요청하면 두 번째 route를 읽고 수정할 수 있었습니다. 권한 검사와 실제 객체 탐색을 따로 추적해 이 불일치를 확인한 과정을 정리했습니다.Taegu Ha · Caddy · CWE-187 · CWE-863 · CVSS 5.4
2026년 5월 13일 공개 · Caddy v2.11.3에서 수정 · 공식 advisory
한눈에 보기공격 흐름취약점 개요원인 분석권한 검사설정 객체 탐색재현 환경대조군 확인판단 변화Prefix 검사객체 식별읽기와 변경인증과 인가수정 범위수정 및 회귀 테스트결과분석 기준과 증거 보존관련 파일2026-08-23 최종 감사느낀 점
한눈에 보기
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와 설정 경로를 제한할 수 있습니다. 재현 환경에서는 한 인증서에
GET과 PATCH, 그리고 다음 경로 하나만 허용했습니다./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/01이 route one을 반환하는 것을 확인하고,routes/02가 배열 범위 오류를 내는 것까지 확인했습니다. 이때 판단이 “경로 prefix 문제”에서 “문자열 권한과 숫자형 객체 식별자의 불일치”로 바뀌었습니다.읽기와 변경
다른 객체를 읽을 수 있다는 결과만으로는 정보 노출 범위를 넘어설 수 없었습니다. 같은 인증서와 경로로
PATCH routes/01을 실행하고, 다시 GET을 보내patched route one이 유지되는 것을 확인했습니다. 이 단계에서 영향 판단을 허용되지 않은 설정의 조회에서 조회와 변경으로 확장했습니다.인증과 인가
처음에는 mTLS 처리까지 함께 의심할 수 있었지만, Caddy 로그에는
secure: true와verified_chains: 1이 기록돼 있었습니다. 모든 요청도 브라우저가 아니라curl로 관리 API에 직접 보냈습니다. 따라서 인증 우회 가설은 제외하고, 정상 인증을 마친 제한된 원격 관리자가 다른 설정 객체에 접근하는 authorization 문제로 범위를 좁혔습니다. 인증서가 없는 공격자나 운영체제 권한 상승은 결론에 포함하지 않았습니다.수정 범위
발견에 사용한
01만 차단하면 002, +1, -0처럼 같은 문제가 다른 표현으로 남을 수 있습니다. 최종 수정 기준은 특정 문자열의 차단이 아니라, 정수로 변환한 뒤 다시 만든 문자열이 원본과 같은 canonical index만 허용하는 것으로 정리됐습니다.수정 및 회귀 테스트
직접 수정 커밋은
18ab0f955fc1075d7727c7658dbfb734c673a5c9입니다. 숫자를 변환한 뒤 다시 문자열로 만들었을 때 원본과 같아야만 정규형 인덱스로 인정합니다.0, 1, 10은 유지하고, 01, 002+1, -0은 거부합니다.수정 커밋:
18ab0f955fc1075d7727c7658dbfb734c673a5c9admin.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 — 조사 기준 커밋에서 권한 검사와 배열 탐색 부분만 발췌한 파일
- http-observations.txt — GET·PATCH 요청과 응답, Caddy 로그를 한 파일로 정리한 기록
- caddy-canonical-index-fix.patch — 공식 수정 커밋 18ab0f9의 패치와 회귀 테스트
- advisory-metadata.json — GHSA·CVE·영향 버전·CVSS·크레딧을 보존한 공개 메타데이터
- artifact-manifest.json — 각 파일의 출처, 범위, SHA-256을 기록한 manifest
느낀 점
이 사례에서 가장 인상적이었던 점은 각 코드가 따로 보면 자연스러워 보였다는 것입니다. 하위 경로를 허용하는 prefix 검사와 배열 위치를 읽는 정수 변환은 각각 설명할 수 있었지만, 두 계층이 같은 문자열을 서로 다른 객체로 해석하면서 권한 경계가 무너졌습니다. 이후 authorization 코드를 검토할 때는 검사 함수만 보는 대신, 검사를 통과한 값이 마지막에 어떤 객체를 선택하는지까지 따라가야 한다고 생각하게 됐습니다.
또한 200 응답 하나로 결론을 내리지 않고 정상 객체, 범위 밖 인덱스, 쓰기와 재조회를 차례로 대조한 과정이 중요했습니다. 취약한 입력 하나를 막는 패치보다 서로 같은 의미를 갖는 입력을 하나의 canonical form으로 제한하는 편이 더 오래 유지되는 수정이라는 점도 배웠습니다.