サイト内の現在位置

国際CTF大会決勝戦「Midnight Flag CTF 2026 Finals」参加記

NECセキュリティブログ

2026年7月3日

2026年6月13日にフランス・レンヌで開催された「Midnight Flag CTF 2026 Finals」に、国内トップクラスのCTFチームであるBunkyoWesternsnew window[1]new window[2]として参加しました。本ブログでは、大会の概要、会場の様子、実際に私が解いたReverse問題のWriteupを紹介します。

目次

Midnight Flag CTF 2026 Finalsとは

Midnight Flag CTFnew window[3]new window[4]は、フランスで毎年開催されるCTF大会です。決勝大会は、レンヌ近郊のブルズにあるESNAnew window[5]で開催されました。

図 1 CTF会場のESNAの外観

予選を通過した17チーム、約80人の競技者が現地に集まり、8時間という限られた時間の中で高難易度のセキュリティ課題に取り組みました。決勝では、以下の10カテゴリから約30問が出題されました。

  • OSINT
  • Reverse
  • Hardware
  • Crypto
  • Web
  • Pwn
  • AD
  • Forensic
  • Android
  • Misc

また、本大会ではChatGPT、Claude、CopilotなどのAIツールの利用が完全に禁止されていました。詳細な検出方法は分かりませんが、閉会式後にルール違反によって1チームが失格となる事例もあり、AI利用禁止ルールが厳格に運用されていることが印象的でした。

大会の様子

当日は朝9時頃から参加者が会場に集まり始め、各チームが競技開始に向けて準備を進めていました。テーブルは自由席となっており、参加者同士が声を掛け合いながら準備を進めるなど、会場は和やかで活気のある雰囲気でした。

図 2 競技会場の様子
図 3 会場中央に設置されたモニター
(競技中はこのモニターにランキングやFirst Bloodのアナウンスが投影されていました)

競技中は長時間にわたって問題に取り組むため、運営側から軽食や飲み物も用意されていました。朝にはパンが提供され、ドリンクは自由に取れる形式でした。また、昼には参加者1人につき1枚ずつピザが配られました。会場内で食事を取りながら競技を続けられる形になっており、8時間という限られた競技時間を有効に使えるよう配慮されていました。

図 4 朝に提供された軽食
図 5 昼食として配布されたピザ

Hardwareカテゴリの問題に取り組むため、会場では解析用の機器やケーブル類も配布されました。電子基板、ロジックアナライザ、ジャンパワイヤなどが用意されており、参加者は実際に機器を接続しながら解析を進める形式でした。

図 6 Hardware問題で配布された機器類

現地開催の大会では、他チームの取り組む様子や会場の雰囲気を直接感じながら競技に参加できます。オンラインCTFとは異なり、参加者同士の距離も近く、国際CTF決勝ならではの熱量を感じることができました。

大会の結果

本大会には、国内トップクラスのCTFチームであるBunkyoWesternsとして参加しました。本大会のチーム人数は最大5名でしたが、チームメンバー1名が急遽参加できなくなったため、今回は4名で本戦に臨みました。競技では、各メンバーが得意分野を中心に問題へ取り組みました。限られた時間の中で効率よく問題を攻略するため、担当分野ごとに調査を進めつつ、詰まった点や気付いた点はチーム内で共有しながら進めました。

図 7 競技中にチーム内で相談している様子

チームとしては、OSINT、Reverse、Web、Pwnなど複数のカテゴリでフラグを取得し、最終的に6つのフラグ、1,737点を獲得しました。最終順位は、ルール違反により失格となったチームを除き、16チーム中13位でした。順位としては悔しさの残る結果でしたが、国際CTF決勝の現場で世界レベルの問題に取り組めたことは、非常に貴重な経験となりました。

zoom拡大する
図 8 最終スコアボード
zoom拡大する
図 9 出題カテゴリと問題一覧

私は主に、ReverseとADの問題を担当しました。Reverseでは、普段の業務で扱うバイナリ解析の知見を活かし、1問を解くことができました。実際に解いたReverse問題については、次のセクションでWriteupとして紹介します。

