Hackvent 2024: Day 17

Hackvent 202460

[HV24.17] Santa's Not So Secure Encryption Platform

One day, Santa wanted some platform to encrypt things, so he asked one of his elves. Unfortunately for Santa the elf he asked was a "Bricoleur" or "Bastler".

The closest one can describe the application is probably something along the lines of "Security through obscurity, minus the security".

Start the website and get the flag.
Flag format: HV24{}
sha256sum of handout.tar.gz: f409916d4bb4fd78479b4bcc5559bfe679fca4dac476b00ccc0b5c222772b5c9

This challenge was written by xtea418. HTTP_418_IM_A_TEAPOT.
Hackvent 2024 - Day 17 - Handout

Solution

Initial Analysis

We are provided with several Docker files for a web server. The web server primarily exposes an API with the following routes:

  • users
    • GET /api/users/
    • PATCH /api/users/{user_id}
    • DELETE /api/users/{user_id}
    • GET /api/users/{user_id}
  • auth
    • GET /api/auth/status
    • POST /api/auth/login
    • POST /api/auth/register
    • POST /api/auth/logout
  • crypto
    • POST /api/crypto/encrypt
    • POST /api/crypto/decrypt
    • GET /api/crypto/flag
    • GET /api/crypto/rsa

There are numerous routes to review, so we spend a significant amount of time familiarising ourselves with the code.

De-obfuscating misc.py

We are given an obfuscated misc.py file that appears as follows:

from fastapi import APIRouter

misc_router = APIRouter(include_in_schema=False)

g = getattr
i = __import__
s = lambda m: ''.join(map(chr,m))
def n(m, o): return g(i(s(m)), s(o))
p = n([112, 97, 116, 104, 108, 105, 98], [80, 97, 116, 104])

_666c61675f747874 = g(p(s([115, 97, 110, 116, 97, 115, 95, 101, 110, 99, 114, 121, 112, 116, 105, 111, 110, 47, 115, 116, 97, 116, 105, 99, 47, 102, 117, 110, 110, 121, 45, 117, 115, 101, 114, 46, 106, 112, 103])), s([114, 101, 97, 100, 95, 98, 121, 116, 101, 115]))()

_72756e5f6a7067 =g(p(s([115, 97, 110, 116, 97, 115, 95, 101, 110, 99, 114, 121, 112, 116, 105, 111, 110, 47, 115, 116, 97, 116, 105, 99, 47, 114, 117, 110, 46, 106, 112, 103])), s([114, 101, 97, 100, 95, 98, 121, 116, 101, 115]))()

_686f775f646172655f796f75 = lambda: n([102, 97, 115, 116, 97, 112, 105],[82, 101, 115, 112, 111, 110, 115, 101])(_666c61675f747874)

_72756e = lambda: n([105, 112, 97, 116, 115, 97, 102][::-1],[101, 115, 110, 111, 112, 115, 101, 82][::-1])(_72756e5f6a7067)

_7269636b = lambda: g(n([101, 116, 116, 101, 108, 114, 97, 116, 115][::-1], [115, 101, 115, 110, 111, 112, 115, 101, 114][::-1][::-1][::-1]),s([82, 101, 100, 105, 114, 101, 99, 116, 82, 101, 115, 112, 111, 110, 115, 101]))(url=s([81, 99, 88, 103, 87, 57, 119, 52, 119, 81, 100, 61, 118, 63, 104, 99, 116, 97, 119, 47, 109, 111, 99, 46, 101, 98, 117, 116, 117, 111, 121, 46, 119, 119, 119, 47, 47, 58, 115, 112, 116, 116, 104])[::-1])


g(misc_router,s([103, 101, 116]))(g(bytes,s([120, 101, 104, 109, 111, 114, 102])[::-1])('7478742e67616c662f')[::-1].decode())(_686f775f646172655f796f75);g(misc_router,s([103,101,116]))(s([103, 97, 108, 102, 47])[::-1])(_686f775f646172655f796f75);g(misc_router,s([103,101,116]))(s([47, 100, 111, 99, 115, 47, 99, 114, 121, 112, 116, 111]))(_7269636b);g(misc_router,s([103,101,116]))(s([81, 99, 88, 103, 87, 57, 119, 52, 119, 81, 100, 47])[::-1])(_7269636b);g(misc_router,s([103,101,116]))(s([47, 116, 101, 114, 109, 115, 45, 111, 102, 45, 115, 101, 114, 118, 105, 99, 101]))(_7269636b);g(misc_router,s([103,101,116]))(s([101, 109, 101, 109, 47])[::-1])(_72756e);g(misc_router,s([103,101,116]))(s([47, 104, 101, 108, 112]))(_686f775f646172655f796f75)

