Decrypting IndexedDB with Javascript

IndexedDB is a NoSQL JSON based key store in the browser that allows for more capacity than basic Web Storage.  While testing a web application built with Salesforce, I came across an instance of an encrypted IndexedDB and naturally wanted to know what was being stored in the browser, and if it would be of any use to my engagement.

While browsing the database using Chrome Developer tools, I noticed meta-data in the database which informed me that the Cipher was AES-CBC, and an Initialization Vector (IV) was conveniently provided with each entry. So where was the key? 

With a bit of further inspection I found the key inside the landing page’s source code. It was appropriately named key and was a javascript array of signed integers. Here is an example (generated randomly for this post):

key = [ 11,  -100,  -67,  53,  238,  63,  194,  -102,  51,  95,  142,  -21,  180,  -227,  124,  -86 ];

Unfortunately this is an array of signed integers, a format which is not suited to use as an AES key to decrypt our IndexedDB.

For those interested in following along, load this page in your browser. I used a node.js application serve the HTML, however this is not necessary.

<html>
<body>
<script>
key = [ 11,  -100,  -67,  53,  238,  63,  194,  -102,  51,  95,  142,  -21,  180,  -227,  124,  -86 ];
</script>
<script>

const dbName = "secrets";
var request = indexedDB.open(dbName, 2);
const customerData = [{'ciphertext': [128, 154, 180, 134, 68, 203, 39, 166, 204, 92, 101, 1, 105, 130, 152, 197, 107, 39, 213, 163, 35, 181, 52, 212, 161, 177, 214, 185, 142, 107, 127, 188, 154, 51, 36, 38, 200, 5, 64, 132, 249, 154, 90, 167, 21, 251, 158, 89, 194, 61, 209, 76, 180, 147, 253, 14, 238, 195, 180, 136, 104, 77, 19, 6, 19, 104, 210, 92, 60, 127, 4, 21, 39, 86, 37, 69, 36, 222, 182, 157, 175, 193, 194, 129, 9, 233, 131, 159, 141, 39, 151, 82, 81, 47, 41, 180, 176, 203, 41, 175, 76, 16, 177, 236, 241, 127, 62, 12, 54, 97, 55, 7, 208, 188, 173, 204, 14, 22, 217, 142, 48, 229, 120, 211, 252, 169, 152, 161, 19, 113, 209, 17, 15, 152, 27, 57, 99, 84, 49, 227, 254, 158, 103, 124, 49, 214, 116, 104, 74, 80, 78, 95, 52, 210, 175, 116, 49, 195, 174, 137], 'id': 1234, 'iv': [50, 115, 62, 11, 44, 82, 42, 69, 117, 86, 77, 66, 101, 101, 86, 11]}]

request.onerror = function(event) {
  // Handle errors.
};
request.onupgradeneeded = function(event) {
  var db = event.target.result;
  var objectStore = db.createObjectStore("customers", { keyPath: "id" });

  // Create an index to search customers by name. We may have duplicates
  // so we can't use a unique index.
  objectStore.createIndex("iv", "iv", { unique: false });
  objectStore.createIndex("ciphertext", "ciphertext", { unique: true });

  // Use transaction oncomplete to make sure the objectStore creation is 
  // finished before adding data into it.
  objectStore.transaction.oncomplete = function(event) {
    // Store values in the newly created objectStore.
    var customerObjectStore = db.transaction("customers", "readwrite").objectStore("customers");
    customerData.forEach(function(customer) {
      customerObjectStore.add(customer);
    });
  };
};
</script>
</body>
</html>

Once loaded, you should be able to see the encrypted data in IndexedDB, structured in arrays of bytes (stored in JSON as signed ints).

indexeddb_arr_devtools.png

I set a breakpoint on the first line after the key was defined. This allowed me to work in the JS console to extract the key as a buffer of raw binary, Base64 encoded for easy handling.

key_buf = new Uint8Array(key.length);
key_buf.set(key);
binary = '';
for(var i=0;i<key_buf.length;i++) {
    binary+=String.fromCharCode(key_buf[i]);
}
window.btoa(binary);

