Linux 5.8内核后设置堆执行权限的方法

在Linux 5.8左右的更新里,堆内存(Heap Memory)的执行权限不再和栈内存执行权限(NX bit/GNU_STACK)同步。在更早一点的内核里,当 -z execstack 被用于链接二进制文件时,不仅会使栈里的代码可被执行,也会使堆里的代码可被执行。当然现实中没人会真的给自己程序开可执行堆栈除非一些特殊用途(比如留后门),但可执行堆对于一些CTF练习题来说是必要的,比如堆溢出攻击的练习题。

具体Patch的版本根据Reddit一个用户的说法是5.8,我自己实测的话5.4是堆是可执行的,5.15是不可执行。至于是哪个commit我还没有去考证。

因为课程使用的执行文件是32位的,所以下面的讨论以32位的程序为准(IA32和X86_64的一些内核特性不一定相同,我还没有测试64位程序有没有类似的问题)。

问题复现

就拿一个堆溢出的执行文件的内存映射做例子,在5.15下是这样子的:

08048000-08049000 r--p 00000000 fd:03 304032                             /root/testzone/example414
08049000-0804c000 r-xp 00001000 fd:03 304032                             /root/testzone/example414
0804c000-0804d000 r--p 00004000 fd:03 304032                             /root/testzone/example414
0804d000-0804e000 r--p 00004000 fd:03 304032                             /root/testzone/example414
0804e000-0804f000 rw-p 00005000 fd:03 304032                             /root/testzone/example414
093bb000-093bc000 rw-p 00000000 00:00 0                                  [heap]
f2624000-f2647000 r--p 00000000 fd:03 303355                             /usr/lib32/libc.so.6
f2647000-f27c6000 r-xp 00023000 fd:03 303355                             /usr/lib32/libc.so.6
f27c6000-f284b000 r--p 001a2000 fd:03 303355                             /usr/lib32/libc.so.6
f284b000-f284d000 r--p 00226000 fd:03 303355                             /usr/lib32/libc.so.6
f284d000-f284e000 rw-p 00228000 fd:03 303355                             /usr/lib32/libc.so.6
f284e000-f2858000 rw-p 00000000 00:00 0
f285f000-f2861000 rw-p 00000000 00:00 0
f2861000-f2865000 r--p 00000000 00:00 0                                  [vvar]
f2865000-f2867000 r-xp 00000000 00:00 0                                  [vdso]
f2867000-f2868000 r--p 00000000 fd:03 303352                             /usr/lib32/ld-linux.so.2
f2868000-f288b000 r-xp 00001000 fd:03 303352                             /usr/lib32/ld-linux.so.2
f288b000-f2899000 r--p 00024000 fd:03 303352                             /usr/lib32/ld-linux.so.2
f2899000-f289b000 r--p 00031000 fd:03 303352                             /usr/lib32/ld-linux.so.2
f289b000-f289c000 rw-p 00033000 fd:03 303352                             /usr/lib32/ld-linux.so.2
ffafa000-ffb1b000 rwxp 00000000 00:00 0                                  [stack]

可以看到stack是 rwxp ,说明NX是已经关闭了。如果在5.4内核下,同一个执行文件结果是这样的:

08048000-0804d000 r-xp 00000000 08:03 1157095                            /home/ubuntu/CTF/example414
0804d000-0804e000 r-xp 00004000 08:03 1157095                            /home/ubuntu/CTF/example414
0804e000-0804f000 rwxp 00005000 08:03 1157095                            /home/ubuntu/CTF/example414
098e4000-098e5000 rwxp 00000000 00:00 0                                  [heap]
f7d52000-f7f38000 r-xp 00000000 08:03 1231522                            /usr/lib32/libc-2.31.so
f7f38000-f7f39000 ---p 001e6000 08:03 1231522                            /usr/lib32/libc-2.31.so
f7f39000-f7f3b000 r-xp 001e6000 08:03 1231522                            /usr/lib32/libc-2.31.so
f7f3b000-f7f3c000 rwxp 001e8000 08:03 1231522                            /usr/lib32/libc-2.31.so
f7f3c000-f7f3f000 rwxp 00000000 00:00 0
f7f4c000-f7f4e000 rwxp 00000000 00:00 0
f7f4e000-f7f51000 r--p 00000000 00:00 0                                  [vvar]
f7f51000-f7f53000 r-xp 00000000 00:00 0                                  [vdso]
f7f53000-f7f7d000 r-xp 00000000 08:03 1230865                            /usr/lib32/ld-2.31.so
f7f7e000-f7f7f000 r-xp 0002a000 08:03 1230865                            /usr/lib32/ld-2.31.so
f7f7f000-f7f80000 rwxp 0002b000 08:03 1230865                            /usr/lib32/ld-2.31.so
ffdaa000-ffdcb000 rwxp 00000000 00:00 0                                  [stack]

可见在5.4内核下,Heap也有 rwxp 。如果在5.15内核下执行堆溢出攻击,虽然可以修改堆数据和返回指针,但无法在堆上放可执行代码,不然就会出喜闻乐见的SegFault。

解决方案

说实在的这个问题网上的讨论真的很少,翻遍了都没找到。因为CTF题是已经发出去了,如果要重新编译(就是加个mprotect语句)不太方便因为地址什么的可能会变。总结了一下大概是这么几个方案:

1. 降级内核——没什么好说的,降到5.4肯定管用。存在问题同下第二点。

2. 添加 noexec32=off 启动参数——添加这个参数之后就会使内核在处理32位执行文件时使用老一套的方法(大概),就是开着execstack就两个都可执行,要不就都不可执行。管用,但这个是全局修改。因为CTF实例是用docker在跑,改宿主机内核/启动参数会动所有实例的内核配置。虽然当前题目都是按照两个权限共通的来设计的,但往后指不定要出些栈可执行堆不可执行的题目,那就不太方便了。

