Writeup

Frag Gap

8 min read

Abstract

Fraggap(CVE-2026-53362)은 Linux UDPv6 corking 경로에 오류가 있어 skb_shared_info의 15바이트를 OOB write할 수 있는 취약점입니다. 코드는 여기에 있습니다.

fraggap exploit flow

Vulnerability Summary

CONFIG_IPV6=y가 필요합니다.

  1. 두 번의 splice()로 새 skb 뒤의 skb_shared_info 첫 15바이트를 덮습니다.
  2. OOB write로 heap에 남아있던 pipe 주소를 frags[0]으로 취급하게 한 뒤, put_page()를 트리거해 dangling pipe page를 만듭니다.
  3. 그 page를 PTE page로 재할당해 physical memory를 읽고 쓰게 만듭니다.
  4. core_pattern을 바꿔 root helper를 실행합니다.

문제의 accounting은 commit 773ba4fe9104에서 들어왔고, 현재 MSG_SPLICE_PAGES trigger는 commit ce650a166335 이후 트리거할 수 있게 되었습니다. IPv6 path는 commit 736b380e28d0에서 수정됐습니다.

Vulnerability Analysis

Overview

UDP corking은 여러 send를 하나의 datagram으로 모읍니다. Datagram이 fragment boundary를 넘으면 __ip6_append_data()는 이전 skb에서 경계를 넘어간 부분을 fraggap으로 계산하고, 그 bytes를 다음 skb의 linear area로 옮깁니다. 기본적인 skb는 아래와 같습니다.

pasted image 1784187985518

fraggap은 linear 영역에 복사될 data이므로 새 skb의 linear 할당 과정에 포함돼야 하고, pipe page에 남겨둘 길이에서는 빠져야 합니다. 취약한 코드는 반대로 fraggap을 linear data 영역에선 빼고 pagedlen에는 포함합니다.

Kernel은 data 영역에 공간을 예약하지 않은 채로 fraggap bytes를 linear tail에 그대로 복사합니다. 이 때문에 tail 바로 뒤의 skb_shared_info를 원하는 15바이트로 변경할 수 있습니다. 이때 nr_frags 한 바이트만 1로 변경한다면 OOB 범위 밖의 frags[0]은 heap에 남겨진 pipe의 page 주소를 할당된 주소로 취급하게 됩니다.

Root cause

이 버그는 같은 bytes를 경로에 따라 서로 다른 방식으로 계산하기 때문에 발생합니다. datalen을 만들 때는 fraggap을 다음 skb가 처리해야 할 data로 포함하지만, paged branch에 들어가면 같은 fraggap을 외부 데이터의 일부로 취급합니다.

datalen = length + fraggap;
fraglen = datalen + fragheaderlen;
pagedlen = 0;

/* ... */

else {
    alloclen = fragheaderlen + transhdrlen;
    pagedlen = datalen - transhdrlen;
}

/* ... */

copy = datalen - transhdrlen - fraggap - pagedlen;
if (copy < 0 && !(flags & MSG_SPLICE_PAGES)) {
    err = -EINVAL;
    goto error;
}

datalen에는 fraggap을 더하지만 alloclen엔 추가하지 않습니다. 반대로 pagedlendatalen을 기반으로 연산하기 때문에 gap까지 포함합니다. 이 때문에 copy-fraggap으로 줄어들지만, MSG_SPLICE_PAGES가 설정된 경우 negative-copy 검사를 통과합니다.

그 후 fraggap이 non-linear data임을 무시하고 실제 linear copy를 수행합니다.

data = skb_put(skb, fraglen - pagedlen);
data += fragheaderlen;

if (fraggap) {
    skb->csum = skb_copy_and_csum_bits(
        skb_prev, maxfraglen,
        data + transhdrlen, fraggap);
    /* ... */
}

결국 paged branch는 gap을 위한 공간은 만들지 않으면서 gap 내용은 복사합니다.

Triggering the 15-byte OOB

목표는 첫 skb를 fragment boundary보다 정확히 15바이트 크게 만들고, 다음 skb의 linear tail을 skb_shared_info 시작점에 맞추는 것입니다. 과정은 아래와 같습니다.

  1. 320바이트 routing header와 MTU 1287을 설정한 UDPv6 loopback socket 설정
  2. 첫 skb를 fragment boundary보다 15바이트 크게 만드는 919바이트 pipe payload 전송
  3. 같은 cork queue에 append할 두 번째 20바이트 pipe payload

