CVE-2026-43284_(1) ESP 패킷이 커널에서 decrypt되기까지
1. 개요
Dirty Frag에 사용된 취약점인 CVE-2026-43284를 분석하던 중, ESP 패킷과 관련 함수들의 코드만으로는 커널 내부에서의 ESP 패킷의 흐름을 파악하기 어려워 취약점 분석 전 ESP 패킷 흐름 분석을 진행했다.
CVE-2026-43284는 Linux 커널의 ESP(IPSec) 패킷 처리 경로에서 발생하는 취약점이다. ESP decrypt 과정에서 특정 조건을 만족할 때, 커널이 파일의 Page Cache 메모리에 직접 데이터를 덮어쓰는 것에서 취약점이 발생한다. 그런데 ESP 패킷을 처리하는 데 왜 파일 메모리가 바뀌는지, 커널 내부에서는 어떤 함수들이 어떤 순서로 실행되는지, 그리고 그 과정에서 정확히 어느 시점에 메모리 쓰기가 일어나는지 등은 Linux 소스코드만 봐서는 머릿속에 그려지지 않았다.
그래서 취약점 자체를 분석하기 전에, 먼저 ESP 패킷이 Linux 커널 내부에서 어떻게 처리되는지를 직접 실습하며 확인하기로 했다.
이 글에서 다루는 내용 다음과 같다.
먼저 ESP 패킷이 실제로 생성되는지 확인하고, bpftrace와 ftrace라는 커널 추적 도구를 사용해 패킷이 어떤 함수들을 거쳐 처리되는지 함수 호출 트리 형태로 확인한다. 그 다음, splice()와 vmsplice()를 사용하여 Page Cache가 pipe와 메모리를 공유한다는 것을 실험으로 검증한다. 취약점을 실제로 트리거하거나 악성 행위를 수행하는 것은 본 포스팅의 범위가 아니며, 다음 글에서 다룬다.
2. 배경 지식
2.1 Page Cache
Linux에서 파일을 읽으면 커널은 그 파일 내용을 디스크에서 읽은 뒤 RAM에 올려놓는다. Page Cache란 메모리에 올라간 파일 내용의 복사본이다. 파일 데이터를 메모리에 캐싱해두는 것이다.
디스크는 메모리보다 접근 속도가 느리다. 만약 파일을 읽을 때마가 매번 디스크에서 읽어야 한다면 프로그램이 매우 느려진다. 그래서 커널은 한 번 읽은 파일 데이터를 메모리에 올려두고, 다음번에 같은 파일을 읽을 때는 메모리에서 바로 가져옴으로써 접근 속도를 높인다.
그런데 여러 프로세스가 같은 파일을 열더라도 Page Cache는 하나만 존재한다. 예를 들어, 프로세스 A와 프로세스 B가 모두 /usr/bin/su를 읽는다면, 커널은 /usr/bin/su의 Page Cache를 하나만 만들고 두 프로세스가 그것을 공유하도록 한다.
또한, 파일을 읽기 전용(O_RDONLY)로 열어도 Page Cache는 동일하게 존재한다. 읽기 전용이라는 것은 write() 시스템 콜을 쓸 수 없다는 의미이지, 그 파일의 Page Cache 메모리 자체가 보호된다는 것은 아니다. 즉, 커널 내부의 다른 경로를 통해 Page Cache 메모리에 직접 접근하면 읽기 전용 파일이라고 내용이 바뀔 수 있다. CVE-2026-43284는 이 점을 악용한다.
2.2 skb(Socket Buffer)
리눅스 커널이 네트워크 패킷을 다룰 때 사용하는 가장 기본적인 자료구조가 skb(sk_buff)이다. 패킷이 네트워크 카드에서 수신되거나 소켓을 통해서 전송될 때, 커널은 항상 skb를 통해 그 패킷을 관리한다.
skb는 패킷 데이터가 아니라, 패킷에 대한 메타데이터를 담고 있는 구조체다. skb 안에는 패킷의 헤더 정보, 데이터가 어디에 있는지를 가리키는 포인터, 패킷 길이 등이 들어있다. 실제 패킷 데이터는 skb가 가리키는 메모리 공간에 따로 저장된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
struct sk_buff {
struct sk_buff *next, *prev; // Linked list pointers
struct sock *sk; // Associated socket
unsigned char *head; // Buffer start
unsigned char *data; // Data start
unsigned char *tail; // Data end
unsigned char *end; // Buffer end
unsigned int len; // Data length
unsigned int data_len; // Length of paged data
__u16 mac_len; // MAC header length
__u16 hdr_len; // Header length
// ... many more fields
};
skb는 두 가지 형태로 존재할 수 있다.
- linear skb
- 패킷 데이터가 하나의 연속된 메모리 공간에 모두 들어있는 형태
- nonlinear skb
- 패킷 데이터가 여러 조각(fragment)으로 나뉘어 저장된 형태.
- 각 조각은
skb_shared_info라는 구조체의frag[]배열에 저장됨 - 각 조각은 물리 메모리의 특정 page를 가리킴
CVE-2026-43284에서는 nonlinear skb의 frag를 이용한다. 공격자는 splice()를 통해 파일의 Page Cache를 frag 슬롯에 미리 저장해둔다. 그러면 커널이 이 skb에 대해 ESP decrypt를 수행할 때, frag가 가리키는 메모리인 파일의 Page Cache에 직접 데이터를 쓰게 된다.
2.3 ESP(Encapsulating Security Payload)
ESP는 네트워크 패킷을 암호화하고 인증하는 프로토콜이다. IPsec의 구성 요소 중 하나이며, 인터넷을 통해 데이터를 안전하게 전송할 때 사용된다. 대표적인 활용 사례로 VPN이 있다.
ESP의 동작 방식은 다음과 같다.
- 송신 측 : 원본패킷을 암호화한 뒤 인증 태그를 붙여서 ESP 패킷으로 만듦
- 수신 측 : 인증 태그를 검증하고 복호화하여 원본 패킷을 꺼냄
리눅스 커널은 이 과정을 XFRM 프레임워크를 통해 자동으로 처리한다.
CVE-2026-43284에서는 ESP의 복호화 방식에 주목한다. Linux 커널은 ESP 패킷을 복호화할 때 in-place decrypt, 즉 제자리 복호화 방식을 사용한다. 새로운 메모리 공간을 할당해서 복호화 결과를 새로운 공간에 쓰는 것이 아니라, 원본 패킷 데이터가 있는 메모리 공간에 직접 복호화 결과를 덮어쓰는 것이다. 성능 측면에서 in-place decrypt는 유리하지만, 만약 그 메모리 공간이 Page Cache라면 파일 내용이 바뀌어 버린다는 취약점이 존재한다.
2.4 XFRM
XFRM은 리눅스 커널의 IPsec 프레임워크이다. 커널 내부에서 ESP, AH와 같은 IPsec 프로토콜을 처리하는 역할을 한다. 사용자는 ip xfrm이나 netlink 소켓을 통해 XFRM에 암호화 규칙을 등록하고 관리할 수 있다.
XFRM에서 두 가지 핵심 개념이 있다.
- SA(Security Association)
- 특정 패킷을 어떻게 암호화/복호화할지를 정의하는 규칙
- 어떤 암호화 알고리즘을 쓸지, 어떤 키를 쓸지, SPI는 무엇인지 등이 SA에 담긴다
- Policy
- 어떤 패킷에 SA를 적용할지를 정의
- [EX] 10.0.0.1에서 10.0.0.2로 가는 모든 패킷은 ESP를 사용해라
패킷이 수신될 때 XFRM의 처리 흐름은 아래와 같다.
1
2
3
4
5
6
7
8
9
커널이 ESP 패킷을 수신
|
xfrm4_esp_rcv() 함수 호출
|
xfrm_input() 호출
|
패킷의 SPI를 보고 해당하는 SA를 찾아서 esp_input() 호출
|
esp_input()은 복호화 수행
2.5 splice()와 zero-copy
일반적으로 파일 내용을 네트워크로 전송하려면 파일을 읽어서 커널 메모리로 가져오고, 그것을 다시 사용자 공간 메모리로 복사하고, 또 다시 커널의 소켓 버퍼로 복사하는 과정이 필요하다. 이때 데이터가 여러 번 복사되어 메모리와 CPU를 낭비하게 된다.
splice() 시스템 콜은 이 문제를 해결하기 위해 만들어졌다. splice()는 데이터를 직접 복사하지 않고, 데이터가 있는 메모리 페이지의 참조(주소)만 옮긴다. 파일의 Page Cache 페이지를 파이프에 연결할 때, 실제로 데이터를 복사하는 것이 아닌 봐야할 페이지의 위치 정보를 담고 있는 포인터만 넘기는 것이다. 따라서 zero-copy라고도 불린다.
vmsplice()는 splice()와 비슷하지만, 파일이 아닌 사용자 공간의 일반 메모리를 파이프에 연결할 때 사용한다. 마찬가지로 데이터를 복사하지 않고, 메모리 페이지의 포인터만 넘긴다.
이 두 시스템 콜이 CVE-2026-43284에서 핵심 역할을 한다. vmsplice()로 가짜 ESP 헤더를 파이프에 등록하고, splice()로 타겟 파일의 Page Cache 페이지를 파이프에 연결한 뒤, 이 파이프를 소켓으로 전송하면 커널이 자동으로 Page Cache 페이지를 skb의 frag로 사용하게 된다. 이후 ESP Decrypt가 해당 frag 위에서 in-place로 수행되면 파일 Page Cache가 오염된다.
3. 실습
3.1 가상 네트워크와 ESP 패킷 생성
ESP 패킷 처리 실습을 위해서는 먼저 ESP 패킷이 실제로 생성되고 전송되는 환경이 필요하다. 물리적인 네트워크 장비 대신 Linux 커널의 네트워크 네임스페이스와 veth pair를 이용해 가상 네트워크 환경을 구성했다.
네트워크 네임스페이스는 Linux의 격리 기능 중 하나로, 독립된 네트워크 스택을 가진 가상 공간을 만들 수 있다. 각 네임스페이스는 독립적인 인터페이스, 라우팅 테이블, 방화벽 규칙은 가진다. veth pair는 두 개의 가상 이더넷 인터페이스가 서로 연결된 구조로, 한쪽으로 들어간 패킷이 반대쪽으로 나온다. 이 두 가지를 조합하여 ns1과 ns2라는 두 개의 네임스페이스를 만들고, veth1과 veth2로 연결했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 네임 스페이스 생성
sudo ip netns add ns1
sudo ip netns add ns2
# veth pair 생성
sudo ip link add veth1 type veth peer name veth2
# 각 네임스페이스에 인터페이스 할당
sudo ip link set veth1 netns ns1
sudo ip link set veth2 netns ns2
# IP 주소 설정
sudo ip netns exec ns1 ip addr add 10.0.0.1/24 dev veth1
sudo ip netns exec ns2 ip addr add 10.0.0.2/24 dev veth2
# 인터페이스 활성화
sudo ip netns exec ns1 ip link set veth1 up
sudo ip netns exec ns2 ip link set veth2 up
sudo ip netns exec ns1 ip link set lo up
sudo ip netns exec ns2 ip link set lo up
이 상태에서 ns1에서 ns2로 ping을 보내면 일반 ICMP 패킷이 전송된다. 
여기에 XFRM SA와 Policy를 등록하면 ICMP 패킷이 자동으로 ESP로 암호화되어 전송된다. 앞에서 말했듯 XFRM SA는 패킷을 어떻게 암호화할지 정의하는 규칙으로 암호화 알고리즘, 인증 알고리즘, 키, SPI 등을 정의한다. XFRM Policy는 어떤 패킷에 이 SA를 적용할지 정의한다.
ns1에서 ns2 방향, ns2에서 ns1 방향 각각에 대해 SA와 Policy를 등록해야 양방향 ESP 통신이 가능하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# ns1 -> ns2 방향 SA 등록
sudo ip netns exec ns1 ip xfrm state add \
src 10.0.0.1 dst 10.0.0.2 \
proto esp spi 0x100 \
mode transport \
auth 'hmac(sha256)' 0x1111111111111111111111111111111111111111111111111111111111111111 \
enc 'cbc(aes)' 0x22222222222222222222222222222222
# ns2 → ns1 방향 SA 등록
sudo ip netns exec ns2 ip xfrm state add \
src 10.0.0.2 dst 10.0.0.1 \
proto esp spi 0x200 \
mode transport \
auth 'hmac(sha256)' 0x1111111111111111111111111111111111111111111111111111111111111111 \
enc 'cbc(aes)' 0x22222222222222222222222222222222
# Policy 등록(ns1 outbound/inbound, ns2 outbound/inbound)
sudo ip netns exec ns1 ip xfrm policy add \
src 10.0.0.1 dst 10.0.0.2 dir out \
tmpl src 10.0.0.1 dst 10.0.0.2 proto esp mode transport
sudo ip netns exec ns1 ip xfrm policy add \
src 10.0.0.2 dst 10.0.0.1 dir in \
tmpl src 10.0.0.2 dst 10.0.0.1 proto esp mode transport
sudo ip netns exec ns2 ip xfrm policy add \
src 10.0.0.2 dst 10.0.0.1 dir out \
tmpl src 10.0.0.2 dst 10.0.0.1 proto esp mode transport
sudo ip netns exec ns2 ip xfrm policy add \
src 10.0.0.1 dst 10.0.0.2 dir in \
tmpl src 10.0.0.1 dst 10.0.0.2 proto esp mode transport
SA와 Policy를 등록한 뒤 ping을 보내면 ICMP 패킷 대신 ESP 패킷이 전송되는 것을 확인할 수 있다.
1
2
3
4
5
# 터미널 1: tcpdump로 패킷 캡처
sudo ip netns exec ns1 tcpdump -i veth1 -n
# 터미널 2: ping 전송
sudo ip netns exec ns1 ping 10.0.0.21
일반 ICMP echo/reply 대신 ESP 패킷이 오가고 있음을 알 수 있다. 커널이 ICMP 패킷을 자동으로 ESP로 암호화해서 보내고, 수신측에서 복호화하는 것이다.
3.2 ESP 패킷이 커널 내부에서 처리되는 흐름
ESP 패킷이 생성되는 것을 확인했으니 이제 커널 내부에서 그 패킷이 어떤 경로로 처리되는지 추적할 것이다. 이를 위해 bpftrace와 ftrace을 사용하였다.
먼저 bpftrace로 특정 함수가 실제로 호출되는지 확인하고, kstack으로 전체 호출 스택을 확인했다. 그 다음 ftrace의 function_graph 모드로 함수 호출 트리 전체를 추적했다.
[bpftrace로 수신 경로 진입 확인]
bpftrace는 커널 함수에 probe를 걸어 함수가 호출될 때마다 원하는 정보를 출력할 수 있는 도구다. kprobe는 사용하면 특정 함수의 진입점에 probe를 걸 수 있다.
1
2
3
4
5
6
sudo bpftrace -e '
kprobe:xfrm4_esp_rcv
{
printf("xfrm4_esp_rcv hit\n");
}
'
다른 터미널에서 ping을 보내면 다음과 같이 출력된다. 
이를 통해 ping 패킷이 전송될 때마다 xfrm4_esp_rcv가 호출되는 것을 확인하였다. 이것으로 ESP 패킷이 실제로 커널의 수신 경로로 진입하고 있음이 증명됐다.
[kstack으로 전체 호출 경로 확인]
앞서 진행한 단순 함수 호출 여부 확인을 넘어, 이 함수가 어떤 경로를 거쳐 호출됐는지 알아보자. 즉, 패킷이 어떤 함수들을 통해 xfrm4_esp_rcv까지 왔는지를 알아야 한다. bpftrace의 kstack을 사용하면 함수 호출 스택 전체를 볼 수 있다.
1
2
3
4
5
6
7
sudo bpftrace -e '
kprobe:xfrm4_esp_rcv
{
printf("=== xfrm4_esp_rcv ===\n");
print(kstack);
}
'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
=== xfrm4_esp_rcv ===
xfrm4_esp_rcv+1
ip_local_deliver_finish+119
ip_local_deliver+110
ip_rcv+403
__netif_receive_skb_one_core+145
__netif_receive_skb+21
process_backlog+144
__napi_poll+51
net_rx_action+524
handle_softirqs+228
__do_softirq+16
do_softirq.part.0+63
__local_bh_enable_ip+110
__dev_queue_xmit+463
neigh_hh_output+143
ip_finish_output2+500
__ip_finish_output+186
ip_finish_output+43
ip_output+95
xfrm_output_resume+532
xfrm_output+228
__xfrm4_output+38
xfrm4_output+79
ip_push_pending_frames+219
raw_sendmsg+2131
inet_sendmsg+125
__sys_sendto+552
__x64_sys_sendto+36
x64_sys_call+9536
do_syscall_64+126
entry_SYSCALL_64_after_hwframe+118
스택이므로 아래에서부터 해석해보자.
entry_SYSCALL_64_after_hwframe,do_syscall_64: 사용자 공간에서 시스템 콜이 호출됨__sys_sendto,inet_sendmsg: ping 프로그램이sendto()시스템 콜로 패킷 전송을 요청xfrm4_output,xfrm_output,xfrm_output_resume: 커널이 xfrm4 Policy를 확인하고, ICMP 패킷을 ESP로 암호화- 여기서 등록한 Policy가 적용됨
ip_output,ip_finish_output,__dev_queue_xmit: 암호화된 ESP 패킷이 실제 네트워크 인터페이스(veth)로 전송됨여기서부터는 수신 경로이다.
net_rx_action,process_backlog,__netif_receive_skb: 수신된 패킷이 커널의 네트워크 수신 큐를 통해 처리됨ip_rcv,ip_local_deliver,ip_local_deliver_finish: IP 계층에서 패킷을 처리하는 함수들. 커널이 이 패킷의 목적지가 로컬임을 확인하고 상위 프로토콜 핸들러로 넘김xfrm4_esp_rcv: 여기서부터 ESP 복호화 경로가 시작됨
여기서 한 번의 ping에서 송신측 암호화 경로와 수신측 복호화 경로가 모두 하나의 스택에 담겨있는데, 이는 veth pair의 특성 때문이다. ns1에서 보낸 패킷이 veth를 통해 ns2로 전달될 때, 같은 커널 내에서 처리되므로 송신과 수신이 연속적으로 일어난다.
[ftrace function_graph로 함수 트리 추적]
xfrm4_esp_rcv 내부에서 어떤 함수들이 순서대로 호출되는지 보자. ftrace의 function_graph모드를 사용하면 특정 함수 내부의 모든 함수 호출을 트리 형태로 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 기존 버퍼 비우기
echo | sudo tee /sys/kernel/tracing/trace
# function_graph 모드 활성화
echo function_graph | sudo tee /sys/kernel/tracing/current_tracer
# xfrm4_esp_rcv부터 추적 시작
echo xfrm4_esp_rcv | sudo tee /sys/kernel/tracing/set_graph_function
# tracing 활성화
echo 1 | sudo tee /sys/kernel/tracing/tracing_on
# 실시간 출력
sudo cat /sys/kernel/tracing/trace_pipe
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
| xfrm4_esp_rcv() {
| xfrm4_rcv() {
| xfrm_input() {
| secpath_set() {
| skb_ext_add() {
| __skb_ext_alloc() {
2.593 us | kmem_cache_alloc_noprof();
4.245 us | }
5.844 us | }
7.045 us | }
0.339 us | xfrm_parse_spi();
| xfrm_input_state_lookup() {
0.318 us | __rcu_read_lock();
0.323 us | __rcu_read_unlock();
2.289 us | }
0.335 us | _raw_spin_lock();
| xfrm_replay_check() {
0.334 us | xfrm_replay_check_legacy();
1.433 us | }
0.394 us | xfrm_state_check_expire();
0.317 us | _raw_spin_unlock();
0.351 us | xfrm_replay_seqhi();
| esp_input [esp4]() {
| esp_alloc_tmp [esp4]() {
2.165 us | __kmalloc_noprof();
3.087 us | }
| skb_to_sgvec() {
0.379 us | __skb_to_sgvec();
1.119 us | }
| crypto_aead_decrypt() {
| echainiv_decrypt [echainiv]() {
| scatterwalk_map_and_copy() {
0.398 us | scatterwalk_ffwd();
0.445 us | scatterwalk_copychunks();
3.275 us | }
| crypto_aead_decrypt() {
| crypto_authenc_decrypt [authenc]() {
| crypto_ahash_digest() {
| shash_ahash_digest() {
| crypto_shash_digest() {
| shash_default_digest() {
| hmac_init() {
0.401 us | crypto_shash_import();
1.142 us | }
| hmac_finup() {
| crypto_shash_finup() {
| sha256_ni_finup [sha256_ssse3]() {
| sha256_finup [sha256_ssse3]() {
0.313 us | irq_fpu_usable();
| kernel_fpu_begin_mask() {
0.327 us | irq_fpu_usable();
1.094 us | }
0.356 us | kernel_fpu_end();
3.799 us | }
4.504 us | }
5.224 us | }
0.333 us | crypto_shash_import();
| crypto_shash_finup() {
| sha256_ni_finup [sha256_ssse3]() {
| sha256_finup [sha256_ssse3]() {
0.313 us | irq_fpu_usable();
| kernel_fpu_begin_mask() {
0.311 us | irq_fpu_usable();
1.056 us | }
0.369 us | kernel_fpu_end();
3.526 us | }
4.235 us | }
4.949 us | }
+ 12.005 us | }
+ 14.701 us | }
+ 15.408 us | }
+ 16.194 us | }
+ 16.912 us | }
| crypto_authenc_decrypt_tail [authenc]() {
| scatterwalk_map_and_copy() {
0.339 us | scatterwalk_ffwd();
0.372 us | scatterwalk_copychunks();
1.953 us | }
0.405 us | __crypto_memneq();
0.337 us | scatterwalk_ffwd();
| crypto_skcipher_decrypt() {
| simd_skcipher_decrypt [crypto_simd]() {
0.328 us | irq_fpu_usable();
0.326 us | cryptd_skcipher_queued [cryptd]();
0.316 us | cryptd_skcipher_child [cryptd]();
| crypto_skcipher_decrypt() {
| cbc_decrypt [aesni_intel]() {
| skcipher_walk_virt() {
| skcipher_walk_first() {
0.342 us | skcipher_walk_next();
1.060 us | }
1.835 us | }
| kernel_fpu_begin_mask() {
0.324 us | irq_fpu_usable();
1.086 us | }
0.317 us | kernel_fpu_end();
0.364 us | skcipher_walk_done();
6.481 us | }
8.050 us | }
+ 11.034 us | }
+ 12.471 us | }
+ 17.252 us | }
+ 35.377 us | }
+ 36.234 us | }
+ 40.673 us | }
+ 41.806 us | }
| esp_input_done2 [esp4]() {
1.541 us | kfree();
0.397 us | skb_copy_bits();
0.313 us | skb_pull_rcsum();
5.124 us | }
+ 54.423 us | }
0.328 us | _raw_spin_lock();
| xfrm_replay_recheck() {
0.366 us | xfrm_replay_check_legacy();
1.497 us | }
0.358 us | xfrm_replay_advance();
0.313 us | ktime_get_real_seconds();
0.324 us | _raw_spin_unlock();
0.591 us | xfrm_inner_mode_input();
0.342 us | xfrm_parse_spi();
| xfrm_rcv_cb() {
0.309 us | __rcu_read_lock();
| xfrm4_rcv_cb() {
0.325 us | esp4_rcv_cb [esp4]();
1.454 us | }
0.309 us | __rcu_read_unlock();
4.081 us | }
0.308 us | __rcu_read_lock();
0.313 us | xfrm_state_afinfo_get_rcu();
| xfrm4_transport_finish() {
0.330 us | ip_send_check();
| xfrm_trans_queue() {
| xfrm_trans_queue_net() {
0.635 us | _raw_spin_lock_bh();
| _raw_spin_unlock_bh() {
0.355 us | __local_bh_enable_ip();
1.064 us | }
| queue_work_on() {
0.356 us | clear_pending_if_disabled();
| __queue_work() {
0.555 us | __rcu_read_lock();
0.336 us | _raw_spin_lock();
0.574 us | pwq_tryinc_nr_active();
0.313 us | insert_work();
| kick_pool() {
| wake_up_process() {
| try_to_wake_up() {
0.927 us | _raw_spin_lock_irqsave();
1.209 us | kthread_is_per_cpu();
0.364 us | ttwu_queue_wakelist();
| raw_spin_rq_lock_nested() {
0.335 us | _raw_spin_lock();
1.068 us | }
| update_rq_clock() {
0.308 us | arch_scale_cpu_capacity();
1.143 us | }
| ttwu_do_activate() {
| enqueue_task() {
| enqueue_task_fair() {
| enqueue_entity() {
| update_curr() {
0.477 us | update_curr_se();
0.416 us | __calc_delta.constprop.0();
0.420 us | update_min_vruntime();
3.220 us | }
0.550 us | __update_load_avg_se();
0.565 us | __update_load_avg_cfs_rq();
0.342 us | update_cfs_group();
| place_entity() {
0.444 us | avg_vruntime();
1.323 us | }
0.938 us | __enqueue_entity();
+ 10.190 us | }
0.308 us | hrtick_update();
+ 12.548 us | }
| psi_task_change() {
0.320 us | psi_flags_change();
| psi_group_change() {
0.372 us | record_times();
1.260 us | }
3.469 us | }
+ 17.754 us | }
| wakeup_preempt() {
| check_preempt_wakeup_fair() {
| update_curr() {
0.353 us | update_curr_se();
1.164 us | }
| pick_eevdf() {
0.315 us | vruntime_eligible();
1.914 us | }
4.588 us | }
5.981 us | }
+ 26.621 us | }
0.349 us | _raw_spin_unlock();
0.391 us | _raw_spin_unlock_irqrestore();
+ 37.224 us | }
+ 38.024 us | }
+ 39.713 us | }
0.325 us | _raw_spin_unlock();
0.317 us | __rcu_read_unlock();
+ 48.286 us | }
+ 50.487 us | }
+ 54.107 us | }
+ 54.837 us | }
+ 56.455 us | }
0.311 us | __rcu_read_unlock();
! 143.486 us | }
! 144.928 us | }
! 151.572 us | }
이 트리에서 주목해야할 부분은 다음과 같다.
xfrm_input()안에서xfrm_parse_spi()가 패킷의 SPI를 파싱하고,xfrm_input_state_lookup()이 해당 SPI에 맞는 SA를 찾는다. SA를 찾으면esp_input()이 호출된다.esp_input()안에서skb_to_sgvec()이 skb의 데이터를 scatterlist로 변환한다.- scatterlist : 메모리 조각들의 목록으로, 암호화 연산의 입출력 버퍼를 표현하는 데 사용된다.
- 이때 skb의 frag가 가리키는 메모리가 scatterlist에 등록된다 (공격 상황일 경우 Page Cache 페이지가 scatterlist에 등록됨)
crypto_aead_decrypt()가 호출되어 실제 복호화가 시작된다.- 내부적으로
echainiv_decrypt(), crypto_authenc_decrypt()가 순서대로 호출된다.
- 내부적으로
crypto_authenc_decrypt_tail()을 보자.1 2 3 4 5 6 7 8
crypto_authenc_decrypt_tail [authenc]() { scatterwalk_map_and_copy() { scatterwalk_ffwd(); scatterwalk_copychunks(); } __crypto_memneq(); ... }
scatterwalk_map_and_copy()는 데이터를 메모리에 쓰는 함수이고, __crypto_memneq()는 인증 태그를 검증하는 함수다. 즉, 메모리 쓰기가 먼저 발생한 뒤에 인증 검증이 나중에 일어나므로, 인증이 실패하더라도 이미 쓰기는 완료된 상태이다.
이곳이 CVE-2026-43284의 취약점 발생 지점이다.
4. esp_input 분석
[esp_input]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
static int esp_input(struct xfrm_state *x, struct sk_buff *skb)
{
struct crypto_aead *aead = x->data;
struct aead_request *req;
struct sk_buff *trailer;
int ivlen = crypto_aead_ivsize(aead);
int elen = skb->len - sizeof(struct ip_esp_hdr) - ivlen;
int nfrags;
int assoclen;
int seqhilen;
__be32 *seqhi;
void *tmp;
u8 *iv;
struct scatterlist *sg;
int err = -EINVAL;
if (!pskb_may_pull(skb, sizeof(struct ip_esp_hdr) + ivlen))
goto out;
if (elen <= 0)
goto out;
assoclen = sizeof(struct ip_esp_hdr);
seqhilen = 0;
if (x->props.flags & XFRM_STATE_ESN) {
seqhilen += sizeof(__be32);
assoclen += seqhilen;
}
if (!skb_cloned(skb)) {
if (!skb_is_nonlinear(skb)) {
nfrags = 1;
goto skip_cow;
} else if (!skb_has_frag_list(skb) &&
!skb_has_shared_frag(skb)) {
nfrags = skb_shinfo(skb)->nr_frags;
nfrags++;
goto skip_cow;
}
}
err = skb_cow_data(skb, 0, &trailer);
if (err < 0)
goto out;
nfrags = err;
skip_cow:
err = -ENOMEM;
tmp = esp_alloc_tmp(aead, nfrags, seqhilen);
if (!tmp)
goto out;
ESP_SKB_CB(skb)->tmp = tmp;
seqhi = esp_tmp_extra(tmp);
iv = esp_tmp_iv(aead, tmp, seqhilen);
req = esp_tmp_req(aead, iv);
sg = esp_req_sg(aead, req);
esp_input_set_header(skb, seqhi);
sg_init_table(sg, nfrags);
err = skb_to_sgvec(skb, sg, 0, skb->len);
if (unlikely(err < 0)) {
kfree(tmp);
goto out;
}
skb->ip_summed = CHECKSUM_NONE;
if ((x->props.flags & XFRM_STATE_ESN))
aead_request_set_callback(req, 0, esp_input_done_esn, skb);
else
aead_request_set_callback(req, 0, esp_input_done, skb);
aead_request_set_crypt(req, sg, sg, elen + ivlen, iv);
aead_request_set_ad(req, assoclen);
err = crypto_aead_decrypt(req);
if (err == -EINPROGRESS)
goto out;
if ((x->props.flags & XFRM_STATE_ESN))
esp_input_restore_header(skb);
err = esp_input_done2(skb, err);
out:
return err;
}
esp_input()은 ESP 패킷의 복호화를 담당하는 핵심 함수다. 함수 인자로 xfrm_state *x와 sk_buff *skb를 받는다. x는 이 패킷에 적용한 SA(암호화 규칙)이고, skb는 수신된 ESP 패킷이다. 암호화 알고리즘 정보, IV 길이, 암호화된 페이로드 길이 등 복호화에 필요한 기본 값들을 계산한 뒤, XFRM_STATE_ESN에서 ESN(Extended Sequence Number) 모드 여부를 확인한다. ESN 모드일 때는 시퀀스 번호가 64비트로 확장되므로 asscolen에 4바이트가 추가된다. 이후 COW 분기 판단, scatterlist 구성, crypto_aead_decrypt() 호출 순서로 진행된다. CVE-2026-43284는 ESN 모드가 활성화된 경우에 트리거된다.
4.1 skip_cow 분기: 취약점 시작 지점
ESN 모드를 확인한 뒤, 다음 분기문을 거친다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (!skb_cloned(skb)) {
// [1] skb가 비선형이 아닐 때 COW를 건너뛴다.
if (!skb_is_nonlinear(skb)) {
nfrags = 1;
goto skip_cow;
}
// [2] skb가 비선형이지만 frag_list가 없을 때 COW를 건너뛴다.
else if (!skb_has_frag_list(skb) &&
!skb_has_shared_frag(skb)) {
nfrags = skb_shinfo(skb)->nr_frags;
nfrags++;
goto skip_cow;
}
}
err = skb_cow_data(skb, 0, &trailer);
COW는 Copy-On-Write로, 공유된 메모리를 수정하기 전에 먼저 복사본을 만들고 복사본을 수정하는 일종의 안전장치다. 커널이 ESP 패킷을 in-place로 복호화할 때, 패킷 데이터가 다른 곳과 공유된 메모리에 있다면 원본이 손상될 수 있으므로 먼저 복사본을 만들어야 한다. skb_cow_data()는 이 역할을 수행한다. 문제는 이 함수를 실행하지 않는 경우, 즉 skip_cow를 거치는 [1], [2] 경우이다.
[1] 지점에서 skb가 linear 상태(데이터가 하나의 연속된 메모리에 있는 상태)이면 frag가 없으므로 외부 page를 참조할 가능성이 없다. 따라서 COW 없이 skip_cow로 점프하는 것은 안전하다. 그러나 [2] 지점에서 skb가 nonlinear 상태라면, 즉 frag가 있음에도 불구하고 frag_list만 없으면 skip_cow로 점프한다. 공격자는 splice()를 통해 file Page Cache 페이지를 skb의 frag에 심어놓은 경우, frag_list는 없지만 frag[0]이 file Page Cache를 가리키고 있다. 이 경우에도 [2] 지점의 조건을 만족하여 skip_cow로 점프하므로, COW 없이 file Page Cache 페이지 위에서 직접 in-place decrypt가 수행된다.
만약 공격자가 splice()를 통해 Page Cache 페이지를 이 frag에 고정시켜 두었다면, 해당 페이지가 곧 src이자 dst가 된다. in-place decrypt이므로 입력과 출력이 동일한 메모리를 가리키고, 그 메모리가 file Page Cache이므로 decrypt 결과가 파일에 직접 기록된다.
4.2 scatterwalk_map_and_copy : write 발생 지점
skip_cow 이후의 코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
skip_cow:
err = -ENOMEM;
tmp = esp_alloc_tmp(aead, nfrags, seqhilen);
...
esp_input_set_header(skb, seqhi);
sg_init_table(sg, nfrags);
err = skb_to_sgvec(skb, sg, 0, skb->len);
...
aead_request_set_crypt(req, sg, sg, elen + ivlen, iv);
aead_request_set_ad(req, assoclen);
err = crypto_aead_decrypt(req);
esp_input_set_header()는 ESN 모드일 때 시퀀스 번호의 상위 32비트를 skb에 삽입한다. 이 값은 SA 등록 시 XFRMA_REPLAY_ESN_VAL 넷링크 속성의 seq_hi 필드에 지정한 값이다. 공격자가 이 필드에 파일에 쓰고 싶은 4바이트 데이터를 집어넣으면, 커널은 이를 단순한 시퀀스 번호로 취급하고 저장한다.
1
2
3
4
5
6
7
8
9
10
11
12
static void esp_input_set_header(struct sk_buff *skb, __be32 *seqhi)
{
struct xfrm_state *x = xfrm_input_state(skb);
struct ip_esp_hdr *esph;
if ((x->props.flags & XFRM_STATE_ESN)) {
esph = skb_push(skb, 4);
*seqhi = esph->spi;
esph->spi = esph->seq_no;
esph->seq_no = XFRM_SKB_CB(skb)->seq.input.hi;
}
}
skb_to_sgvec()은 skb의 데이터를 scatterlist로 변환한다. skb의 frags[0]이 file Page Cache 페이지를 가리키고 있다면, 그 페이지가 scatterlist에 그대로 등록된다.
또한, aead_request_set_crypt(req, sg, sg, elen + ivlen, iv);에서 두 번째 인자와 세 번째 인자가 모두 sg로 동일한데, 이는 in-place decrypt이기 때문이다. 입력(src)과 출력(dst)이 같은 scatterlist를 가리키므로, 복호화 결과가 원본 데이터 위에 덮어써진다. 원본 데이터가 file Page Cache이므로, 복호화 결과가 파일 메모리에 직접 기록되는 것이다.
crypto_aead_decrypt()가 호출되면 내부적으로 crypto_authenc_esn_decrypt를 호출하며, 이 함수 내부에서 write가 발생한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
...
/* 시퀀스 번호의 상위 비트를 끝으로 이동시킴 */
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);
...
}
if (crypto_memneq(ihash, ohash, authsize)) // auth 검증
return -EBADMSG;
...
return crypto_skcipher_decrypt(skreq); // 실제 복호화
[3] 지점에서 scatterwalk_map_and_copy() 호출이 핵심이다. 마지막 인자가 1이므로 쓰기 방향이며, dst의 assoclen+cryptlen 위치에 tmp+1이 가리키는 4바이트를 쓴다. dst는 aead_request_set_crypt(req, sg, sg, elen + ivlen, iv);에서 sg와 동일하게 설정됐고, sg는 skb_to_sgvec()이 skb의 frag로부터 만든 것이므로, 결국 이 write는 skb의 frags[0]이 가리키는 메모리인 file Page Cache 페이지에 직접 4바이트를 쓴다.
만일 공격자가 페이로드 길이를 조절하여 splice로 심어둔 페이지가 assoclen+cryptlen 위치에 오도로 맞춘다면, 페이지의 정확히 원하는 파일 오프셋 위치에 4바이트를 쓸 수 있게 된다. 쓰이는 값인 tmp + 1은 esp_input_set_header()가 삽입한 seq_hi 값이다. 공격자가 SA 등록시 XFRMA_REPLAY_ESN_VAL의 seq_hi 필드에 지정한 값이 그대로 파일 메모리에 기록된다. 결과적으로 공격자는 쓰기가 발생하는 위치(파일 오프셋)와 값(4바이트)를 모두 완벽하게 제어할 수 있다.
4.3 write 이후 auth 검증
4.2의 crypto_authenc_esn_decrypt의 코드를 보면 scatter_map_and_copy()가 __cryto_memneq()보다 먼저 호출된다. __crypto_memneq()는 인증 태그를 비교해 패킷이 변조되지 않았는지 검증하는 함수다. 인증에서 실패하면 -EBADMSG를 반환한다.
그러나 AEAD 인증 확인은 쓰기 작업 이후에 실행되므로, 인증에 실패하더라도 이미 쓰기는 발생한 상태이다. 따라서 공격자는 SA의 인증 키를 몰라도 수정을 성공시킬 수 있다. 공격자는 올바른 암호화 키나 인증키를 알 필요가 없으며 SA에 임의의 키를 등록하고, seq_hi에 파일에 쓰고 싶은 값을 넣은 뒤, splice()로 file Page CaChe를 skb frag에 심어 패킷을 전송하면 된다.
추가로, esp_input이 호출되려면 XFRM SA가 등록되어야 하며, 이를 위해서는 CAP_NET_ADMIN 권한이 필요하다. 이는 공격자가 사용자 네임스페이스(User Namespace)를 생성할 수 있는 권한이 있어야 함을 의미한다. 실습에서 unshare -rn 명령어를 사용한 것이 바로 이 때문이다. 일반 사용자 권한으로 새로운 사용자/네트워크 네임스페이스를 생성하면, 그 네임스페이스 안에서는 CAP_NET_ADMIN을 포함한 모든 권한을 갖게 된다. 이 안에서 XFRM SA를 등록하면 취약점 트리거가 가능해진다.
5. 정리
이번 포스팅에서는 CVE-2026-43284를 이해하기 위해 ESP 패킷이 Linux 커널 내부에서 어떻게 처리되는지를 직접 실습하며 확인했다. esp_input()의 skip_cow 분기에서 COW가 우회되고, scatterwalk_map_and_copy()가 인증 검증보다 먼저 실행되어 file Page Cache에 write가 발생한다는 것까지 코드 수준에서 확인했다.
다음 포스팅에서는 이러한 패킷 처리 과정을 기반으로 작성된 Exploit 코드를 분석할 것이다
Reference
리눅스 커널에 구멍이 뚫렸다, 지금은 새 소프트웨어를 설치하지 마라
Linux 루트 권한 획득 취약점 Dirty Frag 분석(CVE-2026-43284+CVE-2026-43500)

