>Bingo
Flag format: TSGCTF{...}
>TL;DR
A logic bug in the server lets you get the flag simply by sending the message Get Flag. (hex: 47657420466c61672e) followed by any integer as the "signature". The server prints "Signature verification failed." but still continues to check the message and prints the flag when the message starts with Get Flag..
I provide a short explanation, the exact steps I used locally and remotely, and two tiny solver scripts (exploit_local.py and exploit_remote.py).
>Files provided
-
server.py— the challenge server (Python) -
Dockerfile,compose.yaml, andrequirements.txt— how the service was packaged
>Static analysis / understanding the server
Important excerpts from server.py (simplified):
# Hash function (custom)
def cvhp_hash(message_bytes):
m = bytes_to_long(message_bytes)
m_1 = m % (hash_p - 1)
m_2 = m // (hash_p - 1)
h = pow(alpha, m_1, hash_p) * pow(beta, m_2, hash_p) % hash_p
return h
# Signature verification (RSA-like check)
def verify(message_bytes, signature, N, e):
h = cvhp_hash(message_bytes)
if pow(signature, e, N) != h:
return False
return True
# Check message and reveal flag if message starts with b"Get Flag."
def check_message(message_bytes, signature, N, e):
if not verify(message_bytes, signature, N, e):
print("Signature verification failed.")
if message_bytes.startswith(b"Get Flag."):
print(FLAG)
else:
print("We have nothing to give you.")
Why this is exploitable
-
The verification returns
Falsefor incorrect signatures and prints a warning, but it does not terminate the flow. The code always proceeds to the nextifwhich checks whether the message begins withb"Get Flag."and printsFLAGif so. -
Therefore an attacker does not need to compute any valid signature — sending any integer (e.g.
0) as the signature is sufficient.
This is purely a logic/flow bug (not a complicated crypto break), and can be exploited trivially.
>Local exploitation
Steps I used locally:
-
Extract the archive and inspect
server.py(already done). -
Run the server locally and provide inputs.
Example (manual):
$ python3 server.py
N = ...
e = 65537
hash_p = ...
alpha = ...
beta = ...
input message (hex): 47657420466c61672e
input signature (int): 0
Signature verification failed.
TSGCTF{THIS_IS_FAKE}
Explanation: The program printed Signature verification failed. (because 0^e mod N != H) and then printed the flag because the message started with Get Flag..
Local helper script
I included exploit_local.py in the repo (in the same folder). It spawns server.py, sends the payload and prints server output.
Usage:
$ python3 exploit_local.py
It prints the server's startup info and then the flag.
>Remote exploitation
Because the behavior is the same over the network, we can connect to the remote service and send the same message hex + any signature pair.
The exact message we sent (hex for Get Flag.):
-
47657420466c61672e(this is ASCIIGet Flag.in hex) -
Signature:
0(an arbitrary integer)
I tested with a short Python client (see exploit_remote.py) that connects and sends these two inputs.
Remote terminal output (captured):
N = 94788024375365760853147670634494760403756975343917897489131981615355733465218461171045...
e = 65537
hash_p = 15296403113196288063295238350364867242230645195171379905624...
alpha = 1438923097156689521297368618465847410950034118726488957525259...
beta = 1359000061564595656493009060536669352970860547185611396242556...
input message (hex): input signature (int): Signature verification failed.
TSGCTF{+he|23_was_4_f4rm3|2_had_a_|)o6_a|^_||)_8i|^_|6o_wa5_his_nam3}
That final line above is the real flag returned by the remote service.
>All solver code
I included both scripts in the repository so you can reproduce the exploit easily.
exploit_remote.py
#!/usr/bin/env python3
import socket, sys
host = sys.argv[1] if len(sys.argv) > 1 else '35.194.98.181'
port = int(sys.argv[2]) if len(sys.argv) > 2 else 10961
payload = bytes.fromhex('47657420466c61672e') + b'\n' # "Get Flag."
sig = b'0\n' # any integer
with socket.create_connection((host, port), timeout=10) as s:
s.sendall(payload + sig)
data = b''
while True:
try:
chunk = s.recv(4096)
if not chunk:
break
data += chunk
except socket.timeout:
break
print(data.decode(errors='replace'))
Usage:
$ python3 exploit_remote.py
Or provide host/port: python3 exploit_remote.py 35.194.98.181 10961.
exploit_local.py
#!/usr/bin/env python3
import subprocess, time
# Start server.py locally (it prints N,e,hash_p,alpha,beta first)
p = subprocess.Popen(['python3', 'server.py'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
time.sleep(0.1)
# Send hex for "Get Flag." and an arbitrary integer for the signature
payload = '47657420466c61672e\n0\n'
out, _ = p.communicate(input=payload.encode(), timeout=5)
print(out.decode(errors='replace'))
Usage:
$ python3 exploit_local.py
>Fixes and lessons learned
-
The correct behavior after
verify(...)fails is to stop the request (e.g.,returnorexit) and not proceed to reveal sensitive information. -
Avoid printing flags or secret values under an
ifthat is reachable regardless of verification outcome.
Suggested patch (simple):
if not verify(...):
print("Signature verification failed.")
return # <-- important, stop here
>References
-
RSA signature verification concept: https://en.wikipedia.org/wiki/RSA_(cryptosystem)
-
PyCryptodome: https://pycryptodome.readthedocs.io
>Conclusion
This challenge was a classic logic/flow vulnerability: the cryptography looked complicated at first (custom hash, RSA-style verification) but the actual flaw was trivial once you read the code carefully. Sending Get Flag. and any integer as signature is enough to retrieve the flag.