AbraCryptabra
15 minutos de lectura
Se nos proporciona el código fuente del servidor en Python:
from Crypto.Util.number import long_to_bytes, GCD
from Crypto.Util.Padding import pad
from Crypto.Cipher import AES
import hashlib
import random
import socketserver
import signal
from secret import FLAG
LOGO = ("""
╭━━━┳╮╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╭╮╱╱╱╭╮
┃╭━╮┃┃╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╭╯╰╮╱╱┃┃
┃┃╱┃┃╰━┳━┳━━┳━━┳━┳╮╱╭┳━┻╮╭╋━━┫╰━┳━┳━━╮
┃╰━╯┃╭╮┃╭┫╭╮┃╭━┫╭┫┃╱┃┃╭╮┃┃┃╭╮┃╭╮┃╭┫╭╮┃
┃╭━╮┃╰╯┃┃┃╭╮┃╰━┫┃┃╰━╯┃╰╯┃╰┫╭╮┃╰╯┃┃┃╭╮┃
╰╯╱╰┻━━┻╯╰╯╰┻━━┻╯╰━╮╭┫╭━┻━┻╯╰┻━━┻╯╰╯╰╯
╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╭━╯┃┃┃
╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╰━━╯╰╯\n""")
class Handler(socketserver.BaseRequestHandler):
def handle(self):
signal.alarm(0)
main(self.request)
class ReusableTCPServer(socketserver.ForkingMixIn, socketserver.TCPServer):
pass
def sendMessage(s, msg):
s.send(msg.encode())
def receiveMessage(s, msg):
sendMessage(s, msg)
return s.recv(4096).decode().strip()
def bytes_to_bits(input):
return ''.join(format(i, '08b') for i in input)
class DisruptionSpell(object):
def __init__(self, n):
self.n = n
def spawnScroll(self):
intList = []
intList.append(random.randint(1, self.n))
sum = intList[0]
for i in range(1, self.n):
intList.append(random.randint(sum + 1, (sum + i) * 2))
sum = sum + intList[i]
x1 = random.randint(sum + 1, sum * 2)
while True:
x2 = random.randint(1, x1)
if GCD(x2, x1) == 1:
break
scrollOfWorthiness = []
for i in range(self.n):
scrollOfWorthiness.append(x2 * intList[i] % x1)
return scrollOfWorthiness
def disrupt(self, scrollOfWorthiness, flag_bits):
disruptedFlag = 0
for i in range(len(flag_bits)):
if int(flag_bits[i]) != 0: disruptedFlag += scrollOfWorthiness[i]
return long_to_bytes(disruptedFlag).hex()
class Wizard:
def __init__(self):
self.magicka = 108314726549199134030277012155370097074
self.armor = 31157724864730593494380966212158801467
self.stamina = 32
self.critChance = random.randint(1337, self.magicka - 1)
self.spell = random.randint(1337, self.magicka - 1)
def attack(self):
self.spell = (self.armor * self.spell + self.critChance) % self.magicka
spellAttack = self.spell >> (self.magicka.bit_length() - self.stamina)
return spellAttack
def dontAcceptDefeat(self, playerHealth, flag):
flag = flag.lstrip('HTB{').rstrip('}')
flag_bits = bytes_to_bits(flag.encode())
distruptionSpell = DisruptionSpell(len(flag_bits))
scrollOfWorthiness = distruptionSpell.spawnScroll()
disruptedFlag = distruptionSpell.disrupt(scrollOfWorthiness, flag_bits)
for _ in range(playerHealth):
self.spell = (self.armor * self.spell +
self.critChance) % self.magicka
finalSpellIngredient = str(self.spell >> (self.magicka.bit_length() -
self.stamina)).encode()
finalSpellIngredient = hashlib.md5(finalSpellIngredient).digest()
finalSpell = AES.new(finalSpellIngredient, AES.MODE_CBC)
disruptedFlag = finalSpell.encrypt(
pad("You're a wizard Harry, ".encode() + disruptedFlag.encode(),
AES.block_size)).hex()
return disruptedFlag, scrollOfWorthiness
def main(s):
encryptionWizard = Wizard()
playerHealth = 100
wizardHealth = 200
try:
sendMessage(s, LOGO)
sendMessage(s, " * The Basilisk is approaching... *\n")
sendMessage(s, " - You think you're a wizard?")
while playerHealth > 0 and wizardHealth > 0:
block = receiveMessage(s, "\n > ")
spellAttack = encryptionWizard.attack()
if int(block) != spellAttack:
playerHealth -= 1
sendMessage(s, " - This is too easy...\n")
sendMessage(s, str(spellAttack))
else:
wizardHealth -= 1
sendMessage(s, " - You won't be so lucky next time!\n")
if playerHealth <= 0:
sendMessage(s, " - You can't even save your self!\n")
s.close()
else:
disruptedFlag, scrollOfWorthiness = encryptionWizard.dontAcceptDefeat(
playerHealth, FLAG)
scrollOfWorthinessSize = len(scrollOfWorthiness)
sendMessage(s, f"\n {str(scrollOfWorthinessSize)}\n")
for i in scrollOfWorthiness:
sendMessage(s, f"{str(i)}\n")
sendMessage(s, f"{disruptedFlag}\n")
s.close()
except:
try:
sendMessage(s, "Unexpected error occured.\n")
s.close()
except:
pass
exit()
if __name__ == '__main__':
socketserver.TCPServer.allow_reuse_address = True
server = ReusableTCPServer(("0.0.0.0", 1337), Handler)
server.serve_forever()
El servidor simula un juego en el que nosotros (como jugadores) debemos adivinar el número que ha generado el mago (wizard). Si nuestra suposición es correcta, el mago pierde un punto de salud; de lo contrario, nosotros perdemos un punto de salud:
$ nc 165.227.237.190 31839
╭━━━┳╮╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╭╮╱╱╱╭╮
┃╭━╮┃┃╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╭╯╰╮╱╱┃┃
┃┃╱┃┃╰━┳━┳━━┳━━┳━┳╮╱╭┳━┻╮╭╋━━┫╰━┳━┳━━╮
┃╰━╯┃╭╮┃╭┫╭╮┃╭━┫╭┫┃╱┃┃╭╮┃┃┃╭╮┃╭╮┃╭┫╭╮┃
┃╭━╮┃╰╯┃┃┃╭╮┃╰━┫┃┃╰━╯┃╰╯┃╰┫╭╮┃╰╯┃┃┃╭╮┃
╰╯╱╰┻━━┻╯╰╯╰┻━━┻╯╰━╮╭┫╭━┻━┻╯╰┻━━┻╯╰╯╰╯
╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╭━╯┃┃┃
╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╱╰━━╯╰╯
* The Basilisk is approaching... *
- You think you're a wizard?
> 1234
- This is too easy...
1053715130
> 1337
- This is too easy...
751145160
>
Análisis del código fuente
El juego se implementa en este bucle while
:
while playerHealth > 0 and wizardHealth > 0:
block = receiveMessage(s, "\n > ")
spellAttack = encryptionWizard.attack()
if int(block) != spellAttack:
playerHealth -= 1
sendMessage(s, " - This is too easy...\n")
sendMessage(s, str(spellAttack))
else:
wizardHealth -= 1
sendMessage(s, " - You won't be so lucky next time!\n")
Y el método encryptionWizard.attack()
es este:
def attack(self):
self.spell = (self.armor * self.spell + self.critChance) % self.magicka
spellAttack = self.spell >> (self.magicka.bit_length() - self.stamina)
return spellAttack
La expresión anterior es un generador lineal congruencial (Linear Congruential Generator, LCG):
$$ s_{i + 1} = a \cdot s_i + c \mod{m} $$
Sin embargo, las salidas $s_i$ se truncan. En la expresión anterior, ya sabemos $a$ (self.armor
) y $m$ (self.magicka
). Estos son los valores:
def __init__(self):
self.magicka = 108314726549199134030277012155370097074
self.armor = 31157724864730593494380966212158801467
self.stamina = 32
self.critChance = random.randint(1337, self.magicka - 1)
self.spell = random.randint(1337, self.magicka - 1)
Entonces, el problema aquí es que no se nos dan las salidas $s_i$, sino los 32 bits más significativos (self.stamina = 32
). Sean $z_i$ los bits más significativos de $s_i$.
Encontré un write-up excelente de DownUnder CTF 2020 que tenía un LCG truncado, pero no es adaptable para este reto. Sin embargo, es un buen recurso para aprender más sobre LCG y el criptoanálisis basado en retículos (lattices).
Hidden Number Problem
Leyendo código de crypto-attacks sobre LCG truncado, encontré una función que intenta recuperar los parámetros del LCG a partir de las salidas. Afortunadamente, ya tenemos $m$ y $a$, por lo que nos gustaría encontrar $c$ y $s_0$ (la semilla inicial).
Es extremadamente difícil encontrar los valores exactos de $c$ y $s_0$, pero hay que tener en cuenta que solo estamos interesados en los bits más significativos de $s_i$ para ganar el juego. Entonces, tal vez haya una manera de encontrar algunos valores que dan los resultados esperados en la mayoría de las rondas.
El código de crypto-attacks utiliza un retículo para resolver el Hidden Number Problem (pero con dos números ocultos). Este problema se enuncia como $x_i = \alpha_i \cdot y - \beta_i \pmod{m}$, donde $y$ es el número oculto. Esta vez, podemos adaptarlo a $x_i = \alpha_{ij} \cdot y_j + \beta_i \pmod{m}$, y nos interesa $y_j$.
Supongamos que tomamos 10 salidas del juego. Entonces, sea $y_j = \begin{pmatrix} s_0 \\ c \end{pmatrix}$. Sea $\alpha_{ij}$:
$$ \alpha_{ij} = \begin{pmatrix} a & 1 \\ a^2 & a + 1 \\ a^3 & a^2 + a + 1 \\ a^4 & a^3 + a^2 + a + 1 \\ a^5 & a^4 + a^3 + a^2 + a + 1 \\ a^6 & a^5 + a^4 + a^3 + a^2 + a + 1 \\ a^7 & a^6 + a^5 + a^4 + a^3 + a^2 + a + 1 \\ a^8 & a^7 + a^6 + a^5 + a^4 + a^3 + a^2 + a + 1 \\ a^9 & a^8 + a^7 + a^6 + a^5 + a^4 + a^3 + a^2 + a + 1 \\ a^{10} & a^9 + a^8 + a^7 + a^6 + a^5 + a^4 + a^3 + a^2 + a + 1 \\ \end{pmatrix} $$
Y definimos $\beta_i$ como:
$$ \beta_i = - \begin{pmatrix} X \, z_1 \\ X \, z_2 \\ X \, z_3 \\ X \, z_4 \\ X \, z_5 \\ X \, z_6 \\ X \, z_7 \\ X \, z_8 \\ X \, z_9 \\ X \, z_{10} \\ \end{pmatrix} $$
Donde $X$ es una cota superior de $x_i$. Por ejemplo, $2^{L - s}$, donde $L - s$ es la cantidad de bits ocultos para nosotros ($L$ es la longitud en bits de $m$ y $s = 32$).
Por tanto,
$$ x_i = \alpha_{ij} \cdot y_j + \beta_i = $$
$$ \begin{pmatrix} a & 1 \\ a^2 & a + 1 \\ a^3 & a^2 + a + 1 \\ a^4 & a^3 + a^2 + a + 1 \\ a^5 & a^4 + a^3 + a^2 + a + 1 \\ a^6 & a^5 + a^4 + a^3 + a^2 + a + 1 \\ a^7 & a^6 + a^5 + a^4 + a^3 + a^2 + a + 1 \\ a^8 & a^7 + a^6 + a^5 + a^4 + a^3 + a^2 + a + 1 \\ a^9 & a^8 + a^7 + a^6 + a^5 + a^4 + a^3 + a^2 + a + 1 \\ a^{10} & a^9 + a^8 + a^7 + a^6 + a^5 + a^4 + a^3 + a^2 + a + 1 \\ \end{pmatrix} \begin{pmatrix} s_0 \\ c \end{pmatrix} - \begin{pmatrix} X \, z_1 \\ X \, z_2 \\ X \, z_3 \\ X \, z_4 \\ X \, z_5 \\ X \, z_6 \\ X \, z_7 \\ X \, z_8 \\ X \, z_9 \\ X \, z_{10} \\ \end{pmatrix} $$
Si operamos las matrices anteriores, tenemos este vector:
$$ \begin{pmatrix} a s_0 + c - X \, z_1 \\ a^2 s_0 + (a + 1) c - X \, z_2 \\ a^3 s_0 + (a^2 + a + 1) c - X \, z_3 \\ a^4 s_0 + (a^3 + a^2 + a + 1) c - X \, z_4 \\ a^5 s_0 + (a^4 + a^3 + a^2 + a + 1) c - X \, z_5 \\ a^6 s_0 + (a^5 + a^4 + a^3 + a^2 + a + 1) c - X \, z_6 \\ a^7 s_0 + (a^6 + a^5 + a^4 + a^3 + a^2 + a + 1) c - X \, z_7 \\ a^8 s_0 + (a^7 + a^6 + a^5 + a^4 + a^3 + a^2 + a + 1) c - X \, z_8 \\ a^9 s_0 + (a^8 + a^7 + a^6 + a^5 + a^4 + a^3 + a^2 + a + 1) c - X \, z_9 \\ a^{10} s_0 + (a^9 + a^8 + a^7 + a^6 + a^5 + a^4 + a^3 + a^2 + a + 1) c - X \, z_{10} \\ \end{pmatrix} $$
Y simplificando un poco más, tenemos lo siguiente:
$$ x_i = \alpha_{ij} \cdot y_j + \beta_i = \begin{pmatrix} a s_0 + c - X \, z_1 \\ a s_1 + c - X \, z_2 \\ a s_2 + c - X \, z_3 \\ a s_3 + c - X \, z_4 \\ a s_4 + c - X \, z_5 \\ a s_5 + c - X \, z_6 \\ a s_6 + c - X \, z_7 \\ a s_7 + c - X \, z_8 \\ a s_8 + c - X \, z_9 \\ a s_9 + c - X \, z_{10} \\ \end{pmatrix} $$
Resumiendo, si podemos resolver el Hidden Number Problem, encontraremos $x_i$ (que son los bits ocultos de $s_i$) e $y_j$ (la semilla inicial $s_0$ y el incremento $c$).
El ataque funciona definiendo un retículo generado por las columnas de
$$ \begin{pmatrix} m & & & & & & & & & & \alpha_{1,1} & \alpha_{1,2} & \beta_1 - \frac{X}{2} \\ & m & & & & & & & & & \alpha_{2,1} & \alpha_{2,2} & \beta_2 - \frac{X}{2} \\ & & m & & & & & & & & \alpha_{3,1} & \alpha_{3,2} & \beta_3 - \frac{X}{2} \\ & & & m & & & & & & & \alpha_{4,1} & \alpha_{4,2} & \beta_4 - \frac{X}{2} \\ & & & & m & & & & & & \alpha_{5,1} & \alpha_{5,2} & \beta_5 - \frac{X}{2} \\ & & & & & m & & & & & \alpha_{6,1} & \alpha_{6,2} & \beta_6 - \frac{X}{2} \\ & & & & & & m & & & & \alpha_{7,1} & \alpha_{7,2} & \beta_7 - \frac{X}{2} \\ & & & & & & & m & & & \alpha_{8,1} & \alpha_{8,2} & \beta_8 - \frac{X}{2} \\ & & & & & & & & m & & \alpha_{9,1} & \alpha_{9,2} & \beta_9 - \frac{X}{2} \\ & & & & & & & & & m & \alpha_{10,1} & \alpha_{10,2} & \beta_{10} - \frac{X}{2} \\ & & & & & & & & & & \frac{X}{m} & & \\ & & & & & & & & & & & \frac{X}{m} & \\ & & & & & & & & & & & & X \\ \end{pmatrix} $$
Nótese que el vector $\left(x_1 - \frac{X}{2}, \dots, x_{10} - \frac{X}{2}, s_0 \frac{X}{m}, c \frac{X}{m}, X\right)$ está dentro del retículo. Dado que puede considerarse un vector corto, podemos usar LLL para reducir la base y ver si está dentro de la nueva base. Luego, tomaremos las posiciones que corresponden a $ Y_1 $ y $ Y_2 $, que son las que son útiles para el reto.
Para más información, puedes leer este artículo y el código de crypto-attacks.
Esta es la implementación de esta parte del reto:
def do_round(io, guess=1337):
io.sendlineafter(b'> ', str(guess).encode())
io.recvline()
if (r := io.recvline().decode()) != '\n':
return int(r)
def hidden_number_problem(a, b, m, X):
n1 = len(a)
n2 = len(a[0])
B = matrix(QQ, n1 + n2 + 1, n1 + n2 + 1)
for i in range(n1):
for j in range(n2):
B[n1 + j, i] = a[i][j]
B[i, i] = m
B[n1 + n2, i] = b[i] - X // 2
for j in range(n2):
B[n1 + j, n1 + j] = X / QQ(m)
B[n1 + n2, n1 + n2] = X
B = B.LLL()
for v in B.rows():
xs = [int(v[i] + X // 2) for i in range(n1)]
ys = [(int(v[n1 + j] * m) // X) % m for j in range(n2)]
if all(y != 0 for y in ys) and v[n1 + n2] == X:
return xs, ys
def crack_tlcg(yj, k, s, m, a):
X = 2 ** (k - s)
alpha = [[a, 1]]
beta = [-X * y for y in yj]
while len(alpha) < len(beta):
alpha.append([alpha[-1][0] * a % m, (alpha[-1][1] * a + 1) % m])
_, (s0, c) = hidden_number_problem(alpha, beta, m, X)
return m, a, c, s0
class LCG:
def __init__(self, m, a, s, c, s0):
self.m = m
self.a = a
self.s = s
self.c = c
self.k = int(m).bit_length()
self.state = s0
def next(self):
self.state = (self.a * self.state + self.c) % self.m
return self.state >> (self.k - self.s)
def main():
io = get_process()
M, a = 108314726549199134030277012155370097074, 31157724864730593494380966212158801467
k = int(M).bit_length()
s = 32
Y = [do_round(io) for _ in range(10)]
_, _, c, s0 = crack_tlcg(Y, k, s, M, a)
lcg = LCG(M, a, s, c, s0)
try:
for y in Y:
assert y == lcg.next()
except AssertionError:
log.warning('LCG failed. Trying again...')
io.close()
main()
log.success('LCG cracked')
player_health, wizard_health = 90, 200
prog = log.progress('Health')
while wizard_health:
prog.status(f'Player: {player_health} | Wizard {wizard_health}')
if do_round(io, lcg.next()) is None:
wizard_health -= 1
else:
player_health -= 1
prog.success(f'Player: {player_health} | Wizard {wizard_health}')
Obsérvese que usamos 10 rondas para obtener las salidas truncadas del LCG y luego nos aseguramos de que funcionan los parámetros del LCG. De lo contrario, comenzamos de nuevo.
Problema del knapsack
Cuando el mago pierde el juego, se niega a perder y cifra la flag usando AES. La clave para este cifrado se toma del LCG, realizando tantas rondas como puntos de salud del jugador:
def dontAcceptDefeat(self, playerHealth, flag):
flag = flag.lstrip('HTB{').rstrip('}')
flag_bits = bytes_to_bits(flag.encode())
distruptionSpell = DisruptionSpell(len(flag_bits))
scrollOfWorthiness = distruptionSpell.spawnScroll()
disruptedFlag = distruptionSpell.disrupt(scrollOfWorthiness, flag_bits)
for _ in range(playerHealth):
self.spell = (self.armor * self.spell +
self.critChance) % self.magicka
finalSpellIngredient = str(self.spell >> (self.magicka.bit_length() -
self.stamina)).encode()
finalSpellIngredient = hashlib.md5(finalSpellIngredient).digest()
finalSpell = AES.new(finalSpellIngredient, AES.MODE_CBC)
disruptedFlag = finalSpell.encrypt(
pad("You're a wizard Harry, ".encode() + disruptedFlag.encode(),
AES.block_size)).hex()
return disruptedFlag, scrollOfWorthiness
Como ya hemos roto el LCG, podemos encontrar esa clave fácilmente:
for _ in range(player_health - 1):
lcg.next()
key = md5(str(lcg.next()).encode()).digest()
cipher = AES.new(key, AES.MODE_CBC)
El mago usa AES para cifrar este mensaje: You're a wizard Harry, <enc_flag>
. El modo de operación es AES CBC, con un IV desconocido. Sin embargo, esto no es un problema, ya que solo el primer bloque no se descifrará correctamente. El resto de los bloques serán correctos:
try:
message = unpad(cipher.decrypt(enc_message), AES.block_size)
except ValueError:
log.warning('Padding error. Trying again...')
main()
log.info(f'Encrypted message: {message}')
enc_flag = int(message.split(b'Harry, ')[1].decode(), 16)
Nos importa el número que aparece en <enc_flag>
, el cual es el resultado de un cifrado de knapsack:
def disrupt(self, scrollOfWorthiness, flag_bits):
disruptedFlag = 0
for i in range(len(flag_bits)):
if int(flag_bits[i]) != 0: disruptedFlag += scrollOfWorthiness[i]
return long_to_bytes(disruptedFlag).hex()
Particularmente, está utilizando un criptosistema knapsack de Merkle-Hellman. Básicamente, toma el texto a cifrar en formato binario, multiplica cada bit por el índice correspondiente de la clave pública y suma todos los resultados intermedios (que son 32 números). Sea $u_n$ una secuencia de $0$ y $1$ tal que $m = \displaystyle\sum_{i = 0}^{31} u_i \cdot 2^i$, donde $m$ es el número que se cifrará. Entonces, el número cifrado es $b$:
$$ b = \sum_{i = 0}^{31} a_i \cdot u_i $$
Donde $a_i$ es la clave pública configurada por el servidor.
Antes de enviar el mensaje cifrado con AES, se nos da la clave pública del knapsack y la longitud de la flag en bits:
disruptedFlag, scrollOfWorthiness = encryptionWizard.dontAcceptDefeat(
playerHealth, FLAG)
scrollOfWorthinessSize = len(scrollOfWorthiness)
sendMessage(s, f"\n {str(scrollOfWorthinessSize)}\n")
for i in scrollOfWorthiness:
sendMessage(s, f"{str(i)}\n")
sendMessage(s, f"{disruptedFlag}\n")
Tomaremos todos los parámetros con este trozo de código usando un pequeño truco:
length = int(io.recvline().decode())
log.info(f'Flag length: {length // 8}')
public_key = []
for i in range(length):
if i % 8:
public_key.append(int(io.recvline().decode()))
else:
io.recvline()
enc_message = bytes.fromhex(io.recvline().decode())
io.close()
Dado que el objeto cifrado es la flag, todos los caracteres son códigos ASCII válidos (menos de 0x7f
). Por lo tanto, los bits en índices múltiplo de 8 serán siempre 0
(por eso omitimos esos índices y no los metemos en la lista de clave pública).
En este punto, resolveremos el problema del knapsack utilizando un ataque basado en retículo y LLL (para obtener más información sobre esta técnica, eche un vistazo a Infinite Knapsack):
def knapsack(a_i: List[int], b: int) -> int:
M = matrix(ZZ, len(a_i) + 1, len(a_i) + 1)
for i, a in enumerate(a_i):
M[i, i] = 1
M[i, -1] = a
M[-1, -1] = -b
B = M.LLL()
for u_i in B.rows():
if all(u in {0, 1} for u in u_i[:-1]) and b == sum(a * u for a, u in zip(a_i, u_i)):
return u_i[:-1]
def bin2dec(b: List[int]) -> int:
return int(''.join(map(str, b)), 2)
Y una vez que obtenemos todos los bits, necesitamos agregar el 0
que nos saltamos antes:
if (r := knapsack(public_key, enc_flag)):
flag = [bin2dec([0, *r[i : i + 7]]) for i in range(0, len(r), 7)]
log.success('HTB{' + ''.join(map(chr, flag)) + '}')
else:
log.warning('Knapsack failed. Trying again...')
main()
Flag
Ahora podemos unir todas las partes y esperar que el ataque sea exitoso:
$ python3 solve.py 139.59.184.45:30535
[+] Opening connection to 139.59.184.45 on port 30535: Done
[+] LCG cracked
[+] Health: Player: 74 | Wizard 0
[*] Flag length: 36
[*] Closed connection to 139.59.184.45 port 30535
[!] Padding error. Trying again...
[+] Opening connection to 139.59.184.45 on port 30535: Done
[+] LCG cracked
[+] Health: Player: 59 | Wizard 0
[*] Flag length: 36
[*] Closed connection to 139.59.184.45 port 30535
[*] Encrypted message: b'\xe9\xcb=\x81+&\x1c\x7f\x17\rS\x84\xd2\xec\x90\xbdHarry, 531241055385a8abb32946e08f3b84a8f891fe86d22d03d540cd0028b6ad738f3b802d05dc5eff2c6ddcb71bfdfa5216af'
[!] Knapsack failed. Trying again...
[+] Opening connection to 139.59.184.45 on port 30535: Done
[+] LCG cracked
[+] Health: Player: 74 | Wizard 0
[*] Flag length: 36
[*] Closed connection to 139.59.184.45 port 30535
[*] Encrypted message: b'\x07\x98}\x9c*\x1e\x99u\x0f\x07\x8ak\xbe\xa8\xc00Harry, 1600d8f64cd991497e4f47d4a4ab48abf25b04c0fa20be5a5da40ad956b4be17d51b023aabbea7d20ea4a12b73ecbc176f91'
[!] Knapsack failed. Trying again...
[+] Opening connection to 139.59.184.45 on port 30535: Done
[!] LCG failed. Trying again...
[*] Closed connection to 139.59.184.45 port 30535
[+] Opening connection to 139.59.184.45 on port 30535: Done
[+] LCG cracked
[+] Health: Player: 72 | Wizard 0
[*] Flag length: 36
[*] Closed connection to 139.59.184.45 port 30535
[*] Encrypted message: b':(B\x98\xd7\xa8|\xa8,\xb8\x14z\xfe\x07q\x9aHarry, 02a4c39eb0e2eea5e01fc259e630e06377275e81801cc21d8b5b3ab1b35e403eceeb0676a7dd5d9c0a4d7c9bff648beccbd4'
[+] HTB{2_14771c3_ch4113n935_837732_7h4n_0n3}
El script completo se puede encontrar aquí: solve.py
.