---
# System prepended metadata

title: Xorsa writeup - UMassCTF 2025

---

# Xorsa writeup - UMassCTF 2025

## Intro
I've recently played in the awesome UMassCTF 2025 with my team [Flag Fortress 2](https://ctftime.org/team/302712)♥️  
We ended up with 6th place on the scoreboard! :D  
I had a fantastic time and I really loved all the challenges this year.

---

## Challenge details

**Category:** Cryptography  
**Points:** 488 (19 solves)  
**Challenge Description:**  
```
oops, i leaked a bit more than half of the xor of the primes
```
**Challenge files:** `main.py`, `output.txt`

---

## Solution
### TL;DR
We can use `n` and the leaked partial XOR of `p` and `q` to generate a list of candidates for the most significant bits of `p`.  
Then, we can apply Coppersmith's method to each candidate to recover the remaining lower bits of `p` and validate the result.

---

### Analysis
Let's break down `main.py`:
```py
from Crypto.Util import number

flag = b"REDACTED"
bits = 1024

p = number.getPrime(bits)
q = number.getPrime(bits)

n = p * q
phi = (p - 1) * (q - 1)
e = 65537
d = number.inverse(e, phi)
extra = 75

c = pow(int.from_bytes(flag, 'big'), e, n)
print(f"n: {hex(n)}")
print(f"e: {hex(e)}")
print(f"c: {hex(c)}")
print(f"partial p^q: {hex((p^q) >> (bits // 2 - extra))}")
```

I won't go into detail regarding how RSA encryption works.  
The interesting part here is the partial leak of the XOR of the primes p and q.

The `output.txt` file contains the values of `n`, `e`, the encrypted flag `c` and the partial leak of `p ^ q` - namely, only the 587 most significant bits: `(p ^ q) >> (bits // 2 - extra)`.

In order to decrypt the flag, we need to factor `n` into `p` and `q`, and use that knowledge to find the private exponent `d`.

---

### Step 1: Generate Candidates for the Primes

The leaked XOR of the primes (`p ^ q`) reveals the 587 most significant bits. Using this information, we can recursively reconstruct these bits for either `p` or `q` (in this case, we arbitrarily choose `p`):

- **XOR Constraint**:
  - For each bit position `i`, the XOR relationship `p[i] ^ q[i] = leak[i]` must hold.
  - Based on the value of `leak[i]`, the possible bit pairs for `p[i]` and `q[i]` are:
    - If `leak[i] = 1`: The pairs are `(0, 1)` or `(1, 0)`.
    - If `leak[i] = 0`: The pairs are `(0, 0)` or `(1, 1)`.

- **Pruning Candidates**:
  - At each step, we calculate the minimum and maximum possible values of `p` and `q` given the most significant bits by filling their remaining bits with `0` or `1`, respectively.
  - A candidate is discarded if either:
    - The product of the minimum values exceeds `n`, *or*
    - The product of the maximum values is less than `n`.
  - This ensures that only candidates satisfying the condition $p_{min} \cdot q_{min} \leq n = p \cdot q \leq p_{max} \cdot q_{max}$ are kept.

By applying these constraints, we generate a list of candidates for the most significant bits of `p` that meet the XOR constraint and are consistent with `n`.

### Step 2: Recover the Full Primes Using Coppersmith's Method

After generating candidates for the most significant bits of `p`, we use **Coppersmith's method** to recover the full primes:

- **Polynomial Construction**:
  - For each candidate `p_high`, we construct the polynomial `f(x) = p_high + x` over the ring `Zmod(n)`.
  - The goal is to find small roots of this polynomial, as these roots correspond to the missing lower bits of `p`.

- **Root Finding**:
  - Using SageMath's [small_roots](https://doc.sagemath.org/html/en/reference/polynomial_rings/sage/rings/polynomial/polynomial_modn_dense_ntl.html#sage.rings.polynomial.polynomial_modn_dense_ntl.small_roots) method, we search for small roots of `f(x)` within the range of the missing bits.
  - We set `beta=0.5`, assuming without loss of generality that $p \geq q$. This implies $p^2 > pq = n$, which leads to $p > \sqrt{n} = n^{0.5}$.
  - The upper bound for the root is `X = 1 << MISSING_BITS` because we are missing exactly 437 bits from `p`. When combined with `p_high`'s `KNOWN_BITS`, we reconstruct the full 1024 bits of `p`.

- **Validation**:
  - For each root `x0`, we compute `p = f(x0)` and check if `n % p == 0`.
  - If it is, we calculate `q = n // p` to recover the second prime.

### Step 3: Decrypt the Flag

