SecretRezipe
4 minutes to read
This challenge was made by me for Hack The Box. We are given this website:
And we also have the source code of the website in Node.js.
Source code analysis
The source code is simple (because it is not a web challenge), and there’s only one relevant endpoint (/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
Basically, we are able to write information in a text file that contains a secret (the flag). The format of the text file appears on the website:
Then, the text file will be compressed into a ZIP file, but it is password-protected. We cannot use fcrackzip
or john
to crack the ZIP file because the password is strong:
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()
}
Solution
The idea here is that the file is being compressed as a ZIP file (LZW algorithm), so if there are repeated strings in the file, the compressed file size will be shorter.
As a proof of concept, we can see how Secret: A
results in a 331-byte file, whereas Secret: H
gives a 330-byte file. So, we can see that Secret: H
has a better compression rate, because the string Secret: H
appears twice in the file:
We can perform the same proof of concept using curl
and some 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
Therefore, we can loop through all printable characters until we find the one that results in a smaller file size, and then continue until we have the full flag. Actually, this is based on existing attacks (CRIME and BREACH), which attempt to break SSL/TLS security when compression is enabled in HTTPS.
Implementation
This is the relevant part of the solution script, in 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)
The code is straight-forward, it loops over all printable characters and saves the file size in a list. Then, it takes the minimum of the list to determine which character gave that file size and appends it to the flag.
Flag
If we run the script, we will capture the flag:
$ python3 solve.py 165.22.123.39:32053
[+] Flag: HTB{C0mpr3sSi0n_1s_n0t_3NcryPti0n}
The full script can be found in here: solve.py
.
Unintended way
There was an unintended solution using bkcrack
. This is a tool that breaks ZipCrypto security if some conditions are met. For instance, a partially-known plaintext of at least 12 bytes can result in the decryption of the whole file.
In the challenge, we already know that the file starts with Secret: HTB{
(exactly 12 bytes), so bkcrack
is able to find cryptographic keys and decrypt the ZIP file:
$ 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