NFLabs. エンジニアブログ

セキュリティやソフトウェア開発に関する情報を発信する技術者向けのブログです。

Cyber Apocalypse CTF 2024 - Rev Writeup -

TL;DR

  • 2024/03/09 ~ 03/14 にかけて行われた Cyber Apocalypse 2024: Hacker Royale の Writeup 記事です
  • QuickScan, MazeOfPower の Rev. 問2つあります

ctf.hackthebox.com

はじめに

皆さまこんにちは @strinsert1Na という人です。

Hack The Box が主催する CTF イベント『Cyber Apocalypse 2024: Hacker Royale』に、Team Enu で参加してきました。
Team Enu はNTTグループの社員で結成されたCTFチームであり、NFLabs. のメンバーの他にも NTT Communications, NTT社会情報研究所, NTTセキュリティジャパンのメンバーも参加し競技に臨みました。
team-enu.github.io


結果は 5693 チーム中28位、国内チームでは見事トップをとることができました。

筆者は Rev. 以外戦力になれないので Rev. 5問だけ貢献し、なんとかジャンル全完できました。全部書くのは面倒なので簡単すぎる問題を載せても面白くないので、本投稿では難易度 medium 以上の問題 2つに絞って writeup を書きたいと思います。

QuickScan (medium)

問題文

In order to escape this alive, you must carefully observe and analyze your opponents. Learn every strategy and technique in their arsenal, and you stand a chance of outwitting them. Just do it fast, before they do the same to you...

チャレンジ概要

Rev. 問であるにもかかわらず配布ファイルなしでサービスのIPアドレスとポートだけが提示されているという珍しい問題です。試しに nc で接続してみると Base64 でエンコードされた ELF ファイルが降ってきて、これを解析して stack に格納されている byte 列を hex で答えろという問題のようです。

