1

I used aes-256-ctr to decrypt some app data, but made the mistake of generating a new IV on each app startup

Is there some type of attack or vulnerability that I can use to recover the IV used on the ciphertext using only that and the key?

Thanks in advance

Edit 12/9

I tried to use the first 16 bytes of a plaintext to get the IV, which worked (I think), however that only managed to decipher to first 16 bytes of the ciphertext.

Here's what I did

function hex2ab(hex: string) {
    return new Uint8Array(
        (hex.match(/[\da-f]{2}/gi) || []).map((v) => parseInt(v, 16))
    );
}

function xor(a: Buffer, b: Buffer) {
    var aBuff = hex2ab(a.toString("hex"));
    var bBuff = hex2ab(b.toString("hex"));
    const length = Math.min(aBuff.length, bBuff.length);
    var cBuff = new Uint8Array(length);
    for (var i = 0; i < length; i++) {
        cBuff[i] = aBuff[i] ^ bBuff[i];
    }
    return Buffer.from(cBuff);
}
const aXORb = xor(Buffer.from(plaintext, "hex"), Buffer.from(cipher, "hex"));

const decipher = crypto.createDecipheriv("aes-256-ecb", secretKey, null);
decipher.setAutoPadding(false);

const iv = Buffer.concat([decipher.update(aXORb), decipher.final()]);

console.log(`Possible IV = ${iv.toString("hex")}`);
  • 1
    S. e.g. [Decrypt AES 128 CTR without IV (Counter)](https://security.stackexchange.com/q/7812) – Topaco Sep 11 '22 at 16:12
  • Do you know (or can guess) what the first 16 bytes of plaintext are? – President James K. Polk Sep 11 '22 at 16:31
  • @PresidentJamesK.Polk I think I can, is there a way to find IV using that? – Ibrahim A. Elaradi Sep 11 '22 at 19:57
  • 3
    The reconstruction of the IV is described in the *Edit 10/2* section of the [accepted answer](https://security.stackexchange.com/a/7814) (the logic is most easily understood if you keep the [CTR flowchart](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Counter_(CTR)) in mind). If you use the *first* 16 bytes (as suggested in President James K. Polk's comment), you get the IV directly (and don't have to count back). – Topaco Sep 11 '22 at 20:33
  • @Topaco I was able to find the first 16 bytes of a plaintext (backups had the same 16 bytes at the start), but I'm having trouble recovering the IV, I may have misunderstood the step where it says "decrypt that keystream block under AES (with the known AES key); the decryption will be the value of the counter for that block", I'm assuming that I need to use the same algorithm but it requires a value for the IV – Ibrahim A. Elaradi Sep 11 '22 at 22:43
  • 1
    @IbrahimA.Elaradi - Please see my answer. – Topaco Sep 12 '22 at 07:54

1 Answers1

3

You just have to xor-ing the first 16 bytes of the plaintext and the first 16 bytes of the ciphertext. The result is the IV encrypted with the AES primitive, which must therefore be decrypted with the AES primitive (which is functionally identical to using the ECB mode with padding disabled). You can see this most easily with the CTR flowchart.

Example:

Key (hex):          01ae3fd52761ebe55ebae2d33ff7e380ef32e264fabc32890079ca8037eed254
IV (hex):           4eb334a2ebcdcbe46399a5e445c61ac0
Plaintext:          The quick brown fox jumps over the lazy dog
Plaintext (hex):    54686520717569636b2062726f776e20666f78206a756d7073206f76657220746865206c617a7920646f67
Ciphertext (hex):   a2b6bd79e3aa8709f081ca15e513d548336a84ca205d8a9ca3955bb00ac8d70b68beabe5658cdab543a5bb

which can be checked with CyberChef here.

Since you tagged your question with CryptoJS, the following solution uses CryptoJS:

var ptFirst16bytes = hex2ab("54686520717569636b2062726f776e20");         // first 16 bytes of plaintext
var ctFirst16bytes = hex2ab("a2b6bd79e3aa8709f081ca15e513d548");         // first 16 bytes of ciphertext
var encIv = xor(ptFirst16bytes, ctFirst16bytes);                         // xor both values, gives the encrypted IV
var encIvWA = CryptoJS.lib.WordArray.create(encIv);                      // convert to WordArray (for processing with CryptoJS)

var keyWA = CryptoJS.enc.Hex.parse("01ae3fd52761ebe55ebae2d33ff7e380ef32e264fabc32890079ca8037eed254");
var ivWA = CryptoJS.AES.decrypt(                                         // decrypt with AES primitive (i.e. ECB, no padding)
    {ciphertext: encIvWA}, 
    keyWA, 
    {mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.NoPadding});
console.log("IV (hex): " + ivWA.toString());

function xor(aBuff, bBuff){
    var cBuff = new Uint8Array(aBuff.length);
    for (var i = 0;  i < aBuff.length; i++){
        cBuff[i] = aBuff[i] ^ bBuff[i];
    }
    return cBuff;
}

function hex2ab(hex){
    return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) {
        return parseInt(h, 16)}));
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>

