Cryptopals solutions > Set 7

Set 7: Hashes¶

This is the first of two sets we generated after the original 6.

Unlike the last few sets, this set is a hodge-podge. It also includes some of the few challenges we have that probably aren't useful against real targets (they were fun enough to include anyways). On the other hand, we also include a challenge that models the CRIME attack on TLS.

This set is hard. There's a significant amount of programming, and Wang's attack in particular is as difficult as anything we've done.

  • Preliminaries
  • Challenge 49: CBC-MAC message forgery
  • Challenge 50: Hashing with CBC-MAC
  • Challenge 51: Compression ratio side-channel attacks
  • Challenge 52: Iterated hash function multicollisions
  • Challenge 53: Kelsey and Schneier's expandable messages
  • Challenge 54: Kelsey and Kohno's Nostradamus attack
  • Challenge 55: MD4 collisions
  • Challenge 56: RC4 single-byte biases

Preliminaries¶

In [1]:
from base64 import b64decode
from itertools import islice
from random import randbytes
from zlib import compress

import pandas as pd
import matplotlib.pyplot as plt

# From pyca/cryptography
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import cryptography.hazmat.decrepit.ciphers.algorithms as decrepit_algorithms

def aes_128_ctr_crypt(text, key, nonce):
    # Symmetric encryption/decryption
    encryptor = Cipher(algorithms.AES128(key), modes.CTR(nonce)).encryptor()
    return encryptor.update(text) + encryptor.finalize()

def pad_pkcs7(text):
    padder = padding.PKCS7(128).padder()
    return padder.update(text) + padder.finalize()

def aes_128_cbc_encrypt(ptext, key, iv, padding=True):
    if padding:
        ptext = pad_pkcs7(ptext)
    encryptor = Cipher(algorithms.AES128(key), modes.CBC(iv)).encryptor()
    return encryptor.update(ptext) + encryptor.finalize()

def rc4_encrypt(ptext, key):
    encryptor = Cipher(decrepit_algorithms.ARC4(key), mode=None).encryptor()
    return encryptor.update(ptext) + encryptor.finalize()

def xor(x, y):
    return bytes(xb^yb for xb, yb in zip(x, y))

zero = bytes(16)

Challenge 49: CBC-MAC message forgery¶

Let's talk about CBC-MAC.

CBC-MAC is like this:

  1. Take the plaintext P.
  2. Encrypt P under CBC with key K, yielding ciphertext C.
  3. Chuck all of C but the last block C[n].
  4. C[n] is the MAC.

Suppose there's an online banking application, and it carries out user requests by talking to an API server over the network. Each request looks like this:

message || IV || MAC

The message looks like this:

from=#{from_id}&to=#{to_id}&amount=#{amount}

Now, write an API server and a web frontend for it. (NOTE: No need to get ambitious and write actual servers and web apps. Totally fine to go lo-fi on this one.) The client and server should share a secret key K to sign and verify messages.

The API server should accept messages, verify signatures, and carry out each transaction if the MAC is valid. It's also publicly exposed - the attacker can submit messages freely assuming he can forge the right MAC.

