0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SECCON14 quals: Nostalgic Upsolve

Posted at

Nostalgic

authored by kanon

On a rainy afternoon after school, a girl quietly gazed out the classroom window. When a rainbow appeared in the sky, she wished for her feelings to find their way to their destination.

from Crypto.Cipher import ChaCha20_Poly1305
from Crypto.Random import get_random_bytes
import os


def xor(a, b):
    return bytes(x ^ y for x, y in zip(a, b))


FLAG = os.getenv("FLAG", "flag{dummy}")

key = get_random_bytes(32)
nonce = get_random_bytes(12)
SPECIAL_MIND = get_random_bytes(16)

print(f"my SPECIAL_MIND is {SPECIAL_MIND.hex()}")


def enc(plaintext=None):
    if plaintext == None:
        plaintext = get_random_bytes(15)
    cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce)
    ct, tag = cipher.encrypt_and_digest(plaintext)
    return ct, tag


special_rain = get_random_bytes(16)
special_ct, special_tag = enc(plaintext=special_rain)

print(f"special_rain_enc = {special_ct.hex()}")
print(f"special_rain_tag = {special_tag.hex()}")

while True:

    if (inp := input("what is your mind: ")) != "need":
        if enc(plaintext=xor(special_rain, bytes.fromhex(inp)))[1] == SPECIAL_MIND:
            print(f"I feel the same!!.. The flag is {FLAG}")
        else:
            print("No... not the same...")
        break
    else:
        print(f"my MIND was {enc(plaintext=None)[1].hex()}")

同じkeyとnonceを使いまわしているChaCha20-Poly1305の問題です。
keynonceをランダムに1回生成を行い、SPECIAL_MINDが公開されます。
さらに、special_rainを暗号化し、暗号文special_ctとtagspecial_tagの2つが公開されます。

その後、我々は2つの選択肢を選択できます。

  1. special_rainと任意の入力inpのXORしたものを暗号化したときのtagがSPECIAL_MINDと一致するか
  2. ランダム15bytesのplaintextを暗号化し、そのtagだけ返却する
    我々はこの1番をクリアすることでフラグを得ることができます。

解法

key, nanceを使いまわることで何が問題になるのか

まず、ライブラリのコードを見てみましょう。下記URLのコード(218行)には以下のように書かれています。
https://github.com/Legrandin/pycryptodome/blob/master/lib/Crypto/Cipher/ChaCha20.py

def _derive_Poly1305_key_pair(key, nonce):
    """Derive a tuple (r, s, nonce) for a Poly1305 MAC.

    If nonce is ``None``, a new 12-byte nonce is generated.
    """

    if len(key) != 32:
        raise ValueError("Poly1305 with ChaCha20 requires a 32-byte key")

    if nonce is None:
        padded_nonce = nonce = get_random_bytes(12)
    elif len(nonce) == 8:
        # See RFC7538, 2.6: [...] ChaCha20 as specified here requires a 96-bit
        # nonce.  So if the provided nonce is only 64-bit, then the first 32
        # bits of the nonce will be set to a constant number.
        # This will usually be zero, but for protocols with multiple senders it may be
        # different for each sender, but should be the same for all
        # invocations of the function with the same key by a particular
        # sender.
        padded_nonce = b'\x00\x00\x00\x00' + nonce
    elif len(nonce) == 12:
        padded_nonce = nonce
    else:
        raise ValueError("Poly1305 with ChaCha20 requires an 8- or 12-byte nonce")

    rs = new(key=key, nonce=padded_nonce).encrypt(b'\x00' * 32)
    return rs[:16], rs[16:], nonce

今回key, nonceは固定です。
encrypt(b'\x00' * 32)から、先頭16ビットが$r$となり、後半16ビットが$s$となります。
このnewはどのようにして作成するのでしょうか?
下記のRFCを見てみましょう。

2.4.1. The ChaCha20 Encryption Algorithm in Pseudocodeによると、

