防衛省サイバーコンテスト2026 Writeup
2026/02/01、 SyoBoN、nexryai、無為の3人でチームを組み防衛省サイバーコンテスト2026に参加しましたので、そのWriteupです。
nexryai
担当部分
今回私は以下の分野を担当させてもらいました。
- Web
- Network
- Pwn
運営の推奨環境は Kali Linuxらしいですが、Kaliというと中学2年生がインストールして自慢してるイメージが払拭できずUbuntu Desktop 25.10で挑みました。
Web
ここは一番ためになった分野だと思います。
SQLインジェクション
一般的なSQLインジェクションです。ORMに甘えてるせいでSQL書く機会があまりなく、一部のクエリはGeminiに手伝ってもらいました。
直接SQLインジェクションをContextにして頼んでもよかったのですが、GoogleアカウントがBANされるのが怖くてできませんでした。
パストラバーサル
典型的なパストラバーサルの課題でした。
../を使うと弾かれますが、.././で回避することができました。
パーミッションやSELinuxで対策できる脆弱性ですが、現実世界ではそもそもこんな危険な実装書かないのが一番だと思います。
SSRF
ここは一番出てきて嬉しい問題でした。というのも私は以前に、この問題を解決するためのGoライブラリを書いたことがあったからです。
https://github.com/nexryai/archer
localhostにアクセスしてフラグを得るという内容でした。
最初以下の方法を試しましたが、ちゃんと対策されていました。
- 自分の持ってるドメインが
localhostを解決するようにする 0.0.0.0http://example.com@127.0.0.1/
http://2130706433/みたいなn進数で回避する方法が正解でした。
実世界での話をすると、SSRF対策はかなり難易度が高い作業なので許可リスト形式にするか、それが難しいならLambdaみたいなマネージドな環境で権限を絞って動かすのがベストだと思います。
XSS
ユーザーが入力した内容をサニタイズせずに危険な関数で挿入してるサイトから、管理者の認証Cookieを抜き出す課題でした。
最初以下の方法を試しましたが、ちゃんと対策されていました。
- scriptタグのXSS
- aタグのhref経由でのXSS
- SVG XSS
javascript:やonerrorなどの文字列を機械的に置き換えてサニタイズっぽいことをやってるとう挙動だったので、エンコードされた文字列とiframeを組み合わせて回避できました。
現実世界ではそもそもユーザーが入力した内容は、ただの文字列として以上は扱わないべきです。 フォーマットされた文章を保存したいなら、ProseMirrorなどの外部ライブラリを使いJSONとして保存しましょう。
またReactなど近代的なライブラリを使っていても、SVGやhref経由でのXSSは避けられないので注意するべきです。
Pwn
Cで書かれたバイナリと問題が示され、ncコマンドを使用してフラグを取得する課題でした。
私はELFバイナリを読めないので、最初見たとき「無理じゃん…」とか思ってましたが、NSAが公開しているGhidraというツールを使うことでデコンパイルして4問解くことに成功しました。NSAさんありがとうございます。無料でこれだけ使えるのに感謝はしていますが、HiDPI環境でUIが小さくなって画面を凝視しないと作業できない問題さえなければ完璧でした。
最初公式のリリースからインストールしたGhidraがどうやってもJVMを認識してくれず困っていたところ、ちゃんとメンテナンスされているsnapパッケージがありそちらを利用することで救われました。
https://github.com/dclane-code/ghidra-snap
デコンパイルすると存在する脆弱性が推測できるので、後は入力を細工してスタックオーバーフローなど典型的なメモリバグを誘発することでフラグを取得できました。
注意点としては外部ライブラリにスタティックリンクしてるバイナリを、そのままexportしようとすると巨大なCファイルが生成されて混乱するので、普通にGhidraのGUIでgキーを押してmain関数からそれっぽい関数を辿ってく方が結果的に早いです。
https://github.com/NationalSecurityAgency/ghidra/issues/4267
スタックカナリアを回避してret2winするタイプの問題が少し手こずりましたが、最終的にGeminiと協力して解くことに成功しました。
最後の問題はかなり難易度が高く、ROPする必要がありそうというところまでは分かったのですが、低レイヤーにそもそも私があんまり詳しくないのもあり結局解けませんでした。コンソールの改造とかやってる人は得意な分野だと思います。
現実世界では、そもそもメモリセーフでない言語で書いたプログラムはよっぽど自信がない限り、外部からアクセス可能な状態にしないのが一番だと思います。
Network
典型的な不適切なPRNGと、DNS Exfiltrationの問題でした。前者はNetworkというより暗号に近い内容だと思います。
残り2つは私には解けませんでした。ネットワーク周りはもう少し勉強する必要がありそうです。
まとめ - nexryai
人生で初めての本格的なCTFでしたが、思ってたよりは解けました。
React2Shellの件で問題になった、Prototype Pollutionの問題が出題されなかったのは意外でした。
改めて書いたものをインターネットに公開するときは、細心の注意を払わなければならないんだなと感じた一日でした。
無為
crypto
画像の記憶(10)
この画像には秘密が隠されているようです。メタデータを詳しく調べて、隠されたフラグを見つけてください。
与えられたjpgファイルを画像として開くがなにもなし、exiftoolで画像の情報を確認する。
$ exiftool challenge.jpg
======== challenge.jpg/challenge.jpg
ExifTool Version Number : 13.44
File Name : challenge.jpg
Directory : challenge.jpg
File Size : 2.6 kB
File Modification Date/Time : 2026:02:01 10:01:49+09:00
File Access Date/Time : 2026:02:01 18:19:13+09:00
File Inode Change Date/Time : 2026:02:01 18:19:00+09:00
File Permissions : -rwxrwx---
File Type : JPEG
File Type Extension : jpg
MIME Type : image/jpeg
JFIF Version : 1.01
Resolution Unit : None
X Resolution : 1
Y Resolution : 1
Exif Byte Order : Big-endian (Motorola, MM)
Image Description : c3ludHtya3ZzX3FuZ25fcG5hX3V2cXJfZnJwZXJnZn0=
Make : CTF_Creator
Software : Python_PIL
Image Width : 300
Image Height : 200
Encoding Process : Baseline DCT, Huffman coding
Bits Per Sample : 8
Color Components : 3
Y Cb Cr Sub Sampling : YCbCr4:2:0 (2 2)
Image Size : 300x200
Megapixels : 0.060
1 directories scanned
1 image files read
Image Descriptionに長い文字列があったので、CyberChefでc3ludHtya3ZzX3FuZ25fcG5hX3V2cXJfZnJwZXJnZn0=をBase64でデコードすると、synt{rkvs_qngn_pna_uvqr_frpergf}とフラグっぽい文字列が出てきたので、シーザー暗号解読機でフラグのフォーマットになっているものを見つけて回答
flag{exif_data_can_hide_secrets}
埋もれし痕跡(10)
産業用ゲートウェイデバイスのファームウェアイメージを入手しました。このイメージの中には重要な情報が含まれているようです。フラグを見つけてください。
与えられたファイルの種類をfileコマンドで確認する。
$ file firmware.bin
firmware.bin: Squashfs filesystem, little endian, version 4.0, zlib compressed, 7780 bytes, 75 inodes, blocksize: 131072 bytes, created: Tue Nov 25 01:17:29 2025
Squashfs filesystemと出てきたので検索。出てきたWebサイトに倣ってマウントする。
イメージの中をしらみつぶしに見ていくと、/home/admin/.ash_hitoryに/etc/config/networkを確認した痕跡があったので、/etc/config/networkを見ると/etc/config/wirelessにダミーのフラグがあった。
$ cat ../../etc/config/wireless
config wifi-device 'radio0'
option type 'mac80211'
option channel '11'
option hwmode '11g'
option path 'platform/ar933x_wmac'
config wifi-iface
option device 'radio0'
option network 'lan'
option mode 'ap'
option ssid 'MOD-Device-AP'
option encryption 'psk2'
option key 'dummy{n0t_th3_r34l_p4ssw0rd}'
wifi関係だと想定しながらまたファイルを見ていき、/lib/firmware/wifi-firmware.binをfileで確認するとまたSquashfs
filesystemだったので再度マウントする。
さらに奥を探していくとsquashfs-root/lib/firmware/squashfs-root/var/log/install.logにフラグが記載されていた。
flag{squ4sh3d_f1rmw4r3_r3c0v3ry}
misc.
消えゆく残像(20)
古びた端末に接続したところ、何かが高速で流れていきました。一瞬で消えてしまう残像の中に、何か秘密が隠されているようです。
$ nc 10.2.4.7 10008
(@%:~#?@^*=&)
(:^<^@~f%=)
(+;;@?~<)
(@+>@+)
(@^^)
(?)
==== __
_D _| |_______/
|[O]--- | H\__
/ | | H |
| | | H |
| ________|___H__/
|/ | |----------
__/ =| o |=-~~\ /~~
|/-=|___|= ||
\_/ \O=====O=
[2J[H
(*~~&@*)
(>+=%)
(:l)
()
==== ____
_D _| |_______/
|[O]--- | H\____
/ | | H |
| | | H |__
| ________|___H__/__
|/ | |-----------I
__/ =| o |=-~~\ /~~\
|/-=|___|= || |
\_/ \O=====O===
slコマンドだ。
一通り見てもSLが走っていくだけでflagがありそうなのは煙の中しかないので、nc 10.2.4.7 10008 > out.txtで出力を記録する。
f,l,a,g,{をそれぞれ検索すると、一枚ごとに一文字出てくることに気づく。(上記の出力にf,lが出てきている。)
煙の中にはflag候補の半角英数字および_が一つしか出てこないので、目で見てflagを集める。
flag{sm0k3_s1gn4l_h1dd3n_1n_th3_sky}
まとめ - 無為
たのしかった
またやりたい
SyoBoN
主にProgrammingとMisc.を担当する予定でしたが、結果的にかなり満遍なく解くことになりました。
問題の紹介順は解いた順に従います。フラグをメモし忘れていたので、サーバにアクセスするタイプの問題についてはフラグが載っていません。
コーヒーブレイク
あなたの同僚が「最新の IoT コーヒーメーカーを導入した」と自慢しています。 なんでもインターネット経由でコーヒーを淹れられるらしいのですが、どうやって操作するのでしょうか? ちなみに同僚はブラックコーヒーが苦手なので、何かトッピングを追加してあげてください。
見るからにHTCPCPなわけですが、HTCPCPサーバーにアクセスしたことなんかねーよ!(それはそう)
とりあえずHTTP拡張なのでcurlで叩けそうです。そのままだと怒られたので--http0.9をつけてHTTP/0.9を許可し、とりあえずアクセスできることを確認。
この後どうしたら良いのかわからないので、とりあえず原典(RFC2324)にあたります。すると
BREWメソッドを使えばいいらしいContent-Typeはapplication/coffee-pot-commandとすればいいらしい- リクエストボディは
startかstopである必要があるっぽい Accept-Additionsヘッダでトッピングが指定できるらしい
ということで
curl -v --http0.9 -X BREW -H 'Content-Type: application/coffee-pot-command' -d 'start' -H 'Accept-Additions: Vanilla' http://<IP>:<Port>
としてみますが、404と共に“Unknown device”と言われてしまいます。
原因を見つけるのにかなり苦労しましたが、結果としては以下を見落としていたせいでした。
coffee-url = coffee-scheme ":" [ "//" host ]
["/" pot-designator ] ["?" additions-list ]
pot-designator = "pot-" integer ; for machines with multiple pots
というわけで、
curl -v --http0.9 -X BREW -H 'Content-Type: application/coffee-pot-command' -d 'start' -H 'Accept-Additions: Vanilla' http://<IP>:<Port>/pot-1
から順に試し、pot-3で当たりを引きました。ちなみに、pot-1とpot-2はかの有名な“418 I’m a teapot”を返してきました。何台ポットあるんだよ同僚の家。
三角の綻び
三角形パターンを生成する C コードがあります。プログラムを実行すると視覚的にパターンが崩れて表示されます。添付画像は正しく生成された場合の最初の 16 行です。プログラムを修正し、正しいパターンを生成して出力値を flag{数値} の形式で答えてください。
とりあえずコードを見ます。
/*
* Triangle Pattern Generator
*
* このプログラムは三角形パターンを生成します。
* しかし、正しく動作していません。
* デバッグして修正し、正しいパターンを生成してください。
*
* 期待される出力は問題文の画像を参照してください。
*/
#include <stdio.h>
int triangle[64][64];
void generate_pattern() {
// すべて0で初期化
for(int i = 0; i < 64; i++) {
for(int j = 0; j < 64; j++) {
triangle[i][j] = 0;
}
}
// 最初の要素
triangle[0][0] = 1;
// パターンを生成
for(int i = 1; i < 64; i++) {
triangle[i][0] = 1; // 左辺は常に1
triangle[i][i] = 1; // 右辺は常に1
for(int j = 1; j < i; j++) {
triangle[i][j] = (triangle[i-1][j-1] + triangle[i-1][j]) % 3;
}
}
}
void print_pattern() {
printf("Triangle Pattern:\n");
printf("(*=1, space=0)\n\n");
for(int i = 0; i < 64; i++) {
// 左側のスペースで中央揃え
for(int space = 0; space < 63 - i; space++) {
printf(" ");
}
// パターンを表示
for(int j = 0; j <= i; j++) {
if(triangle[i][j] == 1) {
printf("* ");
} else {
printf(" ");
}
}
printf("\n");
}
printf("\n");
}
int count_stars_in_row(int row) {
int count = 0;
for(int j = 0; j < row; j++) {
if(triangle[row][j] == 1) {
count++;
}
}
return count;
}
int main() {
printf("=== Triangle Pattern Challenge ===\n\n");
generate_pattern();
print_pattern();
// 32行目と64行目の*の数を数える
int stars_row32 = count_stars_in_row(32);
int stars_row64 = count_stars_in_row(64);
printf("Statistics:\n");
printf("32行目: %d stars\n", stars_row32);
printf("64行目: %d stars\n", stars_row64);
printf("\n");
// 答えを計算(32行目 × 1000 + 64行目)
int result = stars_row32 * 1000 + stars_row64;
printf("Result: %d\n", result);
printf("Flag: flag{%d}\n", result);
return 0;
}
ふむ、32行目と64行目の*の数を数えて、32行目 × 1000 + 64行目の結果がフラグになるわけですね。
とりあえず、想定出力を見て……

……これ、2ⁿ行目の*の数、2ⁿ個ですよね?
ということは、32行目は32個で、64行目は64個で、フラグはflag{32064}ですよね?
「プログラムを修正」する必要、なし!
認証問合
あるデータクエリサービスのサーバープログラムが動作しています。このサービスには独自のプロトコルが実装されており、正しいプロトコルに従ってリクエストを送信することでフラグを取得できます。
とりあえず、指示されたサーバにアクセスしてみます。
===================================
Data Query Service
===================================
Available commands:
- HELLO: Get a session token
- LIST: Show available commands
- HELP: Show this help message
- QUERY <token>: Query data with your token
Note: This service uses a binary protocol.
Each message format: [4-byte length (little-endian)][command string]
===================================
なにやら入力できるっぽいですが、適当に入力すると接続を切られてしまいます。
うーん、とりあえずGeminiに相談してみよう!ということで上の出力を雑にコピペしたところ、頼んでもいないのに専用クライアントをPythonで作ってくれました。
import socket
import struct
# Configuration
HOST = '127.0.0.1' # Replace with actual target IP
PORT = 1337 # Replace with actual target Port
def send_command(sock, cmd_str):
"""
Packs a string into [Length][String] format and sends it.
"""
# 1. Encode the string to bytes
payload = cmd_str.encode('utf-8')
# 2. Calculate length
length = len(payload)
# 3. Pack length as 4-byte little-endian unsigned integer ('<I')
header = struct.pack('<I', length)
# 4. Send combined packet
sock.sendall(header + payload)
print(f"[>] Sent: {cmd_str} (Header: {header.hex()})")
def receive_response(sock):
"""
Reads the 4-byte length header, then reads the rest of the message.
"""
# 1. Read the first 4 bytes (Length Header)
raw_len = sock.recv(4)
if not raw_len:
return None
# 2. Unpack the length (Little-endian unsigned int)
msg_len = struct.unpack('<I', raw_len)[0]
# 3. Read the payload based on the length
# Note: In production, you might need a loop here to ensure full receipt
payload = sock.recv(msg_len)
return payload.decode('utf-8', errors='ignore')
def main():
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
print(f"[*] Connecting to {HOST}:{PORT}...")
s.connect((HOST, PORT))
# Read initial banner/help message
# (Note: This assumes the banner also follows the protocol or is raw text.
# If the banner is raw text, just use s.recv(1024))
print(f"[<] Banner: {s.recv(1024).decode()}")
# --- STEP 1: GET TOKEN ---
send_command(s, "HELLO")
response = receive_response(s)
print(f"[<] Response: {response}")
# Logic to extract token would go here
# e.g., token = response.split()[-1]
# --- STEP 2: USE TOKEN ---
# send_command(s, f"QUERY {token}")
# print(f"[<] Query Result: {receive_response(s)}")
except Exception as e:
print(f"Error: {e}")
if __name__ == '__main__':
main()
“Logic to extract token would go here”の部分をちょいちょいと修正しまして、
# Logic to extract token would go here
token = response[47:53]
# --- STEP 2: USE TOKEN ---
send_command(s, f"QUERY {token}")
print(f"[<] Query Result: {receive_response(s)}")
実行してみるとフラグが手に入りました。
人間、要る?
細胞の回帰
ライフゲームが 2 世代進行した後の状態が与えられます。時を戻し、初期状態を復元してフラグを取得してください。
与えられたサーバにアクセスしてみると、2世代目の盤面として以下がもらえました:
[[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,1,0,1,0,0,0,0,0,0,0,1,1,0,0],[0,0,1,0,1,0,0,0,0,0,0,0,1,1,0,0],[0,0,0,0,1,0,0,0,0,0,0,1,1,0,0,0],[0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0],[0,0,0,0,1,1,0,0,0,1,1,0,0,0,0,0],[0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0],[0,0,1,0,1,1,0,0,0,0,0,1,0,0,0,0],[0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]]
とはいえ、ライフゲームの前世代って普通には計算できないはずでは?全探索?えーやだコード書きたくな〜い、Geminiくんたすけて〜
ということでGeminiに頼んだら書いてくれました。高速化してと頼んだらSATソルバを使うようにもしてくれました。
from z3 import *
def solve_gol_reverse_z3(target_grid_gen2):
rows = len(target_grid_gen2)
cols = len(target_grid_gen2[0])
# ソルバのインスタンス作成
s = Solver()
# 盤面変数の作成 (Gen 0, Gen 1)
# Bool型を使うことで計算を最適化します(True=1, False=0)
gen0 = [[Bool(f'g0_{r}_{c}') for c in range(cols)] for r in range(rows)]
gen1 = [[Bool(f'g1_{r}_{c}') for c in range(cols)] for r in range(rows)]
def add_evolution_constraints(prev_gen, next_gen):
for r in range(rows):
for c in range(cols):
# 近傍の生きているセルを数えるための式を作成
neighbors = []
for i in range(-1, 2):
for j in range(-1, 2):
if i == 0 and j == 0: continue
nr, nc = r + i, c + j
if 0 <= nr < rows and 0 <= nc < cols:
neighbors.append(prev_gen[nr][nc])
# Z3のPbEq (Pseudo-Boolean Equality) や Sum を使うと重くなるため
# 単純な論理式で「近傍の数」を表現します
# ライフゲームのルールをSAT(論理式)に変換
# neighbor_sum を論理的に表現するのは複雑なので、
# ここではIf-Then-Else (If) を使って簡潔に記述します
# 生存数を整数として扱う (0 or 1の和)
live_neighbors = Sum([If(n, 1, 0) for n in neighbors])
# ルール:
# 1. 生存(True) かつ 近傍2or3 -> 次も生存
# 2. 死滅(False) かつ 近傍3 -> 次は誕生
# それ以外 -> 死滅
is_alive = Or(
And(prev_gen[r][c], Or(live_neighbors == 2, live_neighbors == 3)),
And(Not(prev_gen[r][c]), live_neighbors == 3)
)
s.add(next_gen[r][c] == is_alive)
# 制約の追加
# 1. Gen 0 -> Gen 1
add_evolution_constraints(gen0, gen1)
# 2. Gen 1 -> Gen 2 (Target)
# Gen 2 は定数(入力値)なので、変数ではなく直接制約として与えます
for r in range(rows):
for c in range(cols):
target_val = True if target_grid_gen2[r][c] == 1 else False
# Gen 1 から計算された結果が Target と一致しなければならない
neighbors = []
for i in range(-1, 2):
for j in range(-1, 2):
if i == 0 and j == 0: continue
nr, nc = r + i, c + j
if 0 <= nr < rows and 0 <= nc < cols:
neighbors.append(gen1[nr][nc])
live_neighbors = Sum([If(n, 1, 0) for n in neighbors])
is_alive_next = Or(
And(gen1[r][c], Or(live_neighbors == 2, live_neighbors == 3)),
And(Not(gen1[r][c]), live_neighbors == 3)
)
s.add(is_alive_next == target_val)
print("Solving with Z3...")
if s.check() == sat:
m = s.model()
result_grid = [[0 for _ in range(cols)] for _ in range(rows)]
for r in range(rows):
for c in range(cols):
# Trueなら1, Falseなら0
if is_true(m[gen0[r][c]]):
result_grid[r][c] = 1
return result_grid
else:
return None
# 入力データ(第2世代)
gen2 = [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,1,0,0,0,0,0,0,0,1,1,0,0],
[0,0,1,0,1,0,0,0,0,0,0,0,1,1,0,0],
[0,0,0,0,1,0,0,0,0,0,0,1,1,0,0,0],
[0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0],
[0,0,0,0,1,1,0,0,0,1,1,0,0,0,0,0],
[0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0],
[0,0,1,0,1,1,0,0,0,0,0,1,0,0,0,0],
[0,0,1,1,0,0,0,0,0,0,0,0,1,1,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
]
result = solve_gol_reverse_z3(gen2)
if result:
print("Gen 0 Found:")
for row in result:
print(row)
else:
print("No solution found.")
実行すると一瞬で終わりました。SATソルバすげえ。
あとは出てきた0世代目を、要求されているJSONの形に整形してPOSTしてあげればOKでした。
人間、要る?(2回目)
脅威の報告
担当範囲が手詰まりになってきたので、他の人の担当範囲に移っていきます。
組織内で発見されたPDFファイルには、重要な情報が隠されているようです。このファイルを詳しく調査し、隠されたフラグを見つけてください。
ほーん。PDFの解析といえば、Poppler!というわけでいろいろためしたものの、
pdfinfo→特におもしろい情報はなさそうpdfdetach→なにも埋め込まれておらずpdffonts→Noto Sans JPとHelveticaが指定されていることがわかったのみpdfimages→なにも埋め込まれておらずpdftotext→特に不可視文字もなさそう
うーん、うーーー…ん?
Producer: PyPDF2
Custom Metadata: no
Metadata Stream: no
Tagged: no
UserProperties: no
Suspects: no
Form: none
JavaScript: yes
Pages: 1
Encrypted: no
Page size: 595.276 x 841.89 pts (A4)
Page rot: 0
File size: 56305 bytes
Optimized: no
PDF version: 1.4
JavaScript: yes
絶対これじゃ〜ん。
pdfinfoでそのままJavaScriptの抽出もできるらしいので、やってみると
var d1 = [102,108,97,103,123,104,49,100];
var d2 = [113,64,123,108,119,65,131,65];
var d3 = [49,95,116,112,49,114,99,53];
var d4 = [103,88,105,93,95,118];
function decode1() {
return String.fromCharCode.apply(null, d1);
}
function decode2() {
var shifted = d2.map(function(x) { return x - 13; });
return String.fromCharCode.apply(null, shifted);
}
function decode3() {
var str = String.fromCharCode.apply(null, d3);
return str.split('').reverse().join('');
}
function decode4() {
var shifted = d4.map(function(x) { return x + 7; });
return String.fromCharCode.apply(null, shifted);
}
function getSecret() {
return decode1() + decode2() + decode3() + decode4();
}
おー。Node.jsなんていうものは知らないので適当にブラウザのコンソールに貼り付けてgetSecret()するとフラグが手に入りました。
> getSecret()
< "flag{h1dd3n_j4v45cr1pt_1n_pdf}"
遷ろう文字列
暗号化されたメッセージが与えられました。ヒントを参考に復号プログラムを作成して、フラグを取得してください。
与えられたファイルは2つ、
シフト暗号の計算式:
暗号化: encrypted[i] = (plain[i] + shift[i mod N]) mod M
復号: plain[i] = (encrypted[i] - shift[i mod N]) mod M
i: 文字の位置(0から始まるインデックス)
N: シフトパターンの長さ
M: アルファベットの場合26、数字の場合10
注意: 特殊文字({ } _など)はシフト処理を行いません。
データファイルには2つのシフトパターンが含まれています。
STAGE1_SHIFTS:3,7,2,5,9,4,1,8,6,3,7,2,5,9,4,1,8,6,3,7,2,5,9,4,1,8
STAGE2_SHIFTS:5,2,8,3,1,6,4,9,7,2
DATA:}x0bv7t_g6t56wj_3u8i{nkvj
ようするにアルファベットと数字を別処理するだけのヴィジュネル暗号でしょ?と思って愚直にソルバを書いたもののよくわからない文字列が出るのみ。
とりあえず、括弧の並びが逆なので、文字列全体を反転させてみます。
% echo '}x0bv7t_g6t56wj_3u8i{nkvj' | rev
jvkn{i8u3_jw65t6g_t7vb0x}
“jvkn”が“flag”と対応するはずなので、ズレている数を数えると4, 10, 10, 7。あとはSTAGE1_SHIFTSとSTAGE2_SHIFTSをズラして足して4, 10, 10, 7(または逆順の7, 10, 10, 4)が出現する組み合わせを探せば……

ないじゃん!おい!どういうことだよ!
ということで、Geminiに丸投げ。なんと、Pythonすら書かずに正解してきました。どうやら、提示されたデータはflag{...}に対しSTAGE1_SHIFTSを適用した後、反転させてからSTAGE2_SHIFTSを適用したものの様子。つまり
flag{c0d3_br34k3r_m4st3r}
↓3, 7, 2, 5, …, 5, 9, 4, 1ずらし
iscl{g1l9_it83o4z_p1uy2v}
↓反転
}v2yu1p_z4o38ti_9l1g{lcsi
↓5, 2, 8, 3, …, 2, 8, 3, 1ずらし
}x0bv7t_g6t56wj_3u8i{nkvj
ということです。
なるほどね?いや、わからんて。
んで人間、要る?(3回目)
封印されし盤面の啓示
画像ファイルには、封印された盤面が隠されています。その姿を見抜き、解き明かすことで、暗号を解く手がかりを得ることができます。盤面の結末を導き、フラグを取得してください。
画像ファイルといえば、ヘッダ!というわけで、vipsheaderを叩きます。
challenge.png: 300x300 uchar, 3 bands, srgb, pngload
width: 300
height: 300
bands: 3
format: uchar
coding: none
interpretation: srgb
xoffset: 0
yoffset: 0
xres: 2.834
yres: 2.834
filename: challenge.png
vips-loader: pngload
png-comment-0-Number Place: 87...2..6.49.7..8......6.9..3.7.......46.9.23...3.57.829........67.....4...2....5
png-comment-1-Salt: s4l7
png-comment-2-Recipe: When the cipher follows the Advanced Encryption Standard, blocks of 128 bits chained in CBC mode, then shall the key be born of MD5 over the solved fate, and the IV shall arise from the salt upon the same fate.
png-comment-3-Sealed Board: fbc12ea652e3bc2cb4eba9c6b6ad78ded1795f6c823f9d3aa318876b748ee127
bits-per-sample: 8
あからさまにコメントが入ってますね。……って「封印されし盤面」ってナンプレかよ!
ナンプレを解くと啓示が手に入って、啓示のMD5を鍵、啓示にソルトをかけたものを初期化ベクトルとしてCBCモードのAESを外せばいいみたいですね。
もちろん、Geminiにやってもらいましょう。
import hashlib
import binascii
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
# --- 1. The Inputs ---
# The unsolved Sudoku board
puzzle_str = "87...2..6.49.7..8......6.9..3.7.......46.9.23...3.57.829........67.....4...2....5"
salt = "s4l7"
# The hex-encoded ciphertext
sealed_hex = "fbc12ea652e3bc2cb4eba9c6b6ad78ded1795f6c823f9d3aa318876b748ee127"
# --- 2. The Solver (Backtracking Algorithm) ---
def solve_sudoku(s):
# Convert string to list of integers (0 for empty)
board = [int(c) if c != '.' else 0 for c in s]
def is_valid(b, num, pos):
row, col = pos // 9, pos % 9
# Check row and column
for i in range(9):
if b[row*9 + i] == num and (row*9 + i) != pos: return False
if b[i*9 + col] == num and (i*9 + col) != pos: return False
# Check 3x3 box
box_x, box_y = col // 3, row // 3
for i in range(box_y*3, box_y*3 + 3):
for j in range(box_x*3, box_x*3 + 3):
idx = i * 9 + j
if b[idx] == num and idx != pos: return False
return True
def solve(b):
try:
pos = b.index(0) # Find first empty cell
except ValueError:
return True # No empty cells left, solved!
for num in range(1, 10):
if is_valid(b, num, pos):
b[pos] = num
if solve(b): return True
b[pos] = 0 # Backtrack
return False
if solve(board):
return "".join(map(str, board))
return None
# --- 3. The Execution ---
print("[*] Solving Sudoku...")
solved_fate = solve_sudoku(puzzle_str)
if not solved_fate:
print("[-] Failed to solve Sudoku.")
exit()
print(f"[+] Solved Fate: {solved_fate}")
# --- 4. The Recipe (Key & IV Derivation) ---
# Key: MD5 over the solved fate
key = hashlib.md5(solved_fate.encode()).digest()
# IV: Salt upon the same fate -> MD5(salt + fate)
iv_candidate = hashlib.md5((solved_fate + salt).encode()).digest()
# --- 5. The Unsealing ---
ciphertext = binascii.unhexlify(sealed_hex)
try:
cipher = AES.new(key, AES.MODE_CBC, iv_candidate)
# Decrypt and remove PKCS7 padding
plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
print(f"\n[+] FLAG UNLOCKED: {plaintext.decode()}")
except Exception as e:
print(f"\n[-] Decryption failed: {e}")
[*] Solving Sudoku...
[+] Solved Fate: 875932416649571382312846597138724659754689123926315748293457861567198234481263975
[+] FLAG UNLOCKED: flag{encrypted_numberplace}
人間、要る?(4回目)
二重の格子
古い書庫から、古典暗号を二重に用いた文書が発見されました。 外側の暗号を解読すると、内側の暗号の鍵が判明します。 解読してください。
EFLVK OVLJP MWQEQ NHIJR TTLIE TCHLT WCJNW SEFBT WHJVM JMSTV LRKMR KGDGH JVITR WUDMC TFEYW JZGWK ACTUE DTQHI HUKBU SHBXR YEREZ XHYCS CKYYO GBUZG OZIIL ANXKM YRNEK HU
「古典暗号」といえばシーザー暗号かヴィジュネル暗号か……まあそんなところでしょう。
みんな大好きdCodeを使います。シーザー暗号はさすがに簡単すぎるので、ヴィジュネル暗号にあたりをつけて自動解読開始。
……ビンゴ!鍵“CRYPTO”で平文が得られました。
CONGR ATULA TIONS YOUHA VESUC CESSF ULLYD ECODE DTHEO UTERE NCRYP TIONT HEKEY ISMON ARCHY UGSUT CNAGC MVBOU FDMMB EFKZC FQPNB IOKAB EVFKM PDFGS MIKTS MLGMX FDLNM SB
“Congratulations. You have successfully decoded the outer encryption. The key is MONARCHY.”という文章と、内側の暗号
UGSUT CNAGC MVBOU FDMMB EFKZC FQPNB IOKAB EVFKM PDFGS MIKTS MLGMX FDLNM SB
が出てきました。さて、内側の暗号は何でしょう。さすがにヴィジュネル二重掛けってことはないだろうし……
Geminiってこういうのも見分けられたりするんでしょうか。聞いてみましょう。
Gemini「この暗号のタイプは プレイフェア暗号 (Playfair Cipher) です。」
わかるのかよ……。
というわけで、キーマトリクス
| M | O | N | A | R |
| C | H | Y | B | D |
| E | F | G | I | K |
| L | P | Q | S | T |
| U | V | W | X | Z |
を用いて復号すると、
WELXLDONEYOUHAVECRACKEDTHEPLAYFAIRCIPHERTHEFLAGISQUEENVICTORIA
“Well done. You have cracked the PlayFair Cipher. the flag is Queen Victoria.”
とのことで解読完了です。
flag{queenvictoria}
人間、要る?(5回目)
囚われの記録
組織内のサーバーがランサムウェアに感染する事件が発生しました。インシデント対応チームが感染したシステムのディスクイメージを採取しています。このディスクイメージを解析し、ランサムウェアの動作を調査して暗号化されたフラグを復号してください。
ディスクイメージを展開すると、/home/user/Documents下に暗号化されてしまったファイル群、/home/user/.local/bin下にバイナリが入っていました。とりあえずGhidraでデコンパイルしてみます。
undefined8 main(int param_1,undefined8 *param_2)
{
undefined8 uVar1;
if (param_1 == 3) {
display_banner();
uVar1 = encrypt_file((char *)param_2[1],(char *)param_2[2]);
if ((int)uVar1 == 0) {
puts("[*] Encryption completed successfully!");
puts("[*] Original file should be deleted manually for security.");
uVar1 = 0;
}
else {
puts("[!] Encryption failed!");
uVar1 = 1;
}
}
else {
printf("Usage: %s <input_file> <output_file>\n",(char *)*param_2);
printf("Example: %s flag.txt flag.txt.encrypted\n",(char *)*param_2);
uVar1 = 1;
}
return uVar1;
}
main関数を見つけました。encrypt_fileという関数を呼んでいるようなので、探します。
undefined8 encrypt_file(char *param_1,char *param_2)
{
FILE *pFVar1;
undefined8 uVar2;
size_t __size;
void *__ptr;
pFVar1 = fopen64(param_1,"rb");
if (pFVar1 == (FILE *)0x0) {
printf("[ERROR] Failed to open input file: %s\n",param_1);
uVar2 = 0xffffffff;
}
else {
fseek(pFVar1,0,2);
__size = ftell(pFVar1);
fseek(pFVar1,0,0);
__ptr = malloc(__size);
if (__ptr == (void *)0x0) {
puts("[ERROR] Memory allocation failed");
fclose(pFVar1);
uVar2 = 0xffffffff;
}
else {
fread(__ptr,1,__size,pFVar1);
fclose(pFVar1);
printf("[INFO] Encrypting %ld bytes...\n",__size);
xor_encrypt((long)__ptr,__size,0x477010,0x14);
pFVar1 = fopen64(param_2,"wb");
if (pFVar1 == (FILE *)0x0) {
printf("[ERROR] Failed to create output file: %s\n",param_2);
free(__ptr);
uVar2 = 0xffffffff;
}
else {
fwrite(__ptr,1,__size,pFVar1);
fclose(pFVar1);
free(__ptr);
printf("[SUCCESS] File encrypted: %s -> %s\n",param_1,param_2);
uVar2 = 0;
}
}
}
return uVar2;
}
ありました。xor_encryptという関数を呼んでいるようです。え、XOR暗号……ってことはもう一回実行したら戻っちゃうんじゃないの……?いやまさかそんなはずは、きっとなにか簡単には戻らない仕組みが……
というような思考に囚われてしばらくデコンパイル結果とにらめっこしていましたが、試しに実行したら普通に戻りました。おい。
flag{r4ns0mw4r3_4n4lys1s_c0mpl3t3d}
断片の記憶
配布されたバイナリファイルを実行すると、診断メッセージが表示されます。このファイルを解析し、フラグを取得してください。 プログラムは C 言語で書かれています。
一番時間がかかった問題です。理由は後述。
まずは与えられたバイナリを実行してみますが、
====================================
System Diagnostic Tool
====================================
[*] Running system diagnostics...
[+] CPU: OK
[+] Memory: OK
[+] Disk: OK
[+] Network: OK
[*] All systems operational.
うーん、特に情報はなさそう。
とりあえずデコンパイルしてみますが、「囚われの記録」とは異なりデコンパイル結果がめちゃくちゃ読みづらい。Geminiに手伝ってもらいながら少しずつ読み解いていきます。
void FUN_00109d80(undefined8 param_1,undefined4 param_2,undefined4 param_3,undefined4 param_4,
undefined4 param_5,undefined4 param_6,undefined4 param_7,undefined4 param_8,
undefined *param_9,ulong param_10,long *param_11,uint **param_12,uint *param_13,
ulong **param_14,undefined8 param_15)
{
byte *pbVar1;
undefined8 uVar2;
ulong *puVar3;
undefined8 extraout_RDX;
byte **ppbVar4;
byte **ppbVar5;
ulong **ppuVar6;
ulong uVar7;
long in_FS_OFFSET;
undefined4 uVar8;
DAT_001b3890 = (byte **)(param_11 + (long)(int)(uint)param_10 + 1);
DAT_001ac898 = param_15;
ppbVar5 = DAT_001b3890;
do {
ppbVar4 = ppbVar5 + 1;
pbVar1 = *ppbVar5;
ppbVar5 = ppbVar4;
} while (pbVar1 != (byte *)0x0);
uVar7 = param_10;
ppuVar6 = param_14;
uVar8 = FUN_00121ac0((ulong *)ppbVar4);
ppbVar5 = DAT_001b3890;
FUN_001202a0(uVar8,param_2,param_3,param_4,param_5,param_6,param_7,param_8,DAT_001b3890);
FUN_00108c70();
uVar2 = FUN_001209a0();
FUN_00109f60(uVar2,param_2,param_3,param_4,param_5,param_6,param_7,param_8,ppbVar5,uVar7,
extraout_RDX,param_12,(ulong)param_13,ppuVar6);
puVar3 = DAT_001ac888;
*(ulong *)(in_FS_OFFSET + 0x28) = *DAT_001ac888 & 0xffffffffffffff00;
*(ulong *)(in_FS_OFFSET + 0x30) = puVar3[1];
if (param_14 != (ulong **)0x0) {
FUN_0010a3d0((ulong)param_14,(long *)0x0,(long *)0x0,param_12,param_13,ppuVar6);
}
FUN_00123400('\x01');
FUN_001235b0((uint)param_10,param_11,DAT_001b3890);
FUN_0010a3d0(0x107860,(long *)0x0,(long *)0x0,param_12,param_13,ppuVar6);
if (DAT_001ae6d8 != 0) goto LAB_00109f4d;
do {
ppbVar5 = DAT_001b3890;
_DT_INIT();
uVar7 = 0;
do {
(*(code *)(&__DT_INIT_ARRAY)[uVar7])(param_10 & 0xffffffff,param_11,ppbVar5);
uVar7 = uVar7 + 1;
} while (uVar7 < 2);
FUN_0011ef80(0,0);
FUN_00107d40(param_9,param_10 & 0xffffffff,(ulong)param_11,param_12,param_13,ppuVar6);
LAB_00109f4d:
FUN_00123570();
} while( true );
}
……が、1時間経っても進展がゼロ。怪しい関数をGeminiに教えてもらいながらデコンパイルされたコードを読んでいきますが、全く手掛かりが掴めず。そこで、ずっと気になっていた「バイナリファイルが53MBもある」という事実をGeminiに伝えると、
Gemini「手元でターミナルが使えるなら、
binwalkコマンドが一番早いです。これで、ZIPやPNG、ファイルシステムなどが埋め込まれていないか確認できます。」

binwalkを試せと言われたときの私の表情
なんとこの人間、PNGやらransomwareやらどうでもいいファイルに対してはbinwalkを試したのに、あからさまな53MBのバイナリには実行していないのです!おい!
というわけで解析してみると、そこには約52MBのext4ファイルシステムが。
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
0 0x0 ELF binary, 64-bit shared object, AMD X86-64 for System-V (Unix), little endian
713568 0xAE360 EXT filesystem for Linux, inodes: 12800, block size: 4096, block count: 10950, free blocks: 640, reserved blocks: 12800, total size: 52428800 bytes
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
取り出してマウントしてみると、5枚のPNG画像とテキストファイルが入っていました。





とりあえず画像ファイルを見てみるも、ヘッダには特に情報はなし。背景が単色なのが怪しいのでバケツツールを試してみるも、ちょっと色が違うピクセルがあったりはしなさそう。
テキストファイルはこんな感じ:
System Configuration File
Generated: 2025-10-20
[Storage]
backup_location=/mnt/backup
log_rotation=weekly
[Security]
audit_enabled=true
log_level=verbose
[Forensics]
# Data recovery tools:
# - Standard file system utilities
# - Binary analysis frameworks
# - Image processing libraries
[Archive Inventory]
# Files stored in evidence/ directory (before deletion):
# - incident_report.txt
# - access_log.txt
# - note.txt
# - critical_data.png
[Notes]
Some data may have been deliberately removed.
Recovery of deleted artifacts is recommended.
Visual data integrity should be verified at bit level.
1時間苦戦したこの問題、まだまだ続きます。……それまでの1時間は進捗ゼロなんだからそれはそう。
現在空になっているevidenceディレクトリ内にもともと4つのファイルが存在したので、取り出せといっている様子。むかし間違えて消したファイルを復元したことがあるのでわかりますよ。extundeleteでしょ。
……と思い復元を試みるも、復元できたファイルはなし。
ここまできたらGeminiに頼り切ってしまえということで聞いてみると、削除されたファイルの中にPNGがあると言われているので、こういうときはforemostを使えとのこと。実行してみると、おお、出てきた。

※サイト掲載時に不可逆圧縮をしているため、この画像ではフラグの解読はできません。
しかし、なにこれ?他の5つの画像と同じような見た目だし……
と思いつつも5画像で何もなかったバケツツールを試すと、一番上のピクセル行になにやら違う色のピクセルを発見。Geminiに聞いてみると、LSBステガノグラフィと言うらしい。
と、いうわけで、抽出スクリプトを書いてもらい、
from PIL import Image
def extract_lsb_flag():
filename = "00098328.png" # 復元した画像のファイル名
try:
img = Image.open(filename).convert('RGB')
pixels = img.load()
width, height = img.size
# ビット列を格納するリスト
bits = []
# 1行目(y=0)の全ピクセルを走査
print(f"Extracting LSBs from top row (Width: {width})...")
for x in range(width):
r, g, b = pixels[x, 0]
# 各色の最下位ビットを取り出す
bits.append(r & 1)
bits.append(g & 1)
bits.append(b & 1)
# ビット列を8ビットごとに文字に変換
message = ""
for i in range(0, len(bits), 8):
byte_bits = bits[i:i+8]
if len(byte_bits) < 8: break
# ビット配列を整数に変換
char_code = 0
for bit in byte_bits:
char_code = (char_code << 1) | bit
message += chr(char_code)
print("-" * 40)
print("Extracted Message:")
print(message)
print("-" * 40)
# フラグっぽい部分だけ抜き出して表示
import re
flags = re.findall(r"FLAG\{.*?\}", message)
if flags:
print(f"Found FLAG candidate: {flags[0]}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
extract_lsb_flag()
実行して解読完了。この1問に1時間10分かかりました。疲れた……。
Extracting LSBs from top row (Width: 400)...
----------------------------------------
Extracted Message:
!flag{fr4gm3nt3d_m3m0ry_r3c0v3r3d}
----------------------------------------
いや、本当に、人間、要る?(6回目)
刻まれし証
配布された SQLite3データベースファイルを調査し、フラグを取得してください。
さて、序盤に挑戦して諦めた問題に戻ってきました。というのも、「断片の記憶」の問題で、Geminiに「stringsコマンドを使え」と言われまくったから。この問題、stringsコマンド使えるんじゃない?
とりあえず
% strings secret.db | grep "flag{"
はヒットなし。このデータベースについてわかっているのは、いくつかテーブルがあって、うちmessagesテーブルにBase64っぽいけどそんなことはないメッセージが大量に入っていること。まあ、ためしにBase64でもやってみますか。
% echo -n 'flag{' | base64
ZmxhZ3s=
% strings secret.db | grep "ZmxhZ3s"
ヒットなし。
% strings secret.db | grep "ZmxhZ3"
ENCODED MISSION CODE (Base64): ZmxhZ3tkM2wzdDNkX3MzY3IzdF9tM3NzNGczfQ==
ENCODED MISSION CODE (Base64): ZmxhZ3tkM2wzdDNkX3MzY3IzdF9tM3NzNGczfQ==
えっ、出た?!
というわけでBase64デコードをしてクリア。
% echo 'ZmxhZ3tkM2wzdDNkX3MzY3IzdF9tM3NzNGczfQ==' | base64 -d
flag{d3l3t3d_s3cr3t_m3ss4g3}
戦いの中で成長する人間です。
ピカッピカッッ
あなたは新しいスマートホームシステムの検証を依頼されました。 このシステムには、CoAP プロトコルで制御できるスマート電球が接続されています。 電球を調査し、隠された秘密を見つけてください。
さて、このあたりで残り時間が1時間となったので、さらにGeminiへの依存度を上げていきます。CoAPなんてプロトコル、初めて聞いたよ。
まずどうやってサーバにアクセスすれば良いのかすらわからないので聞いてみるとcoap-clientなるコマンドがあるらしい。libcoapパッケージに入っていたのでインストール。で、/.well-known/coreにつなぐといいらしい。
</bulb>;rt="light";title="Smart Bulb";if="core.lb",</bulb/brightness>;rt="light.brightness";title="Brightness Control",</sos>;rt="control";title="SOS Signal",</start>;rt="control";title="Start",</status>;rt="status";title="Status",</history>;rt="log";title="Blink History"
/sosというなにやら不穏なエンドポイントがあるみたい。とりあえずがちゃがちゃしていると、/sosにPOSTした後/historyをGETすると、意味ありげな履歴が取れることに気づきます。
ON,0.000
OFF,0.304
ON,0.611
OFF,0.916
ON,1.220
OFF,1.524
ON,2.437
OFF,3.353
ON,3.656
OFF,4.570
ON,4.873
OFF,5.785
ON,6.702
OFF,7.006
ON,7.312
OFF,7.617
ON,7.921
OFF,8.226
約0.3秒と約0.9秒の二種類の点灯がある様子。モールス信号ですね。SOSなのも納得。
で、本命のコードは/startが出してくれる雰囲気。同じように取ってみると
ON,0.000
OFF,0.322
ON,0.624
OFF,0.926
ON,1.230
OFF,2.138
ON,2.442
OFF,2.743
ON,3.649
OFF,3.953
ON,4.254
OFF,5.159
ON,5.461
OFF,5.762
ON,6.065
OFF,6.367
ON,7.273
OFF,7.577
ON,7.881
OFF,8.788
ON,9.698
OFF,10.603
ON,10.905
OFF,11.812
ON,12.115
OFF,12.417
ON,13.324
OFF,14.231
ON,14.533
OFF,14.836
ON,15.138
OFF,16.046
ON,16.347
OFF,17.256
ON,17.558
OFF,17.859
ON,18.768
OFF,19.675
ON,19.977
OFF,20.886
ON,21.793
OFF,22.702
ON,23.005
OFF,23.910
ON,24.212
OFF,25.118
ON,25.419
OFF,26.326
ON,26.628
OFF,27.537
ON,28.446
OFF,28.750
ON,29.052
OFF,29.964
ON,30.266
OFF,30.569
ON,31.479
OFF,31.781
ON,32.085
OFF,32.389
ON,32.692
OFF,32.996
ON,33.904
OFF,34.207
ON,34.510
OFF,34.813
ON,35.116
OFF,35.420
ON,35.724
OFF,36.634
ON,36.938
OFF,37.847
ON,38.756
OFF,39.059
ON,39.363
OFF,39.665
ON,39.970
OFF,40.880
ON,41.184
OFF,42.090
ON,42.392
OFF,42.694
ON,42.996
OFF,43.902
ON,44.807
OFF,45.110
ON,45.412
OFF,45.713
ON,46.014
OFF,46.316
ON,47.220
OFF,48.126
ON,48.428
OFF,49.334
ON,50.242
OFF,50.545
ON,50.847
OFF,51.148
ON,51.452
OFF,51.753
ON,52.056
OFF,52.359
ON,52.661
OFF,53.568
ON,54.473
OFF,54.777
ON,55.080
OFF,55.985
ON,56.289
OFF,56.591
ON,57.499
OFF,58.406
ON,59.314
OFF,59.615
ON,59.919
OFF,60.220
ON,60.523
OFF,61.430
ON,61.733
OFF,62.645
ON,62.948
OFF,63.252
ON,63.556
OFF,64.468
ON,65.380
OFF,66.290
ON,66.594
OFF,66.899
ON,67.202
OFF,67.505
ON,67.807
OFF,68.110
ON,69.017
OFF,69.320
ON,69.622
OFF,69.925
ON,70.227
OFF,71.137
ON,72.047
OFF,72.350
ON,72.653
OFF,73.562
ON,73.865
OFF,74.774
ON,75.075
OFF,75.981
ON,76.283
OFF,77.187
ON,78.092
OFF,78.995
ON,79.297
OFF,79.600
ON,79.902
OFF,80.203
ON,80.505
OFF,80.806
ON,81.709
OFF,82.615
ON,82.917
OFF,83.218
ON,83.519
OFF,84.423
ON,84.725
OFF,85.629
ON,85.931
OFF,86.232
ON,86.536
OFF,87.439
雑にGeminiに投げたら解読してくれました。電力の無駄遣い。人間、要る?(7回目)
FLAG{M0RS3_SM4RT_BU1B}
量子の足音
2024 年 8 月、NIST は耐量子計算機暗号の標準として ML-DSA(FIPS 204、旧称 CRYSTALS-Dilithium) を公開しました。 我が国でも CRYPTREC による技術評価を踏まえ、(Government Profile 2025 等)への採用が見込まれており、量子コンピュータ時代への備えが始まっています。
ML-DSA 署名サーバーを発見しました。 どうやらこのサーバーの ML-DSA 実装には、独自の簡略化が施されているようです。 秘密鍵 s1 を復元し、フラグを取得してください。
いかにもLLMが得意そうな問題。
=== ML-DSA Signature Service ===
Post-Quantum Cryptography Test Server (FIPS 204)
1. Get public key
2. Sign a message
3. Show signature history
4. Get flag (requires secret key s1)
5. Exit
>
とりあえずサーバにアクセスするとこんなプロンプトが出ました。秘密鍵を入手して4番のコマンドに入れればいいらしい。
ML-DSAなんてメンバーのだーれもわからんのでGeminiに丸投げすると、Nonceの使い回しかRejection Samplingの省略、パラメータの縮小のどれかでしょとのこと。なるほどね。(わかってない)
何度かやりとりをしてNonceの使い回しが確定。するとSageMathという聞いたことないソフトウェアのスクリプトを書いてくれました。
# ==========================================
# ML-DSA (Dilithium) Secret Key Recovery
# Attack: Nonce Reuse (Fixed y)
# ==========================================
# 1. パラメータ設定 (FIPS 204 Standard q = 8380417, n = 256)
q = 8380417
n = 256
R = GF(q)['x']
P.<x> = R.quotient(x^n + 1)
# 2. データの入力 (提供されたデータをここに貼り付け)
# test1 data
z1_raw = [[]] # 省略
c1_raw = [] # 省略
# test2 data
z2_raw = [[]] # 省略
c2_raw = [] # 省略
# 3. 復元処理
# 多項式への変換
poly_c1 = P(c1_raw)
poly_c2 = P(c2_raw)
# cの差分を計算
diff_c = poly_c1 - poly_c2
# 差分が可逆かチェックして逆元を計算
try:
inv_c = 1 / diff_c
print("[+] diff_c is invertible! Calculating s1...")
except ZeroDivisionError:
print("[-] diff_c is not invertible. Attack failed.")
exit()
# 各ベクトル成分ごとに s1 = (z1 - z2) / (c1 - c2) を計算
s1_recovered = []
for i in range(len(z1_raw)):
poly_z1 = P(z1_raw[i])
poly_z2 = P(z2_raw[i])
diff_z = poly_z1 - poly_z2
s_poly = diff_z * inv_c
s1_recovered.append(s_poly)
# 4. 結果表示
# 秘密鍵の係数は非常に小さい整数(-η〜η)になるはずなので、
# 0付近にセンタリングして表示する関数
def centered_coeffs(poly):
# .lift() を使って GF(q) の要素を通常の整数(0 ~ q-1)に変換
coeffs = [c.lift() for c in poly.list()]
# q/2 より大きい場合は負の数として表現 (例: q-1 -> -1)
return [c if c <= q//2 else c - q for c in coeffs]
print("\n=== Recovered Secret Key s1 ===")
formatted_s1 = []
for idx, poly in enumerate(s1_recovered):
coeffs = centered_coeffs(poly)
# 不足している次数分(n=256)を0埋めする (poly.list()は高次の0を省略する場合があるため)
while len(coeffs) < n:
coeffs.append(0)
print(f"s1[{idx}] (first 10 coeffs): {coeffs[:10]} ...")
formatted_s1.append(coeffs)
# 成功判定: 係数が小さければ成功している可能性が高い
max_val = max([abs(c) for c in coeffs]) if coeffs else 0
print(f" -> Max coefficient magnitude: {max_val} (Success if small, e.g. <= 4)")
print("\n=== All s1 vectors recovered ===")
print("Use these values to get the flag.")
# Pythonのリスト形式として表示(コピー用)
print(str(formatted_s1).replace(" ", ""))
あとはできた秘密鍵をプロンプトに入れてあげればOKです。
人間、要る?(8回目)
検閲を嘲笑う残響
社内ネットワークから外部への不正なデータ送信が検出されました。 ファイアウォールで基本的な外部通信は遮断しているにもかかわらず、何らかの方法でデータが外部に送信されています。 セキュリティチームがキャプチャしたパケットを解析し、送信されたデータからフラグを取得してください。
さてここまでで残り10分。無理だろうなあと思いつつ、問題をGeminiに投げながらWireSharkを導入。他のメンバーから「ICMPのパケットが多いのが怪しい」という話は聞いていたので、ICMPのパケットをざーっと眺めてみることに。あ、GeminiもICMPが多いって言ってる。
……なんか、似たようなパケットいっぱいない?しかも、33バイト目だけ変化してない?
……他のICMPパケットは全て33バイト目が0になっている中、一部だけ埋まっているのがあるっぽい?
というわけで、時系列順に並べ直して再度観察。33バイト目に注目してみると、f, l, a, g, ……これだ!というところで残り1分。
急いで文字をかき集めたものの、間に合わずにタイムオーバーとなってしまいました。無念。
flag{1cmp_c0v3r7_ch4nn3l_15_h1dd3n}
まとめ - SyoBoN
自分の無力さと、Geminiの優秀さを分からされた8時間でした。とはいえ、こういうチームでの協力戦は楽しいですね。ゲームとかでも対戦型よりCO-OPの方が好きです。機会があればまたやりたいですね。