好奇心の足跡

飽きっぽくすぐ他のことをしてしまうので、忘れないため・形にして頭に残すための備忘録。

Man-in-the-middle on SECP384R1 (Crypto, Programming)

SECCON 2017 online CTF の問題がGitHubで公開されたので、これを後追いでやってみた記事になります。
後追い記事の一覧はこちら
SECCON 2017 online CTF を後追いでみっちりやってみよう!

問題

Man-in-the-middle on SECP384R1
Steal the conversation between dev0 and dev1.
Host : mitm.pwn.seccon.jp
Port : 8000

sample code for MITM is below.
exploit-5b67f6c7a3a7b84c8e36252773a69e9ce9818f599c63583bde50cce2301f7286.py

exploit-(略).py はpythonスクリプトへのリンク

exploit-(略).py

#!/usr/bin/python3

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
host = ""
s.connect((host, 8000))

s.recv(len("[dev0 to dev1]:"))
data = s.recv(120)
## todo
# s.send(payload)
data = s.recv(len("\n[dev1 to dev0]: OK\n"))

data = s.recv(len("[dev1 to dev0]:"))
data = s.recv(120)
## todo
# s.send(payload)
s.recv(len("\n[dev0 to dev1]: OK\n"))

data = s.recv(len("[KBKDF: SHA256, Encryption: AES]\n"))
## derive keys

data = s.recv(len("[dev0 to dev1]:"))
data = s.recv(256)

## todo
# mitm
# s.send(ct)

data = s.recv(len("\n[dev1 to dev0]: OK\n"))
data = s.recv(len("[dev1 to dev0]:"))

data = s.recv(256)
## todo
# decrypt

事前調査

問題文中の環境にアクセスしてみる

$ curl http://mitm.pwn.seccon.jp:8000
curl: (6) Could not resolve host: mitm.pwn.seccon.jp

むむ

$ nc mitm.pwn.seccon.jp 8000
nc: getaddrinfo: nodename nor servname provided, or not known

おや。どうやら既に競技用の環境は閉じられてしまったようだ(2018/2/4現在)

環境構築

ということで、SECCONのgithubに置いてある環境構築用ディレクトリのbuildを使って環境を構築してみます。

docker-composeというのを使うので、入っていない場合は下記を参考にinstall
Qiita/Docker Compose - インストール @zembutsu

docker-composeはyamlのversionが2なので、1.10以上をinstallすることに注意。
上記の手順通りだと古いバージョンで対応していないので実行時エラーになる。

あとは

$ cd build
$ ./run.sh

でサーバーが稼働。localhostのport 8000にアクセスしてみると

$ nc localhost 8000
xX????'?q?y٬?.?#??@J!ټ?Wz3.(??????ϼ??<c   e???a?^?ed?A?eK
                                   돡??gc~?h?K5J\?
[dev1 to dev0]: OK
[dev1 to dev0]:0v0*?H?=+?"b

                           Yly??D?L????KH???z:?z?B"(&?iT?'?????;?'?[??}???~?ْ??f?V?9E?[LY}???g(!J???    ??Q?b??
