TsukuCTF2025にHashMistressとして参加、1200pointsで75th/882teamsという結果でした。
大会二日間の中で主に解いていたのが最初の6時間だったので、最初はいい感じにポイントを重ねていたのですが、最終的には当チームが解いた12問すべてが100pointsになってしまいました。他は軒並み400点台とかで、ここから上が上級者なんだなあという気持ち。
というわけで、今回私が解いた分のwriteupを書いていきます。
len_len
"length".length is 6 ?
curlすると
How to use -> curl -X POST -d 'array=[1,2,3,4]' http://challs.tsukuctf.org:28888
と使い方を教えてくれるのでこの通り送ってみるとlengthが9でflagはあげられませんとresponseが来る。
添付のソースコードを読むと、文字列として受け取ったarrayのlengthが10以上、このarrayをJSON.parseした値のlengthが0未満だとflagが受け取れそう。lengthが0未満…?
送ったデータをわざわざJSON.parseしてくれるので、lengthを-1に設定したJSONのデータを送るとparseしてくれてlengthが-1になる。
curl -X POST -d 'array={"length":-1}' http://challs.tsukuctf.org:28888
これでflagゲット。
schnee
素敵な雪山に辿り着いた!スノーボードをレンタルをして、いざ滑走!
フラグフォーマットは写真の場所の座標の小数点第4位を四捨五入して、小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
例: TsukuCTF25{12.345_123.456}
どこかのスキー用品レンタル屋さんの画像が出てくる。
よーく見てみると画像上部のSKI RENTALの旗に小さくGRINDELWALDとBuri Sportと書かれており、地名と店名っぽい。GoogleMapでGRINDELWALDのBuri Sportを探すと3店舗出てくるので、順番に見ていくと
ここのバルコニーの模様がよく似ている。かわいい~
というわけでここの緯度経度がフラグだった。
こういう外国でストリートビューを見る問題、けっこう好きだったりする。楽しい。
power
力を感じてきた。
フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。
点字つきの案内板が現れる。右の方に「史蹟」などの漢字が見えるので多分日本っぽい。
点字を読む問題か、確かにpowerって感じだな!と思って読み始めるもちょっと心が折れ始めた。点字案内がある史蹟ってもしかして珍しいのでは?と思って「史蹟 点字案内」で画像検索すると同じ案内板の画像が引っ掛かった。
というわけで、将門塚の場所がflag。そっちのpowerね。
a8tsukuctf
適当な KEY を作って暗号化したはずが、 tsukuctf の部分が変わらないなぁ...
outputと暗号化のコードがもらえる。見た感じはヴィジュネル暗号っぽいが、keyが分からないしカシスキーテストをやるには文量が足りない。
コメントでヒントが書いてあって、平文の[30:38]と暗号文の[30:38]が同じであることが分かる。ヴィジュネル暗号で平文と暗号文が同じになるというと、その部分のkeyがaaaaaaaaであるということになるが、そう考えるとやけにkeyが長いことになってしまう。
暗号化のコードを読むと、keyに生成した暗号文を継ぎ足して使っているのが分かる。tsukuctfの部分が変わらないのは、直前の8文字aaaaaaaaを使って暗号化しているからだ。
cyberchefで一つずつ直前の8文字をkeyにして解読していくとflagが出てきた。
destroyed
このTelegramの投稿の写真に写っている学校を特定してください。
フラグフォーマットはその場所の座標の小数点第4位を四捨五入して、小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
例: TsukuCTF25{12.345_123.456}
注意: この問題を解く過程で、戦争に関わる直接的な画像が表示される場合があります。
戦争で破壊されたと思しき学校の画像が出てくる。
下の注釈を翻訳するとステプネンスカ村の体育館と出てくるので、Степненської громадиでGoogleで検索してみる。
似た画像のニュースがでてきた。GoogleMapでСтепненська(deeplがステプネンスカと言っていたのでこれで入れたが、実際にはおそらくСтепне?)を検索し、中央付近に大きい建物があったのでこれが体育館かと思って座標を入れたら当たった。
ただ、私がflagを投入したのが3日の昼間だったので、本来の場所とは別らしく、結局どこだったのかは分からない。
余談だが、他チームが「どれがどの単語なのか全くわからん」とつまずいていた中、音と文字がどれに対応してるかとか単語の語尾の変化くらいは分かるのでこれが役に立った。第二外国語がロシア語だったのである。
ちなみに検索していた中で気に入った写真。
https://vidbudova.zp.ua/zhyttya-pryfrontovoyi-stepnenskoyi-gromady-zaporizkoyi-oblasti-pid-chas-vijny-problemy-ta-vyklyky-fotoreportazh/
PQC0
PQC(ポスト量子暗号)を使ってみました!
ソースコードと暗号化の結果が渡される。secretをkeyにしてAESで暗号化したflagが渡されるが、秘密鍵とciphertextが分かるので同じ手順で秘密鍵から取り出せば良い。暗号化に使われているのはML-KEM-768で、先月リリースされたばかりのOpenSSL3.5.0が対応しているらしい。
こちらのページを参考にビルドさせていただきました。
無事にビルドできたので、秘密鍵をpriv-ml-kem-768.pemに保存、ciphertextをhexからbinaryにしてciphertext.datに保存して
openssl pkeyutl -decap -inkey priv-ml-kem-768.pem -in ciphertext.dat -out shared.dat
これでsecretが分かるので、cyber chefにBAKEしてもらってflagが復号できた。
flash
3, 2, 1, pop!
フラッシュ暗算のサイトが現れる。10ラウンドあり、1~3と8~10しか数字が出てこない。
burpでリクエストを止めながら見ていけば普通に計算はできるのだが、4~7の数字はデータでも流れておらず、表面的な部分から拾うのは難しそうだ。
コードを読むと、数字を出すところはセッションIDとラウンド数、SEED(不明)から作られている。最初はsessionを固定して送ってみて、ラウンド数を変えてリクエストを送ってみたりとか、render_templateだからもしかしてSSTIがささる?とかいろいろ考えたのだが、ふと、
with open('./static/seed.txt', 'r') as f:
SEED = bytes.fromhex(f.read().strip())
SEEDの場所が、staticというディレクトリになっているのが気になる。ので、
http://challs.tsukuctf.org:50000/static/seed.txt
にアクセスしたらあっさりSEEDが出てきた。びっくりしすぎて椅子から転げ落ちるかと思った。というわけで、
def lcg_params(seed: bytes, session_id: str):
m = 2147483693
raw_a = hmac.new(seed, (session_id + "a").encode(), hashlib.sha256).digest()
a = (int.from_bytes(raw_a[:8], 'big') % (m - 1)) + 1
raw_c = hmac.new(seed, (session_id + "c").encode(), hashlib.sha256).digest()
c = (int.from_bytes(raw_c[:8], 'big') % (m - 1)) + 1
return m, a, c
def generate_round_digits(seed: bytes, session_id: str, round_index: int):
LCG_M, LCG_A, LCG_C = lcg_params(seed, session_id)
h0 = hmac.new(seed, session_id.encode(), hashlib.sha256).digest()
state = int.from_bytes(h0, 'big') % LCG_M
for _ in range(DIGITS_PER_ROUND * round_index):
state = (LCG_A * state + LCG_C) % LCG_M
digits = []
for _ in range(DIGITS_PER_ROUND):
state = (LCG_A * state + LCG_C) % LCG_M
digits.append(state % 10)
return digits
この部分を丸写しして、round_indexを増やしながら10回出した数を合計してフラグゲット。
全体的な感想&関係のない色々
今回職場の技術サークル+αでTsukuCTFに出よう!とチーム募集をかけたところ10名ほど集まりまして、後から4人までのチーム制と知り3チームに分かれて出場することになりました。当チームは3月のCTF4Gに参加した時の女子3人組と同じメンバーだったりします。
前回開催時の「ひたすらOSINT」というイメージでメンバーを募集したので問題セットを見て冷や汗をかきましたが、案外初心者がWeb問を解いてくれたりしてみんな楽しめたようでよかったです。とは言え、前回の問題の雰囲気も唯一無二な感じがして好きだったので、ぜひまた大量のOSINTを解く機会がもらえたら嬉しいです。
余談ですが参加表明してくれた3チームでレンタルスペースで場所を借りて集まって解くというのをやっており(別チームとの相談はしていません)、今回はホテルの朝食会場を借りたのでドリンク飲み放題でお菓子食べながらわちゃわちゃおしゃべりしつつ楽しく解けました。参加者のほとんどが子持ちのため思い切って集まった方が集中して解けて良い反面、今回はOSINTや簡単な問題をけっこう最初の方に解いてしまったので手持ち無沙汰な人が生まれてしまって難しさを感じました。
国内CTFでOSINTメインのCTFはほかにはDIVER OSINT CTFくらいで、初心者を気軽に誘ってできるCTFは珍しいので次回開催される際も必ず参加したいと思います。
今回も楽しいCTFをありがとうございました。今後ともよろしくお願いします。