neven@dimitri-dev:~$
cd ../hackultet-2026

$cat safe-encryption.mdCryptoHackultet 2026

Safe Encryption

An RSA oracle hides its modulus but not its multiplicative structure, and using the flag itself as the private exponent is exactly the precondition a 1990 attack was built for.

  • #rsa
  • #wiener-attack
  • #multiplicative-homomorphism

$man safe-encryption

What this one teaches:

  • Why RSA's multiplicative property (encrypting a product is the same as multiplying two ciphertexts) leaks the modulus to anyone with an encryption oracle.
  • Wiener's attack, recovering a small RSA private exponent through continued fractions.

Task description

An RSA encryption and decryption oracle, where the private exponent is the flag itself.

The setup

d = bytes_to_long(FLAG)
e = inverse(d, phi)

The server never prints N directly, only e, and offers to encrypt or decrypt anything sent to it. With the flag at roughly 20 bytes (about 160 bits) and N a standard 1024-bit modulus, d is far smaller than N, well under the bound where Wiener’s attack (1990) applies: whenever d < N^(1/4) / 3, the convergents of the continued fraction expansion of e/N are guaranteed to include k/d for some small k, making d recoverable directly from e and N alone, no factoring of N required.

The one thing missing is N itself.

Recovering N without being told it

RSA encryption is multiplicative: encrypting a and encrypting b and multiplying the two results together is the same, modulo N, as encrypting a * b directly. That means for any two values where we also know their product’s encryption, encrypt(a) * encrypt(b) - encrypt(a*b) is an exact multiple of N, since the two sides agree modulo N but not as plain integers.

Four such combinations, taking the GCD across all of them, converge quickly on N itself:

c2, c3, c5, c6, c7, c10, c14, c15 = [encrypt(m) for m in [2,3,5,6,7,10,14,15]]

multiples = [c2*c3 - c6, c2*c5 - c10, c2*c7 - c14, c3*c5 - c15]
N = multiples[0]
for m in multiples[1:]:
    N = gcd(N, m)
# strip small spurious common factors, then verify encrypt(1) == 1

Recovering d with Wiener’s attack

With N and e both in hand, computing the continued fraction expansion of e/N and checking each convergent k/d against the condition that (e*d - 1) divides evenly by k, and that the resulting phi candidate leads to a valid factorization of N (checked via the quadratic formula on p + q and p * q), finds the one convergent that’s actually RSA’s real private exponent:

def wiener_attack(e, N):
    for k, d in convergents(continued_fraction(e, N)):
        if k == 0 or (e * d - 1) % k != 0:
            continue
        phi = (e * d - 1) // k
        s = N - phi + 1
        disc = s * s - 4 * N
        if disc >= 0 and isqrt(disc) ** 2 == disc:
            return d

long_to_bytes(d) gives the flag directly, since d is exactly what the flag’s bytes were converted into at keygen time.

The two lessons together

Using a short, guessable value as an RSA private exponent is the textbook precondition for Wiener’s attack; the Boneh-Durfee attack extends the same idea to a slightly larger bound. Separately, hiding N from the output doesn’t actually hide it from an attacker who gets an encryption oracle, since RSA’s own multiplicative structure (E(a) * E(b) = E(a*b) mod N) leaks N out through a handful of chosen encryptions and a GCD.

$cat flag.txt revealCTF2026[157567771435]

Written by Neven Marić. This writeup was written with the help of Claude Code (Anthropic).