보안정보
전문화된 보안 관련 자료, 보안 트렌드를 엿볼 수 있는
차세대 통합보안관리 기업 이글루코퍼레이션 보안정보입니다.
Linux Kernel 내 Use-After-Free 취약점 (CVE-2024-1086) 분석 및 대응방안
2024.12.09
1,352
01. Use-After-Free 취약점 개요
Use-After-Free(UAF)는 메모리 할당 후 해제된 메모리를 사용하여 발생하는 메모리 손상(Memory Corruption)취약점으로 주로 힙 메모리(Heap Memory)에서 발생한다. 힙(Heap)은 컴퓨터의 메모리 구조 중 사용자가 임의로 사용 할 수 있는 공간으로 동적으로 할당(malloc)하고 해제(free)할 수 있다.
힙 영역 중에서 사용하지 않는 공간(Unused Heap)은 이중 연결리스트로 관리하는데, 메모리 해제가 발생하면 해당 영역이 연결 리스트에 연결되고 새로운 메모리 할당이 발생하면 연결 리스트의 첫 번째 매칭(First-Fit)영역을 할당하게 된다. First-Fit알고리즘은 메모리 할당을 위한 방법 중 하나로 사용이 가능한 메모리에서 가장 처음 발견한 곳에 할당하는 것을 의미하며 First-Fit외에도 Best-Fit, Worst-Fit이 존재한다.
문제는 할당했던 메모리를 해제한 후에 재할당해서 사용할 때 동일한 크기로 재할당 하는 경우에는 이전에 사용했던 메모리 공간을 재사용하게 되면서 악의적인 목적으로 사용할 수 있게 되는 것이다. 최근에는 운영체제에서 재할당을 하지 못하도록 주소 공간 배치 난수화(ASLR, Address Space Layout Randomization)나 세그멘테이션 및 힙 보호 등을 통해 동일 공간에 재할당되지 않도록 하고는 있으나 이와 같은 공격으로 인해 원래 객체가 아닌 다른 데이터가 해당 메모리 공간을 쓰거나 읽게 되면 임의의 코드 실행이나 비정상적인 시스템 동작을 유발할 수 있게 된다.
리눅스 커널에서는 동적 메모리를 할당할 때 주로 메모리 블록을 재활용하여 성능을 높이는 것을 목표로 SLUB 할당자(SLUB Allocator)를 사용한다. 그러나 효율적인 부분을 강조하다 보니 힙에 다수의 NOP코드와 쉘 코드를 스프레이처럼 뿌리는 공격 기법인 힙 스프레이(Heap Spray)와 같은 UAF발현이 가능한 공격에 노출될 수 있다.
구조적인 안전성과 보안성을 바탕으로 다수의 서버 환경으로 많이 사용되고 있는 리눅스는 시스템 운영에 효과적이기 때문에 공격자들의 주요 공격 대상으로 활용되고 있다. 따라서 권한 탈취나 상승을 유발하여 임의 코드 실행이 가능한 취약점으로 연결될 수 있는 UAF는 다양한 사이버 공격에 악용될 소지가 높다. 이에 따라 이번 호에서 2024년 1월에 발견된 CVE-2024-1086을 통해 리눅스에서 발생 가능한 UAF 취약점의 발생 원리와 대응 방안에 대해서 살펴보고자 한다.
02. CVE-2024-1086 취약점 분석
CVE-2024-1086은 Linux Kernel 내 리눅스 패킷 필터링 및 네트워크 주소 변환(NAT) 프레임워크인 netfilter의 nf_tables 구성 요소에서 UAF가 발생하는 취약점으로 익스플로잇(Exploit)에 성공하면 로컬 권한 상승(Local Privilege Escalation)이 가능하다. 해당 취약점은 nf_hook_slow() 함수에서 이중으로 메모리를 해제 시켜 발생한다. 메모리 오염(Memory Corruption)의 일종인 UAF은 개발자 의도와 달리 공격자가 메모리 조작이 가능하기 때문에 임의 코드 실행이나 권한 상승 등이 가능해지게 된다.
커널 취약점의 경우 리눅스 배포판 버전이 아닌 사용하고 있는 리눅스 커널 버전에 따라 영향도가 다르다. CVE-2024-1086의 경우 iptables 프레임워크를 대체한 netfilter를 사용하는 커널의 대부분이 취약 버전에 해당되기에 공격 대상 범위가 상당히 넓다.
CVE-2024-1086은 발표 당시 CVSS 3.1 기준 고위험도 수준에 속하는 7.8점의 High 등급에 해당하지만 EPSS 수치의 경우 0.04%로 악용될 가능성이 현저히 적은 편에 속하는 취약점이었다. 그러나 올해 3월 개념증명 코드가 Github에 공개되면서 6월에는 EPSS가 1.09%까지 상승하며 악용될 위험성이 증가됨을 확인할 수 있다.
실제로 지난 4월 중순 크라우드스트라이크(Crowdstrike)에서 발표한 바에 의하면 알려지지 않은 위협 행위자가 CVE-2024-1086을 활용하려는 시도가 관측됐으며, 지난 5월 미국의 사이버보안 및 인프라 보안국 CISA는 KEV(알려진 악용 취약점, Known Exploited Vulnerabilities)에도 CVE-2024-1086이 등록되었다. 취약점 악용을 대응하기 위해 CVE-2024-1086의 발생 원인과 PoC 분석을 통해서 로컬 권한 상승 과정에 대해서 구체적으로 설명하고자 한다.
1) CVE-2024-1086: 취약점 원인
CVE-2024-1086은 netfilter에서 발생하는 패킷 처리 과정에서 해제된 메모리를 사용하는 것과 동시에 이중 해제(double-free) 형식을 이룰 수 있다. nftables 패킷 처리 과정에서 문제가 발생한 함수는 nf_hook_slow(), nft_verdict_init() 함수이다. 여기서 사용자의 입력 값으로 인한 긍정적인 드롭 에러(positive drop errors)가 발생한다고 설명한다. 환경 구축에 사용된 Linux kernel 5.15.30 버전에서 취약점이 발견된 코드를 통해 발현 원인을 살펴보자.
[그림 2]은 netfilter 모듈 내 core.c 파일 내부에 존재하는 nf_hook_slow() 함수의 일부이다. 사용자 및 관리자가 생성한 패킷 처리 규칙을 이용해 패킷에 대한 평가가 반복문 안에서 진행되는데, 590번 라인의 내용을 살펴보면 규칙에 대한 verdict 값을 얻고 verdict 값과 NF_VERDICT_MASK 매크로 값을 통해 패킷 처리를 결정하는 것을 알 수 있다. verdict 값은 패킷 처리 결과값으로, 사용자가 직접 설정할 수 있는데 이에 대한 처리는 [그림 4-3]의 nft_verdict_init() 함수에서 이루어진다.
nft_verdict_init() 함수에서 패킷 처리에 관해 사용될 값인 data->verdict.code는 규칙 설정에 대한 리턴 값으로 설정할 수 있다. 해당 값에 공격자가 “0xFFFF0000”이라는 값을 설정할 경우 취약점이 발현된다. netfilter에서 정의된 NF_VERDICT_MASK 매크로를 통해 verdict.code(“0xFFFF0000” )의 하위 16비트를 추출한다.
그 경우 “data->verdict.code & NF_VERDICT_MASK”는 “0xFFFF0000 & 0x0000FFFF”가 되어 “0x00000000”으로 결과값이 도출된다. 해당 값은 netfilter에서 NF_DROP을 의미하기 때문에 해당 패킷을 버리는 것으로 해석하게 된다.
nf_hook_slow()에서 ‘패킷(skb)을 NF_DROP 처리하라’는 요청을 받아 kfree_skb() 함수를 통해 패킷을 해제한다. 이후 진행되는 NF_DROP_GETERR() 함수에서 공격자가 설정한 verdict 값이 “0xFFFF0000”이기 때문에 함수의 내부 연산으로 ret의 값은 -65535가 설정된다.
이와 같은 과정을 통해 패킷 처리의 값은 return 값을 1로 반환하게 되는데, 이는 NF_ACCEPT 처리로 해석된다. nf_hook_slow() 함수에서는 해당 패킷에 대해 긍정적인 드롭 에러(drop error)가 발생하여 NF_ACCEPT 처리하는 것으로 혼동하게 되어 패킷 처리를 계속하게 된다.
위 과정을 통해 kfree_skb()에 의해 해제되었던 패킷이 NF_ACCEPT 반환 처리로 다시 처리되어 이전에 해제되었던 소켓 메모리(skb) 참조가 남아있게 되고(UAF 발생), 해당 패킷이 NF_ACCEPT 처리를 모두 마치게 되어 해당 소켓 메모리(skb)를 해제하게 되면 이중 해제(double-free)가 발생할 수 있게 된다.
[그림 5]와 [그림 6]은 실제로 이중 해제가 발생되는 과정을 디버깅을 통해 확인한 내용이다. 패킷(skb) 메모리를 해제하는 kfree_skb() 함수에 중단점을 설정하고 PoC exploit 파일을 실행하면 nf_hook_slow() 함수에 의해 호출된 kfree_skb() 함수를 발견할 수 있다. [그림 5]에서 kfree_skb() 함수가 해제하는 메모리 주소는 skb = 0xffff88800c6c8d00 임을 알 수 있는데, [그림 6]을 살펴보면 exploit 파일이 실행되는 과정에서 동일한 패킷(skb)의 메모리 주소가 kfree_skb() 함수에 의해 해제되는 것을 볼 수 있다.
정리하자면 kfree_skb()에 의해 해제되었던 패킷이 nf_hook_slow()에서 NF_ACCEPT 반환 처리되어 이전에 해제되었던 소켓 메모리(skb) 참조가 남아있게 되고(UAF 발생), 해당 패킷이 NF_ACCEPT 처리를 모두 마치게 되면 해당 소켓 메모리(skb)를 해제하게 되어 이중 해제가 발생한다.
[그림 7]은 PoC 테스트를 진행하기 위해 구축했던 Linux Kernel 5.15.30 버전을 포함한 패치 전의nft_verdict_init() 코드로, 외부 switch 문과 내부 switch 문을 통해 패킷에 대한 결과 처리를 진행하는 것을 알 수 있다. 간단히 분석해보면 해당 구조는 내부 switch 문에서 data->verdict.code의 상위 비트가 제대로 검증되지 않아 하위 16비트가 NF_DROP 등으로 설정되어 있을 경우 공격자는 상위 15비트를 조작해서 의도치 않은 동작을 유발할 수 있다.
[그림 8]은 CVE-2024-1086 취약점이 패치된 nft_verdict_init() 함수의 변경된 코드이다. 취약점 발생 및 파악 후에 Linux Kernel 5.15.149 버전을 포함한 패치 코드에서는 기존 코드와 달리 외부와 내부 switch 문으로 구분되었던 구조가 단일 switch 구조로 변경된 것을 알 수 있다.
[그림 8]의 패치된 코드는 data->verdict.code의 값을 직접 검증하고, 특정 허용된 값들만 처리하도록 변경되었다. default 케이스는 이제 잘못된(허용되지 않은) verdict 값이 들어올 경우 무조건적으로 ‘–EINVAL’을 반환하여 사용자로부터 악의적인 입력을 사전에 차단한다. NF_DROP을 제외한 NF_ACCEPT, NF_DROP, NF_QUEUE 케이스의 경우 정상적으로 처리 되며, 나머지 케이스는 기존과 동일하게 처리되는 것을 확인할 수 있다.
2) CVE-2024-1086:PoC 환경 구성 및 시연
CVE-2024-1086 취약점이 발현되는 리눅스 커널의 PoC를 위해 가상 머신 안에서 Qemu 시스템을 통해 리눅스 커널을 빌드하였다.
CVE-2024-1086를 활용한 해당 PoC는 CVE-2024-1086 취약점이 존재하는 버전과는 별개로 Linux Kernel 5.14 버전부터 6.6 버전에서 시연이 가능하며 이 외에도 몇 가지의 전제조건을 필요로 한다. 해당 조건들은 다음과 같다.
각 조건에 대해 설명하기 앞서 1번과 2번 조건에서 활용되는 네임스페이스(Namespace)에 대해 간단히 살펴보자. 네임스페이스란 시스템 리소스를 격리하여 프로세스가 독립적인 실행 환경에서 작동할 수 있는 리눅스 커널의 기능 중 하나이다. 1번 조건인 비특권 사용자 네임스페이스(unprivileged-user namespaces)은 일반 사용자가 사용자 네임스페이스(User namespace)를 생성할 수 있도록 허용하는 설정으로, 일반적인 리눅스의 경우 보안 상의 이유로 일반 사용자가 사용자 네임스페이스를 생성하는 것이 허용하지 않는다. 그러나 Debian과 Ubuntu와 같은 주요 배포판에서는 기본적으로 해당 설정이 활성화되어 있어 Debian GNU/Linux 11 (bullseye)으로 환경을 구축하였다.
일반 사용자의 사용자 네임스페이스 생성을 허용한 이후 로컬 일반 사용자는 사용자 네임스페이스를 생성하여 nftables에 대한 액세스 권한을 얻을 수 있다. 취약점의 원인이 된 nftables의 경우 일반적으로 높은 액세스 권한을 요구하는데, 일반 사용자가 사용자 네임스페이스를 통해 nftables를 사용 가능한 환경을 만들어 주기 위한 조건으로 파악된다.
3번과 4번 조건의 경우 접근 권한을 얻은 로컬 일반 사용자는 취약점을 트리거(triger)하기 위한 악의적인 nftables 체인/룰을 생성하는 것과 background 내 메모리 노이즈(memory noise)가 많을 경우 취약점 발현의 악영향을 줄 수 있기에 메모리를 미리 할당하여 취약점 발현이 예측 가능한 메모리 레이아웃을 만들어 내기 위함으로 분석된다. 특히 3번 조건의 경우 PoC 분석 내용과도 밀접한 연관이 있어 차후에 이어서 설명한다.
CVE-2024-1086취약점이 발현되는 전체적인 구성은 [그림 9]와 같다. [그림 10]과 같이 시연 환경에서 일반 사용자 계정인 ‘igloo’로 CVE-2024-1086 PoC를 실행하면 익스플로잇(Exploit) 중간 과정에서 이중 해제(double-free) 및 메모리 spraying, 커널 베이스(kernel base) 주소 스캔 등의 작업이 이뤄져서 최종적으로 로컬 권한 상승(Local Privilege Escalation)을 수행하게 된다.
CVE-2024-1086취약점이 발현되는 전체적인 구성은 [그림 9]와 같다. [그림 10]과 같이 시연 환경에서 일반 사용자 계정인 ‘igloo’로 CVE-2024-1086 PoC를 실행하면 익스플로잇(Exploit) 중간 과정에서 이중 해제(double-free) 및 메모리 spraying, 커널 베이스(kernel base) 주소 스캔 등의 작업이 이뤄져서 최종적으로 로컬 권한 상승(Local Privilege Escalation)을 수행하게 된다.
3) CVE-2024-1086:PoC 분석
Github에 업로드 된 PoC의 exploit 과정을 면밀히 분석해보자. 먼저 메인(main) 함수 흐름은 [그림 11]에서 확인할 수 있는데, main 함수는 크게 4개의 순서로 구성되어 있으며 각 순서는 다음과 같다.
[그림 11]의 main 함수 내 521번 라인에서부터 메모리 맵핑과 함께 익스플로잇을 위한 코드 세팅을 진행한 뒤 526번 라인에서 익스플로잇의 안정성을 위한 자식 프로세스 생성 및 설정을 진행한다. 이후 542번 라인 privesc_flh_bypass_no_time() 함수를 포함해 상-하에 위치한 코드로 초기 환경 구축 및 실제 익스플로잇이 진행되고 이후 549번 라인에서 부모 프로세스의 설정 마무리 및 PoC의 익스플로잇 과정이 모두 마치게 된다. 이제 각 과정에 대하여 상세히 살펴보자.
[그림 11]의 542번 라인에 위치한 privesc_flh_bypass_no_time() 함수는 PoC의 본체라고 할 수 있다. 해당 함수가 실행되면 CVE-2024-1086 취약점을 활용하여 최종적으로 로컬 권한 상승까지 달성한다. 익스플로잇이 달성되기 까지의 과정은 또 다시 크게 3가지의 순서로 나누어진다.
[그림 12]를 보면 PoC는 크게 세 가지의 흐름으로 진행되는 것을 알 수 있다. 그중 특히 Performing double-free 과정이 다른 과정에 비해 복잡하며 일반적인 double-free와는 전개가 사뭇 다르다. 보통의 메모리 할당의 경우 “할당 – 해제 – 할당 – 해제” 와 같이 할당 이후 해제가 이루어지는 형식으로 진행되지만, 이중 해제(double-free)는 일반적인 흐름과 달리 “할당 – 해제 – 해제” 형식으로 진행되어 메모리 오염(Memory corruption)이 발생하는 취약점이다.
해당 PoC를 분석해보면 이중 해제의 흐름이 아닌 “할당 – 해제 – 할당 – 해제” 형식으로 확인되어 일반적인 메모리 할당 형식으로 보여진다. 허나 이는 리눅스의 메모리 해제를 나타내는 변수인 refcount 때문이며, 이로 인해 위와 같은 형식으로 진행된다. 문제가 되는 원인을 간단히 설명하면 refcount 값이 1에서 0으로 감소해야 실제로 메모리 해제가 이루어지는데, 만약 연속으로 메모리 해제가 일어날 경우 refcount 값이 1 -> 0 -> -1로 변경되어 의도치 않은 값에 의해 커널 패닉(Kernel Panic)이 발생할 수 있기 때문에 “할당 – 해제 – 할당 – 해제” 형식으로 공격이 이루어진다. 커널 패닉은 컴퓨터 운영체제의 커널에서 치명적인 오류(패닉)가 발생한 상태로, 안전하게 복구가 불가능할 때 취하는 동작이기 때문에 이러한 방식은 공격의 안전성을 보장할 수 있게 된다.
그렇다면 이중 해제는 어떻게 발생하는가? 메모리가 이중 참조(A와 B 참조로 가정)가 된 상태에서 A 참조에서 메모리 해제가 이루어지고 다시 해당 메모리를 할당한 후 B 참조에서 메모리 해제를 발생시킴으로써 이중 해제(double-free) 형식이 이루어진다. 이러한 형식의 공격은 사용자의 페이지 테이블을 조작하여 커널 메모리 공간을 임의로 수정할 수 있게 하는 공격 기법인 Dirty Pagetable과 취약점 익스플로잇을 연계하여 수행하기 위함으로도 분석된다.
결국 공격자가 메모리를 해제(free)했음에도 불구하고 해당 메모리에 대한 참조가 아직까지 이루어지고 있는 상태이기에 UAF가 발생하고, 이를 다시 해제함으로써 이중 해제 형식을 발생시켜 커널 메모리 구조 파악 및 LPE 달성이 이뤄진다. [그림 13]을 통해 이중 해제(double-free) 유발 과정을 면밀히 살펴보자.
첫 번째 주요 과정인 이중 해제(double-free)의 경우 먼저 UDP 패킷을 생성함으로써 다량의 skb(sockets buffers)를 생성한다. 해당 작업은 이중 해제 탐지와 안정성을 확보하기 위한 masking용으로 생성하는 동시에 메모리 할당 패턴을 고정하도록 유도할 수 있다.
PoC 내 UDP 관련 코드인 [그림 14]를 살펴보면, alloc_ipv4_udp() 함수는 UDP 패킷을 생성하고 이를 전송하는데 이 과정에서 네트워크 버퍼(skb)가 메모리에 할당된다. 51번 라인에서 memset() 함수를 통해 intermed_buf라는 버퍼를 \x00 값으로 초기화 한 후 send_ipv4_udp() 함수를 호출하여 초기화된 버퍼와 크기를 사용해 UDP 패킷을 생성 및 전송한다.
[그림 15]에서 호출되는 send_ipv4_udp() 함수를 간단히 살펴보자. 먼저 sockaddr_in dst_addr을 통해 목적지 주소를 설정한다. AF_INET 값으로 IPv4 인터넷 프로토콜을 사용을 명시하고 로컬호스트(127.0.0.1)의 45173 포트를 목적지로 설정한 것을 확인할 수 있다. 이후 sendto_noconn() 함수로 지정된 소켓을 통해 데이터그램 전송을 시도한다.
다시 PoC의 privesc_flh_bypass_no_time() 내부로 돌아와서 [그림 16]의 UDP 패킷 생성 부분을 확인해보면 네트워크 버퍼를 메모리에 할당하기 위해 alloc_ipv4_udp() 함수를 반복적으로 호출하는 것을 알 수 있다. 크기 1의 UDP 패킷을 반복적으로 생성 및 전송하여 네트워크 버퍼가 할당되는 데 이를 통해 메모리 할당 패턴을 고정하는 효과를 유도할 수 있다. 이는 곧 특정 메모리 블록을 다시 할당 받을 수 있도록 조작 가능하게 만든다.
이후 이중 해제(double-free)를 트리거 하기 위해 조작된 IP 패킷을 생성하고 이를 전송한다. 이때 해당 패킷은 환경 구축 시 설정한 nftables 규칙에 의해 처리되면서 패킷이 해제된다. 환경 구축 때 설정되는 nftables 규칙은 PoC에 첨부된 파일 중 /src/nftnl.c 파일 내 configure_nftables() 함수로 생성되는데, 그 안에 위치한 alloc_rule() 함수를 보면 어느 패킷을, 어떻게 처리할 지 등의 설정 및 필터링 과정을 확인할 수 있다.
alloc_rule() 함수 내용을 살펴보면 규칙은 프로토콜과 패킷 내 처음 4바이트를 검사한다. add_payload 함수는 IPv4 헤더에서 프로토콜 필드를 추출하여 NFT_REG_1 레지스터에 저장한 후 add_cmp 함수를 통해 NFT_REG_1에 저장된 값이 인자로 전달된 proto 값과 동일한지 비교한다.
그 다음 패킷의 4바이트 검사를 위해서 add_payload 함수를 통해 IPv4 헤더의 끝에서 4바이트를 추출한 뒤 NFT_REG_1 레지스터에 저장한다. 이후 동일하게 add_cmp 함수를 통해 NFT_REG_1에 저장된 값이 “\x41\x41\x41\x41”(ASCII 변환 시 “AAAA”)와 동일한지 비교한다.
위 규칙에 해당되는 패킷이 전송될 경우 규칙의 결과를 “0xFFFF0000”으로 설정하는데, 이는 “1) CVE-2024-1086 : 취약점 원인” 파트에서 설명한 것처럼 nftables에서 NF_DROP 연산과 함께 NF_ACCEPT로 해석되어 긍정적인 드롭 에러(drop error)에 해당되는 동작을 유발시킬 수 있다.
설정한 nftable 규칙이 발현되어 패킷 처리를 실행하도록 별도의 IP 패킷을 생성 및 전송한다. [그림 20]을 보면 send_ipv4_ip_hdr() 함수를 통해 IP 패킷을 생성 및 전송 기능을 수행하는데, IP 헤더와 데이터 설정(69-75번 라인) 및 체크섬 계산(78-79번 라인)을 수행한 뒤 최종적으로 패킷을 전송(83번 라인)한다.
send_ipv4_ip_hdr() 함수는 [그림 21]에서 확인할 수 있듯이 trigger_double_free_hdr(), send_ipv4_ip_hdr_chr() 함수 순서로 호출되는 데, 최종적으로 중요한 부분은 main.c에서 trigger_double_free_hdr()를 호출하는 privesc_flh_bypass_no_time() 함수를 살펴보아야 한다.
privesc_flh_bypass_no_time() 함수에서 IP 패킷 생성을 위해 trigger_double_free_hdr() 함수를 호출하는데, 호출하기 전 [그림 22]의 라인 322번에서 IP 헤더의 오프셋 필드에 IP_MF(0x2000) 플래그를 설정한다. 해당 플래그를 사용할 경우 IP 패킷이 조각화 되어 skb가 IP fragment queue(IP 조각 대기열)에 들어가도록 강제할 수 있다. 또한 조각화된 패킷을 전송하면서 할당된 skb는 이전에 설정했던 nftable 규칙에 의해 nf_hook(), nf_hook_slow() 함수의 NF_DROP 케이스로 해제된다.
그러나 조각화된 패킷은 IP fragment queue에서 관리되므로 queue에 여전히 위치한 상태이며, [그림 22]의 307번 라인과 같이 IP 패킷을 생성하기 전 ipfrag_time을 9999로 설정했기 때문에 시간이 지나면서 조각화된 패킷이 재조립될 일 없이 queue 안에 머물 수 있다. 즉 해제가 된 skb가 아직 ip fragment queue 리스트에 상주하고 있어 double-free 상태를 유도할 수 있다.
첫 번째 해제(free) 이후 이전에 설정했던 UDP 패킷을 모두 해제한다. 해당 과정을 통해 이중 해제가 감지되는 것을 방지하고 익스플로잇의 안정성을 높일 수 있는데, 첫 번째 해제가 발생한 후 메모리 풀에 skb가 해제된 위치를 보존하여 두 번째 해제가 안전하게 진행될 수 있도록 유도한다.
UDP 패킷까지 해제 한 이후 첫 번째 해제된 위치를 다시 할당자(allocator)로 할당을 시도한다. 이때 페이지 테이블 항목을 충분히 할당하기 위해(첫 번째 해제된 위치를 안정적으로 할당하기 위해) PTE spray를 진행한다. VMA에 등록된 가상 메모리 페이지에 접근한 뒤 CONFIG_PTE_SPRAY_AMOUNT 만큼 PTE를 스프레이 한다. 이때 한 페이지 테이블은 512개의 페이지를 포함하고 있으며, PTE는 4KB 크기의 메모리를 가리킬 수 있어 총 2MB (0x200000 bytes)를 차지한다.
즉 2MB 간격으로 PTE를 할당하는 과정이며, 이후 PMD와 PTE가 오버랩 되는 구간을 찾기 위해 PTE 위치에 0x41 값을 할당한다. 또한 [그림 25]에서 메모리 위치 계산을 위해 PTI_TO_VIRT 매크로를 사용하는 것을 볼 수 있는데 해당 매크로에 대한 상세 코드는 [그림 26]과 같다.
PTI_TO_VIRT 매크로는 페이지 테이블 인덱스를 가상 메모리 주소로 변환하여 정확한 메모리 위치에 접근하고, 해당 메모리 위치에 PTE를 할당하기 위해 사용된다. [그림 26]의 첫 번째 코드부터 차례대로 PTE, PMD, PUD, PGD 인덱스를 비트 시프트(bit shift)하여 가상 주소로 변환하는 코드이며, PTI_TO_VIRT 실행 시 각각의 페이지 테이블 인덱스를 가상 메모리 주소로 변환한다.
PTE 스프레이를 마친 후에 두 번째 해제 과정을 트리거 하기 위해서 IP fragment queue 대기열에 위치 중인 IP 조각화 패킷을 전송한다. 이를 위해 ip_id는 이전에 할당한 ip_id와 동일한 값으로 설정한다. 해당 패킷을 전송하면 조각화 대기열이 완성되어 ip_fragement_queue가 패킷 재조립을 시도하고 이 과정에서 대기열에 있는 패킷(skb)이 두 번째 해제된다.
이전 과정에서 PTE 스프레이를 통해 skb 해제 구간을 할당해주었기 때문에 freelist에 존재하지 않아 skb가 두 번째 해제될 때 커널 패닉 없이 정상적으로 해제(free)가 이루어진다. 이중 해제(double-free)가 완료된 시점에서 freelist에 반환된 페이지를 Overlapping PMD(Page Middle Directory)로 재할당한다.
[그림 28] 과정에서 0xCAFEBABE 값을 직접 할당하는 것은 특정 주소에 특정 값을 넣는 행위로 분석된다. 0xCAFEBABE 값을 _pmd_area에 설정함으로써 PMD 페이지가 올바르게 할당되고 접근이 가능한지 확인함과 동시에 정상적으로 이뤄질 경우 해당 PMD를 가지고 PTE와 PMD가 중첩되는 페이지를 찾는다.
앞서 PTE 및 PMD 할당을 완료했다. 오버랩된 PMD와 PTE가 존재하는 상황에서, PTE 스프레이를 통해 할당된 PTE 중 어느 것이 오버랩된 것인지 찾아야 한다. 따라서 PTE 영역에서 PMD 영역에 속하는 PTE 항목을 확인하는 작업을 수행한다.
[그림 30]의 362번 라인에서 If 조건문을 통해 현재 선택된 PTE 항목의 값이 0x41이 아닌 경우를 확인한다. 0x41은 PTE spray에서 초기화 했던 값으로, 변경되었다면 오버랩된 PTE임을 알 수 있다. 이후 해당 PTE를 발견하면 관련 정보 출력과 함께 pte_area 변수에 오버랩된 PTE 항목의 주소를 저장한다.
오버랩된 PTE 항목에 PTE의 물리 주소와 플래그를 포함한 새로운 값으로 설정한다. 이후 flush_tlb() 함수로 TLB를 플러시하여 변경된 PTE 값이 반영되도록 설정한다.
이전 과정을 통해 로컬 권한 상승을 이루기 위해 필요한 첫 단계인 이중 해제 유발 과정을 설명했다. 지금부터 물리적 메모리 스캔(Physical Memory Scan) 과정에 대해 살펴보자. PMD와 PTE의 이중 할당을 설정하였기에 userland에서 커널 공간 미러링 공격(KSMA, Kernel Space Mirroring Attack)를 수행할 수 있다. 즉 PTE 영역 내의 특정 주소에 PTE 항목을 통해 물리적 주소를 쓰고, PMD 영역에서 일반 메모리 페이지로 역참조(dereference)가 가능하다. 물리적 메모리 스캔 과정을 통해 물리적 커널 베이스 주소를 얻은 후 임의의 읽기/쓰기 권한으로 modprobe_path 커널 변수에 액세스하는 것을 목표로 한다.
[그림 32]에서 박스 처리된 커널 서명 비교 코드는 get-sig라는 공통 주소 내 공통 바이트를 추출하여 데이터 시그니처(Signature)를 생성하는 python 스크립트로 해당 스크립트를 활용해 만든 것으로 확인된다. 해당 비교 값 코드를 통해 커널 기본 주소를 찾고, 매칭 여부에 따라 반환 값이 나뉘어 분기가 나누어진다. 그렇다면 해당 코드를 활용한 주요 함수로 다시 복귀하여 커널 메모리에 대한 작업을 확인해보자.
[그림 33]에서 kernel_iteration_base 값을 설정한 후 각 반복에서 512개의 PTE를 설정한다. 해당 과정은 물리적 메모리 주소 범위를 스캔 및 권한 할당 과정이며, 512 페이지를 대상으로 커널 메모리를 할당하려는 것으로 보아 8GiB 크기의 물리적 메모리 환경을 가정하여 작성된 것으로 추정된다.
[그림 33]의 code 내용을 상세히 살펴보자. 반복문(for문) 내부에 위치한 398번 라인의 pte_area[j] = (kernel_iteration_base + CONFIG_PHYSICAL_ALIGN * j) | 0x8000000000000867; 코드를 확인할 수 있다. 해당 코드에서 CONFIG_PHYSICAL_ALIGN은 물리 주소(PFN)을 나타내고, 0x8000000000000867 값은 페이지 읽기/쓰기 권한 플래그이다. 따라서 512개의 페이지를 반복적으로 돌려 물리적 커널 주소와 함께 읽기 및 쓰기 권한을 부여하는 과정임을 이해할 수 있다. 이후 TLB를 플러시하여 변경된 PTE 값이 반영되도록 설정한다.
이후 [그림 34]에서 물리적 메모리를 스캔한 페이지가 커널 기본 주소를 참조하고 있는지 확인하기 위해 is_kernel_base() 함수를 호출한다. 함수의 return 값이 0을 반환하면 다음 페이지로 넘어가 계속해서 스캔을 진행하고 return 값이 1일 때, 커널 기본 주소를 참조하는 것이 확정적으로 판단되어 다음 익스플로잇 과정을 수행한다.
물리적 커널 기본 주소를 찾았다고 판단되면 이제는 modprobe_path를 식별하기 위해'\x00' 패딩을 포함하여 CONFIG_MODPROBE_PATH(“/sbin/modprobe“)부터 최대 256 바이트까지 스캔을 시도한다. modprobe_path를 찾는 이유는 이를 덮어씀으로써 로컬 권한 상승을 위한 악의적인 스크립트를 실행할 수 있기 때문이다.
커널 기본 주소부터 시작해 40 * 0x200000 (2MiB) = 0x5000000 (80MiB) 바이트를 스캔하여 modprobe_path를 찾는다. 스캔하기 전에 modprobe_iteration_base를 설정함으로써 현재 스캔할 물리적 주소의 범위를 설정한다. 이후, 해당 범위 내의 주소들을 효과적으로 스캔하기 위해 512개의 PTE 항목을 설정하여 커널 메모리의 특정 주소 범위를 가리키도록 한다.
위 과정을 통해서 다른 스레드의 PUD 데이터 범위를 커널 메모리로 설정하여 주소 간의 매핑이 가능해지고 물리 메모리 스캔 및 커널의 특정 주소(modprobe_path)를 찾을 수 있다. 설정한 후 TLB를 플러시하여 변경된 PTE 값이 반영되도록 한다.
[그림 36]에서 memmem() 함수를 사용하여 CONFIG_STATIC_USERMODEHELPER_PATH 또는 modprobe_path를 검색한다. 이 때, 결과가 존재하지 않으면(NULL일 경우) 다음 주소 범위를 계속 스캔한다. 위 커널 베이스 주소를 찾는 과정은 최종적으로 modprobe_path를 찾기 위함으로 귀결되기 때문에 모든 루프를 돌았음에도 불구하고 커널 코드 세그먼트를 찾지 못했다면 오류 메시지를 출력한다.
성공적으로 modprobe_path에 접근이 가능하다 하더라도 한 가지 해결 과제가 남아있다. 바로 익스플로잇의 실제 PID를 얻어야 한다. PID를 얻어야만 권한 상승 스크립트를 포함한 파일 디스크립터(/proc//fd)를 실행할 수 있기 때문이다. 해당 PoC는 PID를 얻기 위해 brute force 방식을 차용했다. 반복문으로 PID 값을 일일히 대입하는 데, 이때 PID 추측값마다 MEMCPY_HOST_FD_PATH를 사용하여 경로를 설정하고, dprintf를 사용하여 스크립트를 작성한다. 이후 modprobe_trigger_memfd() 함수를 호출하여 작성한 modprobe 파일을 실행한다. 해당 과정에 대한 코드는 [그림 37]에서 확인할 수 있다.
modprobe_path 커널 변수를 성공적으로 탐지할 경우 lseek() 함수를 통해 modprobe_script_fd 파일 디스크립터(File Descriptor)의 파일 오프셋을 파일 시작점으로 설정하여 작성된 내용을 덮어쓰기 위한 준비를 한다. 이후 dprintf() 함수로 원하는 스크립트를 작성한다. [그림 37]에서 dprintf() 함수에 작성된 스크립트는 [표 3]과 같다.
해당 코드가 실행됐다면 결국 루트 쉘(root shell)을 획득했음을 의미한다. 익스플로잇에 성공했다면 이제는 안정적으로 루트 쉘을 활용할 수 있는 환경을 조성해야 한다. 해당 PoC는 메모리 취약점을 활용한 공격으로, 메모리 조작의 부작용인 프로세스의 페이지 테이블의 페이지가 약간 불안정한 상태라고 볼 수 있다. 하지만 이는 프로세스가 멈출 때 문제가 발생하기 때문에 프로세스를 멈추지 않도록 하여 안정성을 해결할 수 있다.
[그림 38]는 PoC의 main 함수에서 익스플로잇의 안정성을 높이기 위해 작성된 코드를 표시한 것이다. 표시된 (1), (2), (3) 과정은 각각 자식 프로세스와 부모 프로세스에 대한 설정을 진행한다.
(1)번 과정에서 fork() 함수를 선언함과 동시에 return 값을 비교하여 자식 프로세스 생성 및 코드 블록을 실행한다. 이후 자식 프로세스는 SIGINT 시그널을 처리하기 위해 signal_handler_sleep 핸들러를 등록한다. 이는 자식 프로세스는 SIGINT 시그널이 발생할 때, 기본 동작(프로세스 종료)을 수행하는 것이 아닌 signal_handler_sleep 핸들러를 통해 이를 무시하고 백그라운드에서 계속 실행되도록 설정한 것이다.
(2)번 과정에서 익스플로잇 주요 함수가 마쳐지게 되면 exploit_status의 값을 EXPLOIT_STAT_FINISHED을 변경하고 루트 쉘 획득 상태를 오랜 시간 유지하기 위해 sleep 상태로 유지한다. 이후 (3)번 과정에서 부모 프로세스가 SPINLOCK 매크로를 사용하여 exploit_status가 EXPLOIT_STAT_RUNNING 상태에서 벗어날 때까지 대기하다가 자식 프로세스가 익스플로잇을 완료 후 수행이 완료되면 부모 프로세스도 종료 과정을 수행한다. 허나 (1)번과 (2)번 과정으로 자식 프로세스는 종료되지 않기에 부모 프로세스 역시 종료되지 않도록 유지할 수 있다.
03. 대응 방안
CVE-2024-1086은 리눅스 커널의 내장 모듈 중 하나인 netfilter의 nft_verdict_init(), nf_hook_slow() 함수에서 긍정적인 드롭 에러(drop error)가 발생하는 것이 원인이었다. 따라서 커널의 최신 버전 및 보안 업데이트를 적용하는 것이 중요하며, 업데이트가 불가한 경우 CVE-2024-1086을 트리거 할 수 없도록 설정 값을 변경하는 것을 권고한다.
패치는 릴리즈(Release), 메이저(Major), 마이너(minor) 버전에 따라 각 패치 버전이 출시되었기에 패치를 적용하려는 환경을 고려하여 적용하는 것을 권장한다.
1) 일반 사용자의 네임스페이스 생성 권한 제거
처음 PoC 환경 구축을 위해 설정했던 unprivileged-user namespaces 값의 의미를 고려했을 때, 일반 사용자가 네임스페이스 생성할 수 없도록 권한을 제한하여 취약점이 발생한 netfilter 모듈 접근 제한하는 것도 취약점 조치 중 하나이다.
2) nftables 접근 권한 통제 : 규칙 생성 및 적용 권한 제한
네임스페이스로 인한 접근이 아니더라도 일반적으로 생성된 사용자들의 권한이 nftables 규칙 생성 및 적용 권한이 존재할 경우 로컬 일반 사용자가 악의적인 규칙을 생성하여 취약점을 발현할 수 있다. 따라서 nftables 접근 제한 설정을 철저히 함으로써 CVE-2024-1086 취약점 발생 위험도를 낮출 수 있다.
04. 마무리
지금까지 CVE-2024-1086 취약점을 통해 Linux 커널의 내장 모듈에서 발생하는 이중 해제(double-free)를 포함한 Use-After-Free(UAF) 취약점의 원인과 발현 과정을 살펴보았다. 메모리 취약점은 보통 공격 난이도가 높고 발현 과정이 복잡하지만, PoC가 공개됨에 따라 공격 난이도가 하락함과 동시에 공격이 빈번하게 발생할 수 있다.
특히, 메모리 관련 취약점이 악용될 경우 커널 패닉(Kernel Panic)이나 관리자 권한 탈취, 임의 코드 실행 등의 심각한 보안 사고로 이어질 가능성이 크다. 이에 따라, ASLR(Address Space Layout Randomization), DEP(Data Execution Prevention)과 같은 서버의 메모리 보호 기법에 대한 숙지와 활성화에 대한 이해도가 필요하며 접근 권한 제한, 사용 중인 모듈의 설정과 규칙 검토 및 보안 패치 적용과 같은 일반적인 보안 조치를 주기적으로 관리하여 보안성을 강화해야 한다.
05. 참고 자료
1) Vulnerability Details : CVE-2024-1086 : https://nvd.nist.gov/vuln/detail/CVE-2024-1086
2) CVE-2024-1086 Related to Abuse Cases : https://www.crowdstrike.com/blog/active-exploitation-linux-kernel-privilege-escalation-vulnerability/
3) CVE-2024-1086 Proof of Concept Write-up : https://pwning.tech/nftables/
4) Proof of Concept for CVE-2024-1086: https://github.com/Notselwyn/CVE-2024-1086
5) Efficient Python scripts for generating data signatures : get-sig :
https://github.com/Notselwyn/get-sig