Pipe에서 socket으로 splice()하면 splice_to_socket()은 bvec iterator를 만들고 MSG_SPLICE_PAGES를 설정합니다. SPLICE_F_MOREMSG_MORE가 되고, UDP_CORK와 함께 첫 skb를 전송하지 않은 채 write queue에 남깁니다.

msg.msg_flags = MSG_SPLICE_PAGES;
if (flags & SPLICE_F_MORE)
    msg.msg_flags |= MSG_MORE;

/* ... */

iov_iter_bvec(&msg.msg_iter, ITER_SOURCE, bvec, bc,
               len - remain);
ret = sock_sendmsg(sock, &msg);

이후 udpv6_sendmsg()ip6_append_data()__ip6_append_data()skb_splice_from_iter() 순서로 호출합니다. 일반 UDP를 사용하므로 udpv6_sendmsg()ip_generic_getfrag를 선택합니다. __ip6_append_data()는 page-splice 경로를 유지하고 paged = true로 설정합니다. 그 결과 payload를 skb fragment로 붙이는 취약한 경로로 진입합니다.

실제 순서는 다음과 같습니다.

  1. splice(919, SPLICE_F_MORE)는 UDP header 8바이트를 더한 length = 927을 append합니다. 320바이트 routing header 때문에 fragheaderlen은 360이고, 첫 skb의 길이는 정확히 1287이 됩니다.
  2. UDP_CORK 때문에 skb는 queue에 남습니다. 계산된 fragment boundary는 1272이므로 skb tail 15바이트가 다음 fragment로 넘어가야 합니다.
  3. 둘째 splice(20, SPLICE_F_MORE)는 같은 queue의 tail skb를 발견합니다. 첫 copy는 0이고 새 length 20보다 작으므로 boundary를 기준으로 다시 계산해 copy = -15가 됩니다. 이 값으로 alloc_new_skb에 들어가 fraggap = 15인 새 skb를 만듭니다.

pasted image 1784190488457

필요한 계산만 모으면 다음과 같습니다.

fragheaderlen = 40 + 320 = 360
maxfraglen    = ((1287 - 360) & ~7) + 360 - 8 = 1272
first skb len = 360 + 8 + 919 = 1287
fraggap       = 1287 - 1272 = 15

second append: length=20, transhdrlen=0
datalen=35, alloclen=360, pagedlen=35, copy=-15

Loopback headroom 16바이트와 fragment-header reserve 8바이트를 더하면 alloc_skb() request는 384입니다. lts-6.12.85에서는 이 request가 704바이트 skbuff_small_head object를 사용합니다. 앞 384바이트는 skb data area이고 뒤 320바이트는 skb_shared_info입니다.

skb_put(360)이 tail을 정확히 skb->end까지 이동시키고, 이어지는 data += fragheaderlen도 같은 주소를 가리킵니다. 따라서 이전 skb의 마지막 15바이트는 skb_shared_info + 0x00부터 +0x0e까지 복사됩니다. 그래서 skb_shared_info에 우리가 원하는 값 15바이트를 적을 수 있습니다.

pasted image 1784190717346

Primitive to Exploit

이제 skb_shared_info 시작점에 15바이트를 쓸 수 있습니다. 첫 skb에서 boundary 전까지 들어가는 payload는 1272 - 360 - 8 = 904바이트이므로 source payload [904..918]이 destination [+0x00..+0x0e]에 대응합니다.

nr_fragsskb_shared_info + 0x02에 있습니다. 따라서 exploit은 source byte 906만 1로 만들고 나머지는 0으로 둡니다.

pasted image 1784190867845

nr_frags는 offset 0x02, frags[0]0x30, 전체 skb_shared_info 크기는 320바이트입니다. 전체 pahole 출력은 Appendix에 그대로 넣었습니다.

15바이트이기 때문에 사실상 flag만 제어할 수 있습니다. 따라서 제어할 수 있는 것은 nr_frags뿐이고, 실제 page descriptor가 들어갈 frags[0]은 OOB 범위 밖입니다. 이제 이를 어떻게 해결해야 할까요?

아래 문제를 해결하면 root를 얻을 수 있습니다.

Exploit Details

