CVE-2023-34040 Analysis
CVE-2023-34040(Spring Kafka)
Spring Kafka에서 발견된 취약점에 대해서 분석해본다.
https://www.opswat.com/blog/safeguarding-against-deserialization-attacks-in-spring-for-apache-kafka
What is Kafka?
Kafka는 서비스들 사이에서 메시지(데이터)를 전달하는 시스템이다. 직관적으로 예시를 들어서 이해를 해보자면
- 쿠팡이 서버에 주문이 들어왔다고 메시지를 보냄 -> 결제 서비스가 그걸 받아서 처리함 -> 알람 서비스가 문자 보내기를 처리함
이런식으로 한 서비스가 다른 서비스에게 이벤트나 데이터를 전달할 때 Kafka를 사용한다. Kafka의 이벤트 하나는 보통 key, value, headers를 가진다. 여기서 value는 실제 데이터이고, headers는 부가정보라고 할 수 있다.
메시지는 어떻게 생겼나
메시지를 비유하자면
- key = user-123
- value = 주문번호 555, 상품 book, 가격 1500원
- headers = 어느 서비스가 보냈는지, 추적 ID, 에러 정보 같은 부가정보
핵심
보통 역직렬화 취약점이라고 한다면 외부에서 입력하는 message value를 떠올리지만 해당 취약점은 kafka record의 “header”를 통해 트리거 된다. OPSWAT는 공격자가 deserialization exception recored header 중 하나에 악성 직렬화 객체를 넣을 수 있다고 설명한다. 즉 일반 payload body가 아닌 예외 정보를 담는 헤더 처리 경로가 문제가 되는 취약점이다.
취약점 개요
- Target : Spring Kafka
- Impact : RCE
- Conditonal Vulns -> default는 안전함
취약 조건
해당 취약점은 Spring Kafka의 아무 앱에서나 터지는게 아니라 몇 가지 조건이 성립 해야 한다.
- ErrorHandlingDeserializer를 key/value에 설정하지 않음
- checkDeserExWhenKeyNull / ValueNull = true 설정
- Kafka topic에 untrusted input 허용 위의 세 가지 조건을 동시에 만족해야 헤더 기반 예외 처리 경로를 건드릴 수 있다.
취약점 분석
cve에 따르면 연구진은 취약점을 실제로 트리거 하기 위해 checkDeserExWhenKeyNull과 checkDeserExWhenValueNull을 true로 설정했다. 그리고 key나 value가 비어 있는 레코드를 전송한 뒤, producer로부터 kafka 레코드를 받은 consumer를 디버깅 하면서 레코드 처리 과정의 내부 동작 흐름을 상세하게 분석했다.
여기서 말하는 레코드는
1
2
3
key: user-123
value: {"orderId":101,"item":"book"}
headers: trace-id=abc123
이렇게 구성된 kafka topic에 저장되는 한 개의 엔트리이다.
Step1 : Receiving Records(Messages)
레코드를 받으면 consumer는 invokeIfHaveRecords() 메서드를 호출한다. 그리고 이 메서드는 invokeListener() 메서드를 호출한다. 그러면 미리 등록 되어 있던 @KafkaListener 리스너가 동작하여 받은 레코드를 실제로 처리하게 된다.
그리고 InvokeListener()는 invokeOnMessage() 메서드를 호출한다.
Step2 : Checking Records
invokeOnMessage() 안에서는 레코드의 value 상태와 여러 설정값을 확인 한 뒤, 조건에 따라 다음으로 어떤 로직을 실행할지 결정한다.
만약 어떤 레코드의 key나 value가 비어있고(null), key/value가 null 일 때 역직렬화 예외를 확인하라는 설정이 켜져 있다면, Spring Kafka는 그 레코드를 확인하기 위해 checkDeser() 메서드를 실행한다.
- null 자체가 문제를 일으키는 것은 아님
- key value가 nulll 이라고 바로 취약점이 터지는 것이 아니라 null이면 Spring이 header에 역직렬화 예외 정보가 있는가?를 확인하는 분기로 넘어감
- checkDeser()가 위의 분기점
- 이 레코드 header 안에 역직렬화 예외가 들어있는지 보는 역할을 하는 메서드라고 보면 됨
- 따라서 공격자는 null key/value를 이용함
- 아무 레코드나 보내는 것이 아니라 key나 value를 null로 만들어서 이 checkDeser() 경로를 타게 하려는 것이다.
Step3 : Checking Exception from Headers
checkDeser() 메서드에서는 getExceptionFromHeader()를 호출해서 레코드의 메타데이터(header)에 예외 정보가 있는지 확인하고 그 결과를 exception 변수에 저장한다.
1
checkDeser(record, headerName)가 호출됨 -> 그 안에서 ListenerUtils.getExceptionFromHeader(record, headerName, this.logger) 실행 -> 이 함수가 해당 header에서 예외 객체를 꺼내오려고 시도함 -> 가져온 결과를 exception 변수에 저장 -> 만약 exception != null 이라면 예외를 다시 던짐
getExceptionFromHeader()로 넘어가야하기 때문에 header에 예외값이 있어야함
Step4 : Extracting Exception from Headers
getExceptionFromHeader()는 kafka 레코드의 header 안에 들어 있는 예외 정보를 꺼내오는 함수이다.
1
2
3
4
5
6
record
-> headers에서 특정 header 찾기
-> 그 header의 value(byte[]) 꺼냄
-> 그 byte[]를 예외 객체로 바꾸기
-> 성공하면 exception 반환
-> 없거나 실패하면 null 반환
따라서 위의 코드를 보면 레코드 header에서 특정 header 값을 byte[]로 꺼낸 뒤, 그 바이트 배열을 DeserializationException 객체로 변환해 반환하려는 함수라고 설명할 수 있다.
Step5 : Deserializing Data
byteArrayToDeserializationException의 동작
이 함수는 header에서 꺼낸 byte[] value를 받아서, 그걸 객체로 복원(역직렬화) 하려고 한다.
- header의 byte[]를 입력으로 받는다.
- ObjectInputStream을 만듦
- readObject()를 호출해서 객체 복원
- 위의 결과를 DeserializationException으로 캐스팅해서 반환
resolveClass()를 Override하는 이유
원래 ObjectInputStream.readObject()는 직렬화 데이터 안에 들어있는 클래스 정보를 보고 그 클래스를 로드해서 객체를 복원한다.
하지만 위와 같은 동작은 위험할 수 있으니 개발자는 resolveClass()를 오버라이드해서 허용된 클래스만 통과시키도록 일종의 필터링을 넣어둔 것이다.
1
2
3
4
5
6
if (this.first) {
this.first = false;
Assert.state(
desc.getName().equals(DeserializationException.class.getName()),
"Header does not contain a DeserializationException");
}
->첫 클래스 이름이 DeserializationException 이어야만 통과
위의 필터링을 보면 안전하게 보일 수도 있다. 예를 들어서
1
serialized(EvilClass)
이런 클래스를 넣으면 처음 클래스가 DeserializationException이 아니기 때문에 resolveClass()에서 막힌다.
resolveClass 우회
DeserializationException 코드를 보면 왜 우회가 가능한지 알 수 있다.
1
2
3
4
5
public DeserializationException(String message, byte[] data, boolean isKey, Throwable cause) {
super(message, cause);
this.data = data;
this.isKey = isKey;
}
위의 코드를 보면 DeserializationException은 마지막 파라미터로 Throwable cause를 받는다.
→ DeserializationException 객체 안에는 예외가 들어갈 수 있다는 뜻.
즉 DeserializationException이어도 해당 클래스 안에 있는 cause 필드에는 다른 예외 객체가 들어갈 수 있다는 것이다.
Throwable
1
2
3
Throwable
├─ Exception
└─ Error
자바에서의 예외 계층은 위와 같이 구성 되어 있다. 던질 수 있는 예외 객체는 모두 Throwable이다.
그리고 DeserializationException의 cause 타입이 Throwable이라는 것은 내부에 들어가는 객체가 예외 객체라면 허용 가능하다는 것이다.
1
2
3
4
5
6
DeserializationException(
message = "...",
data = ...,
isKey = false,
cause = Evil Throwable Class
)
따라서 공격자는 위와 같이 DeserializationException 객체를 만들 수 있음.
1
2
3
4
5
6
7
boolean first = true;
if (this.first) {
this.first = false;
Assert.state(desc.getName().equals(DeserializationException.class.getName()), ...);
}
return super.resolveClass(desc);
그리고 resolveClass에서는 첫번째 클래스만 DeserializationException 객체인지 확인하고 그 다음부터는 super.resolveClass(desc)로 넘기고 있다. 따라서 cause에 악의적인 예외 클래스를 넣으면 resolveClass로 넘길 수 있다.
정리하자면 지금까지는 ObjectInputStream.readObject()에 도달하기 위한 준비과정이었음
Exploit
공격자는 Kafka record header에 삽입될 악성 직렬화 데이터를 생성하고 그 데이터가 DeserializtionException의 cause 자리에 들어갈 수 있도록 Throwable 계열 객체 구조를 맞춘 뒤, 사용 가능한 가젯 체인을 넣어서 RCE를 달성한다.
→ Step1~Step5에서 발견한 것들을 기반으로 바이트스트림을 생성해서 header에 넣으면 consumer가 해당 record를 확인하는 과정에서 ObjectInputStream.readObject()까지 도달할 수 있음
Generate Payload
일단 Throwable cause에 넣을 악성 가젯 체인을 구성해야한다.
cause에 가젯 체인을 넣기 위해서는 위에서 말했듯이 Throwable 계열이어야 하기 때문에 extends Throwable로 클래스를 구성한다.
→ 연구진들은 commons collections를 사용했다.
다음으로는 위에서 생성한 가젯 체인을 DeserializationException의 cause에 넣은 뒤 Kafka record의 header 값으로 쓰일 바이트스트림으로 직렬화 한다.
그리고 만들어진 poc.ser라는 바이트스트림을 Kafka record로 만들어서 consumer에게 보낸다.
위의 과정을 통해
이런식으로 RCE를 달성한 것을 확인할 수 있다.