The web client should allow the attacker to generate valid messages for accounts he controls. (Feel free to sanitize params if you're feeling anal-retentive.) Assume the attacker is in a position to capture and inspect messages from the client to the API server.

One thing we haven't discussed is the IV. Assume the client generates a per-message IV and sends it along with the MAC. That's how CBC works, right?

Wrong.

For messages signed under CBC-MAC, an attacker-controlled IV is a liability. Why? Because it yields full control over the first block of the message.

Use this fact to generate a message transferring 1M spacebucks from a target victim's account into your account.

I'll wait. Just let me know when you're done.

... waiting

... waiting

... waiting

All done? Great - I knew you could do it!


This challenge and the next appear to have been inspired by Matthew Green's blog post, Why I hate CBC-MAC. The attack here is trivial to implement, but it seems of limited value since only the first block (16 bytes) of the message can be modified. Still, the first block can be modified at will. Here it is convenient to refer back to how CBC mode works:

Let $O$ be the first block of the original message, $I$ the initialization vector, and $O'$ the desired replacement block. Since the first ciphertext block is computed by encrypting $I \oplus O$, we can just replace that with $I' \oplus O'$ where $I' = I \oplus O \oplus O'$ to recreate the original CBC-MAC computation.

But the requested forgery doesn't seem realistically possible. Given the message syntax and a reasonable length for account IDs, the recipient account won't occur within the first 16 bytes. And even if it does, how could one modify the amount, which occurs even later in the message? Another solver suggested submitting a legitimate, innocuous request first, from=attacker_id&to=attacker_id&amount=1000000, and then modifying the sender, not the recipient. But this assumes that the server would allow such requests, and that the attacker has sufficient funds to support it.

In [2]:
key = randbytes(16)

def cbc_mac(text, iv):
    return aes_128_cbc_encrypt(text, key, iv)[-16:]

def authenticate(text, iv, mac):
    return cbc_mac(text, iv) == mac

#                  |..............|  <-- first block
message =        b"from=Bob&to=Dave&amount=5"
forged_message = b"from=Bob&to=Greg&amount=5"

# The message and the following are intercepted...
iv = randbytes(16)
mac = cbc_mac(message, iv)

forged_iv = xor(xor(iv, message[:16]), forged_message[:16])

# And the server will accept...
authenticate(forged_message, forged_iv, mac)
Out[2]:
True

Now let's tune up that protocol a little bit.

As we now know, you're supposed to use a fixed IV with CBC-MAC, so let's do that. We'll set ours at 0 for simplicity. This means the IV comes out of the protocol:

message || MAC

Pretty simple, but we'll also adjust the message. For the purposes of efficiency, the bank wants to be able to process multiple transactions in a single request. So the message now looks like this:

from=#{from_id}&tx_list=#{transactions}

With the transaction list formatted like:

to:amount(;to:amount)*

There's still a weakness here: the MAC is vulnerable to length extension attacks. How?

Well, the output of CBC-MAC is a valid IV for a new message.

"But we don't control the IV anymore!"

With sufficient mastery of CBC, we can fake it.

Your mission: capture a valid message from your target user. Use length extension to add a transaction paying the attacker's account 1M spacebucks.

Hint!

This would be a lot easier if you had full control over the first block of your message, huh? Maybe you can simulate that.

Food for thought: How would you modify the protocol to prevent this?


This attack seems even less feasible than the previous one. It's a variation on Challenge 50, next, which we explore fully. We'll just note here that a successful attack would require two things to happen:

  • The attacker must compute the CBC-MAC of the extension (by itself) in which the first block has been XOR-ed with the original message's CBC-MAC. Thus the client would have to be willing to sign, not just a message that it did not originate, but a message that appears to it to be just a string of arbitrary bytes.

  • The computation of the original message's CBC-MAC incorporated PKCS#7 padding, and that padding would have to be manually inserted between the original message and the extension in order to make the CBC-MAC computation come out right. Thus the server would have to accept a request with padding bytes in the middle.

Both those requirements seem unrealistic.

How can length extension attacks be prevented? Simply by including some kind of end-of-request marker in the syntax.

In [3]:
message = b"from=Bob&tx_list=Dave:5"
mac = cbc_mac(message, iv=zero)

extension = b";Greg:1000000"

# The scenario calls for us to compute the CBC-MAC of the extension in
# which the first block has been XOR-ed with `mac` above, and using
# IV=0, but for brevity we note that that is mathematically equivalent
# to this call.
new_mac = cbc_mac(extension, iv=mac)

forged_message = pad_pkcs7(message) + extension

authenticate(forged_message, iv=zero, mac=new_mac)
Out[3]:
True

So it works. But here's the message the server would have to accept:

In [4]:
print(forged_message)
b'from=Bob&tx_list=Dave:5\t\t\t\t\t\t\t\t\t;Greg:1000000'

Challenge 50: Hashing with CBC-MAC¶

Sometimes people try to use CBC-MAC as a hash function.

This is a bad idea. Matt Green explains:

To make a long story short: cryptographic hash functions are public functions (i.e., no secret key) that have the property of collision-resistance (it's hard to find two messages with the same hash). MACs are keyed functions that (typically) provide message unforgeability -- a very different property. Moreover, they guarantee this only when the key is secret.

Let's try a simple exercise.

Hash functions are often used for code verification. This snippet of JavaScript (with newline):

alert('MZA who was that?');

Hashes to 296b8d7cb78a243dda4d0a61d33bbdd1 under CBC-MAC with a key of "YELLOW SUBMARINE" and a 0 IV.

Forge a valid snippet of JavaScript that alerts "Ayo, the Wu is back!" and hashes to the same value. Ensure that it runs in a browser.


JavaScript has the convenient property that a single-line comment (delimited with "//") extends up to the next newline character and can contain any characters, in fact any bytes (other than newline) along the way. Since the CBC-MAC consists of just the last ciphertext block, the idea is that we add to our forged code (regardless what it does, regardless how long it is) a comment that causes the MAC to turn out right. Referring again to how CBC mode works:

Let $F$ be the forged code with "//" appended and let $C_F$ be the CBC-MAC of $F$, i.e., its last ciphertext block. Note that in computing $C_F$ the encryption process will have added some PKCS#7 padding bytes; call this padding $P$. Let $O$ be just the first block (i.e., 16 bytes) of the original code. Let $O' = C_F \oplus O$. Consider manually padding $F$ and encrypting $F \parallel P \parallel O'$. In computing the ciphertext block corresponding to $O'$ we will be encrypting $C_F \oplus O' = C_F \oplus (C_F \oplus O) = O$. I.e., we will have recreated the start of the original CBC-MAC computation. From this point, if we append the remainder of the original code (i.e., from the second block on) verbatim we will wind up matching the original CBC-MAC.

There are two potential problems. One, if 10 PKCS#7 padding bytes are required we will end up appending newline (ASCII 10) bytes to $F$, which will mess up our commenting mechanism. Two, it may happen that a newline appears in $O'$. We don't address these problems here, but they could both be handled by making some kind of innocuous change to the forged code. And there is one limitation: we're assuming the original code consists of a single line, so that its actions can be disabled by a single comment delimiter. If the original code has multiple lines, we will be commenting out only the first line. Our approach requires that the original code appear at the end of the plaintext verbatim, so we can't add further comment delimiters. A potential workaround would be to add some kind of quit or exit call at the end of the forged code, to avoid running the original code, but the success of this approach would seem to depend on the particulars of the original code (e.g., that commenting out the first line does not create a syntax error).

In [5]:
def cbc_mac_hash(text):
    return aes_128_cbc_encrypt(text, b"YELLOW SUBMARINE", zero)[-16:]

original_code = b"alert('MZA who was that?');\n"
print(cbc_mac_hash(original_code).hex())

forged_code = b"alert('Ayo, the Wu is back!');\n" + b"//"
C_f = cbc_mac_hash(forged_code)
forged_code = (
    pad_pkcs7(forged_code) +
    xor(C_f, original_code[:16]) +
    original_code[16:]
)

print(cbc_mac_hash(forged_code).hex())
296b8d7cb78a243dda4d0a61d33bbdd1
296b8d7cb78a243dda4d0a61d33bbdd1

The forged code looks like this:

In [6]:
print(forged_code)
b'alert(\'Ayo, the Wu is back!\');\n//\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\x0f\xe3l\xa7\xc1BR\x0c\xa8Dy"8tA\xaa~as that?\');\n'

Challenge 51: Compression ratio side-channel attacks¶

Internet traffic is often compressed to save bandwidth. Until recently, this included HTTPS headers, and it still includes the contents of responses.

Why does that matter?

Well, if you're an attacker with:

  1. Partial plaintext knowledge and
  2. Partial plaintext control and
  3. Access to a compression oracle

You've got a pretty good chance to recover any additional unknown plaintext.

What's a compression oracle? You give it some input and it tells you how well the full message compresses, i.e., the length of the resultant output.

This is somewhat similar to the timing attacks we did way back in set 4 in that we're taking advantage of incidental side channels rather than attacking the cryptographic mechanisms themselves.

Scenario: you are running a MITM attack with an eye towards stealing secure session cookies. You've injected malicious content allowing you to spawn arbitrary requests and observe them in flight. (The particulars aren't terribly important, just roll with it.)

So! Write this oracle:

oracle(P) -> length(encrypt(compress(format_request(P))))

Format the request like this:

POST / HTTP/1.1
Host: hapless.com
Cookie: sessionid=TmV2ZXIgcmV2ZWFsIHRoZSBXdS1UYW5nIFNlY3JldCE=
Content-Length: ((len(P)))
((P))

(Pretend you can't see that session id. You're the attacker.)

Compress using zlib or whatever.

Encryption... is actually kind of irrelevant for our purposes, but be a sport. Just use some stream cipher. Dealer's choice. Random key/IV on every call to the oracle.

And then just return the length in bytes.

Now, the idea here is to leak information using the compression library. A payload of "sessionid=T" should compress just a little bit better than, say, "sessionid=S".

There is one complicating factor. The DEFLATE algorithm operates in terms of individual bits, but the final message length will be in bytes. Even if you do find a better compression, the difference may not cross a byte boundary. So that's a problem.

You may also get some incidental false positives.

But don't worry! I have full confidence in you.

Use the compression oracle to recover the session id.

I'll wait.

Got it? Great.


The session key is "Never reveal the Wu-Tang Secret!" Base64-encoded.

This attack was perhaps first described, abstractly, by John Kelsey in 2002 in Compression and Information Leakage of Plaintext and then exploited a decade later in the CRIME and BREACH attacks on web browsers. The challenge here describes it as a MITM attack, but it's better characterized as one in which the attacker can supply input to a system, have that input reflected in the (compressed) output from that system, and observe the length of the output. If the system is a web browser, the input could be supplied in the body of a POST request (as suggested here) or in the URL itself (i.e., just visit URL /sessionid=...); the output length could be determined by packet sniffing. If the system is a web server, then the attack would rely on the server reflecting the input in the response.

The only protection seems to be to disable compression.

Fundamentally, the attack works because the DEFLATE/LZ77 algorithm used by zlib will replace a previously-seen substring within a sliding window with a back reference to the earlier occurrence. Here we're relying on the existence of an anchor of known bytes to provide the match the algorithm is looking for, e.g., sessionid=. We input such an anchor, and if some number of bytes following that anchor match the full and correct session ID already embedded in the message, then fewer bits will be required to compress the subsequent bytes that do not match. In developing our solution we found that we were able to consistently recover the session ID one byte at a time using anchor Cookie: sessionid=, whereas using just anchor sessionid= we had to guess two bytes at a time.

The padding argument will be used in the second part of this challenge.

In [7]:
session_id = b"TmV2ZXIgcmV2ZWFsIHRoZSBXdS1UYW5nIFNlY3JldCE="

base64_alphabet = (
    b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    b"abcdefghijklmnopqrstuvwxyz"
    b"0123456789/+="
)

def format_request(data):
    header = (
        "POST / HTTP/1.1\r\n"
        "Host: hapless.com\r\n"
        "Cookie: sessionid={session_id}\r\n"
        "Content-Length: {length}\r\n"
        "\r\n"
    )
    return header.format(
        session_id=session_id.decode(),
        length=len(data)
    ).encode() + data

def oracle(data):
    key = randbytes(16)
    nonce = randbytes(16)
    return len(aes_128_ctr_crypt(compress(format_request(data)), key, nonce))

def recover_session_id(add_padding=False):
    id = bytes()
    while not id.endswith(b"\r\n"):
        padding = b""
        while True:
            l = [
                [oracle(padding + b"Cookie: sessionid=" + id + bytes([b])), b]
                for b in base64_alphabet + b"\r\n"
            ]
            l.sort(key=lambda t: t[0])
            if l[0][0] < l[1][0]:
                # There is a unique shortest compressed output
                id += bytes([l[0][1]])
                break
            else:
                assert add_padding
                padding += randbytes(1)
    return id[:-2]

print(recover_session_id() == session_id)
True

Now swap out your stream cipher for CBC and do it again.


A block cipher makes this quite a bit harder because the added PKCS#7 padding obscures the length of the nominally compressed output. To counter this we add arbitrary content (designed to compress poorly, so as to have effect) one byte at a time, until, in testing all possibilities for the next byte of the session ID, there is one nominally compressed output that is shorter than the others, and which manages to make the corresponding encrypted output one block shorter. In practice this required adding 30-60 bytes.

In [8]:
def oracle(data):
    key = randbytes(16)
    iv = randbytes(16)
    return len(aes_128_cbc_encrypt(compress(format_request(data)), key, iv))

print(recover_session_id(add_padding=True) == session_id)
True

Challenge 52: Iterated hash function multicollisions¶

While we're on the topic of hash functions...

The major feature you want in your hash function is collision-resistance. That is, it should be hard to generate collisions, and it should be really hard to generate a collision for a given hash (aka preimage).

Iterated hash functions have a problem: the effort to generate lots of collisions scales sublinearly.

What's an iterated hash function? For all intents and purposes, we're talking about the Merkle-Damgard construction. It looks like this:

function MD(M, H, C):
  for M[i] in pad(M):
    H := C(M[i], H)
  return H

For message M, initial state H, and compression function C.

This should look really familiar, because SHA-1 and MD4 are both in this category. What's cool is you can use this formula to build a makeshift hash function out of some spare crypto primitives you have lying around (e.g. C = AES-128).

Back on task: the cost of collisions scales sublinearly. What does that mean? If it's feasible to find one collision, it's probably feasible to find a lot.

How? For a given state H, find two blocks that collide. Now take the resulting hash from this collision as your new H and repeat. Recognize that with each iteration you can actually double your collisions by subbing in either of the two blocks for that slot.

This means that if finding two colliding messages takes 2^(b/2) work (where b is the bit-size of the hash function), then finding 2^n colliding messages only takes n*2^(b/2) work.

Let's test it. First, build your own MD hash function. We're going to be generating a LOT of collisions, so don't knock yourself out. In fact, go out of your way to make it bad. Here's one way:

  1. Take a fast block cipher and use it as C.
  2. Make H pretty small. I won't look down on you if it's only 16 bits. Pick some initial H.
  3. H is going to be the input key and the output block from C. That means you'll need to pad it on the way in and drop bits on the way out.

Now write the function f(n) that will generate 2^n collisions in this hash function.

Why does this matter? Well, one reason is that people have tried to strengthen hash functions by cascading them together. Here's what I mean:

  1. Take hash functions f and g.
  2. Build h such that h(x) = f(x) || g(x).

The idea is that if collisions in f cost 2^(b1/2) and collisions in g cost 2^(b2/2), collisions in h should come to the princely sum of 2^((b1+b2)/2).

But now we know that's not true!

Here's the idea:

  1. Pick the "cheaper" hash function. Suppose it's f.
  2. Generate 2^(b2/2) colliding messages in f.
  3. There's a good chance your message pool has a collision in g.
  4. Find it.

And if it doesn't, keep generating cheap collisions until you find it.

Prove this out by building a more expensive (but not too expensive) hash function to pair with the one you just used. Find a pair of messages that collide under both functions. Measure the total number of calls to the collision function.


This is a fantastic lesson to learn. The effect seen here fundamentally results from the Birthday paradox. Specifically, in the case of hash functions where the hash size is $b$ bits (i.e., the hash space is $2^b$ bins) the probability of finding a collision among $2^{b/2}$ random messages is approximately 50%. See Birthday attack for more information.

The salient property of an iterated hash function $f$ is that the output value from hashing one block becomes the input value for hashing the next block. Thus if $p_1$ and $p_2$ are two block-aligned messages and $h_0$ is the starting hash value, and if $f(p_1, h_0) = h_1$ and $f(p_2, h_1) = h_2$, then $f(p_1 \parallel p_2, h_0) = h_2$.

To create an exponential number of messages that all collide under one hash function we exploit the chained nature of iterated hash functions in much the same way we exploited CBC-MAC. Again, let $f$ be an iterated hash function and $h_0$ the starting hash value. Suppose we find, by randomly probing, blocks (not necessarily messages, single blocks suffice) $p_1$ and $q_1$ such that $f(p_1, h_0) = f(q_1, h_0) = h_1$. Then suppose we use $h_1$ as our new basis and find blocks $p_2$ and $q_2$ such that $f(p_2, h_1) = f(q_2, h_1) = h_2$. Then we will in fact have found four messages that collide:

$$ \begin{eqnarray*} f(p_1 \parallel p_2, h_0) &=& \\ f(p_1 \parallel q_2, h_0) &=& \\ f(q_1 \parallel p_2, h_0) &=& \\ f(q_1 \parallel q_2, h_0) &=& h_2 \end{eqnarray*} $$

If we continue in the same fashion and find blocks $p_3$ and $q_3$ such that $f(p_3, h_2) = f(q_3, h_2) = h_3$, the number of colliding messages doubles again. For example, $p_1 \parallel q_2 \parallel p_3$ would be one such message that hashes to $h_3$. Generally, after finding $n$ such collision pairs we can construct $2^n$ messages that all collide under $f$ to $h_n$ by choosing, for each block position $i$, whether to use block $p_i$ or $q_i$. Suppose the hash size of $f$ is $b$ bits. If we think of $2^{b/2}$ hash probes as the amount of "work" required to achieve at least a 50% chance of finding one collision, then we arrive at the conclusion given in the challenge, namely that with only $n \cdot 2^{b/2}$ "work" we can find $2^n$ colliding messages.

If we look among these $2^n$ colliding messages for collision under a second hash function, $g$, we are aided once again by the Birthday paradox. For simplicity assume the hash size of $g$ is also $b$ bits. If $n$ is large enough (namely, $n \ge b/2$, so that $2^n \ge 2^{b/2}$) we have a 50% chance of finding a collision under $g$, and if we do then we will have found a collision under the combined hash function $f \parallel g$. For $b = 16$, that means having to find only 8 collisions under $f$. So, the challenge's conclusion holds: combining hash functions by concatenation does not significantly increase collision resistance.

For our experiment here we use AES-128, CBC mode for $f$ and CTR mode (modified to operate on blocks) for $g$. To turn these encryption functions into hash functions we do as the challenge suggests, and use $b = 16$ and pad (with zeros) and trim (to the first two bytes) as necessary.

See Antoine Joux, Multicollisions in Iterated Hash Functions. Application to Cascaded Constructions for more discussion. That paper says that 128-bit hash functions are deprecated as being too small. Surely requiring $2^{64} \approx 10^{19}$ work is sufficient? But perhaps the concern is that fewer hash probes will still result in a non-negligible probability of finding a collision.

In [9]:
b = 16  # hash size in bits
B = b//8  # and bytes
key = b"YELLOW SUBMARINE"

def MD(message, hash, encryption_fn):
    # Assumes message is block-aligned.
    for i in range(0, len(message), 16):
        hash = encryption_fn(message[i:i+16], hash+bytes(16-B))[:B]
    return hash

def f(message, hash):
    return MD(
        message,
        hash,
        lambda b, h: aes_128_cbc_encrypt(b, key, h, padding=False)
    )

def g(message, hash):
    return MD(
        message,
        hash,
        lambda b, h: aes_128_ctr_crypt(b, key, h)
    )

def collisions_gen(message_gen, hash_fn, num_hashes):
    # Drawing messages from `message_gen`, yield collision pairs
    # (p_1, q_1), (p_2, q_2), ... as described above.  `num_hashes[0]`
    # is updated to count the number of hashes required to find the
    # collisions.
    seen = {}
    ih = bytes(B)  # input hash value
    while True:
        try:
            m = next(message_gen)
        except StopIteration:
            return
        h = hash_fn(m, ih)
        num_hashes[0] += 1
        if h in seen:
            yield seen[h], m
            seen.clear()
            ih = h
        else:
            seen[h] = m

def random_gen():
    while True:
        yield randbytes(16)

num_pairs = b//2 + 2  # see discussion below

num_hashes = [0]
pairs = list(islice(collisions_gen(random_gen(), f, num_hashes), num_pairs))

print("Number of hashes required to find one collision...")
print(f"    Estimated number, 50% probability: {2**(b//2)}")
print(f"    Average observed: {num_hashes[0]//num_pairs}")
Number of hashes required to find one collision...
    Estimated number, 50% probability: 256
    Average observed: 334

How many collision pairs are required to generate a large enough message pool to find a collision under $g$? In practice we observed that starting with $b/2 = 8$ we found a collision about 25% of the time; adding a 9th pair, about 75% of the time; and adding a 10th pair, for a pool size of 1,024 messages, 99% of the time.

In [10]:
def combinatorial_gen(pairs):
    for i in range(2**len(pairs)):
        m = b""
        for j in range(len(pairs)):
            m += pairs[j][i&(2**j) != 0]
        yield m

num_hashes = [0]
found = any(collisions_gen(combinatorial_gen(pairs), g, num_hashes))

print(f"Found collision under g: {found}")
print(f"Number of messages hashed: {num_hashes[0]}")
Found collision under g: True
Number of messages hashed: 85

Challenge 53: Kelsey and Schneier's expandable messages¶

One of the basic yardsticks we use to judge a cryptographic hash function is its resistance to second preimage attacks. That means that if I give you x and y such that H(x) = y, you should have a tough time finding x' such that H(x') = H(x) = y.

