Lowkey RSA - L3AK-CTF 2025 Write-up

Banner

Challenge: Lowkey RSA Category: Crypto Points: 50

Introduction

Alright, let’s talk about “Lowkey RSA”. When I first saw this challenge, I thought, “Cool, another RSA problem. Probably some common factoring attack, maybe a small public exponent, or something with common factors.” The name itself, “Lowkey RSA,” and the description, “This RSA might lowkey be insecure, no cap fr fr,” definitely hinted that something was intentionally weak. But as I dove into the source code, I realized it wasn’t going to be that straightforward. This challenge took me on a fun little mathematical rollercoaster, from head-scratching confusion to that amazing “aha!” moment when it all finally clicks.

The Journey: From “Huh?” to “Gotcha!”

Let’s walk through the thought process, because the final script hides all the fun trial-and-error.

1. First Glance: “Okay, It’s… Weird RSA”

I opened lowkey-rsa.py and saw the usual suspects: e, N, and c. But then my eyes caught this line:

phi = (p**4-1)*(q**4-1)

Wait, what? That’s not the standard Euler’s totient function, phi = (p-1)*(q-1). My first thought was, “Is this a typo? Or some exotic variant of RSA?” This was the first major red flag that this wasn’t a textbook problem.

2. The Key Generation: The Plot Thickens

Next, I looked at how e was generated.

e = inverse_mod(phi-d, phi)

This looked intentionally cryptic. Time for some modulo arithmetic to simplify it. If e is the inverse of (phi - d) modulo phi, it means:

e * (phi - d) ≡ 1 (mod phi)

Let’s expand that:

e * phi - e * d ≡ 1 (mod phi)

Since e * phi is a multiple of phi, it’s equivalent to 0 in mod phi. So:

-e * d ≡ 1 (mod phi)

Which is the same as:

e * d ≡ -1 (mod phi)

This is a breakthrough! It means that e * d is one less than a multiple of phi. We can write this as an equation with an integer k:

e * d = k * phi - 1

3. The “Lowkey” Clue and Wiener’s Attack

This equation, e * d = k * phi - 1, looked incredibly familiar. It’s the core relationship exploited in Wiener’s Attack, which works when the private exponent d is small.

But wait, in our case, d isn’t the private exponent. It’s just some parameter. However, the challenge name is “Lowkey,” and the source code explicitly makes d small:

d = randint(1, round(sqrt(t)) - 1)

The expression for t is a complicated mess, but the intent is clear: d is small. The puzzle pieces were starting to fit together. We have a small integer d in an equation that looks just like the one from Wiener’s attack.

We can rearrange our equation:

e * d + 1 = k * phi => (e * d + 1) / k = phi

And if we divide by phi:

e / phi ≈ k / d

This is it! This is the classic setup for a continued fraction attack. If d is small enough, then k/d is a very good rational approximation of e/phi.

4. The phi Problem and the Final Attack Plan

There’s one big problem: we don’t know phi. But can we approximate it?

phi = (p⁴-1)(q⁴-1) = p⁴q⁴ - p⁴ - q⁴ + 1

Since p and q are large primes, p⁴ and q⁴ are massive. We can say that p⁴-1 is very close to p⁴, and q⁴-1 is very close to q⁴.

So, phi ≈ p⁴q⁴ = (pq)⁴ = N⁴.

This is a good start, but we can do better. Let’s expand phi again: phi = N⁴ - (p⁴ + q⁴) + 1. A better approximation for p⁴ + q⁴ is 2N². This leads to a much better phi approximation: phi_approx = N⁴ - 2N² + 1 = (N²-1)².

This was the final key. With this approximation, I could build the attack:

  1. Approximate: Calculate phi_approx = (N**2 - 1)**2.
  2. Attack: Run the continued fraction algorithm on e / phi_approx to get a list of convergents (approximations) k/d.
  3. Test: For each (k, d) pair, calculate a phi_candidate = (e * d + 1) // k.
  4. Solve: Use this phi_candidate to find p and q. We know p⁴ + q⁴ = N⁴ - phi_candidate + 1. This lets us form a quadratic equation z² - (p⁴+q⁴)z + (p⁴q⁴) = 0, where the roots are p⁴ and q⁴.
  5. Verify: Check if the roots are perfect fourth powers. If they are, we get p and q. The final check is to see if p * q == N. If it all works, we’ve factored N!
  6. Decrypt: With p and q, we can calculate the real private key d_priv using the standard totient and decrypt the message.

This plan worked perfectly and revealed the flag.

The Final Solution

This is the Python script that implements the attack described above.