After some tedious but enjoyable manual de-obfuscation, we successfully recreate the original misc.py:

from fastapi import APIRouter

misc_router = APIRouter(include_in_schema=False)

_666c61675f747874 = getattr(pathlib.Path("sanitize_function/static/funny-user.jpg"), "read_bytes")()
_72756e5f6a7067 = getattr(pathlib.Path("sanitize_function/static/run.jpg"), "read_bytes")()
_686f775f646172655f796f75 = lambda: fastapi.Response(_666c61675f747874)
_72756e = lambda: getattr(__import__ "fastapi", "Response")(_72756e5f6a7067)
_7269636b = lambda: getattr(__import__ "starlette", "responses", "RedirectResponse")(url="https://www.youtube.com/watch?v=dQw4w9WgXcQ")

getattr(misc_router,"get")("/flag.txt")(_686f775f646172655f796f75);

getattr(misc_router,"get")("/flag")(_686f775f646172655f796f75);

getattr(misc_router,"get")("/docs/crypto")(_7269636b);

getattr(misc_router,"get")("/dQw4w9WgXcQ")(_7269636b);

getattr(misc_router,"get")("/terms-of-service")(_7269636b);

getattr(misc_router,"get")("/meme")(_72756e);

getattr(misc_router,"get")("/help")(_686f775f646172655f796f75)

This file reveals several root-level endpoints. While they are amusing, they turn out to be false leads. Most of them either redirect to the Rick Roll video or display a meme from the /static folder. However, we do discover a useful Swagger endpoint at /docs.

Obtaining admin access

After inspecting the user APIs, we register a new user and discover, by calling GET /api/users/, that we have been assigned the user ID of 2. In the User class within users.py, we observe that the admin account is simply the user with the ID of 1:

class User(BaseModel):
    username: str
    email: EmailStr
    password: SecretStr

    @property
    def is_admin(self):
        return self.id == 1

There does not appear to be an easy way to modify our current user ID to become 1. The PATCH /api/users/{user_id} and DELETE /api/users/{user_id} endpoints seem secure and do not allow us to exploit them.

