Alpaca CTF 2026: reused-n
Challenge Information
| Attribute | Details |
|---|---|
| Event | Alpaca CTF 2026 |
| Category | Cryptography |
| Challenge | reused-n |
Summary
The challenge encrypts a single plaintext flag with the same RSA modulus n under two different public exponents, e1 = 1234 and e2 = 5678. The name reused-n is a direct hint toward the Common Modulus Attack. The twist: gcd(e1, e2) = 2, not 1, so the classic attack only yields m² mod n rather than m directly. However, the constraint len(flag) < 100 guarantees m² < n, meaning no modular reduction occurs and a plain integer square root recovers m exactly.
Analysis
Challenge Setup
flag = os.getenv("FLAG", "Alpaca{DUMMY}").encode()assert len(flag) < 100m = bytes_to_long(flag)
p = getPrime(1024)q = getPrime(1024)n = p * q # 2048-bit modulus
e1 = 1234e2 = 5678
c1 = pow(m, e1, n)c2 = pow(m, e2, n)The output file (chall.txt) gives us n, e1, c1, e2, c2.
Identifying the Attack
Two ciphertexts, same n, same plaintext, different exponents — this is the textbook setup for the Common Modulus Attack. The attack relies on Bézout’s identity: if we can find integers a, b such that:
a·e1 + b·e2 = gcd(e1, e2)then:
c1^a · c2^b ≡ m^(a·e1 + b·e2) ≡ m^gcd(e1,e2) (mod n)The gcd Twist
Computing gcd(e1, e2) = gcd(1234, 5678):
1234 = 2 · 6175678 = 2 · 17 · 167gcd = 2The classic attack assumes gcd = 1 and recovers m directly. Here, gcd = 2, so we only recover m² mod n, not m.
Breaking Out of the Dead-End
Normally, computing a square root modulo an unknown RSA composite is as hard as factoring n. But there is a saving constraint:
len(flag) < 100→m < 2^800(at most 100 bytes)m² < 2^1600nis ~2048 bits →n > 2^2047
Therefore m² < 2^1600 < 2^2047 < n, which means m² mod n == m² (no reduction ever happens). The modular square root is simply an integer square root over the integers — a deterministic, efficient operation.
Solution
Step 1: Extended GCD (Bézout Coefficients)
Find a, b such that a · 1234 + b · 5678 = 2:
def xgcd(a, b): if b == 0: return a, 1, 0 g, x1, y1 = xgcd(b, a % b) return g, y1, x1 - (a // b) * y1Step 2: Handle Negative Exponents
One of a or b will be negative (since e1 · e2 > 0). A negative exponent in modular arithmetic becomes a modular inverse:
g, a, b = xgcd(e1, e2) # g = 2
if a < 0: c1 = pow(c1, -1, n) # modular inverse of c1 a = -aif b < 0: c2 = pow(c2, -1, n) b = -bStep 3: Recover m²
m_squared = (pow(c1, a, n) * pow(c2, b, n)) % nThis equals m^(a·e1 + b·e2) mod n = m² mod n = m².
Step 4: Integer Square Root
from math import isqrt
m = isqrt(m_squared)assert m * m == m_squared, "Square root was not exact — something went wrong"Step 5: Recover the Flag
flag = m.to_bytes((m.bit_length() + 7) // 8, "big")print(flag.decode())Full Solver
import sysfrom math import isqrtfrom pathlib import Path
def xgcd(a, b): if b == 0: return a, 1, 0 g, x1, y1 = xgcd(b, a % b) return g, y1, x1 - (a // b) * y1
def solve(chall_path: str = "chall.txt"): data = {} for line in Path(chall_path).read_text().splitlines(): line = line.strip() if "=" in line: k, v = line.split("=", 1) data[k.strip()] = int(v.strip())
n, e1, c1, e2, c2 = data["n"], data["e1"], data["c1"], data["e2"], data["c2"]
print(f"[+] n bit-length: {n.bit_length()}") print(f"[+] e1 = {e1}, e2 = {e2}")
g, a, b = xgcd(e1, e2) print(f"[+] gcd(e1, e2) = {g}")
if a < 0: c1 = pow(c1, -1, n) a = -a if b < 0: c2 = pow(c2, -1, n) b = -b
m_squared = (pow(c1, a, n) * pow(c2, b, n)) % n print(f"[+] m^{g} mod n bit-length: {m_squared.bit_length()}")
m = isqrt(m_squared) assert m * m == m_squared, "isqrt was not exact — m^2 >= n or attack failed"
flag = m.to_bytes((m.bit_length() + 7) // 8, "big") print(f"[+] flag: {flag.decode()}")
if __name__ == "__main__": path = sys.argv[1] if len(sys.argv) > 1 else "chall.txt" solve(path)Expected output:
[+] n bit-length: 2047[+] e1 = 1234, e2 = 5678[+] gcd(e1, e2) = 2[+] m^2 mod n bit-length: 445[+] flag: Alpaca{e1_e2_share_a_secr3t}Flag
Alpaca{e1_e2_share_a_secr3t}Key Takeaways
- Common Modulus Attack: Encrypting the same message under the same
nbut two different exponents leaksm^gcd(e1,e2). Whengcd = 1this givesmdirectly. - gcd > 1 is not always a dead-end: If the plaintext is small enough that
m^g < n, the modularg-th root collapses to an integerg-th root, which is straightforward to compute. - Small-message guarantees matter: The
len(flag) < 100constraint is load-bearing — it’s what makes the integer square root tractable. Without it, recoveringmfromm² mod nwould require factoringn. - Exponent choice is not cosmetic: Choosing non-coprime exponents for the same modulus introduces structural weaknesses that an attacker can exploit without ever touching the private key.
pow(x, -1, n)in Python: Python 3.8+ natively computes modular inverses, making negative Bézout coefficients trivial to handle.
Tools Used
- Python 3.8+:
math.isqrtfor exact integer square roots;pow(x, -1, n)for modular inverses - Extended Euclidean Algorithm: Hand-rolled
xgcdfor Bézout coefficients - No third-party packages required
References
- RSA Common Modulus Attack: https://crypto.stackexchange.com/questions/16283
- Bézout’s Identity: https://en.wikipedia.org/wiki/B%C3%A9zout%27s_identity
- Integer Square Root: https://en.wikipedia.org/wiki/Integer_square_root