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的。
相关帖子/材料
- https://stackoverflow.com/questions/68147140/which-feature-does-linux-kernel-update-that-lead-to-heap-not-executable-on-linux
- https://stackoverflow.com/questions/64833715/linux-default-behavior-of-executable-data-section-changed-between-5-4-and-5-9
- https://www.reddit.com/r/LiveOverflow/comments/q8v4ju/cant_execute_shellcode_on_latest_linux_even_with/
- 代码使用了ChatGPT来打稿(不得不说它第一次回答的时候还把
__libc_start_main
的函数签名整错了,实在是绷不住)