โ† Back to Writeups
kavklaw@llm ~ /writeups/htb-bleichenbacher-forgery

kavklaw@llm $ cat writeup.md

CloudCompany Email Forgery

Bleichenbacher's RSA Signature Forgery (e=3, CVE-2006-4339)

Crypto Medium HackTheBox 2026-02-06
RSA Bleichenbacher cryptography signature forgery

๐ŸŽฏ Challenge Overview

CloudCompany's email signing server uses RSA to sign outgoing emails. Threat actors impersonated the vendor by forging email signatures. We're given the server source code and need to replicate the attack โ€” forge a valid signature that's different from the original.

Before we dive in, let's make sure we understand the fundamental concepts this challenge is built on. If you already know RSA and digital signatures, feel free to skip ahead to the vulnerability analysis.

What Is RSA? (The 60-Second Version)

RSA is a system that uses two mathematically linked keys:

  • Public key (n, e) โ€” You share this with everyone. Anyone can use it to encrypt messages to you, or to verify your signatures.
  • Private key (d) โ€” You keep this secret. It lets you decrypt messages, or create signatures.

The math is elegant: raising a number to the power e and then to the power d (both modulo n) gives you back the original number. In other words: (me)d mod n = m. This works because of how e, d, and n are constructed from prime numbers.

What Is a Digital Signature?

A digital signature proves that a message really came from who it claims. Here's how RSA signatures work:

  1. Signing: The sender computes a hash of the message, then raises it to the power of their private key d: signature = hash(message)d mod n
  2. Verifying: The receiver raises the signature to the power of the public key e: result = signaturee mod n. If result equals the hash of the message, the signature is valid.

Without the private key d, you can't create a valid signature. At least, that's how it's supposed to work...

The Server Flow

