AmateursCTF 2025
AmateursCTF 2025

AmateursCTF 2025

AmateursCTF 2025 – crypto/addition (330 pts)

Category: Crypto
Solves: 17
Author: helloperson
“it does addition”
We’re given a remote service and an archive addition.tar.gz. The challenge exposes RSA with a small exponent and an additive relation between encryptions of the same secret.

Challenge Overview

When connecting to the service:
  • The server prints:
    • n, e = (N, 3)
  • Then repeatedly asks:
    • scramble the flag:
  • For each s, the server returns:
    • scrambling... c = <integer>
notion image
  • The secret flag is encoded as an integer F.
  • The server computes:
    • [
      c_s = (F + s)^3 \bmod n
      ]
  • We control s.
This matches the Franklin–Reiter related-message attack on RSA with e = 3.

Crypto Background: Franklin–Reiter (e = 3)

For two related plaintexts:
  • (C_1 = M^3 \bmod n)
  • (C_2 = (M + S)^3 \bmod n)
with known S, Franklin–Reiter yields a closed-form solution:
[
M = S \cdot (2C_1 + C_2 - S^3) \cdot (C_2 - C_1 + 2S^3)^{-1} \pmod{n}
]
Where the inverse is modulo n. Once a valid pair ((C_1, C_2)) corresponds to the same underlying M, the formula recovers it.

Attack Plan

To obtain valid related ciphertext pairs:
  1. Query many times with:
      • s = 0 → candidates for (C_1)
      • s = 1 → candidates for (C_2)
  1. Brute-force all (c1, c2) pairs.
  1. For each candidate m:
      • Check if pow(m, 3, n) == c1.
  1. If valid, extract the flag from m.

Exploit Script

#!/usr/bin/env python3 from pwn import * from Crypto.Util.number import inverse, long_to_bytes import ast # Franklin–Reiter solver for e=3 def solve_franklin_reiter(c1, c2, s, n): try: num = s * (2 * c1 + c2 - s**3) den = c2 - c1 + 2 * s**3 den_inv = inverse(den, n) return (num * den_inv) % n except ValueError: return None HOST = 'amt.rs' PORT = 42963 try: r = remote(HOST, PORT) except Exception as e: exit() # Parse n,e line = r.recvline().decode().strip() n, e = ast.literal_eval(line.split('=')[1].strip()) c_zeroes = [] c_ones = [] s = 1 num_queries = 400 for _ in range(num_queries): # s = 0 r.sendlineafter(b'scramble the flag: ', b'0') r.recvline() c1 = int(r.recvline().decode().split('=')[1]) c_zeroes.append(c1) # s = 1 r.sendlineafter(b'scramble the flag: ', b'1') r.recvline() c2 = int(r.recvline().decode().split('=')[1]) c_ones.append(c2) r.close() # Brute-force pairs found = False for c1 in c_zeroes: if found: break for c2 in c_ones: m = solve_franklin_reiter(c1, c2, s, n) if m is None: continue if pow(m, 3, n) == c1: flag_long = m >> 256 flag_bytes = long_to_bytes(flag_long, 72) try: print(flag_bytes.rstrip(b'\x00').decode()) except: print(flag_bytes) found = True break if not found: print("No matching pair found.")

Important Implementation Details

Consuming the Status Line

The service prints "scrambling..." before the ciphertext.
Not consuming this line misaligns responses:
r.recvline() # consume “scrambling...”

Why Many Queries?

We do not know which (c1, c2) pair corresponds to the same M.
Large sampling increases the probability of hitting a valid pair.

Flag Encoding

From source:
[
M = (\text{flag_long} \ll 256) + r
]
Thus:
  1. M >> 256 yields flag_long
  1. Convert to bytes
  1. Strip null padding

Solver Output:

notion image
 

Flag:

 
amateursCTF{1_h0p3_you_didnT_qU3ry_Th3_s3RVer_100k_tim3s_1b9490c255fe83}
 


 

AmateursCTF: Addition 2 Write-up

Category: Crypto
Points: 330
Files: chall.py, Dockerfile

Challenge Overview

We are provided with a Python script running on a remote server. The server holds a flag, pads it with random bits, allows us to add a "scramble" integer to it, and then returns the encrypted result using RSA with a very small public exponent ($e=3$).
Looking at the source code (chall.py), the setup is as follows:
  1. Flag Setup: The flag is read and left-shifted by 256 bits. Let's call this $F$.
  1. Modulus: A standard 2048-bit RSA modulus $N$ is generated.
  1. Encryption Loop:
      • It generates 100,000 candidates.
      • Each candidate is $m = F + r$, where $r$ is a random 256-bit integer.
      • It asks for a user input scramble ($\delta$).
      • It calculates $c = (F + r + \delta)^3 \pmod N$.

Vulnerability Analysis

The critical observation lies in the sizes of the numbers involved:
  • The Flag ($F$) is shifted 256 bits, so it's roughly $2^{832}$.
  • The random padding ($r$) is small: $2^{256}$.
  • The modulus ($N$) is $2^{2048}$.
  • The exponent $e$ is 3.
If we send 0 as the scramble value, the encryption becomes:
$$C = (F + r)^3 \pmod N$$
Let's estimate the magnitude of the cubed message:
$$(F + r)^3 \approx (2^{832})^3 = 2^{2496}$$
While this is larger than $N$ ($2^{2048}$), it is not randomly wrapping around the modulus. Since $r$ is very small compared to $F$, the value $(F+r)^3$ increases very slowly.
Specifically, $(F+r)^3 = k \cdot N + C$. Because the variation caused by $r$ is small, the integer quotient $k$ (how many times it wraps around $N$) remains constant for all generated messages in a session.

Linearization Attack

