Skip to main content
Cipher Visualizers home

Breaking the One-Time Pad by Reusing Its Key

A one-time pad is unbreakable, but only if the key is used once. Reuse it across several messages and the key cancels out. Learn the many-time-pad attack and crib-dragging, with Python that recovers the messages from the ciphertexts alone.

Pashalis Laoutaris 14 min read

Try it

Interactive Many-Time-Pad Attack Breaker

🔐 Many-Time-Pad Attack

One random key, reused across every message, the one mistake that breaks a one-time pad. The attacker sees only the ciphertexts, never the key. The seed just makes each run reproducible.
Enter text and click a button to start!

Step 1: The Intercepted Ciphertexts

The same key enciphers every message. Each row is one ciphertext, shown as hex. The key itself is never transmitted and never seen.

Step 2: Recover the Key, Column by Column

Where any message has a space, XORing that column's bytes flips letter case and gives it away. A recovered column reveals that character of every message. A dot marks a column no space exposed.

Step 3: Crib-Dragging Two Messages

The XOR of two ciphertexts equals the XOR of the two plaintexts. Slide a guessed word along it; where it lands, the readable output is the other message's text at that spot.

Click "Intercept & Attack" to recover the messages from the reused-key ciphertexts alone.

Breaking the One-Time Pad by Reusing Its Key

Introduction

The one-time pad is the only cipher with a proof of perfect secrecy. A ciphertext reveals nothing about its message, no matter how much computing power an attacker has. That proof rests on one rule: the key is random, as long as the message, and used exactly once.

This guide breaks the rule, not the cipher. When the same key encrypts two or more messages, the key cancels out of the math, and the messages fall out with it. This is the single mistake that has sunk real systems, most famously the reused Soviet pad material that the Venona project read for years. The attack below recovers the text of seven messages from their ciphertexts alone, never seeing the key.

Table of Contents

Why Reuse Is Fatal

A one-time pad enciphers a message by XORing it with the key, byte by byte. Call the messages P1 and P2, the key K, and the ciphertexts C1 and C2:

C1 = P1 XOR K
C2 = P2 XOR K

Now XOR the two ciphertexts together. The key appears twice, and anything XORed with itself is zero, so the key vanishes:

C1 XOR C2 = (P1 XOR K) XOR (P2 XOR K) = P1 XOR P2

The attacker is left with P1 XOR P2, the XOR of two unknown plaintexts, with no key in sight. That looks like a hard problem, but it is not. Natural language is highly redundant, and P1 XOR P2 carries the structure of both messages at once. With enough reused-key ciphertexts, that structure is enough to peel the messages apart.

A one-time pad used correctly never reaches this point. Each message gets its own fresh key, so no two ciphertexts ever share one, and C1 XOR C2 mixes two different keys that do not cancel. The whole attack depends on the one forbidden shortcut of reusing key material.

The Space Trick

The fastest way to recover the messages uses one quirk of ASCII text. The space character is 0x20, and a letter XORed with 0x20 flips its case. An uppercase A becomes lowercase a, and the reverse. Nothing else in ordinary text behaves this way at that bit.

Look at a single column, the same byte position across every ciphertext. Within that column, Ci XOR Cj equals Pi XOR Pj. If one of those two plaintext bytes is a space, the result is the other byte with its case flipped, which still lands in the letter range. If neither is a space, the result is usually some non-letter byte.

That gives a detector. For each ciphertext byte in a column, XOR it against every other byte in the same column and count how many results are letters. The byte that scores highest is almost certainly sitting over a space in its own message. Once a space is found at position j of message i, the key byte is immediate:

K[j] = Ci[j] XOR 0x20

One recovered key byte decrypts that column for every message at once. Sweep all the columns, and the key fills in wherever any message happened to have a space. More messages mean more spaces, which means more of the key. The columns where no message had a space stay blank, and those gaps are usually easy to fill by reading the surrounding words.

Crib-Dragging

The space trick is automatic but partial. Crib-dragging is the manual counterpart, and it recovers whole words. A crib is a word you expect to appear somewhere in one of the messages, such as the or attack.

Take C1 XOR C2, which equals P1 XOR P2. Slide the crib along it, and at each position XOR the crib against that slice. Wherever the crib actually sits in P1, the crib cancels P1 at that spot and leaves the matching slice of P2 in the clear. A readable fragment is the tell: it is a real piece of the other message. A fragment of garbage means the crib does not belong there.