3. 使用动态链接库注入——这个是我捣鼓出来的方法。简单来说就是直接改程序加mprotect不方便,那就劫持libc_start_main里跑完再跳转真正的entry point,这样就可以在不动二进制文件的情况下在启动时重新分配mmap权限了。代码如下:

// my_instrumentation.c
#define _GNU_SOURCE
#include <stdio.h>
#include <dlfcn.h>
#include <unistd.h>
#include <sys/mman.h>
#include <stdint.h>
#include <stdlib.h>

#define NUM_PAGES 2

// Define the correct signature of __libc_start_main
typedef int (*orig_libc_start_main_t)(
    int (*main)(int, char**, char**),
    int argc,
    char **ubp_av,
    void (*init)(void),
    void (*fini)(void),
    void (*rtld_fini)(void),
    void (*stack_end)
);

// Our custom version of __libc_start_main
int __libc_start_main(
    int (*main)(int, char**, char**),
    int argc,
    char **ubp_av,
    void (*init)(void),
    void (*fini)(void),
    void (*rtld_fini)(void),
    void (*stack_end)
) {
    // Instrumentation code before main()
    uintptr_t page_size = getpagesize();
    uintptr_t heap_addr = (uintptr_t) malloc(1);
    uintptr_t heap_base = (heap_addr | (page_size - 1)) ^ (page_size - 1);
    mprotect((void *)heap_base, NUM_PAGES * page_size, PROT_WRITE|PROT_READ|PROT_EXEC);

    // Locate the real __libc_start_main using dlsym
    orig_libc_start_main_t real_libc_start_main = (orig_libc_start_main_t)dlsym(RTLD_NEXT, "__libc_start_main");

    // Call the original __libc_start_main with the same arguments
    return real_libc_start_main(main, argc, ubp_av, init, fini, rtld_fini, stack_end);
}

编译和运行:

gcc -fPIC -shared -o my_instrumentation.so my_instrumentation.c -ldl -m32
LD_PRELOAD=./my_instrumentation.so ./example414

现在在程序实际 main 执行前,mprotect就已经重新给Heap的前两个page分配了执行权限。虽然上面取地址的方法略显粗糙,但大部分情况下应该都是够用的,实际上,因为初始的Heap没有那么大(大概只有一页?看下面的maps输出),会把整个Heap全改成可执行。这是用了这个动态库注入的内存映射(5.15内核下):

08048000-08049000 r--p 00000000 fd:03 304032                             /root/testzone/example414
08049000-0804c000 r-xp 00001000 fd:03 304032                             /root/testzone/example414
0804c000-0804d000 r--p 00004000 fd:03 304032                             /root/testzone/example414
0804d000-0804e000 r--p 00004000 fd:03 304032                             /root/testzone/example414
0804e000-0804f000 rw-p 00005000 fd:03 304032                             /root/testzone/example414
09efc000-09efd000 rwxp 00000000 00:00 0                                  [heap]
f08da000-f08fd000 r--p 00000000 fd:03 303355                             /usr/lib32/libc.so.6
f08fd000-f0a7c000 r-xp 00023000 fd:03 303355                             /usr/lib32/libc.so.6
f0a7c000-f0b01000 r--p 001a2000 fd:03 303355                             /usr/lib32/libc.so.6
f0b01000-f0b03000 r--p 00226000 fd:03 303355                             /usr/lib32/libc.so.6
f0b03000-f0b04000 rw-p 00228000 fd:03 303355                             /usr/lib32/libc.so.6
f0b04000-f0b0e000 rw-p 00000000 00:00 0
f0b15000-f0b16000 r--p 00000000 fd:03 304034                             /root/testzone/my_instrumentation.so
f0b16000-f0b17000 r-xp 00001000 fd:03 304034                             /root/testzone/my_instrumentation.so
f0b17000-f0b18000 r--p 00002000 fd:03 304034                             /root/testzone/my_instrumentation.so
f0b18000-f0b19000 r--p 00002000 fd:03 304034                             /root/testzone/my_instrumentation.so
f0b19000-f0b1a000 rw-p 00003000 fd:03 304034                             /root/testzone/my_instrumentation.so
f0b1a000-f0b1c000 rw-p 00000000 00:00 0
f0b1c000-f0b20000 r--p 00000000 00:00 0                                  [vvar]
f0b20000-f0b22000 r-xp 00000000 00:00 0                                  [vdso]
f0b22000-f0b23000 r--p 00000000 fd:03 303352                             /usr/lib32/ld-linux.so.2
f0b23000-f0b46000 r-xp 00001000 fd:03 303352                             /usr/lib32/ld-linux.so.2
f0b46000-f0b54000 r--p 00024000 fd:03 303352                             /usr/lib32/ld-linux.so.2
f0b54000-f0b56000 r--p 00031000 fd:03 303352                             /usr/lib32/ld-linux.so.2
f0b56000-f0b57000 rw-p 00033000 fd:03 303352                             /usr/lib32/ld-linux.so.2
ffb26000-ffb47000 rwxp 00000000 00:00 0                                  [stack]

存在问题:我没有进行严谨测试在Heap不够的时候 sbrk 扩张的话新的内存区域会不会是可执行。我猜测应该是会的。

结语

因为这些题是根据特定版本的 dlmalloc 和某本书上的例题设计的,希望相关书籍改版后能与新Linux内核的改动同步。这些东西是真的难查,虽然已经是三四年前的改动了谷歌搜烂了都只有两三个stack overflow的帖子和一个reddit的。

相关帖子/材料