key_stream = chacha20_block(key, counter+j, nonce)
block = plaintext[(j*64)..(j*64+63)]
encrypted_message +=  block ^ key_strea

によって暗号文が作成されるそうです。
つまり、key_streamと平文ブロックPのXORであり、
$$
encrypt(P) = P \oplus \text{key_stream}
$$
と表せるのと、今回はencrypt(b'\x00' * 32)から
$$
encrypt(0^{32}) = \text{key_stream}
$$
となり、そしてkey_streamkeynonceに依存するかつ、counterは0のままなので、今回の$r$、$s$は一意に決まることがわかります。

タグの作成

先ほどの話から、$r,s$が一意であるため、復元することができればうまく動作します。
print(f"my MIND was {enc(plaintext=None)[1].hex()}")という処理があることから、もう少し、tagの情報を追ってみましょう。
下記コードによると、macタグは以下のように作成されています。
https://github.com/Legrandin/pycryptodome/blob/master/lib/Crypto/Cipher/ChaCha20_Poly1305.py

    def _compute_mac(self):
        """Finalize the cipher (if not done already) and return the MAC."""

        if self._mac_tag:
            assert(self._status == _CipherStatus.PROCESSING_DONE)
            return self._mac_tag

        assert(self._status != _CipherStatus.PROCESSING_DONE)

        if self._status == _CipherStatus.PROCESSING_AUTH_DATA:
            self._pad_aad()

        if self._len_ct & 0x0F:
            self._authenticator.update(b'\x00' * (16 - (self._len_ct & 0x0F)))

        self._status = _CipherStatus.PROCESSING_DONE

        self._authenticator.update(long_to_bytes(self._len_aad, 8)[::-1])
        self._authenticator.update(long_to_bytes(self._len_ct, 8)[::-1])
        self._mac_tag = self._authenticator.digest()
        return self._mac_tag

ただし、AADはないので以降の説明では省きます。
コードと先ほど添付したRFCから
$$
\mathrm{data} = C\ |\ \mathrm{pad16}(C)\ |\ \mathrm{le64}(0)\ |\ \mathrm{le64}(|C|)
$$
と表すことができます。ここで、leはlittle-endianの略語として用いています。

さて、poly.cの中身を見てみましょう。
https://github.com/Legrandin/pycryptodome/blob/master/src/poly1305.c

ここで、Poly1305更新式は以下のように説明されています。

* This procedure performs the following operation (assuming that msg is 16 byte long):
*
*     h = r * (h + (2^128 + little_endian_int(msg))) quasi-modulo 2^130-5
*
* Quasi-modulo means that the computations are performed modulo 2^130-5 but the
* result is still only guaranteed to be smaller than 2^131.
*
* @param[in,out] h:    The 5-word variable to accumulate into.
*                      In input, it must be smaller than 2^131.
*                      In output, it is guranteed to remain smaller than 2^131.
* @param[in] r:        The 4-word array with the multiplier, as generated by
*                      poly1305_load_r()
* @param[in] rr:       The 4-word array with the other value generated by
*                      poly1305__load_r() for the same multipler.
* @param[in]  data:    The next chunk of message, at most 16 bytes. It is
*                      smaller than 16 only if it is the last chunk.
* @param[in]  len:     The length of chunk (<=16)