Guessing one word therefore hands back a word from the other message, which suggests the next crib, and the two messages unzip each other a few letters at a time. The space trick and crib-dragging work well together. The first fills in scattered letters across all messages, and the second turns a lucky guess into a whole phrase.

Interactive Visualizer

The visualizer above runs the whole attack on reused-key ciphertexts. Choose how many messages the attacker intercepts, all under one reused key, and a seed that fixes that key. The attacker code never reads the key.

  • Step 1 shows the intercepted ciphertexts as hex. The key is marked as hidden, because the attack never uses it.
  • Step 2 recovers the key column by column with the space trick, then shows every message with its recovered letters in green and a dot where no space exposed that column.
  • Step 3 drags a crib word of your choice along two of the ciphertexts and lists every readable placement.

Raise the message count and watch more of each message appear, since every extra ciphertext brings more spaces to exploit.

A Worked Example

With the seed 2026 and seven English messages under one reused key, the key is 69 bytes long, the length of the longest message. The attacker sees only the ciphertexts. The first one starts with these bytes:

ciphertext 1: 23123374464c59f986414a0adf7fc15e...

Running the space trick over all seven recovers 48 of the 69 key columns. Every recovered key byte matches the real key, checked against the key the generator actually produced. The first message decrypts to this, with a dot wherever no message had a space in that column:

.h. one ..me p.. is p.r.e.tl. se.ret when the k.y i. .eve. r.us..

That is already readable. The gaps fill themselves in by eye: “the one time pad is perfectly secret when the key is never reused”. The number of recovered columns climbs steadily with each extra message, as more spaces come into play:

Messages Key columns recovered (of 69)
2 21
3 26
4 30
5 36
6 40
7 48

Crib-dragging the word the along C1 XOR C3 reveals xor at offset 0. That is the start of the third message, “xor of two ciphertexts…”, pulled out by a guessed word from the first. No key was ever involved.

Python Implementation

The code generates the reused-key ciphertexts, runs the space trick to recover the key, and crib-drags two messages. The key generator is a small seeded function, ported identically to the visualizer’s JavaScript, so both sides produce the same key for a seed.

# many_time_pad.py
# The many-time-pad attack: a correctly used one-time pad is unbreakable, but reusing one key for
# several messages gives it all away. XOR of two ciphertexts equals XOR of the two plaintexts, with
# the key gone. This is the flaw the Venona project exploited against reused Soviet pad material.


# A small seeded generator, ported identically to the page's JavaScript so both draw the same key.
def rng_stream(seed):
    a = seed & 0xFFFFFFFF
    while True:
        a = (a + 0x6D2B79F5) & 0xFFFFFFFF
        t = a
        t = ((t ^ (t >> 15)) * (1 | a)) & 0xFFFFFFFF
        t = (t ^ (t + ((t ^ (t >> 7)) * (61 | t) & 0xFFFFFFFF) & 0xFFFFFFFF)) & 0xFFFFFFFF
        yield (t ^ (t >> 14)) & 0xFFFFFFFF


def key_bytes(seed, n):
    gen = rng_stream(seed)
    return bytes(next(gen) & 0xFF for _ in range(n))


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


def letterish(x):
    return 65 <= x <= 90 or 97 <= x <= 122 or x == 0


def recover_key(ciphertexts):
    # In each column, the message most likely to hold a space is the one whose byte, XORed with every
    # other byte in the column, lands in the letter range most often, since space ^ letter flips case.
    width = max(len(c) for c in ciphertexts)
    key, known = bytearray(width), [False] * width
    for j in range(width):
        col = [c[j] for c in ciphertexts if j < len(c)]
        if len(col) < 2:
            continue
        best, best_score = None, -1
        for i, ci in enumerate(col):
            score = sum(1 for k, ck in enumerate(col) if k != i and letterish(ci ^ ck))
            if score > best_score:
                best_score, best = score, ci
        if best_score >= max(1, (len(col) - 1) * 0.6):   # trust it only if most of the column agrees
            key[j], known[j] = best ^ 0x20, True
    return bytes(key), known


def decrypt(ciphertext, key, known):
    return ''.join(chr(b ^ key[j]) if known[j] else '.' for j, b in enumerate(ciphertext))