How tough? Brute-force tough. For a 2^b hash function, we want second preimage attacks to cost 2^b operations.

This turns out not to be the case for very long messages.

Consider the problem we're trying to solve: we want to find a message that will collide with H(x) in the very last block. But there are a ton of intermediate blocks, each with its own intermediate hash state.

What if we could collide into one of those? We could then append all the following blocks from the original message to produce the original H(x). Almost.

We can't do this exactly because the padding will mess things up.

What we need are expandable messages.

In the last problem we used multicollisions to produce 2^n colliding messages for n*2^(b/2) effort. We can use the same principles to produce a set of messages of length (k, k + 2^k - 1) for a given k.

Here's how:

  • Starting from the hash function's initial state, find a collision between a single-block message and a message of 2^(k-1)+1 blocks. DO NOT hash the entire long message each time. Choose 2^(k-1) dummy blocks, hash those, then focus on the last block.
  • Take the output state from the first step. Use this as your new initial state and find another collision between a single-block message and a message of 2^(k-2)+1 blocks.
  • Repeat this process k total times. Your last collision should be between a single-block message and a message of 2^0+1 = 2 blocks.

Now you can make a message of any length in (k, k + 2^k - 1) blocks by choosing the appropriate message (short or long) from each pair.

Now we're ready to attack a long message M of 2^k blocks.

  1. Generate an expandable message of length (k, k + 2^k - 1) using the strategy outlined above.
  2. Hash M and generate a map of intermediate hash states to the block indices that they correspond to.
  3. From your expandable message's final state, find a single-block "bridge" to intermediate state in your map. Note the index i it maps to.
  4. Use your expandable message to generate a prefix of the right length such that len(prefix || bridge || M[i..]) = len(M).

