SecretRezipe
4 minutos de lectura
Este es un reto que diseñé para Hack the Box. Se nos proporciona este sitio web:
Y también tenemos el código fuente del sitio web en Node.js.
Análisis del código fuente
El código fuente es simple (porque no es un reto de web), y solo hay un endpoint relevante (/ingredients
):
const { Router } = require('express')
const child_process = require('child_process')
const fs = require('fs')
const crypto = require('crypto');
const os = require('os');
const { FLAG, PASSWORD } = require('./config/config')
const router = Router()
router.post('/ingredients', (req, res) => {
let data = `Secret: ${FLAG}`
if (req.body.ingredients) {
data += `\n${req.body.ingredients}`
}
const tempPath = os.tmpdir() + '/' + crypto.randomBytes(16).toString('hex')
fs.mkdirSync(tempPath);
fs.writeFileSync(tempPath + '/ingredients.txt', data)
child_process.execSync(`zip -P ${PASSWORD} ${tempPath}/ingredients.zip ${tempPath}/ingredients.txt`)
return res.sendFile(tempPath + '/ingredients.zip')
})
router.get('/*', (_, res) => res.sendFile(__dirname + '/static/index.html'))
module.exports = router
Básicamente, podemos escribir información en un archivo de texto que contiene un secreto (la flag). El formato del archivo de texto aparece en el sitio web:
Por tanto, el archivo de texto se comprimirá en un archivo ZIP, pero está protegido con contraseña. No podemos usar fcrackzip
o john
para descifrar el archivo ZIP porque la contraseña es fuerte:
const crypto = require('crypto')
const fs = require('fs')
try {
var FLAG = fs.readFileSync("/flag.txt")
} catch (e) {
var FLAG = "HTB{fake_flag_for_testing}"
}
module.exports = {
FLAG: FLAG,
PASSWORD: crypto.randomUUID()
}
Solución
La idea aquí es que el archivo se está comprimiendo como un archivo ZIP (algoritmo LZW), por lo que si hay cadenas repetidas en el archivo, el tamaño de archivo comprimido será más corto.
Como prueba de concepto, podemos ver cómo Secret: A
resulta en un archivo de 331 bytes, mientras que Secret: H
da un archivo de 330 bytes. Entonces, podemos ver que Secret: H
tiene una mejor tasa de compresión, porque la cadena Secret: H
aparece repetida en el texto:
Podemos realizar la misma prueba de concepto usando curl
y un poco de shell scripting:
$ curl 165.22.123.39:32053/ingredients -d '{"ingredients":"Secret: HTB{A"}' -isH 'Content-Type: application/json'
HTTP/1.1 200 OK
X-Powered-By: Express
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified:
ETag: W/"14b-180c21eaf4d"
Content-Type: application/zip
Content-Length: 331
Date:
Connection: keep-alive
Keep-Alive: timeout=5
$ curl 165.22.123.39:32053/ingredients -d '{"ingredients":"Secret: HTB{A"}' -isH 'Content-Type: application/json' | head | grep Content-Length
Content-Length: 331
$ curl 165.22.123.39:32053/ingredients -d '{"ingredients":"Secret: HTB{C"}' -isH 'Content-Type: application/json' | head | grep Content-Length
Content-Length: 330
Por lo tanto, podemos recorrer todos los caracteres imprimibles hasta que encontremos el que da como resultado un tamaño de archivo más pequeño, y luego continuar hasta que tengamos el indicador completo. En realidad, esto se basa en ataques existentes (CRIME y BREACH), que intentan romper la seguridad de SSL/TLS cuando la compresión está habilitada en HTTPS.
Implementación
Esta es la parte relevante del script de solución, en Python:
def main():
host = sys.argv[1]
url = f'http://{host}/ingredients'
flag = 'HTB{'
flag_progress = log.progress('Flag')
while '}' not in flag:
flag_progress.status(flag)
results = []
for c in string.printable:
ingredients = 'Secret: ' + flag + c
r = requests.post(url, json={'ingredients': ingredients})
content_length = len(r.content)
results.append(content_length)
min_value = min(results)
index = results.index(min_value)
flag += string.printable[index]
flag_progress.success(flag)
El código es sencillo, itera sobre todos los caracteres imprimibles y guarda el tamaño del archivo en una lista. Luego, se necesita el mínimo de la lista para determinar qué carácter dio el tamaño de ese archivo y lo agrega a la flag.
Flag
Si ejecutamos el script, capturaremos la flag:
$ python3 solve.py 165.22.123.39:32053
[+] Flag: HTB{C0mpr3sSi0n_1s_n0t_3NcryPti0n}
El script completo se puede encontrar aquí: solve.py
.
Vía no intencionada
Hubo una solución no intencionada usando bkcrack
. Esta es una herramienta que rompe la seguridad ZipCrypto si se cumplen algunas condiciones. Por ejemplo, un texto claro parcialmente conocido de al menos 12 bytes puede dar lugar al descifrado de todo el archivo.
En el reto, ya sabemos que el archivo comienza con Secret: HTB{
(exactamente 12 bytes), por lo que bkcrack
es capaz de encontrar claves criptográficas y descifrar el archivo ZIP:
$ bkcrack -L ingredients.zip
bkcrack 1.7.0 - 2024-05-26
Archive: ingredients.zip
Index Encryption Compression CRC32 Uncompressed Packed size Name
----- ---------- ----------- -------- ------------ ------------ ----------------
0 ZipCrypto Store af645c13 45 57 tmp/e06ce4c0968ca708adf6c363aa6ab13d/ingredients.txt
$ bkcrack -C ingredients.zip -c tmp/e06ce4c0968ca708adf6c363aa6ab13d/ingredients.txt -p <(echo -n 'Secret: HTB{')
bkcrack 1.7.0 - 2024-05-26
[14:50:27] Z reduction using 5 bytes of known plaintext
100.0 % (5 / 5)
[14:50:27] Attack on 1201849 Z values at index 6
Keys: 98596b51 2b8c7787 782fd218
64.7 % (777453 / 1201849)
Found a solution. Stopping.
You may resume the attack with the option: --continue-attack 777453
[15:17:29] Keys
98596b51 2b8c7787 782fd218
$ bkcrack -C ingredients.zip -c tmp/e06ce4c0968ca708adf6c363aa6ab13d/ingredients.txt -k 98596b51 2b8c7787 782fd218 -d flag.txt
bkcrack 1.7.0 - 2024-05-26
[15:19:56] Writing deciphered data flag.txt
Wrote deciphered data (not compressed).
$ cat flag.txt
Secret: HTB{C0mpr3sSi0n_1s_n0t_3NcryPti0n}
a