/dev/null

脳みそのL1キャッシュ

LINE CTF 2021 writeup

LINE CTF 2021 にチーム Tomatosalad で参加しました。結果は 200 ポイント、 73 位でした。 僕は crypto カテゴリの問題を 2 問解きました。

babycrypto1

ソースコード

#!/usr/bin/env python
from base64 import b64decode
from base64 import b64encode
import socket
import multiprocessing

from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from Crypto.Util.Padding import pad, unpad
import hashlib
import sys

class AESCipher:
    def __init__(self, key):
        self.key = key

    def encrypt(self, data):
        iv = get_random_bytes(AES.block_size)
        self.cipher = AES.new(self.key, AES.MODE_CBC, iv)
        return b64encode(iv + self.cipher.encrypt(pad(data,
            AES.block_size)))

    def encrypt_iv(self, data, iv):
        self.cipher = AES.new(self.key, AES.MODE_CBC, iv)
        return b64encode(iv + self.cipher.encrypt(pad(data,
            AES.block_size)))

    def decrypt(self, data):
        raw = b64decode(data)
        self.cipher = AES.new(self.key, AES.MODE_CBC, raw[:AES.block_size])
        return unpad(self.cipher.decrypt(raw[AES.block_size:]), AES.block_size)

flag = open("flag", "rb").read().strip()

COMMAND = [b'test',b'show']

def run_server(client, aes_key, token):
    client.send(b'test Command: ' + AESCipher(aes_key).encrypt(token+COMMAND[0]) + b'\n')
    client.send(b'**Cipher oracle**\n')
    client.send(b'IV...: ')
    iv = b64decode(client.recv(1024).decode().strip())
    client.send(b'Message...: ')
    msg = b64decode(client.recv(1024).decode().strip())
    client.send(b'Ciphertext:' + AESCipher(aes_key).encrypt_iv(msg,iv) + b'\n\n')
    while(True):
        client.send(b'Enter your command: ')
        tt = client.recv(1024).strip()
        tt2 = AESCipher(aes_key).decrypt(tt)
        client.send(tt2 + b'\n')
        if tt2 == token+COMMAND[1]:
            client.send(b'The flag is: ' + flag)
            client.close()
            break

if __name__ == '__main__':
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(('0.0.0.0', 16001))
    server.listen(1)

    while True:
        client, address = server.accept()

        aes_key = get_random_bytes(AES.block_size)
        token = b64encode(get_random_bytes(AES.block_size*10))[:AES.block_size*10]

        process = multiprocessing.Process(target=run_server, args=(client, aes_key, token))
        process.daemon = True
        process.start()

