Skip to content

Instantly share code, notes, and snippets.

@hellopir2
Created April 14, 2025 20:58
Show Gist options
  • Select an option

  • Save hellopir2/b59ea2b39a5a540f24bea2d67a9a819a to your computer and use it in GitHub Desktop.

Select an option

Save hellopir2/b59ea2b39a5a540f24bea2d67a9a819a to your computer and use it in GitHub Desktop.

Differences with part 1

Compared to part 1, part 2 allowed the bandits to get in 2x the number of failed guesses (for a total of 10 failed guesses), and we need to succeed in tricking the bandit 100 times in a row. We can check for words that fit a similar strategy to part 1, but it quickly becomes evident that there aren't any candidates. Thus, we concluded that the hash must be weak somehow.

Understanding the hash function

Stealing a python reimpl of the hash function from the pins, we can start to piece together the routine of the hash function. It works roughly as follows:

  1. The bandit's salt is used as the IV, and stored in the output array.
  2. For each block of 16 bytes in the input data (which is padded to fit), the output array is modified with the following function:
def inner(ptr, block_data, bandit_salt):
    """
    Manipulate bytes in a1 based on a2 and a3.

    Args:
        ptr: the
        block_data: A bytearray of 16 bytes
        bandit_salt: A list of 2 integers (QWORDs)

    Returns:
        Nothing (modifies a1 in-place)
    """
    # Convert a1 to a bytearray for byte-level operations
    ptr_modifiable = bytearray(ptr)

    state = bytearray(24)

    for i in range(15):  # 0 to 14
        for j in range(16):  # 0 to 15
            index = j | (16 * i)
            shift = indexshiftbox[index] >> 4
            mask = indexshiftbox[index] & 0xF

            ptr_modifiable[j] ^= a1_xor_box[bandit_salt[shift] ^ block_data[j] ^ bandit_salt[mask]]
            state[j] = state_replace[ptr_modifiable[j]]
            ptr_modifiable[j] = 0

        for k in range(128):  # 0 to 127
            byte_index = k >> 3
            bit_index = k & 7

            shift1 = kshiftbox[k] >> 3
            shift2 = kshiftbox[k] & 7

            ptr_modifiable[byte_index] ^= (((state[shift1] >> shift2) & 1) << bit_index)

    return bytes(ptr_modifiable)

The bottom loop appears to just be shuffling the bits around in the array, and checking kshiftbox shows that they are all unique values, so it should just shuffle the bits around.

The top loop is a little more interesting. There's 2 sbox looking things, a1_xor_box and state_replace. It would be convenient if one of these were linear, and it turns out, state_replace is linear! Now, what exactly does this mean?

Abusing linearity

Examining what happens when we change the input data, we see that since each byte is processed independently, the linearity in the state_replace sbox means that changing the value of a byte provides a fixed linear transformation on the output. This means with 128 bytes of input data, we can forge any hash we want by solving the system of linear equations, since the hash is a 128 bit hash.

But, there seems to be a problem. There's a limit of 16 bytes for the salt!

However, using what we learned in part 1, it turns out we can just shove 112 bytes of the data into the word itself by just sending word + b'\x00' + our_salt[:112] as the word, and our_salt[-16:] as the salt (fixing the -17th index of the salt to be a null byte).

For the hangman part, we can just use 4 letter words and always claim the bandit chose letters that weren't in the words. After they ask for verification, we send the "word" and our 16 byte salt. Doing this 100 times, we retrieve the flag!