The result is equal to the IV used in encryption.


Edit:

Regarding your comment that you apply NodeJS' crypto module instead of CryptoJS: In NodeJS, the determination of the IV can be implemented a bit more compactly:

var crypto = require("crypto")

var ptFirst16bytes = Buffer.from("54686520717569636b2062726f776e20", "hex");
var ctFirst16bytes  = Buffer.from("a2b6bd79e3aa8709f081ca15e513d548", "hex");
var encIv = xor(ptFirst16bytes, ctFirst16bytes);

var key = Buffer.from("01ae3fd52761ebe55ebae2d33ff7e380ef32e264fabc32890079ca8037eed254", "hex");
var decipher = crypto.createDecipheriv("aes-256-ecb", key, null);
decipher.setAutoPadding(false);
var iv = Buffer.concat([decipher.update(encIv), decipher.final()]);
console.log("IV (hex): " + iv.toString("hex"));

function xor(a, b) {
    var result = Buffer.alloc(a.length);
    for (var i = 0; i < a.length; i++) {
        result[i] = a[i] ^ b[i];
    }
    return result;
}

which gives the same IV for the same input data as the CryptoJS code: 0x4eb334a2ebcdcbe46399a5e445c61ac0.


The IV determined in this way can then be used to decrypt the entire ciphertext by performing a decryption with CTR:

const crypto = require("crypto")

const ciphertext  = Buffer.from("a2b6bd79e3aa8709f081ca15e513d548336a84ca205d8a9ca3955bb00ac8d70b68beabe5658cdab543a5bb", "hex");
const iv = Buffer.from("4eb334a2ebcdcbe46399a5e445c61ac0", "hex");

const secretKey = Buffer.from("01ae3fd52761ebe55ebae2d33ff7e380ef32e264fabc32890079ca8037eed254", "hex");
const decipher = crypto.createDecipheriv("aes-256-ctr", secretKey, iv);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);

console.log(decrypted.toString('utf8'));

which gives the original plaintext: The quick brown fox jumps over the lazy dog.

If this decryption fails, then it means that the premises were wrong, i.e. when determining the IV, plaintext and ciphertext are not related, and/or the key is wrong, and/or it was not encrypted with CTR, etc. Then, an incorrect IV would be determined and the decryption of the remaining ciphertext would fail.

Topaco
  • 40,594
  • 4
  • 35
  • 62
  • I did tag it with cryptojs but I'm using "node:crypto" - sorry about that. I used ECB without padding, however using this method I've only managed to decrypt the first 16 bytes of the ciphertext (Check the edited section on the question), am I missing something? – Ibrahim A. Elaradi Sep 12 '22 at 10:02
  • 1
    @IbrahimA.Elaradi - Determining the IV can be implemented a bit more efficiently in NodeJS, I ported my CryptoJS solution to a NodeJS solution in the edit section of my answer. However, your implementation also gives the correct result. You can run both implementations online here: https://www.jdoodle.com/iembed/v0/v6S and here: https://www.jdoodle.com/iembed/v0/v6U, both of which return the correct IV. – Topaco Sep 12 '22 at 15:01
  • 1
    @IbrahimA.Elaradi - Once you have determined the IV, the entire ciphertext must be decrypted with CTR using the determined IV. This is demonstrated online here: https://www.jdoodle.com/iembed/v0/v6W. If this decryption fails for you, then it means that the assumptions were wrong, e.g. plaintext and ciphertext are not related when deriving the IV, a wrong key is applied, or something similar. I have added this detail to my answer. – Topaco Sep 12 '22 at 19:22
  • I'm using aes-256-ctr, so maybe I need more than 16 bytes of the plaintext or is that not relevant? – Ibrahim A. Elaradi Sep 12 '22 at 21:10
  • 1
    @IbrahimA.Elaradi - The key size is not relevant, the logic applies to AES-256 as well as AES-128. To demonstrate this, I changed the key size to 32 bytes in the examples of my answer. The NodeJS code to determine the IV can be run online here https://www.jdoodle.com/iembed/v0/v8o, your NodeJS code with my sample data here https://www.jdoodle.com/iembed/v0/v8p and the decryption of the entire ciphertext here https://www.jdoodle.com/iembed/v0/v8q. As you can see it works (as expected) also for AES-256. – Topaco Sep 12 '22 at 22:23
  • Tried multiple plaintexts that cover all the possible values that the first 16 bytes could take in the file, and I'm pretty sure that the algorithm is the same, which means that most probably the key is incorrect, quite unfortunate.. Thanks for your help, I'll try a bit more to crack this down and double check the key, I'll tag this as solved for now since the solution checks out – Ibrahim A. Elaradi Sep 12 '22 at 22:45