The padding in the final block should now be correct, and your forgery should hash to the same value as M.


See John Kelsey and Bruce Schneier, Second Preimages on $n$-bit Hash Functions for Much Less than $2^n$ Work.

This is a very theoretical result. Given a hash size of $b$ bits, the Birthday paradox tells us there is a 50% chance of finding a collision after hashing $2^{b/2}$ random messages; that's unavoidable. But the hope remains that both preimage attacks (given a hash value, find a message that hashes to it) and second preimage attacks (given a message, find another message that has the same hash value) still require a full scan of the hash space, i.e., order $2^b$ hashes. This challenge shows that's not the case, but as will be seen, a successful attack would require significant computation and space. The choice of $k$ figures greatly.

The Merkle-Damgard construction requires some form of terminal "hardening" to be secure, usually by explicitly hashing a block containing the message's length after hashing the message itself (this is the padding referred to above). The reason for this is subtle, and relates to the proof that M-D is no more prone to collisions than is the underlying compression function. The above paper says that such hardening foils any attempt to collide messages of different lengths, though that's not obvious to us. Given messages $p$ and $q$ with $|p| \ne |q|$, why couldn't it still be that $f(p \parallel |p|, h_0) = f(q \parallel |q|, h_0)$? In any case, the paper's claim explains why this challenge is focused on creating a colliding message of the same length as the original. (We don't append length blocks in our experiment below.)

The design of the expandable message is clever. We construct a table of colliding short and long message pairs as depicted below. Then, for desired message length $l$, $k \le l \le k+2^k-1$, we concatenate a message selected from each pair, choosing the long message iff $l-k$ has a set bit in that position.

bit position short length long length input hash value common output hash value
$k-1$ $1$ $1+2^{k-1}$ $h_0$ $h_1$
$k-2$ $1$ $1+2^{k-2}$ $h_1$ $h_2$
... ... ... ... ...
$1$ $1$ $1+2^1$ $h_{k-2}$ $h_{k-1}$
$0$ $1$ $1+2^0$ $h_{k-1}$ $h_k$

Starting with the basic metric of $2^{b/2}$ "work" to find a random collision, Kelsey and Schneier double that in their estimate of the work required to find a collision between random short and long messages. This gets repeated $k$ times, so the work to construct the table (adding in the hashes to build the long messages, which the paper ignores) is $2^k + k \cdot 2^{b/2+1}$ hashes.

The construction presented in this challenge feels odd because the expandable message is entirely independent of the message we're trying to collide with. Instead of there being a relationship, the algorithm has us searching for a "bridge block" that will continue the hashing of the expandable message and match the hash of some block in $M$. But wait, isn't that just a second preimage attack in its own right, and therefore require order $2^b$ hashes? Ah, but here's where the incremental hashing of $M$ comes into play. If $M$ has $2^k$ blocks, and if the intermediate hashes of those blocks are indexed in a hash table (requiring $2^k$ hashes*), then we can compare the hash of a candidate bridge block against $2^k$ blocks simultaneously with the effect of reducing the work to $2^{b-k}$ hashes. This is the motivation for making $k$ as large as possible. (Actually, Kelsey and Schneier double the amount of work to $2^{b-k+1}$ hashes. No explanation is given, and we don't understand why.)