攻撃者ができることは

  • iv + AES.enc(token + "test", iv) を手に入れられる
  • 一度だけ攻撃者が指定した iv', msg を使って iv' + AWS.enc(msg, iv') を手に入れられる
  • テキトーな暗号文を復号してもらえる

目標は iv + AES.enc(token + "show", iv) をサーバに渡して復号してもらうことです。iv はレスポンスの先頭に引っ付いてるので既知です。問題はどのようにして AES.enc(token + "test", iv) から AES.enc(token + "show", iv) を求めるかです。

ここでポイントとなるのは、token はちょうど 10 ブロック分でというところです。CBC モードの暗号化処理を見てみましょう。(Wikipedia より引用)

f:id:d2v:20210321112727p:plain

token がちょうど 10 ブロック分ということは、暗号化した後の token の最後の 1 ブロックを iv 、msg を "show" に設定してサーバに暗号化して貰えれば、 "show" の暗号ブロックのみを手に入れられます。

これを暗号化した token と結合すれば AES.enc(token + "show", iv) の出来上がりです。

ソルバは以下の通り

from pwn import *
from base64 import b64decode
from base64 import b64encode

AES_BLOCK_SIZE = 16 # bytes

p = remote("35.200.115.41", 16001)

def get_test_command(p) -> bytes:
    p.recvuntil("test Command: ")
    return b64decode(p.recvline(keepends=False))

def enc(p, iv: bytes, msg: bytes) -> bytes:
    p.recvuntil("IV...: ")
    p.sendline(b64encode(iv))

    p.recvuntil("Message...: ")
    p.sendline(b64encode(msg))

    p.recvuntil("Ciphertext:")
    return b64decode(p.recvline(keepends=False))

def send_command(p, msg: bytes) -> bytes:
    p.recvuntil("Enter your command: ")
    p.sendline(b64encode(msg))
    return b64decode(p.recvline(keepends=False))

iv_token_test = get_test_command(p)

iv_token = iv_token_test[:AES_BLOCK_SIZE*11]

next_iv = iv_token[-AES_BLOCK_SIZE:] # feed to oracle as iv

next_iv_show = enc(p, next_iv, b"show")
show = next_iv_show[AES_BLOCK_SIZE:]

iv_token_show = iv_token + show

result = p.recvline(keepends=False)
send_command(p, iv_token_show)

print(p.recv())
p.close()

babycrypto2

ぶっちゃけ babycrypto1 より簡単です。ソースコードは babycrypto1 とほぼ同じで、差分だけ取り出してみると

AES_KEY = get_random_bytes(AES.block_size)
TOKEN = b64encode(get_random_bytes(AES.block_size*10-1))
COMMAND = [b'test',b'show']
PREFIX = b'Command: '

def run_server(client):
    client.send(b'test Command: ' + AESCipher(AES_KEY).encrypt(PREFIX+COMMAND[0]+TOKEN) + b'\n')
    while(True):
        client.send(b'Enter your command: ')
        tt = client.recv(1024).strip()
        tt2 = AESCipher(AES_KEY).decrypt(tt)
        client.send(tt2 + b'\n')
        if tt2 == PREFIX+COMMAND[1]+TOKEN:
            client.send(b'The flag is: ' + flag)
            client.close()
            break

ポイントは

  • 最初に手に入れられる暗号が iv + AES.enc(token + "test", iv) から iv + AES.enc("Command: " + "test" + token, iv) に変わった
  • サーバに暗号化してもらえなくなった(できるのは暗号文の復号のみ)

このような状態で AES.enc("Command: " + "show" + token, iv) を求める必要があります。この問題で使えるのが iv です。

CBC モードの復号処理を見てみましょう。(Wikipedia より引用)

f:id:d2v:20210321114738p:plain

最初のブロックが平文に戻る直前に iv と xor を取っていますよね。xor の性質(同じ値で xor を計算すると 0 になる)を使って iv を改ざんすることで、復号したあとの平文を test ではなく show にすることができます。

ソルバは以下の通り

from pwn import *
from base64 import b64decode
from base64 import b64encode
from Crypto.Util.number import bytes_to_long as b2l
from Crypto.Util.number import long_to_bytes as l2b

AES_BLOCK_SIZE = 16 # bytes

p = remote("35.200.39.68", 16002)

def get_test_command(p) -> bytes:
    p.recvuntil("test Command: ")
    return b64decode(p.recvline(keepends=False))

def send_command(p, msg: bytes) -> bytes:
    p.recvuntil("Enter your command: ")
    p.sendline(b64encode(msg))
    return p.recvline(keepends=False)

iv_command_test_token = get_test_command(p)
iv = iv_command_test_token[:AES_BLOCK_SIZE]

partial_iv = b2l(iv[9:9+4]) ^ b2l(b"test") ^ b2l(b"show")
partial_iv = l2b(partial_iv)

iv = iv[:9] + partial_iv + iv[9+4:]

enc = iv + iv_command_test_token[AES_BLOCK_SIZE:]
send_command(p, enc)

print(p.recv())
p.close()

おわりに

死ぬほどやる気がでなかった

参考

ja.wikipedia.org