Play Time - HackOn2025
Reto Cripto basado OTP y Xoshiro256, como generador de números pseudoaleatorios.
Autor del reto: HugoBond
Dificultad: Media
Enunciado
“Linear, linear, linear,linear…”
Archivos
En reto, solo nos dan el siguiente archivo.
chall.py
: Contiene la lógica principal del reto.xorshiro256.py
: Contiene la lógica del PRNG Xoshiro256.output.txt
: Contiene la flag cifrada.
Archivos utilizados aquí.
Analizando el código
Xorshiro256.py
Si comenzamos abriendo el archivo Xorshiro256.py
podemos ver el siguiente código.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import secrets
MASK64 = (1 << 64) - 1
def rotl64(x, n):
return ((x << n) | (x >> (64 - n))) & MASK64
class Xoshiro256estrellaestrella:
def __init__(self, s: list[int]):
if len(s) != 4:
raise ValueError("Invalid state")
self.s = s
@staticmethod
def temper(s1):
return rotl64(s1 * 5 & MASK64, 7) * 9 & MASK64
inv9 = pow(9, -1, 1<<64)
inv5 = pow(5, -1, 1<<64)
@staticmethod
def untemper(s1):
return (rotl64(s1 * Xoshiro256estrellaestrella.inv9 & MASK64, 64 - 7) * Xoshiro256estrellaestrella.inv5 & MASK64)
def step(self):
s0, s1, s2, s3 = self.s
result = s1
t = (s1 << 17) & MASK64
s2 ^= s0
s3 ^= s1
s1 ^= s2
s0 ^= s3
s2 ^= t
s3 = rotl64(s3, 45)
self.s = [s0, s1, s2, s3]
return result
def __call__(self):
return self.temper(self.step())
Este código implementa un generador Xoshiro256 el cual usa un estado de 4 valores de 64 bits, aplica rotaciones y operaciones con XOR para actualizar el estado step()
, usa la función temper()
para mejorar la distribución de bits.
Entrando más en detalle en su funcionalidad podemos observar.
1
MASK64 = (1 << 64) - 1
- Se define una máscara de 64 bits 0xFFFFFFFFFFFFFFFF la cual se utiliza para asegurar que las operaciones se mantengan dentro del rango de 64 bits.
1
2
def rotl64(x, n):
return ((x << n) | (x >> (64 - n))) & MASK64
- Esta función implementa una rotación a la izquierda (ROL) de $x$ en $n$ bits, asegurando que el resultado tenga exactamente 64 bits. Principalmente se utiliza para mezclar bits y mejorar la calidad de la aleatoriedad.
1
2
3
4
5
class Xoshiro256estrellaestrella:
def __init__(self, s: list[int]):
if len(s) != 4:
raise ValueError("Invalid state")
self.s = s
- Se crea una clase
Xorshiro256
la cual inicializa una lista de 4 valores de 64 bits $s0$ $s1$ $s2$ $s3$ (estado interno)
1
2
3
4
@staticmethod
def temper(s1):
return rotl64(s1 * 5 & MASK64, 7) * 9 & MASK64
- Esta función toma el valor $s1$, lo multiplica por 5, rota a la izquierda 7 bits y luego lo multiplica por 9. De esta manera se mejora la distribución de bits del PRNG.
1
2
3
4
5
6
7
inv9 = pow(9, -1, 1<<64)
inv5 = pow(5, -1, 1<<64)
@staticmethod
def untemper(s1):
return (rotl64(s1 * Xoshiro256estrellaestrella.inv9 & MASK64, 64 - 7) * Xoshiro256estrellaestrella.inv5 & MASK64)
- Se calcula los inversos de 9 y 5 en módulo 2^64 para deshacer la trasnsformación de
temper()
. Si capturamos la salida detemper()
, podemos aplicaruntemper()
para recuperar el estado interno $s1$, lo veremos más adelante.
1
2
3
4
5
6
7
8
9
10
11
12
def step(self):
s0, s1, s2, s3 = self.s
result = s1
t = (s1 << 17) & MASK64
s2 ^= s0
s3 ^= s1
s1 ^= s2
s0 ^= s3
s2 ^= t
s3 = rotl64(s3, 45)
self.s = [s0, s1, s2, s3]
return result
- Esta es la función de transición del PRNG la cual actualiza su estado interno.
step()
devuelve $s1$. Además, cada paso mezcla los valores del estado con XOR, desplazamientos y rotaciones.
1
2
3
def __call__(self):
return self.temper(self.step())
- Por último, se genera un número pseudoaleatorio, se llama a
step()
para actualizar el estado y obtener un valor y se aplicatemper()
para mejorar la aleatoriedad antes de devolverlo.
chall.py
Si abrimos el archivo chall.py
podemos ver el siguiente código.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from xorshiro256 import Xoshiro256estrellaestrella
from hashlib import sha512
from secrets import randbits
from Crypto.Util.number import getPrime
FLAG = b"HackOn{??-_-??}"
rng = Xoshiro256estrellaestrella([randbits(64) for _ in range(4)])
def xor(a,b):
return bytes(x ^ y for x,y in zip(a,b))
def otp():
return (rng()<< 128) | (rng()<<64) | rng()
def encrypt(message, key):
return xor(message,key).hex()
def lets_play():
eee = [otp() for _ in range(5)]
hhh = int(sha512(b"What is going on????").hexdigest(),16)
www = int(sha512(b"-_-").hexdigest(),16)
for _ in range(5):
print(www*(hhh*getPrime(400) + eee[_]))
lets_play()
print(f"Of course take the flag: {encrypt(FLAG,otp().to_bytes(24,'big') + otp().to_bytes(24,'big') + otp().to_bytes(24,'big') + otp().to_bytes(24,'big'))}")
1
2
3
4
from xorshiro256 import Xoshiro256estrellaestrella
from hashlib import sha512
from secrets import randbits
from Crypto.Util.number import getPrime
- Se importan las típicas librerias necesarias.
1
rng = Xoshiro256estrellaestrella([randbits(64) for _ in range(4)])
- Se crea el PRNG. Esta línea inicializa el generador de números pseudoaleatorios Xoshiro256 con 4 valores aleatorios de 64 bits cada uno.
1
2
def xor(a,b):
return bytes(x ^ y for x,y in zip(a,b))
- Se define la función XOR que realiza dicha operación entre dos cadenas de bytes.
1
2
3
def otp():
return (rng() << 128) | (rng() << 64) | rng()
- Se genera las claves OTP, la cual llama 3 veces al generador
rng()
y combina los valores para formar un número de 192 bits.
1
2
3
def encrypt(message, key):
return xor(message, key).hex()
- Se define la función
encrypt()
la cual toma un mensaje y una clave, realiza la operación XOR y devuelve el resultado en hexadecimal.
1
2
3
4
5
6
7
def lets_play():
eee = [otp() for _ in range(5)] # Genera 5 números pseudoaleatorios de 192 bits
hhh = int(sha512(b"What is going on????").hexdigest(),16) # Hash SHA-512 de "What is going on????"
www = int(sha512(b"-_-").hexdigest(),16) # Hash SHA-512 de "-_-"
for _ in range(5):
print(www * (hhh * getPrime(400) + eee[_]))
- Se crea la función
lets_play()
la cual genera 5 valores OTP (eee). Se calcula hhh como el hash de SHA-512 de “What is going on????” (convertido a entero en hexadecimal) Se calcula www como el hash de SHA-512 de “-_-“ (también convertido a un entero en hexadecimal. Luego, en un bucle de 5 iteraciones, se imprime el resultado de la expresión: \(www \times (hhh \times P + eee[i])\) Donde $$P$ es un número primo de 400 bits.
1
print(f"Of course take the flag: {encrypt(FLAG,otp().to_bytes(24,'big') + otp().to_bytes(24,'big') + otp().to_bytes(24,'big') + otp().to_bytes(24,'big'))}")
- Finalmente se genera una clave OTP concatenando los 4 valores de
opt()
(cada uno convertido a 24 bytes) y se usa esta clave para cifrar la bandera con XOR.
Solución
Como hemos visto en el código anterior, podemos saber que el generador Xoshiro256 es determinista y si logramos recuperar su estado interno, podemos predecir futuros valores generados para finalmente descifrar la flag.
Además, podemos saber que la función temper()
es invertible, lo que significa que podemos recuperar el estado interno.
El problema que presenta este generador, reside en que revela 5 valores generados por otp()
pero cada otp()
se compone de 3 valores de Xorshiro256
concatenados. Esto nos permite extraer la información del estado interno.
Para comenzar con el procedimiento, tenemos que seguir los siguients pasos.
1. Extraemos las salidas del PRNG
1
print(www*(hhh*getPrime(400) + eee[_]))
Obtenemos los valores, donde sabemos que eee[_]
es un número pseudoaleatorio obtenido de opt()
. Cada `otp() se forma de tres llamadas al PRNG, así que si tenemos 5 valores, podemos extraer 15 valores del PRNG.
Para recuperar eee[_]
, primero sabemos que cada salida tiene la siguiente forma \(\text{output} = www \times (hhh \times \text{primo} + eee[_])\)
Si dividmos por www
, obtenemos \(\frac{\text{output}}{\text{www}} = hhh \times \text{primo} + eee[_]\)
Aplicando módulo hhh
, aislamos eee[_]
: \(\text{eee}[_] = \left(\frac{\text{output}}{\text{www}}\right) \mod hhh\)
Ahora tenemos 5 valores eee[_]
, cada uno compuesto de 3 valores del PRNG.
2. Deshacemos la función temp()
Cada número generado por Xoshiro256
pasa por la función temper()
:
1
2
def temper(s1):
return rotl64(s1 * 5 & MASK64, 7) * 9 & MASK64
Pero podemos revertirlo usando untemper()
.
1
2
3
4
5
6
def untemper(x):
x = BitVecVal(x, 64)
x = (x * inv9) & MASK64 # Deshace la multiplicación por 9
x = RotateRight(x, 7) # Deshace la rotación
x = (x * inv5) & MASK64 # Deshace la multiplicación por 5
return simplify(x).as_long()
Aplicamos untemper()
a cada parte de eee[_]
para obtener los valores originales s1
que el PRNG generó. Como tenemos 5 valores de otp()
, y cada otp()
tiene 3 números pseudoaleatorios, obtenemos 15 valores pseudoaleatorios.
De estos valores podemos saber que 4 son suficientes para reconstruir el estado interno de Xoshiro256
por completo.
3. Resolver el estado interno
Ya tenemos 4 valores s1
generados por el PRNG.
Ahora tenemos que usar Z3 Solver
para reconstruir el estado interno original (s0, s1, s2, s3
). El PRNG usa esta transición de estado.
1
2
3
4
5
6
7
t = (s1 << 17) & MASK64
s2 ^= s0
s3 ^= s1
s1 ^= s2
s0 ^= s3
s2 ^= t
s3 = RotateLeft(s3, 45)
Usamos z3.Solver()
para encontrar los valores s0, s1, s2, s3
que satisfacen estas ecuaciones con los valores s1
observados.
Si el solver encuentra una solución, tenemos el estado interno inicial del PRNG, lo que significa que podemos predecir cualquier número futuro.
4. Generar la clave OTP y descifrar la flag
Sabemos que la flag se cifra con encrypt()
.
1
print(f"Of course take the flag: {encrypt(FLAG, otp().to_bytes(24,'big') + ...)}")
Para finalmente descifrar la flag tenemos que hacer lo siguiente.
Reiniciamos el PRNG con el estado interno encontrado.
Avanzamos el PRNG 15 pasos (porque ya hemos consumido 5
otp()
, cada uno compuesto por 3 valores).Generamos la clave OTP en el mismo formato (24 bytes por cada llamada a
otp()
).Aplicamos XOR con el texto cifrado para recuperar la flag.
Script final
El script final es el siguiente.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
from Crypto.Util.number import long_to_bytes
from hashlib import sha512
from z3 import *
MASK64 = (1 << 64) - 1
# Función de rotación para Z3 (se usa en untemper)
def rotl64(x, n):
return RotateLeft(x, n)
# Función de rotación para enteros (se usa en __call__)
def rotl64_int(x, n):
return ((x << n) | (x >> (64 - n))) & MASK64
inv9 = pow(9, -1, 1 << 64)
inv5 = pow(5, -1, 1 << 64)
def untemper(x):
# Convertir x a un bit-vector de 64 bits
x = BitVecVal(x, 64)
x = (x * inv9) & MASK64
x = RotateRight(x, 7)
x = (x * inv5) & MASK64
return simplify(x).as_long()
outputs = [
13212604756760576839566029879790507340621125351650910037096438542986281767798935731815960919847190335319997626490657290703982780531188982755812359825778991287851722231653240101516281221277771687552595606357705667021283245647184176190780371803847239046057899625306709335445492253829904457728963663330232360074002592294748902165490734661754641346105555480845725245401274622849042633220680814862218507825000273496174518135135018296,
10941254150150025674552873841829377160894872702989189221030375278924020303440766829212501568712317129316625966056943292892254051556591182699583782809766043430039526087782632726822390246237207588223614532129919563546317959123226772171340188871049507419759715801243252593170422974418428462985058137138632654284570886374958625043528751248848999225696572462957490312158113035640608574297096993882606872975815007084967924414642980776,
15101349666572801938485401280432371477809444483981100057606730238263165151424318566626548931488229895122994206046583802385648294413304175367322953662352599315904063583374091625850491510370325443484473334570074021702629429967091991051778066603994035464123640521499352527048160060494173173666036904182158407134434879411353463525907301113557907096744334931379461289162704385044839685089971009445572331890345614370546943037400147983,
13342383541948912904739657745633850307061066808554491420342437647156840907474202734010723903582264217743491384126126906186261528960170260592481378655261612977076194385513721691794702104584131178573340233624384858733837689459678253142113892528886104912973772094271490509833087078998828250566430425723306082267268756937078197726277984632313371500970952851404697896276325125638170652585429636027841332778024087695314635287635675733,
12636229806834250241258615367399065807361433585224380319327082221039501263453353205604056010474585287969305429059979257806627860612598485817264675754852118128230084965081330020085917729023163749382526170463539873149440032710085890969050246185772149152903578407419978590749722355505197313305689658366450442894623670629857776783671952094413125054252154677545972238794873293061464124474598848521323188015310501328407376905710535393
]
hhh = int(sha512(b"What is going on????").hexdigest(), 16)
www = int(sha512(b"-_-").hexdigest(), 16)
eee = []
for output in outputs:
quotient = output // www
eee_i = quotient % hhh
eee.append(eee_i)
s1_values = []
for e in eee:
part1 = (e >> 128) & MASK64
part2 = (e >> 64) & MASK64
part3 = e & MASK64
s1_values.append(untemper(part1))
s1_values.append(untemper(part2))
s1_values.append(untemper(part3))
observed = s1_values[:4]
s = Solver()
s0 = BitVec('s0', 64)
s1 = BitVec('s1', 64)
s2 = BitVec('s2', 64)
s3 = BitVec('s3', 64)
state = (s0, s1, s2, s3)
for i in range(4):
new_s0, new_s1, new_s2, new_s3 = state
t = (new_s1 << 17) & MASK64
s2_prime = new_s2 ^ new_s0
s3_prime = new_s3 ^ new_s1
s1_prime = new_s1 ^ s2_prime
s0_prime = new_s0 ^ s3_prime
s2_double_prime = s2_prime ^ t
s3_rot = RotateLeft(s3_prime, 45)
next_state = (s0_prime, s1_prime, s2_double_prime, s3_rot)
s.add(new_s1 == observed[i])
state = next_state
if s.check() == sat:
m = s.model()
initial_s0 = m.eval(s0).as_long()
initial_s1 = m.eval(s1).as_long()
initial_s2 = m.eval(s2).as_long()
initial_s3 = m.eval(s3).as_long()
initial_state = [initial_s0, initial_s1, initial_s2, initial_s3]
print("Estado inicial encontrado:", initial_state)
else:
print("No se pudo resolver el estado inicial")
exit()
class Xoshiro256:
def __init__(self, s):
self.s = s.copy()
def step(self):
s0, s1, s2, s3 = self.s
result = s1
t = (s1 << 17) & MASK64
s2 ^= s0
s3 ^= s1
s1 ^= s2
s0 ^= s3
s2 ^= t
s3 = ((s3 << 45) | (s3 >> (64 - 45))) & MASK64
self.s = [s0, s1, s2, s3]
return result
def __call__(self):
# Se aplica el tempering usando la versión para enteros
raw = self.step()
return ((rotl64_int(raw * 5 & MASK64, 7) * 9) & MASK64)
# Recuperar el estado inicial y avanzar 15 pasos (5 llamadas a otp(), 3 pasos cada una)
rng = Xoshiro256(initial_state)
for _ in range(15):
rng.step()
# Generación del OTP: se usan los valores temperados llamando a rng() (lo que invoca __call__)
otp_bytes = b''
for _ in range(4):
key_part = 0
for __ in range(3):
key_part = (key_part << 64) | rng()
otp_bytes += key_part.to_bytes(24, 'big')
def xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))
encrypted_flag = bytes.fromhex("b375f90caac87e919e6f761d8e518e124b62a9658674b09a210503d8844083715f005912fa1e1cfed720c20e9d4f55d3a8eb9b80f0e185c96efce878a15aeb49ebf30eb17de3bd356d465c1e")
flag = xor(encrypted_flag, otp_bytes)
print("Flag:", flag)
El output:
1
2
3
4
5
┌──(kesero㉿kali)-[~]
└─$ python solver.py
Estado inicial encontrado: [12884506012955422719, 18404887875378004044, 12245814120916282883, 12686849208438892288]
Flag: b'HackOn{la4ticces_4nd_l1n34r_s1yst3ms_wh4t_a_misterious_w0rld_pd:iwant2sleep}'
Flag
HackOn{la4ticces_4nd_l1n34r_s1yst3ms_wh4t_a_misterious_w0rld_pd:iwant2sleep}