def crib_drag(c1, c2, crib):
    x = xor(c1, c2)
    hits = []
    for off in range(len(x) - len(crib) + 1):
        frag = xor(x[off:off + len(crib)], crib.encode())
        if all(32 <= b < 127 for b in frag):
            hits.append((off, frag.decode()))
    return hits


if __name__ == "__main__":
    messages = [
        b"the one time pad is perfectly secret when the key is never reused",
        b"but reusing a single key across many messages destroys that secrecy",
        b"xor of two ciphertexts equals xor of the two underlying plaintexts",
        b"the venona project broke soviet cables that reused pad key material",
        b"a space xored with a letter flips its case and reveals the key byte",
        b"crib dragging slides a guessed word until the output reads as english",
        b"never reuse a one time pad key not even once for any two messages ok",
    ]
    width = max(len(m) for m in messages)
    key = key_bytes(2026, width)
    ciphertexts = [xor(m, key) for m in messages]
    print("reused key starts with:", key[:16].hex())
    print("ciphertext 1 starts with:", ciphertexts[0][:16].hex())

    rec, known = recover_key(ciphertexts)
    assert all(rec[j] == key[j] for j in range(width) if known[j])
    print(f"recovered {sum(known)} of {width} key columns, all correct")
    print("message 1:", decrypt(ciphertexts[0], rec, known))

    hits = crib_drag(ciphertexts[0], ciphertexts[2], "the ")
    print("crib 'the ' against C1 xor C3 at offset 0:", hits[0][1])

Running it prints the real numbers from the worked example:

reused key starts with: 577a565429223cd9f228276fff0fa03a
ciphertext 1 starts with: 23123374464c59f986414a0adf7fc15e
recovered 48 of 69 key columns, all correct
message 1: .h. one ..me p.. is p.r.e.tl. se.ret when the k.y i. .eve. r.us..
crib 'the ' against C1 xor C3 at offset 0: xor

For Fun: The Whole Key Recovery in a Few Lines

The space trick is small enough to write in one comprehension. For each column, pick the byte whose XOR against the rest is letter-like most often, and that byte XOR 0x20 is the key byte. This version drops the confidence gate of the full code, so columns that no space exposed come out as noise instead of dots, but every exposed letter still reads correctly:

def rng(seed, n):
    a = seed & 0xFFFFFFFF; o = []
    for _ in range(n):
        a = (a + 0x6D2B79F5) & 0xFFFFFFFF; t = a
        t = ((t ^ (t >> 15)) * (1 | a)) & 0xFFFFFFFF
        t = (t ^ (t + ((t ^ (t >> 7)) * (61 | t) & 0xFFFFFFFF) & 0xFFFFFFFF)) & 0xFFFFFFFF
        o.append((t ^ (t >> 14)) & 0xFF)
    return bytes(o)

M = [b"the one time pad is perfectly secret when the key is never reused", b"but reusing a single key across many messages destroys that secrecy", b"xor of two ciphertexts equals xor of the two underlying plaintexts", b"the venona project broke soviet cables that reused pad key material", b"a space xored with a letter flips its case and reveals the key byte", b"crib dragging slides a guessed word until the output reads as english", b"never reuse a one time pad key not even once for any two messages ok"]
w = max(map(len, M)); K = rng(2026, w); C = [bytes(a ^ b for a, b in zip(m, K)) for m in M]
L = lambda x: 65 <= x <= 90 or 97 <= x <= 122 or x == 0
key = [max(col, key=lambda c: sum(L(c ^ d) for d in col)) ^ 32 for col in zip(*C)]
print(''.join(chr(c ^ k) for c, k in zip(C[0], key)))

It prints the first message with the exposed letters in place: h one &me p2- is p r#e&tl< se ret when the k y i* eve r6us. The readable words line up with the full version, and the junk marks the columns where no space fell.

Limitations