Once `p` and `q` are recovered, the private key `d` is computed as `d = pow(e, -1, (p-1)*(q-1))`.  
The flag is then decrypted using `m = pow(c, d, n)` and converted back to bytes.

---

## Solve script
```py
from Crypto.Util.number import long_to_bytes
from sage.all import PolynomialRing, Zmod
from tqdm import tqdm
from halo import Halo

n = 0x46dbd0780b618c8dea0dc6b13a47a5b40bca30b96ed5b15e1be1ffad2fbab901bd0e26c8f3e2fa80cf208e3ba936acb3eef98002459833f96901e1fedb5e97432e05d5ad892beea06a96c059b5c6652a2241bcdaf91ccb4320298868ed3e0929778030e0cce2316b7677cc5574676545aacdef446f078fc08b56415315750ce659fc61db73f633b47a1c874145dd8676e70079ab40587be81c4a9673ded5e61e11c705e45fb4a910a7a1be2b3c3d0af8555a7d9a7aa90d4e4ec0bceb14cdbb1e4c91e379fa6961411139306b6add555734feeb77d51b40e568185b20c141bb4d07b96a1944d187cf8b6019dcdcb27a7309839393e6a63cfaffb9f11d26b503df
e = 0x10001
c = 0x389d979c400a145704d5685bce1f65f642e66c2778e62a7d0519addef8c92d9df42677df805a4e99962b14fb5acd512a7ab65f811842547a1a6670f73b6232e8790887584884caa66dad345c6aeb559402c16990eed47212f0794e11972a1e92030b84663de7cd472ed85c98a6cc42f79f02f7243755f0950894c741740400d0c2c84c6bc1c0380a4ca16eab0ad7ccc3314174c96ecad28bbd364c4ea56e3bc8a7e62c3351307fd1ebe18d3f6e82d778d77e75677a858c4993e4df53ff4d38ce69427b5170631ded7c34d1cc907681a3252d159105891348b4ca84e811611f2f04e6fa2ef6e006e0855f939f41bddcb585777d14942c7f10b2fdd979515cba5f
leak = 0x643e09f2948d2df7b16ffe591ac61e2a57b4cca7ead6a49e10b52593e5f9eb28be9d17eec9f04b6295221968d8bbfb698f87fe6f64b243b5cfd8fcf428b2599bc7fe54dcc695e3fad9

BITS = 1024
EXTRA_BITS = 75

KNOWN_BITS = BITS // 2 + EXTRA_BITS
MISSING_BITS = BITS // 2 - EXTRA_BITS

xor_bits = f"{leak << MISSING_BITS:b}".zfill(BITS)

candidates = []

def extend_msb(p_bits: str, q_bits: str, depth: int) -> None:
    if depth == KNOWN_BITS:
        p_candidate = int(p_bits + '0' * (BITS - depth), 2)
        q_candidate = int(q_bits + '0' * (BITS - depth), 2)
        if p_candidate * q_candidate <= n:
            candidates.append(p_candidate)
        return

    bit = xor_bits[depth]
    bit_options = [('0', '1'), ('1', '0')] if bit == '1' else [('0', '0'), ('1', '1')]

    for bit_p, bit_q in bit_options:
        extended_p = p_bits + bit_p
        extended_q = q_bits + bit_q

        min_p = int(extended_p + '0' * (BITS - depth - 1), 2)
        min_q = int(extended_q + '0' * (BITS - depth - 1), 2)
        max_p = int(extended_p + '1' * (BITS - depth - 1), 2)
        max_q = int(extended_q + '1' * (BITS - depth - 1), 2)

        if min_p * min_q <= n <= max_p * max_q:
            extend_msb(extended_p, extended_q, depth + 1)

with Halo('', animation='dots') as spinner:
    spinner.text = "Searching for candidates..."
    extend_msb("", "", 0)
    spinner.succeed(f"Found {len(candidates)} candidates.")

print("[*] Starting Coppersmith attack...")
for p_high in tqdm(candidates, leave=False):
    PR = PolynomialRing(Zmod(n), 'x')
    x = PR.gen()
    f = p_high + x

    roots = f.small_roots(X=1<<MISSING_BITS, beta=0.5)
    if roots:
        x0 = int(roots[0])
        p = int(f(x0))
        if n % p == 0:
            q = n // p
            phi = (p-1)*(q-1)
            d = pow(e, -1, phi)
            m = int(pow(c, d, n))
            flag = long_to_bytes(m)
            tqdm.write(f"[+] Found p: {p}")
            tqdm.write(f"[+] Flag: {flag}")
            break
```