一方で、AD問題では初期アクセスには至りませんでした。調査の過程でMSSQLにおけるSQLインジェクションの脆弱性を発見し、バックアップ情報から気になるユーザ名(iis_svc)を見つけることができたものの、そこから有効な攻略ルートを構築するところまでは到達できませんでした。

Reverse問題(Cubethulhu)のWriteup

実際に私が解いたReverse問題であるCubethulhuのWriteupを紹介します。

図 10 Cubethulhuの問題文

問題文には、「It was scrambled to keep it asleep. You came here to fix that...」と書かれており、cubethulhuという名前のファイルが添付されていました。

fileコマンドでファイルタイプを確認すると、x86-64のELFファイルでした。cubethulhuを試しに実行してみると、ユーザ入力待ちになります。適当な文字列を入力すると、「Nope.」と表示されました。実行結果から正しいフラグを入力すると、正解のメッセージが表示されるタイプの問題だと推測できます。

zoom拡大する
図 11 fileコマンドによるファイルタイプの確認
図 12 cubethulhuの実行結果

cubethulhu をGhidraでデコンパイルし、読みやすいように変数名等を調整すると、以下のようになりました。

図 13 main関数の処理
図 14 apply_move関数の処理
図 15 is_solved関数の処理

デコンパイル結果を読み解くと、cubethulhuは入力された文字列を次のような手順で検証していることが分かりました。まず、入力文字列が「MCTF{...}」という形式になっているかを確認します。その後、中括弧内の文字列を「_」で区切り、4つのチャンクとして個別に検証します。

各チャンクの検証では、最初に54個の値からなるシャッフルされた配列(SCRAMBLES)が用意されます。次に、チャンク内の各文字を順番に読み取り、対応表(MOVE_LOOKUP)を使って、その文字が表す操作番号に変換します。そして、得られた操作番号に対応する順列テーブル(MOVE_PERM)を使い、配列を並び替えていきます。すべての文字を処理した結果、配列が[0,1,2,...,53]という元の順番に戻れば、そのチャンクは正しいと判定されます。この確認を4つのチャンクすべてに対して行います。

すべての検証を通過した場合、最後に「MCTF{...}」内の文字列に対してSHA256が計算されます。計算したハッシュ値が、バイナリに埋め込まれている正しいハッシュ値と一致すれば、最終的に「Correct!」が表示されます。

次に、入力文字がどの操作を表しているのかを確認しました。MOVE_LOOKUPという対応表を使い、入力された1文字を並び替え操作の番号に変換していました。MOVE_LOOKUPを確認すると、ASCIIコードの97〜100に対応する位置にだけ0、1、2、3が格納されており、それ以外の位置には-1が格納されていました。ASCIIコードの97〜100はそれぞれa、b、c、dを表すため、有効な操作文字はa、b、c、dの4種類だけであることが分かります。それ以外の文字が入力された場合は、MOVE_LOOKUPの値が-1となるため、プログラム上では不正な文字として扱われます。

図 16 MOVE_LOOKUP配列

なお、各チャンクの長さはCHUNK_LENで管理されており、4つのチャンクは順に13文字、12文字、13文字、11文字であることも確認できました。

図 17 CHUNK_LEN配列

以上から、フラグを特定するためには、シャッフルされた配列を元の順番に戻せる正しい操作列を求める必要があります。原理的には、各チャンクについてa、b、c、dの全組み合わせを総当たりすれば解を見つけることができます。しかし、チャンク長は順に13文字、12文字、13文字、11文字であるため、それぞれ413、412、413、411通りを試す必要があります。8時間という限られた競技時間の中で、この方法だけで特定するのは現実的ではありません。そのため、単純な総当たりではなく、探索範囲を削減する工夫が必要になります。今回は、操作列を前半と後半に分けて探索する中間一致攻撃(Meet-in-the-middle attack)を用いて解くことにしました。

中間一致攻撃では、操作列を前半と後半に分けて探索します。例えば13文字のチャンクであれば、前半6文字と後半7文字に分けます。まず、前半の操作列については、SCRAMBLESから開始し、a、b、c、dの全組み合わせを適用した結果を記録します。次に、後半の操作列については、最終的に到達すべき配列である[0,1,2,...,53]から逆向きに探索します。後半の探索では、操作を逆向きにたどる必要があるため、各MOVE_PERMについて、操作を元に戻すための逆置換テーブルを作成しました。この逆置換テーブルを使うことで、「ある後半の操作列を実行する直前に、配列がどの状態であればよいか」を求めることができます。前半の探索で記録した配列と、後半から逆向きに求めた配列が一致すれば、その配列が前半と後半の中間地点になります。つまり、前半の操作列と後半の操作列をつなげることで、そのチャンクを解くための正しい操作列が得られます。この方法により、13文字のチャンクを単純に413通り探索する必要がなくなります。前半6文字と後半7文字に分けた場合、必要な探索はおおよそ46+47通りとなり、探索範囲を大きく削減できます。

以上の方法を実装したスクリプトを以下に示します。

from itertools import product
import hashlib

SCRAMBLES = [
    
[0x00,0x07,0x23,0x1e,0x04,0x05,0x06,0x22,0x1d,0x11,0x0a,0x0f,0x0c,0x0d,0x0e,0x0b,0x10,0x09,0x12,0x19,0x35,0x30,0x16,0x17,0x18,0x2e,0x2f,0x1b,0x1c,0x08,0x03,0x1f,0x20,0x21,0x01,0x02,0x24,0x2b,0x26,0x29,0x28,0x27,0x2a,0x25,0x2c,0x2d,0x13,0x1a,0x15,0x31,0x32,0x33,0x34,0x14],
    
[0x08,0x1c,0x06,0x1e,0x04,0x03,0x1b,0x22,0x1d,0x26,0x25,0x0f,0x0e,0x0d,0x0c,0x24,0x0a,0x11,0x33,0x19,0x35,0x15,0x16,0x32,0x18,0x13,0x1a,0x02,0x01,0x00,0x20,0x1f,0x05,0x21,0x07,0x23,0x0b,0x2b,0x2a,0x27,0x28,0x29,0x09,0x10,0x2c,0x14,0x34,0x12,0x30,0x31,0x17,0x2f,0x2e,0x2d],
    
[0x00,0x1c,0x02,0x05,0x04,0x20,0x23,0x07,0x21,0x09,0x10,0x2c,0x27,0x0d,0x0e,0x0f,0x25,0x26,0x1a,0x13,0x18,0x15,0x16,0x17,0x14,0x19,0x12,0x1b,0x01,0x1d,0x1e,0x1f,0x03,0x08,0x22,0x06,0x24,0x0a,0x11,0x0c,0x28,0x29,0x2a,0x2b,0x0b,0x2d,0x34,0x2f,0x32,0x31,0x30,0x33,0x2e,0x35],
    
[0x21,0x01,0x02,0x03,0x04,0x20,0x23,0x07,0x00,0x09,0x10,0x24,0x27,0x0d,0x0c,0x0b,0x2b,0x26,0x1a,0x13,0x2d,0x15,0x16,0x32,0x35,0x19,0x12,0x1b,0x1c,0x08,0x05,0x1f,0x1e,0x1d,0x22,0x06,0x2c,0x25,0x11,0x0e,0x28,0x29,0x2a,0x0a,0x0f,0x18,0x2e,0x2f,0x17,0x31,0x30,0x33,0x34,0x14]
]
MOVE_PERM = [
    [
        
[0x08,0x07,0x06,0x05,0x04,0x03,0x02,0x01,0x00,0x26,0x25,0x24,0x0c,0x0d,0x0e,0x0f,0x10,0x11,0x2f,0x2e,0x2d,0x15,0x16,0x17,0x18,0x19,0x1a,0x1b,0x1c,0x1d,0x1e,0x1f,0x20,0x21,0x22,0x23,0x0b,0x0a,0x09,0x27,0x28,0x29,0x2a,0x2b,0x2c,0x14,0x13,0x12,0x30,0x31,0x32,0x33,0x34,0x35],
        
[0x00,0x01,0x23,0x03,0x04,0x20,0x06,0x07,0x1d,0x11,0x10,0x0f,0x0e,0x0d,0x0c,0x0b,0x0a,0x09,0x12,0x13,0x35,0x15,0x16,0x32,0x18,0x19,0x2f,0x1b,0x1c,0x08,0x1e,0x1f,0x05,0x21,0x22,0x02,0x24,0x25,0x26,0x27,0x28,0x29,0x2a,0x2b,0x2c,0x2d,0x2e,0x1a,0x30,0x31,0x17,0x33,0x34,0x14],
        
[0x00,0x01,0x02,0x03,0x04,0x05,0x23,0x22,0x21,0x09,0x0a,0x2c,0x0c,0x0d,0x29,0x0f,0x10,0x26,0x1a,0x19,0x18,0x17,0x16,0x15,0x14,0x13,0x12,0x1b,0x1c,0x1d,0x1e,0x1f,0x20,0x08,0x07,0x06,0x24,0x25,0x11,0x27,0x28,0x0e,0x2a,0x2b,0x0b,0x2d,0x2e,0x2f,0x30,0x31,0x32,0x33,0x34,0x35],
        
[0x21,0x01,0x02,0x1e,0x04,0x05,0x1b,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f,0x10,0x11,0x33,0x13,0x14,0x30,0x16,0x17,0x2d,0x19,0x1a,0x06,0x1c,0x1d,0x03,0x1f,0x20,0x00,0x22,0x23,0x2c,0x2b,0x2a,0x29,0x28,0x27,0x26,0x25,0x24,0x18,0x2e,0x2f,0x15,0x31,0x32,0x12,0x34,0x35],
    ],
    [
        
[0x08,0x07,0x06,0x05,0x04,0x03,0x02,0x01,0x00,0x26,0x25,0x24,0x0c,0x0d,0x0e,0x0f,0x10,0x11,0x2f,0x2e,0x2d,0x15,0x16,0x17,0x18,0x19,0x1a,0x1b,0x1c,0x1d,0x1e,0x1f,0x20,0x21,0x22,0x23,0x0b,0x0a,0x09,0x27,0x28,0x29,0x2a,0x2b,0x2c,0x14,0x13,0x12,0x30,0x31,0x32,0x33,0x34,0x35],
        
[0x00,0x01,0x23,0x03,0x04,0x20,0x06,0x07,0x1d,0x11,0x10,0x0f,0x0e,0x0d,0x0c,0x0b,0x0a,0x09,0x12,0x13,0x35,0x15,0x16,0x32,0x18,0x19,0x2f,0x1b,0x1c,0x08,0x1e,0x1f,0x05,0x21,0x22,0x02,0x24,0x25,0x26,0x27,0x28,0x29,0x2a,0x2b,0x2c,0x2d,0x2e,0x1a,0x30,0x31,0x17,0x33,0x34,0x14],
        
[0x00,0x01,0x02,0x03,0x04,0x05,0x23,0x22,0x21,0x09,0x0a,0x2c,0x0c,0x0d,0x29,0x0f,0x10,0x26,0x1a,0x19,0x18,0x17,0x16,0x15,0x14,0x13,0x12,0x1b,0x1c,0x1d,0x1e,0x1f,0x20,0x08,0x07,0x06,0x24,0x25,0x11,0x27,0x28,0x0e,0x2a,0x2b,0x0b,0x2d,0x2e,0x2f,0x30,0x31,0x32,0x33,0x34,0x35],
        
[0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x2c,0x2b,0x2a,0x12,0x13,0x14,0x15,0x16,0x17,0x35,0x34,0x33,0x23,0x22,0x21,0x20,0x1f,0x1e,0x1d,0x1c,0x1b,0x24,0x25,0x26,0x27,0x28,0x29,0x11,0x10,0x0f,0x2d,0x2e,0x2f,0x30,0x31,0x32,0x1a,0x19,0x18],
    ],
    [
        
[0x08,0x07,0x06,0x05,0x04,0x03,0x02,0x01,0x00,0x26,0x25,0x24,0x0c,0x0d,0x0e,0x0f,0x10,0x11,0x2f,0x2e,0x2d,0x15,0x16,0x17,0x18,0x19,0x1a,0x1b,0x1c,0x1d,0x1e,0x1f,0x20,0x21,0x22,0x23,0x0b,0x0a,0x09,0x27,0x28,0x29,0x2a,0x2b,0x2c,0x14,0x13,0x12,0x30,0x31,0x32,0x33,0x34,0x35],
        
[0x00,0x01,0x23,0x03,0x04,0x20,0x06,0x07,0x1d,0x11,0x10,0x0f,0x0e,0x0d,0x0c,0x0b,0x0a,0x09,0x12,0x13,0x35,0x15,0x16,0x32,0x18,0x19,0x2f,0x1b,0x1c,0x08,0x1e,0x1f,0x05,0x21,0x22,0x02,0x24,0x25,0x26,0x27,0x28,0x29,0x2a,0x2b,0x2c,0x2d,0x2e,0x1a,0x30,0x31,0x17,0x33,0x34,0x14],
        
[0x00,0x01,0x02,0x03,0x04,0x05,0x23,0x22,0x21,0x09,0x0a,0x2c,0x0c,0x0d,0x29,0x0f,0x10,0x26,0x1a,0x19,0x18,0x17,0x16,0x15,0x14,0x13,0x12,0x1b,0x1c,0x1d,0x1e,0x1f,0x20,0x08,0x07,0x06,0x24,0x25,0x11,0x27,0x28,0x0e,0x2a,0x2b,0x0b,0x2d,0x2e,0x2f,0x30,0x31,0x32,0x33,0x34,0x35],
        
[0x1d,0x1c,0x1b,0x03,0x04,0x05,0x06,0x07,0x08,0x2a,0x0a,0x0b,0x27,0x0d,0x0e,0x24,0x10,0x11,0x12,0x13,0x14,0x15,0x16,0x17,0x18,0x19,0x1a,0x02,0x01,0x00,0x1e,0x1f,0x20,0x21,0x22,0x23,0x0f,0x25,0x26,0x0c,0x28,0x29,0x09,0x2b,0x2c,0x35,0x34,0x33,0x32,0x31,0x30,0x2f,0x2e,0x2d],
    ],
    [
        
[0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x2c,0x2b,0x2a,0x12,0x13,0x14,0x15,0x16,0x17,0x35,0x34,0x33,0x23,0x22,0x21,0x20,0x1f,0x1e,0x1d,0x1c,0x1b,0x24,0x25,0x26,0x27,0x28,0x29,0x11,0x10,0x0f,0x2d,0x2e,0x2f,0x30,0x31,0x32,0x1a,0x19,0x18],
        
[0x21,0x01,0x02,0x1e,0x04,0x05,0x1b,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f,0x10,0x11,0x33,0x13,0x14,0x30,0x16,0x17,0x2d,0x19,0x1a,0x06,0x1c,0x1d,0x03,0x1f,0x20,0x00,0x22,0x23,0x2c,0x2b,0x2a,0x29,0x28,0x27,0x26,0x25,0x24,0x18,0x2e,0x2f,0x15,0x31,0x32,0x12,0x34,0x35],
        
[0x1d,0x1c,0x1b,0x03,0x04,0x05,0x06,0x07,0x08,0x2a,0x0a,0x0b,0x27,0x0d,0x0e,0x24,0x10,0x11,0x12,0x13,0x14,0x15,0x16,0x17,0x18,0x19,0x1a,0x02,0x01,0x00,0x1e,0x1f,0x20,0x21,0x22,0x23,0x0f,0x25,0x26,0x0c,0x28,0x29,0x09,0x2b,0x2c,0x35,0x34,0x33,0x32,0x31,0x30,0x2f,0x2e,0x2d],
        
[0x00,0x01,0x23,0x03,0x04,0x20,0x06,0x07,0x1d,0x11,0x10,0x0f,0x0e,0x0d,0x0c,0x0b,0x0a,0x09,0x12,0x13,0x35,0x15,0x16,0x32,0x18,0x19,0x2f,0x1b,0x1c,0x08,0x1e,0x1f,0x05,0x21,0x22,0x02,0x24,0x25,0x26,0x27,0x28,0x29,0x2a,0x2b,0x2c,0x2d,0x2e,0x1a,0x30,0x31,0x17,0x33,0x34,0x14],
    ]
]
CHUNK_LEN = [0x0d,0x0c,0x0d,0x0b]

