BitcoinLink
During the Bitcoin++ hackathon, I built BitcoinLink as a simple showcase of how you can use Nostr Wallet Connect (NWC) to create non-custodial, one-time-use reward links redeemable via lightning. The service allows you to send bitcoin to anyone with a simple link redeemable with Lightning via NWC. The sender generates an NWC with either Alby or Mutiny, chooses a total budget and the number of links to split that budget across. BitcoinLink then generates a pseudorandom secret, encrypts the NWC with the secret, stores the secret in one or many links, and saves the encrypted NWC in our database. The receiver clicks one of the links, the app detects and decrypts the NWC, and sends the bitcoin directly to the receiver's wallet from the sender's wallet over lightning.
I was particularly proud of this scheme because it splits the encryption key and the encrypted NWC into two separate places. The server never saves the secret, and the sender can create multiple links from one NWC. However, despite this theoretically sound approach, I made a grave implementation mistake that allowed a hacker to access both the secret and the encrypted NWC, leading to the theft of one of my user's funds.
In this post, I'll walk you through the hack, how I fixed it, and what I learned from the experience.
The Hackathon
I built the first implementation of BitcoinLink at the Bitcoin++ hackathon. During development, I made a few bad assumptions about how I could build my MVP, which required some architectural changes midway through the build. This is a common occurrence in hackathons, so I made the changes and continued on. I managed to get the MVP working and submitted it to the hackathon. I was thrilled with the result and excited to see people's reactions. After the hackathon, a few people approached me, interested in using BitcoinLink for various purposes, which was encouraging.
Following the hackathon, I made basic improvements to the UI and flow, added the ability to generate links with Mutiny Wallet, and created an API endpoint for programmatically pulling generated links. Unfortunately, during this time, I neglected to review my code thoroughly and audit the core flow of the app. Had I done so, I likely would have found the vulnerability before it was exploited. However, I was distracted by shiny new features and didn't take the time to clean up what had already been built.
The Hack
Last week, I woke up to a message from one of my users saying their Alby wallet had been drained of over 200,000 sats. I was shocked. I had built this service to be non-custodial and secure—how could this have happened? I quickly checked my logs and saw a trail of requests that appeared to be someone generating and claiming links, playing with the endpoints. My user had generated many links with a large budget on their NWC. The hacker got one of these links, accessed the secret, and decrypted it. But how?
I reviewed my code and quickly saw that one of my most critical endpoints was left unprotected, allowing the attacker to pull down the encrypted NWC if they had the ID. I was devastated. I had failed my users. The hacker, with a valid link someone else generated, was able to grab the secret from the link, pull the encrypted NWC from my unprotected endpoint, decrypt it, and drain it of its full budget. It was such a simple oversight—I couldn't believe I had missed this critical vulnerability. Because the code was open source, the hacker was able to play around with the service, discover the vulnerability, and exploit it all within a single night.
The Fix
Once I understood how the attack happened, I immediately began working on fixes. This started simple but quickly ballooned into a full re-architecture of the service and security model. I decided to move the decryption of the NWC and the payment into the execution context of my claim endpoint so that the encrypted NWC would never be exposed to the client. Payments could only be executed server-side for the exact amount encoded in the link. I also now duplicate and re-encrypt the NWC across my database with different unique secrets for each link/NWC and unique NWC IDs for each encrypted NWC. This effectively decorrelates all links and NWCs, ensuring that even if a secret gets leaked, an attacker will never have access to the encrypted NWC and vice versa.
What I Learned
This experience taught me a lot. It was the first time a project of mine had been hacked, the first time I lost (someone else's) money due to my mistake, and the first time I had to play cat-and-mouse with a hacker trying to pwn my users in real time. I'm very disappointed in myself for letting this happen, but I know it's important to share exactly what happened and take responsibility for it. I strive to be transparent with my work—everything I've ever worked on as a developer has been open source and done in public. It would be hypocritical of me not to share my failures as well.
Overall, this was an important learning experience. I gained a better understanding of security, the importance of code reviews, and the necessity of auditing your code to ensure it's secure. I have a newfound respect for the level of responsibility I have when building and shipping projects to end users, especially when they involve payments. I hope others will heed my warning and always proceed with caution and an adversarial mindset when building and deploying software.
Conclusion
With all of that out of the way, let me put my money where my mouth is.
Here is a bitcoinlink that correlates to a 100,000 sats NWC that I generated with Alby (this one is a freebie, someone will be able to claim 1000 sats): https://www.bitcoinlink.app/claim/clxj1w9sn0001zjaemkmmv0uk?secret=6fb00c23ed97261f18d7fbf5ce71e6f5d524531bdf7be9cbb5cd65885f923e9f&linkIndex=ceab5be1-1422-405d-acc3-e21a4c12c41c
Here is the bitcoinlink source code
I invite anyone with the technical chops to take the information in this link and the source code and try to hack the updated version of BitcoinLink and drain my NWC. I'm fairly confident that my updated version of BitcoinLink will make this immensely more difficult, but I would love to be proven wrong. You can earn a decent bounty for doing so. If you do, please let me know how you did it so I can fix it and make it better for everyone else.
Onwards!