B4B8

B4B8

BB84

This challenge is based on the BB84 Quantum Key Distribution Protocol for the secure creation of shared secrets.

The standard protocol is as follows:

  1. Alice creates a set of entangled qubit pairs (bell pairs)
  2. Alice sends said qubits to Bob.
  3. Both Alice and Bob randomly pick whether to apply a Hadamard gate for each of their qubits. They write down which they choose for each.
  4. Alice and Bob measure all their qubits.
  5. Alice and Bob publicly publish their choice of gates (but not measurements).
  6. Bob looks at his remaining qubits and randomly picks half of them and publishes which ones he chose and their values.
  7. If Alice agrees, they use the remaining half to qubits to deterministically construct a shared secret.

The B4B8 protocol differs in that the shared qubits were agreed upon ahead of time, and are always the first half of the remaining qubits, and only the last 128 shared qubits are used for construction of the shared key. (Well, and it doesn’t use real qubits, but I’ll come to that later).

The modification to the protocol makes this directly susceptible to meddling by Eve.

The Code

The QubitPair Class

class QubitPair:
    def __init__(self, density_matrix: np.matrix):
        global qubit_id
        self.density_matrix = density_matrix
        self.id = qubit_id
        qubit_id += 1

    def measure_first(self, POVM: list[np.matrix]):
        post_measure = [np.kron(kraus, np.identity(2)) * self.density_matrix * np.kron(kraus.H, np.identity(2)) for
                        kraus in POVM]
        prob = [np.abs(np.trace(m)) for m in post_measure]
        result = random.choices([x for x in range(len(POVM))], weights=prob)
        self.density_matrix = post_measure[result[0]] / prob[result[0]]
        return result[0]

    def measure_second(self, POVM: list[np.matrix]):
        post_measure = [np.kron(np.identity(2), kraus) * self.density_matrix * np.kron(np.identity(2), kraus.H) for
                        kraus in POVM]
        prob = [np.abs(np.trace(m)) for m in post_measure]
        result = random.choices([x for x in range(len(POVM))], weights=prob)
        self.density_matrix = post_measure[result[0]] / prob[result[0]]
        return result[0]

The QubitPair class is the simplified implementation of a Quantum System. You can only create two qubits, and gates get directly tied in with measurement. You cannot add qubits to the system, nor entangle separate qubit pairs. You can measure either qubit of the pair.

Bit Initialization

def bell_state():
    return QubitPair(np.matrix([[1 / 2, 0, 0, 1 / 2], [0] * 4, [0] * 4, [1 / 2, 0, 0, 1 / 2]]))

Alice_bits = [(bell_state(), 1) for s in range(n)]
Your_bits = [(x, 2) for x, _ in Alice_bits]
Bob_bits = []

Alice creates a bunch of QubitPairs. Here we see that the scheme to represent a single Qubit is as a pair of the QubitPair “system” with the qubit id (either 1 or 2). Here, we always get the second of Alice’s Qubits. Later we’ll see that when we measure, whether measure_first or measure_second gets called is determiend by whether the index is 1 or 2.

Main Loop

The beginning of the main loop is what happens once Bob recieves 1024 qubits. We’ll ignore that for now, only noting that once that happens, we can still perform actions.

(1) Measure a Qubit

        if option == 1:
            count = int(input(
                "How many Kraus operators does your POVM have? If you just want the standard basis, put 0. If you just want the hadamard basis, put 1."))
            if count < 0:
                raise ValueError("I don't think you're gonna measure much with that...")
            elif count > 4:
                raise ValueError("Unfortunately we don't quite have the hardware for that...")
            elif count == 0:
                POVM = standard_basis()
            elif count == 1:
                POVM = hadamard_basis()

We can choose to measure the qubit in either the standard basis or the hadamard basis. You can alternatively think of the hadamard basis as simply first applying a hadamard gate and then measuring “in the standard basis”.

There’s also an option to apply up to four POVM transformations to the state. This may have been added as a red herring or because the author was bored. Either way, I’m ignoring it as it isn’t used in any of the attacks.

After that, the measurement is made

            index = int(input("Which state would you like to measure?"))
            if index < 0 or index >= len(Your_bits):
                raise ValueError("That's not a valid state index!")

            state, bit_number = Your_bits[index]
            print("Making measurement...")
            if bit_number == 1:
                print(f"The measurement result is: {state.measure_first(POVM)}")
            if bit_number == 2:
                print(f"The measurement result is: {state.measure_second(POVM)}")