def apply_move(buf, move_perm):
    return tuple(buf[i] for i in move_perm)

def inv(move_perm):
    inv_move_perm = [0] * len(move_perm)
    for i, n in enumerate(move_perm):
        inv_move_perm[n] = i
    return tuple(inv_move_perm)

def solve_chunk(chunk_idx, chunk_len):
    left_len = chunk_len // 2
    right_len = chunk_len - left_len

    left = {}
    for move_seq in product(range(4), repeat=left_len):
        buf = SCRAMBLES[chunk_idx]
        for move_id in move_seq:
            buf = apply_move(buf, MOVE_PERM[chunk_idx][move_id])
        left[buf] = move_seq

    inv_moves = []
    for move_perm in MOVE_PERM[chunk_idx]:
        inv_moves.append(inv(move_perm))

    for move_seq in product(range(4), repeat=right_len):
        solved = [i for i in range(54)]
        buf = solved
        for move_id in reversed(move_seq):
            buf = apply_move(buf, inv_moves[move_id])

        if buf in left:
            ans = left[buf] + move_seq
            return ''.join("abcd"[move_id] for move_id in ans)

chunks = []
for chunk_idx in range(4):
    chunk = solve_chunk(chunk_idx, CHUNK_LEN[chunk_idx])
    print(f'chunk {chunk_idx}: {chunk}')
    chunks.append(chunk)