For our experiment we continue as before and use hash size $b = 16$ and AES-128, CBC mode for our compression function, and use $k = 7$ for reasons we will discuss later. First, let's just confirm that we can create a colliding message.

(*) The paper does not include this quantity in the work required to find a bridge block, but we do.

In [11]:
class ExpandableMessage:

    def __init__(self, k, ih):
        self.table = []
        self.num_hashes = 0  # number of hashes required to build the table
        h = ih
        for e in range(k-1, -1, -1):
            short, long, h = self.find_collision(2**e+1, h)
            self.table.append((short, long))
        self.last_hash = h

    def find_collision(self, l, ih):
        # Find a short message (block length 1) and long message
        # (block length l > 1) that collide.
        # Return (short, long, hash).
        long_base = randbytes((l-1)*16)
        long_base_h = f(long_base, ih)
        self.num_hashes += l-1
        seen = {}
        while True:
            short = randbytes(16)
            h = f(short, ih)
            self.num_hashes += 1
            if h in seen:
                if len(seen[h]) > 16:
                    return short, seen[h], h
            else:
                seen[h] = short
            long_tail = randbytes(16)
            h = f(long_tail, long_base_h)
            self.num_hashes += 1
            if h in seen:
                if len(seen[h]) == 16:
                    return seen[h], long_base+long_tail, h
            else:
                seen[h] = long_base+long_tail

    def construct(self, l):
        # Return message of length l.
        k = len(self.table)
        return b"".join(
            self.table[i][(l-k)&(2**(k-1-i)) != 0]
            for i in range(k)
        )

