01개요
Apache Struts 2의 파일 업로드 처리 로직에서 발생하는 경로순회 취약점입니다(CVE-2023-50164, Apache 자체 식별자 S2-066). 공격자가 파일 업로드 요청의 파라미터를 조작하면, 서버가 검증을 거치지 않은 상대경로가 섞인 파일명을 최종 값으로 받아들일 수 있습니다.
이 결함 자체가 곧바로 RCE는 아닙니다. 그러나 업로드를 처리하는 애플리케이션이 이 파일명을 그대로 파일시스템 경로에 써서 파일을 복사한다면, 웹에서 실행 가능한 위치에 JSP 같은 스크립트 파일을 놓을 길이 열리고 — 그 경우 원격 코드 실행으로 이어질 수 있습니다.
CVSS: 9.8
관련 식별자: CVE-2023-50164(S2-066, 이 글의 대상) · CWE-552(외부에서 접근 가능한 파일/디렉터리). 1년 뒤 같은 인터셉터 계열에서 나온 CVE-2024-53677(S2-067)은 별개 CVE이며 §08에서 다룹니다.
02영향받는 버전
| 대상 | 취약 버전 | 안전 버전 |
|---|---|---|
| Apache Struts 2 (2.x 계열) | 2.0.0 – 2.5.32 | 2.5.33 이상 |
| Apache Struts 2 (6.x 계열) | 6.0.0 – 6.3.0.1 | 6.3.0.2 이상 |
03배경 — 파라미터 바인딩은 요청 값을 액션 필드로 밀어 넣는 통로다
Struts 2는 HTTP 요청 파라미터를 액션(Action) 클래스의 필드에 자동으로 채워 넣습니다. upload라는 이름으로 파일을 올리면 프레임워크가 setUpload(...) 같은 세터를 찾아 값을 넣어주는 식입니다 — 이게 Struts 2의 편의 기능인 파라미터 바인딩입니다.
파일 업로드는 조금 특별합니다. FileUploadInterceptor가 먼저 업로드된 파일을 받아, 파일 객체 자체(upload)뿐 아니라 파일명(uploadFileName)과 콘텐츠 타입(uploadContentType)까지 인터셉터가 직접 만들어 나머지 파라미터들과 함께 하나의 맵(HttpParameters)에 밀어 넣습니다.
이 파일명은 그냥 쓰지 않습니다. 브라우저가 보낸 원본 파일명에서 경로 구분자(/, \) 앞부분을 잘라내는 정제 과정을 거칩니다 — 경로가 섞인 값이 들어와도 마지막 조각만 남기려는 의도입니다.
여기서 하나만 미리 짚습니다. 이 정제 과정은 "이 파일명 값을 만드는 경로는 하나뿐"이라는 전제 위에 서 있습니다. 같은 필드를 가리키는 값이 다른 경로로, 다른 형태로 또 들어올 수 있다면, 정제는 그 다른 경로까지 따라가지 못합니다.
04취약점 발생 원인
이 취약점은 파일 업로드 처리 흐름을 따라가며 세 지점에서 순서대로 성립합니다. 정제 로직 자체(W1)는 정상이고, 그 값이 맵에 병합되는 지점(W2)과, 병합을 받는 자료구조가 "이름이 같다"를 판단하는 기준(W3)이 문제입니다.
1"파일명은 여기서 한 번만 정제된다"
정제 로직(getCanonicalName) 자체는 정상 동작합니다 — 문제는 이 값이 유일한 값이라는 보장이 없다는 것입니다.
FileUploadInterceptor는 업로드된 파일의 이름을 가져올 때 multiWrapper.getFileNames(inputName)을 호출합니다. 이 내부에서 AbstractMultiPartRequest#getCanonicalName()이 원본 파일명 중 마지막 경로 구분자(/ 또는 \) 뒤의 조각만 남겨, ../../evil.jsp 같은 값이 들어와도 evil.jsp만 남도록 정제합니다.
Apache Struts 실제 소스 — AbstractMultiPartRequest.java · getCanonicalName() (커밋 bfb6c54, STRUTS_2_5_32 기준). 이 함수 자체는 정상 동작합니다.
이 정제 함수만 놓고 보면 결함이 없습니다. 문제는 이 값이 inputName + "FileName"(예: UploadFileName)이라는 이름으로 새 파라미터 맵(newParams)에 담겨, 이후 기존 파라미터 맵과 병합된다는 다음 단계에 있습니다.
왜 이렇게 됐나작성자는 파일명 정제를 "이 함수를 거치면 끝"으로 설계했습니다. 같은 대상을 가리키는 값이 이 함수를 거치지 않고도 요청 파라미터로 직접 들어와 나중에 같은 필드에 바인딩될 수 있다는 경로는 고려 대상이 아니었습니다.
2"정제된 값을 넣는 자리는 기존 값을 정리하지 않는다"
안전한 파일명을 파라미터 맵에 넣는 appendAll()이, 이미 맵에 있던 대소문자만 다른 키를 지우지 않고 그대로 둡니다.
정제된 파일명이 만들어지면 FileUploadInterceptor는 이를 newParams 맵에 담아 ac.getParameters().appendAll(newParams)로 기존 파라미터 맵에 병합합니다(305행). 이때 기존 맵에는 요청에 함께 실려 온 다른 파라미터들도 이미 들어 있습니다 — 파일 필드가 아니라 그냥 폼 필드로 보낸, 이름이 비슷한 파라미터도 포함해서입니다.
Apache Struts 실제 소스 — FileUploadInterceptor.java · intercept() 내부 파일 바인딩 루프(패치 전, STRUTS_2_5_32 기준). 붉은 305행이 기존 키를 지우지 않고 병합하는 지점입니다.
패치 전 appendAll()은 새 값을 그냥 얹기만 했습니다(parameters.putAll(newParams)). 새로 넣는 키와 대소문자만 다른 키가 이미 맵에 있어도 지우지 않았습니다 — 그래서 예를 들어 UploadFileName(정제됨)과 uploadFileName(공격자가 보낸 원본)이 서로 다른 키로 맵 안에 나란히 살아남았습니다.
왜 이렇게 됐나appendAll()은 "인터셉터가 새로 만든 파일 관련 파라미터를 추가한다"는 목적만 갖고 작성됐습니다. 이미 맵에 있는, 이름은 같지만 대소문자가 다른 키와의 정합성을 맞추는 책임은 이 메서드에도, 호출하는 쪽에도 없었습니다.
3"이름이 같다"의 기준이 자료구조 안에서만 대소문자를 구분했다
HttpParameters의 get/contains/remove/appendAll이 전부 대소문자를 구분하는 정확한 문자열 비교로 짜여 있었습니다.
HttpParameters는 파라미터 이름을 키로 쓰는 맵을 감싼 래퍼입니다. 패치 전에는 contains()가 parameters.containsKey(name)을, appendAll()이 parameters.putAll(newParams)을 그대로 호출했습니다 — 둘 다 자바 String의 기본 equals()·hashCode() 기준, 즉 대소문자를 구분하는 정확 일치로 동작합니다.
Apache Struts 실제 소스 — HttpParameters.java (패치 전, 커밋 649db4d = 162e29f의 부모 커밋). 붉은 줄이 대소문자 구분 비교가 남아 있던 자리입니다.
그런데 이 맵의 값을 최종적으로 소비하는 다음 단계 — 요청 파라미터를 액션 필드의 세터로 연결하는 프로퍼티 바인딩 — 은 자바빈 관례상 사실상 대소문자를 구분하지 않는 방식으로 파라미터명과 세터를 매칭합니다(이 연결 지점은 이 세션에서 OGNL 소스까지 직접 추적하지 않았고, Rapid7의 공개 분석을 근거로 서술합니다).
결과적으로 UploadFileName과 uploadFileName은 맵 안에서는 서로 다른 두 항목이지만, 세터 바인딩 단계에서는 같은 필드를 가리키는 두 후보가 됩니다. 처리 순서상 나중에 반영된 값이 최종 값으로 확정됩니다.
왜 이렇게 됐나맵의 "같음" 기준(대소문자 구분)과, 그 값을 최종 소비하는 프로퍼티 바인딩의 "같음" 기준(사실상 대소문자 무시)이 서로 달랐습니다. 이 불일치 자체가 취약점의 본질이고, W1·W2는 그 불일치가 실제로 발화하는 경로였을 뿐입니다.
세 지점처럼 보이지만 무게 중심은 W3(HttpParameters의 대소문자 처리) 하나입니다.
W1(정제 로직)은 그 자체로 결함이 없습니다. W2(appendAll 병합)도 "이름이 같으면 하나"라는 전제가 지켜졌다면 안전했을 자리입니다. 그 전제를 깨는 게 W3 — 그래서 패치도 HttpParameters.java 한 파일, 네 메서드로 끝났습니다.
한 문장으로 묶으면 이렇습니다. 파라미터를 저장하는 자료구조의 '같음' 기준과, 그 값을 최종 소비하는 프로퍼티 바인딩 단계의 '같음' 기준이 서로 달랐습니다. 그 불일치가 정제된 값 옆에 정제되지 않은 값이 살아남을 자리를 만들었습니다.
05공격 과정
5.1 공격 과정 요약
- 공격자가 파일 필드와 함께, 이름은 같고 대소문자만 다른 별도 파라미터를 같은 요청에 담아 보냅니다.
- FileUploadInterceptor가 업로드된 파일의 이름을 정제해 안전한 파일명 파라미터를 만듭니다.
- 이 안전한 값을 appendAll()로 파라미터 맵에 병합하는데, 이미 있던 대소문자 다른 키는 지우지 않습니다.
- 프로퍼티 바인딩 단계에서 두 값 모두 같은 세터의 후보로 처리되고, 나중에 반영된 값이 최종 값으로 확정됩니다.
- 액션이 확정된 파일명을 그대로 파일 복사 경로에 사용하면, 검증되지 않은 상대경로가 그대로 반영됩니다.
- 실행 가능한 위치에 스크립트 파일이 놓이는 조건이 갖춰지면 원격 코드 실행으로 이어질 수 있습니다.
5.2 상세 공격 과정
예를 들어 파일 선택 필드 이름이 Upload라면, 인터셉터는 UploadFileName이라는 정제된 파라미터를 만듭니다(원본 파일명이 경로를 포함해도 마지막 조각만 남긴 안전한 값입니다). 공격자는 같은 요청에 uploadFileName(소문자 u)이라는 별도 파라미터를, 상대경로가 섞인 값으로 함께 보낼 수 있습니다.
패치 전 appendAll()은 두 값을 모두 맵에 남깁니다. 이후 프로퍼티 바인딩 단계에서 두 파라미터 모두 같은 세터(setUploadFileName)의 후보로 처리되는데, 처리 순서에 따라 공격자가 보낸 미검증 값이 나중에 반영되어 최종 값을 덮어쓸 수 있습니다.
이 시점부터는 애플리케이션 코드에 달려 있습니다. 액션이 이 파일명을 재검증 없이 파일시스템 경로 조합에 그대로 쓴다면, 업로드 대상 디렉터리를 벗어난 위치 — 예컨대 웹 서버가 스크립트로 실행하는 디렉터리 — 에 파일을 놓을 수 있는 조건이 만들어집니다. 여기서부터는 개념 설명이며, 실제 작동하는 페이로드나 목적 경로는 이 글에 싣지 않습니다.
5.3 미흡점 ↔ 공격 흐름 매핑
| 미흡점(§4) | 발화 지점(§5.2) | 여는 프리미티브 | → 다음으로 잇는 것 |
|---|---|---|---|
| W1 — 정제 로직은 정상 | multiWrapper.getFileNames() 호출(FileUploadInterceptor.java:280, 내부적으로 getCanonicalName 사용) | 안전한 정제 파일명 하나 생성 | appendAll 병합 단계로 전달 |
| W2 — appendAll이 기존 키를 지우지 않음 | ac.getParameters().appendAll(newParams) 호출(FileUploadInterceptor.java:305) | 병합 시 기존의 대소문자 다른 키를 제거하지 않고 그대로 추가 | 대소문자만 다른 두 값이 맵에 공존 |
| W3 — HttpParameters의 대소문자 구분 | HttpParameters.contains()/appendAll()의 문자열 정확 일치 비교(HttpParameters.java:63-84, 패치 전) | "이름이 같다"의 기준이 자료구조와 바인딩 단계에서 서로 다름 | 두 값 모두 프로퍼티 바인딩의 세터 후보로 노출 |
| (귀결) | setUploadFileName() 반복 호출과 처리 순서 | 공격자의 미검증 경로 문자열이 최종 값으로 확정 | 업로드 파일이 검증되지 않은 목적 경로에 복사 — RCE로 이어질 조건 성립 |
06취약점 패치
왜appendAll()이 새 파라미터를 넣기 전에, 같은 이름을 가리키는 기존 키(대소문자가 달라도)를 먼저 제거하지 않는 것이 W2/W3의 근본 자리였기 때문입니다.
어떻게appendAll() 시작에 remove(newParams.keySet())를 추가해, 새 값을 넣기 직전 같은 이름의 기존 키를 먼저 지우도록 바꿨습니다. 이때 remove()·contains()·get() 자체도 equalsIgnoreCase 기반 비교로 함께 바뀌어, 맵 전체가 "이름이 같으면 대소문자와 무관하게 하나"라는 일관된 정체성을 갖게 됩니다.
비고같은 커밋에 65줄짜리 신규 테스트(HttpParametersTest.java)가 함께 추가되어, 대소문자가 다른 키 조회·병합이 의도대로 동작하는지 회귀 검증합니다.
07대응방안
패치가 사실상 유일한 근본 해결입니다. 사용 중인 라인(2.5.x / 6.x)에 맞춰 §영향받는 버전 표의 안전 버전으로 업그레이드합니다(2.5.33 / 6.3.0.2 이상).
- 업그레이드 전까지는, 파일 업로드를 받는 액션에서 세터가 넘겨준 파일명을 그대로 신뢰하지 말고, 최종 목적 경로를 직접 정규화(canonical path)한 뒤 업로드 허용 디렉터리 하위인지 재검증하는 방어선을 애플리케이션 코드에 별도로 둡니다 — 프레임워크의 파라미터 바인딩 계층을 최종 신뢰 경계로 삼지 않습니다.
- 파일 업로드 인터셉터를 쓰는 액션이 많다면, 업로드 대상 파일명에 경로 구분자(
/,\)나 상위 디렉터리 이동 패턴이 남아 있는지 애플리케이션 레벨에서 한 번 더 걸러내는 2차 검증을 임시로 추가합니다. - 탐지 관점. 같은 업로드 요청 안에 이름은 같고 대소문자만 다른 파라미터가 중복으로 존재하는 패턴(예:
uploadFileName과UploadFileName이 함께 옴)을 로깅·WAF 규칙에서 이상 신호로 관찰합니다 — 정상적인 브라우저 폼 제출에서는 나타나지 않는 형태입니다.
08왜 이게 반복되는가
이번 결함의 핵심은 "경로순회"라는 표면보다, "이름이 같은데 서로 다른 두 값이 공존할 수 있었다"는 파라미터 맵의 정체성 혼동에 있습니다. 정제 로직은 값을 못 지운 게 아니라, 안전하게 만든 값 옆에 공격자가 넣은 값이 서로 다른 이름표(대소문자)를 달고 나란히 살아남은 것입니다.
이 패턴은 저희가 다른 사례에서도 본 것과 뿌리가 같습니다 — 문자열 필터가 비재귀적으로 한 번만 걷어내면 중첩으로 부활하듯([[xss-strip-nonrecursive-nesting]] 계열 사고), 여기서도 "정제"는 한 값에만 적용되고 그 옆의 동의어(대소문자만 다른 값)에는 적용되지 않았습니다. 검증·정제 로직을 설계할 때는 "이 데이터를 가리키는 다른 이름이 또 있는가"를 항상 물어야 합니다.
그리고 1년 뒤 CVE-2024-53677(S2-067)로 같은 FileUploadInterceptor 계열에서 또 다른 경로순회가 나왔습니다. 이번에는 Apache가 코드 몇 줄을 고치는 대신, 이 인터셉터 자체를 사실상 대체하는 새 ActionFileUploadInterceptor로 옮겨가는 길을 택했습니다 — 파일명을 일반 프로퍼티 바인딩 계층에 그대로 흘려보내는 설계 자체가 반복 위험이었다는 뜻으로 읽힙니다.
대소문자 하나만 바뀌어도 서버가 "다른 이름"이라 믿어버리는 자리가 있다면, 그 틈은 언젠가 누군가 찾아냅니다 — 그래서 저희는 도구가 아니라 이 사고방식을 기록으로 남깁니다.
참고 자료
이 글의 코드 발췌(HttpParameters.java, FileUploadInterceptor.java, AbstractMultiPartRequest.java)는 apache/struts 오픈소스(Apache License 2.0)의 실제 소스를 커밋 SHA 기준으로 직접 열어 대조했으며, 패치 diff(커밋 162e29f/d8c6969)도 실제 diff를 그대로 확인했습니다. 다만 "대소문자만 다른 두 파라미터가 같은 세터로 순차 바인딩되어 나중 값이 이긴다"는 최종 익스플로잇 인과관계는 이 세션에서 OGNL/XWork 프로퍼티 바인딩 소스까지 직접 추적하지 않았고, Rapid7의 공개 기술분석을 교차 확인해 재구성했습니다 — 그 연결 부분은 2차 분석 기반임을 밝힙니다.
- NVD — CVE-2023-50164 상세 (CVSS 9.8) · CWE-552, 공식 설명·CVSS 벡터
- Apache Struts — S2-066 보안 공지 · 공식 벤더 어드바이저리
- GitHub Advisory Database — GHSA-2j39-qcjm-428w · 영향 버전·패치 버전 정리
- Apache Struts 오픈소스 — 패치 커밋 162e29f · HttpParameters.java 대소문자 무시 처리 추가
- Apache Struts 오픈소스 — 2.5.x 백포트 커밋 d8c6969 · 동일 수정의 2.5.x 라인 반영
- Rapid7 — CVE-2023-50164 Analysis · 대소문자 기반 파라미터 오염 메커니즘 분석
- Sonatype — CVE-2024-53677 (S2-067) 분석 · 같은 인터셉터 계열 후속 결함, §08 패치완전성 논의 근거