Since $k$ is constant, we can treat this as a linear equation over the integers (ignoring the modulus for the difference).
Let's take two ciphertexts $C_1$ and $C_2$ generated with random nonces $r_1$ and $r_2$:
$$C_1 = (F + r_1)^3 - k \cdot N$$
$$C_2 = (F + r_2)^3 - k \cdot N$$
Subtracting them cancels out the modulus term:
$$\Delta C = C_1 - C_2 = (F+r_1)^3 - (F+r_2)^3$$
Using the difference of cubes factorization, or simply differentiating $(F+r)^3 \approx F^3 + 3F^2r$, we can approximate the difference:
$$\Delta C \approx 3F^2(r_1 - r_2)$$
This is huge. It implies that the difference between ciphertexts is linearly proportional to the difference between the random nonces. The "slope" of this line is approximately $3F^2$.

The Attack Strategy

  1. Collect Samples: We request 3 ciphertexts ($C_1, C_2, C_3$) setting the scramble value to 0.
  1. Calculate Differences:
      • $D_1 = C_1 - C_2 \approx 3F^2(r_1 - r_2)$
      • $D_2 = C_2 - C_3 \approx 3F^2(r_2 - r_3)$
  1. Eliminate the Unknown: By taking the ratio $D_1 / D_2$, the large unknown term $3F^2$ cancels out:
    1. $$\frac{D_1}{D_2} \approx \frac{r_1 - r_2}{r_2 - r_3}$$
  1. Recover Nonces: The values $(r_1 - r_2)$ are relatively small integers (approx 256 bits). We can use Continued Fractions on the ratio $D_1/D_2$ to find the "best rational approximation," which will reveal the integer values of the nonce differences.
  1. Solve for Flag: Once we have the nonce difference (let's call it $\Delta r$), we calculate the slope:
    1. $$\text{Slope} = \frac{D_1}{\Delta r} \approx 3F^2$$
      Then, $F \approx \sqrt{\text{Slope} / 3}$.

Solution Script

The server was extremely slow because it computed 100,000 RSA operations per request, so I had to optimize the script to only fetch the minimum required samples (3).
 
from pwn import * from Crypto.Util.number import long_to_bytes import math def get_rational_approximation(num, den): """ Standard continued fraction algorithm to recover the fraction p/q that best approximates num/den, where q is around 256 bits. """ convergents = [] x_num, x_den = abs(num), abs(den) p_2, q_2 = 0, 1 p_1, q_1 = 1, 0 while x_den != 0: a = x_num // x_den p = a * p_1 + p_2 q = a * q_1 + q_2 # If denominator exceeds expected nonce size (256 bits), stop. if q.bit_length() > 265: break convergents.append((p, q)) x_num, x_den = x_den, x_num % x_den p_2, q_2 = p_1, q_1 p_1, q_1 = p, q return convergents[-1] if convergents else (0, 1) def solve(): r = remote('amt.rs', 45157) # 1. Parse N and e log.info("Waiting for n, e...") while True: line = r.recvline().decode().strip() if "n, e =" in line: break val_str = line.split('=')[1].strip().strip('()') parts = val_str.split(',') n = int(parts[0]) # 2. Collect 3 Ciphertexts ciphertexts = [] samples = 3 log.info(f"Collecting {samples} samples. PLEASE WAIT (Server is slow)...") for i in range(samples): r.recvuntil(b'scramble the flag: ') r.sendline(b'0') r.recvuntil(b'c = ') c_val = int(r.recvline().strip()) ciphertexts.append(c_val) log.info(f"Got sample {i+1}/{samples}") # 3. Differential Analysis diffs = [ciphertexts[i+1] - ciphertexts[i] for i in range(len(ciphertexts)-1)] D1 = diffs[0] D2 = diffs[1] log.info("Calculating flag from differences...") # Recover delta_r using continued fractions dr1, dr2 = get_rational_approximation(D1, D2) # Calculate slope (approx 3 * F^2) slope = abs(D1) // abs(dr1) # Recover F F = math.isqrt(slope // 3) # Shift back (flag was shifted << 256) flag_int = F >> 256 try: flag = long_to_bytes(flag_int) log.success(f"FLAG: {flag.decode()}") except Exception as e: log.error(f"Error decoding: {e}") if __name__ == "__main__": solve()

Execution Output

Running the script against the live instance:
 
notion image
 

Flag:

amateursCTF{n0_th3_fl4g_1s_n0T_th3_Same_1f_y0U_w3r3_w0ndeRing_533e72a10}


misc/snake Amateurs2025

 
20 solves / 309 points
Description:
lets play some snake
 

Step 1: Connect

Run the command in your terminal:
Bash
nc amt.rs 34411

Step 2: Register an Account

  1. When prompted, type register and press Enter.
  1. The system will give you a UID (e.g., 9457385429662). Copy this number.
  1. For the Password, type pass and press Enter.
(You are now logged in as a normal user. We need to log out first to perform the injection).
notion image

Step 3: Log Out

  1. Type settings and press Enter.
  1. Type logout and press Enter.

Step 4: Malicious Login (The Injection)

This is the most critical part. We will use the backslash \ to trick the system into accepting spaces in the UID variable.
  1. Type login and press Enter.
  1. At the UID: prompt, do exactly this:Plaintext
      • Type your UID followed by a space and a backslash: [YOUR_UID] \
      • Press Enter.
      • Type this exact payload: pass data/d;1d;data/#
      • Press Enter.
      Example of what it looks like on your screen:
      UID: 9457385429662 \
      pass data/d;1d;data/#
notion image
 
  1. flag:
notion image
 

Flag:

 
amateursCTF{y0u_ar3_th3_r3al_w1nn3r_0f_sn4k3}