"The splice() family of calls lets userspace plant a reference to a page-cache page...into the frag slots of a sender-side socket buffer"
— Edera
안녕하세요, 수 많은 제로카피 크래시 계열의 취약점이 쏟아져(?) 나오고 있는 시국입니다.
물론 그 말은, 매일같이 업계에도 비상이 걸린다는 뜻 이겠지요 껄껄..
요즘도 그렇고 과거에도 그렇고 많이 떠오르는 보안 취약점은 커널 수준에서 발생하는 취약점입니다.
그 만큼 개발자들이 놓치기 쉬운 부분이 많고, 메모리 관리에서, 페이지 관리에서 특히 많이 발생하는 만큼 맘편하게 이야기 좀 해보려고 합니다.
먼저 2026년 사례 2건만 간단하게 설명하고 시작하겠습니다.
먼저 Copy Fail 입니다. CVE-2026-31431 유명하죠?
커널 AEAD 암호화 API의 유저스페이스 인터페이스에 있던 성능 최적화 코드가 원인이였다고 합니다.
소스 데이터와 목적지 출력이 메모리 공간을 잘못 공유하면서, 읽기 전용 페이지가 일시적으로 쓰기 가능한 상태가 돼버렸죠. 일반 권한 사용자가 보호된 파일의 페이지 캐시에 '4바이트'를 마음대로 써넣을 수 있었습니다.
충격적인건 2017년 이후의 모든 배포된 리눅스에 해당하는 취약점이였고, 익스플로잇 코드도 매우 단조로운 10줄 내외로 이루어진 코드였다는 것을 생각하면 끔찍합니다.
CVE-2026-43284 입니다. 이것도 카피페일과 비슷한 패턴으로 다시 발견된 취약점입니다.
splice_to_socket() 으로 읽기 권한만 있는 페이지의 캐시 페이지를 네트워크 버퍼 조각으로 슬쩍 끼워넣으면, 커널이 그걸 자신 소유라고 착각하고 제자리에 덮어써버리는 구조입니다.
그냥 단순하게 캐시 페이지가 "Dirty(수정 됨)" 상태로 마킹되는 타이밍과,
그 페이지를 참조하는 Pipe Fragment 간의 동기화 실패가 원인입니다.
취약 흐름대로 이어지면,
- splice()로 읽기 전용 파일의 페이지를 pipe에 fragment로 참조
- 특정 경쟁 조건(race condition)에서 해당 페이지가 쓰기 가능한 dirty 상태로 전환
- pipe fragment는 여전히 같은 물리 페이지를 가리키고 있는데, 권한 체크 없이 쓰기가 허용됨
- 읽기 전용으로 열린 파일의 페이지 캐시에 임의 데이터 쓰기 가능
2022년의 Dirty Pipe와 굉장히 유사한 구조입니다. Dirty Frag는 변종이자 진화형으로, fragmentation 과정에서 발생하는 별도 경로를 악용합니다.
https://github.com/v4bel/dirtyfrag (Dirty Frag 익스플로잇 코드입니다.)
/* 패치 전 - 권한 체크 없이 dirty 마킹 */
set_page_dirty(page);
/* 패치 후 - pipe 참조 확인 후 COW 적용 */
if (page_mapcount(page) > expected_refs)
goto copy_needed;
set_page_dirty(page);
패치 전 후의 차이는 Fragment 생성시 권한을 재검증 하는 부분입니다.
pipe buffer에 fragment를 추가할 때, 원본 페이지의 현재 권한을 다시 확인하도록 강제합니다.
그리고 페이지를 dirty로 전환하기 전에, 해당 페이지를 참조 중인 pipe fragment가 있는지 검사하고 , 만약 있다면 Copy on Write를 강제로 적용하는 방식으로 패치 되었습니다.
그리하여, 리눅스 7.0.1 오픈소스의 kernel/bpf/core.c를 분석하고 생각을 잠깐이나마 적어보고자 합니다.
static struct bpf_prog *bpf_prog_clone_create(struct bpf_prog *fp_other,
gfp_t gfp_extra_flags)
{
fp = __vmalloc(fp_other->pages * PAGE_SIZE, gfp_flags);
if (fp != NULL) {
memcpy(fp, fp_other, fp_other->pages * PAGE_SIZE);
// fp->aux == fp_other->aux (동일 포인터)
}
return fp;
}
대충 느낌이 오나요? 그렇지 않다면 죄송합니다.
memcpy로 페이지 전체를 복사하는데, fp->aux와 fp_pther->aux가 동일한 구조체를 가르킵니다. 이 상태에서 bpf_jit_blind_constants()가 에러 경로로 진입하면,
bpf_prog_clone_free()는 fp->aux = NULL; __bpf_prog_free(fp)를 실행하는데, 이미 aux는 원본이 사용 중입니다.
bpf_jit_prog_release_other()에서 fp->aux->prog = fp 재설정이 선행되지 않으면 원본 프로그램이 클론의 해제된 aux를 참조하는 UAF 창이 열립니다.
다시 돌아가서.. '상수 블라인딩이 왜 클론을 만드는가." 를 생각해보면,
BPF JIT는 JIT 스프레이 공격 방버를 위해 상수를 난수화합니다.
예를들어,mov r0, 0xdeadbeef 같은 명령을 그대로 JIT하면 공격자가 ROP 가젯을 페이지에 심을 수 있습니다.
이를 막기 위해 bpf_jit_blind_constants()가 호출되고, 원본 프로그램을 건드리지 않기 위해 클론을 만들어서 클론에 상수 난수화를 적용합니다.
struct bpf_prog *bpf_jit_blind_constants(struct bpf_prog *prog)
{
clone = bpf_prog_clone_create(prog, GFP_USER); // 클론 생성
}
문제의 핵심. memcpy는 포인터 값만 복사됩니다.
memcpy(fp, fp_other, fp_other->pages * PAGE_SIZE);
memcpy가 포인터를 복사하는 방식에 대해서 설명을 해보면, 컴퓨터 메모리에서 구조체는 결국 바이트 덩어리입니다.
memcpy는 그 바이트들을 그대로 목적지로 옮기게 됩니다. 아무 생각 없이 비트 단위로 복사하는 것 입니다.
문제는 bpf_prog 구조체 안에 aux라는 필드가 있는데, 이 필드의 실제 내용은 포인터, 즉 메모리 주소라는 겁니다.
fp_other->aux 필드의 실제 값: 0x7fff_a3c0_0000 (임의의 메모리 주소)
memcpy를 하면 이 숫자 0x7fff_a3c0_0000 자체가 클론에도 그대로 복사됩니다. 그러면 원본과 클론 둘 다 aux 필드에 똑같이 0x7fff_a3c0_0000 이 들어가 있는겁니다.
좀 더 쉽게 비유를 통해서 설명하자면..
집 주소가 적힌 종이를 복사기로 복사했다고 합시다. 종이는 두 장이 됐지만, 두 장 모두 같은 주소가 적혀있지요. 그런데, 종이가 두 장이라고 해서 집이 두 채가 되는 건 아닙니다. 두 종이 모두 같은 집 한 채를 가리키고 있는 겁니다.
코드에서도 정확히 이게 일어납니다. bpf_prog_aux 라는 구조체는 힙 어딘가에 딱 하나만 존재하는데, 원본과 클론이 둘 다 그 하나를 가리키고 있는 상태가 되는겁니다.
그 결과로 원본에서 prog->aux-> (임의의 필드) 를 바꾸면 클론에서도 clone->aux-> (임의의 필드)를 읽었을 때 변경된 값이 보입니다. 같은 집을 보는거니까요.
진짜 위험한 순간은 둘 중 하나가 해제될 때 생깁니다. 예를 들어 클론이 aux = NULL 로 설정하고 메모리를 해제해버리면, 원본은 자기 aux 포인터가 아직도 그 주소를 가리키고 있습니다. 하지만 그 주소에 있던 메모리는 이미 운영체제에 반납된 상태입니다.
원본이 나중에 그 주소로 접근하려 하면, 그 공간에는 이미 다른 데이터가 들어왔을 수 있습니다. 그리고 이게 Use-After-Free가 되는 것 입니다.
먼저 bpf_jit_blind_insn() 함수에 대해서 먼저 설명하고자 합니다.
BPF 프로그램이 커널에 로드되면 JIT 컴파일러가 BPF 바이트코드를 실제 CPU 기계어로 변환합니다. 그런데 이 과정에서 JIT 스프레이 공격이라는 위협이 존재합니다.
공격자가 BPF 프로그램 안에 mov r0, 0xdeadbeef 같은 명령을 대량으로 심어두면, JIT가 이것을 기계어로 변환할 때 그 0xdeadbeef라는 상수값이 실행 가능한 메모리 페이지 안에 그대로 박히게 됩니다.
공격자 입장에서는 원하는 바이트 패턴을 커널 메모리에 심는 방법이 됩니다. 이 바이트들이 마침 유용한 ROP 가젯이 되도록 설계하면 다른 취약점과 연계해서 커널을 장악할 수 있습니다.
이를 막기 위해 상수 블라인딩(constant blinding) 이라는 기법을 씁니다.
mov r0, 0xdeadbeef
대신 아래처럼 변환하는 겁니다.
mov r0, 0xdeadbeef
//블라인딩 후
mov ax, (0xdeadbeef XOR 랜덤값) ← 난수화된 값
xor ax, 랜덤값 ← XOR로 원래 값 복원
mov r0, ax ← 결과 사용
이렇게 하면 실제 0xdeadbeef라는 바이트 패턴이 메모리에 직접 나타나지 않아서 JIT 스프레이가 어려워집니다.
bpf_jit_blind_insn()이 바로 이 변환을 수행하는 함수입니다.
이제 문제의 코드로 가봅시다.
case BPF_JMP | BPF_JEQ | BPF_K: // if (dst == 상수) goto 어딘가
case BPF_JMP | BPF_JNE | BPF_K:
off = from->off; // 원래 점프 오프셋을 복사
if (off < 0)
off -= 2; // 여기가 문제
*to++ = BPF_ALU64_IMM(BPF_MOV, BPF_REG_AX, imm_rnd ^ from->imm);
*to++ = BPF_ALU64_IMM(BPF_XOR, BPF_REG_AX, imm_rnd);
*to++ = BPF_JMP_REG(from->code, from->dst_reg, BPF_REG_AX, off);
break;
off -= 2는 왜 필요한가를 생각해보면,
블라인딩을 하면 원래 1개짜리 명령이 3개짜리가 됩니다. 앞에 2개 명령이 추가로 삽입됩니다.
//원본
명령 0: (다른 명령)
명령 1: JEQ 상수, -5 // 5칸 뒤로 점프 (음수 = 뒤로)
명령 2: (다른 명령)
//블라인딩 후
명령 0: (다른 명령)
명령 1: MOV ax, (상수 XOR 랜덤) // 새로 추가
명령 2: XOR ax, 랜덤 // 새로 추가
명령 3: JEQ ax, -5 // 원래 점프
명령 4: (다른 명령)
원래 JEQ -5는 "현재 위치에서 5칸 앞으로 가라"는 뜻이었는데, 이제 현재 위치 자체가 1번 위치에서 3번 위치로 밀렸습니다.
따라서 같은 목적지에 도달하려면 -5가 아니라 -7이 되어야 합니다. 명령 2개가 앞에 삽입됐으니까요. 그래서 off -= 2를 하는 겁니다.
그런데 왜 오버플로가 발생하는가를 생각해야 합니다. 그리고 다시 off의 타입을 보면,
s16 off;
s16은 부호 있는 16비트 정수입니다. 저장할 수 있는 범위가 정해져 있습니다.
-32768 부터 +32768까지 저장할 수 있다고 알려져 있습니다.
이제 만약 from->off가 -32768이라면 어떻게 될까요? BPF 명령어 구조체의 off 필드도 s16이라서 이론적으로 -32768이 들어올 수 있습니다.
off = from->off; // off = -32768
if (off < 0) // -32768 < 0 → 참
off -= 2; // -32768 - 2 = ???
-32768 - 2는 수학적으로 -32770이지만, 이 값은 s16 통에 들어가지 않습니다. 통의 바닥이 -32768이니까요. 넘쳐버리는 겁니다.
컴퓨터는 정수를 2의 보수(two's complement) 방식으로 저장합니다. 16비트 기준으로 비트 패턴을 직접 보면 이렇습니다.
//32768을 16비트로 표현하면:
1000 0000 0000 0000
// 여기서 2를 빼면:
1000 0000 0000 0000
- 0010
= 0111 1111 1111 1110
0111 1111 1111 1110 을 s16으로 해석하면 +32766
즉, -32768 - 2를 s16으로 계산하면 결과가 +32766 이 되어버립니다.
음수 방향으로 2를 더 빼려 했는데, 결과는 반대 방향의 엄청 큰 양수가 나오는 겁니다.
그럼 이 잘못된 값이 어디로 들어가는가..
*to++ = BPF_JMP_REG(from->code, from->dst_reg, BPF_REG_AX, off);
BPF_JMP_REG는 새로운 BPF 점프 명령어를 하나 만드는 매크로입니다. 이 명령어의 오프셋 필드에 off 값이 그대로 박혀 들어갑니다.
즉 +32766이라는 잘못된 값이 새로 만들어진 블라인딩된 점프 명령어 안에 저장되는 겁니다.
그럼 JIT 컴파일러가 이 명령어를 받으면 어떻게 되는가를 생각해보면,
BPF 검증기는 블라인딩 이전 원본 프로그램만 검사합니다. 블라인딩은 검증이 끝난 후에 일어나는 일이지요.
그러니 블라인딩 후에 만들어진 이 망가진 명령어는 검증기의 눈을 피한 채로 JIT 컴파일러에게 전달됩니다.
JIT 컴파일러는 이 명령어를 그냥 믿습니다.
"오프셋이 +32766이네, 그럼 조건 충족 시 앞으로 32766칸 가는 기계어 점프 명령을 만들면 되겠구나"라고 생각하고 그대로 x86이나 ARM64 기계어로 변환합니다.
결과적으로 커널 메모리 안에 실제로 실행 가능한 기계어 코드가 만들어지는데, 그 코드 안의 조건부 점프 명령이 전혀 의도하지 않은 위치를 가리키고 있는 겁니다.
일반적인 버그라면 커널이 이상한 걸 감지하고 패닉을 일으키거나 안전하게 오류 처리를 합니다. 그런데 이 버그는 다릅니다.
BPF 상수 블라인딩은 보안을 위해 존재하는 기능입니다. 커널은 이 기능이 작동했다고 믿고 있고, BPF 서브시스템 전체가 "블라인딩 완료된 안전한 프로그램"이라는 전제로 이후 처리를 합니다. 보안 담당자가 자기 자신을 신뢰하는 상황입니다.
더 큰 문제는 이 오버플로가 조용히 일어난다는 겁니다.
컴파일러나 런타임이 경고를 내지 않습니다. 커널 로그에도 아무것도 안 남습니다. 그냥 조용히 잘못된 오프셋이 들어간 블라인딩된 명령어가 만들어집니다.
그리고 앞서 얘기한 것처럼, -32768이라는 오프셋은 충분히 긴 BPF 프로그램에서 완전히 합법적인 값입니다.
검증기를 정상적으로 통과한 프로그램도 이 상황에 빠질 수 있다는 뜻입니다. 실제로 버그를 유발하는 게 어렵지 않습니다.