Note, here we see how the service ensures that we only can measure a Qubit that shows up in our

(2) Create a Qubit

        if option == 2:
            if len(Your_bits) > 2 * n:
                raise ValueError("You don't have enough quantum storage for that!")
            print("Input the coefficients for the 4x4 density matrix of 2 qubits: ")
            m = input_matrix(4)
            if check_valid(m):
                state = QubitPair(m)
                Your_bits.append((state, 1))
                Your_bits.append((state, 2))
                print(f"Added 2 parts of qubit with id {state.id} into storage")
            else:
                raise ValueError(
                    "That doesn't seem to be a physically possible state... but raise a ticket with your input if you think the check is giving a false negative!"
                )

The main thing to note here is that we have to specify the initial state of the qubits as a density Matrix. I used Quirk to get the density matrix of a Bell State.

(3) Send Qubit to Bob

        if option == 3:
            if key_exchanged:
                raise ValueError("Bob: Oh look, Eve's trying to send me qubits. That can't be good...")
            index = int(input("Which bits would you like to send to Bob?"))
            if index < 0 or index >= len(Your_bits):
                raise ValueError("That's not a valid state index!")
            Bob_bits.append(Your_bits.pop(index))

We can send either (or both!) of the qubits to bob. Once removed from our list of qubits, we won’t be able to measure it later.

(4) Show Qubits

        if option == 4:
            for i, t in enumerate(Your_bits):
                print(f"#{i} | id: #{t[0].id}, part of qubit: {t[1]}")

Nice for debugging and making you feel like you’re interacting with an actual Qubit Service. 10/10

Note that we also get to see the QubitPair system id.

Bob Gets His Qubits

