ASCIS CTF 2024 - LoveLinhaLot

ASCIS CTF 2024 - LoveLinhaLot Writeup#
- Category: Crypto
- Score: 999/1000
- Solve: 2/63
Source code#
import random
import string
from Crypto.Util.number import isPrime
BLOCK_LEN = 129
CHARSET = string.ascii_uppercase + string.ascii_lowercase + string.digits
users, pwd_hashes = {}, []
allowed_blocks = []
q1 = 57895665874783536962369408363969823887021530656373208299565102620846005563716018275834077962292286213472570266375824572745671541793458387390711613089471407869558363212866932533545785125988453002675479793768261480181947144057144941974626043243654731721303589851520175899531854692118423229594279209070187162279
p1 = 2 * q1 + 1
g1 = 2
assert isPrime(p1)
assert isPrime(q1)
assert pow(g1, q1, p1) == 1
x1 = random.randint(1, 2 ** 256)
y1 = pow(g1, x1, p1)
def block_hash(block, bases, a):
for x, y in zip(bases, block):
a = a * pow(x, y, p1) % p1
return a
def secure_hash(data, token, is_login = False):
assert len(data) + 1 >= BLOCK_LEN, "Invalid Length"
if len(data) % BLOCK_LEN != 0:
data += b'\x80'
data += b'\x00' * (BLOCK_LEN - len(data) % BLOCK_LEN - 1)
blocks = [data[i:i + BLOCK_LEN] for i in range(0, len(data), BLOCK_LEN)]
bases = [pow(g1, x, p1) for x in token] + [g1]
yu_1 = y1
for block in blocks:
if all(x == 0 for x in block[:-1]):
raise ValueError("No cheese this time")
if is_login:
if block not in allowed_blocks:
raise ValueError("Invalid block")
yu_1 = block_hash(block, bases, yu_1)
allowed_blocks.append(block)
return yu_1
def register(username, password):
token = [random.randint(1, q1 - 1) for _ in range(BLOCK_LEN - 1)]
if username in users:
print("Username already exists")
return False
pwd_hash = secure_hash(password, token)
users[username] = token
pwd_hashes.append(pwd_hash)
return True
def login(username, password):
if username not in users:
return False
token = users[username]
try:
password.decode()
except:
return False
pwd_hash = secure_hash(password, token, True)
return pwd_hash in pwd_hashes
def breach(username):
if username not in users:
return None
return users[username]
def menu():
print("1. Register")
print("2. Login")
print("3. Exit")
def main():
admin_username = "admin"
admin_password = ''.join(random.choices(CHARSET, k = BLOCK_LEN - 1)).encode() + b'\x00'
register(admin_username, admin_password)
print(f'User {admin_username} registered successfully')
for _ in range(5):
try:
menu()
choice = int(input("> "))
if choice == 1:
username = input("Enter username: ")
password = bytes.fromhex(input("Enter password: "))
if register(username, password):
print(f'User {username} registered successfully')
elif choice == 2:
username = input("Enter username: ")
password = bytes.fromhex(input("Enter password: "))
if login(username, password):
if username == admin_username:
print("Welcome admin, here is your flag: ")
print(open("flag.txt").read())
exit()
else:
print(f"Welcome user {username}")
else:
print("Invalid credential")
elif choice == 3:
print("Gud bye")
exit(0)
elif choice == 1337:
victim = input("Give me the victim name: ")
victim_token = breach(victim)
print("Shhhhh, don't tell anyone about this")
print(victim_token)
else:
print("Invalid choice")
exit(0)
except ValueError:
print("No No No No")
if __name__ == "__main__":
main()
Challenge Analysis#
Here is a quick summarization of the problem:
- The server implements a simple authentication with 3 main functions:
- Register a user with username $U$ and password $P$, compute and save password hash $H(T, P)$ where $T$ is a unique token for each user and $H$ is a custom function
- Login with username $U$ and password $P$, the server verify by compute password hash $H(T, P)$ again and check if it is stored.
- Retrieve token $T$ of abitrary user.
- You have 5 queries, the goal is to login as
admin
and retrieve the flag
Solution#
Brainstorm#
- Upon the connection, the server generate a private key $x$ in range $[1, q - 1]$ and compute $y = g^x \pmod{p}$
- The $H(T, P)$ can be written as follow: $$\displaylines{ T = (T_0, T_1, …, T_{127}, 1) \\ P = (P_0, P_1, …) \\ H(T, P) = y \prod_{i=0}\prod_{j=0}^{128}g^{T_jP_{ij}} \pmod{p}}$$ where $P_i$ is the number representation of each 129 characters in $P$ and $T_i \in [1, q - 1]$
- In the
login
, you can spot out that return line looks fishy:
return pwd_hash in pwd_hashes
- If I can enforce a hash collision of hash function $H$, then I can grab the flag as follow:
- Create account with credential $U_{user}, P_{user}$, obtain $H_{user} = H(T_{user}, P_{user})$
- Find a password $P’$ such that $H(T_{admin}, P’) = H_{user}$
- Login with username
admin
and password $P’$ to obtain the flag
Hash Collision#
- Denote $S_i$ as sum of characters at position $i$ of all blocks in $P$, i.e. $$S_i = \sum_{j=0}P_{ji}$$$$S = (S_0, S_1, …)$$$$=> H(T, P) = yg^{\sum_{i=0}^{128}{T_iS_i}} = g^{x + \sum_{i=0}^{128}T_iS_i} \pmod{p}$$
- Set $h = x + \sum_{i=0}^{128}{T_iS_i} $
- The target is to find $P’$ whose $S’$ satisfy the equation: $$x + \sum{T_{admin}S’} = h_0 = x + \sum{T_{user}S_{user}} \pmod{q} $$$$ => \sum{T_{admin}S’} = \sum{T_{user}S_{user}} \pmod{q} $$
- I have full control over the $P_{user}$, and $T_{user}$ can be retrieved from the server => Compute the RHS of the equation is trivial
- Since each element in $S’$ is sum of characters, it is supposed to be small, while $T_{admin}$ is very huge (range $[1, q - 1]$). This implies the usage of LLL algorithm to find such $S'$
- Here is the lattice: $$ \begin{align} M = \left( \begin{array}{ccccc} 1 & 0 & \dots & 0 & T_0 \\ 0 & 1 & \dots & 0 & T_1 \\ \vdots & \vdots & \vdots & \vdots & \vdots \\ 0 & 0 & \dots & 1 & T_{127} \\ 0 & 0 & \dots & 0 & q \end{array} \right) \end{align} $$
- The target vector should only contains positive values so that I can craft the payload
- In this challenge, I use Babai CVP algorithm and slowly increase the target vector until I get the desired output. Also, the final value must be smaller than the RHS, so that $S’_{128}$ exists.
Notes#
- For the payload craft, please refer the to the script below, also the
decode
method in Python does not raise an exception when the byte value is in range $[0, 127]$ - The login only allows blocks that are used in register function, so you have to register another account with $P’$ before login as
admin
- This challenge is heavily inspired by Invention in OpenECSC Round 2. The main change here is the use of group $Z_p$ and token generation.
- My script does not guarantee to find such target vector in each connection, so I have to run it 2-3 times to obtain the flag. Also the runtime is pretty long since it includes performing LLL on 128 x 128 lattice.
- The first version of this challenge can be solved trivially by register a password with 129 null bytes. Therefore I delivered a revenge challenge with a check in
secure_hash
function in later phase of the CTF. - After the competition,
@tvdat20004
from teamUIT.Efficiency V
- who blooded the revenge version - told me that the CVP part can be speed up by using ConnorM’s repo.
Solve Script#
import string
from sage.all import *
from pwnlib.tubes.process import process
from pwnlib.tubes.remote import remote
from sage.modules.free_module_integer import IntegerLattice
BLOCK_LEN = 128
q1 = 57895665874783536962369408363969823887021530656373208299565102620846005563716018275834077962292286213472570266375824572745671541793458387390711613089471407869558363212866932533545785125988453002675479793768261480181947144057144941974626043243654731721303589851520175899531854692118423229594279209070187162279
p1 = 2 * q1 + 1
g1 = 2
username = b"Zayn"
passwd = b'NCIm6RJuC5dKHohq2J6vnd8mSsesHzDC77TH5PDoGLcXUEXSJQdPGWSBGa1Y03Vzyz0GNTkm6S8iO3grixHmY07sobuhXFwmuYfFDEjzkxgbs5aajuEe7ijpkHB3JF8T\x00'
M = [[0 for _ in range(BLOCK_LEN + 1)] for __ in range(BLOCK_LEN + 1)]
CONN = process(['python3', 'server.py'])
CONN.sendlineafter(b'>', b'1')
CONN.sendlineafter(b'username:', username)
CONN.sendlineafter(b'password:', passwd.hex().encode())
CONN.sendlineafter(b'>', b'1337')
CONN.sendlineafter(b'name:', username)
CONN.recvline()
user_token = eval(CONN.recvline().decode().strip())
print(user_token[0])
CONN.sendlineafter(b'>', b'1337')
CONN.sendlineafter(b'name:', b"admin")
CONN.recvline()
admin_token = eval(CONN.recvline().decode().strip())
print(admin_token[0])
bases = [pow(g1, x, p1) for x in user_token]
rhs = 0
for x, y in zip(user_token, passwd):
rhs += x * y % q1
for i in range(BLOCK_LEN):
M[i][i] = 1
M[i][-1] = admin_token[i]
M[-1][-1] = -q1
M = Matrix(ZZ, M)
M = IntegerLattice(M, lll_reduce=True).reduced_basis
G = M.gram_schmidt()[0]
# Babai CVP
for bit in range(7, 15):
print(f'{bit = }')
target = vector(ZZ, [2 ** bit for _ in range(BLOCK_LEN)] + [rhs])
diff = target
for i in reversed(range(G.nrows())):
diff -= M[i] * ((diff * G[i]) / (G[i] * G[i])).round()
res = target - diff
msg = [x for x in res[:-1]]
if all(x >= 0 for x in msg) and res[-1] < rhs:
msg.append(rhs - res[-1])
print(msg)
break
if len(msg) != 129:
print("Failed")
exit()
admin_password = b""
while any([x > 0 for x in msg]):
for i in range(len(msg)):
if msg[i] >= 0x7f:
admin_password += b"\x7f"
msg[i] -= 0x7f
elif msg[i] == 0:
admin_password += b"\x00"
elif msg[i] < 0x7f:
admin_password += bytes([msg[i]])
msg[i] = 0
if len(admin_password) % 129 != 0:
admin_password += b"\x00" * (129 - len(admin_password) % 129)
print(len(admin_password))
blocks = [admin_password[i:i + 129] for i in range(0, len(admin_password), 129)]
blocks = b''.join(list(set(blocks)))
CONN.sendlineafter(b'>', b'1')
CONN.sendlineafter(b'username: ', b'Zyan')
CONN.sendlineafter(b'password: ', blocks.hex().encode())
CONN.sendlineafter(b'>', b'2')
CONN.sendlineafter(b'username: ', b'admin')
CONN.sendlineafter(b'password: ', admin_password.hex().encode())
CONN.interactive()
Flag: ASCIS{d1D_U_U53_lll?}
Flag: ASCIS{d1d_1_g3t_My_r3v3ng3???}
Read other posts