In August we modeled WalletConnect v2’s one-click sign-in in Verifpal, our tool for analyzing cryptographic protocols. WalletConnect connects crypto wallets to websites and apps, usually called dapps. In the one-click flow, wc_sessionAuthenticate, the dapp sends a sign-in request and the wallet answers with a signed statement saying which account is signing in to which site. That statement is called a CACAO. The dapp’s SDK checks it and opens a session for the account.
We wrote the model from the SDK’s code as shipped, because the specification says less than the code does. Then we asked one question: when a dapp believes it has authenticated a wallet, is that belief justified?
Verifpal found two attacks. We reported them to WalletConnect on August 31, and both are fixed in release 2.25.0. If you depend on @walletconnect/sign-client or @walletconnect/utils, upgrade to 2.25.0 or later.
A failed check that kept going
This is the dapp-side code that handled the wallet’s answer in version 2.24.0:
const isValid = await validateSignedCacao({ cacao, projectId: this.client.core.projectId });
if (!isValid) {
this.client.logger.error(cacao, "Signature verification failed");
reject(getSdkError("SESSION_SETTLEMENT_FAILED", "Signature verification failed"));
}
reject() tells the application that the sign-in failed, but it doesn’t stop the function. There was no return after it, so the SDK went on to read the account out of the CACAO that had just failed, derive a session key, and save the session.
We spotted the missing return by reading the code. Reading didn’t tell us what an attacker could do with it, because the application does get its error. So we wrote the behavior into the model. In Verifpal, a ? after a check means the principal stops if the check fails. The dapp’s signature check in our first model has no ?:
_ = SIGNVERIF(iss_d, cp_d, cs_d)We asked whether an attacker could read txdata, which stands for whatever the dapp sends once it thinks the wallet is authenticated. Verifpal said yes and printed how. These are four of the 22 steps, and its conclusion. The excerpts in this post come from Verifpal 1.6.7. Our report used 1.3.7, so its step numbers differ.
| 12. Attacker constructs cs_d, where it is SIGN(nil, cp).
| 13. Attacker constructs d_d, where it is CONCAT(cp, cs_d, PUBKEY(nil)).
| 19. Dapp's ASSERT(gw, gw)? passes — the attacker controls one of its inputs.
| 22. Attacker opens sessreq with r_d, sessreq_iv, obtaining txdata.
> txdata (txdata) is obtained by Attacker.
cp is a sign-in statement naming the victim’s account, gw. The attacker writes it from values it can already see: the victim’s public address, and the domain and nonce in the dapp’s request. The signature cs_d is made with nil, which in Verifpal is a value everyone knows, so the wallet never signed anything. Step 13 is the forged answer: the statement, the worthless signature, and the attacker’s own public key. The dapp accepts it for the victim’s account at step 19 and derives the session key from the attacker’s public key. By step 22 the attacker is reading the session.
The application was told the sign-in had failed. Its session store held a working session for the victim’s account, with the attacker on the other end.
A real signature for another site
Adding the return is one character in the model, the ?. Our second model has it. It also includes an ordinary sign-in by the wallet’s owner to a second site, one the attacker runs. Verifpal failed both queries again. This is from a run with one session per participant:
| 9. Attacker is handed ecp_r by a leaks declaration in Evil.
| 10. Attacker is handed ecs_r by a leaks declaration in Evil.
| 11. Attacker constructs d_d, where it is CONCAT(ecp, ecs, PUBKEY(nil)).
| 18. Dapp's SIGNVERIF(gw, ecp, ecs)? passes — the attacker controls one of its inputs.
| 21. Attacker opens sessreq with r_d, sessreq_iv, obtaining txdata.
> txdata (txdata) is obtained by Attacker.
ecp and ecs are the statement and signature the wallet produced for the attacker’s site, Evil. The attacker sends them to the honest dapp with its own public key. At step 18 the signature check passes, because the signature is real.
We hadn’t seen this in the code. The trace is what sent us to read validateSignedCacao. That function rebuilds the signed message from the CACAO’s own contents and checks that the account named in it signed it. The dapp’s request isn’t one of its arguments, and nothing else in the SDK compared the answer’s domain, audience or nonce with the request. So a sign-in approved for one site was accepted by an unrelated one. The user did nothing wrong here: their wallet showed evil.example and they approved evil.example.
Checking the published packages
A trace describes our model, and the model is our reading of the code. To make sure the reading was right, we wrote proofs of concept that run against the packages published on npm, unmodified.
In the first, the attacker holds the pairing URI and no key for the victim’s address. The dapp’s authenticate() call rejects. A session for the victim’s address is in the client’s store anyway, it is still there after a restart, and the attacker decrypts what the dapp sends on it. In the second, a CACAO signed for evil.example passes the shipped validateSignedCacao, while the dapp’s request was for honest.example with a different nonce.
Both attacks need an attacker who can answer the dapp’s sign-in request. That means holding the pairing URI, which is usually shown as a QR code, or sitting on the dapp’s connection to the relay.
The fix, and the part still open
WalletConnect fixed both findings in one pull request, released in 2.25.0 on September 14. A failed signature check now stops the session from being created, and the wallet’s answer is compared with the request’s domain, audience and nonce. The team ran our proof of concept against the fix and it created no sessions. Thank you to the WalletConnect team for the fix.
Our report made a third recommendation, which is still open. The dapp takes the session key from a public key in the wallet’s answer, and no signature covers that key. Verifpal shows what follows from that too. Run the second model with the default two sessions per participant and it prints a different attack. An attacker who holds the pairing key and can rewrite messages in both directions swaps its own public key into the dapp’s request. The wallet answers, the attacker opens that answer, and it forwards the wallet’s real signature to the dapp with its own public key attached. The domain and nonce match, so the checks added in 2.25.0 don’t stop it.
We modeled those two checks in a third file to confirm that, and a repair in a fourth. When the signed statement also names the wallet’s session key and the dapp checks it, Verifpal finds no way for the attacker to read the session. That search is bounded, so it tells us no attack was found within it. It is not a proof. WalletConnect’s pull request lists this change as out of scope, because it needs a spec change and a coordinated update of wallet SDKs.
What the model did for us
Neither bug was in a cryptographic primitive. The signatures, the key exchange and the encryption all worked. In both cases the dapp accepted a signature without establishing that it answered the request the dapp had sent. That is hard to see one function at a time. validateSignedCacao does what its name says, and the problem only shows when you follow one statement from the site it was signed for to a different site.
Following values across a whole protocol is what Verifpal does. You write down what each party knows and sends, state what should hold, and it searches for an attacker who breaks it. When it finds one, it prints the steps. The first model here is 57 lines of Verifpal. The rest of the file is comments citing the source file behind each modeling decision. Verifpal analyzes it in about four seconds.
We found the first bug by reading and needed the model to see what it gave an attacker. We would not have found the second without the model.
The models and proofs of concept are public. To run one, install Verifpal and try:
verifpal verify models/01-signature-bypass.vp
The documentation starts with a quickstart, and the Workbench runs models in the browser if you would rather not install anything.