본문 바로가기
삵
Apache Tomcat

Tomcat AJP 연동에서 Ghostcat 막기

aappsroot·2026년 10월 11일·조회 0

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"는 외부 차단이 확실한 사설망으로만 한정한다.

자주 묻는 질문

secret과 secretRequired는 무엇이 다른가?

secret은 워커가 보내야 할 공유 비밀값으로 기본값은 null이다. 이 값이 설정되면 Tomcat은 일치하는 비밀을 보낸 요청만 받는다. secretRequired는 커넥터 시작 시 secret을 강제할지 결정하는 속성으로 기본값은 true이며, true일 때 secret이 비어 있으면 커넥터가 시작되지 않는다. 워커에게 비밀 제출을 강제하는 것은 secret 쪽이 담당한다.

9.0.31로 올린 뒤 AJP 커넥터가 시작되지 않는다.

secretRequired의 기본값이 true로 바뀌어서 secret이 비어 있으면 커넥터가 뜨지 않는다. 로그에 secretRequired="true"인데 secret이 null 또는 빈 값이라는 IllegalArgumentException이 찍힌다. server.xml 커넥터에 secret 값을 넣고, Apache쪽 커넥터에도 같은 값을 설정하면 해결된다.

address를 루프백으로 묶으면 원격 Apache가 못 붙는다.

127.0.0.1로 바인딩하면 같은 호스트의 프로세스만 접속할 수 있다. Apache가 다른 서버에 있다면 address를 내부 인터페이스 IP로 지정하고, 방화벽에서 8009 포트를 해당 Apache 호스트로만 허용한다. 이 경우에도 secret은 반드시 함께 사용한다.

AJP를 더 이상 쓰지 않는데 커넥터는 어떻게 하나?

프런트에서 mod_proxy_http로 8080 HTTP 커넥터에 붙이고 있다면 AJP 커넥터는 필요 없다. server.xml에서 AJP 커넥터를 주석 처리하거나 삭제하는 것이 가장 안전하다. 쓰지 않는 포트를 열어두지 않는 것이 Ghostcat 같은 노출을 원천 차단하는 방법이다.

mod_jk에서 secret은 어떻게 설정하나?

worker.properties에서 worker.<워커이름>.secret 지시자로 설정하며, mod_jk 1.2.12부터 쓸 수 있다. Tomcat server.xml의 secret 값과 정확히 일치해야 한다. 로드밸런서 워커를 쓰면 lb 워커에만 secret을 걸어도 멤버 워커들이 값을 상속받는다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.