flag_body = '_'.join(chunks)

target_hash = '15d94d8fb5f6aab15074d5974bad21e29b1fd27757620ed8048d200edd3ebab4'
flag_body_sha256 = hashlib.sha256(flag_body.encode()).hexdigest()
print(f'check hash: {target_hash == flag_body_sha256}')

print('flag: MCTF{' + flag_body + '}')

このスクリプトを実行すると、フラグが求まります。

図 18 スクリプトの実行結果

実際に cubethulhuを実行して、フラグを入力してみると、「Correct! Submit your input as the flag.」と表示されました。

図 19 cubethulhuへのフラグ入力結果

以上でフラグを取得することができました。

最後に、問題全体の構造を振り返ると、問題名がcubethulhuであることからも、この問題はルービックキューブのような構造を意識して作られていたのではないかと考えられます。状態として扱われている配列は54要素であり、これはルービックキューブの6面×9マスと同じ数です。そのため、SCRAMBLESはシャッフルされた状態、MOVE_PERMは各操作によって6面上の各マスがどこへ移動するかを表す表、入力文字列は元の状態に戻すための操作列のように見ることができます。実際にルービックキューブを表示する問題ではありませんが、54要素の状態に対して操作を適用し、最終的に元の順番へ戻すという点では、ルービックキューブを解く処理に近い問題でした。

