Stacks Wallet Audit (or How I Stole Private Keys from an App That Doesn’t Store Them)

Em
Least Authority

--

Blockstack promptly resolved the issue discussed here. If you want to read the official report, it’s here.

I have to be honest. When I heard that I was going to be conducting a security audit of Blockstack’s “Stacks Investor Wallet”, I wasn’t super excited. And that’s a good thing. See, I had already conducted an audit of the very same project last year and post-verification of the remediation of findings, it was found to be squeaky clean.

When I say “excited” in relation to a security audit, what I really mean is confident. Confident that I will find a critical oopsie that would have exposed users to risk or some fundamental architectural flaw that invalidates some critical assumptions around how a system should behave. Which is why after learning that this iteration of the wallet was greatly simplified and did not even store private keys, I sort of shrugged the audit off as a “routine check-up”, not expecting to find anything substantive.

And for the vast majority of the time I spent reviewing the code, it was exactly that: straight-forward and simple. The application is almost entirely interface code. The inputs are checked correctly, sanitized, validated, etc. If you are using a hardware wallet, there isn’t even any input to get from the user — just plug in your device and that’s it.

So, I started focusing on the non-hardware wallet users. After all, when you select “proceed without a hardware wallet”, you are clicking a giant red button underneath a clear warning that the developers really recommend that you don’t, so it seemed like a good area to focus on. Once you proceed, the application generates a seed phrase (used to create the private key) and shows it on the screen.

I take a mental note: “okay well here is a private key, currently at least in memory”. On the next page, I have to re-enter the phrase from the previous view to prove to the program that I did in fact write it down (so of course I now have to start over). I take another mental note that the key is still in memory (though could be a hash), because the program checks that what I enter matches correctly. Finally, I’m taken to a screen that shows my zero balance.

There are only two options available to me: send and receive. The receive option shows my address and a QR code and send requires that I re-enter the secret in order to actually send funds. The application behaves as if it has no knowledge of the private key anymore — good. I wonder if I have to re-enter the seed phrase upon starting the application every time since I have to do so for every transaction, my assumption being that the application does not remember state at all. I restart the application.

The program starts up again, but this time it shows me my balance straight away. There is no “create wallet” or “restore wallet” as before. So, I realize that the application is remembering state, but I need to find out where. This search leads me to discover the use of a package called electron-store, which handles the persistence of configuration data. A quick inspection of that package leads me the location of the configuration file, which I open to inspect:

{
"wallet": {
"seed": null,
"pubKey": null,
"addresses": {
"stx": "SP8THDNCS97TWJG1E18S5YEBT4MN3TWZC35V8CBW",
"btc": "12cNbgmsLbHHjxWDboXkNwqFZng9qA5CLr"
},
"balances": {
"stx": "0"
},
"transactions": [],
"type": "wallet_types/SOFTWARE",
"data": {
"success": false,
"balances": {
"confirmed": 0,
"unconfimed": 0
},
"transactions": []
},
"fetchingBalances": false,
"fetchingAddressData": false,
"loading": false,
"signing": false,
"broadcasting": false,
"lastFetch": 1560801100600,
"lastAttempt": null,
"error": null
},
"router": {
"location": {
"pathname": "/",
"search": "",
"hash": ""
},
"action": "POP",
"pathname": "/dashboard",
"search": "",
"hash": ""
},
"app": {
"terms": false,
"keepModalOpen": false,
"appTime": 1560801100600
}
}

When I see this, two flags go up in my head. First off, the contents of this file appear to be an exact snapshot of the entire application state right down to the null values for seed and pubkey. And second, I know that the seed phrase exists in the application state at some point, because it is shown in a view and that means at some point it probably gets written to this file!

So, I wrote a really simple script (which is included in the report) that watches this file for changes, parses the state data, and notifies me if the seed phrase is there. Then, I started flexing the application. I went through as many different paths as possible to manipulate the state such that the seed phrase was shown so my script could exfiltrate it from the config file, but I wasn’t having any luck doing this by simply going through the “fresh” flow. As it turned out, the mechanism that persisted the state data was not initialized until after the seed was nullified from memory.

So, instead of trying “fresh”, I waited until the state persistence was initialized (which happens immediately if the wallet is already created), then used the “reset wallet” feature to go through the seed generation again. Ding! My script tells me that it found the seed phrase and I have one of those moments where, in complete privacy, I shout victory at a completely unreasonable volume.

From there, I started thinking about how an attacker might go about this and came to a couple of prerequisites:

  1. The attacker would have to get our exfiltration script onto the target’s computer and execute it
  2. The attacker would have to trick the user into restoring their wallet

The first prerequisite could occur by gaining physical access the computer, tricking the target into downloading our malware, or compromising one of the application’s dependencies so that the script is unknowingly packaged into the application for us on the next release. The second prerequisite could likely be achieved through manipulation or corruption of the state file itself, causing the user to out of confusion or necessity, attempt to restore their seed.

The reason why I have detailed this vulnerability is because the package used to persist this application’s state is reported by GitHub to be used in almost 3,000 other packages. Of those 3,000, there are bound to be some handling sensitive data, and of those that are handling sensitive data there are bound to be some that do so in such a way that it exposes their users to similar risks.

The developers of this project were clearly unaware that the seed was ever written to disk. The statement that it wasn’t was a key detail during the scoping phase of this audit and yet, here I had stolen my own private key off the disk in beautiful cleartext. So, the moral of the story here is: if you don’t need to persist something, then don’t and if you are persisting an entire state machine, you better sanitize it before it touches code you didn’t write.

--

--