30Dec 2020

The MEME "Rug Pull"

One thing I observed with particular interest last year was the MEME "Rug Pull" NFT auction, set up by @DirSchmidt and @iamsimonwan. The idea: There are 12 NFTs and hidden inside one of them is a Bitcoin. None of the NFT owners know who among them is in possession of the Bitcoin, but each has the ability to look inside their own art piece to see if the Bitcoin is there. If they do so, what they found will be available on-chain for everyone to see. And of course, if they find the Bitcoin, the mystery is now ruined for all the other owners as well - the rug pull.

An intriguing concept. I'm a big fan of using the possibilities provided by NFTs to add an interactive dimension to digital art, a kind of collective performance if you will. It's worth watching the video introducing the concept behind the rug pull, which expresses these ideas quite well.

The auction took place about about a month ago. So what happened? No one looked into their NFT, not until four days days ago, when RStudios acquires NFT 73 on OpenSea for $13,000, finds the Bitcoin, then sells it on straight-away for 1.7 ETH (around $1200). A lucky guess? Not really.

I was curious how MEME built this from the beginning, since it isn't really possibly to hide information on the blockchain (you may remember Rarible's easily defeated early attempts at "unlockable" content). Now doubly so. Let's have a closer look.

The Players

  • 0xe6df9 - the Vault: A contract, created by MEME, which holds the 1 BTC prize, and knows who the winner is. The source code is not published.
  • 0x21e2f: The account of RStudios, our winner/attacker.
  • 0xb0261 - Exploit1: A self-destructing contract, created by RStudios.
  • 0x35740 - Exploit2: Another self-destructing contract, created by Exploit1. MEME credits this address with the rekt by.

What happened

It's basically all over in one transaction - in block 11531666. This is where RStudios sends the NFT they bought to the Exploit1 smart contract they had prepared [^1]. Exploit1 then immediately proceeds to create a child-contract, Exploit2, and transfers the NFT to that. Exploit2, now being the owner of the NFT, asks the Vault to reveal if the Bitcoin is hidden inside. The vault confirms that it is, and hands the 1 BTC to Exploit2. Exploit2 sends both the Bitcoin and the NFT back to RStudios [^2].

RStudios goes on to pretty much right away sell the BTC for ETH on Uniswap and asks both constructs to delete themselves, apparently trying to hide their traces.

[^1]: safeTransferFrom is called on the MEME ERC721 contract which issues the NFT tokens. This is the transaction entry point, and everything else flows from there. [^2]: The easiest way to see this is in the Logs tab on Etherscan. You can also follow the execution on Tenderly.

How they did it

This is all very suspicious - enough to cause us to suspect that RStudios knew perfectly well that the NFT they were buying was the one hiding the Bitcoin. Since there are no secrets on the blockchain, maybe they were able to look inside the Vault contract and figure out what the winning NFT was? Not quite - the actual attack is a lot more interesting.

Since we don't have access to any of the contract source codes, we have to read disassembled, reconstructed code - not my strong suit, so if an actual expert wants to add to this, let me know. I don't understand it 100%, but this is what I figure:

  • The idea was to have the vault select one of the NFTs at random as the Bitcoin holder. But they didn't just want to write the number of that NFT number into the contract, which likely would have been pretty easy to find.

  • Also, since the blockchain is about as good at random numbers as it is at hiding stuff (you can't really do things at random), developers tend to try to use block hashes as a source of randomness. Unfortunately, only the 256 most recent block hashes can be accessed in smart contracts.

  • For this reason, the winning NFT was, I think, not actually fixed. The vault contract probably picked a new random value whenever the old one was no longer available (due to the block hash it was supposed to be based on not being available anymore). In other words, it decided if you are the winner the moment you try to look.

The contract the attacker deployed actually has this loop (source):

idx = 1
while idx <= 50:
    if sha3(block.hash(block.number - 70), addr(_321), block.timestamp) % 12 != 3:
          idx = idx + 1
    create2 contract

And the Vault contract calculates the random number something like this (source):

sha3(block.hash(someBlockNumber - 70), someSender, someBlockTimestamp)

My conclusion: The attacker waited until the Vault had to generate a new random number, at which point the vault would initialize someBlockNumber and someBlockTimestamp to the current block being mined, something they have access to in their Exploit1 smart contract. They now have to get someSender to the right value - a value that will cause whatever NFT they have to win. I don't think the actual NFT they bought matters, the winner is being re-determined here.

Since the Vault is going to use the address of the sender here, Exploit1 will loop until it finds an address thata is right, then creates Exploit2 at that address, and start the redeem process from there. It can now be sure that whatever it's NFT is, the pseudo-randomness code of the Vault will declare it the winner.

With bitcoin at $27,000, the attacker walks away with a profit of $15,200. Not too bad for what I assume where some nights of coding. And maybe a literal rug-pull by a hacker is a perfect conclusion to this experiment.

From buying the NFT (in block 11530839) to flipping the now empty one in block 11531732, only about 3 1/2 hour passed.

An interesting post script here is that the subsequent buyer of the NFT, who got it for a dirty cheap 1.7 ETH, clearly thought that no artistic ideals are lofty enough to not take a chance at a $30,000 pay-off, and immediately tried to look into the NFT, only to find it now empty. His is the lone visible reveal transaction on the vault contract.