ここで、$p = 2^{130}-5$、$m=2^{128}$とすると、
$$
h_n \equiv r*(h_{n-1} + (m + leint(msg)) \pmod p
$$
となります。以降、便宜上$(m + leint(B_1))$を$n$とします。
また、poly1305_accumulate(h, s)より、tag:t
$$
t \equiv (h + s) \pmod m
$$
としています。
今回の問題コードからneedを送ると$|C| = 15$となるため、先ほどのdataの中身を考えると、前半$B_0$は
$$
B_0 = C |\ pad16(C) = C |\ 0x00
$$
となり、後半は
$$
B_1 = le64(0) |\ le64(|C|) = le64(0) |\ le64(15)
$$
と表せます。
つまり、

\begin{align}
h_0 \equiv r*(h + n_0) \pmod p\\
h_1 \equiv r*(h + n_1) \pmod p
\end{align}

となります。

1ブロック目は
$$
h_0 \equiv r*(h_{-1} + n_0) \equiv n_0r\pmod p
$$
となり、2ブロック目は
$$
h_1 \equiv r*(h_0 + n_1) \equiv (n_0r + n_1)r \equiv n_0r^2 + n_1r \pmod p
$$
となります。
これを
$$
t \equiv (h + s) \pmod m
$$
に戻すと、
$$
t \equiv (h_1 + s) \equiv ((n_0r^2 + n_1r \pmod p) + s) \pmod m
$$
となり、タグを求めることができました。
さて、ここで、$h_1$のmod pを外してみましょう。$u := n_0r^2 + n_1r \pmod p $とすると、
$$
u = n_0r^2 + n_1r - kp
$$
と表すことができます(ただし、$k \in \mathbb{Z}$)。
同様にmod mを外してみましょう。そうして、
$$
t = u + s - lm
$$
となるでしょう(ただし、$l \in \mathbb{Z}$)。
そして、この2式から
$$
t = n_0r^2 + n_1r - kp + s - lm
$$
と変形することができます。さて、ここで$i$番目と$i+1$番目の$t$を考えましょう。すると、
$$
t_{i+1} - t{i} = (n_{0,{i+1}} - n_{0,i})r^2 + (n_{1,{i+1}} - n_{1,i})r - (k_{i+1} - k_i)p - (l_{i+1} - l_i)m
$$
となり、$s$が消えます。もっと言うと、$n_{1,{i+1}} - n_{1,i}$はneedで得られるケースでは、長さが一定のため0となります。よって、
$$
t_{i+1} - t{i} = (n_{0,{i+1}} - n_{0,i})r^2 - (k_{i+1} - k_i)p - (l_{i+1} - l_i)m
$$
となります。

さて、上記の式を再度mod pにしましょう。ただし、便宜上、$\Delta t_i := t_{i+1} - t{i}, \Delta n_{0,i} := n_{0,{i+1}} - n_{0,i}, \Delta k_i := k_{i+1} - k_i, \Delta l_i := l_{i+1} - l_i$とします。すると、先ほどの式は
$$
\Delta t_i \equiv \Delta n_{0,i} r^2 - \Delta l_im
$$
という式に変形できます。
つまり、$\Delta l_im$が今回のずれであることがわかります。

lの範囲はどれくらい?

uの定義上、$0 \leq u_i \leq p-1$であることがわかります。
そして、$m = 2^{128}$より、
$$
2^{130} = 2^2 2^{128} = 4m
$$
と表すことができます。そして、$p = 2^{130}-5$より、
$$
p < 4m
$$
という関係がわかります。
同様に、$s$も128ビットなので、$0 \leq s < m$であることがわかります。
以上より、
$$
u + s < 4m+m = 5m
$$
となり、
$$
0 \leq \frac{u+s}{m} < 5
$$
であることから、$l$の値は0~4のいずれかになることがわかります。
最後に$r^2$をLLLを用いて復元すれば、内部状態は復元できたといえるでしょう。

tagをSPECIAL_MINDに合わせる

情報として与えられているspecial_rainは16バイトなので、暗号文も16バイトとなります。
つまり、
$$
u(C) \equiv (m + C)r^2 + n_1r \pmod p
$$
となります。
ここで、$t:= T + im$とし、$dt:= T_{goal} - t$と考え、
$$
u(C') \equiv u(C) + dt \pmod p
$$
を満たせたとします。すると、タグは
$$
T' \equiv u(C') + s \equiv u(C) + dt + s\pmod m
$$
となります。すると、$t = u(C) + s$とみなすことができ、
$T' \equiv t+ dt \equiv T_{goal}$とSPECIAL_MINDと一致することができます。
後は、dtの求め方を考えましょう。

\begin{align}
u(C) \equiv (m + C)r^2 + n_1r \pmod p\\
u(C') \equiv (m + C')r^2 + n_1r \pmod p
\end{align}

より、引き算すると
$$
u(C')- u(C) \equiv (C' - C)r^2 \pmod p
$$
となります。$ (C' - C)r^2 $を$dt$にしたいので、
$$
(C' - C)r^2 \equiv dt \pmod p
$$
となり、両辺を$r^2$で割って
$$
C' \equiv C + \frac{dt}{r^2} \pmod p
$$
となります。
最後に、ChaCha20の暗号化から、
$$
C' = P \oplus (C' + C) \oplus \text{key_stream} = C \oplus (C' + C)
$$
となり、我々が書き換えたい暗号文$C'$として送ることができます。
このようにして、タグを一致され、フラグを得ることができます。

これらを実装したものは以下となります。

from sage.all import *
from pwn import *
from lll_cvp import find_ortho, reduce_mod_p

HOST = "nostalgic.seccon.games"
PORT = 5000
io = remote(HOST, PORT)

def oracle_need():
   io.sendline(b"need")
   io.recvuntil(b"my MIND was ")
   ct = io.recvline().strip().decode()
   return bytes.fromhex(ct)

def xor_bytes(a, b):
   return bytes(x ^ y for x, y in zip(a, b))

def le_bytes_to_int(b):
   return int.from_bytes(b, "little")

def int_to_le_bytes(x, n):
   return int(x).to_bytes(n, "little")

io.recvuntil(b"my SPECIAL_MIND is ")
SPECIAL_MIND = bytes.fromhex(io.recvline().strip().decode())
io.recvuntil(b"special_rain_enc = ")
special_ct = bytes.fromhex(io.recvline().strip().decode())
io.recvuntil(b"special_rain_tag = ")
special_tag = bytes.fromhex(io.recvline().strip().decode())

p = 2**130 - 5
m = 2**128
F = GF(p)

tags = []
for _ in range(256):
   tags.append(le_bytes_to_int(oracle_need()))

dt = []
for i in range(len(tags)-1):
   dt.append(tags[i] - tags[i+1])
vdt = vector(F, dt)
ortho_basis = find_ortho(p, vdt).LLL()
ortho_basis = find_ortho(p, *ortho_basis[:-2])

print("LLL Done")

r2_list = []
for carry_sign in (1, -1):
   carry_direction = carry_sign * ortho_basis[0]
   corrected_dt = vdt + carry_direction * m

   reduced_vecs = reduce_mod_p(matrix(corrected_dt), p)
   smallest_rep = min(reduced_vecs, key=lambda v: v.norm().n())

   for ratio_sign in (1, -1):
       denom = ratio_sign * smallest_rep[0]
       if denom == 0:
           continue

       r2 = F(corrected_dt[0] / denom)
       if not r2.is_square():
           continue
       r2_list.append(r2)


ct_int = le_bytes_to_int(special_ct)
tag_int = le_bytes_to_int(special_tag)
mind_int = le_bytes_to_int(SPECIAL_MIND)

for r2 in r2_list:
   print(f"r2: {r2}")

   for lift in range(4):
       lifted_tag = tag_int + lift * m
       tag_shift = mind_int - lifted_tag

       c_prime_F = F(ct_int) + F(tag_shift) / r2

       c_prime_int = int(c_prime_F)
       if not (0 <= c_prime_int < m):
           continue

       c_prime = int_to_le_bytes(c_prime_int, 16)
       delta = xor_bytes(c_prime, special_ct)
       io.sendline(delta.hex().encode())
io.interactive()

Flag: SECCON{Listening_to_the_murmuring_waves_and_the_capricious_passing_rain_it_feels_like_a_gentle_dream}

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?