boojit.
Fri, 13-Jan-2023

LastPass vaults and shared folders

In the Episode #905 of the Security Now! podcast, Steve Gibson unveils a tool to view the unencrypted data stored in your LastPass vault, as well as understand the level of encryption used to protect the vault. This is an extremely useful tool and has greatly increased the ease at which all of us can understand what's happening with our private LastPass stores.

However, when I used the tool, I was presented with a very meager list of entries. I quickly located the reason why: My use of LastPass is limited to my corporate LastPass account, and even then I only use it to share entries with other coworkers.

So this means the vast majority of the entries that I access through LastPass are in what they call "shared folders", and those entries are not retrieved with the tooling that Steve provided.

Here's the snippet of javascript provided by Steve that is used in-browser to retrieve a personal LastPass vault:

fetch("https://lastpass.com/getaccts.php", {method: "POST"})
.then(response => response.text())
.then(text => console.log(text.replace(/>/g, ">\n")));

If you run this code from the Developer Tools (F12) console in your browser, after first logging into LastPass, you'll be returned with your personal vault, but it won't contain any of the shared entries. This is true whether you made the entries and shared them with others, or vice-versa.

So surely, since these shared folders appear from within my LastPass session, LastPass must be calling getaccts.php a bit differently than in the code snippet above. And indeed that appears to be the case.

A quick peek in the Developer Tools Network tab during a LastPass page load shows the following parameters passed to getaccts.php, returning a much different payload from the one returned in the javascript snippet above. When LastPass calls it, it sends the following parameters in the request payload:

mobile=1
&b64=1
&shap=1
&includependingsharedfolders=1
&includesharedfolderformfillprofiles=1
&includeemergencyaccess=1
&includelinkedsharedfolders=1
&requestsrc=newvault
&hasplugin=4.0
&u=[userId redacted]

When I run the javascript snippet provided by Steve, I get back an xml payload approximately 26k in length. But when called with the included parameters, I get back a base64-encoded payload approximately 400k in length.

There's some code of interest within the LastPass javascript that works with this payload; let's take a look:

function get_accts() {
    var a = localStorage_getItem(g_username_hash + "_personalaccountlinktoken")
    , b = "";
    a && (b = "&personalaccountlinktoken=" + encodeURIComponent(a));
    return $.ajax({
        type: "POST",
        url: base_url + "getaccts.php",
        data: "mobile=1&b64=1&shap=1&includependingsharedfolders=1&includesharedfolderformfillprofiles=1&includeemergencyaccess=1&includelinkedsharedfolders=1&requestsrc=newvault&hasplugin=" + encodeURIComponent(lpversion) + "&u=" + encodeURIComponent(g_username_hash) + b,
        dataType: "text",
        success: function(a) {
            try {
                a = atob(a)
            } catch (d) {
                sendError("BLOB ERROR: cannot be base64 decoded for user " + g_uid)
            }
            a.endsWith("ENDM\x00\x00\x00\u0002OK") || sendError("BLOB ERROR: not complete for user " + g_uid);
            g_sites = [];
            g_securenotes = [];
            g_prompts = [];
            g_formfills = [];
            g_identities = [];
            g_pendings = [];
            g_maxid = [];
            g_groups = [];
            g_prefoverrides = [];
            g_shares = [];
            g_applications = [];
            lp_attaches = [];
            g_attachversion = [];
            g_emer_sharers = [];
            g_emer_sharees = [];
            g_note_templates = [];
            g_pending_shares = [];
            g_equivalentdomains = {};
            parsemobile(a, a.length, 100, 0, postacctsload, g_sites, g_securenotes, g_prompts, g_formfills, g_identities, g_equivalentdomains, null, !0, !1, null, g_pendings, null, g_maxid, null, g_groups, g_prefoverrides, lp_rsaprivatekeyhex, g_shares, null, null, null, g_applications, lp_attaches, g_attachversion, null, null, null, g_emer_sharers, g_emer_sharees, null, g_note_templates, g_pending_shares, null, null, null)
        }
    })
}

You can see the parameters passed to getaccts.php here match what I listed earlier. Note the success handler function; the first thing it does is take the response payload from getaccts.php and then does a base64 decode using atob(). If you examine the output from this step you'll find it's indeed decoded, but still pretty unreadable. I haven't spent a lot of time looking at it as of this writing.

Then it initializes a list of global variables, all starting with g_. Next, observe the parsemobile() call; this is responsible for parsing the decoded contents returned from getaccts.php and then dumping this parsed data into those previously-initialized global variables.

All of these g_ variables are accessible from the developer's console simply by typing in their name. You don't need to run any other code snippets, just log into LastPass, drop to a dev console, and type in eg, g_securenotes and you'll get back a Proxy object. Simply unfold the Target part of that Proxy object to see the list of entries, and then you can unfold further from there.

My take on this is I think that first javascript snippet could probably be improved upon to create a more complete list of entries that could then be analyzed by Rob Woodruff's powershell tool. I tried to play with the parameters passed to getaccts.php to see if I could retrieve a payload more akin to the one in the javascript snippet, something more like an unencoded xml file, but with a more complete list of entries... but no luck there. My next step will be to investigate the parsemobile function more carefully to see if I can produce an exportable list of all entries, and perhaps get that to be processable by Rob's tool.

Finally, there's the issue of how shared vaults are stored, handled, encrypted, and decrypted by LastPass. Obviously the "TNO" model used by LastPass (and there's just not enough airquotes in the world to put around "TNO" in this case) for unshared vault entries (whereby the master password needed to unlock the entries never leaves the client computer) cannot work for shared entries -- the decryption key must be available to multiple individuals, and so must be stored server-side somehow.

And indeed this is the case. From the LastPass technical whitepaper:

LastPass uses RSA public key cryptography to allow users to share credentials with trusted parties synced through LastPass. Admins and users can create Shared Folders to give appropriate access to individuals or groups, without the need to expose the credentials themselves. And even though it is shared through LastPass, LastPass is unable to decrypt the data.

RSA uses asymmetric key algorithms, where the key used to encrypt a message is different from the key used to decrypt it. Each user has a pair of cryptographic keys, one public, one private. The public key can be shared with anyone and can be used to encrypt data, while the private key is available only to the user and can be used to decrypt data encrypted with their public key.

When a Shared Folder is created, a 256-bit encryption key is generated and used to encrypt the data stored in the Shared Folder. This encryption key is further encrypted with the public key of anyone invited to the Shared Folder and can be decrypted only with the invitee’s corresponding private key.

All users who share folders generate a 2048-bit RSA key pair locally on their own device. The user’s private key is encrypted with their vault encryption key using AES-256-bit encryption then sent to LastPass along with the user’s public key. The encrypted private key is sent to LastPass so that it can be attained from other devices in the future. Public keys will be used by other users to encrypt data that can only be decrypted with the original private key.

So, I'm not sure what this means for the data breach. My first read of this is that if the attackers are able to decrypt a users private vault, they'll have the key they need to decrypt the shared vault. Whether the shared vaults are a part of what the attackers made off with, I can't say, but my guess is they got all of it.