Skip to content

Instantly share code, notes, and snippets.

@Lydxn
Last active April 25, 2025 06:55
Show Gist options
  • Save Lydxn/03c1ccf9686014c984b3b9c2258babb6 to your computer and use it in GitHub Desktop.
Save Lydxn/03c1ccf9686014c984b3b9c2258babb6 to your computer and use it in GitHub Desktop.
i cant tell categories

Pesky CBC (crypto, 7 solves)

Description

This new pesky mode of CBC makes decrypting too hard >:(
ncat --ssl peskycbc.atreides.b01lersc.tf 8443

Attachments: pesky_cbc.py

Challenge

In pesky_cbc.py:

import secrets
from Crypto.Cipher import AES

try:
    with open('./flag.txt', 'r') as f:
        flag = f.read()
except:
    flag = 'bctf{REDACTED}'

key1 = secrets.token_bytes(32)
key2 = secrets.token_bytes(32)

def pesky_decrypt(ciphertext):
    assert len(ciphertext) % 16 == 0

    iv1 = secrets.token_bytes(16)
    iv2 = secrets.token_bytes(16)

    c1 = AES.new(key1, AES.MODE_CBC, iv1)
    c2 = AES.new(key2, AES.MODE_CBC, iv2)

    return c1.decrypt(c2.decrypt(ciphertext))

def main():
    cipher = AES.new(key2, AES.MODE_ECB)

    secret = secrets.token_bytes(16)
    ciphertext = cipher.encrypt(secret)

    print('Here is the encrypted secret:')
    print(ciphertext.hex())
    print()

    print('Here are some hints for you ^_^')
    for _ in range(8):
        random_value = secrets.token_bytes(16)
        ciphertext = cipher.encrypt(random_value)
        print(random_value.hex())
        print(ciphertext.hex())
    print()

    while True:
        print('Options:')
        print('1: pesky decrypt')
        print('2: guess secret')
        choice = input('>> ').strip()

        if choice == '1':
            ciphertext = bytes.fromhex(input('>> '))
            print(pesky_decrypt(ciphertext).hex())
        elif choice == '2':
            guess = bytes.fromhex(input('>> '))
            if secret == guess:
                print('Here is your flag :)')
                print(flag)
                return
            else:
                print('lmao skill issue')
                return
        else:
            print('Invalid Choice')
            return

if __name__ == '__main__':
    main()

Overview

This challenge involves attacking a custom CBC decryption oracle. The server generates two 32-byte AES keys $k1$ and $k2$ for encrypting and decrypting. It then generates a random 16-byte secret $s$, and sends $ENC_{k2}(s)$ to the player. The goal is to recover $s$ via the decryption oracle pesky_decrypt(). The oracle is unusual because it applies CBC decryption twice, first with $k2$ and then $k1$, each with a new random $IV$.

The server also provides 8 random plaintext/ciphertext pairs under $ENC_{k2}$.

Part I: Analysis

Recall that in classic CBC decryption, each plaintext block is XORed with the previous ciphertext block:

$$ \begin{align} P_0 = DEC(C_0) \oplus IV \\ P_1 = DEC(C_1) \oplus C_0 \\ P_2 = DEC(C_2) \oplus C_1 \\ P_3 = DEC(C_3) \oplus C_2 \\ \end{align} $$

It gets somewhat more complicated with pesky_decrypt() since CBC is nested twice:

$$ \begin{aligned} P_0 &= DEC_{k1}(DEC_{k2}(C_0) \oplus IV_2) \oplus IV_1 \\ P_1 &= DEC_{k1}(DEC_{k2}(C_1) \oplus C_0) \oplus DEC_{k2}(C_0) \oplus IV_2 \\ P_2 &= DEC_{k1}(DEC_{k2}(C_2) \oplus C_1) \oplus DEC_{k2}(C_1) \oplus C_0 \\ P_3 &= DEC_{k1}(DEC_{k2}(C_3) \oplus C_2) \oplus DEC_{k2}(C_2) \oplus C_1 \\ \end{aligned} $$

Notice that both $P_0$ and $P_1$ get XORed by a completely random and unknown IV, so we probably can't extract any useful information from those blocks. We can also make the following observations:

  • $P_2,P_3,...P_n$ are all exactly the same equation except for which $C_i$ they depend on. We can therefore focus on a single block (e.g. $P_2$) and ignore the rest.
  • Since AES is secure and nothing is known about $k_1$, it is easier to treat $DEC_{k1}$ as a black box function $F$. It simply maps some 16-byte input to another 16-byte output, but we don't know anything else about it.
  • The final XOR with $C_i$ at the end does not provide any extra information, and it can therefore be set to any arbitrary value.

Let's attempt to simplify pesky_decrypt() by defining a new oracle, $D$, that focuses only on $P_2$:

$$ D(C_1, C_2) = F(DEC_{k2}(C_2) \oplus C_1) \oplus DEC_{k2}(C_1) $$

Note that $F$ is the "black-box" function $DEC_{k1}$, and we got rid of $C_0$ by arbitrarly assigning $C_0=0$. The observations above imply that any information obtained by calling pesky_decrypt() directly can also be obtained by just querying $D$. In Python, we can implement this with one call to pesky_decrypt():

def D(C1, C2):
    # D(C1, C2) = F(DEC(C2) ^ C1) ^ DEC(C1)
    assert len(C1) == len(C2) == 16
    return pesky_decrypt(b'\x00' * 16 + C1 + C2)[32:48]

From now on, let's ignore pesky_decrypt() and work exclusively with $D$.

Recall that the goal is to decrypt $ENC_{k2}(s)$ to obtain $s$. Plugging $ENC_{k2}(s)$ into $C_2$ doesn't seem to help because it just goes into the black box $F$. However, if we let $C_1 = ENC_{k2}(s)$, then we get:

$$ D(ENC_{k2}(s), C_2) = F(DEC_{k2}(C_2) \oplus ENC_{k2}(s)) \oplus s $$

Now we have $s$ XORed with some $F(...)$ that we don't know (yet). However, if we can find some other input to $D$ that generates the same $F(...)$, then we'll know the secret!

In other words, we want another input pair $(A,B)$ such that $DEC_{k2}(C_2) \oplus ENC_{k2}(s) = DEC_{k2}(B) \oplus A$. Note that we prefer to choose inputs so that all $DEC_{k2}(...)$ are known values, otherwise there will be too many unknowns.

This comes back to the 8 plaintext/ciphertext pairs we get for free in the beginning. For brevity I'll label these pairs as $(X_0,Y_0), (X_1,Y_1), \dotsc, (X_7,Y_7)$, where $X_i$ are the plaintexts and $Y_i$ are the ciphertexts. Since $X_i=DEC_{k2}(Y_i)$, it makes sense to replace $C_2$ and $B$ above with $Y_i$. Arbitrarily choosing $Y_0, Y_1$, we get

$$ \begin{aligned} DEC_{k2}(Y_0) \oplus ENC_{k2}(s) = DEC_{k2}(Y_1) \oplus A \\ X_0 \oplus ENC_{k2}(s) = X_1 \oplus A \end{aligned} $$

which implies that $A$ should equal $X_1 \oplus X_0 \oplus ENC_{k2}(s)$.

Knowing this, suppose we make two calls to $D$ with the inputs $(ENC_{k2}(s), Y_0)$ and $(X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_1)$.

$$ \begin{aligned} D(ENC_{k2}(s), Y_0) &= F(X_0 \oplus ENC_{k2}(s)) \oplus s \\ D(X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_1) &= F(X_0 \oplus ENC_{k2}(s)) \oplus DEC_{k2}(X_1 \oplus X_0 \oplus ENC_{k2}(s)) \end{aligned} $$

It follows that

$$ D(ENC_{k2}(s), Y_0) \oplus D(X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_1) = s \oplus DEC_{k2}(X_1 \oplus X_0 \oplus ENC_{k2}(s)) $$

Now this is interesting! Everything on the LHS is known, so if we can find $DEC_{k2}(X_1 \oplus X_0 \oplus ENC_{k2}(s))$, then we can also find $s$. Oddly, this is very similar to the original problem we were trying to solve: given $ENC_{k2}(s)$, find $s$.

Part II: Building the chain

To rephrase the above differently, we converted the problem of decrypting $ENC_{k2}(s)$ to one that is about decrypting $X_1 \oplus X_0 \oplus ENC_{k2}(s)$. At a glance, this doesn't seem any easier than before, but the key idea here is that we can apply this conversion process iteratively.

$X_0,X_1$ can be replaced with arbitrary $X_i,X_j$ since the indices can be chosen freely. Hence, if $X_{i_1} \oplus X_{i_2} \oplus \dotsc \oplus X_{i_n} \oplus ENC_{k2}(s)$ is decryptable, then it follows that $ENC_{k2}(s)$ is decryptable, by repeatedly applying the transformation $ENC_{k2}(s) \rightarrow ENC_{k2}(s) \oplus X_i \oplus X_j$.

We already know that $Y_i$ decrypts to $X_i$. Therefore, we can win by finding some subset of $X$ that satisfies

$$ X_{i_1} \oplus X_{i_2} \oplus \dotsc \oplus X_{i_n} \oplus ENC_{k2}(s) = Y_0 $$

Unfortunately, only 8 values of $(X_i,Y_i)$ are given, which is not enough plaintext/ciphertext pairs to expect a solution. On average, we'll need about 128 pairs, since $Y_i$ is 128 bits in size.

So we need to generate 120 additional plaintext/ciphertext pairs, given what we have so far. Is this possible?

Let's go back and experiment with $D(C_1, C_2) = F(DEC_{k2}(C_2) \oplus C_1) \oplus DEC_{k2}(C_1)$. There is an easy way to generate additional pairs for $F$ (a.k.a. $DEC_{k1}$). Suppose we query $D(Y_0, Y_1)$:

$$ D(Y_0, Y_1) = F(X_1 \oplus Y_0) \oplus Y_0 $$

Rearranging, we get $F(X_1 \oplus Y_0) = D(Y_0, Y_1) \oplus Y_0$, which reveals a plaintext/ciphertext pair for $F$. Doing this for all possible pairs of indices yields $8^2 = 64$ total pairs, in which we know $F(X_i \oplus Y_j)$ for all $i,j$.

How does this help us find pairs for $k2$? With a little more inspection work, we see that $D(X_0 \oplus X_1 \oplus Y_2, Y_0)$ is rather helpful:

$$ D(X_0 \oplus X_1 \oplus Y_2, Y_0) = F(X_1 \oplus Y_2) \oplus DEC_{k2}(X_0 \oplus X_1 \oplus Y_2) $$

Recall that since we now know the value of $F(X_1 \oplus Y_2)$, we indeed have a known plaintext/ciphertext pair for $DEC_{k2}$:

$$ DEC_{k2}(X_0 \oplus X_1 \oplus Y_2) = D(X_0 \oplus X_1 \oplus Y_2, Y_0) \oplus F(X_1 \oplus Y_2) $$

Since $X_0,X_1,Y_2$ can be generalized to all triplets $X_i,X_j,Y_k$ where $i \ne j$, this yields $8 \times \frac{8(8-1)}{2}=224$ total plaintext/ciphertext pairs. This is more than enough pairs needed to solve the challenge!

Ok, so given 224 + 8 $(X_i, Y_i)$ pairs, how do we find a valid subset of $X$ that satisfies the following equation?

$$ X_{i_1} \oplus X_{i_2} \oplus \dotsc \oplus X_{i_n} = ENC_{k2}(s) \oplus Y_0 $$

The answer is linear algebra! This is essentially solving the XOR subset sum problem, which unlike the subset sum problem has a polynomial time solution in $O(n^3)$. The idea is to represent each $X_i$ as a binary column vector in a $128 \times n$ matrix $A$. Then, finding a subset is equivalent to solving $Ax = y \pmod 2$, where $y$ is the binary column vector of $ENC_{k2}(s) \oplus Y_0$.

Gaussian elimination is perfectly valid $\pmod 2$, so we can solve this system efficiently.

Part III: Implementation details

We have everything needed to solve this challenge, so the rest is about implementation. I imagine there are several ways to implement the logic that solves $ENC_{k2}(s)$ from $ENC_{k2}(s) \oplus X_{i_1} \oplus ... \oplus X_{i_n}$. The way I approached it was by expanding and noticing the pattern. At the end of Part I, we deduced that

$$ s = D(ENC_{k2}(s), Y_0) \oplus D(X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_1) \oplus DEC_{k2}(X_1 \oplus X_0 \oplus ENC_{k2}(s)) $$

The unknown part of the equation is $DEC_{k2}(X_1 \oplus X_0 \oplus ENC_{k2}(s))$, but we can substitute it with a copy of the original equation, except $ENC_{k2}(s)$ becomes $X_1 \oplus X_0 \oplus ENC_{k2}(s)$. This gives

$$ \begin{aligned} s &= &D(ENC_{k2}(s), Y_0) \\ &\oplus &D(X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_1) \\ &\oplus &D(X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_2) \\ &\oplus &D(X_3 \oplus X_2 \oplus X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_3) \\ &\oplus &DEC_{k2}(X_3 \oplus X_2 \oplus X_1 \oplus X_0 \oplus ENC_{k2}(s)) \end{aligned} $$

Let's unpack one more layer...

$$ \begin{aligned} s &= &D(ENC_{k2}(s), Y_0) \\ &\oplus &D(X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_1) \\ &\oplus &D(X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_2) \\ &\oplus &D(X_3 \oplus X_2 \oplus X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_3) \\ &\oplus &D(X_3 \oplus X_2 \oplus X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_4) \\ &\oplus &D(X_5 \oplus X_4 \oplus X_3 \oplus X_2 \oplus X_1 \oplus X_0 \oplus ENC_{k2}(s), Y_5) \\ &\oplus &DEC_{k2}(X_5 \oplus X_4 \oplus X_3 \oplus X_2 \oplus X_1 \oplus X_0 \oplus ENC_{k2}(s)) \end{aligned} $$

We're essentially peeling back layers of $DEC_{k2}$ using $D$ and chaining them together until $DEC_{k2}$ becomes resolved. After three layers, the pattern is quite obvious. Note that we would replace the sequential $X_i$ indices with the actual subset.

For the linear algebra part, we can just use SageMath's .solve_right() which has a built-in GF(2) solver:

def xor_subset_sum(nums, target):
    from sage.all import GF, matrix, vector
    # introduce extra `+ [1]` constraint to ensure an even-length subset
    mat = matrix(GF(2), [[(x >> i) & 1 for i in range(128)] + [1] for x in nums])
    vec = vector(GF(2), [(target >> i) & 1 for i in range(128)] + [0])
    return mat.T.solve_right(vec)

Here is the final solution script:

from pwn import *
from functools import reduce

def xor(*a):
    assert all(len(x) == 16 for x in a)
    return bytes([reduce(int.__xor__, x) for x in zip(*a)])

def pesky_decrypt(ct):
    io.sendlineafter(b'>> ', b'1')
    io.sendlineafter(b'>> ', ct.hex().encode())
    return bytes.fromhex(io.recvline().decode())

def D(C1, C2):
    # D(C1, C2) = F(DEC(C2) ^ C1) ^ DEC(C1)
    assert len(C1) == len(C2) == 16
    return pesky_decrypt(b'\x00' * 16 + C1 + C2)[32:48]

# io = process(['python', 'pesky_cbc.py'])
io = remote('peskycbc.atreides.b01lersc.tf', 8443, ssl=True)

io.recvuntil(b'Here is the encrypted secret:\n')
s_enc = bytes.fromhex(io.recvline().decode())

io.recvuntil(b'Here are some hints for you ^_^\n')
k2_pairs = []
for _ in range(8):
    X = bytes.fromhex(io.recvline().decode())
    Y = bytes.fromhex(io.recvline().decode())
    k2_pairs.append((X, Y))

k1_dec = {}
for x1, y1 in k2_pairs:
    for x2, y2 in k2_pairs:
        c = xor(y1, x2)
        p = xor(D(y1, y2), x1)
        k1_dec[c] = p

k2_dec = {}
for x1, y1 in k2_pairs[:3]:
    for x2, y2 in k2_pairs:
        for x3, y3 in k2_pairs:
            if x1 != x2:
                c = xor(x1, x2, y3)
                p = xor(D(c, y1), k1_dec[xor(x2, y3)])
                k2_dec[c] = p

def xor_subset_sum(nums, target):
    from sage.all import GF, matrix, vector
    # introduce extra `+ [1]` constraint to ensure an even-length subset
    mat = matrix(GF(2), [[(x >> i) & 1 for i in range(128)] + [1] for x in nums])
    vec = vector(GF(2), [(target >> i) & 1 for i in range(128)] + [0])
    return mat.T.solve_right(vec)

X_0, Y_0 = k2_pairs[0]

# find a subset of plaintexts whose xor is `s_enc ^ Y_0`
k2_dec_pairs = [(pt, ct) for ct, pt in k2_dec.items()]
plaintexts = [int.from_bytes(pt) for pt, _ in k2_dec_pairs]
solution = xor_subset_sum(plaintexts, int.from_bytes(xor(s_enc, Y_0)))
soln_pairs = [(pt, ct) for b, (pt, ct) in zip(solution, k2_dec_pairs) if b]

secret = X_0
acc = s_enc
for i in range(len(soln_pairs)):
    secret = xor(secret, D(acc, soln_pairs[i][1]))
    if i % 2 == 0:
        acc = xor(acc, soln_pairs[i][0], soln_pairs[i + 1][0])

io.sendlineafter(b'>> ', b'2')
io.sendlineafter(b'>> ', secret.hex().encode())
io.interactive()

bctf{neighbor_c5975dc61dbfd49a} Hooray, we get the flag :)

This was a really nice challenge involving CBC, and my favorite crypto this year so far. Wow, a CBC challenge that isn't padding oracle!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment