9th February 2024 / Document No. D24.102.33
Prepared By: aris
Challenge Author(s): aris
Difficulty: Insane
Classification: Official
- In this challenge, the task is to find collisions for a custom hash function. The resulting hash consists only of linear operations such as XOR and bitwise rotations. The player has to transform the problem into equations in the field
$GF(2^{128})$ , where XOR is equivalent to addition. Then they can automate the process to figure out$r_2, r_4, y$ and then$r_1, r_3, x$ .
- In the eerie stillness of the Bitting village, a dilapidated laboratory lies forgotten and forsaken, its ancient walls whispering secrets of unspeakable horrors. As you awaken within its confines, a shiver runs down your spine, the air thick with the weight of untold darkness. With no recollection of how you came to be here, you begin to explore the place. The dim glow of flickering lights casts long shadows across the worn floors, revealing rusted equipment and decaying machinery. The air is heavy with the scent of decay and abandonment, a tangible reminder of the atrocities that once transpired within these walls. Soon, you uncover the sinister truth lurking within the laboratory's forgotten depths. This place was a chamber of horrors, a breeding ground for abominable experiments in human cloning. The realization sends chills coursing through your veins, your mind reeling at the thought of the atrocities committed in the name of science. But there is no time to dwell on the horrors of the past, because a sinister countdown echoes through the laboratory, its ominous tones a harbinger of impending doom. Racing against the ticking clock, you discover the source of the impending catastrophe—a chemical reactor primed to unleash devastation upon the village. With the weight of the world upon your shoulders, you realize that you alone possess the knowledge to defuse the deadly device. As a chemist, you understand the delicate balance of chemical reactions, and you know that triggering a specific collision multiple times is the key to averting disaster. With steady hands and a racing heart, you get to work. As the seconds tick away, you feel the weight of the world bearing down upon you, but you refuse to falter.
- Sufficient experience in Python source code analysis.
- Basic research skills.
- Knowledge regarding extended Galois Fields
$GF(2^n)$ .
- Become familiar with translating a problem from code to mathematical relations.
- Learn how to solve equations in
$GF(2^n)$ to find collisions for hash functions based on XOR and ROL operations. - Learn how to convert XOR and other binary operations to polynomial representation.
In this challenge we are provided with a single file:
server.py
This script contains the source code that runs when we connect to the remote instance.
Let us first analyze the main flow of the server script.
ROUNDS = 3
N = 128
print('Can you test my hash function for second preimage resistance? You get to select the state and I get to choose the message ... Good luck!')
hashfunc = HashRoll()
for _ in range(ROUNDS):
print(f'ROUND {_+1}/{ROUNDS}!')
server_msg = os.urandom(32)
hashfunc.reset_state()
server_hash = hashfunc.digest(server_msg)
print(f'You know H({server_msg.hex()}) = {server_hash.hex()}')
signal.signal(signal.SIGALRM, handler)
signal.alarm(2)
user_state = input('Send your hash function state (format: a,b,c,d,e,f) :: ').split(',')
try:
user_state = list(map(int, user_state))
if not validate_state(user_state):
print("The state is not valid! Try again.")
exit()
hashfunc.update_state(user_state)
if hashfunc.digest(server_msg) == server_hash:
print(f'Moving on to the next round!')
USED_STATES.append(sorted(user_state[:4]))
else:
print('Not today.')
exit()
except:
print("The hash function's state must be all integers.")
exit()
finally:
signal.alarm(0)
print(f'Uhm... how did you do that? I thought I had cryptanalyzed it enough ... {FLAG}')
First, an object of the custom hash function HashRoll is initialized that will be used through the rest of the challenge. It looks like that to get the flag, we have to complete a task 3 times in a row. The task is summarized as follows:
- The server generates a random 32-byte message and computes the server hash with the HashRoll hash function. We will dive into it later.
- We are asked to provide our own internal state of the hash function but we have only two seconds to do so.
- Finally, the server message is rehashed but this time with our provided state and if the hash matches the server hash, we get the flag. In other words, the goal, for each round, is the following:
Find a state
However, the server does not accept any input. There are the following checks:
- The rotation offsets should be less than
$N = 128$ . -
$x, y$ should be at least$1$ and at most$2^N - 2$ . - The rotation offsets quadruple cannot be submitted more than once, when sorted in ascending order.
- The sum of the four rotate offsets must be greater or equal to 2. With this check, we make sure that the player does not submit three or more zero rotate offsets as this could potentially result in unintended solutions to the problem.
These checks are performed by the function validate_state
:
def validate_state(state):
if not all(0 < s < 2**N-1 for s in user_state[-2:]) or not all(0 <= s < N for s in user_state[:4]):
print('Please, make sure your input satisfies the upper and lower bounds.')
return False
if sorted(state[:4]) in USED_STATES:
print('You cannot reuse the same state')
return False
if sum(user_state[:4]) < 2:
print('We have to deal with some edge cases...')
return False
return True
Let us first inspect how the custom hash function looks like.
_ROL_ = lambda x, i : ((x << i) | (x >> (N-i))) & (2**N - 1)
class HashRoll:
def __init__(self):
self.reset_state()
def hash_step(self, i):
r1, r2 = self.state[2*i], self.state[2*i+1]
return _ROL_(self.state[-2], r1) ^ _ROL_(self.state[-1], r2)
def update_state(self, state=None):
if not state:
self.state = [0] * 6
self.state[:4] = [random.randint(0, N) for _ in range(4)]
self.state[-2:] = [random.randint(0, 2**N) for _ in range(2)]
else:
self.state = state
def reset_state(self):
self.update_state()
def digest(self, buffer):
buffer = int.from_bytes(buffer, byteorder='big')
m1 = buffer >> N
m2 = buffer & (2**N - 1)
self.h = b''
for i in range(2):
self.h += int.to_bytes(self.hash_step(i) ^ (m1 if not i else m2), length=16, byteorder='big')
return self.h
In summary, the digest
There is also the update_state
function which receives an optional parameter called state
. This function updates the internal state, either with random numbers or with the provided state, if there is any.
At first glance the problem to solve seems straight forward - all we have to do is, given only
To begin with, this challenge was inspired by this post in which the OP basically asks for the same thing. In fact, the core idea of the intended solution is based on the accepted answer. The vulnerability of this hash function is that it is entirely linear. The state is connected to
Since XOR and rotates are not convenient to work with, let us see whether we can transform them to simpler mathematical operations. Before moving on, let us first establish some preliminaries.
Galois Field (GF) is an alternative name for a finite field. In simple words, a finite field is a ring with a prime modulus
We shall denote Galois Fields over some prime modulus
Now take
The elements of
Let us explain how polynomial representation works by setting
Notice that this is identical to representing the numbers in binary format, when there is a
Integer | Binary Representation | Polynomial Representation |
---|---|---|
0 | ||
1 | ||
2 | ||
3 | ||
4 | ||
5 | ||
6 | ||
7 |
In other words, the elements of
As already discussed, the XOR operation can be expressed as addition in
However, this is not entirely the case. Notice in the table above we introduced a variable
Therefore, since the elements of polynomial rings are polynomials, we will represent left rotation by
The challenge task is to provide three distinct states to find collisions for three distinct messages. However the thread solution describes only one so we have to find two more and this is not something trivial to achieve.
Let us restate the problem that has to be solved. The sub-hashes are calculated as follows:
$$
h_1 = R(x, r_1) \oplus R(y, r_2) \oplus m_1\
h_2 = R(x, r_3) \oplus R(y, r_4) \oplus m_2
$$
According to the thread solution, which does not include the extra XOR with
...
SNIPPED
...
def digest(self):
self.h1 = self.hash_step(0)
self.h2 = self.hash_step(1)
return self.h1, self.h2
def hamming_weight(x):
return bin(x).count('1')
hashroll = HashRoll()
for _ in range(10000):
h1, h2 = hashroll.digest()
# the parity of hamming_weight(h1) is the same as the parity of hamming_weight(h2)
assert hamming_weight(h1) % 2 == hamming_weight(h2) % 2
# hamming_weight(h1 XOR h2) is even
assert hamming_weight(h1^^h2) % 2 == 0
This script completes successfully and no exception is raised which is a strong indication that the statement is true. By altering the digest method as follows:
def digest(self, buffer):
buffer = int.from_bytes(buffer, byteorder='big')
m1 = buffer >> N
m2 = buffer & (2**N - 1)
self.h1 = self.hash_step(0) ^^ m1
self.h2 = self.hash_step(1) ^^ m2
return self.h1, self.h2
the assertion check is not passed.
Nevertheless, let us proceed into figuring out a solution for
At this point we can make an assumption;
First of all, let us write a function that connects to the challenge instance and receives the server message and its corresponding digest.
from Crypto.Util.number import isPrime, long_to_bytes as l2b
def get_server_message_and_hash(io):
io.recvuntil(b'H(')
server_msg = int(io.recv(64), 16)
io.recvuntil(b' = ')
server_hash = l2b(int(io.recvline().strip().decode(), 16))
return server_msg, server_hash
Next, we need two Sage functions; one that extracts
from Crypto.Util.number import bytes_to_long as b2l
N = 128
def extract_m1_m2(server_msg):
m1 = server_msg >> N
m2 = server_msg & (2**N - 1)
return m1, m2
def compute_H1_H2(server_msg, server_hash):
m1, m2 = extract_m1_m2(server_msg)
H1 = b2l(server_hash[:16]) ^^ m1
H2 = b2l(server_hash[16:]) ^^ m2
return H1, H2
However, as aforementioned, all operations must be performed in the Polynomial Ring of
N = 128
F.<w> = GF(2^N)
PR.<z> = PolynomialRing(GF(2))
def int2pre(i):
coeffs = list(map(int, bin(i)[2:].zfill(N)))[::-1]
return PR(coeffs)
def pre2int(p):
coeffs = p.coefficients(sparse=False)
return sum(2**i * int(coeffs[i]) for i in range(len(coeffs)))
int2pre
takes the binary representation of the integer, reverses it, and converts it to a polynomial. pre2int
takes the coefficients of the polynomial and converts back to an integer.
Now we need a way to ensure that we get a distinct valid factor of
def get_all_possible_candidates():
powers = '0123456789'
cands = itertools.product(powers, repeat=2)
d = {}
for cand in set(cands):
r2 = int(cand[0])
r4 = int(cand[1])
s = 2**r2+2**r4
d[s] = sorted([r2, r4])
return d
The format of each key-value pair is
{
513: [0, 9],
8: [2, 2],
3: [0, 1],
136: [3, 7],
36: [2, 5],
68: [2, 6],
... SNIPPED ...
66: [1, 6],
544: [5, 9],
128: [6, 6],
1024: [9, 9]
}
Therefore each time, we can iterate through each candidate and check if it is a factor of
Let us write a function that factors
def extract_r2_r4_candidate(B, d, visited):
factors = sorted([F(i^j).to_integer() for i,j in list(B.factor())])
for fact in factors:
if fact in visited:
continue
if fact in d:
r2, r4 = d[fact]
visited.append(fact)
return (r2, r4)
At this point we can solve for
y = B / int2pre(2^r2 + 2^r4)
Then,
def extract_r1_r3_candidates(numer):
numer_factors = sorted([F(i^j).to_integer() for i,j in list(numer.factor())])
cands = []
for factor in numer_factors:
r1 = int(math.log2(factor))
if 2^r1 == factor:
r3 = r1
cands.append((r1, r3))
return cands
Then for each candidate, it is tested whether
Finally, let us write a function that submits the state.
def send_state(io, r1, r2, r3, r4, x, y):
io.sendlineafter(b' :: ', f'{r1},{r2},{r3},{r4},{x},{y}'.encode())
To summarize, the skeleton of the solver is the following and is the part that runs until three collisions are found in the row.
def run_task(io, d, used_states, visited):
server_msg, server_hash = get_server_message_and_hash(io)
if len(server_hash) < 32:
return None
H1, H2 = compute_H1_H2(server_msg, server_hash)
B = int2pre(H1) + int2pre(H2)
if not B:
return None
r2_r4_cand = extract_r2_r4_candidate(B, d, visited)
if not r2_r4_cand:
return None
r2, r4 = r2_r4_cand
assert B.mod(int2pre(2^r2 + 2^r4)) == 0
y = B / int2pre(2^r2 + 2^r4)
numer = int2pre(H1) - y * int2pre(2^r2)
r1_r3_cands = extract_r1_r3_candidates(numer)
if not r1_r3_cands:
return None
for (r1, r3) in r1_r3_cands:
x = numer / int2pre(2^r1)
x = pre2int(PR(x))
y = pre2int(PR(y))
if sorted([r1, r2, r3, r4]) in used_states:
continue
if H1 == R(x, r1) ^^ R(y, r2) and H2 == R(x, r3) ^^ R(y, r4):
state = r1, r2, r3, r4, x, y
return state
A final summary of all that was said above:
- First, we noticed that to get the flag, we have to provide three distinct states to find a collision for three different messages.
- The task is to solve a linear system of equations that consists of XOR and bitwise rotate operations.
- We can represent this system as equations in
$GF(2)$ because in this field, XOR is equivalent to addition. - We factored
$H_1 + H_2$ and found a candidate for$r_2, r_4$ . Then we solved for$y$ . - Having obtained the above, we can apply the process similarly to find
$r_1, r_3$ and$x$ .
This recap can be represented by code with the pwn()
function:
def pwn():
d = get_all_possible_candidates()
while True:
used_states = []
visited = []
done = 0
io = remote('0.0.0.0', 1337)
for _ in range(ROUNDS):
state = run_task(io, used_states, visited)
if state:
r1, r2, r3, r4, x, y = state
send_state(io, r1, r2, r3, r4, x, y)
done += 1
used_states.append(sorted([r1, r2, r3, r4]))
print(f'round {done} done!')
if done == ROUNDS:
io.recvline()
print(io.recvline().decode())
exit()
else:
print('fail!')
io.close()
break
if __name__ == '__main__':
pwn()