まとめ

Midnight Flag CTF 2026 Finalsへの参加を通じて、国際CTF決勝レベルの技術水準を現地で体感することができました。Reverse問題では、普段の業務で培っているバイナリ解析の知見を活かしてフラグを取得できた一方、AD問題では初期アクセスに至ることができず、初期調査の網羅性の重要性を改めて実感しました。

また、Hardwareカテゴリのように実機解析を伴う問題も出題されており、世界レベルで競うためには、ソフトウェア分野だけでなく、ハードウェアを含む幅広い技術力が必要であることを認識しました。

今回得られた経験を、今後のペネトレーションテスト業務やマルウェア解析、社内での技術共有に活かしていきたいと考えています。

参考文献

執筆者プロフィール

松本 隆志(まつもと たかし)
担当領域:リスクハンティング
専門分野:マルウェア解析、ペネトレーションテスト、フォレンジック

マルウェア解析や攻撃者の行動分析などの業務を経て、現在はペネトレーションテスト、脆弱性診断などに従事。これまでに Botconf、JSAC、CODE BLUEで講演。
CISSP、情報処理安全確保支援士(RISS)、CPENT、LPT Master、OSCP+、SANS FOR710 メダルを保持。
趣味は、CTFとコーヒーを飲むこと。

執筆者の他の記事を読む

アクセスランキング