import gmpy2
from Crypto.Util.number import long_to_bytes

e = 370641246943654763647982436393968410523035056803076571952063446221981054741105804986870907803130391736840420704227524827167178043545763070520011423497365360567040835216714988776285676818833967899487393611410406708467049153990487431775151667103817558875154145780446157545062795321820537740212495675608976163877567007753523774447008611976905578477758365862741282887079873779055623972564793977884741350325450634869927603664722967323341473363320613467433998603537242156610765948379449307405122629600556105209482040761323271134932553579828576227233549741862990693111061962892676568398083446001891012661453694340879845386900986024512140441823076068075531089610607812090402852586184229193699718454197060072575595570232588935191272416546819793045623275550409871218062357273309812154110783534714662063322116568964675372602159108306251453500390105034890229052958512010283429459687714879084097494098542745605324460172680461006303552579466987732938596341830436505942616479890554056163452471835707573885837976471753073413505028206370632139586750855217201926605743452826397576584492732225029497982216694648573014796836126574081158869231364821712046050068243878660143909750030922147254462228826952501087389154612318844202411291844150163167021
N = 10222014062768125922601962004686361136447658578111413896046596746110249358112354000488449664371774177977274016313103826803116662735101208575040021998413602496525815373151213550295992813258424882626853824039678993334143891154760939712139640336395595628492284893024078520796288685014103193630287988814952604029
c = 4323184196594280140760873888779221921406692838206184372853784052006066772207175604399047711017170078453742445880600303200397632746051500194774569530024097959399922254605516672962219900174336028512116159752401576376530557036245218800162889461620882177398454137759956927838320086276276377067055171421259852996

def solve():
    print("[*] Starting Wiener-like attack...")

    phi_approx = (N**2 - 1)**2
    print("[*] Approximating phi...")

    print("[*] Finding (k, d) wit continued fractions...")
    num = e
    den = phi_approx
    k_prev, d_prev = 0, 1
    k_curr, d_curr = 1, 0
    
    p_found, q_found = None, None

    while den != 0:
        a, remainder = divmod(num, den)
        num, den = den, remainder
        
        k_next = a * k_curr + k_prev
        d_next = a * d_curr + d_prev
        k_prev, d_prev = k_curr, d_curr
        k_curr, d_curr = k_next, d_next

        k, d = k_curr, d_curr

        if k == 0 or d == 0:
            continue

        if (e * d + 1) % k == 0:
            phi_candidate = (e * d + 1) // k
            
            P_plus_Q = N**4 - phi_candidate + 1
            
            discriminant = P_plus_Q**2 - 4 * (N**4)
            if discriminant >= 0:
                sqrt_discriminant = gmpy2.isqrt(discriminant)
                if sqrt_discriminant**2 == discriminant:
                    P = (P_plus_Q + sqrt_discriminant) // 2
                    Q = (P_plus_Q - sqrt_discriminant) // 2   
                    p_cand, p_is_perfect = gmpy2.iroot(P, 4)
                    if p_is_perfect:
                        q_cand, q_is_perfect = gmpy2.iroot(Q, 4)
                        if q_is_perfect:
                            if p_cand * q_cand == N:
                                p_found = p_cand
                                q_found = q_cand
                                print("\n[+] P And Q Factor Found!")
                                print(f"  > p = {p_found}")
                                print(f"  > q = {q_found}")
                                break 
    if p_found and q_found:
        print("\n[*] Decrypting")       
        phi_std = (p_found - 1) * (q_found - 1)
        
        d_priv = gmpy2.invert(e, phi_std)
        
        m = pow(c, d_priv, N)
        
        flag = long_to_bytes(m)    
        print(f"Flag: {flag.decode('utf-8')}")
    else:
        print("\n[-] Failed")

if __name__ == '__main__':
    solve()
python3 lowkeyrsa.py
[*] Starting Wiener-like attack...
[*] Approximating phi...
[*] Finding (k, d) wit continued fractions...

[+] P And Q Factor Found!
  > p = 38744845630229096973223564078774890632168139978141962677044971931180793166793401934
57338651072052279492626914668259535884521069809739439009166898918784261                      > q = 26382900477458125043920894322225379241125953196389554679901635270856200294942187137
37790740237119243610722880728776987283256391634009123635310812720036889                    
[*] Decrypting
Flag: L3AK{L0wK3y_Th1S_RSA_i5_kiNda_ScuFf3D_LmA0}

Flag

flag: L3AK{L0wK3y_Th1S_RSA_i5_kiNda_ScuFf3D_LmA0}