[dev0 to dev1]: OK
[KBKDF: SHA256, Encryption: AES]
[dev0 to dev1]:Rܓ???J?A?B  ???O?M?7e?#??3l??r? @6Vp?6??SES??0?ʕ???͌v?.CXJt
}??h??t???dI?s?;?~?gy?9O???Ύ??E??o??w?YuǤբ??u??Ь4k?;h?_??$q?̔??cߎ??ud??   ?[е??S??m?j@?`?g?L?
&??A??V??j)/I??rTڷ?fa?2?'6???IO⇉???>???!?
             y1?@^|????
[dev1 to dev0]: OK
[dev1 to dev0]:???褖???<nMM~???M?}?O[I????~?
                                            ?????6?_??β?j?????I?
                                                                ??13*?Q8?۸O?=?????Q?i????d?~??ϫEd??????֮A?-??)Ӆ??-+?.1??L֙?
?;??    Yb??;L~
??y?????EsE?u???[?><G?>M⍅????????V?xsP?y?v?xH

ってな感じで、バケバケだが問題文と関連ありそうな情報(dev0 and dev1の会話っぽいの)が取得できる。簡単!素晴らしい!

解法

問題文からして、中間者攻撃で通信をのぞくのがミッション。
恒例の問題文でググる。今回はこの単語がキーになりそう。
SECP384R1

IPA様のサイトに概要からSECP384R1の説明まで詳しく、かつ易しく書いてあった。
どうやら、RSA暗号方式と同程度の強度を持つECC暗号(楕円曲線暗号)方式の一種らしい。
暗号界では常識なのであろう。
なにやら最近特に話題のBitCoinでも使われているらしい。
他、ヒットしたサイトなどを眺めると、opensslコマンドで鍵を作ったり覗いたりということができるようだ。

SECP384R1脆弱性・攻撃手法がないかざっと調べてみたが、特にヒットしなかったので配布されたサンプルコードを見てみることに。

s.recv(len("[dev0 to dev1]:"))
data = s.recv(120)

こんな感じで、特定の文字列をとり、その後に続くbufferを一定のサイズずつ読んでいるようなコードが何回か続く。
特定の文字列、は上記でlocalhost:8000にアクセスしたときに得られたやり取りと完全に一致している。

[dev0 to dev1]: xxxxxx
[dev1 to dev0]: OK
[dev1 to dev0]: xxxxxx
[dev0 to dev1]: OK
[KBKDF: SHA256, Encryption: AES]
[dev0 to dev1]: xxxxxx
[dev1 to dev0]: OK
[dev1 to dev0]: xxxxxx

更に、そのやりとりの間に、コメントで

## todo
# xxx (s.send(payload), drive keys, mitm, s.sendct())

的なのが書いてあり、何かここで処理をはさめ、的なメッセージだと受け取れる。 試しに、サンプルコードのはじめの#todo部分で適当なデータを送ってみたら、次の応答でNGが返って来ることが確認できた。

import socket # 追加
~ 略 ~

host = "localhost" # 今回は自分のマシンで立てた環境が対象なのでlocalhost指定
~ 略 ~

## todo
# s.send(payload)
# 適当なデータを送信
s.send("payload")
# 次の通信内容
[dev1 to dev0]: NG

これだけでは何をしてよいのかさっぱりなので、まずは中間者攻撃の方法のフローや概要を調査してみる。 参考にしたサイトたち:

上記2つが入門編レベルで図解あり、非常に分かりやすかった。
今回のケース(上のやり取りに番号をつけた)だと、通信の回数が最小限なので

  1. dev0 -> dev1  # dev0の公開鍵送信
  2. dev1 -> dev0  # 1. のvalidation結果
  3. dev1 -> dev0  # dev1の公開鍵送信
  4. dev0 -> dev1  # 3. のvalication結果
  5. ここから共通鍵を使って通信開始
  6. dev0 -> dev1  # 共通鍵で暗号化されたメッセージ
  7. dev1 -> dev0  # 7. のvalication結果
  8. dev1 -> dev0  # 共通鍵で暗号化されたメッセージ(おそらくフラグ)

となっているだろう、と推測できる。
本来なら、それぞれの通信を中継するような手段が必要なのだが、今回のサンプルスクリプトを見ると、あとから送ることで送った情報の上書き的な扱いになるようだ。
ということで、やることは下記。

  1. 中間者攻撃用に、自分の鍵ペアを作成しておく
  2. dev0の公開鍵送信と思われる通信
    • dev0の通信を確認し、保存しておく
    • この通信は公開鍵なのか?を確認
    • dev1に自分の公開鍵を送りつける
  3. OKで返ってきたら、ここまでの仮説が合っていそう
  4. dev1の公開鍵送信と思われる通信
    • dev1の通信を確認し、保存しておく
    • この通信は公開鍵なのか?を確認
    • dev0に自分の公開鍵を送りつける
  5. OKで返ってきたら、ここまでの仮説が合っていそう
  6. 以下で使用する共通鍵を計算
  7. dev0からの、dev0との共通鍵で暗号化されているらしきメッセージ
    • dev0のメッセージを、5. で推測したdev0との共通鍵で復号してみる
    • 復号したメッセージを、今度はdev1との共通鍵で暗号化してdev1へ送りつける
  8. OKで返ってきたら、ここまでの仮説が合っていそう
  9. dev1からの共通鍵で暗号化されたフラグと思われる通信
    • dev1との共通鍵で復号、フラグっぽいのが出てくるはず!

dev0の公開鍵を確認・保存、dev1に自分の公開鍵を送りつける

まずは1.の確認。 exploit.py

s.recv(len("[dev0 to dev1]:")) # 既存行
data = s.recv(120) # 既存行

with open('dev0.pub', 'wb') as f:
    f.write(data)

opensslコマンドで、データが公開鍵かを検証。 今回は楕円曲線暗号のはずなので、それ用のコマンドを使用。

$ openssl ec -pubin -in dev0.pub -text
read EC key
unable to load Key
6189:error:0906D06C:PEM routines:PEM_read_bio:no start line:/BuildRoot/Library/Caches/com.apple.xbs/Sources/OpenSSL098/OpenSSL098-64.50.7/src/crypto/pem/pem_lib.c:648:Expecting: PUBLIC KEY

あれー!違うのかー!!!読めないかー!
公開鍵以外が送られてきているとすると見当違いのことをしていることになるので不安・・・
いやまて、いままでのRSAのときもあるあるだった、フォーマット違いかもしれない。
pemじゃなくてder形式かも・・・!

$ openssl ec -inform der -pubin -in dev0.pub -text
read EC key
pub:
    04:1f:68:84:e4:da:fc:a8:f5:df:99:91:81:ec:82:
    b9:41:29:59:b9:2f:f3:76:29:2f:42:d5:be:99:4d:
    29:aa:96:cb:d7:9c:bb:81:40:77:ed:2e:22:08:ee:
    c5:97:97:b4:de:8c:8d:49:17:f8:f4:c2:47:11:7a:
    8e:da:69:d1:05:dc:90:30:bf:7a:fb:a7:5d:8f:eb:
    9d:59:9c:9f:1e:90:c6:d7:f7:fb:8d:14:61:24:11:
    8a:9f:64:0e:d4:6f:46
ASN1 OID: secp384r1
writing EC key
-----BEGIN PUBLIC KEY-----
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEH2iE5Nr8qPXfmZGB7IK5QSlZuS/zdikv
QtW+mU0pqpbL15y7gUB37S4iCO7Fl5e03oyNSRf49MJHEXqO2mnRBdyQML96+6dd
j+udWZyfHpDG1/f7jRRhJBGKn2QO1G9G
-----END PUBLIC KEY-----

おおおー読めた!よかった。ここでちょっと詰まった。
明日以降の自分が間違えないように dev0.pub.der にリネームしておこう。

次は、dev1に自分の公開鍵を送るために、鍵ペアを作成する。
問題文にもあるSECP384R1曲線と、der形式で作ることに注意。

$ openssl ecparam -list_curves
略。対応曲線に`secp384r1`があることを確認。
$ openssl ecparam -name secp384r1 -genkey > my.key
$ openssl ec -in my.key -pubout -outform der -out my.pub.der

ここで作った鍵を、最初の#todo ##s.send(payload)の箇所で送ってみる。

with open('my.pub.der', 'rb') as f:
    my_pubkey = f.read()
s.send(my_pubkey)
[dev1 to dev0]: OK

お、OKが返ってきた!!!1,2. の過程は合っていそう!

dev1の公開鍵を確認・保存、dev1に自分の公開鍵を送りつ受ける

上記と同じことをdev1 -> dev0の通信でも実施。
dev1からの通信内容をdev1.pub.derとして吐き出し、中身を確認。

$ openssl ec -inform der -pubin -in dev1.pub.der -text
read EC key
pub:
    04:cb:7f:93:f1:35:c2:43:33:3b:14:c8:71:99:6b:
    c0:f9:08:d1:75:17:c8:9f:21:05:c6:08:b3:80:7d:
    91:31:97:3b:74:f9:d2:8b:2c:53:bc:59:53:db:17:
    b9:51:c4:24:fd:62:68:e0:46:37:1e:1f:4a:d6:c7:
    76:c5:91:88:af:52:ea:fa:9b:1d:4a:6d:0b:a9:21:
    e7:63:74:58:3e:dc:15:f5:67:3b:10:67:a2:3b:ce:
    06:a2:57:32:79:34:bd
ASN1 OID: secp384r1
writing EC key
-----BEGIN PUBLIC KEY-----
MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEy3+T8TXCQzM7FMhxmWvA+QjRdRfInyEF
xgizgH2RMZc7dPnSiyxTvFlT2xe5UcQk/WJo4EY3Hh9K1sd2xZGIr1Lq+psdSm0L
qSHnY3RYPtwV9Wc7EGeiO84GolcyeTS9
-----END PUBLIC KEY-----

dev1 -> dev0の通信内容も、公開鍵だった模様。
dev0にもmy.pubkeyを送りつけると、OKが返ってくることを確認。

共通鍵を割り出す

ここから共通鍵で暗号化された通信が始まっていると思われる。
ヒントは KBKDF: SHA256, Encryption: AES
楕円曲線暗号の共通鍵について調べてみる。

共通鍵をどうやって計算するのか調査

Wikipediaの説明に合わせて、A,Bのやり取りにおいて、それぞれの秘密鍵 d(A),d(B)に対して、公開鍵Q(A),Q(B)、ベースポイントをGとすると、

(x(k),y(k)) = d(A)Q(B) = d(A)d(B)G = d(B)d(A)G = d(B)Q(A)

となり、A,Bともに同じ座標を計算できるので、このx(k)を共有鍵とするらしい。

dev1さんは私の公開鍵に対して上記を計算するはずなので、

d(dev1)Q(my.pub) = d(my.key)Q(dev1)

となり、共通鍵が計算できそう。 同様にdev0さんも下記で計算できそう。

d(dev0)Q(my.pub) = d(my.key)Q(dev0)

計算できそう、ということは何某かのライブラリがあるはず。

楕円曲線暗号用ライブラリ探し

ここで、opensslコマンドで自分の鍵ペアは作ったものの、暗号化や復号しないといけないことに気づく。
楕円曲線暗号をアレコレするための良さげなライブラリを探してみる。

Elliptic Curve Cryptographyecc, python library で検索したり、github検索したりすると、たくさんのライブラリがヒットした。
こんなにあるとどれを使ってよいか選ぶのがめんどくさい・・・
とりあえず色々対応してそうなCryptographyを使ってみることに。 ※他のwrite-upを見ても、Cryptography使っているのが多かった。もともと入れている人が多いのかな。
Cryptographyの楕円曲線暗号に関する関数はこちら

その中の鍵交換の関数
を参考に、鍵を割り出してみる。

ライブラリの機能を使って共通鍵計算

とにかく計算してみた。ここまでのコードは下記。
各フローの説明はコード中に。
共通鍵の作成までだと何も結果を確かめられないので、その後の鍵の復号・暗号化まで一気に実装。

#!/usr/bin/python3

import socket
import os
import hashlib
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import *
from cryptography.hazmat.primitives.asymmetric import ec
from Crypto.Cipher import AES

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
host = "localhost"
s.connect((host, 8000))


## read and check defined conversation
def read_defined_conversation(defined):
    data = s.recv(len(defined))
    print(data.decode('utf-8'))
    if (data.decode('utf-8') != defined):
        print("[ERROR] fail to receive: " + defined)
        exit()


##################
### main logic ###
##################
my_pubkey = None
my_key = None


## receive dev0's message(publickey)
read_defined_conversation("[dev0 to dev1]:")
data = s.recv(120)

## output dev0's publickey
with open('dev0.pub.der', 'wb') as f:
    f.write(data)
dev0_pubkey = data

## send my publickey to dev1 (format = der)
with open('my.pub.der', 'rb') as f:
    my_pubkey = f.read()
s.send(my_pubkey)

## receive dev1's response
read_defined_conversation("\n[dev1 to dev0]: OK\n")


## receive dev1's message(publickey)
read_defined_conversation("[dev1 to dev0]:")
data = s.recv(120)

## output dev1's publickey
with open('dev1.pub.der', 'wb') as f:
    f.write(data)
dev1_pubkey = data

## send my publickey to dev0 (format = der)
s.send(my_pubkey)

## receive dev0's response
read_defined_conversation("\n[dev0 to dev1]: OK\n")


read_defined_conversation("[KBKDF: SHA256, Encryption: AES]\n")
## generate shared key for dev0 and dev1
with open('my.key', 'rb') as f:
    my_key = f.read()
c_my_key = load_pem_private_key(my_key, password=None, backend=default_backend())
# print(isinstance(c_my_key, EllipticCurvePrivateKey)) # True
c_dev1_pubkey = load_der_public_key(dev1_pubkey, backend=default_backend())
# print(isinstance(c_dev1_pubkey, EllipticCurvePublicKey)) # True
c_dev0_pubkey = load_der_public_key(dev0_pubkey, backend=default_backend())
# print(isinstance(c_dev0_pubkey, EllipticCurvePublicKey)) # True

shared_key_dev1 = c_my_key.exchange(ec.ECDH(), c_dev1_pubkey)
shared_key_dev0 = c_my_key.exchange(ec.ECDH(), c_dev0_pubkey)

digest_dev1 = hashlib.sha256(shared_key_dev1).digest()
digest_dev0 = hashlib.sha256(shared_key_dev0).digest()

# TODO ivが不明
aes_mode = AES.MODE_CBC # modeも与えられていないが、違うmodeだと"NG"が返ってくるのでCBCと判明
iv = os.urandom(16)
print("iv: " + str(iv))


## receive dev0's message
read_defined_conversation("[dev0 to dev1]:")
data = s.recv(256)

## decrypt dev0's message with guessed shared key
aes_dev0 = AES.new(digest_dev0, aes_mode, iv)
pt = aes_dev0.decrypt(data)
print(str(pt))

## mitm, send mitm message to dev1
aes_dev1 = AES.new(digest_dev1, aes_mode, iv)
ct = aes_dev1.encrypt(pt)
s.send(ct)

## receive dev1's response
read_defined_conversation("\n[dev1 to dev0]: OK\n")

## receive dev1's message
read_defined_conversation("[dev1 to dev0]:")
data = s.recv(256)

## decrypt
aes_dev1 = AES.new(digest_dev1, aes_mode, iv)
flag = aes_dev1.decrypt(data)
print(str(flag))

実行結果

$ python exploit.py
[dev0 to dev1]:

[dev1 to dev0]: OK

[dev1 to dev0]:

[dev0 to dev1]: OK

[KBKDF: SHA256, Encryption: AES]

iv: b'\x97\xb2\xb3\xc8\xffT\x9e\x1a\xa7\xe1\x81\xf8N+8\xe2'
[dev0 to dev1]:
b'\xd3\xe7\xf0\x8c\xef\t\xcbY\xe4\xb0\xd6\xadN+8\xe2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'

[dev1 to dev0]: OK

[dev1 to dev0]:
b'\xf4\xc7\xc0\xbb\x80*\xd5l\xd1\x97\xf7\x8e8]N\x94FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFCB3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875AC656398D8A2ED19D2A85C8EDD3EC2AEF00000000000000000000000000000000000000000000000000000000}'

AES-CBC復号・暗号化

スクリプトは上記。
PyCryptのAESを初めて使ってみたのだけども、使い方を知らなくてハマった。
AESオブジェクトについて、同じ設定のものは使いまわせると思っていたが、どうやらオブジェクトに状態を持っているようで使うたびにnewする必要があるようだ。
これのせいで最終結果が正しくDecryptされず。毎回異なる結果になっていたので、初期化周りを疑うことができて気づけた。
参考:(公式ページの説明が見つけられず・・・)

処理自体はこのあたりを参考にしました。AESを使った暗号化・復号化 with python * Qiita/Pythonで暗号化と復号化 * PyCrypt.org/Module AES(公式APIドキュメント)

また、コード中に#TODOとあるように、AESのmode, ivが特に与えられていない。ヒントもないようにみえる。
modeについては他のmodeを試すとdev1からNGが返ってくるので、どうやらCBCらしいということがわかった。
ivについてはどうしたらよいの?ということで、まずはivについて調べてみる。

初期ベクトル(iv)とは

iv(初期化ベクトル・初期ベクトル)について、Wikipediaから引用すると

初期化ベクトル(英: initialization vector、IV)はビット列であり、ストリーム暗号またはブロック暗号を任意の暗号利用モードで実行するとき、同じ暗号鍵でストリームを生成しても毎回異なるストリームを生成するのに必要とされる。これにより、毎回暗号鍵を替えるといった時間のかかる作業を省くことができる。

IVの大きさは使用する暗号化アルゴリズムと暗号プロトコルに依存し、通常は暗号のブロックサイズと同じか暗号鍵と同じサイズである。IVは受信者がその暗号を解読する際に必須である。IVを渡す方法としては、鍵交換やハンドシェイクの際に合意した上でパケットと共にIVを送るか、IVの計算方法を共有しておくか、現在時間のようなインクリメンタルな測定値(RSA SecurID、VASCO Digipassなどのハードウェア認証トークン)や、送信者または受信者のID、ファイルID、パケット、セクタ番号やクラスタ番号などをパラメータとして使う。

だそうだ。
鍵交換の際は、他の情報が含まれていないことは確認済み。その他の方法もプロトコルがわかっていないと難しい。
ランダムに生成したものだとしたら直接推測する手段がないので

  1. 何らかの法則で可能性の有りそうなものを入れていく(つまり当てずっぽう)
  2. 最後の通信がflag(SECCON{****})であることを信じて、そこからivを推測

このあたりを試すことになる。
AESにおける初期ベクトルについて下記が参考になった

AESの場合の、更にCBC-modeの暗号化は

一つ前の暗号ブロックと平文ブロックのXORを取ってから暗号化を行う。

そうだ。更に、この場合の初期ベクトルは

  • 1ブロック前の平文ブロックを使用するが、最初の平文ブロックの前にはブロックは存在しない。
  • 最初の1ブロック分のみ、代わりのものを用意する。
  • 初期ベクトル(initialization vector)と呼ばれ、一般的にIVと呼ばれる。

との説明の通り。初期ベクトルならぬ初期ブロック的なものになるようだ。

iv推測

なんか既知平文攻撃的な手法がありそうな気がするが、見つからなかったので、
最初の7文字しかわからないけど 2. の方法を選択。
最後の通信が SECCON{***** となることを前提として、ivをブルートフォースで見つけていく。
※そもそもcandidatesがalphabet+number+ちょっと記号、だけで良いのかという問題は大いにある
下記のようにスクリプトを組み替えて実行。

#!/usr/bin/python3

import socket
import os
import hashlib
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import *
from cryptography.hazmat.primitives.asymmetric import ec
from Crypto.Cipher import AES

host = "localhost"

## read and check defined conversation
def read_defined_conversation(s, defined):
    data = s.recv(len(defined))
    # print(data.decode('utf-8'))
    if (data.decode('utf-8') != defined):
        print("[ERROR] fail to receive: " + defined)
        exit()

def try_mitm(iv):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((host, 8000))

    ## receive dev0's message(publickey)
    read_defined_conversation(s, "[dev0 to dev1]:")
    data = s.recv(120)

    ## output dev0's publickey
    dev0_pubkey = data

    ## send my publickey to dev1 (format = der)
    s.send(my_pubkey)

    ## receive dev1's response
    read_defined_conversation(s, "\n[dev1 to dev0]: OK\n")


    ## receive dev1's message(publickey)
    read_defined_conversation(s, "[dev1 to dev0]:")
    data = s.recv(120)

    ## output dev1's publickey
    dev1_pubkey = data

    ## send my publickey to dev0 (format = der)
    s.send(my_pubkey)

    ## receive dev0's response
    read_defined_conversation(s, "\n[dev0 to dev1]: OK\n")


    read_defined_conversation(s, "[KBKDF: SHA256, Encryption: AES]\n")
    ## generate shared key for dev0 and dev1
    c_my_key = load_pem_private_key(my_key, password=None, backend=default_backend())
    c_dev1_pubkey = load_der_public_key(dev1_pubkey, backend=default_backend())
    c_dev0_pubkey = load_der_public_key(dev0_pubkey, backend=default_backend())

    shared_key_dev1 = c_my_key.exchange(ec.ECDH(), c_dev1_pubkey)
    shared_key_dev0 = c_my_key.exchange(ec.ECDH(), c_dev0_pubkey)

    digest_dev1 = hashlib.sha256(shared_key_dev1).digest()
    digest_dev0 = hashlib.sha256(shared_key_dev0).digest()

    aes_mode = AES.MODE_CBC


    ## receive dev0's message
    read_defined_conversation(s, "[dev0 to dev1]:")
    data = s.recv(256)

    ## decrypt dev0's message with guessed shared key
    aes_dev0 = AES.new(digest_dev0, aes_mode, iv)
    pt = aes_dev0.decrypt(data)
    #print("pt: " + str(pt))

    ## mitm, send mitm message to dev1
    aes_dev1 = AES.new(digest_dev1, aes_mode, iv)
    ct = aes_dev1.encrypt(pt)
    s.send(ct)

    ## receive dev1's response
    read_defined_conversation(s, "\n[dev1 to dev0]: OK\n")

    ## receive dev1's message
    read_defined_conversation(s, "[dev1 to dev0]:")
    data = s.recv(256)

    ## decrypt
    aes_dev1 = AES.new(digest_dev1, aes_mode, iv)
    flag = aes_dev1.decrypt(data)
    # print("flag: " + str(flag))

    s.close()

    return flag

##################
### main logic ###
##################
my_pubkey = None
my_key = None

with open('my.pub.der', 'rb') as f:
    my_pubkey = f.read()

with open('my.key', 'rb') as f:
    my_key = f.read()

candidates = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz_{}"
prefix = "SECCON{"

iv = "A" * 16
for i, p in enumerate(prefix):
    for c in candidates:
        print("------------[i=" + str(i) + ", c=" + c +"]------------")
        iv = iv[0:i] + c + "A"*(15 - i)
        print("iv: " + str(iv))
        answer = try_mitm(iv).decode('utf-8')
        print(answer)
        if answer[i] == p:
            break

力技。
はじめのスクリプトをまるっと関数にして、外からivを変えて呼び出す。
socketは毎回connectし直さないとはじめからやってくれないので、毎回作る。
それでも先頭から順に処理するロジックで解けるので、一文字ずつのブルートフォースになっており比較的早く結果が出る。

実行結果

$ python exploit.py
------------[i=0, c=A]------------
iv: AAAAAAAAAAAAAAAA
"422>?
777777777FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF000000
0000000000FFFFFFFCB3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013
875AC656398D8A2ED19D2A85C8EDD3EC2AEF000000000000000000000000000000000000000000
00000000000000}
------------[i=0, c=B]------------
iv: BAAAAAAAAAAAAAAA
!422>?
777777777FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFCB3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875AC656398D8A2ED19D2A85C8EDD3EC2AEF00000000000000000000000000000000000000000000000000000000}

~中略~

------------[i=6, c=Z]------------
iv: 000000ZAAAAAAAAA
SECCON^Q777777777FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFCB3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875AC656398D8A2ED19D2A85C8EDD3EC2AEF00000000000000000000000000000000000000000000000000000000}
------------[i=6, c=0]------------
iv: 0000000AAAAAAAAA
SECCON{777777777FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFCB3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875AC656398D8A2ED19D2A85C8EDD3EC2AEF00000000000000000000000000000000000000000000000000000000}

ということで、最終的にivは7文字目まで0000000であることがわかった。
ここからは 1. のやり方(つまり当てずっぽう)で、全部"0"であろうと予想。
iv = "0"*16
と設定して、もともスクリプトを実行すると、flagが得られた。

b'SECCON{FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFF0000000000000000FFFFFFFCB3312FA7E23EE7E4988E056BE3F82D19181D9C6EFE8141120314088F5013875AC656398D8A2ED19D2A85C8EDD3EC2AEF00000000000000000000000000000000000000000000000000000000}'

※これを見ただけでは合っているかどうか怪しい・・・

感想

  • これは絶対競技中に解けなかった。手を出さなくて正解・・・!
  • とくにivがわからないのに解けるの?とか、PyCryptoの使い方とか、ハマりどころ満載だった
  • 結局最後のivは、当てずっぽう要素が入ってしまっている。ちゃんと解くやり方があるのだろうか?