Introduction
This CTF I played with Cosmic Bit Flip. We managed to get a calm 1st place high school win! Thanks to all of my orz teammates:
- c-bass
- anywheres
- wjat
- brew
- mathlegend

More thanks to les amateurs for organizing a great CTF with high quality challenges.
Here I’ll write about two crypto challenges that wjat and I solved using a timing side-channel.

What the server did
from Crypto.Util.number import *from random import getrandbits, choiceimport hashlib
flag = open('flag.txt','rb').read().strip()assert len(flag) == 72flag = bytes_to_long(flag) << 256
n = getPrime(1024) * getPrime(1024)e = 3
print(f'{n, e = }')
while True: cs = [flag + getrandbits(256) for _ in range(100000)] scramble = int(input('scramble the flag: '))
ms = [(m + scramble)%n for m in cs]
print('scrambling...')
c = choice([pow(m, e, n) for m in ms]) print(f'{c = }')That was the server for the first challenge, addition2. The second one (addition3) only had a few minor changes:
from Crypto.Util.number import *import osfrom random import getrandbits, choiceimport hashlib
flag = open('flag.txt','rb').read().strip()assert len(flag) == 52
flag = bytes_to_long(flag) << 512
while True:
n = int(os.popen('openssl genrsa 2048 | openssl rsa -noout -modulus').read()[8:], 16) e = 3
print(f'{n, e = }') cs = [flag + getrandbits(512) for _ in range(100000)] scramble = int(input('scramble the flag: '))
ms = [(m + scramble)%n for m in cs]
print('scrambling...')
c = choice([pow(m, e, n) for m in ms]) print(f'{c = }')%Here, the server:
- Generates an RSA modulus and uses , then prints these values
- It builds a list
cs = [flag_shifted + getrandbits(K) for _ in range(100000)].
- addition2: flag shifted left 256 bits, .
- addition3: flag shifted left 512 bits, .
- It asks for an integer .
- It computes
ms = [(m + scramble) % n for m in cs]. - It computes
cands = [pow(m, e, n) for m in cs], and prints one random choice of these ciphertexts.
Key observation: the inner loops are repeated 100,000 times, implying that tiny timing differences are massively amplified.
Where’s the leak?
Let be the integer flag after the left shift (). For a candidate padding we have , being the random bytes. When we send a scramble the server computes:
Now, two operations within the 100k loop depend on :
- Reduction:
A % n - Modular exponentiation:
pow(A, e, n). This computes . The cost of this grows with the bit-length of the base . If is small, then repeated exponentiation is faster; if is near when mod , exponentiation is much slower.
We found in practice that pow dominates the wall-clock difference on our machine: large effecive bases lead to much slower pow, amplified by 100k. Roughly, it might take seconds instead of .
A very robust probe is to send .
Then per candidate:
- If , is large positive and dominates , leading to a positive and a small base, making it FAST.
- If , is negative and dominates , leading to a negative and a large base, making it SLOW.
Note that a positive is going to be much smaller than . So, when it is positive, , which is a small base for pow. When is negative, becomes (a number very close to ), which is a huge base.
This gives us a stable oracle to search for the flag: FAST guess too low, SLOW guess too high.
How we turned the timing leak into an attack
For addition2, we could do a whole-value binary search ont he value . This only look roughly 500 or so queries, and if we used the flag format this would reduce further.
For addition3, there was more noise introduced (512 bits), so we did a byte-by-byte guess, and used null byte padding as a suffix. This makes guesses monotonically ordered by the byte we are testing; the transition FAST to SLOW falls exactly at the correct byte.
Solve script for addition2
import timefrom tqdm import trangefrom pwn import *from Crypto.Util.number import *
g1 = b"amateursCTF{" + bytes(48) + b"}"g1 = bytes_to_long(g1) << 256g2 = b"amateursCTF{" + bytes(48) + b"}"g2 = bytes_to_long(g2) << 256
def get_time(sus_flag): chall.sendline(str(-sus_flag).encode()) start = time.time() chall.recvuntil(b':') return time.time() - start
chall = process(['python3', 'chall.py'])# chall = remote('amt.rs', 38593)msg = chall.recvuntil(b':').decode()
for _ in trange(472): g3 = (g1 + g2)//2 if get_time(g3) >= 1: g2 = g3 else: g1 = g3
print(long_to_bytes(g3))That’s the binary-search approach, here is the solve script for addition3:
Solve script for addition3
#!/usr/bin/env python3from pwn import *from Crypto.Util.number import *import time
LOCAL = FalseTIME_THRESHOLD = 1.5
FLAG_LEN = 52FLAG_SHIFT = 512RANDOM_BITS = 512
PREFIX = b"amateursCTF{"SUFFIX = b""MIDDLE_LEN = FLAG_LEN - len(PREFIX) - len(SUFFIX)
CHARSET = list(range(48, 127))
def get_connection(): if LOCAL: return process(['python3', 'chall.py']) else: return remote('amt.rs', 5959)
def solve(): known_middle = b"" r_max = (2**RANDOM_BITS) - 1
conn = get_connection()
n_line = conn.recvline().decode() n, e = eval(n_line.split(" = ")[1])
for i in range(MIDDLE_LEN): log.info(f"Solving for byte {i}...") last_fast_byte = -1
for b_val in CHARSET: current_middle_guess = known_middle + bytes([b_val]) padded_middle = current_middle_guess.ljust(MIDDLE_LEN, b'\x00') full_guess_bytes = PREFIX + padded_middle + SUFFIX
F_guess = bytes_to_long(full_guess_bytes) << FLAG_SHIFT
scramble = n - r_max - F_guess conn.recvuntil(b'scramble the flag: ')
start_time = time.time() conn.sendline(str(scramble).encode())
conn.recvline() conn.recvline() duration = time.time() - start_time print(f"duration was {duration} for char {b_val}")
n_line = conn.recvline().decode() n, e = eval(n_line.split(" = ")[1])
if duration < TIME_THRESHOLD: last_fast_byte = b_val else: log.info(f" -> Guess '{chr(b_val)}' was SLOW ({duration:.2f}s). Transition found.") break
if last_fast_byte == -1: log.error(f"Failed to find any fast response for byte {i}. Threshold might be wrong or char not in charset.") break
known_middle += bytes([last_fast_byte]) log.success(f"Solved byte {i}: {PREFIX.decode()}{known_middle.decode()}...")
final_flag = PREFIX + known_middle + SUFFIX log.success(f"Recovered Flag: {final_flag.decode()}") conn.close()
if __name__ == "__main__": solve()This is the byte-by-byte approach. It takes a bit longer, but progress persists across instances, so that’s okay. Also, the charset within the flag format turned out to be lowercase hex, so that could have been optimized.
Conclusion
Wjat and I were stuck on this for a long time. We tried and failed to implement multivariate coppersmith for addition2 and were completely stumped for addition3. Eventually though, we thought of this timing side channel and managed to solve both.
I guess the lesson is that in creating cryptographically secure services, you always need to watch out for any side-channel that can leak information about secrets! By manipulating our controlled value (scramble) to probe for timing differences, we managed to leak the entire flag, even though the cryptosystem itself is secure.