Exploit Summary

  • Reuse Uninitialized Variable → 정상 skb_frag_t가 있던 object를 free한 뒤 vulnerable skb로 재할당해 초기화되지 않은 frags[0]을 재사용
  • Fraggap OOBnr_frags를 0에서 1로 바꿔 frags[0]을 할당받은 것처럼 취급
  • Append Pipe → 둘째 pipe page를 slot 1에 붙여 최종 nr_frags == 2로 변경
  • Unmatched Page Free → Destructor가 frags[0]put_page()를 실행
  • Dirty Pagetable → Dangling Page를 PTE page로 재할당
  • Physical write → PTE를 변경해 core_pattern을 찾아 덮고 root helper를 실행

Background of the exploit

Why stale frags[] survives?

새 skb head가 할당될 때 skb_shared_info 전체가 초기화되는 것은 아닙니다. __finalize_skb_around()dataref 앞까지만 0으로 지웁니다.

shinfo = skb_shinfo(skb);
memset(shinfo, 0, offsetof(struct skb_shared_info, dataref));
atomic_set(&shinfo->dataref, 1);

frags[]dataref 뒤에 있으므로 이전 allocation의 descriptor bytes가 그대로 남을 수 있습니다. 다만 nr_frags는 0으로 초기화되기 때문에 stale descriptor에 정상적으로는 접근할 수 없습니다. Fraggap OOB를 이용해 nr_frags를 1로 바꾼다면 해당 page를 할당한 것처럼 취급할 수 있습니다.

Dirty Pagetable

Page table은 page allocator에서 받는 4 KiB page입니다. Dangling pipe page와 PTE page를 겹치면 pipe에 값을 적는 순간 PTE를 변조할 수 있습니다. PTE의 PFN만 바꿔 원하는 physical page를 userspace에 할당할 수 있습니다.

Planting a stale frags[0]

현재 제어할 수 있는 것은 nr_frags뿐이고, 필요한 frags[0]은 OOB 범위 밖에 있습니다. 그래서 exploit은 취약점을 트리거하기 전에 같은 cache에 유효한 descriptor를 만들고, 그 bytes가 남아 있는 object를 다시 받습니다.

먼저 source pipe wp에 256바이트 marker를 씁니다. 이어서 8개의 holding pipe에 tee()해 같은 page의 refcount를 9로 만듭니다. 이 extra references는 grooming과 trigger가 진행되는 동안 source page가 해제되지 않도록 합니다.

Descriptor는 하나의 corked datagram으로 만듭니다. Paged payload는 전부 tee()로 가져온 같은 source page이므로 target head의 frags[0]에는 정상 descriptor와 정상 page reference가 들어갑니다.

pasted image 1784192239418

Trigger socket과 두 pipe payload는 groom socket을 닫기 전에 준비합니다. 이후 다른 allocation 없이 두 번의 splice를 실행합니다. 첫 trigger skb는 request 392라 kmalloc-1k을 사용하고 small-head freelist를 건드리지 않으며, 둘째 request 384만 방금 free한 target 중 하나를 재사용합니다.

이제 재사용된 object의 frags[0]에는 source page descriptor bytes가 있지만 nr_frags == 0이라 아직 무해합니다. 다음 단계에서 OOB가 이 stale slot을 활성화합니다.

From nr_frags to a dangling page

Fraggap OOB가 nr_frags = 1로 바꾸면 destructor는 frags[0]을 정상 fragment로 취급합니다. 이후 두 번째 splice는 새 skb를 queue에 넣은 뒤 다음 loop iteration이 남은 정상 20바이트를 같은 skb에 붙입니다.

skb_append_pagefrags()는 현재 nr_frags를 새 descriptor index로 사용합니다.

int i = skb_shinfo(skb)->nr_frags;

if (skb_can_coalesce(skb, i, page, offset)) {
    skb_frag_size_add(&skb_shinfo(skb)->frags[i - 1], size);
} else if (i < max_frags) {
    get_page(page);
    skb_fill_page_desc_noacc(skb, i, page, offset, size);
}

Index는 이미 1이고, 둘째 trigger pipe page는 source wp page와 다르므로 frags[0]과 병합되지 않고 frags[1]에 들어갑니다. 이 path는 정상 get_page()를 수행하고 nr_frags를 2로 올립니다.

