Cryptography
Step 230. RSA Attacks 2: Fermat Factorization, Wiener’s Attack — When Key Generation Is Botched, the Math Collapses
Level 3 — Real-World CTF & Advanced Offensive Skills | Difficulty ★★★★☆ | Estimated time: 4 hours
Prerequisites: Step 229 (RSA Attacks 1) — last chapter was mistakes in "how you use it"; today is mistakes in "how you make it."
⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- What you need: Python 3 (measured: 3.12.14), the prime-generation code from Step 228, the
irootinstincts from Step 229. - Caution: factordb.com lookups are a "concept introduction" only today — this chapter’s runtime environment uses no external network.
RSA’s security hangs on "factoring n is hard." But that hardness holds only when key generation is sound. If p and q are too close, factoring finishes just by sweeping near the square root of n (Fermat); if the private exponent d is too small, d can be computed mathematically from the public values (n, e) alone (Wiener). Today you implement both attacks yourself, craft vulnerable keys, and break them yourself. The two classics of "something’s off about this n" in CTF.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Implement Fermat factorization and explain the relationship between the p–q distance and the iteration count
- Factor an n made from close primes, compute d, and decrypt a ciphertext
- Implement continued-fraction expansion yourself and recover a small d with Wiener’s attack
- Build the diagnostic habit of "Fermat → suspect small d → factordb" when facing an unfamiliar RSA public key
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 (measured: 3.12.14) — standard library only, nothing to install |
| Today’s commands | math.isqrt(), hand-rolled fermat_factor(), convergents() (continued fractions), wiener() |
| Concepts needed | All of Steps 227–228 + perfect-square testing, continued fractions |
| Today’s deliverable | fermat_factor() and wiener() — attackers that actually break two kinds of weak keys |
2-1. Fermat Factorization — A Difference of Squares
Every odd composite n can be written as a difference of squares: n = a² - b² = (a-b)(a+b). Then p = a-b, q = a+b. The method is simple — start from a = isqrt(n) + 1, test whether a² - n is a perfect square, and if not, bump a by 1 and repeat.
The iteration count is proportional to a - √n ≈ (p-q)² / (8√n). In other words, the closer p and q, the overwhelmingly faster it is. With proper key generation (independent random primes), the difference is on the scale of √n so it runs effectively forever, but with botched generation like "previous prime + small offset," it ends in a single iteration — you’ll confirm this in today’s measurements.
2-2. Wiener’s Attack — A Small d Has Nowhere to Hide
In RSA, e·d ≡ 1 (mod φ(n)), so there’s an integer k with e·d = k·φ(n) + 1. Rearranging gives e/n ≈ k/d — k/d appears as an approximation of e/n. If d is small enough (d < n^0.25 / 3 is the classic condition), k/d is guaranteed to appear in the list of continued-fraction convergents of e/n.
A continued fraction expands a fraction into the form a0 + 1/(a1 + 1/(a2 + ...)), and its truncated prefixes — the convergents — yield, in order, the best approximations with small denominators. Take each convergent’s denominator as a d candidate, compute φ(n) = (e·d - 1)/k, and check whether the discriminant of the quadratic x² - (n-φ(n)+1)x + n = 0 built from that φ(n) is a perfect square — if so, p and q are confirmed, and d is found. It’s all fast integer arithmetic, so even a 1024-bit key falls in milliseconds.
2-3. The Diagnostic Habit — What to Check When Handed an n
When a CTF hands you a public key (n, e): ① look up n on factordb.com (plenty of numbers have already been factored by someone — an external service, so today it’s concept only), ② run Fermat briefly (close primes break instantly), ③ if e is abnormally large — close to n in size — that’s a strong hint of a small d, so run Wiener. Today you build ② and ③ yourself.
3. Follow Along
All output in this chapter was measured 2026-09-09 on Python 3.12.14.
3-1. Implementing Fermat Factorization
from math import isqrt
def fermat_factor(n, limit=10_000_000):
a = isqrt(n)
if a * a < n:
a += 1
for i in range(limit):
b2 = a * a - n
b = isqrt(b2)
if b * b == b2: # if a² - n is a perfect square, n = (a-b)(a+b)
return a - b, a + b, i + 1
a += 1
return None, None, limit
isqrt gives the exact integer square root — float ** 0.5 carries the same precision trap as Step 229 Wall 1, so it’s forbidden.
3-2. Breaking a Key Made from Close Primes
Reproduce the botched generation "the next prime after adding a small offset to the previous prime" on purpose.
def next_prime(n):
n |= 1
while not is_probable_prime(n):
n += 2
return n
p = gen_prime(512)
q = next_prime(p + secrets.randbelow(2000) * 2) # a very close prime
n = p * q
print("p-q difference (bits):", (q - p).bit_length(), "bits")
fp, fq, iters = fermat_factor(n)
print("factoring success:", fp * fq == n, "| iterations:", iters)
phi = (fp - 1) * (fq - 1)
d = pow(65537, -1, phi)
m = int.from_bytes(b"weak key!", "big")
c = pow(m, 65537, n)
rec = pow(c, d, n)
print("decrypted:", rec.to_bytes((rec.bit_length()+7)//8, "big"))
p-q difference (bits): 9 bits
factoring success: True | iterations: 1
decrypted: b'weak key!'
How to read the output: a 1024-bit RSA modulus was factored in 1 iteration. The difference was only 9 bits (a few hundred), so a = isqrt(n) + 1 was already the answer. Factoring → φ(n) → d → decryption — the entire private key recomputed from one public key.
3-3. Distance vs Iterations — How Far Does It Break?
The same experiment with growing differences (measured):
p,q difference ~2^200: 1 iteration, success=True, 0.00s
p,q difference ~2^264: 1908 iterations, success=True, 0.00s
Even a 2^200 difference breaks instantly — for a 512-bit prime, 2^200 is "the same neighborhood." By contrast, two properly independent primes (256 bits each):
proper key (two 256-bit primes): success after 200k iterations? False | 0.11s
How to read it: iterations are proportional to (p-q)² / (8√n), so they grow steeply as the difference widens even a little. The decision criterion is "do the iterations start exploding" — if it doesn’t finish within tens of thousands, this key is not a Fermat target. Fermat is a "quick diagnostic," not a universal factoring machine.
3-4. Wiener’s Attack — Computing d via Continued Fractions
Build the convergent generator and the attack function.
def convergents(num, den):
# yield continued-fraction convergents (numerator, denominator) of num/den in order
h_prev, h_cur = 0, 1
k_prev, k_cur = 1, 0
a, b = num, den
while b:
q_, r_ = divmod(a, b)
yield q_ * h_cur + h_prev, q_ * k_cur + k_prev
h_prev, h_cur = h_cur, q_ * h_cur + h_prev
k_prev, k_cur = k_cur, q_ * k_cur + k_prev
a, b = b, r_
def wiener(e, n):
for k, d_cand in convergents(e, n):
if k == 0 or (e * d_cand - 1) % k != 0:
continue
phi_cand = (e * d_cand - 1) // k
s = n - phi_cand + 1 # s = p + q
disc = s * s - 4 * n # discriminant
if disc < 0:
continue
t = isqrt(disc)
if t * t == disc and (s + t) % 2 == 0:
return d_cand # p, q confirmed → this d is real
return None
Build a vulnerable key — draw d small first, then compute e backward (the same structure as real-world incident cases):
p3, q3 = gen_prime(512), gen_prime(512)
n3 = p3 * q3
phi3 = (p3 - 1) * (q3 - 1)
while True:
d_small = secrets.randbits(240) | 1 # below n^(1/4) ≈ 256 bits
if math.gcd(d_small, phi3) == 1:
break
e_big = pow(d_small, -1, phi3)
print("weak key: d bits =", d_small.bit_length(), ", e bits =", e_big.bit_length())
d_found = wiener(e_big, n3)
print("d recovered by Wiener == original d:", d_found == d_small)
print("recovered d bits:", d_found.bit_length() if d_found else None)
weak key: d bits = 238 , e bits = 1021 (n is 1024 bits)
d recovered by Wiener == original d: True
recovered d bits: 238
How to read the output: a 238-bit private key d was computed in 0.002 seconds from the public values (n, e) alone. And the diagnostic hint is right there in the output — e is abnormally large at 1021 bits, nearly the size of n. When d is small, e is forced to grow to the scale of φ(n), so a giant e is circumstantial evidence of a small d.
3-5. Diagnostic-Order Practice
Drill the order for diagnosing an unfamiliar (n, e) with today’s tools: ① run fermat_factor(n, limit=100_000) briefly → if it fails, ② if e.bit_length() is close to n’s, run wiener(e, n) → if both fail, ③ a factordb lookup (external, concept only) → if that fails too, this problem belongs to a different attack. Each stage takes no more than a few seconds, so there’s no reason not to run them.
4. Missions & Exercises
Mission — The Weak-Key Triage Tool rsa_triage.py
- Write a function
triage(n, e)— run ① 100k iterations of Fermat, then ② if e is large, Wiener, in that order, and return the result together with one of"fermat","wiener","unknown" - Generate two vulnerable keypairs (close primes, small d) and one proper keypair, and demonstrate the triage tool classifying all three correctly
- For each broken key, continue on to computing d and decrypting a
"flag{weak_key}"ciphertext — a diagnosis is complete only when it ends in recovery - (Optional) On the proper key, print how many seconds each attack takes before giving up
Exercises
Exercise 1. Explain why Fermat factorization’s iteration count is proportional to (p-q)² / (8√n), via the relationship between a = (p+q)/2 and √n.
Exercise 2. Why must key-generation libraries draw p and q "completely independently"? Answer with evidence from which measurement broke the q = next_prime(p + K) style of generation today.
Exercise 3. In Wiener’s attack, how was each convergent k/d verified? Explain the flow: get p+q from a φ(n) candidate and confirm via a quadratic equation.
Exercise 4. You saw a public key whose e is as large as n. Starting from e·d ≡ 1 (mod φ(n)), explain why this hints at a small d.
Answers & completion criteria · expand/collapse
5. Model Answers & Completion Criteria
Mission Model Answer
def triage(n, e):
fp, fq, iters = fermat_factor(n, limit=100_000)
if fp:
d = pow(e, -1, (fp - 1) * (fq - 1))
return "fermat", d
if e.bit_length() > n.bit_length() * 3 // 4: # e large on n's scale → suspect small d
d = wiener(e, n)
if d:
return "wiener", d
return "unknown", None
Grading criteria: ① both vulnerable keypairs break under their correct labels ② the proper key returns "unknown" ③ decrypting with the broken key’s d via pow(c, d, n) matches the original. The e.bit_length() threshold is a heuristic, not an absolute standard — the essence is "if e is large, try Wiener," as in the text.
How to verify: regenerate the three keys, rerun, and check the labels are stable. In particular, the proper key must come out "unknown" — a triage tool that breaks proper keys has a false positive.
Exercise Answers
Answer 1. With n = pq, setting a = (p+q)/2 makes a² - n = ((p-q)/2)², satisfying Fermat’s termination condition exactly. The distance from the start point √n to the end point (p+q)/2 is the iteration count, and by Taylor approximation (p+q)/2 - √n ≈ (p-q)²/(8√n). Since it’s sensitive to the square of the difference, close primes break instantly while distant ones take effectively forever — the measurements (9-bit difference → 1 iteration, 2^264 → 1908 iterations, proper → 200k-iteration failure) correspond exactly.
Answer 2. If q is derived from p, their difference is determined by the generation parameter, handing the attacker a map that says "just sweep near the square root of n." In 3-2, a key with a 9-bit difference broke in 1 iteration, and in 3-3 even a 2^264 difference broke in 1908 iterations. Independent generation is the only way to keep the difference on the √n scale.
Answer 3. A convergent k/d is only a "candidate," so verification is required. From the e·d = k·φ(n) + 1 relation, compute φ(n) = (e·d - 1)/k (if it doesn’t divide evenly, discard), then check whether the discriminant s² - 4n of the quadratic built via the root-coefficient relations p + q = n - φ(n) + 1 and p·q = n is a perfect square. If it’s a perfect square, p and q are confirmed as integers, so that d is real.
Answer 4. Since e·d = k·φ(n) + 1 and k < d (under Wiener’s condition), a small d forces k small too, so e ≈ k·φ(n)/d grows to the scale of φ(n) — that is, of n. Conversely, if d is large on n’s scale, e can stay small. So "1024-bit n with a 1021-bit e" (measured in 3-4) strongly suggests a small d exists.
Completion Criteria Checklist
- [ ] I implemented
fermat_factor()and know whyisqrtis used - [ ] I factored a close-prime key in 1 to a few thousand iterations and recovered d
- [ ] I can explain the difference-vs-iterations relationship with the three measured pairs
- [ ] I implemented the
convergents()generator andwiener()and recovered a 238-bit d - [ ] I can explain "large e = small d hint" with formulas
- [ ] I can apply the diagnostic order (Fermat → Wiener → factordb) from memory
- [ ] Mission: triage broke both vulnerable types under correct labels and marked the proper key unknown
6. Common Pitfalls & Fixes
Wall 1. ValueError: isqrt() argument must be nonnegative
Symptom: ValueError: isqrt() argument must be nonnegative (measured 2026-09-09).
Cause: it arrives via two paths — you forgot to handle n being a perfect square in fermat_factor, or you skipped the disc < 0 check in wiener.
Fix: check the if disc < 0: continue in wiener. Most candidate convergents have negative discriminants, so this branch is normal operation.
Wall 2. Fermat never finishes
Symptom: it keeps iterating for minutes.
Cause: that key is a proper key that Fermat can’t break — in 3-3, the proper key failed even at 200k iterations (measured). Iterations scale with the square of the difference, so there’s no "just a bit longer."
Fix: set a limit and give up past it. With Fermat, the answer comes within 100k iterations or it never comes — one or the other.
Wall 3. Wiener returns None
Symptom: you clearly made a small d, yet wiener returns None.
Cause: d is outside the condition — d < n^0.25 / 3 is the classic ceiling, so for a 1024-bit n, a d past roughly 250 bits can fail. Or the convergent update order in convergents may be flipped.
Fix: draw d at 240 bits or below as in 3-4, confirm success inside the condition first, then experiment with the boundary.
Wall 4. Letting through candidates whose phi_cand doesn’t divide evenly
Symptom: a wrong d gets "found," or nothing is found even after a full pass.
Cause: the (e * d_cand - 1) % k != 0 filter was skipped — if it doesn’t divide evenly, φ(n) wouldn’t be an integer, so that candidate is physically impossible.
Fix: all three checks from 3-4 must be present — the two filters (k == 0, the remainder check) and the discriminant perfect-square test. Continued fractions without verification are just guessing.
Wall 5. Testing perfect squares with a float square root
Symptom: the check int((a*a - n) <strong> 0.5) </strong> 2 == a*a - n is occasionally wrong.
Cause: the same float-precision issue as Step 229 Wall 1 — ** 0.5 isn’t exact on large numbers.
Fix: every square root is math.isqrt. Both of today’s attacks must complete using integer arithmetic alone.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Fermat factorization | Searches for n = a² - b² just above the square root — close p, q break instantly |
| Iteration formula | (p-q)²/(8√n) — proportional to the square of the difference; useless on proper keys |
| Wiener’s attack | A small d always appears among e/n’s continued-fraction convergents — d computed from the public key alone |
| Continued-fraction convergents | A fraction expansion yielding, in order, the best approximations with small denominators |
| Diagnostic habit | Fermat (briefly) → Wiener if e is large → factordb lookup, in that order |
Today’s Commands & Code
| Command | What it does |
|---|---|
math.isqrt(n) |
Exact integer square root — the basis of perfect-square testing |
fermat_factor(n, limit) |
Perfect-square search from a = isqrt(n)+1 |
convergents(e, n) |
Generate (k, d) candidates: e/n’s continued-fraction convergents |
wiener(e, n) |
Convergent → φ(n) candidate → discriminant check → d confirmed |
e.bit_length() |
Small-d suspicion heuristic (detecting large e) |
An Instinct More Important Than Commands
Yesterday was mistakes in "how you use it" (small e, n reuse); today was mistakes in "how you make it" (close primes, small d). The common thread is one — verification beats guessing. Fermat’s perfect-square test and Wiener’s discriminant check were both devices that turn a "plausible-looking candidate" into a "confirmed answer." Your attacker toolbox has grown, and now, facing an unfamiliar n, your hand will reach for isqrt first.
Once every box is checked, Step 230 is complete.
ONE STEP FURTHER
Finished this lesson?
Check the completion criteria, then mark your progress.