Description
The bingo challenge is a cryptography task that requires providing a valid RSA signature for a message with a specific prefix: “Get Flag.”.
Upon connection, the server provides the public RSA parameters $N$ and $e$, along with three parameters ($hash_p$, $\alpha$, and $\beta$) for a custom hashing mechanism. The goal is to find a message $M$ and a signature $S$ such that $S^e \equiv H(M) \pmod N$.
Analysis
The Verification Bypass
The core of the challenge lies in the relationship between the custom hash $H(M)$ and the RSA signature verification equation: $$S^e \equiv H(M) \pmod N$$
In many custom hash implementations used in CTF challenges, there exists a mathematical “null point” where the hash output simplifies to a trivial value. Based on the parameters provided ($hash_p$, $\alpha$, and $\beta$), the hash function $H(M)$ is designed such that if the message $M$ is a multiple of $(hash_p - 1)^2$, the internal state of the hash collapses, resulting in: $$H(M) = 1$$
The Trivial Signature
When $H(M) = 1$, the RSA equation becomes: $$S^e \equiv 1 \pmod N$$
Since $1$ raised to any power $e$ is always $1$ (i.e., $1^e \equiv 1 \pmod N$), we do not need the private key to sign the message. We simply need to provide $S = 1$.
Thought Process
To solve this, we must satisfy the server’s prefix constraint while forcing the hash to 1.
1. Identifying the Target Multiple
We define $L = hash_p - 1$. Our target is to create a message $M$ such that $M$ is a multiple of $L^2$. $$M \equiv 0 \pmod{L^2}$$
2. Satisfying the Prefix Constraint
The server requires $M$ to start with the bytes Get Flag.. We can represent this prefix as a large integer $P$. To maintain this prefix while ensuring $M$ is a multiple of $L^2$, we:
- Append a large amount of “padding” to $P$ (e.g., 300 bytes) to create a lower bound.
- Calculate the smallest integer $k$ such that $k \cdot L^2$ is greater than or equal to this padded value.
- Verify that the resulting $M = k \cdot L^2$ still starts with the desired prefix bytes.
Solution
- Retrieve Parameters: Connect to the service and parse $N$, $e$, $hash_p$, $\alpha$, and $\beta$.
- Calculate $M$:
- Let $mod_target = (hash_p - 1)^2$.
- Convert
Get Flag.to integer $P$. - Find the smallest $k$ where $k \cdot mod_target \ge P \cdot 256^{300}$.
- Submit:
- Send the hexadecimal representation of $M = k \cdot mod_target$.
- Send the signature $S = 1$.
The server verifies the prefix, computes $H(M) = 1$, and confirms that $1^e \equiv 1 \pmod N$, ultimately granting the flag.
Conclusion
The challenge demonstrates that custom hash functions often introduce vulnerabilities if they are not cryptographically secure against pre-image attacks. By forcing the hash output to a fixed point of the RSA function (in this case, 1), the entire security of the signature scheme is bypassed.
Code Implementation
Here is a Python script that implements the solution:
from pwn import *
from Crypto.Util.number import bytes_to_long, long_to_bytes
def solve():
# Connect to the challenge instance
# Replace with actual host/port from the challenge (or docker)
io = remote('35.194.98.181', 10961)
# Parse parameters from the server
io.recvuntil(b"N = ")
N = int(io.recvline().strip())
io.recvuntil(b"e = ")
e = int(io.recvline().strip())
io.recvuntil(b"hash_p = ")
hash_p = int(io.recvline().strip())
io.recvuntil(b"alpha = ")
alpha = int(io.recvline().strip())
io.recvuntil(b"beta = ")
beta = int(io.recvline().strip())
L = hash_p - 1
mod_target = L * L
# Target prefix
prefix = b"Get Flag."
P = bytes_to_long(prefix)
# We need M = k * (L^2) such that M starts with 'Get Flag.'
# We use a large padding (e.g., 300 bytes) to ensure we can find such a k
padding_bytes = 300
lower_bound = P * (256 ** padding_bytes)
# Find the smallest k such that k * L^2 >= lower_bound
k = (lower_bound + mod_target - 1) // mod_target
M_int = k * mod_target
M_bytes = long_to_bytes(M_int)
# Safety check: ensure the prefix is correct
if not M_bytes.startswith(prefix):
print("[-] Prefix mismatch. Adjusting padding...")
# If it doesn't match, it's likely due to byte alignment;
# normally k calculation ensures it starts with P.
return
# If H = 1, then signature = 1 is always valid for RSA
message_hex = M_bytes.hex()
signature = 1
print(f"[+] Sending message starting with: {M_bytes[:10]}")
io.sendlineafter(b"input message (hex): ", message_hex.encode())
io.sendlineafter(b"input signature (int): ", str(signature).encode())
# Get the flag
result = io.recvall().decode()
print(result)
if __name__ == "__main__":
solve()