이제 socket을 닫으면 cork queue가 flush되고 skb destructor는 두 slot을 모두 해제합니다.

for (i = 0; i < shinfo->nr_frags; i++)
    __skb_frag_unref(&shinfo->frags[i], skb->pp_recycle);

따라서 frags[0]put_page()가 호출되며 refcount를 감소시킵니다. 마지막으로 pipe를 닫으면 dangling page가 생기게 됩니다.

pasted image 1784191876526

Reclaiming as a PTE

이제 allocator에는 반환됐지만 wp->pipe_buffer가 계속 가리키는 4 KiB page가 있습니다. 이제 mmap() 호출 후 접근해 PTE page를 생성하면 free한 page를 재할당받을 수 있습니다.

이제 임의 physical page를 userspace에서 읽고 쓸 수 있습니다.

Finding and overwriting core_pattern

PTE를 제어해 physical R/W를 얻었으므로 core_pattern을 검색합니다.

해당 값을 찾으면 아래와 같이 그 값을 변경합니다. 이후 child에서 SIGSEGV가 발생하면 root 권한으로 아래 명령을 실행하게 됩니다.

|/proc/%P/fd/666 %P

Flag

Fraggap kernelCTF result

Appendix

Verified skb_shared_info layout

struct skb_shared_info {
	__u8                       flags;                /*     0   0x1 */
	__u8                       meta_len;             /*   0x1   0x1 */
	__u8                       nr_frags;             /*   0x2   0x1 */
	__u8                       tx_flags;             /*   0x3   0x1 */
	short unsigned int         gso_size;             /*   0x4   0x2 */
	short unsigned int         gso_segs;             /*   0x6   0x2 */
	struct sk_buff *           frag_list;            /*   0x8   0x8 */
	union {
		struct skb_shared_hwtstamps hwtstamps;   /*  0x10   0x8 */
		struct xsk_tx_metadata_compl xsk_meta;   /*  0x10   0x8 */
	};                                               /*  0x10   0x8 */
	unsigned int               gso_type;             /*  0x18   0x4 */
	u32                        tskey;                /*  0x1c   0x4 */
	atomic_t                   dataref;              /*  0x20   0x4 */
	unsigned int               xdp_frags_size;       /*  0x24   0x4 */
	void *                     destructor_arg;       /*  0x28   0x8 */
	skb_frag_t                 frags[17];            /*  0x30 0x110 */

	/* size: 320, cachelines: 5, members: 14 */
};

Mitigation

The patch

Patch는 앞에서 설명한 invariant를 그대로 복구합니다. Linear area에 실제로 복사할 fraggap을 더하고, non-linear에서는 같은 값을 뺍니다.

- alloclen = fragheaderlen + transhdrlen;
- pagedlen = datalen - transhdrlen;
+ alloclen = fragheaderlen + transhdrlen + fraggap;
+ pagedlen = datalen - transhdrlen - fraggap;

IPv6 fix는 negative copy 검사에서 MSG_SPLICE_PAGES 예외도 제거합니다. 같은 accounting bug가 있던 IPv4 path는 commit eca856950f7c에서 수정됐습니다.

Partial mitigations

SPLICE를 제한하면 되지만, 좋은 방법은 아닙니다.

Timeline

날짜내용
2022-07-12Commit 773ba4fe9104에서 해당 버그가 도입되었습니다.
2023-08-02Commit ce650a166335 이후 현재의 MSG_SPLICE_PAGES corruption이 가능해졌습니다.
2026-05-15취약점을 security@kernel.org에 비공개로 제보했습니다.
2026-06-21IPv6 fix 736b380e28d0이 netdev net tree에 반영됐습니다.
2026-06-25Fix가 Linux mainline에 병합됐습니다.
2026-07-04Linux kernel CNA가 CVE-2026-53362를 공개했습니다.
2026-07-14Openwall linux-distros list에 알렸습니다.
2026-07-16Linux kernel CNA가 CVE-2026-53366를 공개했습니다.
2026-07-20Writeup과 Exploit을 공개하였습니다.

Acknowledgements

Fraggap은 @physicube@qwerty가 IPv4/IPv6에서 발견하고 kernelCTF에 사용했습니다. IPv4 버그가 exploit 가능함을 확인해주신 Sultan Alsawaf(team CIQ)께 감사드립니다.

References

Comments

Loading...