$ nc 83.136.254.199 38279
I am about to send you 128 base64-encoded ELF files, which load a value onto the stack. You must send back the loaded value as a hex string
You must analyze them all in under 20 seconds
Let's start with a warmup
ELF:  f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAo4EECAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAEAAOAABAAAAAAAAAAEAAAAFAAAAAAAAAAAAAAAAgAQIAAAAAACABAgAAAAA9gMAAAAAAAD2AwAAAAAAAAAQAAAAAAAA0qOn5zYC68wJLWIbtdP1ndrNJqCYNglmSIPFiQWaZVrM8j3gsqqdyOs3v3CzNfFwgfCR+QQCJqVsyQAFqDxarPZdny+ci5GrWUEQXJS1BRAAZIGG/XIryUzW473aEWGGBcUC7htmZNPpCuiVCKrNMt3LkcQ452+JoTm6q/Hke4m7I/BplIRoE7cZYh9AMIYiLl0lnFpJSFLs9sMO0gDXhnKnbhrzztDMLLv68S/SiWQyLARYNSyk32ntL1OoAJbEoMJ1sWoJ5Unbweiy+GwzjX7uBJelNZyhhfoGkku/z2k/+2mrbEjUY6+H6EgDPLGB77s9zgLxIyf9XTerSftml1/buQtTTuxWl0b3pBgPBcZXa0xPwKoQ00pPXpTAMdnmCsQrxkHlszHWPuRIg+wYSI01rAEAAEiJ57kYAAAA86S4PAAAAA8Fxwk2pitOoD2pIHDnDwEl8yHrYgasgdSNsbhYwtjktCLW/U9SVImAFingWYbPdfHJGwKFB3AOWtaftMDU2VSJWXeFy6509WGG5vMg59Fqum9qIO1i192yMluv42HCYVWbM3xhwTW/PLueDlXrI+S0lyVCQRt2s2nqacMG8amt7+G6REgx5AprqpIF1S+MlxvYC4l2G0I8wy3t9k6XIWUaUP9Jqen8hAQ7IKNx65YXsahrZH42Za6arUtCBG+Sv6sEZhvjXVeYWENlAu3v+2IDDQutLVnMV7CX3y/tqqREeb/q/cFGjHA+wB5ZzyqcDOSWfKN3XkJfE2ElVEYasF8rGJHaGLWZDyO1knrrAFN5zrGRgAgeMb0MLp4j/+9XTz580JoasKbL3Vn5EeavQCABsWgwpGrzt4Al4shM/txkPnvBdqNRwAUUeaFKl297zLSBiuM+yJO9N06GIoHRXXkOo08ieGrosxoFtOF24KZrjyGa+3bwHK1qMEHUVnFP0wM1XcweQ1XKO7toP3KO/WnVK21F49vZtL3UP2J06scPPZHJOUfvhqqjVlROWUe7FsjzN2d9SaLtxkH5WdTayRDwCefJ3stPQrjUKJbNfDYAMzYHdAsxk4hEpDaTe1Mnxzuz4zDPuerQn/vITPx/7k3i1dug3JWzBCC8233a7QHBs4vTbdAYvCnqOvZnipZheLdV6u1z4xBvoC5P4QHbng0jbCr7HMriFCI4rPdTRKz7LgHq7Ae+IyBB
Expected bytes: eac70f3d91c93947ef86aaa356544e5947bb16c8f337677d
Bytes? eac70f3d91c93947ef86aaa356544e5947bb16c8f337677d
Now we start for real :)
ELF:  f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAIIIECAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAEAAOAABAAAAAAAAAAEAAAAFAAAAAAAAAAAAAAAAgAQIAAAAAACABAgAAAAAEAMAAAAAAAAQAwAAAAAAAAAQAAAAAAAAJoZT/bcZwfZ+e6zS2NYX/O1KWsEVUImblkEqSkj/rsUZCCg5j2k+TfHIUUIsBVNh4Q0wfzAVEvSE99YxVzBu94chIRIcSB5gDxjh7uFFwgWUqiabNtTE+47uSt7YN/Mwp/EA+sEkBYHVW+jiNh+/ZAUbBiQrHKN+qKGelRl9ul1xeosnhz/HixTdbFi0gA9JLF0vuYKj0sFTUK4Eh64BlvKFstFbCwb6YlL+6XoCgFp2q1UkDaXhIh/xT6fEXlz6jWS2n+jF+QQD3GrsnuvTrWApcI0A+225OKtmjUPmO+9hMbc/DyQGVALoBaZVTWXf9bSEffxHiX1dm/r+EMnsQ9+1QFk9Wgq5PKggwURZMsUdOoMh7dwsoOpHFeggdh10TfdmG2g8YRILMFcngYNZL77M5hX0cOPv3mZbVLibhWW7QISFOqUt3XoAu+r5VbKE0w1Zqcwcmk0n3LT05uISKRXpWUBoLSVrJx6ImneiIQK1xfXkrw79rMCEttNAbu9OP4tCBFH6FZndFgyAPhYvG9Qaa0x00THApjghqm2nnsuPOr4e9ZN5KkiD7BhIjTUWAAAASInnuRgAAADzpLg8AAAADwXud3beFtTDrQelaUaAurOYyIm8DzEPysuw1qYE0aGdAvct0gDZGbuUrEMGKuyfzqOk0tvEnJ/1/lnbDwqpbvU7ZP4KVvaqlImxKyXkWHh47NjGUX3o9KTFdL0U1LqJkcmXPWDjdVxAQJbQNg25MQizaHEqmPyEQ0J6fjK5bvsI66589lF6mrHwtMX8moWPQfm1WO8FvojdImhMvPI7esdfihzYidf7HUWuj2KvQQ5P4nNfs4mLcIo3TzG613m9FYrHhs1ea1t8TZSxsAKCnYdS6OkPhg==
Bytes? d4c3ad07a5694680bab398c889bc0f310fcacbb0d6a604d1
Too slow!

降ってきた ELF ファイルの1つを IDA Pro で見ると下図のような感じでした。

実装されている関数は start 1つしかなく、関数内部で 0x18 バイトのスタックを定義しています。おそらくその他の領域にはランダムデータが詰められていて、開始アドレス(図の unk_8048241)とスタックの値をランダムで変更することによってチャレンジの答えが毎回変更されるような作りになっていると思われます。

なので、スタックの開始アドレスと深さを読み取り、そこからデータを抽出して送信を繰り返すようなコードを書けば solve できそうだという見込みが立つでしょう。

solve まで

バイトコードの抽出方針は立ちましたが、20秒で128個の ELF ファイルを解析するというのが少々厄介です。ネットワーク送受信分の時間も考えると 1秒あたり 7つは ELF ファイルを解析しなければならず、1つずつファイルを disassemble して上記の lea に該当する部分を解析していたら到底時間内に終わりません。正規表現も同様です。悩みながら複数の ELF ファイルを解析していると、送信されてくる ELF ファイルはすべて以下のような構造になっていることがわかりました。

  • start 関数のつくりはすべて同じ
    • sub rsp, [即値] ( \x48\x83...) で始まり、その次には必ず lea 命令が来て stack に格納される文字列のアドレスが決まる
  • stack の深さは 0x18 固定


そこで、プライドを捨てて正確に解析できなくてもいいのでバイト列マッチをさせる方針にします。具体的には関数の先頭 2bytes (\x48\x83) を Base64 をデコードしたデータから探し、そこから 7bytes 先の4バイトをスタックの相対位置として決め打ちして取得、その相対位置にあるバイト列を 0x18 分取得して送信するというアルゴリズムです。正直 2bytes だと 65536 分の 1 でマッチしてしまうのですが、速度が必要だったので出現したら運が悪かったと思い乱数ガチャを引き直すことにしました。この概念を実装したものが以下のコードです。