The challenge server does the following:

  1. Generates a 2048-bit RSA key with e = 3 (this is unusual โ€” we'll explain why)
  2. Signs the email sender's address using PKCS#1 v1.5 + SHA-1
  3. Sends us the signed email with a certificate
  4. Asks us to provide a different valid signature for the same message
  5. If our forged signature passes verification AND differs from the original โ†’ flag

๐Ÿ” Vulnerability Analysis

There are two bugs here that, combined, make forgery trivial. Let's examine each one.

Bug #1: e = 3 (Dangerously Small Public Exponent)

class RSA():
    def __init__(self, key_length):
        self.e = 3  # โ† Dangerously small public exponent

The public exponent e is how "hard" it is to reverse an RSA operation. Standard RSA uses e = 65537. This challenge uses e = 3.

Why does this matter? Remember, verification computes signaturee mod n. With e = 3, we're computing signature3 mod n โ€” that's just a cube. And computing cube roots is easy โ€” even for numbers with thousands of digits.

Here's a simple analogy. Imagine RSA verification is like a lock that requires you to guess a number that, when cubed, gives a specific result. If the "cubing" is replaced by "raising to the 65537th power," guessing becomes impossibly hard. But cubing? You just compute the cube root. That's elementary math.

Let's make this concrete with small numbers:

# With e=3: Find s such that sยณ = target
# Target: 27 โ†’ s = โˆ›27 = 3. Trivial!

# With e=65537: Find s such that s^65537 = target
# This is computationally infeasible without the private key.

Bug #2: Broken PKCS#1 v1.5 Verification

Before we look at the code, let's understand what PKCS#1 v1.5 is supposed to look like. When you sign a message with RSA, the signature doesn't just contain the hash โ€” it contains a structured block that fills the entire key length (256 bytes for a 2048-bit key):

Proper PKCS#1 v1.5 signature block (256 bytes total):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ 0x00 0x01 โ”‚ 0xFF 0xFF 0xFF ... 0xFF โ”‚ 0x00 โ”‚ ASN.1 โ”‚ SHA-1 hash โ”‚
โ”‚  2 bytes  โ”‚  at least 8 bytes of   โ”‚1 byteโ”‚15 bytesโ”‚  20 bytes  โ”‚
โ”‚           โ”‚  0xFF padding          โ”‚      โ”‚        โ”‚            โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
      โ†‘              โ†‘                  โ†‘       โ†‘          โ†‘
   Header     Padding (fills     Separator  Algorithm   The actual
              the ENTIRE block)            identifier   hash value

The ASN.1 bytes identify which hash algorithm was used (SHA-1, SHA-256, etc.), and the padding 0xFF bytes fill ALL remaining space. A correct verifier checks that the entire block matches this structure exactly.

Now let's look at the challenge's broken verification code:

def verify(self, message, signature):
    keylength = len(long_to_bytes(self.n))
    decrypted = self.encrypt(signature)  # s^e mod n
    clearsig = decrypted.to_bytes(keylength, "big")

    r = re.compile(b'\x00\x01\xff+?\x00(.{15})(.{20})', re.DOTALL)
    m = r.match(clearsig)
    #            ^^^^
    # CRITICAL: \xff+? is non-greedy โ€” only needs ONE \xff byte
    # AND: no check for what comes AFTER the 20-byte hash!
    
    if not m:
        raise VerificationError('Verification failed')
    if m.group(1) != self.asn1:
        raise VerificationError('Verification failed')
    if m.group(2) != sha1(message).digest():
        raise VerificationError('Verification failed')
    return True

The verification uses a regex to check the decrypted signature block. Let's break down what the regex \x00\x01\xff+?\x00(.{15})(.{20}) is looking for:

  • \x00\x01 โ€” The header bytes (correct)
  • \xff+? โ€” One or more 0xFF bytes... but with a non-greedy quantifier! The ? after + means "match as few as possible." This means the regex is satisfied with just one single 0xFF byte, instead of requiring the padding to fill the entire block.
  • \x00 โ€” The separator byte (correct)
  • (.{15}) โ€” Captures 15 bytes (the ASN.1 identifier)
  • (.{20}) โ€” Captures 20 bytes (the SHA-1 hash)

Three critical flaws:

  1. \xff+? (non-greedy) โ€” Only requires one \xff byte. Proper PKCS#1 requires the padding to fill the entire block, which for a 2048-bit key means about 218 bytes of \xff.
  2. No anchor at the end โ€” The regex doesn't verify what comes after the 20-byte hash. Hundreds of arbitrary garbage bytes can trail after the hash and the regex won't care.
  3. No length check โ€” It doesn't verify that the total decoded structure fills exactly 256 bytes (the key length).

This means the verifier will accept a block that looks like this:

What the verifier ACTUALLY requires (only 39 bytes of structure!):
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚ 0x00 0x01 โ”‚ 0xFF โ”‚ 0x00 โ”‚ ASN.1 (15 bytes) โ”‚ SHA-1 (20 bytes) โ”‚ ANYTHING โ”‚
โ”‚  2 bytes  โ”‚1 byteโ”‚1 byteโ”‚                   โ”‚                  โ”‚217 bytes!โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                                                                   โ†‘
                                                          We control these!
                                                          They can be garbage!
This is exactly CVE-2006-4339 โ€” Daniel Bleichenbacher's 2006 attack against PKCS#1 v1.5 signature verification. Bleichenbacher presented this at CRYPTO 2006, and it affected Firefox, OpenSSL, GnuTLS, NSS, and dozens of other implementations. The core insight: if the verifier doesn't check the entire block, the attacker has enormous freedom to forge signatures.

๐Ÿงฎ The Attack

Putting It All Together

Let's combine both bugs. We need to find an integer s (our forged signature) such that when the server computes s3 mod n, the result matches the loose regex pattern.

Since we can fill 217 out of 256 bytes with anything we want, we only need to control the first 39 bytes of sยณ. Here's the target structure:

sยณ = \x00\x01\xff\x00 + ASN.1(15 bytes) + SHA1(20 bytes) + [anything]
     ^^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^   ^^^^^^^^^^
     4 bytes            15 bytes          20 bytes         217 bytes of garbage

That's only 39 bytes (312 bits) of constrained prefix out of 256 total bytes (2048 bits). The remaining 217 bytes (1,736 bits) can be anything โ€” that's enormous freedom.

The Cube Root Trick

Here's the key insight that makes the attack work. Since e = 3, "verifying" the signature means computing sยณ mod n. But what if sยณ is smaller than n? Then the modular reduction does nothing: sยณ mod n = sยณ exactly. No modular arithmetic needed!

This means: if we can find a number s whose cube starts with our 39-byte prefix, we have a valid forgery. And finding such an s is just... computing a cube root.

The algorithm:

  1. Construct the target prefix: \x00\x01\xff\x00 + ASN.1 + SHA1(user_email)
  2. Create bounds: Pad to 256 bytes with \x00 for the lower bound and \xff for the upper bound
  3. Compute the cube root: s = โŒˆโˆ›(lower_bound)โŒ‰ (the smallest integer whose cube is โ‰ฅ the lower bound)
  4. Verify: Check that sยณ falls within [lower_bound, upper_bound]
  5. Confirm: Make sure the regex matches the resulting block

Why It Works โ€” The Math

With 217 bytes of unrestricted suffix, the range [target_low, target_high] spans 21736 consecutive integers. How many perfect cubes exist in this range?

The density of perfect cubes near a number N is approximately 1 / (3 ร— N2/3). For our range, that gives approximately 21736 / (3 ร— 21365) โ‰ˆ 2370 valid cubes. That's an astronomically large number โ€” we're guaranteed to find one immediately.

๐Ÿ’ป The Exploit

Now let's turn the theory into working code:

#!/usr/bin/env python3
"""Bleichenbacher e=3 RSA Signature Forgery"""
import socket, re
from hashlib import sha1
from Crypto.Util.number import long_to_bytes, bytes_to_long
from Crypto.PublicKey import RSA as PYRSA

def integer_cube_root(n):
    """Newton's method for integer cube root."""
    if n == 0: return 0
    x = 1 << ((n.bit_length() + 2) // 3)
    while True:
        y = (2 * x + n // (x * x)) // 3
        if y >= x: return x
        x = y

def forge_signature(n, user):
    keylength = len(long_to_bytes(n))  # 256 bytes
    asn1 = b"\x30\x21\x30\x09\x06\x05\x2b\x0e\x03\x02\x1a\x05\x00\x04\x14"
    hash_value = sha1(user).digest()

    # Construct target block prefix (39 bytes)
    prefix = b'\x00\x01\xff\x00' + asn1 + hash_value
    suffix_len = keylength - len(prefix)  # 217 bytes

    # Lower bound: prefix + all zeros
    target_low = int.from_bytes(prefix + b'\x00' * suffix_len, 'big')
    # Upper bound: prefix + all 0xff
    target_high = int.from_bytes(prefix + b'\xff' * suffix_len, 'big')

    # Find s where target_low <= sยณ <= target_high
    s = integer_cube_root(target_low)
    while s ** 3 < target_low:
        s += 1

    # Verify
    cube = s ** 3
    assert target_low <= cube <= target_high, "Cube out of range!"
    assert cube < n, "Cube wraps around mod n!"

    # Confirm regex matches
    clearsig = cube.to_bytes(keylength, 'big')
    r = re.compile(b'\x00\x01\xff+?\x00(.{15})(.{20})', re.DOTALL)
    m = r.match(clearsig)
    assert m and m.group(1) == asn1 and m.group(2) == hash_value

    return long_to_bytes(s)

# Connect โ†’ get n from certificate โ†’ forge โ†’ profit
sock = connect(host, port)
n, user, original_sig = parse_response(sock)
forged = forge_signature(n, user)
sock.send(forged.hex().encode() + b"\n")
# โ†’ HTB{...}

Let's walk through what this code does, function by function:

The integer_cube_root function uses Newton's method to compute integer cube roots. Even for numbers with thousands of digits, this converges in about 20 iterations โ€” it's practically instant.

The forge_signature function:

  1. Builds the 39-byte prefix that the regex expects (header + one \xff + separator + ASN.1 + SHA-1 hash)
  2. Creates a lower bound (prefix + 217 zero bytes) and upper bound (prefix + 217 \xff bytes)
  3. Computes the cube root of the lower bound and rounds up
  4. Verifies the cube falls in range and the regex matches
  5. Returns s โ€” our forged signature

๐Ÿ”‘ Solution Walkthrough

Step 1: Connect and Parse

The server sends us a signed email containing:

  • signature: โ€” the original hex-encoded signature
  • certificate: โ€” the RSA public key in PEM format
  • The email body (From: IT Department <[email protected]>)

We extract the modulus n from the PEM certificate and note e = 3.

Step 2: Compute the Target

The user string (what gets signed) is: IT Department <[email protected]>

We compute SHA-1(user) โ€” a 20-byte hash โ€” and construct the 39-byte prefix that must appear at the start of sยณ.

Step 3: Find the Cube Root

Using Newton's method for integer cube roots, we find the smallest s where sยณ โ‰ฅ target_low. This takes microseconds โ€” it's a single integer cube root computation on a ~2048-bit number.

Step 4: Submit the Forgery

We send our forged signature (which differs from the original, since there are ~2370 valid forgeries and we're almost certainly not hitting the same one). The server computes sยณ mod n, runs the flawed regex, and... it passes! Since it's different from the original โ†’ we get the flag.

๐Ÿ† Flag
HTB{4_8131ch3n84ch32_254_vu1n}

"A Bleichenbacher RSA vuln" โ€” the flag says it all.

๐Ÿ“š Key Takeaways

  • Never use e = 3 for RSA signatures. The standard e = 65537 makes this attack computationally infeasible. With e = 65537, you'd need a 65537th root instead of a cube root โ€” and there's no efficient algorithm for that.
  • PKCS#1 v1.5 verification must be strict. The regex must verify:
    • Padding \xff bytes fill the entire block (not just "one or more")
    • Nothing follows the hash digest โ€” the block must end exactly after the hash
    • The total decoded length matches the key length exactly
  • Use PKCS#1 v2.1 (PSS) for new implementations. PSS (Probabilistic Signature Scheme) is provably secure against these structural attacks because it uses randomized padding and a fundamentally different verification approach.
  • This bug is 20 years old (2006) and still appears in CTFs because it still appears in production code. CVE-2006-4339 affected Firefox, GnuTLS, OpenSSL, and NSS. The lesson is clear: don't write your own crypto verification โ€” use well-audited libraries.
  • Non-greedy regex quantifiers are dangerous in security-critical parsing. The difference between \xff+ (greedy: match as many as possible) and \xff+? (non-greedy: match as few as possible) might seem minor, but it's the difference between a secure and completely broken implementation.

๐Ÿ›  Tools Used

  • pycryptodome โ€” RSA key parsing, number theory utilities
  • Python hashlib โ€” SHA-1 hash computation
  • Newton's method โ€” Integer cube root algorithm
  • Python 3.12

๐Ÿ“– References

๐ŸŽ“ What I Learned

  • Bleichenbacher's e=3 signature forgery โ€” with a small public exponent and broken PKCS#1 v1.5 verification, computing a cube root is enough to forge RSA signatures without the private key
  • Non-greedy regex is dangerous โ€” the difference between \xff+ (greedy) and \xff+? (non-greedy) transforms a correct check into one that accepts a single padding byte, leaving 217 bytes of attacker-controlled garbage
  • PKCS#1 v1.5 verification must be total โ€” checking only the prefix (header + ASN.1 + hash) without verifying that padding fills the entire block is the exact implementation flaw Bleichenbacher exploited in 2006
  • Newton's method for integer roots โ€” computing integer cube roots of multi-thousand-digit numbers converges in ~20 iterations; this makes e=3 forgery instantaneous in practice
  • CVE-2006-4339 is a 20-year-old bug that keeps appearing โ€” it affected Firefox, OpenSSL, GnuTLS, and NSS at the time, and the pattern (lax signature verification + small exponents) still shows up in custom implementations
  • Use PSS, not PKCS#1 v1.5 โ€” PKCS#1 v2.1 (PSS) uses randomized padding and is provably secure against structural attacks like this one

๐Ÿ”€ Alternative Paths

  • Brute-force suffix search โ€” instead of a clean cube root, you could iterate over suffix values to find an integer whose cube matches the prefix pattern; slower but works for slightly larger exponents
  • Multiple valid forgeries โ€” with ~2370 valid cubes in the range, you could generate thousands of distinct forged signatures for the same message, useful for replay resistance testing