This gives us the key in a format we can copy/paste into other scripts.

C5y9Ne4/wpozX47rtB18qg==

So now we have the key, but how can IndexedDB be decrypted using this information? 

First, I needed to dump the database. For this task I found Dexie.js to be invaluable. Modifying Dexie’s sample code, I created a function called “dumpy” which walked the entire database and for each table, would convert the ciphertext and IV to base64 and dump it to the console.

dumpy = function(databaseName) {
    var db = new Dexie(databaseName);
    // Now, open database without specifying any version. This will make the database open any existing database and read its schema automatically.
       db.open().then(function () {
        console.log("var db = new Dexie('" + db.name + "');");
        console.log("db.version(" + db.verno + ").stores({");
        db.tables.forEach(function (table, i) {
            var primKeyAndIndexes = [table.schema.primKey].concat(table.schema.indexes);
            var schemaSyntax = primKeyAndIndexes.map(function (index) { return index.src; }).join(',');
            console.log("    " + table.name + ": " + "'" + schemaSyntax + "'" + (i < db.tables.length - 1 ? "," : ""));
            // Note: We could also dump the objects here if we'd like to:
             table.each(function (object) {
                // here were convert from arrays to base64
                object.iv = arr_to_b64(object.iv);
                object.ciphertext = arr_to_b64(object.ciphertext);
                console.log(JSON.stringify(object));
             });
        });
        console.log("});\n");
    }).finally(function () {
        console.log("Finished dumping database");
        console.log("==========================");
        db.close();        
    });;
}

The function is written specifically for this application and a few things are worth noting when applying this technique in other scenarios. First, since IndexedDB is an object store, we need to know the format of it’s objects, which is easy to learn by browsing the database in Developer Tools. In this situation, each object has a ciphertext and iv attribute. Simple enough.

I converted the ciphtertext and iv from arrays of signed integers into binary, and then base64 encoded them. For this conversion I used the following function:

function arr_to_b64(arr) {
    var binary = '';
    var bytes = new Uint8Array( arr );
    var len = bytes.byteLength;
    for(var i=0;i<len;i++) {
      binary+=String.fromCharCode(bytes[i]);
    }
  return window.btoa(binary);
}

Then in the table.each() loop within dumpy, I called arr_to_b64 on both object.iv and object.ciphertext.

Running dumpy first needs Dexie.js to be available, so I ran the following from the console:

script = document.createElement("script")
script.onload = function() {console.log("Script ready")}
script.src = "https://unpkg.com/dexie/dist/dexie.js";
document.getElementsByTagName("head")[0].appendChild(script)

Once the console outputs “script ready”, Dexie has been loaded.

Then we run our function dumpy and provide the database name:

dumpy("secrets")
indexeddb_dumpy.png

So now we’ve dumped both the IV and ciphertexts for each item in the database. We can copy that offline for decryption using Python:

import base64
from Crypto.Cipher import AES

key = base64.decodestring('C5y9Ne4/wpozX47rtB18qg==')

ct = base64.decodestring('gJq0hkTLJ6bMXGUBaYKYxWsn1aMjtTTUobHWuY5rf7yaMyQmyAVAhPmaWqcV+55Zwj3RTLST/Q7uw7SIaE0TBhNo0lw8fwQVJ1YlRSTetp2vwcKBCemDn40nl1JRLym0sMspr0wQsezxfz4MNmE3B9C8rcwOFtmOMOV40/ypmKETcdERD5gbOWNUMeP+nmd8MdZ0aEpQTl800q90McOuiQ==')
iv = base64.decodestring('MnM+CyxSKkV1Vk1CZWVWCw==')

cipher = AES.new(key, mode=AES.MODE_CBC, IV=iv)

pt = cipher.decrypt(ct)
print pt

Note this code doesn’t handle any padding, so there will be some garbage bytes at the end. But the value in Indexeddb now decrypts to:

[{"age": 35, "ssn": "444-44-4444", "name": "Bill", "email": "bill@company.com"}, {"age": 32, "ssn": "555-55-5555", "name": "Donna", "email": "donna@home.org"}]