import base64
import binascii
import socket

# pattern = b"\x48\x83\xEC\x18\x48\x8D\x35"
pattern = b"\x48\x83"


def recvuntil(s, tail):
    data = b''
    while True:
        if tail in data:
            return data.decode()
        data += s.recv(1)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('94.237.62.149', 47555))

for i in range(129):
    if (i % 10 == 0):
        print(f"[*] === {i}  ===")
    d = recvuntil(s, b'ELF:  ').rstrip()
    b64 = recvuntil(s, b'\n').rstrip()
    data = base64.b64decode(b64)
    di = data.find(pattern)
    offset = int.from_bytes(data[di+7:di+7+4], "little")
    if offset > 0xfffffff:
        offset = -((~offset+1) & 0xffffffff)

    stack_addr = offset + di + 7 + 4
    s.send(binascii.hexlify(data[stack_addr:stack_addr+0x18]) + b'\n')

fin = recvuntil(s, b'\n').rstrip()
print(fin)

offset 部分を2の補数にしている部分があるのは、stack のアドレスが関数よりも上位に出現する場合があったためです。これを実行すると、ギリギリですが20秒以内に終わり flag がとれました。

$ python3 sig_match.py 
[*] === 0  ===
[*] === 10  ===
[*] === 20  ===
[*] === 30  ===
[*] === 40  ===
[*] === 50  ===
[*] === 60  ===
[*] === 70  ===
[*] === 80  ===
[*] === 90  ===
[*] === 100  ===
[*] === 110  ===
[*] === 120  ===
Bytes? Wow, you did them all. Here's your flag: HTB{y0u_4n4lyz3d_th3_p4tt3ns!}

ちなみに開始6時間くらいまではサーバへのアクセスが集中していたようで、超高速アルゴリズムを実装しているか運がよくなければ解けない問題と化していました。サーバ負荷によって flag の取得が左右されるような問題は、運営側で想定解が通るかの死活監視をしてアナウンスしてくれるとありがたかったです。。。(か、正直30秒くらいの余裕あってもよかったんじゃないかなと思います。)

筆者はどう頑張っても 70 ~ 80 問目で "Too slow!!" と言われ、 golang や rust でも書きなおし「速度が足らないって言われる~」と悩んでいましたが、一晩経ったらサーバ側も安定したようで「上記のコードでそのままいけるよ!」とチームの方が教えてくれました。気付かなかったらずっと高速化のための施策を練っていたので、チームメンバー感謝です。

HTB{y0u_4n4lyz3d_th3_p4tt3ns!}

MazeOfPower (insane)

問題文

You’ve come across a great labyrinth, which must be completed in a short time lest you be locked in forever. Luckily, you have secret contacts with the shadowy organizers of the Fray and have learned that there lies a hidden backdoor through the maze…

配布ファイル: golang で compile された ELF

main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=246eec7fd33acbeba185edfe290af7cb632dec84, for GNU/Linux 3.2.0, with debug_info, not stripped

チャレンジ概要

Rev. のラスボスです。チャレンジとしては1つのバイナリファイルとサービスのIPアドレス/ポートが渡されるため、とりあえず nc で接続してみます。最初にチャレンジの proof of work が求められた後、20秒以内に迷路を解けというチャレンジのようです。また20秒か......

ただし迷路の壁は一切表示されず、移動をするたびにサーバから送られてくる迷路の全体マップと自身の現在位置を見て進もうとしたマスが壁かどうかを知ることができる仕様となっています。そして、配布されたバイナリファイルはこの迷路を生成するバイナリでした。IDA Pro で簡単に中を見るとシンボル情報も残っており、itchyny さんが書かれた maze という project のコードをもとに作られていることがわかります。

github.com

少し触ってみたところ迷路は100×50マス程度あるため、壁のヒント無しの状態で20秒以内に迷路を解かせることはかなり難しいです。なので、Github のプロジェクトにあるコードとの差分に着目しながら読めば解けるのではないか、という予想をしながら進めてみることにしました。

solve まで

ユーザからのインプットを処理する部分のコードを読んでいくと、非常に興味深いコードを発見します。Github を見るとユーザからの入力は以下の5つが用意されていますが、本バイナリには隠しコマンドが用意されています。

  • l => 右移動
  • j => 下移動
  • h => 左移動
  • k => 上移動
  • q => 終了

その隠しコマンドとは "b" で、bを押すことでを押すことで迷路の答えを表示させることができます。

