bit-leak
bit-leak
Platform: Tjctf | Category: Cryptography | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-05-15 | Status: Solved Techniques: binary_search_interval_narrowing, chosen_ciphertext_multiplication, rsa_lsb_oracle_attack
Summary
Task: RSA parity oracle — server decrypts chosen ciphertexts and returns only the LSB (even/odd). Solution: exploit RSA multiplicative homomorphism to perform binary search on plaintext via repeated multiplication by 2^e mod n, recovering the exact message in ~512 queries.
Recon
Port scan
bash read-only • copy allowednmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
tjctf| ID:20260515_tjctf_bit_leak - Tags: binary_search, rsa, multiplicative_homomorphism, chosen_ciphertext, lsb_oracle, parity_oracle
- Indicators: RSA decryption oracle that reveals only the LSB (parity) of plaintext, server accepts arbitrary ciphertexts for decryption, limited query count but sufficient for log2(n) iterations, 512-bit RSA modulus (two 256-bit primes), pow(candidate
- Source:
20260515_tjctf_bit_leak.md
Foothold
Vulnerability / Misconfiguration
- binary_search_interval_narrowing
- chosen_ciphertext_multiplication
- rsa_lsb_oracle_attack
bash read-only • copy allowed<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
bash read-only • copy allowedsudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
bash read-only • copy allowed<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- binary_search_interval_narrowing
- chosen_ciphertext_multiplication
- rsa_lsb_oracle_attack
- Tags: binary_search, rsa, multiplicative_homomorphism, chosen_ciphertext, lsb_oracle, parity_oracle
Original Writeup
<details><summary>Click to expand original content</summary>bit-leak — TJCTF 2026
Description
our security monitor only ever tips us off about the parity of RSA decryptions. turns out "even or odd" isn't much of a secret. can you recover the message one bit at a time?
The server generates RSA with two 256-bit primes (512-bit modulus), e=65537. It encrypts the flag and provides n, e, and the ciphertext. The user gets up to 2100 parity oracle queries: send any ciphertext, receive pow(ciphertext, d, n) & 1 — the least significant bit (parity) of the decrypted value.
Analysis
This is a classic RSA LSB (Parity) Oracle scenario. The key insight is that RSA's multiplicative homomorphism allows us to manipulate the underlying plaintext without knowing it:
RSA Homomorphism: Given ciphertext c = m^e mod n, if we compute c' = c * 2^e mod n, then decrypting c' yields 2m mod n.
Parity reveals range information: Since n is odd (product of two odd primes):
- If
2m mod nis even →2m < n→m < n/2 - If
2m mod nis odd →2m ≥ n(reduction happened) →m ≥ n/2
Each query eliminates half the candidate range, giving us a binary search over [0, n).
Query budget: With a 512-bit modulus, we need exactly 512 queries. The server allows 2100, giving ample margin.
Precision: Floating-point arithmetic would lose precision over 512 iterations. The solve script tracks bounds as integer numerators with a shared power-of-2 denominator (lower_num/2^k, upper_num/2^k), maintaining exact arithmetic throughout.
Solution
Step-by-step
- Connect to the server and parse
n,e, andciphertext - Precompute the multiplier:
multiplier = pow(2, e, n)— this is2^e mod n - Initialize bounds:
lower = 0,upper = n(conceptually) - For each of 512 iterations:
- Multiply the working ciphertext by
multiplier mod n(equivalent to multiplying the plaintext by 2) - Send the modified ciphertext to the oracle
- If LSB = 0 (even): plaintext is in the lower half → update upper bound
- If LSB = 1 (odd): plaintext is in the upper half → update lower bound
- Multiply the working ciphertext by
- Recover the plaintext from the converged bounds
- Verify by re-encrypting:
pow(m, e, n) == c
Server source (key oracle logic)
python read-only • copy allowed# server.py - RSA setup p = generate_prime(256) q = generate_prime(256) n = p * q e = 65537 d = modinv(e, (p-1)*(q-1)) ciphertext = pow(bytes_to_long(flag), e, n) # Oracle: user sends candidate ciphertext, gets LSB of decryption parity = pow(candidate, d, n) & 1 print(f"lsb = {parity}")
Solve script
python read-only • copy allowed#!/usr/bin/env python3 """ RSA LSB (Parity) Oracle Attack TJCTF 2026 - bit-leak """ from pwn import * import re HOST = "tjc.tf" PORT = 31001 def main(): r = remote(HOST, PORT) data = r.recvuntil(b"parity queries.") data_str = data.decode() n = int(re.search(r'n = (\d+)', data_str).group(1)) e = int(re.search(r'e = (\d+)', data_str).group(1)) c = int(re.search(r'ciphertext = (\d+)', data_str).group(1)) log.info(f"n = {n}") log.info(f"e = {e}") log.info(f"c = {c}") num_bits = n.bit_length() # Track bounds as integers: actual_lower = lower_num * n / 2^denom_power lower_num = 0 upper_num = 1 denom_power = 0 multiplier = pow(2, e, n) current_c = c log.info(f"Starting LSB Oracle Attack ({num_bits} iterations)") for i in range(num_bits): # Multiply ciphertext by 2^e mod n → plaintext becomes 2*m mod n current_c = (current_c * multiplier) % n r.recvuntil(b"> ") r.sendline(b"1") r.recvuntil(b"ciphertext = ") r.sendline(str(current_c).encode()) resp = r.recvline().decode().strip() lsb = int(resp.split("=")[1].strip()) # Double both bounds (shift denominator) lower_num *= 2 upper_num *= 2 denom_power += 1 if lsb == 0: # 2^k * m mod n is even → m < midpoint upper_num = (lower_num + upper_num) // 2 else: # 2^k * m mod n is odd → m >= midpoint lower_num = (lower_num + upper_num) // 2 if (i + 1) % 64 == 0: log.info(f"Progress: {i + 1}/{num_bits} bits") # Recover plaintext from converged bounds for label, base in [("upper", (upper_num * n) >> denom_power), ("lower", (lower_num * n) >> denom_power)]: for delta in range(-5, 6): candidate = base + delta if candidate <= 0: continue try: flag_bytes = candidate.to_bytes((candidate.bit_length() + 7) // 8, 'big') flag_str = flag_bytes.decode('ascii', errors='replace') if 'tjctf{' in flag_str: log.success(f"[{label}+{delta}] FLAG: {flag_str}") # Verify by re-encrypting verify_c = pow(candidate, e, n) if verify_c == c: log.success(f"VERIFIED! m encrypts to c correctly") except: pass r.close() if __name__ == "__main__": main()
Why integer arithmetic matters
After k iterations, the bounds are fractions with denominator 2^k. With k = 512, floating-point (64-bit double, ~53 bits of mantissa) would lose all precision after ~53 iterations. The script avoids this by tracking lower_num and upper_num as arbitrary-precision Python integers, only converting to the actual plaintext value at the very end via (bound * n) >> denom_power.
Why the small delta search
Due to integer division rounding over 512 steps, the final bounds may be off by a few units. The script checks base ± 5 for both the upper and lower bound, verifying each candidate by re-encryption (pow(m, e, n) == c).
signed by XESXOR