This is a teaching demonstration of a real attack, not a general-purpose cracking tool:

  • ASCII text only. The space trick assumes messages are English text in ASCII, where spaces are common and 0x20 flips letter case. Binary data, other encodings, or compressed text break the heuristic.
  • It needs several messages. Two reused-key ciphertexts already leak P1 XOR P2, but the automatic key recovery grows much stronger with more messages, since each one adds spaces to exploit. Columns no message exposes stay blank.
  • The messages are equal-ish in length and aligned. The demo XORs from the first byte of each message. Real traffic with offsets, headers, or padding needs alignment work first.
  • The key is a plain reused pad. This attack is about one key used many times. It says nothing about a correctly used one-time pad, which remains unbreakable, or about stream ciphers whose keystream never repeats.

Security Status

  • A correct one-time pad is still perfect. Nothing here dents the proof of secrecy. The attack only exists because the key was reused, which the one-time pad explicitly forbids. The lesson is operational, not mathematical.
  • This is why stream ciphers carry a nonce. A stream cipher is a one-time pad with a keystream generated from a key and a nonce. Reusing a nonce under the same key reproduces the keystream, which is exactly key reuse, and the same attack applies. The repeated-nonce failures covered in the AES-GCM and ChaCha20-Poly1305 guides are this attack in modern dress.
  • Venona was the real thing. Soviet one-time pads were manufactured in duplicate under wartime pressure, and some pages were used twice. United States cryptanalysts spent decades reading the overlaps, a direct historical instance of the method shown here.

FAQ

Does this break the one-time pad?

No, it breaks key reuse. A one-time pad with a fresh, random, single-use key is provably unbreakable. The attack only works once the same key has enciphered more than one message, which the pad’s own rules prohibit.

Why does XORing two ciphertexts help?

Because the shared key cancels. C1 XOR C2 equals P1 XOR P2, a combination of the two plaintexts with no key left in it. The redundancy of natural language then lets the two messages be separated.

Why do spaces matter so much?

A space is 0x20, and XORing it with a letter flips the letter’s case into another letter. That makes a space detectable in the XOR of two texts, and a detected space reveals the key byte directly. Spaces are also the most common character in English, so they appear in many columns.

How many messages do I need?

Two already leak the XOR of their plaintexts, which crib-dragging can attack. Automatic key recovery improves steadily with more messages. In the worked example, two messages recover 21 key columns and seven recover 48.

Is this the same as attacking a stream cipher with a repeated nonce?

Yes, it is the same thing in substance. A repeated nonce under the same key makes a stream cipher reproduce its keystream, so two ciphertexts share one pad. That is key reuse by another name, and the method on this page applies directly.

References

  1. Shannon, Claude. “Communication Theory of Secrecy Systems,” Bell System Technical Journal, 1949 (the perfect-secrecy proof that key reuse violates).

  2. National Security Agency. “The Venona Story,” center for cryptologic history. Available at: https://www.nsa.gov/portals/75/documents/about/cryptologic-heritage/historical-figures-publications/publications/coldwar/venona_story.pdf

  3. Mason, Joshua, Kathryn Watkins, Jason Eisner, and Adam Stubblefield. “A Natural Language Approach to Automated Cryptanalysis of Two-Time Pads,” ACM CCS 2006 (automated separation of reused-key plaintexts).

A visual representation of recovering the ADFGVX transposition and substitution to decrypt a message
Basic Classic Symmetric Breakers Advanced

Breaking the ADFGVX Cipher

ADFGVX fractionates each letter into two symbols and then shuffles them with a columnar transposition, the combination that made it Germany's toughest field cipher of 1918. This guide runs the modern automated attack: recover the transposition with the index of coincidence, then solve the substitution with quadgrams, and read a 330-letter message back out.

14 min read

A visual representation of index-of-coincidence period finding and column key recovery against the Nihilist cipher
Basic Classic Symmetric Breakers Intermediate

Breaking the Nihilist Cipher

The Nihilist cipher turns letters into Polybius numbers and adds a repeating keyword, which makes it a Vigenere cipher wearing a numeric disguise. Index of coincidence finds the period, and column-by-column statistics peel off the key. This guide runs the real attack and recovers a 514-letter message.

13 min read

A visual representation of simulated annealing recovering a Bifid Polybius square from ciphertext
Basic Classic Symmetric Breakers Advanced

Breaking the Bifid Cipher with Simulated Annealing

Bifid fractionates each letter into coordinates and shuffles them, which hides single-letter frequencies. Given the period, a hill climb over the Polybius square guided by English quadgram statistics peels the key apart anyway. This guide runs the real attack and recovers a 669-letter message.

13 min read