from tqdm import tqdm
from pwn import *
import random
import galois
import numpy as np
GF = galois.GF(2)
from Crypto.Util.number import bytes_to_long, long_to_bytes
from Crypto.Util.strxor import strxor
a1_xor_box = bytes.fromhex('98e86cf491a7ee7bf521635c2c2f7cc88a9f40e460bd9bb6d66e03e0a5abbba270c94a9d59f3653e150a0cda06c32b2eaf0f7731d361a41992f0684911e178feaa084b53c5f979fc86552ad8675d5033727ecacd38a12d2539b5cb27d475cc897a3d483220a80ece52b7741fc282103595ec7f41dc51e7993b143a42b0defdeb24c6e24d64058bc1beff019ea028ef5f29566a71887dd9941eea1a76b3305b5aac04b89c8fbffb8066d146dd96850209c7f637dfd034620de50bc0c4e63c26a6b2455707e9d2aea336b917815eb41812fa4f6d47d7d51b908edb13ed3f8443877322a900696f8d1df2b1ba4c8c4e1c546b58f8f7ad23e3bc164497f1939a83cf')
state_replace = bytes.fromhex('c25318895ecf841576e7ac3dea7b30a11382c9588f1e55c4a7367dec3baae170ff6e25b463f2b9284bda9100d7460d9c2ebff465b22368f99a0b40d10697dc4dbc2d66f720b1fa6b0899d24394054edf6dfcb726f1602bbad948039245d49f0e81105bca1d8cc75635a4ef7ea93873e250c18a1bcc5d1687e4753eaf78e9a2330495de4f980942d3b0216afb2cbdf667d5440f9e49d8930261f0bb2afd6c27b639a8e372a5347fee8d1c57c61180cb5ae87932a374e5ae3f5ccd8617c0511a8b7aeba031e6773cadce5f148552c38819ab3a71e037a6ed7c1f8ec554831259c847d69d0cdb4a0190f36229b86ffeb52496074cdd0a9bd04122b3f869be2f64f5')
indexshiftbox = bytes.fromhex('ee389c0c49763c93951927c417a5bcf5cf46fd77fb5129855e662b7bf1396560e7c15fd89ebbf9e92d83a953cd45500423d2374ac06e42bdb1225224d463c9db8a31011a701c05728cd60a3a33efb58de055788e09a02f75b8d988aa148669c5dc4beb0784ac5426ec289056039dd7f0970bb3d38081b0c6594e91be36b9fcb7102e3d87e56d96160002997306da4f324cc33b2a944825622c8b98b2e6e25a71b6ea080da7897af7df1ee3437c3f351dccae1b82e86fd0c8b4923e34edd1670ec73041f4dd5c4d205812f815a161d5f3c2a4794021e46baba3a66af27e477f5b749f7d6818bf8fad13cbbaa86c64a2ca')
kshiftbox = bytes.fromhex('7d32092356362e3d050c7e495c4d474543101e1b5f1622026679135b784c7a57205a0a4f3b333012530860354117243e461f765e7129483a4e6d2c4a7c522d381d0e672f61113427441a6a312a3f28750f774b14636e69253c641821705804506b655d0d74260700620b017b556f397f4051597368192b6c54420306151c7237')
def hash_compute(bandit_salt, input_data):
"""
Compute a hash from the input data.
Args:
bandit_salt: A bytes-like object representing the bandit input
input_data: A bytes-like object representing the input data
Returns:
A list of 2 integers (the computed hash)
"""
# Allocate memory for block_data (16 bytes)
block_data = bytearray(16)
# Copy values from a1 to ptr
ptr = bandit_salt
# Process data in chunks of 16 bytes
i = 0
while i <= len(input_data):
for j in range(16):
if j + i >= len(input_data):
block_data[j] = len(input_data) - i
else:
block_data[j] = input_data[i + j]
# print(block_data)
assert strxor(inner(b'\x00' * 15 + b'\x01', block_data, bandit_salt), inner(b'\x00' * 14 + b'\x01\x00', block_data, bandit_salt)) == strxor(inner(b'\x00' * 16, block_data, bandit_salt), inner(b'\x00' * 14 + b'\x01\x01', block_data, bandit_salt))
old_ptr = ptr
ptr = inner(ptr, block_data, bandit_salt)
# print(strxor(inner_mix(old_ptr), ptr))
i += 16
return ptr
def inner(ptr, block_data, bandit_salt):
"""
Manipulate bytes in a1 based on a2 and a3.
Args:
ptr: the
block_data: A bytearray of 16 bytes
bandit_salt: A list of 2 integers (QWORDs)
Returns:
Nothing (modifies a1 in-place)
"""
# Convert a1 to a bytearray for byte-level operations
ptr2 = bytearray(ptr)
state = bytearray(16)
for i in range(15): # 0 to 14
for j in range(16): # 0 to 15
index = j | (16 * i)
s1 = indexshiftbox[index] >> 4
s2 = indexshiftbox[index] & 0xF
state[j] = state_replace[ptr2[j] ^ a1_xor_box[bandit_salt[s1] ^ bandit_salt[s2] ^ block_data[j]]] # state_replace is linear.
ptr2[j] = 0
# state, ptr2 = ptr2, state
# scrambles bits of ptr
for k in range(128): # 0 to 127
new_byte = k >> 3
new_bit = k & 7
original_byte = kshiftbox[k] >> 3
original_bit = kshiftbox[k] & 7
ptr2[new_byte] ^= (((state[original_byte] >> original_bit) & 1) << new_bit)
return bytes(ptr2)
def forge(prefix, bandit_salt, target=b'\x00' * 16):
assert len(prefix) <= 16
prefix = prefix + b'\x00'
out = prefix + b'\x00' * 129
default = hash_compute(bandit_salt, out)
for c in range(256):
diffs = []
for i in range(128):
out1 = bytearray(out)
out1[i+len(prefix)+i//112] = c
diffs.append(bytes_to_long(strxor(hash_compute(bandit_salt, bytes(out1)), default)))
diffs = GF([[*bin(i)[2:].zfill(128)] for i in diffs]).T
tt = GF([*bin(bytes_to_long(target) ^ bytes_to_long(default))[2:].zfill(128)])
try:
sol = np.linalg.solve(diffs, tt)
except:
continue
out1 = bytearray(out)
for i,j in enumerate(sol):
out1[i+len(prefix)+i//112] = int(j)*c
assert hash_compute(bandit_salt, bytes(out1)) == target, (hash_compute(bandit_salt, bytes(out1)), target, bytes(out1))
return bytes(out1)
def main():
words = open('dictionary.txt').read().splitlines()
words = [*filter(lambda x: len(x) == 4, words)]
io = remote('hangman.chal.pwni.ng', '6002')
used = set()
for i in tqdm(range(100)):
random.shuffle(words)
io.recvuntil(b"Salt: ")
bandit_salt = bytes.fromhex(io.recvline().strip().decode())
io.sendline(b"0" * 32)
io.sendline(b"4")
badlist = []
for j in range(10):
io.recvuntil(b"guesses: ")
badlist.append(io.recvline().strip().decode())
io.sendline()
word = ''
for j in words:
if not any(k in j for k in badlist) and j not in used:
used.add(j)
word = j.encode()
break
io.recvuntil(b"What was your word?! ")
print(word)
salt = forge(word, bandit_salt)
io.sendline(salt[:-17])
io.sendline(salt[-16:].hex().encode())
# io.interactive()
io.interactive()
if __name__ == '__main__':
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment