DEV Community

Cover image for THM Crypto Failures — Full Walkthrough
Muhammad Saad
Muhammad Saad

Posted on

THM Crypto Failures — Full Walkthrough

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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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

curl -s -i http://10.113.155.191/ | head -20
Enter fullscreen mode Exit fullscreen mode
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: /
Enter fullscreen mode Exit fullscreen mode

Following the redirect with cookies stored gives the landing page:

curl -s -L -c jar.txt -b jar.txt http://10.113.155.191/
Enter fullscreen mode Exit fullscreen mode
<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-->
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.";
}
Enter fullscreen mode Exit fullscreen mode

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"
'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode
congrats: THM{ok_you_f0und_w3b_fl4g_6cbe2bc}. Now I want the key.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}")
Enter fullscreen mode Exit fullscreen mode

Run it:

python3 extract_key.py
Enter fullscreen mode Exit fullscreen mode
THM{Traditional_Own_Crypto_is_Always_Surprising!_and_this_hopefully_is_not_easy_to_crack_e41d20b5b0989cac65ed4a090cace944bf30e6d3ab88f9d447f52fd2140525b9}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
salt: ej match: True
Enter fullscreen mode Exit fullscreen mode

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").

Top comments (0)