We notice that SJWT (Santa's JSON Web Token) tokens are being used to authenticate users via a seauthtoken cookie. The relevant encoding code from jwt.py is as follows:

@dataclass
class SJWT:
    rsa: RSA

    def encode(self, payload: dict[str], requires_expiry=True) -> str:
        if "exp" not in payload and requires_expiry:
            raise SJWTEncodingError("Sir we need an expiry on the token!")
        as_string = dumps(payload)
        _body = as_string.encode()
        sig = b64encode(self.rsa.sign(_body))
        body = b64encode(_body)

        return body + "." + sig

The JWT token is generated by encoding a body and appending a signature, which is signed using a custom RSA implementation. Below is an example of a JWT token body after being b64decode'd:

{"sub": 2, "exp": "2024-12-26T06:58:32.736495+00:00"}

The sub claim identifies the user ID, which in our case is 2. The exp claim specifies the token's expiry time, which is 15 minutes after it is generated (configured by SJWT_TTL in auth.py). Our objective is now to create our own JWT token and forge a valid RSA signature for it. To achieve this, we need to identify a vulnerability in the custom RSA implementation.

In rsa.py, we observe the following:

@classmethod
def new(cls, n=25):
    return cls([getPrime(42) for _ in range(n)])

This indicates that every new RSA object creates an N value based on 25 primes, each with a bit length of 42 or less. The low bit length of these primes makes the factorisation of N feasible. This enables us to retrieve the list of primes, ps, which in turn allows us to calculate ϕ (phi) and d (the RSA private key).

The values for N and e are exposed via the /api/crypto/rsa endpoint:

// 20241227000435
// /api/crypto/rsa

{
  "N": 5172270897474462958584351655422600735806482920826533689996552753759611676229179239701577358890638301342967890465406510926048762134413206046848516864455503887645764588877208314697059900535281724437737674899489069935542387061363305881592938248875005347778816291974899142710655089871672444748815453522351777981755053,
  "e": 65537
}

To find the prime factors, we decide to use online factorisation tools. Several options are available, including:

We simply input our N value and retrieve the list of prime factors. If any prime factor has a bit length greater than 42 bits, we must re-factorise it. The final result should be a list of 25 primes, all with a bit length of 42 or less.

Using our list of primes, we then apply the following script to calculate phi and d:

# Hackvent 2024 - Day 17
# Mo Beigi
#
# Find phi & d using 25 prime factors of n

from functools import reduce
from operator import mul

# From /api/crypto/rsa endpoint
e = 65537
n = 5172270897474462958584351655422600735806482920826533689996552753759611676229179239701577358890638301342967890465406510926048762134413206046848516864455503887645764588877208314697059900535281724437737674899489069935542387061363305881592938248875005347778816291974899142710655089871672444748815453522351777981755053

# From factorising N using dcode.fr/prime-factors-decomposition, factordb.com or wolframalpha.com
ps = [2305922284529, 2379854511653, 2391940610687, 2606924615563, 2685270721649, 2709272300249, 2774884274683, 2797523046203, 2813872894003, 2904697831753, 3014253683983, 3097445997523, 3161274749287, 3207953334851, 3343049236489, 3539105483717, 3841850942383, 3876444479993, 3892655731127, 4002752684069, 4026709945091, 4084639058639, 4183775364479, 4193057449837, 4365665526889]

assert(len(ps) == 25)

computed_n = reduce(mul, ps, 1)

assert(computed_n == n)

phi = reduce(mul, map(lambda p: p - 1, ps), 1)

d = pow(e, -1, phi)

print(f"phi: {phi}")
print(f"d: {d}")

This spits out:

$ py -3 find-d.py

phi: 5172270897433583559501306692884582065614233994061282984681836385866275179778381818445816770184385606368503530875670568852678478239414158035611471209613642323463768433295690362751156554078922379550162561964088358842727447430457273602206260639784681982391811883990610625175783033496915701863398604784924262137856000
d: 817941248165183942058250187909971596625202879510065106020149721578926041216765325943702717643330827233519547644772415209563448868170473684805183142597857531476547538683133724759341845468574264028836913380164056197964924625314847851034769447346208158223732217917797404814407363156110812733452296259989853865705473

With the RSA private key d in hand, we can now forge any JWT token we desire. We write a script to generate a forged JWT token with a sub value of 1 (making us the admin user) and a long exp value to extend the token's validity:

# Hackvent 2024 - Day 17
# Mo Beigi
#
# Forge JWT tokens using known RSA parameters

import hashlib
from Crypto.Util.number import bytes_to_long, long_to_bytes

from utils import encode as b64encode

# Known RSA parameters
e = 65537
n = 5172270897474462958584351655422600735806482920826533689996552753759611676229179239701577358890638301342967890465406510926048762134413206046848516864455503887645764588877208314697059900535281724437737674899489069935542387061363305881592938248875005347778816291974899142710655089871672444748815453522351777981755053
d = 817941248165183942058250187909971596625202879510065106020149721578926041216765325943702717643330827233519547644772415209563448868170473684805183142597857531476547538683133724759341845468574264028836913380164056197964924625314847851034769447346208158223732217917797404814407363156110812733452296259989853865705473

# From: rsa.py
def _decrypt(ciphertext: bytes) -> bytes:
    return long_to_bytes(pow(bytes_to_long(ciphertext), d, n))

def sign(msg: bytes) -> bytes:
    hashed_msg = hashlib.sha256(msg).digest()
    return _decrypt(hashed_msg)

# Sign crafted body
_body = '{"sub": 1, "exp": "2038-01-19T00:00:00.000000+00:00"}'
body = b64encode(_body)
sig = b64encode(sign(_body.encode()))

jwt = body + "." + sig

print(f"jwt: {jwt}")

This outputs:

$ py -3 sign.py

jwt: eyJzdWIiOiAxLCAiZXhwIjogIjIwMzgtMDEtMTlUMDA6MDA6MDAuMDAwMDAwKzAwOjAwIn0.PsKtMw_j6yHgRiDG08UoneZwZLkYe-6gkpc1jfNA4Z-ggt0HIvo75-JPhS6AoKA8PAkTmns1eR2g8rrSBJoggM8liHRFSKsUr5JdntrlzWwGBuc-oJTYyKlx2B34OTulBvH2ROstiDS_Qe-Cwn0AtbiU5tIRG9ou6RnY71N2tXN_Pw

By using this forged JWT value as our seauthtoken, we successfully gain admin privileges! We verify this by accessing the /api/users endpoint, which now displays all users—an admin-only feature.

Brute-forcing the flag

Calling the /api/crypto/flag endpoint gives us:

// 20241227001807
// /api/crypto/flag

{
  "ciphertext": "gtU00WKGTFITzFjYE87r-EWUlnJBtF0sH8smCYZH70Nnn2VL8Cx08KLyMVJstryGu6DFJIlfWcXRR_pbP0GzBAwaIz0uv8wghWJ52FzXrFI",
  "signature": "RqpQusyB5Wwgm8Eng-9UReiq0_Cj2f3UOZGzbzn-lsFJW1ngGXFoWONVDuvvF9LXWfmYk8cQXqQncQKxJ45MarUPdY2bss34BAO1iMkrm8U704ZwFXG1FlZTEXPYBH4MV45Jm8YqpFvpXIU1g_Eb2UrzotsJ_NPu-TFUYHeBTyGIeQ",
  "iv": "FBr0qMTH31a-2V_naQIfKw"
}

This endpoint is powered by the following function in crypto.py:

@crypto_router.get("/flag")
def flag(
    user: AuthorizedUser,
    key: GlobalSEAESEKey,
    rsa: GlobalRSA,
):
    """Encrypted Flag."""
    iv = long_to_bytes(randbits(16 * 8))
    # TODO: handle this better
    while len(iv) != 16:
        iv = long_to_bytes(randbits(16 * 8))
    cipher = SEAESECipher(key=key, iv=iv, rsa=rsa)

    ciphertext, signature = cipher.encrypt(FLAG)

    return {
        "ciphertext": encode(ciphertext),
        "signature": encode(signature),
        "iv": encode(iv),
    }

Each request produces new results because the 16-byte IV is randomly generated. Fortunately, we also have access to the /api/crypto/decrypt endpoint, which is an admin-only endpoint:

@crypto_router.post("/decrypt")
def decrypt(
    user: AdminUser,
    key: GlobalSEAESEKey,
    rsa: GlobalRSA,
    body: DecryptionBody,
):
    """Decrypt things."""
    iv = decode(body.iv)
    ciphertext = decode(body.ciphertext)
    signature = decode(body.signature)

    cipher = SEAESECipher(key=key, iv=iv, rsa=rsa)

    try:
        decrypted = cipher.decrypt(ciphertext, signature)
    except ValueError as e:
        return {"error": e.args[0]}

    if b"HV" in decrypted:
        # funny user detected
        return {"error": "JUST EXACTLY WHAT DO YOU THINK YOU ARE DOING LOL!!!!!"}

    try:
        return {"plaintext": decrypted.decode("utf-8")}
    except Exception:
        return {
            "error": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
        }

Unfortunately, we cannot directly copy and paste our response from the flag endpoint into the decrypt endpoint. This is due to the if b"HV" in decrypted check, which prevents the response from being returned to us. We know that our flag contains "HV" as a prefix, and it may also include other occurrences of "HV" elsewhere in the flag.

Delving deeper into the SEAESECipher class, we observe the following:

class SEAESECipher(BaseModel):
    """Santas/~~Secure~~ enhanced AES encryption Cipher."""

    key: conbytes(min_length=16, max_length=16)
    iv: conbytes(min_length=16, max_length=16)
    rsa: RSA

    @property
    def _ecb(self):
        """Underlying inferior AES Mode."""
        return AES.new(self.key, AES.MODE_ECB)

    def encrypt(self, plaintext: bytes) -> bytes:
        """Encrypt plaintext, returns ciphertext and signature!"""
        assert len(plaintext) % 16 == 0

        enc = []
        blocks = _chunk(plaintext)
        enc.append(self._ecb.encrypt(xor(blocks[0], self.iv)))

        for i, block in enumerate(blocks[1:]):
            enc.append(self._ecb.encrypt(xor(block, enc[i])))

        ciphertext = b"".join(enc)
        signature = self.rsa.sign(self.iv + ciphertext)

        return ciphertext, signature

    def decrypt(self, ciphertext: bytes, signature: bytes) -> bytes:
        """Decrypt ciphertext, signature required!"""
        assert len(ciphertext) % 16 == 0

        ndec = _chunk(ciphertext)
        dec = []

        if not self.rsa.verify(self.iv + ciphertext, signature):
            raise ValueError("Invalid signature!")

        dec.append(xor(self._ecb.decrypt(ndec[0]), self.iv))

        for i, block in enumerate(ndec[1:]):
            dec.append(xor(self._ecb.decrypt(block), ndec[i]))

        return b"".join(dec)

This class provides a custom AES implementation. While we cannot obtain the secret key, the RSA class used is the same one we previously cracked. This allows us to easily forge signatures and replicate the line signature = self.rsa.sign(self.iv + ciphertext). The encryption operates on 16-byte blocks. For the first block, the plaintext is XORed with the IV and then encrypted using ECB mode. Subsequent blocks are XORed with the ciphertext of the previous block before encryption.

Given a ciphertext and IV, we can reverse the encryption by:

  1. Processing one 16-byte block at a time.
  2. Using the original IV for the first block and the previous 16 bytes of ciphertext for subsequent blocks.

However, this still does not bypass the "HV" occurrence check. Furthermore, when we refer to the placeholder flag HV24{f4ke_fl4g_g0_brrrrrrrrrrrrrrrrrrrrrrrrrr_HV} in compose.yaml, we notice that it contains two occurrences of "HV" in different blocks.

To bypass the "HV" occurrence check, we must mutate a random byte in the IV so that, when XORed with the ECB-decrypted data of the block, it no longer contains the "HV" sequence. This produces a decrypted text that is almost correct, except for one altered character. The real character can be identified by attempting multiple mutations and observing the results, as only one character will differ across outputs.

Running our initial script, we quickly realise that some blocks, specifically block 4, have multiple occurrences of HV. To address this, we must mutate at least two random bytes, ensuring that one character in each HV occurrence is altered.

Finally, we prepare our script, incorporating the known RSA parameters and our forged JWT cookie:

# Hackvent 2024 - Day 17
# Mo Beigi
#
# Bruteforce flag using /api/crypto/decrypt endpoint
# by varying some bytes in the IV.

import hashlib
from Crypto.Util.number import bytes_to_long, long_to_bytes
from itertools import combinations
import random
import requests

from utils import decode as b64decode
from utils import encode as b64encode

# Known RSA parameters
e = 65537
n = 5172270897474462958584351655422600735806482920826533689996552753759611676229179239701577358890638301342967890465406510926048762134413206046848516864455503887645764588877208314697059900535281724437737674899489069935542387061363305881592938248875005347778816291974899142710655089871672444748815453522351777981755053
d = 817941248165183942058250187909971596625202879510065106020149721578926041216765325943702717643330827233519547644772415209563448868170473684805183142597857531476547538683133724759341845468574264028836913380164056197964924625314847851034769447346208158223732217917797404814407363156110812733452296259989853865705473

# From: rsa.py
def _decrypt(ciphertext: bytes) -> bytes:
    return long_to_bytes(pow(bytes_to_long(ciphertext), d, n))

def sign(msg: bytes) -> bytes:
    hashed_msg = hashlib.sha256(msg).digest()
    return _decrypt(hashed_msg)

# From: /api/crypto/flag
ciphertext = b64decode("gtU00WKGTFITzFjYE87r-EWUlnJBtF0sH8smCYZH70Nnn2VL8Cx08KLyMVJstryGu6DFJIlfWcXRR_pbP0GzBAwaIz0uv8wghWJ52FzXrFI")
iv = b64decode("FBr0qMTH31a-2V_naQIfKw")

# API target
host = "http://152.96.15.5:8000"
url = f"{host}/api/crypto/decrypt"
headers = {"Content-Type": "application/json"}

# Using our forged JWT 
cookies = { "seauthtoken": "eyJzdWIiOiAxLCAiZXhwIjogIjIwMzgtMDEtMTlUMDA6MDA6MDAuMDAwMDAwKzAwOjAwIn0.PsKtMw_j6yHgRiDG08UoneZwZLkYe-6gkpc1jfNA4Z-ggt0HIvo75-JPhS6AoKA8PAkTmns1eR2g8rrSBJoggM8liHRFSKsUr5JdntrlzWwGBuc-oJTYyKlx2B34OTulBvH2ROstiDS_Qe-Cwn0AtbiU5tIRG9ou6RnY71N2tXN_Pw" }

NUM_OF_BLOCKS = 4
RESULTS_PER_BLOCK = 4

for block in range(NUM_OF_BLOCKS):
    print(f"Block {block+1}...")
    found_count = 0
    
    block_ciphertext = bytearray(ciphertext)
    block_ciphertext = block_ciphertext[block*16:(block+1)*16]
    
    if block == 0:
        # Use original IV for first block
        block_iv = bytearray(iv)
    else:
        # Use previous 16 bytes of ciphertext for subsequent blocks
        block_iv = bytearray(ciphertext[(block-1)*16:((block-1)+1)*16])
    
    # Break presence of 'HV' character sequence in decrypted text
    combinations_list = list(combinations(range(len(block_iv)), 2))
    random.shuffle(combinations_list)

    for j, k in combinations_list:
        if found_count <= RESULTS_PER_BLOCK:
            mutated_iv = bytearray(block_iv)
            
            # Mutate 2 random bytes in the IV
            mutated_iv[j] = mutated_iv[j] + 1
            mutated_iv[k] = mutated_iv[k] + 1
        
            # Create signature
            _sig = bytes(mutated_iv) + bytes(block_ciphertext)
            sig = sign(_sig)

            data = {
                "ciphertext": b64encode(block_ciphertext),
                "signature": b64encode(sig),
                "iv": b64encode(mutated_iv),
            }
            response = requests.post(url, json=data, headers=headers, cookies=cookies)
            
            if b"plaintext" in response.content:
                found_count += 1
                print("Response:", response.json())
        
    print("\n")

Our script processes each 16-byte block in the ciphertext and selects the appropriate IV for each block. It generates a list of all possible combinations of mutation pair indices for the mutated_iv, shuffling them to ensure that mutations are exhaustive but non-sequential. The script then mutates the selected indices in the mutated_iv by incrementing the byte values by 1.

A valid signature is generated for the mutated_iv and block_ciphertext, which is then sent to the /api/crypto/decrypt endpoint. If the server responds with a valid decryption, the result is printed. The script collects four valid results for each block, as this should suffice to identify the mutated characters across attempts.

Running this script outputs:

$ py -3 brute-decrypt.py
Block 1...
Response: {'plaintext': 'HW24{w3_r0ll\\0ur'}
Response: {'plaintext': 'IV24{w3_s0ll_0ur'}
Response: {'plaintext': 'IV25{w3_r0ll_0ur'}
Response: {'plaintext': 'HW24{w3_r0lc_0ur'}


Block 2...
Response: {'plaintext': '_3vn_crypt0_h3re'}
Response: {'plaintext': '_0wn_brypt1_h3re'}
Response: {'plaintext': '_3wm_crypt0_h3re'}
Response: {'plaintext': '_0wn_crypu0_h3ue'}


Block 3...
Response: {'plaintext': '_5ls0_wh4s_4re_s'}
Response: {'plaintext': '_4ls0_wh\x0bt_7re_s'}
Response: {'plaintext': '_4ls0_wh\x0bt_4re_t'}
Response: {'plaintext': '_4ls0_wi4t_4se_s'}


Block 4...
Response: {'plaintext': 'tandards?_KV_IV}'}
Response: {'plaintext': 'tandards?_HW_IV}'}
Response: {'plaintext': 'tandards?_HW_HW}'}
Response: {'plaintext': 'tandards?_KV_HW}'}

For each result in each block, it is clear which two characters have been mutated. The final block was particularly tricky, as it contained two "HV" sequences that had to be disrupted.

We assemble the plaintext from each block, manually correcting the mutated characters, to construct our daily flag!

Flag:

HV24{w3_r0ll_0ur_0wn_crypt0_h3re_4ls0_wh4t_4re_standards?_HV_HV}

Leave a comment

(required)(will not be published)(required)

Comments

There are no comments yet. Be the first to add one!