Phew
A modified Paillier scheme whose decrypt returns an affine transform of the plaintext (α·raw + β) — that leak lets us recover n, factor it, and recover the key.
Overview
The challenge implements a modified Paillier cryptosystem:
class pailier:
def __init__(self):
self.primes = [getPrime(512) for _ in range(2)]
self.n = 1
self.phi = 1
self.mul = 1
for i in range(2):
self.n *= self.primes[i]
self.phi *= (self.primes[i] - 1)
self.n2 = self.n * self.n
self.g = [pow(random.randrange(1, self.n2), self.primes[i], self.n2) for i in range(2)]
for x in self.g:
self.mul = (self.mul * x) % self.n2
self.miu = inverse(self.L(pow(self.mul, self.phi, self.n2)), self.n)
self.alpha = [None, None]
for idx in range(2):
while True:
a = random.randrange(2, self.n - 1)
if GCD(a, self.n) == 1:
self.alpha[idx] = a
break
self.beta = [random.randrange(0, self.n), random.randrange(0, self.n)]
def L(self, val):
return (val - 1) // self.n
def encrypt(self, msg: int) -> int:
r = random.randrange(0, self.n - 1)
gb = self.g[random.randrange(0, 2)]
gm = pow(gb, msg, self.n2)
rn = pow(r, self.n, self.n2)
return (gm * rn) % self.n2
def decrypt(self, ct: int) -> int:
raw = self.L(pow(ct, self.phi, self.n2)) % self.n
raw = (raw * self.miu) % self.n
t = pow(ct % self.n, (self.n - 1) // 2, self.n) if (self.n % 2 == 1) else 0
idx = 0 if t == 1 else 1
return (self.alpha[idx] * raw + self.beta[idx]) % self.n
Main parameters:
n = p · q(product of two 512-bit primes),g = [g0, g1]— there are two generators,- ciphertext
= g^m · r^n (mod n²).
The twist is in decrypt: instead of returning the plaintext, it returns an affine transform
of it, selecting idx by a Legendre-symbol-like test:
t = pow(ct % self.n, (self.n - 1) // 2, self.n) if (self.n % 2 == 1) else 0
idx = 0 if t == 1 else 1
return (self.alpha[idx] * raw + self.beta[idx]) % self.n
So decrypt(c) = (α[idx] · raw + β[idx]) mod n. That leak is exactly what lets us peel back the
internal structure of the modulus n.
Interface
Connecting to the service gives a menu:
encrypt(m)→ encrypt an arbitrary integer.bingo(key)→ submit a guessed key (must be the correct 66-byte value).decrypt(c)→ decrypt a ciphertext.key?→ returnsEnc(key_int).
Our goal is to recover the key and submit it via option 2.
Vulnerability & steps
Since decrypt returns (α[idx]·raw + β[idx]) mod n, we can use it to recover:
- the modulus
nfrom the difference of "cubed" decryptions, and - the factors of
nvia a GCD leak when we decryptencrypt(1).
1. Recover β. Encrypting 0 gives Decrypt(0) = (α[idx]·0 + β[idx]) mod n = β[idx] mod n.
2. Recover modulus n. Using the homomorphism, c^3 = Enc(3m), so for the key ciphertext ck:
d3 = Dec(ck^3) = α·3m + β
d = Dec(ck) = α·m + β
d3 − 3·d + 2β ≡ 0 (mod n)
Every such Δ = d3 − 3·d + 2β is a multiple of n, so taking the GCD of many deltas yields n.
3. Factor n. For several c1 = encrypt(1), compute alpha = (decrypt(c1) − β) mod n; then
gcd(alpha, n) yields p or q. Collect until both factors are found.
4. Split the key ciphertext. From Enc(key), compute u = (Dec(c³) − Dec(c)) / 2 (mod n).
Depending on which generator was used, u ≡ 0 (mod p) ("p-vanishing") or u ≡ 0 (mod q)
("q-vanishing"); store these as up and uq.
5. Match with the encrypt(1) generators. Find a_p (a decryption of encrypt(1) divisible
by p) and a_q (divisible by q).
6. Solve the congruences and CRT:
key ≡ (up · a_p⁻¹) mod p
key ≡ (uq · a_q⁻¹) mod q
CRT-combining these gives the key; submitting it to option 2 (bingo) returns the flag.
Solution script
#!/usr/bin/env python3
import sys, math
from pwn import context, process, remote
class PwntoolsClient:
def __init__(self):
context.log_level = 'INFO'
self.p = remote('54.169.34.94', 13000)
self.p.recvuntil(b'> ')
def encrypt(self, m_int: int) -> int:
self.p.sendline(b'1'); self.p.recvuntil(b'pt (hex)'); self.p.recvuntil(b'> ')
self.p.sendline(f'{m_int:x}'.encode()); self.p.recvuntil(b'ct : ')
ct = int(self.p.recvline().strip().decode(), 16); self.p.recvuntil(b'> '); return ct
def decrypt(self, c_int: int) -> int:
self.p.sendline(b'3'); self.p.recvuntil(b'ct (hex)'); self.p.recvuntil(b'> ')
self.p.sendline(f'{c_int:x}'.encode()); self.p.recvuntil(b'pt : ')
pt = int(self.p.recvline().strip().decode(), 16); self.p.recvuntil(b'> '); return pt
def key_ct(self) -> int:
self.p.sendline(b'4'); self.p.recvuntil(b'ct : ')
ct = int(self.p.recvline().strip().decode(), 16); self.p.recvuntil(b'> '); return ct
def bingo(self, key_hex: str) -> str:
self.p.sendline(b'2'); self.p.recvuntil(b'key (hex)'); self.p.recvuntil(b'> ')
self.p.sendline(key_hex.encode())
return self.p.recvuntil(b'> ', drop=True).decode(errors='ignore')
def inv_mod(a, n): return pow(a, -1, n)
def recover_n(cli, beta1, rounds=6):
G = 0
for _ in range(rounds):
ck = cli.key_ct()
d1 = cli.decrypt(ck)
d3 = cli.decrypt(pow(ck, 3))
delta = d3 - 3 * d1 + 2 * beta1
if delta:
G = math.gcd(G, abs(delta))
while G % 2 == 0:
G //= 2
return G
def factor_n(cli, n, beta1, tries=40):
p = q = None
for _ in range(tries):
c1 = cli.encrypt(1); d = cli.decrypt(c1)
a = (d - beta1) % n; g = math.gcd(a, n)
if 1 < g < n:
if p is None: p = g
elif g != p: q = n // p; break
if p > q: p, q = q, p
return p, q
def crt_pair(a1, m1, a2, m2):
inv_m1 = inv_mod(m1 % m2, m2); inv_m2 = inv_mod(m2 % m1, m1)
n = m1 * m2
return (a1 * m2 * inv_m2 + a2 * m1 * inv_m1) % n
def solve():
cli = PwntoolsClient()
beta1 = cli.decrypt(cli.encrypt(0)) # β for idx = 1
n = recover_n(cli, beta1, rounds=6) # modulus from key-ct deltas
p, q = factor_n(cli, n, beta1) # factor via enc(1) leak
up = uq = None
for _ in range(30):
ck = cli.key_ct(); d1 = cli.decrypt(ck); d3 = cli.decrypt(pow(ck, 3))
u = ((d3 - d1) % n) * inv_mod(2, n) % n
if up is None and u % p == 0: up = u
if uq is None and u % q == 0: uq = u
if up is not None and uq is not None: break
a_p = a_q = None
for _ in range(30):
d = cli.decrypt(cli.encrypt(1)); a = (d - beta1) % n; g = math.gcd(a, n)
if g == p and a_p is None: a_p = a
elif g == q and a_q is None: a_q = a
if a_p is not None and a_q is not None: break
key_mod_q = (up % q) * inv_mod(a_p % q, q) % q
key_mod_p = (uq % p) * inv_mod(a_q % p, p) % p
key_int = crt_pair(key_mod_p, p, key_mod_q, q)
key_hex = format(key_int, 'x').zfill(132)
sys.stdout.write(cli.bingo(key_hex))
if __name__ == '__main__':
solve()
Running it recovers the key and prints the flag:
python poc.py
GEMASTIK18{tlRgvMsOotJEbKfIzltPgkd9pb070y1t0rBR6gUhljPTDGCYOlQ9ugzj5S0fK4iq}
Flag
GEMASTIK18{tlRgvMsOotJEbKfIzltPgkd9pb070y1t0rBR6gUhljPTDGCYOlQ9ugzj5S0fK4iq}