Small Trouble picoCTF 2026 Solution

Published: March 20, 2026

Description

Everything seems secure; strong numbers, familiar parameters but something small might ruin it all. Can you recover the message? Download the message.txt and source encryption.py .

Download message.txt and encryption.py.

Inspect the RSA parameters: n, e, and ciphertext c.

bash
cat message.txt
bash
cat encryption.py
  1. Step 1Identify Wiener's attack condition
    Title hint: 'small' is d. When the private exponent is too small, the public exponent e is forced to be huge, and that ratio leaks. Wiener: if d < n^(1/4)/3, you recover d from (n, e) alone via continued fractions.
    Learn more

    Frame the attack. The RSA key equation e*d ≡ 1 (mod phi(n)) means e*d - 1 = k * phi(n) for some integer k. Divide both sides by d * phi(n) and you get e/phi(n) - 1/(d*phi(n)) = k/d. Since phi(n) ≈ n for large n (off by about p + q), e/n ≈ k/d. Both k and d are small, so this is an unusually good rational approximation of e/n by a fraction with small denominator. Continued fractions are exactly the tool for finding such fractions: the convergents of e/n are provably the best rational approximations with bounded denominator. So (k, d) appears in the convergents of e/n.

    Verification step. Each convergent p_i / q_i is a candidate (k, d). Compute phi_candidate = (e*d - 1) / k. If correct, phi(n) = (p-1)(q-1) = n - (p+q) + 1, so p + q = n - phi + 1. Then p and q are roots of x^2 - (p+q) * x + n = 0; the discriminant is disc = (p+q)^2 - 4n. If disc is a perfect square, we have the real factorization and the candidate is correct. If not, move to the next convergent. The convergents are tried in order of decreasing approximation quality, so the correct one usually appears within a handful of iterations.

    The right way to make RSA fast is the Chinese Remainder Theorem (CRT) optimization: keep d normal-sized, store d_p = d mod (p-1) and d_q = d mod (q-1), decrypt mod p and mod q separately, recombine. Roughly 4x faster than computing m = c^d mod n directly, and no Wiener exposure. See RSA attacks for CTF for the full attack catalogue.

  2. Step 2Apply Wiener's continued fraction attack
    Compute the continued fraction expansion of e/n, then check each convergent p/q as a candidate for k/d (where e*d = k*phi(n) + 1). Verify by checking that the resulting phi(n) is consistent.
    python
    python3 << 'EOF'
    # isqrt arrived in Python 3.8; for older interpreters use the Newton fallback.
    try:
        from math import isqrt
    except ImportError:
        def isqrt(n):
            if n < 0:
                raise ValueError
            if n == 0:
                return 0
            x = n
            y = (x + 1) // 2
            while y < x:
                x = y
                y = (x + n // x) // 2
            return x
    
    def wiener(e, n):
        def cf_expansion(num, den):
            while den:
                yield num // den
                num, den = den, num % den
    
        def convergents(cf):
            p0, p1 = 1, 0
            q0, q1 = 0, 1
            for a in cf:
                p0, p1 = a*p0 + p1, p0
                q0, q1 = a*q0 + q1, q0
                yield p0, q0
    
        for k, d in convergents(cf_expansion(e, n)):
            if k == 0:
                continue
            if (e*d - 1) % k != 0:
                continue
            phi = (e*d - 1) // k
            b = n - phi + 1
            disc = b*b - 4*n
            if disc < 0:
                continue
            sq = isqrt(disc)
            if sq*sq == disc:
                return d
        return None
    
    n = YOUR_N
    e = YOUR_E
    c = YOUR_C
    
    d = wiener(e, n)
    print("d =", d)
    m = pow(c, d, n)
    print(bytes.fromhex(hex(m)[2:]))
    EOF
    Learn more

    Continued fractions provide the best rational approximations to real numbers. For a real number x, its continued fraction expansion is [a0; a1, a2, ...] where each a_i is computed as the integer part of the current value, then the process is repeated on the reciprocal of the fractional part. The convergents p_k/q_k are the rational numbers formed by truncating the expansion - they are provably the closest rational approximations with denominators up to q_k.

    The verification step is crucial: once a candidate (k, d) is found from the convergents, we compute the implied phi(n) = (e*d - 1)/k and check whether it is consistent with n. Since phi(n) = (p-1)(q-1) = n - (p+q) + 1, if we know n and phi(n), we can solve for p+q as b = n - phi(n) + 1, then solve the quadratic x^2 - bx + n = 0 to find p and q. If the discriminant is a perfect square, we have found the real factorization of n and confirmed d.

    Worked Wiener example (small but realistic):
      p = 17,  q = 23,  n = p*q = 391
      phi(n) = 16 * 22 = 352
      Pick small d = 5 (note: 5 < 391^(1/4)/3 ~ 1.49 fails the tight
      bound, but Wiener actually works up to about n^(1/4)).
      e = d^(-1) mod 352 = 141   (since 5*141 = 705 = 2*352 + 1)
    
    Public key: (n, e) = (391, 141)
    
    Continued fraction expansion of e/n = 141/391:
      141/391 = 0 + 1/(391/141) -> a0 = 0
      391/141 = 2 + 109/141     -> a1 = 2
      141/109 = 1 + 32/109      -> a2 = 1
      109/32  = 3 + 13/32       -> a3 = 3
      32/13   = 2 + 6/13        -> a4 = 2
      13/6    = 2 + 1/6         -> a5 = 2
      6/1     = 6               -> a6 = 6
    
    Convergents (k_i / d_i):
      i=0: 0/1
      i=1: 1/2
      i=2: 1/3
      i=3: 4/11
      i=4: 9/25
      i=5: 22/61
      i=6: 141/391
    
    For each convergent, test k/d as candidate (k, d):
      i=2: k=1, d=3.  phi_candidate = (141*3 - 1)/1 = 422.
           b = 391 - 422 + 1 = -30. b^2 - 4n = 900 - 1564 = -664. Reject.
      i=3: k=4, d=11. phi_candidate = (141*11 - 1)/4 = 387.5 -> not integer.
           Reject.
      i=4: k=9, d=25. phi_candidate = (141*25 - 1)/9 = 391.5... reject.
      i=5: k=22, d=61. phi_candidate = (141*61 - 1)/22 = 391.0 -> 391?
           Doesn't match phi=352. Reject.
      ...the actual matching convergent for d=5 is at k=2, d=5 (skipped
      here for clarity). In the real attack, all convergents are tried
      until phi_candidate satisfies n - phi + 1 squared - 4n is a perfect
      square - the unique correct (k, d).
    
    Once correct: phi=352, b = 391-352+1 = 40, disc = 1600 - 1564 = 36,
      sqrt(disc) = 6, so {p, q} = (40 +/- 6)/2 = {17, 23}. RSA broken.

    This attack is implemented in many CTF tools and libraries: RsaCtfTool(a Python tool that automates dozens of RSA attacks including Wiener's), and SageMath's number theory library can also be used. In practice, Wiener's attack is rarely seen against properly implemented RSA - it specifically targets the insecure shortcut of choosing a small private exponent.

Alternate Solution

Once Wiener's attack recovers the private exponent d, plug N, e, d, and the ciphertext into the RSA Calculator on this site to perform the final decryption in the browser without writing any additional code.

Flag

picoCTF{w13n3r_4tt4ck_...}

The RSA private exponent d is small enough for Wiener's continued fraction attack to recover it directly from the public key.

How to prevent this

Wiener's attack works whenever d is small enough. The fix is letting your library generate d normally.

  • Do not pick d. Pick e (typically 65537) and let the library compute d = e-1 mod φ(n). The standard generation always produces d > n1/4, beyond Wiener's reach.
  • Use a vetted library's key generator: cryptography.hazmat, OpenSSL's EVP_PKEY_keygen, or RustCrypto's RsaPrivateKey::new. Hand-rolling any RSA parameter is a high-risk path.
  • Migrate off RSA where you can. Ed25519 / X25519 sidestep this entire family of attacks (Wiener, Coppersmith, common modulus, low-exponent) because the math is fundamentally different.

Want more picoCTF 2026 writeups?

Useful tools for Cryptography

Related reading

What to try next