def construct_collision(message, k):
    # Return (colliding message, number of hashes to build expandable
    # message, number of hashes to find bridge block).
    # First, construct an expandable message.
    em = ExpandableMessage(k, zero[:B])
    # Incrementally hash the message.
    inc_hashes = {}
    h = zero[:B]
    num_hashes = 0
    for i in range(len(message)//16):
        h = f(message[i*16:(i+1)*16], h)
        num_hashes += 1
        if i >= k:
            inc_hashes[h] = i
    # Find a bridge block.
    while True:
        bridge = randbytes(16)
        h = f(bridge, em.last_hash)
        num_hashes += 1
        if h in inc_hashes:
            i = inc_hashes[h]
            break
    # Construct and return a colliding message.
    forged = em.construct(i) + bridge + message[(i+1)*16:]
    return forged, em.num_hashes, num_hashes

k = 7
M = randbytes(2**k * 16)
M2, num_hashes_em, num_hashes_bb = construct_collision(M, k)

print(len(M), f(M, zero[:B]).hex())
print(len(M2), f(M2, zero[:B]).hex())
print(M == M2)
2048 dc92
2048 dc92
False

And now for the accounting. The question here is not, Can we create a colliding message? We always can, just keep looking. The question is, How much more cheaply can we do it than order $2^b$ work? The figures below are for $k = 7$.

In [12]:
print("Number of hashes required...")
print(f"    Desired minimum effort (2^b): {2**b}")
print(f"    Paper's estimate: {(2**k + k*2**(b//2+1)) + (2**k + 2**(b-k))}")
print(f"    Actual: {num_hashes_em + num_hashes_bb}")
Number of hashes required...
    Desired minimum effort (2^b): 65536
    Paper's estimate: 4352
    Actual: 3030

What value do we choose for $k$? Increasing $k$ reduces the effort to find the bridge block (recall $2^{b-k}$ hashes) but it also exponentially increases the size of the message ($2^k$ blocks) and the storage required for the incremental hash table. We found empirically that for $b = 16$, work was minimized in the range $6 \le k \le 8$.

In [13]:
def run_experiments():
    num_iterations = 100
    df = pd.DataFrame({
        "k": pd.Series(dtype=int),
        "avg_hashes_em": pd.Series(dtype=float),
        "avg_hashes_bb": pd.Series(dtype=float)
    })
    for k in range(4, 9):
        M = randbytes(2**k * 16)
        sum_hashes_em = sum_hashes_bb = 0
        for _ in range(num_iterations):
            _, num_hashes_em, num_hashes_bb = construct_collision(M, k)
            sum_hashes_em += num_hashes_em
            sum_hashes_bb += num_hashes_bb
        df.loc[len(df)] = {
            "k": k,
            "avg_hashes_em": sum_hashes_em/num_iterations,
            "avg_hashes_bb": sum_hashes_bb/num_iterations
        }
    df.set_index("k", inplace=True)
    df["total"] = df.avg_hashes_em + df.avg_hashes_bb
    return df

df = run_experiments()

plt.plot(df.avg_hashes_em)
plt.plot(df.avg_hashes_bb)
plt.plot(df.total, linewidth=3)
plt.xlabel("k")
plt.xticks(range(4, 9))
plt.ylabel("number of hashes")
plt.legend(["build expandable message", "find bridge block", "total"])
None
No description has been provided for this image

Challenge 54: Kelsey and Kohno's Nostradamus attack¶

Hash functions are sometimes used as proof of a secret prediction.

For example, suppose you wanted to predict the score of every Major League Baseball game in a season. (2,430 in all.) You might be concerned that publishing your predictions would affect the outcomes.

So instead you write down all the scores, hash the document, and publish the hash. Once the season is over, you publish the document. Everyone can then hash the document to verify your soothsaying prowess.

But what if you can't accurately predict the scores of 2.4k baseball games? Have no fear - forging a prediction under this scheme reduces to another second preimage attack.

We could apply the long message attack from the previous problem, but it would look pretty shady. Would you trust someone whose predicted message turned out to be 2^50 bytes long?

It turns out we can run a successful attack with a much shorter suffix. Check the method:

  1. Generate a large number of initial hash states. Say, 2^k.
  2. Pair them up and generate single-block collisions. Now you have 2^k hash states that collide into 2^(k-1) states.
  3. Repeat the process. Pair up the 2^(k-1) states and generate collisions. Now you have 2^(k-2) states.
  4. Keep doing this until you have one state. This is your prediction.
  5. Well, sort of. You need to commit to some length to encode in the padding. Make sure it's long enough to accommodate your actual message, this suffix, and a little bit of glue to join them up. Hash this padding block using the state from step 4 - THIS is your prediction.

What did you just build? It's basically a funnel mapping many initial states into a common final state. What's critical is we now have a big field of 2^k states we can try to collide into, but the actual suffix will only be k+1 blocks long.

The rest is trivial:

  1. Wait for the end of the baseball season. (This may take some time.)
  2. Write down the game results. Or, you know, anything else. I'm not too particular.
  3. Generate enough glue blocks to get your message length right. The last block should collide into one of the leaves in your tree.
  4. Follow the path from the leaf all the way up to the root node and build your suffix using the message blocks along the way.

The difficulty here will be around 2^(b-k). By increasing or decreasing k in the tree generation phase, you can tune the difficulty of this step. It probably makes sense to do more work up-front, since people will be waiting on you to supply your message once the event passes. Happy prognosticating!


See John Kelsey and Tadayoshi Kohno, Herding Hash Functions and the Nostradamus Attack.

This is a slight variation on the previous challenge, and despite the catchy framing is again quite theoretical in nature. In the previous challenge we used brute force to find a bridge block between an expandable message and a set of entry points (the intermediate hashes of a $2^k$-block message). The feasibility of doing this hinges on choosing $k$ large enough that the work required, $2^{b-k}$, is sufficiently reduced. Here we start with a known message (our prediction) and find a bridge block between it and the $2^k$ leaves of a specially constructed tree (or "funnel") of random message blocks.

As noted in the previous challenge, a message's length would ordinarily be incorporated into the message's final hash value. We omit lengths here, but are careful to maintain the same message length by using a fixed length for our prediction and by bridging into leaf nodes of the tree only, so that the path up the tree is of constant length. (In principle we could bridge into any node in the tree, as all nodes ultimately hash to the same value, but then some kind of expandable message would need to be incorporated to counter the effect of traveling different length paths through the tree.)

For our demonstration below we imagine predicting whether the British will arrive by land or by sea. We compute the tree by simple brute force, but the paper authors note that there are more efficient ways of doing this.

First, create a pre-event prediction.

In [14]:
k = 4
prediction_buffer_len = 6  # in blocks; roughly 100 bytes

class Funnel:

    class Node:
        def __init__(self, block, hash):
            self.block, self.hash = block, hash
            self.parent = None

    def __init__(self, k):
        # Create the leaf level of 2^k random messages with unique hash values.
        self.leaf_hashes = {}
        while len(self.leaf_hashes) < 2**k:
            m = randbytes(16)
            h = f(m, zero[:B])
            if h not in self.leaf_hashes:
                self.leaf_hashes[h] = Funnel.Node(m, h)
        level = list(self.leaf_hashes.values())
        # Now create the remaining levels of the tree up to the root.
        while len(level) > 1:
            next_level = []
            for i in range(0, len(level), 2):
                while True:
                    m = randbytes(16)
                    h1, h2 = f(m, level[i].hash), f(m, level[i+1].hash)
                    if h1 == h2:
                        n = Funnel.Node(m, h1)
                        level[i].parent = level[i+1].parent = n
                        next_level.append(n)
                        break
            level = next_level
        self.final_hash = h1

funnel = Funnel(k)

print(f"Pre-event prediction length: {(prediction_buffer_len+k+1)*16}")
print(f"Pre-event pediction hash: {funnel.final_hash.hex()}")
Pre-event prediction length: 176
Pre-event pediction hash: aacc

Now let's make a prediction after the fact.

In [15]:
def attach_funnel(message, funnel):
    message_hash = f(message, zero[:B])
    while True:
        bridge = randbytes(16)
        h = f(bridge, message_hash)
        if h in funnel.leaf_hashes:
            message += bridge
            # Now walk up the tree to the root node.
            n = funnel.leaf_hashes[h].parent
            while n != None:
                message += n.block
                n = n.parent
            break
    return message

prediction = b"A second lamp in the belfry burns!"
prediction += b" "*(prediction_buffer_len*16-len(prediction))
prediction = attach_funnel(prediction, funnel)

print(f"Post-event prediction length: {len(prediction)}")
print(f"Post-event prediction hash: {f(prediction, zero[:B]).hex()}")
Post-event prediction length: 176
Post-event prediction hash: aacc

Challenge 56: RC4 single-byte biases¶

RC4 is popular stream cipher notable for its usage in protocols like TLS, WPA, RDP, &c.

It's also susceptible to significant single-byte biases, especially early in the keystream. What does this mean?

Simply: for a given position in the keystream, certain bytes are more (or less) likely to pop up than others. Given enough encryptions of a given plaintext, an attacker can use these biases to recover the entire plaintext.

Now, search online for On the Security of RC4 in TLS and WPA. This site is your one-stop shop for RC4 information.

Click through to "RC4 biases" on the right.

These are graphs of each single-byte bias (one per page). Notice in particular the monster spikes on z16, z32, z48, etc. (Note: these are one-indexed, so z16 = keystream[15].)

How useful are these biases?

Click through to the research paper and scroll down to the simulation results. (Incidentally, the whole paper is a good read if you have some spare time.) We start out with clear spikes at 2^26 iterations, but our chances for recovering each of the first 256 bytes approaches 1 as we get up towards 2^32.

There are two ways to take advantage of these biases. The first method is really simple:

  1. Gain exhaustive knowledge of the keystream biases.
  2. Encrypt the unknown plaintext 2^30+ times under different keys.
  3. Compare the ciphertext biases against the keystream biases.

Doing this requires deep knowledge of the biases for each byte of the keystream. But it turns out we can do pretty well with just a few useful biases - if we have some control over the plaintext.

How? By using knowledge of a single bias as a peephole into the plaintext.

Decode this secret:

QkUgU1VSRSBUTyBEUklOSyBZT1VSIE9WQUxUSU5F

And call it a cookie. No peeking!

Now use it to build this encryption oracle:

RC4(your-request || cookie, random-key)

Use a fresh 128-bit key on every invocation.

Picture this scenario: you want to steal a user's secure cookie. You can spawn arbitrary requests (from a malicious plugin or somesuch) and monitor network traffic. (Ok, this is unrealistic - the cookie wouldn't be right at the beginning of the request like that - this is just an example!)

