Zukane CTF

Mancity (CryptoCTF 2025)

RSA Coppersmith small roots
Challenge overview

In this CTF challenge we are given the following encryption script:

#!/usr/bin/env python3

from Crypto.Util.number import *
from flag import flag

def man(n):
        B = bin(n)[2:]
        M = ''
        for b in B:
                if b == '0':
                        M += '01'
                else:
                        M += '11'
        return int(M, 2)

def keygen(nbit):
        while True:
                p = getPrime(nbit)
                r = man(p)
                B = bin(p)[2:] + '1' * nbit
                q = int(B, 2)
                if isPrime(q) and isPrime(r):
                                return q, r

nbit = 256
p, q = keygen(nbit)
m = bytes_to_long(flag)
assert m < n
e, n = 1234567891, p * q
c = pow(m, e, n)

print(f'n = {n}')
print(f'c = {c}')  

as well as output.txt:

n = 147170819334030469053514652921356515888015711942553338463409772437981228515273287953989706666936875524451626901247038180594875568558137526484665015890594045767912340169965961750130156341999306808017498374501001042628249176543370525803456692022546235595791111819909503496986338431136130272043196908119165239297
c = 77151713996168344370880352082934801122524956107256445231326053049976568087412199358725058612262271922128984783428798480191211811217854076875727477848490840660333035334309193217618178091153472265093622822195960145852562781183839474868269109313543427082414220136748700364027714272845969723750108397300867408537     

This is an RSA-like implementation with some funky key generation

Source code analysis

The encryption script implements a Manchester encoding scheme in the function man(). The number passed to this function is converted to bits, and each bit is replaced with either 01 or 11. The resulting bitstream is converted back into an integer.

The key generation algorithm produces a 256-bit prime integer $p$, which is used to generate both RSA prime factors. One is generated by passing $p$ to man(), resulting in a 512-bit integer, while the other prime is generated by appending 256 bits to $p$’s bitstream. This tells us a lot about the structure of the prime factors.

r: ?1?1?1?1?1?1?1?1...?1?1?1?1?1?1?1?1 
q: ????????????????...1111111111111111

Since half of the prime factor $q$ is known, recovering the prime using coppersmith is a promising idea. However, even with 50% consecutive bits, the approach failed. Another approach may be to use branch and prune, since 50% of both primes are known and are scattered in $r$, but this also did not work out, probably due to $q$ missing too many consecutive bits.

Instead, we take advantage of the prime factor’s unique structure and relationship with the modulus $n$. First, for clarity, I will refer to the prime factors $q$ and $r$ as $p$ and $q$ respectively, due to the way the encryption script reuses variable names and returns the factors from the keygen algorithm

p: ????????????????...1111111111111111
q: ?1?1?1?1?1?1?1?1...?1?1?1?1?1?1?1?1 

We can represent $p$ as the following:

\[\large p = 2^{256}\cdot x + 2^{256}-1\]

for some unknown $x$ (upper bits). Taking $p$ modulo $2^{256}$, we get:

\[\large p = -1 \mod 2^{256}\]

Which means:

\[\large \begin{align} \nonumber n = p \cdot q \mod 2^{256} \\ \nonumber n = -1 \cdot q \mod 2^{256} \\ \nonumber n = -q \mod 2^{256} \\ \nonumber -n = q \mod 2^{256} \end{align}\]

So by taking $-n \mod 2^{256}$, we are able to recover the bottom $256$ bits of $q$. Remember, $q$ (r) was generated using the Manchester encoding. The $256$ bottom bits of $q$ include the $128$ least significant bits of the random prime. Since $p$ and $q$ are generated using the same prime, this leaks another $128$ bits of $p$, for a total of $384$. With 75% of a prime factor’s bits, the primes are recovered from coppersmith in a trivial manner.

Recovering the prime factors

Recovering 75% of $p$’s bits can be done like so:

rlow = (-n) % 2^256
rbits = bin(rlow)[2:]

# Undoing manchester encoding
qlow = ""
for i in range(0,len(rbits),2):
    qlow += rbits[i]
qlow += "1"*256
leak = int(qlow,2)

With the leaked value, the prime is instantly recovered using cuso:

T = var("x")
f = 2^384 * x + leak

roots = cuso.find_small_roots(
    relations           = [f],
    bounds              = {T: (0, 2^128)},
    modulus             = "p",     
    modulus_multiple    = n,   
    modulus_lower_bound = 2^511, 
)
assert roots, "CuSO found no root"

p = roots[0]["p"]

From here, its just regular RSA decryption

p = roots[0]["p"]
q = n // p
assert p*q == n

phi = (p-1)*(q-1)
d   = pow(e, -1, phi)
m   = pow(c, d, n)
print(long_to_bytes(m).decode())
# CCTF{M4nch3sReR_c0D!ng_wI7H_RSA}
Solve script
import cuso
from Crypto.Util.number import long_to_bytes

n = 147170819334030469053514652921356515888015711942553338463409772437981228515273287953989706666936875524451626901247038180594875568558137526484665015890594045767912340169965961750130156341999306808017498374501001042628249176543370525803456692022546235595791111819909503496986338431136130272043196908119165239297
c = 77151713996168344370880352082934801122524956107256445231326053049976568087412199358725058612262271922128984783428798480191211811217854076875727477848490840660333035334309193217618178091153472265093622822195960145852562781183839474868269109313543427082414220136748700364027714272845969723750108397300867408537
e = 1234567891

M = 2^256
rlow = (-n)%M
rbits = bin(rlow)[2:]

qlow = ""
for i in range(0,len(rbits),2):
    qlow += rbits[i]
qlow += "1"*256
leak = int(qlow,2)

T = var("x")
f = 2^384 * x + leak

roots = cuso.find_small_roots(
    relations           = [f],
    bounds              = {T: (0, 2^128)},
    modulus             = "p",     
    modulus_multiple    = n,   
    modulus_lower_bound = 2^511, 
)
assert roots, "CuSO found no root"

p = roots[0]["p"]
q = n // p
assert p*q == n

phi = (p-1)*(q-1)
d   = pow(e, -1, phi)
m   = pow(c, d, n)
print(long_to_bytes(m).decode())
# CCTF{M4nch3sReR_c0D!ng_wI7H_RSA}