Once bob gets 1024 qubits, alice and bob perform their measurements.

        if not key_exchanged and len(Bob_bits) == n:
            print("Bob: I've received enough states!")
            print("Alice: ok let's start then.")
            key_exchanged = True
            Alice_basis = int.from_bytes(os.urandom(n // 8), byteorder='big')
            Alice_results = measures(Alice_bits, Alice_basis)
            print("Alice: i've made my measurements.")

            Bob_basis = int.from_bytes(os.urandom(n // 8), byteorder='big')
            Bob_results = measures(Bob_bits, Bob_basis)
            print("Bob: Same here!")

The challenge author makes our life slightly harder by having Bob check if his qubits look random.

            Bob_standard = ""
            Bob_hadamard = ""
            for i in range(n):
                if ((Bob_basis >> i) & 1) == 0:
                    Bob_standard = Bob_standard + str((Bob_results >> i) & 1)
                else:
                    Bob_hadamard = Bob_hadamard + str((Bob_results >> i) & 1)
            if randomness_test(int(Bob_hadamard, 2)) and randomness_test(int(Bob_standard, 2)):
                print("Bob: Mine passed the randomness test, looks like everything is good!")
            else:
                raise ValueError("Bob: Mine doesn't seem that random, maybe Eve's here again...")

Alice and bob exchange basis choices

            print(f"Bob: My bases were {('0' * n + bin(Bob_basis)[2:])[-n:]}")
            print(f"Alice: cool, my bases were {('0' * n + bin(Alice_basis)[2:])[-n:]}")

(note: the basis choices are presented in the reverse order of the qubits. The measurement basis for the qubit index 0 is in fact the last digit of this string. This was super annoying)

Now they figure out where they match. The associated measurements should match if the qubits were untampered with.

            Alice_key = ""
            Bob_key = ""
            count = 0
            chosen = []
            for i in range(n):
                if ((Alice_basis >> i) & 1) == ((Bob_basis >> i) & 1):
                    chosen.append(i)
                    Alice_key = Alice_key + str((Alice_results >> i) & 1)
                    Bob_key = Bob_key + str((Bob_results >> i) & 1)
                    count += 1

Then they ensure that about the first half of remaining qubits weren’t tampered with.

            print(
                f"Bob: Great! Now let's just make sure that our measurements actually match. My first {count // 4} bits are: {Bob_key[:count // 4]}")

            if sum([1 if a != b else 0 for a, b in zip(Alice_key[:count // 4], Bob_key[:count // 4])]) > count // 100:
                raise ValueError("Alice: that doesn't seem to match mine, eve's dropped by again it seems.")
            else:
                print("Alice: yeah that looks good.")

Then Alice and Bob construct (what should be) a shared key for AES

            Alice_key = int(Alice_key[-128:], 2)
            Alice_AES = AES.new(Alice_key.to_bytes(length=16, byteorder='big'), mode=AES.MODE_CBC)
            Bob_key = int(Bob_key[-128:], 2)
            Bob_AES = AES.new(Bob_key.to_bytes(length=16, byteorder='big'), mode=AES.MODE_CBC)

Alice Encrypts the FLAG

encrypted_once = Alice_AES.encrypt(FLAG)

And then magically sends this encrypted message to Bob (just roll with it) who then encrypts it with his key.

encrypted_twice = Bob_AES.encrypt(encrypted_once)

(Again, please ignore the the logical implications of their setup from this step.)

And we get the resulting magically doubly encrypted message ciphertext and IVs.

            print(Alice_AES.iv.hex())
            print(Bob_AES.iv.hex())
            print(encrypted_twice.hex())

The Attack

It should be noted that we don’t have to measure our Qubits upon recieving them. We can delay that until after Bob has published his measurement basis.

So if we send the first 512 of Alice’s qubits, which won’t be used in the key, Bob and Alice will agree that everything looks good when they measure. So we Send 512 of Alice’s qubits to Bob, and then we create 512 pairs (also in a Bell State) and send those to Bob.

We can measure the remaining Alice qubits ourselves to find out Alice’s Key (we’re basically “Bob” at that point and we now know what basis she measured them in) and then we can measure the remaining Qubits as if we were Alice (again we know the basis that Bob measured in at this point). This get’s us Bob’s key.

And that’s it really.

Attack Code

Full Exploit:

import pwn
import re

from Crypto.Cipher import AES

### Interaction With Service
def send_bell_state(r : pwn.remote):
    mat = [[1 / 2, 0, 0, 1 / 2], [0] * 4, [0] * 4, [1 / 2, 0, 0, 1 / 2]]
    for row in mat:
        for entry in row:
            r.sendline(str(entry).encode())

def create_bell_state(r : pwn.remote):
    r.sendline(b"2")
    send_bell_state(r)
    return read_prompt(r)

def read_prompt(r : pwn.remote):
    res= r.recvuntil(b"See qubits in storage")
    return res

def send_bob_bits(r : pwn.remote, i : int):
    r.sendline(b"3")
    r.sendline(str(i).encode())
    return read_prompt(r)

def measure_qubit(r : pwn.remote, qubit_id : int, basis : int):
    r.sendline(b"1")
    r.sendline(str(basis).encode())
    r.sendline(str(qubit_id).encode())
    r.recvuntil(b"The measurement result is: ")
    res = r.recvline()[:-1]
    return int(res)

# This is just for debugging
def show_qubits(r):
    r.sendline(b"4")
    print("\n".join(read_prompt(r).decode().split("\n")[:-5]))

def extract_basis(btext):
    text = btext.decode()
    bob = re.findall("Bob: My bases were ([01]*)", text)[0]
    alice = re.findall("Alice: cool, my bases were ([01]*)", text)[0]
    return alice, bob

def extract_cipher_data(btext):
    text = btext.decode()
    text += "\n" # just in case I didn't grab it
    res =  re.findall("\n([^\n]*)\n([^\n]*)\n([^\n]*)\n\nYou can:\n", text)[0]
    return res

# Final Decryption once ciphertext is obtained and keys derived
def decrypt(ciphertext : str, iv1 : str, iv2 : str, key1 : int, key2 : int):
    ciphertext = bytes.fromhex(ciphertext)
    iv1 = bytes.fromhex(iv1)
    iv2 = bytes.fromhex(iv2)
    Alice_AES = AES.new(key1.to_bytes(length=16, byteorder='big'), mode=AES.MODE_CBC, iv=iv1)
    Bob_AES = AES.new(key2.to_bytes(length=16, byteorder='big'), mode=AES.MODE_CBC,iv=iv2)
    return Alice_AES.decrypt(Bob_AES.decrypt(ciphertext))


def compute_shared_basis(alice_basis, bob_basis):
    basis = []
    assert len(alice_basis) == len(bob_basis)
    for i, (a,b) in enumerate(zip(alice_basis[::-1], bob_basis[::-1])):
        if a==b:
            basis.append((i,a))
    return basis


def main():
    r = pwn.process(["python3", "b4b8.py"]) # run locally
    #r = pwn.remote("b4-the-b8.ctf.umasscybersec.org",1337) # run against server

    read_prompt(r)

    # send the first 512 qubits from alice to bob
    for i in range(512):
        send_bob_bits(r, 0)

    # Create 512 more pairs, send one from each to bob
    # annoying index logic.
    for i in range(512):
        create_bell_state(r)
        chatter = send_bob_bits(r, 512+i)
    
    # print out the alice bob chatter if you want to do the final decryption manually
    print(chatter.decode())

    # Get the ivs and ciphertext
    iv1,iv2, ct = extract_cipher_data(chatter)

    # parse alice's and bob's basis from the chatter
    alice_basis, bob_basis = extract_basis(chatter)

    # Compute the shared basis and then whittle it down to the part that is
    # used in key construction
    shared_basis = compute_shared_basis(alice_basis, bob_basis)
    used_basis = shared_basis[-128:]

    # determine alice's bits
    alice_key = ""
    bob_key = ""
    for i,b in used_basis:
        alice_qubit = i - 512
        bob_qubit = i
        alice_measure = measure_qubit(r, alice_qubit, b)
        bob_measure = measure_qubit(r, bob_qubit, b)
        alice_key += str(alice_measure)
        bob_key += str(bob_measure)
    
    # Display our hard earned keys
    print("Alice_key", int(alice_key,2))
    print("Bob_key", int(bob_key,2))

    # decrypt!
    print(decrypt(ct, iv1, iv2, int(alice_key,2), int(bob_key,2)))

    # have fun with the glorious simulator / debug what went wrong
    r.interactive()

Wait not real qubits? What was that?

Oh yeah. There’s another way to deal with this. After the competition @lrnzsir asked

Was cracking Mersenne Twister also intended?

Sure enough our “quantum” bits are using the pseudo random Mersenne Twister

        result = random.choices([x for x in range(len(POVM))], weights=prob)

Quick Review of Choices

choices generates a single random floating point number in [0,1). It then maps each entry, based on the weights, into that interval and uses the random number to select the correct one. Random floats in python are generated with:

static PyObject *
_random_Random_random_impl(RandomObject *self)
/*[clinic end generated code: output=117ff99ee53d755c input=afb2a59cbbb00349]*/
{
    uint32_t a=genrand_uint32(self)>>5, b=genrand_uint32(self)>>6;
    return PyFloat_FromDouble((a*67108864.0+b)*(1.0/9007199254740992.0));
}

Which looks intimidating, but is really just constructing 54 non overlapping bits from two 32 random bit numbers and then mapping dividing by 2**54.

In our specific situation, we want to know when this is greater than or equal to 0.5. If it is, then we get 1. This happens when the high bit of a is 1.

Back to the problem at hand

But hey, we’d need to get 19968 bits of information to reconstruct stuff right? And we can’t have more than 2048 qubits total, right?

Well, we can just alternate the basis and measure the same qubit.

So our alternative gameplan is simply to send all the qubits to Bob, create a new qubit, and just keep measuring it to leak enough bits from the twister. Then we just shove that output into something that can reconstruct the state.

solver = MT19937Solver()
basis = 0
create_bell_state(r)
results = []
for i in range(624*33):
    result = measure_qubit(r, 1024, basis)
    basis = (basis+1) % 2
    solver.submit(32, [result]+[None]*31) # High bit is known and is the result
    solver.submit(32, None) # don't know anything about it
initial_state = solver.solve()
rand = random.Random()
rand.setstate(initial_state)

# resync solver
for i in range(624*33):
    _ = rand.getrandbits(32)
    _ = rand.getrandbits(32)

# send all of alice's bits to bob
for i in range(1024):
    chatter = send_bob_bits(r, 0)
iv1,iv2, ct = extract_cipher_data(chatter)

# generated basis is still_random, so we need to get it
alice_basis, bob_basis = extract_basis(chatter)
shared_basis = compute_shared_basis(alice_basis, bob_basis)
used_basis = shared_basis[-128:]

alices_measurements = []
for i in range(len(alice_basis)):
    alices_measurements.append(str(rand.choices([0,1], [1,1])))
alice_key = ""
# Here we can throw away the basis info, since we don't
# care anymore. The calls to rand.choices will be the same
for i, _ in used_basis:
    alice_key += alice_measurements[i]
bob_key = alice_key
print(decrypt(ct, iv1, iv2, int(alice_key,2), int(bob_key,2)))