Cracking RSA keys by brute force, on all twelve threads
CSC 482 — Homework 5. An RSA key is the product n = p · q of two big primes, and it is only safe because nobody can get p and q back out of n. This program tries anyway: starting at √n it tests every prime upward until one divides n, with the search range split across a Java fork/join pool. The assignment: time it for 16-bit through 28-bit primes, plot it, and say what the curve means for a real 2048-bit key.
The required plot: bit length of each prime on the x axis, seconds to find a factor on the y axis, each point the average of three runs. Below 22 bits the crack is over before the JIT has warmed up; above it the time roughly doubles with every bit.
Results
Measured October 21, 2026 with nothing else running. The program prints only two integers per line, the bit length and the milliseconds, exactly as the assignment asked; the full output files are linked at the bottom.
One run each — java Rsa 16 28 1
| bits | ms | seconds |
|---|---|---|
| 16 | 226 | 0.23 |
| 17 | 225 | 0.23 |
| 18 | 8 | 0.01 |
| 19 | 207 | 0.21 |
| 20 | 541 | 0.54 |
| 21 | 780 | 0.78 |
| 22 | 1,168 | 1.17 |
| 23 | 2,682 | 2.68 |
| 24 | 3,159 | 3.16 |
| 25 | 9,964 | 9.96 |
| 26 | 35,066 | 35.07 |
| 27 | 52,923 | 52.92 |
| 28 | 137,682 | 137.68 |
Whole pass: 4 min 5 s.
Average of three runs — java Rsa
| bits | ms | seconds |
|---|---|---|
| 16 | 238 | 0.24 |
| 17 | 106 | 0.11 |
| 18 | 143 | 0.14 |
| 19 | 364 | 0.36 |
| 20 | 427 | 0.43 |
| 21 | 235 | 0.24 |
| 22 | 1,557 | 1.56 |
| 23 | 3,850 | 3.85 |
| 24 | 4,843 | 4.84 |
| 25 | 5,181 | 5.18 |
| 26 | 18,004 | 18.00 |
| 27 | 37,254 | 37.25 |
| 28 | 82,151 | 82.15 |
Whole pass: 7 min 44 s. The 28-bit average, 82.2 s, landed almost on top of the assignment's own example of 82.3 s.
Environment
- CPU AMD Ryzen 5 5600, 6 cores / 12 threads, 3.5 GHz
- RAM 48 GB
- OS Windows 11 Pro
- Java Oracle JDK 26.0.2.1, plain
javac/java, no flags - Threads
new ForkJoinPool(), so the pool's parallelism isRuntime.availableProcessors()= 12 - Timer
System.currentTimeMillisaround the crack only; making the key is not timed
Extra credit: 29 to 32 bits, and what comes after
The assignment's extra credit was to push the bit length higher and estimate the sizes that are out of reach. Four more lengths, one run each, took 67 minutes of wall-clock time with every core busy:
| bits | ms | time |
|---|---|---|
| 29 | 268,824 | 4 min 29 s |
| 30 | 424,362 | 7 min 4 s |
| 31 | 1,045,343 | 17 min 25 s |
| 32 | 2,267,644 | 37 min 48 s |
Estimating the rest
Chaining all the points from 22 to 32 bits, the time grows by ×2.07 per bit (geometric mean of the bit-to-bit ratios, counting only pairs where both times are over a second; unrounded, ×2.072). Starting from the measured 37.8 minutes at 32 bits, at the unrounded rate (with ×2.07 the 64-bit row would read 928,000 years):
| bits in each prime | at ×2.072 per bit | at an exact ×2.00 |
|---|---|---|
| 36 | about 12 hours | 10 hours |
| 40 | about 9 days | 7 days |
| 48 | about 8 years | 5 years |
| 64 | about 950,000 years | 310,000 years |
So what about a real key? A 2048-bit RSA key means each prime is 1024 bits. That is 992 more doublings past the 38-minute crack of a 32-bit prime, and 2992 is far more than the number of atoms in the observable universe, so no amount of hardware or patience gets there by brute force. A madlabs computer would not change that: the search is embarrassingly parallel, so k times the cores buys about log2(k) more bits in the same wall time (64 cores against this machine's 12 threads is about 2.4 bits), which is nothing against 992. Buying lottery tickets does no better: there are about 21013.5 primes with 1024 bits (21023 / ln 21024), so one random 1024-bit prime hits a particular factor one time in about 21013.5, and either of the two factors one time in about 21012.5. The keys that have actually fallen (RSA-250, 829 bits, in 2020) fell to the number field sieve, a far cleverer algorithm, and still took about 2,700 core-years.
Do the results match expectations?
Yes, once the times are big enough to measure. Each extra bit doubles the width of the range
the search has to cover (from √n up to max = 2^(bits+1) − 1), so
the time should roughly double per bit. Measured growth per bit:
| range | growth per bit | how measured |
|---|---|---|
| 22 to 28 bits | ×1.94 | averages of 3 runs |
| 29 to 32 bits | ×2.04 | single runs |
| 22 to 32 bits | ×2.07 | all points chained |
| expected | ×2.00 | the range doubles |
A geometric mean of neighbouring ratios telescopes to the ratio of the two end points, so each figure really rests on its first and last time (×1.94 = (82151 / 1557)1/6). A least-squares line through log2(time) over the same points, which uses every point, gives ×1.88, ×2.07 and ×2.10. Either way the answer is about ×2.
On the log-scale plot the points from 22 bits up sit on a straight line next to the dotted “doubles every bit” guide, which is what this growth looks like.
Why the small sizes are noisy
Below about a second the numbers bounce around: 18 bits took 8 ms in the single run and 17 bits took 225 ms. Two reasons, both ones the assignment warned about. The first cracks also pay for class loading and the JIT compiler warming up. And where the factor happens to land in the range decides how much of it the threads burn through before one of them hits it. Averaging three runs smooths that but does not remove it: the single 28-bit run took 138 s and the three-run average was 82 s, and the jump from 28 to 29 bits (×3.3) compares an average with a single run. The same luck is why the bit-to-bit ratios wander between ×1.1 and ×3.5 even though the trend is ×2.
Why it is not exactly ×2
Two smaller effects, each about 4% per bit, pull in opposite directions and cancel. Primes get
sparser as the numbers grow (the density falls as bits/(bits+1), which on its own would make the
growth ×1.92 per bit instead of ×2), and each candidate costs more because
BigInteger.nextProbablePrime runs a primality test on bigger numbers (the cost per
candidate rises by about the same factor, (bits+1)/bits). Their product is ×2.00, so the
doubling is still the expectation. There is also something the instructor flagged in a comment,
“It's likely overly large. Can it be smaller?”: the code searches up to
max = 2^(bits+1) − 1, while the larger factor q is always below
2bits and √n is at least 2bits−0.5, so at least the top
three quarters of the range is work that can never succeed. I left max alone so
these timings are for the code as given.
An honest footnote. Before the extra-credit run I predicted 32 bits at about 39 minutes, and it took 37 min 48 s, within 3%. But that prediction used a growth of ×2.3 per bit, which I later found included the noisy jump from 21 to 22 bits (the 21-bit average was only 235 ms). With the clean ×1.94 the estimate would have been 19 minutes; the 28-to-32 stretch actually grew ×2.29 per bit. Both are “about double” with one-run noise on top. The lesson from HW3 holds: one run at each size is a wide error bar.
How the crack works
The program, Rsa.java from the course page, makes two random primes of the
requested bit length with BigInteger.probablePrime, multiplies them into n,
and then forgets them. The crack starts at n.sqrt() and walks upward through
probable primes, because one of the two factors must be at or above √n. To use
every core, the range is handed to a RecursiveAction that keeps cutting it in half,
forking one half to the pool and computing the other itself, until a piece is under 1,000 wide,
and then just loops through it. The thread that finds a divisor flips a shared flag,
cont, guarded by static synchronized methods, and every other thread
checks that flag each time round its loop and stops.
protected void compute() {
BigInteger diff = max.subtract(min);
if (diff.compareTo(THRESHOLD) < 0) { // small enough: just loop through it
BigInteger currentPrime = min;
do {
if (n.mod(currentPrime).equals(BigInteger.ZERO)) {
endCont(currentPrime); // found it; tell every thread to stop
break;
}
currentPrime = currentPrime.nextProbablePrime();
} while (getCont() && currentPrime.compareTo(max) <= 0);
}
else if (getCont()) { // still searching: split in half
BigInteger mid = (max.add(min)).divide(BigInteger.TWO);
ForkJoinRsaTask taskA = new ForkJoinRsaTask(n, min, mid);
ForkJoinRsaTask taskB = new ForkJoinRsaTask(n, mid.add(BigInteger.ONE), max);
taskA.fork(); // hand one half to the pool
taskB.compute(); // do the other half ourselves
taskA.join();
}
}
The same divide-and-conquer shape as a multithreaded quicksort: fork one side, recurse on the other, join. The pool's work-stealing keeps all twelve threads fed, which is why the order the pieces get searched in, and so the time to the lucky hit, varies from run to run.
What I changed
Every change is marked // HW5 in the source. The thread
coordination is the instructor's; I did not touch it.
- A loop over bit lengths. The body of the old
mainbecame acrack(bitLength, random, pool)method that returns the milliseconds, andmaincalls it for 16 through 28 bits (or whatever the command line says). - Averaging. Each bit length is cracked three times and only the average prints,
under
Average results:; a run count of 1 printsResults:instead. - One pool for the whole program instead of a new
ForkJoinPoolper key. - Verify before trusting the time. The finder now remembers the factor as well as
flipping the flag, and
crackchecks that it divides n and equals p or q before the time counts. Carried over from HW3: verify every run. - Quiet output. The chatty prints are commented out so only the two integers print.
- Small housekeeping. The command line is checked (min-bits under 2, max-bits under
min-bits, runs under 1, a non-number or more than three arguments print the usage line and
exit); the pool is shut down when the loop is done; and a failed verification prints
CRACK FAILEDand stops the program with exit status 1 instead of reporting that time.
Usage: java Rsa for the assignment's run, java Rsa 16 28 1 for one
run per length, java Rsa 29 32 1 for the extra credit.
Code and files
Rsa.java
The modified program with the // HW5 lines, and the plot script, as readable pages.
Download Rsa.java
Plain text, ready for javac. Rename the .txt away.
README
The write-up that went in with the submission: changes, how to run, results, discussion.
Average results
The program's own Average results: output, three runs per bit length.
Single-run results
The Results: output from the one-run pass over 16 to 28 bits.
Extra credit output
29 to 32 bits, one run each, plus the timestamped run log.
Also: environment.txt, the 16 to 28 run log, and make_plot.py. The starting point was Rsa.java from the CSC 482 course page (J. Hastings), kept unchanged apart from the marked lines.
Sources
- Rsa.java, CSC 482 course page (J. Hastings), johnhastings.herokuapp.com/classes/csc482/homework/Rsa.java
- CSC 482 unit 5 notes: Euclidian Algorithm, Modular Arithmetic
- Oracle Java docs:
java.math.BigInteger(probablePrime, nextProbablePrime, sqrt),java.util.concurrent.ForkJoinPoolandRecursiveAction - RSA Factoring Challenge, en.wikipedia.org/wiki/RSA_Factoring_Challenge (RSA-250, 2020)
- AI: the loop, averaging, verification check, plot script, write-up and this page were done with Claude (Anthropic), per the course AI policy.