You can control the position of the cookie by requesting "/", "/A", "/AA", and so on.

Build bias maps for a couple chosen indices (z16 and z32 are good) and decrypt the cookie.


This work was first described in Nadhem J. AlFardan, Daniel J. Bernstein, Kenneth G. Paterson, Bertram Poettering, and Jacob C. N. Schuldt, On the security of RC4 in TLS.

RC4 is a nonce-less stream cipher. The algorithm was a trade secret and licensed for profit by its creator, and the name was trademarked. In response, the security community reverse-engineered the algorithm and began identifying its weaknesses. The trademarking issue was sidestepped by referring to the algorithm as ACR4, or "alleged RC4." Today the algorithm is prohibited for use in TLS. So much for security by obscurity.

Recall from Challenge 18 that a stream cipher produces a "keystream" of random-looking bytes $Z_r$, and encryption of plaintext bytes $P_r$ is simply $C_r = P_r \oplus Z_r$. AlFardan et al discovered that the values of the first bytes of the RC4 keystream are not uniformly distributed for randomly generated keys, but exhibit biases in that some values occur slightly more often than others. While they examined only the first few keystream bytes, apparently that was sufficient to attack RC4's use in TLS and WPA.

For example, the probability distribution of values of byte 15 (the 16th byte) is:

As can be seen, there is a predominant bias toward value 240 and some smaller biases toward values 0 and 16. And for byte 31 (the 32nd byte) there is a predominant bias toward value 224 along with smaller biases toward 0 and 32:

The value distributions of other initial keystream bytes are similar, though the biases are smaller. Note that these biases are intrinsic to the algorithm; they're unrelated to the plaintext being encrypted.

Now, if we encrypt a given (unknown to us) plaintext many times with randomly generated keys we will form, for each byte position $r$, a distribution of ciphertext values $C_r$. The key insight here is that, given that $P_r$ is being held constant, this empirically observed distribution of $C_r$ will necessarily match the underlying, theoretical distribution of $Z_r$, just permuted by the action of $P_r \oplus Z_r$. (XOR with a constant, over a domain $[0, 2^n-1]$, is bijective.) AlFardan's approach is to permute the (precomputed) theoretical distribution of $Z_r$ by every possible plaintext value, see which of those distributions most closely matches the distribution of $C_r$ observed (using cosine similarity as the metric), and from that identify the likely plaintext value.

We choose the similar but simpler approach suggested in the challenge statement. The cookie is 30 bytes in length; we pad it to 32. After encrypting the cookie many times, we identify the most frequently-occurring values in byte positions 15 and 31. Assuming that those values correspond to the theoretical predominant bias values 240 and 224, respectively, we recover the plaintext values for those two byte positions via $P_r = C_r \oplus Z_r$. The cookie is then shifted by one byte and the process repeated to pick up two more plaintext values, and so on.

We achieved reliable results using $2^{24}$ encryptions.

Before presenting the solution, it may be helpful to see an example of the kind of signal we're looking for. Here are the empirical byte value distributions for a byte with a strong bias (byte 15) and one without such a strong bias (byte 12).

In [16]:
num_encryptions = 2**24
byte_15_bias = 240
byte_31_bias = 224

cookie = b64decode("QkUgU1VSRSBUTyBEUklOSyBZT1VSIE9WQUxUSU5F")

def rc4_byte_counts(ptext):
    n = len(ptext)
    a = [[0]*256 for _ in range(n)]
    for _ in range(num_encryptions):
        key = randbytes(16)
        ctext = rc4_encrypt(ptext, key)
        for i in range(n):
            a[i][ctext[i]] += 1
    return a

df = pd.DataFrame(
    { f"byte_{i}": a for i, a in enumerate(rc4_byte_counts(cookie)) }
)

plt.axhline(y=1/256, color="r", label=None)
plt.plot(df.byte_12/df.byte_12.sum(), label="byte 12")
plt.plot(df.byte_15/df.byte_15.sum(), label="byte 15")
plt.xlabel("byte values (0-255)")
plt.ylabel("frequency")
plt.xticks([])
plt.legend()
None
No description has been provided for this image

XOR-ing the most frequently-occurring value of byte 15 with the theoretical bias value yields the recovered plaintext value:

In [17]:
print(chr(cookie[15]))
print(chr(df.byte_15.argmax() ^ byte_15_bias))
K
K

Now for our solution, which takes 32 minutes to run.

In [18]:
recovered = bytearray(32)

assert len(cookie) == 30
prefix = b"AA"  # add two starting bytes for an even 32

for i in range(16):
    a = rc4_byte_counts(prefix + cookie)
    recovered[15-i] = a[15].index(max(a[15])) ^ byte_15_bias
    recovered[31-i] = a[31].index(max(a[31])) ^ byte_31_bias
    prefix += b"A"

print(bytes(recovered)[2:])
b'BE SURE TO DRINK YOUR OVALTINE'