1. 개요
Apache HTTP Server 앞단에 Tomcat을 두고 AJP로 붙이는 구성은 오래된 WAS 현장에서 아직도 흔하다. 저도 구형 시스템을 넘겨받을 때마다 server.xml에 포트 8009짜리 커넥터가 주석도 없이 살아 있는 걸 종종 본다. 2020년 Ghostcat이 터진 뒤로 상황이 꽤 바뀌었는데, 그 변화를 모르고 옛날 설정을 그대로 복사해 쓰다가 커넥터가 아예 안 뜨는 경우를 자주 만난다.
결론부터 말하자면, Tomcat 9.0.31 이상에서는 AJP 커넥터에 secret을 지정하지 않으면 기본적으로 커넥터가 시작되지 않는다. 안전하게 가려면 server.xml 커넥터에 secret을 넣고 address를 루프백으로 묶은 뒤, Apache쪽 커넥터(mod_jk 또는 mod_proxy_ajp)에 같은 비밀값을 설정하면 된다. secretRequired="false"로 끄는 길은 신뢰된 사설망에 한해서만 쓴다.
이 글은 대상 버전을 Tomcat 9.0.x(9.0.31 이상)와 Apache HTTP Server 2.4, mod_jk 1.2.x 기준으로 잡는다. 공식 문서 기준값을 먼저 밝히고, server.xml과 worker.properties의 변경 전/후를 비교하면서 설정 항목을 하나씩 본다.
2. Ghostcat(CVE-2020-1938)이 무엇인가
AJP(Apache JServ Protocol)는 Apache 같은 웹서버와 Tomcat 사이를 잇는 이진 프로토콜이다. HTTP를 매번 다시 파싱하지 않고 요청 정보를 압축해 넘기려고 만든 것으로, Tomcat에서는 포트 8009가 관례였다. 문제는 이 프로토콜이 웹서버가 전달한 요청 속성(attribute)을 그대로 신뢰한다는 점이다.
Ghostcat은 바로 이 신뢰를 악용한다. 공격자가 AJP 포트에 직접 접속해 요청 속성을 조작하면, 웹 애플리케이션 디렉터리 안의 파일을 읽어낼 수 있다. 예를 들어 WEB-INF/web.xml처럼 외부에 노출되면 안 되는 설정 파일을 그대로 가져가는 식이다. 애플리케이션이 파일 업로드를 허용하는 구조라면 읽기를 넘어 원격 코드 실행까지 이어질 수 있다.
영향 범위가 넓었다. Tomcat 9.0.30 이하, 8.5.50 이하, 7.0.99 이하가 대상이었고, 각각 9.0.31, 8.5.51, 7.0.100에서 고쳐졌다. 핵심 원인은 AJP 커넥터가 기본으로 켜져 있고, 아무 인증 없이 열린 포트였다는 데 있다.
3. 9.0.31에서 바뀐 AJP 기본값
Tomcat 9.0.31부터 AJP 커넥터는 "기본적으로 안전한" 방향으로 바뀌었다. 세 가지가 핵심이다.
- 기본 배포본의 server.xml에서 AJP 커넥터가 주석 처리되어 꺼진 상태로 제공된다.
- 커넥터의 기본 바인딩 주소(
address)가 루프백으로 바뀌었다. 과거처럼 모든 인터페이스에 열리지 않는다. - 과거의
requiredSecret속성이secret으로 이름이 바뀌고,secretRequired라는 새 속성이 기본값true로 추가됐다.
secretRequired="true"이면 secret이 비어 있는 상태에서는 커넥터가 시작되지 않는다. 즉 비밀값을 명시하지 않으면 AJP 자체가 안 뜬다. 옛 설정을 그대로 가져온 서버를 9.0.31로 올리면 기동 시 이런 에러를 만난다.
SEVERE [main] org.apache.catalina.util.LifecycleBase.handleSubClassException Failed to initialize component [Connector[AJP/1.3-8009]] ... Caused by: java.lang.IllegalArgumentException: The AJP Connector is configured with secretRequired="true" but the secret attribute is either null or "". This combination is not valid.
이 에러를 보면 당황하지 말고 secret을 채워 넣는 쪽으로 가면 된다. 에러 메시지 자체가 해결책을 알려주는 셈이다.
4. server.xml에 secret과 address 설정
변경 전, 흔히 보던 옛날 커넥터는 이런 모습이다. 포트만 있고 비밀값도 바인딩 제한도 없다.
<!-- 변경 전: 위험한 구성 -->
<Connector protocol="AJP/1.3"
port="8009"
redirectPort="8443" />
변경 후에는 secret, address를 명시한다. Apache와 Tomcat이 같은 호스트에 있다면 address를 루프백으로 고정하는 것이 가장 확실하다.
<!-- 변경 후: 같은 호스트에 Apache가 있을 때 -->
<Connector protocol="AJP/1.3"
address="127.0.0.1"
port="8009"
secret="J8s2qQ4vB7nK0wZ"
secretRequired="true"
redirectPort="8443" />
secret 값은 표준 ASCII 문자로만 구성한다. 공식 문서 기준으로, secret이 비어 있지 않은 값으로 설정되면 워커는 반드시 같은 값을 보내야 하며, 일치하지 않는 요청은 secretRequired 설정과 무관하게 거부된다.
두 속성의 역할을 혼동하기 쉬운데 구분해 두자.
4-1. secret
워커가 보내야 할 공유 비밀값이다. 기본값은 null. 이 값이 설정되어 있으면 Tomcat은 일치하는 비밀을 가진 요청만 받는다.
4-2. secretRequired
커넥터를 시작할 때 secret을 강제할지 결정한다. 기본값은 true. true이면 secret이 비어 있을 때 커넥터가 뜨지 않는다. 워커에게 비밀 제출을 강제하는지 여부를 결정하는 속성이 아니라는 점을 유의한다. 그것은 secret 쪽이 담당한다.
Apache가 별도 호스트에 있어 루프백으로 묶을 수 없다면, address를 내부 인터페이스 IP로 지정하고 방화벽에서 8009 포트를 해당 Apache 호스트로만 제한한다. 이 경우에도 secret은 반드시 함께 쓴다.
5. secretRequired를 끄는 경우
신뢰된 사설망이고 AJP 포트가 외부에 절대 노출되지 않는 환경이라면 secretRequired="false"로 비밀값 없이 띄울 수는 있다.
<Connector protocol="AJP/1.3"
address="127.0.0.1"
port="8009"
secretRequired="false"
redirectPort="8443" />
다만 이 선택은 "포트가 절대 외부에서 닿지 않는다"는 보장에 전적으로 의존한다. 그 보장이 운영 중 깨지는 순간 Ghostcat과 같은 상태로 돌아간다. 저는 특별한 사정이 없으면 secretRequired를 끄지 않고 secret을 쓰는 쪽을 권한다. 비밀값 한 줄 넣는 비용이 사고 비용보다 훨씬 싸다.
6. Apache쪽 커넥터에 같은 secret 넣기
Tomcat에 secret을 걸었으면 Apache쪽 AJP 커넥터에도 같은 값을 넣어야 요청이 통과한다. 안 맞으면 요청이 거부되고 502나 403 계열 응답을 받는다.
6-1. mod_jk (worker.properties)
mod_jk는 worker.<이름>.secret 지시자로 설정한다. 이 지시자는 mod_jk 1.2.12부터 쓸 수 있다.
# worker.properties worker.list=tomcat1 worker.tomcat1.type=ajp13 worker.tomcat1.host=127.0.0.1 worker.tomcat1.port=8009 worker.tomcat1.secret=J8s2qQ4vB7nK0wZ
로드밸런서 워커를 쓴다면 lb 워커에만 secret을 걸어도 멤버 워커들이 값을 상속받는다.
worker.list=lb worker.lb.type=lb worker.lb.secret=J8s2qQ4vB7nK0wZ worker.lb.balance_workers=tomcat1,tomcat2 # tomcat1, tomcat2가 secret을 상속
6-2. mod_proxy_ajp
mod_proxy_ajp를 쓴다면 ProxyPass의 secret 파라미터로 지정한다.
ProxyPass /app ajp://127.0.0.1:8009/app secret=J8s2qQ4vB7nK0wZ ProxyPassReverse /app ajp://127.0.0.1:8009/app
7. 검증과 함정
설정을 바꿨으면 실제로 막혔는지 확인한다. 먼저 AJP 포트가 어디에 열려 있는지 본다. 루프백으로만 떠 있어야 한다.
$ ss -tlnp | grep 8009
LISTEN 0 100 127.0.0.1:8009 0.0.0.0:* users:(("java",pid=2314,fd=52))
0.0.0.0:8009로 떠 있으면 address가 적용되지 않은 것이다. server.xml을 다시 확인한다.
다음으로 Apache를 거친 정상 요청이 되는지 본다. 브라우저나 curl로 Apache 포트(80/443)에 접속해 애플리케이션이 뜨면 secret이 양쪽에 맞게 들어간 것이다.
$ curl -I http://localhost/app/ HTTP/1.1 200
양쪽 secret이 어긋나면 mod_jk 로그(mod_jk.log)에 거부 흔적이 남는다. 이럴 때 500이나 502를 받고 다음과 비슷한 경고가 찍힌다.
[error] ajp_connection_tcp_get_message::jk_ajp_common.c ... can't receive the response message from tomcat, ... [info] ajp_service::jk_ajp_common.c ... sending request to tomcat failed
자주 걸리는 함정 몇 가지를 적어 둔다.
- secret 값 불일치. 복사 과정에서 공백이나 줄바꿈이 끼어 들어가는 경우가 많다. 양쪽 값을 눈으로만 보지 말고 실제 파일에서 비교한다.
- Tomcat만 바꾸고 Apache를 안 바꾼 경우. Tomcat은 비밀을 요구하는데 워커는 안 보내니 전부 거부된다. 반대도 마찬가지다.
- address를 지정했지만 Apache가 다른 호스트인 경우.
127.0.0.1로 묶으면 원격 Apache는 붙지 못한다. 이때는 내부 IP로 바인딩하고 방화벽으로 제한한다. - AJP를 아예 안 쓰는데 커넥터만 남은 경우. 프런트에서 mod_proxy_http로 8080에 붙이고 있다면 AJP 커넥터는 필요 없다. server.xml에서 커넥터를 통째로 주석 처리하거나 지우는 것이 가장 깔끔하다.
8. 마무리
정리하면, AJP를 계속 쓴다면 Tomcat 9.0.31 이상으로 올리고 server.xml 커넥터에 secret을 지정한 뒤 address를 루프백이나 내부 IP로 묶는다. Apache쪽 mod_jk 또는 mod_proxy_ajp에 같은 비밀값을 넣고, ss로 바인딩 주소를, 실제 요청으로 연동을 검증한다. AJP가 필요 없는 구성이라면 커넥터를 지우는 것이 가장 안전하다. 비밀값을 끄는 secretRequired="false"는 외부 차단이 확실한 사설망으로만 한정한다.