こんにちは、デジタルペンテスト部(DP部)のst98です。
以前の記事で、ラックグループ内CTFの「LACCON 2022」で出題した「Hadena Star」というWebセキュリティを題材にした問題について紹介しました。ほかにも十数問を出題したと記事中で言及していましたが、この記事ではまた別の問題について紹介したいと思います。
[Misc 66] Concentration 1 (37 solves)
問題の概要
次のような問題文と、ゲームを遊べるWebページを参加者に提供していました。また、あわせて一部の重要な箇所を伏せたソースコードも提供していました。
神経衰弱を遊べるWebサイトを作ってみました。
AI? の作り方がよくわからなかったので、ランダムにカードをめくらせています。
「初級」のAIに勝てたらフラグがもらえるので、挑戦してみてください。(問題サーバのURL)
もしよろしければ、解法を読む前に以下のリンクから添付ファイルのダウンロードをして、distfiles/server
下で docker compose up -d
でサービスを立ち上げ(8005/tcp
で問題サーバが立ち上がっています)、この問題に挑戦してみてください。
問題サーバのURLにアクセスすると、以下のようにゲームのタイトル画面が表示されます。プレイにあたって難易度を選択できますが、問題文によると「初級」の「AI」に勝てばこの問題のフラグが得られるようですから、そちらを選びましょう。
「初級」を選んでゲームを始めると、AIはカードの表面に書かれている数値が既知であるかどうかを全く気にせずランダムにめくっていることが、そのプレイスタイルからわかります。
添付ファイルにはソースコードの一部分が含まれています。フラグの得られる条件が問題文の通りであるかや、「初級」でのAIの思考ルーチン等について確認してみましょう。
まず、問題文や1行目の #include <Siv3D.hpp>
から、C++とSiv3Dという組み合わせでこのゲームが作られていることがわかります。あわせて、サーバ側ではこれをコンパイルしたものがホストされていますが、読み込まれているファイルからWeb向けにWebAssemblyへコンパイルされていることがわかります。
先ほど一部の重要な箇所が伏せられていると述べましたが、該当する箇所は次の通りです。ゲームの終了後の画面を表示する処理において、// (snipped)
というコメントが入っています。
// ゲームの結果 if (state == GAME_STATE::RESULT) { int aiCards = game->aiCards; int playerCards = game->playerCards; // (snipped) font32(U"結果: あなたは{}枚、AIは{}枚を取りました"_fmt(playerCards, aiCards)).drawAt(Scene::Center().movedBy(0, -30), Palette::White); if (SimpleGUI::Button(U"タイトルに戻る", Scene::Center().movedBy(-150, 100), 300)) { state = GAME_STATE::TITLE; } }
「初級」AIの思考ルーチンを見ていきます。これは次の通りシンプルで、卓上にある52枚のカードからランダムに選び、すでにAIもしくはプレイヤーによって取られているか、同じカードが2回選ばれた場合は再度カードをランダムに選ぶという処理がなされています。
// 初級AIの思考(とにかくランダムに開く) int thinkEasy() { int i; i = Random(51); while (used[i] || flipped.first == i) { i = Random(51); } return i; }
そういうわけで、「初級」では実質ソロプレイとして神経衰弱を遊ぶことができます。カードを選ぶ際に時間の制限がないため、メモを取りつつゆっくり手作業で遊ぶとフラグが得られます。画像認識等によって自動化することも考えられますが、何度もAIに勝利する必要があるわけではないので、実装のコストが上回るかと思います。
FLAG{i_learned_c++_and_siv3d_just_for_this_chall_6f93e443}
ただ神経衰弱を遊ぶだけの問題でした。ほかの問題に挑戦する合間に遊ぶ箸休めのような、あるいは次のConcentration 2でも共通のファイルを用いるということで、次の問題に向けてのチュートリアルのような立ち位置になっています。
元々の構想は、100ラウンドほど連続で勝利する必要があり、それでは手作業でこなすには厳しいために頑張って自動化していくというものでした。しかしながら、実装をかなり工夫しなければ次のConcentration 2の解法で楽に解けてしまうことから、このように非常に簡単な問題となりました。
[Misc 261] Concentration 2 (4 solves)
問題の概要
次のような問題文と、ゲームを遊べるWebページを参加者に提供していました。また、あわせて一部の重要な箇所を伏せたソースコードも提供していました。なお、いずれもConcentration 1とまったく同じものです。
神経衰弱を遊べるWebサイトを作ってみました。
最強のAIを作ってみたので、「上級」から挑んでみてください。注意点:
- 解くのに必ずしもリバースエンジニアリングは必要ではありません(想定していません)
eb3a1a5
時点でのnokotan/OpenSiv3DForWeb-VSCode
を使ってコンパイルしました(問題サーバのURL)
さて、Concentration 1では実質ソロプレイで神経衰弱を遊ぶだけでしたが、今度はフラグを得るために「上級」のAIを打ち負かす必要があるようです。問題文では「最強のAI」とされていますが、どういうことでしょうか。試しに「上級」を選んでみると、AIはまるでどこにどのカードがあるかを見通しているかのようにめくっていきます。あっという間に52枚すべてを取られてしまいました。
思考ルーチンを見てみると、"think hard" と言いつつも、実態は前もってカードをめくって必ずペアになるようなカードを探していることがわかります。明らかにズルです。「AI」がズルをしている以上、フェアプレーを続ける道理はありません。こちらもチートをして対抗していきましょう。
// 上級AIの思考(ズルをする) int thinkHard() { int i; if (flipped.first == -1) { i = Random(51); while (used[i]) { i = Random(51); } } else { const int rank = cards[flipped.first].rank; for (i = 0; i < 52; i++) { if (used[i] || rank != cards[i].rank || flipped.first == i) { continue; } break; } } return i; }
想定解法
では、このゲームではどういうチートができると嬉しいでしょうか。ひとつ考えられるのは、カードの情報が格納されている構造体であるとか、あるいは取ったカードの枚数といった変数のメモリ上の位置を特定し、それを改ざんすることです。つまり、卓上のカードをすべて同じものにしてしまったり、本当は2枚しか取っていないはずなのに、52枚取ったことにできたりすると嬉しいわけです。
添付されているソースコードを見ると、ゲームが終了したかどうかはプレイヤーとAIの取ったカードの枚数の合計が52になったかで判定されていることがわかります。この int
型の変数ひとつを見つけて改ざんするだけでよいので、メモリ上で取ったカードの枚数が格納されている箇所を探しましょう。
// 決着がついたか bool isGameEnd() { return playerCards + aiCards == 52; }
前述の通り、このゲームはSiv3Dで作成されています。それをEmscriptenを使ってWeb向けにビルドしており、出力されたファイルではWebAssemblyとWebGLが使われています。
WebAssemblyが使われているゲームでのメモリハックには、Qwokka/Cetus
というブラウザ拡張が便利です。いわばWebAssembly版の簡易的なCheat EngineやGameGuardianであるわけですが、今回は学習のために機能を絞って再実装してみましょう。
Cetusのコードを参考にして、WebAssemblyの実行環境を初期化している WebAssembly.instantiateStreaming
にフックします。このAPIが作成する WebAssembly.Instance
が持つプロパティをたどると、エクスポートされている WebAssembly.Memory
というメモリにアクセスできるオブジェクトへアクセスできます。これを使って、メモリから適当な数値を探したり、書き換えたりしていきます。
以下のような手順でプレイヤーが取ったカードの枚数が格納されているアドレスを特定し、書き換えていきます。
- メモリ上に存在しているすべての
0
について、そのアドレスを収集する - 2枚めくる。もし取れなければ、手順1からやり直す
- 取ったカードの枚数は
2
になっているはずなので、手順1で収集した各アドレスについて、値が2
に変わったものを探す - 手順3で残ったすべてのアドレスについて、値を
52
に書き換える
これらの手順を実装したコードが以下のとおりです。メモリを Uint32Array
(32ビットの符号なし整数配列)として解釈することで、0
や指定した数値を探したり、この検索によってヒットしたアドレスに格納されている値を 52
に書き換えたりしています。
(() => { class Cheat { constructor(memory) { this.memory = memory; this.candidates = []; this.initUI(); } initUI() { const div = document.createElement('div'); div.style.position = 'fixed'; div.style.right = '0'; div.style.bottom = '0'; div.style.textAlign = 'right'; div.style.zIndex = '100'; const button1 = document.createElement('button'); button1.textContent = '1. Search zeroes'; button1.addEventListener('click', () => { this.findZeroes(); this.updateButton2(); }, false); div.appendChild(button1); div.appendChild(document.createElement('br')); const input = document.createElement('input'); input.value = '0'; const button2 = document.createElement('button'); this.button2 = button2; button2.addEventListener('click', () => { this.filter(parseInt(input.value, 10)); this.updateButton2(); }, false); this.updateButton2(); div.appendChild(input); div.appendChild(button2); div.appendChild(document.createElement('br')); const button3 = document.createElement('button'); button3.addEventListener('click', () => { this.hack(); }, false); button3.textContent = '3. Hack!'; div.appendChild(button3); document.body.appendChild(div); } updateButton2() { this.button2.textContent = `2. Filter addresses (candidates: ${this.candidates.length})`; } findZeroes() { this.candidates = []; let i = 0; const mem = new Uint32Array(this.memory.buffer); while (true) { i = mem.indexOf(0, i); if (i === -1) { break; } this.candidates.push(i); i++; } } filter(x) { let result = []; const mem = new Uint32Array(this.memory.buffer); for (let i of this.candidates) { if (mem[i] === x) { result.push(i); } } this.candidates = result; } hack() { const mem = new Uint32Array(this.memory.buffer); for (let i of this.candidates) { mem[i] = 52; } } } const oldInstantiate = WebAssembly.instantiateStreaming; let cheat; function hook(source, importObject) { return new Promise(resolve => oldInstantiate(source, importObject).then(instance => { const memory = instance.instance.exports.memory; cheat = new Cheat(memory); resolve(instance); })); } WebAssembly.instantiateStreaming = hook; })();
添付されていたwasmファイル等と同じディレクトリに inject.js
としてこのJSコードを保存し、添付ファイルの index.html
の適当な位置に <script src="inject.js"></script>
を挿入します。正常に読み込まれていれば、次のようにページの右下にボタンなどが表示されるはずです。
このツールを使って、先程の手順でプレイヤーが取ったカードの枚数が格納されているアドレスを探します。うまくいくと、数個までアドレスを絞れます。
最後に、"3. Hack!" ボタンをクリックして、それらのアドレスについて 52
に書き換えます。すると、以下のようにチート勝負に勝つことができました。
FLAG{as_you_know_ai_is_cheating_5ba61cc2}
非想定解法
このようにメモリを改ざんするのが想定解法でしたが、参加者に解いてもらう中で想定していなかった解法が見つかりました。すでに取っているカードであり、画面上ではカードが表示されていなかったとしても、元々カードの置かれていたところをクリックすると、再びカードを取得できる*1ということでした。
たとえば、次のスクリーンショットでは画面上では2枚のカードがすでに取られていますが、この2箇所をもう一度クリックすることで、また2枚のカードを取得したことにできます。これを繰り返してAIに勝利することができます。
ソースコードを確認してみましょう。プレイヤーがカードをクリックした際の処理は次のとおりです。2枚のカードを選択する際に、同じ場所にあるカードが選ばれないようになってはいますが、選択されたカードがすでに取られているかどうかをチェックする処理はありません。
void updatePlayer() { // もしカードがクリックされれば、それを裏返す for (int i = 0; i < 52; i++) { const Vec2 pos = calcCardPosition(i); if (pack.regionAt(pos + Vec2{25, 40}).mouseOver()) { Cursor::RequestStyle(CursorStyle::Hand); if (MouseL.down()) { if (flipped.first == -1) { flipped.first = i; cards[i].flip(); } else if (flipped.first != i) { flipped.second = i; cards[i].flip(); } } } } }
実装時の私は、カードの描画処理においてすでに取られていた場合には描画しないという処理を書いただけで満足し、実際のカードの選択時の処理でそのチェックを書くことを忘れてしまっていたようです。これはこれで面白い解法だと思いますし、見た目に惑わされず実装を疑い、このバグを見つけられた参加者の方には感服です。
おわりに
CTFでは、ゲームチート問題がよく出る…という程ではありませんが、SekaiCTF 2023 - Azusawa's Gacha WorldやDiceCTF 2021 - dice-is-youのように、たまに出題されます。CakeCTFのようにゲームチート問題が出題されるのが恒例となっているCTFもあります。こういったゲームチート問が好きであることから、自分でも作ってみたいという気持ちがあり「Concentration 2」を作問しました。
「LACCON 2022」で出題したゲームチート問は、Siv3Dを利用した「Concentration 2」の1問のみでしたが、2023年に開催された「LACCON 2023」では「Concentration 2」とは趣向を変えた形で2問を出題しました。機会があれば、紹介できればと思います。また、ラックのデジタルペンテスト部はチート対策ペネトレーションテストというサービスを提供していますが、その中でやっていることの一部を体験できるような、オンラインゲームを題材としたゲームチート問をいずれ作問してみたいという気持ちもあります。
さて、ラックは「LACCON」のような主にグループ内の社員を対象としたCTFだけでなく、たとえばKOSENセキュリティコンテストのような外部のCTFにも問題を提供しています。また、ラックでは採用活動の一環として、『即!西本』特別技能CTFチャレンジという、好成績を収めるといきなり社長との最終面接に進めるCTFを毎年開催しています。
2024年の『即!西本』はすでに終了していますが、毎年1月頃に募集が始まるはずですので、次回以降参加したいという方はぜひXの@lac_securityや@lac_recruitをフォローしてチェックください。LAC WATCHをチェックするという手もあります。もしかすると、『即!西本』では私の問題も出るかもしれません。
*1:無を取得できる