SYNTRA
Your task is to investigate the SYNTRA and see if you can find any leads. http://chals.tisc25.ctf.sg:57190/ Attached files: syntra-server
The website is designed to simulate a radio, with various dials and knobs.
Let’s look at the provided server binary to figure out what it does. Opening it in IDA, we see that it is a Golang Gin web server. It’s possible to solve the whole challenge by throwing the decompilation into your favourite LLM, so I’ll just give a brief overview of the server design.
The index handler index_handler_fn()
parses the request body using main_parseMetrics()
. This function first extracts a actions quantity and checksum from the payload. Then, it tries to extract that number of actions from the remaining payload, checking against the checksum. Finally, it returns an object containing a list of actions, and the number of actions in that list.
Next, the index handler uses these metrics with main_determineAudioResource()
to determine which resource to send the user. This function calls main_evaluateMetricsQuality()
which compares the supplied metrics against a baseline metrics, determining if the flag should be returned. If main_evaluateMetricsQuality()
returns true, then main_determineAudioResource()
returns the flag file. Otherwise, it chooses from a number of other audio asset files.
The goal is to pass the check in main_evaluateMetricsQuality()
. The tricky bit is that these checks are against a dynamically generated baseline metrics from main_computeMetricsBaseline()
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
__int64 __golang main_computeMetricsBaseline(...)
{
v9 = main_calibrationData;
v10 = qword_BCBEE8;
v11 = 0;
v12 = 0;
v13 = 0;
while ( v10 > 0 )
{
v44 = v10;
v49 = v9;
a8 = (__int64)v9[1];
v41 = a8;
a9 = *v9;
v47 = *v9;
for ( i = 0; i < a8; i = v43 )
{
a5 = (RTYPE *)(i + 8);
if ( a8 < (unsigned __int64)(i + 8) )
runtime_panicSliceAlen(v11, i, i + 8);
if ( i > (unsigned __int64)a5 )
runtime_panicSliceB(i, i, i + 8);
v43 = i + 8;
v42 = v12;
v40 = v13;
v48 = v11;
a4 = 32;
v24 = strconv_ParseUint(
(int)a9 + (int)i,
8,
16,
32,
(_DWORD)a5,
(_DWORD)v9,
v10,
a8,
(_DWORD)a9,
v32,
v33,
v34,
v35);
v29 = v42 + 1;
v30 = v40;
if ( v40 < v42 + 1 )
{
v38 = v24;
a4 = 1;
a5 = &RTYPE_uint32;
v31 = runtime_growslice(
v48,
v29,
v40,
1,
(unsigned int)&RTYPE_uint32,
v25,
v26,
v27,
v28,
v32,
v33,
v34,
v35,
v36);
v24 = v38;
}
else
{
v31 = v48;
}
*(_DWORD *)(v31 + 4 * v29 - 4) = v24;
a8 = v41;
LODWORD(a9) = (_DWORD)v47;
v9 = v49;
v10 = v44;
v11 = v31;
v13 = v30;
v12 = v42 + 1;
}
v9 += 2;
--v10;
}
for ( j = 0; v12 > (__int64)j; ++j )
{
if ( j >= 0x40 )
runtime_panicIndex(j, i, 64, a4, a5);
*(_DWORD *)(v11 + 4 * j) ^= main_correctionFactors[j];
}
v39 = v12;
v46 = v11;
v15 = 0;
v16 = 0;
v17 = 0;
v18 = 0;
while ( v15 < v12 )
{
v19 = v18 + 1;
v20 = *(_DWORD *)(v11 + 4 * v15);
if ( v16 < v18 + 1 )
{
v45 = v15;
v37 = *(_DWORD *)(v11 + 4 * v15);
v21 = runtime_growslice(
v17,
v19,
v16,
1,
(unsigned int)&RTYPE_main_ActionRecord,
v19,
v20,
a8,
(_DWORD)a9,
v32,
v33,
v34,
v35,
v36);
v15 = v45;
v20 = v37;
v17 = v21;
v19 = v18 + 1;
v16 = v22;
v11 = v46;
v12 = v39;
}
a8 = 3 * v19;
LODWORD(a9) = v20;
*(_DWORD *)(v17 + 4 * a8 - 12) = HIWORD(v20);
*(_QWORD *)(v17 + 4 * a8 - 8) = (unsigned __int16)v20;
++v15;
v18 = v19;
}
return v17;
}
The decompiled Go code isn’t very pretty, but all it really does is convert eight-character hex sequences from main_calibrationData
into integers and XORs it against the values in main_correctionFactors
to create an array of actions. From the Golang array metadata, we can tell that main_calibrationData
is an array of 3 strings of length 0x20.
Then, we can extract the required bytes and reconstruct the baseline metrics. Here is the final solve script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import struct
import requests
# MD5 constants (main_correctionFactors)
correction_factors = [
0x0D76AA478, 0x0E8C7B756, 0x242070DB, 0x0C1BDCEEE, 0x0F57C0FAF,
0x4787C62A, 0x0A8304613, 0x0FD469501, 0x698098D8, 0x8B44F7AF,
0x0FFFF5BB1, 0x895CD7BE, 0x6B901122, 0x0FD987193, 0x0A679438E,
0x49B40821, 0x0F61E2562, 0x0C040B340, 0x265E5A51, 0x0E9B6C7AA,
0x0D62F105D, 0x2441453, 0x0D8A1E681, 0x0E7D3FBC8, 0x21E1CDE6,
0x0C33707D6, 0x0F4D50D87, 0x455A14ED, 0x0A9E3E905, 0x0FCEFA3F8,
0x676F02D9, 0x8D2A4C8A, 0x0FFFA3942, 0x8771F681, 0x6D9D6122,
0x0FDE5380C, 0x0A4BEEA44, 0x4BDECFA9, 0x0F6BB4B60, 0x0BEBFBC70,
0x289B7EC6, 0x0EAA127FA, 0x0D4EF3085, 0x4881D05, 0x0D9D4D039,
0x0E6DB99E5, 0x1FA27CF8, 0x0C4AC5665, 0x0F4292244, 0x432AFF97,
0x0AB9423A7, 0x0FC93A039, 0x655B59C3, 0x8F0CCC92, 0x0FFEFF47D,
0x85845DD1, 0x6FA87E4F, 0x0FE2CE6E0, 0x0A3014314, 0x4E0811A1,
0x0F7537E82, 0x0BD3AF235, 0x2AD7D2BB, 0x0EB86D391
]
correction_factors = correction_factors[:0x40]
assert len(correction_factors) == 0x40
cd = r"d76ba478e8c2b755242670dcc1bfceeef5790fae4781c628a8314613fd439507698698dd8b47f7affffa5bb5895ad7be"
assert len(cd) == 0x60
calibration_data = [
cd[:0x20], cd[0x20:0x40], cd[0x40:0x60]
]
def compute_metrics_baseline(calibration_strings):
"""
Reimplementation of main_computeMetricsBaseline.
- calibration_strings: list of hex strings (like in main_calibrationData).
Returns a list of ActionRecords (tuples).
"""
# Step 1: parse calibration strings into 32-bit ints
parsed_values = []
for s in calibration_strings:
# take every 8 hex chars (32 bits)
for i in range(0, len(s), 8):
chunk = s[i:i+8]
val = int(chunk, 16)
parsed_values.append(val)
# Step 2: XOR with correction factors
for j in range(len(parsed_values)):
parsed_values[j] ^= correction_factors[j % len(correction_factors)]
# Step 3: build ActionRecords
# (high 16 bits, low 16 bits) per value
records = []
for v in parsed_values:
hi = (v >> 16) & 0xFFFF
lo = v & 0xFFFF
# records.append((hi, lo))
records.append({"type": hi, "v1": lo, "v2": 0})
return records
def build_metrics_payload(actions: list[dict]) -> bytes:
"""
Build a binary MetricsData payload to satisfy main.parseMetrics.
Each action is a dict: {"type": int, "v1": int, "v2": int}
"""
count = len(actions)
# compute checksum (like parseMetrics does)
checksum = count
for rec in actions:
checksum ^= (rec["type"] ^ rec["v1"] ^ rec["v2"])
# header: 16 bytes
# [0:8] unused/padding, [8:12] count, [12:16] checksum
header = b"\x00" * 8
header += struct.pack("<I", count)
header += struct.pack("<I", checksum)
# actions
body = b""
for rec in actions:
body += struct.pack("<III", rec["type"], rec["v1"], rec["v2"])
payload = header + body
assert len(payload) == 16 + 12 * count
return payload
baseline = compute_metrics_baseline(calibration_data)
actions = list(filter(lambda x: x["type"] != 4, baseline))
payload = build_metrics_payload(actions)
r = requests.post("http://chals.tisc25.ctf.sg:57190/?t=1757695234447", data=payload, headers={'Content-Type': 'application/octet-stream'})
print(r.text[:200])
print(r.status_code)
This returns the flag file, which contains the the flag in its header.
Flag: TISC{PR3551NG_BUTT0N5_4ND_TURN1NG_KN0B5_4_S3CR3T_S0NG_FL4G}