Recreating Chris Veness' AES256 CTR decryption with CryptoJS for fun and profit
Sat, Feb 4, 2023 in post javascript encryption AES256 CryptoJS
A quick one tonight. Having just spent enjoyable hacking time to reverse engineer Chris Veness' AES 256 encryption library enough to be able to decrypt some old data I had using CryptoJS, I thought I will share it with the world to enjoy.
Now Chris' library is nice and simple, you can just encrypt stuff with AES 256 counter mode with a single line of code:
> AESEncryptCtr('Well this is fun!', 'password', 256)
'SdzeY4GBgYHDEWay4JdHr/CnwwnAoBfjQA=='
Now AES 256 is a super standard cipher, so it should be pretty easy to decrypt that with another libray, right?
WRONG!
Wrong, standard AES crypto is not always easy to decrypt
Turns out that Chris' library does not use the 'password'
in a way most other libraries use it, but instead chooses to:
- Decode the string into UTF8
- Create a 16 byte array and put the decoded string into beginning of the array
- Initialize AES encryption ("key expansion") using this array
- Encrypt a copy of the array as a single AES block using the key expansion (that the library has internally just been initialized with)
- Expand the AES encrypted 16 bytes by doubling the array into 32 byte one
- Use the 32 byte array as the AES key
Now if you think that any other library would use the same method, you would be dead wrong. Also, most libraries do not expose the same set of functions to replicate this process in any simple manner.
To add insult to injury, JavaScript has so poor support for byte data that it seems each crypto library uses its own internal representation of binary data. For example, CryptoJS likes to make a uint32 array, so [1, 2, 3, 4, 5, 6, 7, 8]
is represented as {words: [0x01020304, 0x05060708], sigBytes: 8}
!
Thankfully, yours truly is a true master and after just 2 hours of trial and error, I managed to produce this golden nugget:
const crypto = require('crypto-js');
function VanessKey(password) {
const iv = { words: [0,0,0,0], sigBytes: 16};
const encrypted = crypto.AES.encrypt(
crypto.enc.Utf8.parse(password.padEnd(16, '\0')),
crypto.enc.Utf8.parse(password.padEnd(32, '\0')), { iv }
).ciphertext;
const ew = encrypted.words.slice(0, 4);
// double the first 4 words of the encrypted
encrypted.words = ew.concat(ew);
return encrypted;
}
const key = VanessKey('password');
console.log(key);
console.log(crypto.enc.Hex.stringify(key));
Saving it as decrypt.cjs
and running node decrypt.cjs
should produce the "Vaness key" for 'password'
:
$ node poista.cjs
{
words: [
233507778, -213013704,
65782802, -856145110,
233507778, -213013704,
65782802, -856145110
],
sigBytes: 32
}
0deb0bc2f34dab3803ebc412ccf8432a0deb0bc2f34dab3803ebc412ccf8432a
Using the key to decrypt the encrypted data
Now we only need the magic incantation to decrypt the Base64 encoded data SdzeY4GBgYHDEWay4JdHr/CnwwnAoBfjQA==
produced in the intro! This is also pretty non-straightforward:
- Parse the encrypted data into CryptoJS binary format with
crypto.enc.Base64.parse()
- Extract 8 first bytes of the binary data and expand (by zero padding) it to 16 byte iv / nonce
- Fix
sigBytes
to reflect the removed 8 bytes - Wrangle the "binary data" back to Base64 encoded form with
crypto.enc.Base64.stringify()
sodecrypt()
will not fail spectacularly with an error crypto.AES.decrypt()
the Base64 encoded data usingcrypto.mode.CTR
, the iv andcrypto.pad.NoPadding
- Oh and supply
crypto.enc.Utf8
totoString()
method to get the decrypted text as an actual UTF8 string!
Once you know these six simple steps, the code basically writes itself! (Author's note: It definitely did not write itself even with Tabnine AI autocompletion, seems like no man has been gone here before...):
const encrypted = 'SdzeY4GBgYHDEWay4JdHr/CnwwnAoBfjQA==';
const content = crypto.enc.Base64.parse(encrypted);
// extract 8 bytes of nonce from content
const iv = {words: content.words.splice(0,2).concat([0,0]), sigBytes: 16};
content.sigBytes -= 8;
const contentB64 = crypto.enc.Base64.stringify(content);
const decrypted = crypto.AES.decrypt(contentB64,
key, { mode: crypto.mode.CTR, iv, padding: crypto.pad.NoPadding})
.toString(crypto.enc.Utf8);
console.log(decrypted);
Appending this code and running it should produce the desired result: Well this is fun!
Well, that was fun! I hope I don't have to repeat that session again...