Three weeks ago a friend had their Terra LUNA wallet hacked. He had googled “terra station” and absent-mindedly clicked the top link — not realizing it was an ad to fake website. He entered in his seed phrase in the seemingly legit website and instantly the hackers had begun emptying his wallet.
Beware scam ads when googling crypto wallets!
He had staked 1000 Luna in his wallet (worth about $100,000). Using the stolen seed phrase, the hacker opened his wallet and proceeded to unstake this Luna. There is a 21-day lock-up period for withdrawing staked Luna, so the hacker could not immediately withdraw it to his own wallet. The Luna block explorer publishes the exact time that staked Luna will be released, so thus began a 3 week-long countdown of our friend anxiously watching his wallet, knowing that he was helpless to prevent his Luna from being stolen the moment it was unlocked.
During this time, he reached out to us for help.
We assumed the hacker would be running a script that spends the Luna the moment it becomes available. So we decided the best chance we had to recover the Luna was to write a similar script that hopefully withdrew the money to a safe wallet before the hacker’s script could do the same.
The result was a race to spend the Luna at the moment of unstaking — almost like trying to be the first person to grab the last cookie from a plate, except that cookie is worth $100k.
We wrote our script using the terra.js SDK. It worked as follows:
- Once started, the script would wait until a provided execution_time
- An interval timer then runs every 200ms which attempts to withdraw the Luna
- To withdraw the Luna, you need to:
— Create an LCDClient and connect to a node
— Create a MsgSend
— Create and sign a transaction
— Broadcast the transaction
Note that these steps are all done asynchronously
Here’s the Github Repo
Assuming the script was well-engineered, this project was essentially a race against the clock. To improve our odds of submitting our transaction first, we wanted to connect to as many nodes as possible. Of the 6 public LCD nodes listed in the docs, only 2 of them worked for us — namely https://terra-lcd.easy2stake.com and https://blockdaemon-terra-lcd.api.bdnodes.net:1317.
An IP address lookup of the above 2 nodes revealed that one was located in Germany and the other in Virginia in the US. To reduce the latency between us and the nodes, we set up two AWS EC2 instances: one located in Virginia and the other in Frankfurt. Each connected to their closest node. Then, for good measure, we set up an additional two AWS EC2 instances in the same locations.
Each server would begin executing the script roughly 30 seconds before the unlock, but with a start time that staggered by about 250 ms, to try to improve the odds of sliding a transaction in at just the right moment.
When the time to unlock came, our instances were able to successfully create and sign the transactions (which could only happen once the Luna was unlocked). Unfortunately, the broadcast of our transaction failed. This is presumably due to the hacker being able to broadcast their transaction before us and hence our broadcast was rejected.
We believe that in the time between us creating the transaction and broadcasting it to the node, the hacker had already submitted their competing broadcast, thereby taking the Luna from the wallet.
Here’s the hacker’s successful transaction. This hacker is a professional. They have a website that looks exactly like a Luna wallet and Google ads to lure victims into revealing their seed phrases. We were able to trace some of their previous transactions in a different wallet to find that they’re holding about $20 million in stolen crypto.
Attempting to create and broadcast transactions on the 4 servers at execution time
Unfortunately, we weren’t able to save the Luna, but with only one attempt, we can’t be certain if it was due to bad luck, or lack of resources, or an inferior solution. What changes or improvements do you think we could have made to better increase our chances of success against the hacker?