ここまでできたので勝ち。。。かと思いましたがここからが詰まりました。どうやら "b" を押すとフラグがセットされてしまい、迷路を攻略しても flag 出力の分岐に到達しなくなってしまいます。

何かしら抜け道がないものか。。。と悩んでいると、チームメンバーから『乱数の seed の決め方がバイナリと Github で違うからlocalで同一のseedを生成させればいけるかも!?』という素晴らしいアイディアをいただきます。たしかによくよく読んでみると、Github では time.Now().UnixNano() をseedに使っていたため同期に一苦労必要そうでしたが、本バイナリではチャレンジの冒頭で使用した proof of work の検証後の値の CRC32 を使用しているのでこちらでも計算できそうです。

よって、以下の方針で解けそうなことがわかります。

  • nc でサーバに接続し、proof of work で検証
  • 検証で使用した proof of work から乱数のシードを求めてlocalで同じ迷路を作成
  • bコマンドで答えを確認し、その迷路を攻略するためのコマンド(l/j/h/k 表記) を作る
  • 20秒以内にサーバにコマンドを送って flag GET !!!

あとはやるだけです。

local で好きな迷路を生成するためのバイナリ作成

local で迷路を再現するために自身で新たにコーディングしてもよいですが、配布されたバイナリをチョット改変して自由な迷路を作れるようにした方が楽なので下準備としてバイナリ側をいじります。
具体的には、0x4B5E36 にある jz 命令 を変更しました。proof of work での検証が失敗した場合、ここの分岐でコードは終了します。なので、このアドレスの 0x74 => 0x75 にして jnz 命令に書き換えてだいたい何でも検証をすり抜けるようにします。

この変更により、nc でサーバにつないだ時にもらえる proof of work を local にそのまま打ち込むことによって同一の迷路を生成できるようになりました。あとは、迷路を攻略するコマンドを作ります。

迷路の答えから server に送るコマンドへ変換

変換といっても、バイナリの標準出力を 100 × 50 の迷路のマップに変換し、l/j/h/k 表記にするだけです。自分は以下のようなコードを書きました。(たぶんもっともっときれいに書けると思います。)

f = open("maze", "rb")
sample = f.read()
f.close()
maze_map = []
index = sample.index(b"SS")
for i in range(49):
	maze_map.append(sample[index:index+101*2])
	index = index + 101 * 2  + 3

maze_map.append(b" "*202)
prev_point = [99, 99]
point = [0 ,0]
cmd = b""
for i in range(1000):
    if maze_map[point[0]+1][point[1]] == 58 and [point[0]+1, point[1]] != prev_point:
        cmd += b"j"
        prev_point = point
        point = [point[0]+1, point[1]]
    elif maze_map[point[0]][point[1]+2] == 58 and [point[0], point[1]+2] != prev_point:
        cmd += b"l"
        prev_point = point
        point = [point[0], point[1]+2]
    elif maze_map[point[0]-1][point[1]] == 58 and [point[0]-1, point[1]] != prev_point:
        cmd += b"k"
        prev_point = point
        point = [point[0]-1, point[1]]
    elif maze_map[point[0]][point[1]-2] == 58 and [point[0], point[1]-2] != prev_point:
        cmd += b"h"
        prev_point = point
        point = [point[0], point[1]-2]
    elif maze_map[point[0]+1][point[1]] == 69:
        cmd += b"j"
        print("fin")
        break
    elif maze_map[point[0]][point[1]+2] == 69:
        cmd += b"l"
        print("fin")
        break
print(maze_map)

final = ""
counter = 0
for c in cmd[1:-1]:
    if counter % 2 == 0:
        final += chr(c)
    counter += 1
print(final)

一番最後に表示される 150 文字くらいの長さの文字列が、サーバに送るべきコマンドです。あとはこの文字列を send してあげるだけで flag です。

1文字送る => 全バイト分迷路表示を繰り返すのでサーバが重いと多少迷路生成ガチャがありますが、いい乱数引くまで耐えましょう。

HTB{by_th3_p0w3r_0f_th3_m4z3!!1}

おわりに

Cyber Apocalypse は毎年弊社のエンジニアが多く参加する一種のお祭りのようなもので、今年も好成績を収められてよかったです。
弊社では Cyber Apocalypse の他にも DEFCON や HTB Business CTF など、 エンジニア仲間と一緒に CTF に参加する機会があります。

blog.nflabs.jp

blog.nflabs.jp


CTFが好きなエンジニアが多くおり、Pentest に限らずRev, DFIRにも強いエンジニアもいますので、一緒に参戦してみたい方・CTFご興味ある方の募集をお待ちしています!
それでは👋

nflabs.jp