虚数は好きですか?

問題

リモートで虚数を追加、表示できるアプリが動いています
各虚数はRe + Imiのようなキー名で保存されます
また、虚数のリストを暗号化してエクスポートしたりそのデータをインポートしたりすることもできます
これらの機能を上手く使って、キー名が1337iのデータを作成して_secret()を呼べばflagが得られるようです

問題ファイル

import json
import os
from socketserver import ThreadingTCPServer, BaseRequestHandler
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from secret import flag, key

class ImaginaryService(BaseRequestHandler):
    def handle(self):
        try:
            self.request.sendall(b'Welcome to Secret IMAGINARY NUMBER Store!\n')
            self.numbers = {}

            while True:
                num = self._menu()
                if num == 1:
                    self._save()
                elif num == 2:
                    self._show()
                elif num == 3:
                    self._import()
                elif num == 4:
                    self._export()
                elif num == 5:
                    self._secret()
                else:
                    break

        except Exception as e:
            try:
                self.request.sendall(f'ERR: {e}\n'.encode())
            except Exception:
                pass

    def _menu(self):
        self.request.sendall(b'1. Save a number\n')
        self.request.sendall(b'2. Show numbers\n')
        self.request.sendall(b'3. Import numbers\n')
        self.request.sendall(b'4. Export numbers\n')
        self.request.sendall(b'0. Exit\n')
        self.request.sendall(b'> ')
        try:
            return int(self.request.recv(128).strip())
        except ValueError:
            return 0

    def _save(self):
        try:
            self.request.sendall(b'Real part> ')
            re = int(self.request.recv(128).strip())

            self.request.sendall(b'Imaginary part> ')
            im = int(self.request.recv(128).strip())

            name = f'{re} + {im}i'
            self.numbers[name] = [re, im]
        except ValueError:
            pass

    def _show(self):
        self.request.sendall(b'-' * 50 + b'\n')
        for name in self.numbers:
            re, im = self.numbers[name]
            self.request.sendall(f'{name}: ({re}, {im})\n'.encode())
        self.request.sendall(b'-' * 50 + b'\n')

    def _import(self):
        self.request.sendall(b'Exported String> ')
        data = self.request.recv(1024).strip().decode()
        enc = bytes.fromhex(data)
        cipher = AES.new(key, AES.MODE_ECB)
        plaintext = unpad(cipher.decrypt(enc), AES.block_size)

        self.numbers = json.loads(plaintext.decode())
        self.request.sendall(b'Imported.\n')
        self._show()

    def _export(self):
        cipher = AES.new(key, AES.MODE_ECB)
        dump = pad(json.dumps(self.numbers).encode(), AES.block_size)
        self.request.sendall(dump + b'\n')
        enc = cipher.encrypt(dump)
        self.request.sendall(b'Exported:\n')
        self.request.sendall(enc.hex().encode() + b'\n')

    def _secret(self):
        if '1337i' in self.numbers:
            self.request.sendall(b'Congratulations!\n')
            self.request.sendall(f'The flag is {flag}\n'.encode())

if __name__ == '__main__':
    host = os.getenv('CTF4B_HOST')
    port = os.getenv('CTF4B_PORT')

    if not host:
        host = 'localhost'

    if not port:
        port = '1337'

    ThreadingTCPServer.allow_reuse_address = True
    server = ThreadingTCPServer((host, int(port)), ImaginaryService)

    print(f'Start server at {host}:{port}')
    server.serve_forever()

解説

_import()及び_export()関数の暗号化処理ではAESのECBモードを利用しています
Wikipediaにも書いてある通りECBモードは「各ブロックの内容と鍵が一致すれば常に暗号文は同じになる(前のデータや位置に左右されない)」という特徴があります
つまり、「ブロックの最後が"で終わる暗号文」と「ブロックの最初が1337i"で始まる暗号文」を用意してつぎはぎしたものをimportさせればキー名が1337iのデータを作成することが可能です

pycryptodomeのAESのブロックサイズは16Bらしいので、16nB目が"になるようなデータと16n+1Bからの6Bが1337i"のデータをexportしてもらいます
前者は{Re: 1111111, Im: 1}の虚数を追加後{Re: 1, Im: 1}の虚数を追加してexport、後者は{Re: 11111, Im: 1}の虚数を追加後{Re: 1, Im: 1337}の虚数を追加してexportすることで得られます

それぞれのデータは以下のようになります

{"1111111 + 1i": [1111111, 1], "1 + 1i": [1, 1]}
33c7461caec455639a2c78889df87b2b787ed5c63954b411c12f2306190bb676450ebe4d6d0ea85378c0212781ca5cdd8db4341b6d2b363abdc9d13de3042f42

{"11111 + 1i": [11111, 1], "1 + 1337i": [1, 1337]}
8de3745294d8770f84b8c692029f1cc452280c588bc07a960f5fdd9f619008dd063254e779270e657f7ffba29dcd4af2f36e1309837f85a8ef0b81de1e9a6bb2

これらのデータは16進数表記なので2文字で1Bを表します。
前者では32B目が"になっているので先頭64文字、後者では33B目以降が1337i"なので先頭64文字を落としたものを組み合わせた暗号文を作成します

33c7461caec455639a2c78889df87b2b787ed5c63954b411c12f2306190bb676063254e779270e657f7ffba29dcd4af2f36e1309837f85a8ef0b81de1e9a6bb2

後は作成した暗号文をimportさせて_secret()を呼べばflag

ctf4b{yeah_you_are_a_member_of_imaginary_number_club}