問題概要
picoCTFの「Flag Hunters」という問題の解説記事です。
- カテゴリ: Reverse Engineering
- 難易度: Easy
問題文
解説
ステップ1: picoCTFのサーバーに接続
まず、問題文に記載されているサーバーに接続します。
ncコマンドを使用して、指定されたホストとポートに接続します。$ nc verbal-sleep.picoctf.net xxxxx
Command line wizards, we’re starting it right,
Spawning shells in the terminal, hacking all night.
Scripts and searches, grep through the void,
Every keystroke, we're a cypher's envoy.
Brute force the lock or craft that regex,
Flag on the horizon, what challenge is next?
We’re flag hunters in the ether, lighting up the grid,
No puzzle too dark, no challenge too hid.
With every exploit we trigger, every byte we decrypt,
We’re chasing that victory, and we’ll never quit.
Crowd:
※ポート番号は問題文に記載されているものを使用してください。
接続すると、上記の詩が表示されます。
最後に出力される以下の部分でなにか入力を求められているようです。
Crowd:
ステップ2: lyric-reader.pyの解析
提供された
lyric-reader.py ファイルを解析します。2-1. 詩の生成部分の解析
flag = open('flag.txt', 'r').read()
secret_intro = \
'''Pico warriors rising, puzzles laid bare,
Solving each challenge with precision and flair.
With unity and skill, flags we deliver,
The ether’s ours to conquer, '''\
+ flag + '\n'
このコードは、
flag.txt ファイルからフラグを読み込み、secret_intro 変数にフラグを含む文章を格納しています。
この部分から以下のことがわかります。- フラグは
secret_intro変数の最後に直接含まれている - secret_intro変数の内容を出力すればフラグが得られる
また、以下の部分で
song_flag_hunters 変数に歌詞が格納されていることがわかります。
song_flag_huntersの先頭にsecret_introが連結されているため、song_flag_huntersの先頭を実行できればフラグが得られることがわかります。song_flag_hunters = secret_intro +\
'''
[REFRAIN]
We’re flag hunters in the ether, lighting up the grid,
No puzzle too dark, no challenge too hid.
With every exploit we trigger, every byte we decrypt,
We’re chasing that victory, and we’ll never quit.
CROWD (Singalong here!);
RETURN
(中略)
REFRAIN;
END;
'''
2-2. プログラムの実行部分の解析
ここからは、実際に歌詞を出力している reader 関数の処理 を詳しく見ていきます。 この部分を理解することで、どのようにしてフラグを取得できるかが明らかになります。
実行制御に使われる変数の初期化
MAX_LINES = 100
def reader(song, startLabel):
lip = 0
start = 0
refrain = 0
refrain_return = 0
finished = False
ここでは、歌詞の実行状態を管理するための変数が初期化されています。
-
lip
現在実行中の行番号(Line Instruction Pointer) → この値が実行フローを完全に支配します。 -
start
実行開始位置 -
refrain
[REFRAIN] ブロックの開始位置 -
refrain_return
サブルーチンから戻るための RETURN 行の位置 -
finished
実行終了フラグ
また、MAX_LINES は無限ループを防ぐための安全装置です。
歌詞を配列化
song_lines = song.splitlines()
歌詞全体を行ごとに分割し、配列
song_lines に格納しています。
1つの巨大な文字列を行ごとに分割することで、各行を個別に処理できるようにしています。ラベル位置の探索
for i in range(0, len(song_lines)):
if song_lines[i] == startLabel:
start = i + 1
elif song_lines[i] == '[REFRAIN]':
refrain = i + 1
elif song_lines[i] == 'RETURN':
refrain_return = i
このループでは歌詞全体を一度走査し、以下のラベルの位置を特定しています。
- startLabel: 実行開始位置
- [REFRAIN]: サブルーチンの開始位置
- RETURN: サブルーチンから戻る位置
lyric-reader.pyの下部で以下のようにreader関数が呼び出されているため、startLabelには'[VERSE1]'が渡されます。
startには[VERSE1]ラベルの次の行番号が格納されます。reader(song_flag_hunters, '[VERSE1]')
実行開始位置の設定
lip = start
lipは現在実行中の行番号を示す変数です。
ここで、lipにstartが代入されることで、lipが[VERSE1]ラベルの次の行番号に設定され、実行が開始されます。
そのため、secret_introに含まれているフラグ部分は通常の実行フローではスキップされます。メインの実行ループ
while not finished and line_count < MAX_LINES:
この
whileループが、歌詞を1行ずつ実行していくメインのループです。
ループは以下のどちらかで終了します。END行に到達したときMAX_LINESに達したとき(無限ループ防止)
REFRAIN(サブルーチン呼び出し)
if line == 'REFRAIN':
song_lines[refrain_return] = 'RETURN ' + str(lip + 1)
lip = refrain
この処理は、
REFRAIN行に到達したときに実行されます。- 現在の行の次の行番号を
RETURN行に保存 lipをrefrain([REFRAIN]ラベルの次の行番号)に設定し、サブルーチンにジャンプ
つまり「戻り先を書き換えてからジャンプする」という自己書き換え型の制御フローになっています。
CROWD(ユーザー入力)
elif re.match(r"CROWD.*", line):
crowd = input('Crowd: ')
song_lines[lip] = 'Crowd: ' + crowd
lip += 1
この部分が、この問題で最も重要な箇所です。 この処理では
- ユーザー入力をそのまま受け取る
- 検証・サニタイズは一切なし
- 入力内容を
song_linesの現在行に直接書き換え という挙動です。
つまり、ユーザー入力がそのまま実行フローに影響を与える可能性があります。
この部分に
RETURNやENDを入力することで、制御フローそのものを上書きできる構造になっています。RETURN(任意行ジャンプ)
elif re.match(r"RETURN [0-9]+", line):
lip = int(line.split()[1])
RETURN 数字形式の行に到達したときに実行されます。- 行番号を抽出し、
lipに設定 lipが更新されることで、任意の行にジャンプ可能
重要なのは、
- 行番号の検証・サニタイズは一切なし
- 範囲外の行番号でも受け入れられる
という点です。
そのため、
RETURN行に不正な行番号を指定することで、歌詞の外部にジャンプできる可能性があります。
ステップ3: 脆弱性の整理
上記の解析から、このプログラムには以下の3つの致命的な特徴があることがわかります。
1. ユーザー入力が「命令」として保存される
song_lines[lip] = 'Crowd: ' + crowd
CROWD 行に到達すると、ユーザー入力は
- 単なる表示用テキストではなく
- song_lines の 実体そのもの に書き戻されます
これはつまり、 ユーザーが入力した文字列が 次回以降「歌詞」ではなく「命令」として再解釈される ということを意味します。
2. RETURN 命令で任意行にジャンプ可能
elif re.match(r"RETURN [0-9]+", line):
lip = int(line.split()[1])
RETURN 数字という形式さえ満たしていれば、- 行番号の妥当性チェックなし
- 範囲チェックなし で、任意の行番号へジャンプできます。
この仕様により、通常は到達しない
secret_intro
フラグが含まれる冒頭部分へジャンプすることが可能になります。3. 実行開始位置は固定だが、途中から上書き可能
lip = start
プログラムは常に
[VERSE1]ラベルの次の行から実行を開始します。
しかし、CROWD命令でユーザー入力を上書きできるため、- 実行開始位置は固定
- 途中から実行フローを上書き可能
という特徴があります。
この特徴により、CROWD命令で
RETURN命令を上書きし、任意の行にジャンプすることが可能になります。 つまり実行開始位置の制限を回避できます。
ステップ4: 攻撃方針の決定
上記の解析から、以下の攻撃方針が決定されます。
- 処理が
CROWD行に到達する - ユーザー入力として
;RETURN <行番号>を入力 - 次のループで
RETURN命令が実行され、フラグ行にジャンプ - フラグ行が実行され、フラグが表示される
ステップ5: フラグ行の特定
まず、
secret_intro変数が歌詞の何行目にあるかを特定します。
song_flag_hunters変数の内容を行ごとに分割し、secret_introの開始行を特定します。secret_intro = \
'''Pico warriors rising, puzzles laid bare,
Solving each challenge with precision and flair.
With unity and skill, flags we deliver,
The ether’s ours to conquer, '''\
+ flag + '\n'
song_flag_hunters = secret_intro +\
'''
[REFRAIN]
We’re flag hunters in the ether, lighting up the grid,
(以下省略)
フラグは
secret_introの最後の行に含まれているため、secret_introの開始行から数えて4行目(index 3)がフラグ行であることがわかります。ここで重要なのは、
song_flag_hunters は secret_intro を先頭に連結して作られている点です。
つまり、song_lines = song.splitlines() の結果では、- 0行目:
Pico warriors rising, ... - 1行目:
Solving each challenge, ... - 2行目:
With unity and skill, ... - 3行目:
The ether’s ours to conquer, picoCTF{...}← ここにフラグ
という並びになります。
そのため、ジャンプ先としては以下のどちらでもOKです。
- 確実・簡単: 先頭に戻す →
RETURN 0 - 最短: フラグ行に直行 →
RETURN 3
(この記事では、挙動が分かりやすい
RETURN 0 を使います)ステップ6: 実際に入力してフラグを出力
Crowd: の入力待ちになったら、以下を入力します。;RETURN 0
なぜ ; が必要なのか?(重要)
この問題のミニ言語では、歌詞中でも
REFRAIN; や END; のように ;(セミコロン)が文の区切り/終端として使われています。一方で、プログラムはユーザー入力を次のように 必ず
Crowd: を先頭に付けて行に書き戻します。song_lines[lip] = 'Crowd: ' + crowd
つまり、素直に
RETURN 0 と入力しても、保存される行はCrowd: RETURN 0
となり、
RETURN [0-9]+ の命令としては解釈されず(単なる歌詞の1行として扱われて)ジャンプが起きません。そこで先頭に
; を付けて 「Crowd:(歌詞)」と「RETURN 0(命令)」を区切ることで、
後半の RETURN 0 が命令として扱われ、行番号0へジャンプできるようになります。実行例(出力イメージ)
$ nc verbal-sleep.picoctf.net xxxxx
...
Crowd: ;RETURN 0
Pico warriors rising, puzzles laid bare,
Solving each challenge with precision and flair.
With unity and skill, flags we deliver,
The ether’s ours to conquer, picoCTF{xxxxxx}
...
※フラグはマスクしています。
フラグを取得できました。
使用したコマンドの軽い解説
nc
nc <ホスト名> <ポート>
指定したホスト/ポートへ TCP 接続し、対話的に入出力できます。 picoCTFの「接続して操作するタイプ」の問題で頻出です。
まとめ
この問題は「歌詞っぽいテキストを実行するミニ言語インタプリタ」に対して、 ユーザー入力が無検証で混ざることで起きる制御フロー改変(命令注入)がテーマでした。
▼ポイントは以下の通りです。
- フラグは
secret_introに埋め込まれているが、通常フローではスキップされる CROWDで入力がそのままプログラム内部の行データに書き戻されるRETURN <行番号>によって任意行へジャンプできるCrowd:のプレフィックスを突破するために;が必要
閲覧ありがとうございました!
NEXT
次におすすめ
読み終わったら、そのまま次へ
【picoCTF】Rust fixme 2 - 所有権と参照のエラーを修正してXOR復号
カテゴリ: General Skills難易度: Easy#picoCTF
次の記事へ →
同じカテゴリ/難易度/picoCTFでの表示順が近い記事を優先しておすすめしています。