Level 5 - Syntra (rev)

Given binary:
The front-end of the service looks like this:

All 6 buttons are clickable.
When we click play
or next
it just plays some classical music track.
Although you could start by reverse engineering the javascript on the webpage, I opted to try to reverse engineer the given binary instead.
I decided to use this opportunity to try integrating GitHub Copilot with an IDA Pro MCP (the MCP setup can be found here). I interchanged between Claude Sonnet 4 and ChatGPT 5 depending on which one gave a more legitimate-looking answer.
From my own (very brief) analysis, there seemed to be a flag at location assets/flag.mp3
The important functions are:
main_parseMetrics
: Parses the metrics provided (which are a set of key-value pairs)main_determineAudioResource
: Determines which audio resource to usemain_evaluateMetricsQuality
: If this returns true,flag.mp3
is played. Otherwise, a random music file fromassets/music
is played
The LLMs were unable to get the correct metrics from the start. However after several prompts, it was able to obtain a set of baseline pairs:
baseline_pairs = [
(1, 0), # d76b… ⊕ d76a… => 0x00010000
(5, 3), # e8c2… ⊕ e8c7… => 0x00050003
(6, 7), # 2426… ⊕ 2420… => 0x00060007
(2, 0), # c1bf… ⊕ c1bd… => 0x00020000
(5, 1), # f579… ⊕ f57c… => 0x00050001
(6, 2), # 4781… ⊕ 4787… => 0x00060002
(1, 0), # a831… ⊕ a830… => 0x00010000
(5, 6), # fd43… ⊕ fd46… => 0x00050006
(6, 5), # 6986… ⊕ 6980… => 0x00060005
(3, 0), # 8b47… ⊕ 8b44… => 0x00030000
(5, 4), # fffa… ⊕ ffff… => 0x00050004
(6, 0), # 895a… ⊕ 895c… => 0x00060000
]
which were obtained from XORing calibration and correction factors. It then adds timestamps to the key-value pairs.
One thing which helped me determine if the LLM produced the right key-value pairs was asking it to attempt each set of values thrice. If the MD5 hashes of the obtained audio files were different then we most likely didn't get the flag. If they were all the same, however, then it is highly likely that we got the flag.
Below is the final solve script:
#!/usr/bin/env python3
"""
Solve script for TISC25 Level 5 - Syntra Audio Player CTF Challenge
Analysis:
- The server expects POST requests with binary metrics data
- Metrics format: header(8) + count(4) + checksum(4) + action_records(12*count)
- If metrics match the baseline, server returns flag.mp3
- Need to reverse engineer the expected metrics pattern
"""
import requests
import struct
import time
import hashlib
import os
# Server configuration
SERVER_HOST = "chals.tisc25.ctf.sg"
SERVER_PORT = 57190
BASE_URL = f"http://{SERVER_HOST}:{SERVER_PORT}"
def create_metrics_payload(action_records):
"""
Create binary metrics payload matching the expected format
Format based on parseMetrics and W() functions:
- 8 bytes: header/ID (from M() function - random bytes)
- 4 bytes: count (number of action records)
- 4 bytes: checksum (XOR of all action record fields)
- N * 12 bytes: action records (type, value, timestamp)
Action record format (12 bytes each):
- 4 bytes: type (uint32)
- 4 bytes: value (uint32)
- 4 bytes: timestamp (uint32)
"""
# Generate header (8 random bytes, but we'll use a pattern)
header = b'\x01\x02\x03\x04\x05\x06\x07\x08'
# Count of action records
count = len(action_records)
# Calculate checksum (XOR of all action record fields)
checksum = count # Start with count
for action in action_records:
action_type, value, timestamp = action
checksum ^= action_type ^ value ^ (timestamp & 0xff)
# Build payload
payload = bytearray()
payload.extend(header)
payload.extend(struct.pack('<I', count)) # Little endian count
payload.extend(struct.pack('<I', checksum & 0xffffffff)) # Little endian checksum
# Add action records
for action in action_records:
action_type, value, timestamp = action
payload.extend(struct.pack('<I', action_type))
payload.extend(struct.pack('<I', value))
payload.extend(struct.pack('<I', timestamp))
return bytes(payload)
def send_metrics(payload, save_filename=None):
"""Send metrics payload to server"""
try:
timestamp = int(time.time() * 1000) # Current timestamp in milliseconds
url = f"{BASE_URL}/?t={timestamp}"
headers = {
'Content-Type': 'application/octet-stream',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
print(f"Sending {len(payload)} bytes to {url}")
response = requests.post(url, data=payload, headers=headers, timeout=10)
print(f"Response status: {response.status_code}")
if response.status_code == 200:
# Save the audio file if filename provided
if save_filename:
with open(save_filename, 'wb') as f:
f.write(response.content)
print(f"✓ Audio saved as {save_filename}")
return response.content
else:
print(f"Response body: {response.text}")
return None
except Exception as e:
print(f"Error sending request: {e}")
return None
def get_md5(data):
"""Get MD5 hash of data"""
return hashlib.md5(data).hexdigest()
def try_baseline_pattern():
"""
Try to create metrics that match the expected baseline pattern.
Verified from reversing main.computeMetricsBaseline:
- Baseline is derived by XORing 8-hex-digit words from calibration strings with
correction factors, then splitting into (type = high 16 bits, value = low 16 bits).
- The resulting baseline has 12 entries (3 segments × 4 words each).
- Verification uses the last N non-type-4 actions (N = baseline length) and checks:
* Types must match exactly
* For type 5 and 6, values must also match exactly
* Timestamps are NOT used in matching (only in checksum low byte)
"""
current_time = int(time.time() * 1000) & 0xffffffff
# Exact 12-entry baseline (type, value) computed from calibration ⊕ correction factors
# calibration strings at 0xbd1ee0
# correction factors at 0xb7d240
# Words (cal): d76ba478 e8c2b755 242670dc c1bfceee | f5790fae 4781c628 a8314613 fd439507 | 698698dd 8b47f7af fffa5bb5 895ad7be
# Words (cf): d76aa478 e8c7b756 242070db c1bdceee | f57c0faf 4787c62a a8304613 fd469501 | 698098d8 8b44f7af ffff5bb1 895cd7be
# XOR result → (type,value):
baseline_pairs = [
(1, 0), # d76b… ⊕ d76a… => 0x00010000
(5, 3), # e8c2… ⊕ e8c7… => 0x00050003
(6, 7), # 2426… ⊕ 2420… => 0x00060007
(2, 0), # c1bf… ⊕ c1bd… => 0x00020000
(5, 1), # f579… ⊕ f57c… => 0x00050001
(6, 2), # 4781… ⊕ 4787… => 0x00060002
(1, 0), # a831… ⊕ a830… => 0x00010000
(5, 6), # fd43… ⊕ fd46… => 0x00050006
(6, 5), # 6986… ⊕ 6980… => 0x00060005
(3, 0), # 8b47… ⊕ 8b44… => 0x00030000
(5, 4), # fffa… ⊕ ffff… => 0x00050004
(6, 0), # 895a… ⊕ 895c… => 0x00060000
]
baseline_len = len(baseline_pairs)
print("Testing the EXACT baseline pattern extracted from binary analysis")
print(f"Using the precise {baseline_len}-action sequence the server expects")
# Create patterns with timestamps for testing
patterns = []
# Single canonical pattern with decreasing timestamps (oldest -> newest)
pattern = [(t, v, (current_time - (baseline_len - 1 - j) * 1000) & 0xffffffff)
for j, (t, v) in enumerate(baseline_pairs)]
patterns.append(pattern)
print(f"Total patterns to test: {len(patterns)}")
print("Each pattern will be tested 3 times to check for consistency")
for i, pattern in enumerate(patterns):
pattern_letter = chr(65 + (i % 26)) # A, B, C, etc.
print(f"\n--- Trying pattern {pattern_letter} ({i+1}/{len(patterns)}) ---")
print(f"Pattern has {len(pattern)} total actions")
# Count non-type-4 actions for verification
non_type4_actions = [action for action in pattern if action[0] != 4]
print(f"Non-type-4 actions: {len(non_type4_actions)} (last {baseline_len} will be used for verification)")
if len(non_type4_actions) >= baseline_len:
print("✓ Sufficient actions for verification")
last_seq = non_type4_actions[-baseline_len:]
print(f"Last {baseline_len} actions for verification:")
for j, (action_type, value, timestamp) in enumerate(last_seq):
print(f" {j}: type={action_type}, value={value}")
# Show which actions need exact value matching
type_5_6_actions = [(j, t, v) for j, (t, v, _) in enumerate(last_seq) if t in [5, 6]]
if type_5_6_actions:
print(f"Type 5/6 actions (need exact value match): {type_5_6_actions}")
else:
print("✗ Insufficient non-type-4 actions for verification")
continue
payload = create_metrics_payload(pattern)
print(f"Payload size: {len(payload)} bytes")
# Run the pattern 3 times and collect responses
responses = []
md5_hashes = []
for attempt in range(3):
print(f" Attempt {attempt + 1}/3...")
response_data = send_metrics(payload)
if response_data:
responses.append(response_data)
md5_hash = get_md5(response_data)
md5_hashes.append(md5_hash)
print(f" MD5: {md5_hash}, Size: {len(response_data)} bytes")
# Check if this looks like audio data (MP3 starts with ID3 or 0xFF)
if response_data.startswith(b'ID3') or response_data[0] == 0xFF:
print(f" ✓ Response appears to be MP3 audio data")
else:
print(f" ? Response format: {response_data[:16].hex()}")
else:
print(f" Failed attempt {attempt + 1}")
break
time.sleep(0.5) # Small delay between attempts
# Check if all 3 attempts returned the same file (same MD5)
if len(md5_hashes) == 3 and len(set(md5_hashes)) == 1:
print(f"\n✓ Pattern {pattern_letter}: All 3 attempts returned identical files!")
print(f"✓ MD5: {md5_hashes[0]}")
print(f"✓ File size: {len(responses[0])} bytes")
# Check if this is likely the flag vs random music
if len(responses[0]) > 50000: # Reasonable size for flag.mp3
print("✓ File size suggests this could be flag.mp3")
# Save the flag
with open('flag.mp3', 'wb') as f:
f.write(responses[0])
print("✓ Flag saved as flag.mp3!")
print(f"✓ Winning pattern: {pattern}")
return True
elif len(md5_hashes) == 3:
print(f" Different MD5s returned: {len(set(md5_hashes))} unique")
print(" This indicates random music files (not the flag)")
time.sleep(1) # Rate limiting between patterns
return False
def check_server():
"""Check if server is running"""
try:
response = requests.get(f"{BASE_URL}/", timeout=5)
print(f"Server check - Status: {response.status_code}")
return True
except:
print("Server not responding")
return False
def main():
print("TISC25 Level 5 - Syntra Audio Player Solver")
print("=" * 50)
if not check_server():
print("Please start the server first")
return
# Try to get flag.mp3
if try_baseline_pattern():
print("\n🎉 Successfully obtained flag.mp3!")
print("Check the flag.mp3 file in the current directory.")
else:
print("\n✗ Failed to get flag.mp3")
print("\nThis might require:")
print("1. Correct calibration data extraction from binary")
print("2. Precise reconstruction of expected metrics pattern")
print("3. Proper timing and checksum calculation")
if __name__ == "__main__":
main()
# flag: TISC{PR3551NG_BUTT0N5_4ND_TURN1NG_KN0B5_4_S3CR3T_S0NG_FL4G}
Last updated