A review of etherscan's 0day (zero-day) vulnerability
"What would you do if you found a 0day in etherscan? I decided to build a 'pinball machine'."——samczsun, Paradigm Research Partner
Note: The original author is samczsun.
I like to challenge assumptions.
I love trying to do the impossible, to find what others miss, to surprise them with things they've never seen. Last year, I wrote a challenge for Paradigm CTF 2021 based on a very convoluted Solidity vulnerability. While a variant was publicly disclosed, the exploit I found was never really discussed. As a result, almost everyone who has tried to challenge it has been stumped by its seemingly impossible nature.
We were discussing plans for Paradigm CTF 2022 a few weeks ago when Georgios tweeted about a puzzle and I thought it would be really cool to post a challenge on the same day as the kickoff call. However, this can't just be a corny challenge. I want something out of this world, something no one else will see, something beyond the limits of what people can imagine. I want to write the first Ethereum CTF challenge to exploit a 0day (zero-day) vulnerability.
1
How to create a 0day (zero-day) vulnerability
As security researchers, in order to optimize our time, we make some fundamental assumptions. One is that the source code we're reading does produce the contract we're analyzing. Of course, this assumption only holds if we read the source code from a trusted source, such as Etherscan. So if I can find a way to get Etherscan to incorrectly verify something, I'll be able to devise a really devious puzzle around it.
In order to find out how to utilize Etherscan's contract verification system, I had to verify some contracts. I deployed some contracts on the Ropsten testnet to toss and try to validate them. Soon, I saw the interface below.
I selected the correct setting and proceeded to the next page. Here I am asked to provide my contract source code.
I entered the source code and clicked the verify button, and sure enough, my source code is now attached to my contract.
Now that I know how things work, I can start playing around with the verification process. The first thing I tried was to deploy a new contract, change foo to bar, and verify the contract with the original source code. Not surprisingly, Etherscan refused to verify my contract.
However, when I manually compare the two bytecode outputs, I notice something strange. The contract bytecode should be in hex, but apparently some are not.
I know that Solidity appends contract metadata to the deployed bytecode, but I've never really considered how that affects contract verification. Apparently, Etherscan is scanning the bytecode for metadata and replacing it with a marker that says, "Anything in this area can be different and we'll still think it's the same bytecode."
This seems like a promising lead for potential 0day (zero-day) exploits. If I can trick Etherscan into interpreting non-metadata as metadata, then I'll be able to tweak the bytecode I deploy in regions marked {ipfs} while still having it verify as legitimate bytecode.
The easiest way I can think of to include some arbitrary bytecodes in the create transaction is to encode them as constructor parameters. Solidity encodes constructor parameters by attaching the ABI-encoded form directly to the create transaction data.
However, Etherscan is so smart that it excludes constructor parameters from any kind of metadata sniffing. You can see that the constructor parameters are italicized to show that they are separate from the code itself.
This means that I need to somehow trick the Solidity compiler into emitting a sequence of bytes that I control so that it resembles embedded metadata. However, this seems like a hard problem to figure out, since I have very little control over which opcodes or bytes Solidity chooses to use without some serious compiler wrangling, and the source code looks very suspicious afterwards.
I thought about this for a while, until I realized: it's actually pretty easy to cause Solidity to emit (almost) arbitrary bytes. The following code will cause Solidity to emit 32 bytes of 0xAA.
bytes32 value = 0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;
Encouraged, I quickly wrote a small contract that would push a series of constants so that Solidity would emit bytecode that looked exactly like the embedded metadata.
To my delight, Etherscan marked the existence of the IPFS hash in the middle of my contract.
I quickly copied the expected bytecode and replaced the IPFS hash with some random bytes, then deployed the resulting contract. Sure enough, Etherscan considered different byte business as usual and allowed my contract to be verified.
With this contract in place, the source code suggests that calling example() should return a simple bytes object. However, this is what happens if you actually try to call it.
$ seth call 0x3cd2138cabfb03c8ed9687561a0ef5c9a153923f 'example()'
seth-rpc: {"id":1,"jsonrpc":"2.0","method":"eth_call","params":[{"data":"0x54353f2f","to":"0x3CD2138CAbfB03c8eD9687561a0ef5C9a153923f"},"latest"]}
seth-rpc: error: code -32000
seth-rpc: error: message stack underflow (5 <=> 16)
I successfully found a 0day (zero-day) vulnerability in Etherscan, and I can now verify contracts that behave completely differently than the source code suggests. And now, I just need to design a puzzle game around it.
2
a false start
Obviously, this decryption game will revolve around the idea that the source code seen on Etherscan does not imply how the contract actually behaves. I also want to make sure players can't simply replay transactions directly, so the solution must be unique per address. The best way is obviously to ask for a signature.
But under what circumstances would a player be asked to sign some data? My first design was a simple puzzle with a single exposed function. Players would call the function with some input, sign the data to prove they came up with a solution, and if the input passed all the various checks, then they would be marked as a solver. However, as I spent the next few hours fleshing out the design, I quickly became dissatisfied with how things turned out, it started to feel very clunky and inelegant, and I couldn't bear to work on a puzzle with such a poor design Destroy such a great 0day (zero-day) vulnerability idea.
Have to admit, I couldn't get this done by Friday, so I decided to get some sleep.
3
Pinball Puzzle
I continued to try to iterate on my initial design over the weekend, but made no further progress. It's as if my current approach has hit a wall, and as much as I hate to admit it, I know I may have to start over if I want something I'm happy with.
Eventually, I found myself revisiting the problem from first principles. What I want is a puzzle game where the player has to complete various knowledge checks. However, I am not claiming that completing the knowledge check itself is a condition for winning, rather, it may be one of the many paths the player is allowed to choose. Maybe the player can accumulate points throughout the puzzle, exploiting this bug to get some sort of reward. The condition for winning is simply the highest score, thus indirectly encouraging the use of exploits.
I recall a challenge I designed last year, Lockbox, which forced players to construct a single block of data to satisfy the requirements of six different contracts. Contracts apply different constraints to the same bytes, forcing players to be clever in how they construct their payloads. I realized I wanted to do something similar here, where I'd ask the player to submit a single blob of data, and I'd award points based on certain parts of the data meeting certain requirements.
It was at this point that I realized I was basically describing pinboooll, a challenge I faced during DEFCON CTF 2020 finals. The gimmick of pinboooll is that when you execute a binary, the execution bounces around the control flow graph, like a ball bouncing in a pinball machine. By properly structuring your input, you will be able to find specific code sections and earn points. Of course, there is a bug, but frankly, I've forgotten what it is, and I'm not going to look for it. Beyond that, I already had a vulnerability I wanted to exploit.
Since I was dealing with a working zero-day exploit, I decided to get this puzzle resolved as quickly as possible. In the end, I spent a few hours reacquainting myself with how pinboooll works, and a few days re-implementing it. This solves the puzzle's bracket issue, now I just need to integrate the exploit.
4
Arming a zero-day exploit
The way I've gotten Solidity to output the correct bytes has been to just load a few constants and have Solidity issue the corresponding PUSH instructions. However, arbitrary constants like this can be a huge red flag, and I'd like something that blends in better. I also have to load all the constants in one line, which is hard to explain in real code.
Since I really only had to hardcode the two magic byte sequences (0xa264...1220 and 0x6473...0033), I decided to see if I could sandwich code between them instead of a third constant . In the deployed contract, I just need to replace the sandwiched code with some other instructions.
address a = 0xa264...1220;
uint x = 1 + 1 + 1 + ... + 1;
address b = 0x6473...0033;
After some experimentation, I found out that this is possible, but only if the optimizer is enabled. Otherwise, Solidity emits excessive value sanitization code. This is acceptable, so I move on to improving the code itself.
I can only modify the code within two addresses, but it would be weird to see a dangling address at the end, so I decided to use them in conditional statements. I also had to prove the necessity of the second condition, so in the end I voted for a little bonus point. I did the first conditional check that tx.origin matched the hardcoded value to give an initial impression that there was no need to pursue this code path further.
if (tx.origin != 0x13378bd7CacfCAb2909Fa2646970667358221220) return true;
state.rand = 0x40;
state.location = 0x60;
if (msg.sender != 0x64736F6c6343A0FB380033c82951b4126BD95042) return true;
state.baseScore += 1500;
Now that the source code is all ready, I have to write the actual backdoor. My backdoor needs to verify that the player triggers the exploit correctly, fail without giving any prompts if they don't, and reward them if they trigger successfully. I wanted to make sure the exploit couldn't be easily replayed, so I decided to just require players to sign their own addresses and submit the signature in the transaction. To spice things up, I decided to require the signature to be at offset 0x44 in the transaction data, where the ball would normally start. This will require the player to understand how the ABI encoding works, and manually relocate the ball data elsewhere.
But here I run into a big problem: It's simply not possible to fit all this logic into 31 bytes of hand-written assembly. Fortunately, after some thought, I realized I had another 31 bytes to work with. After all, the real embedded metadata contains another IPFS hash that Etherscan also ignores.
After some code golfing, I arrived at a working backdoor. On the first IPFS hash, I immediately pop the address I just pushed, then jump to the second IPFS hash. There I would hash the caller and partially setup the memory/stack to call ecrecover. Then I jump back to the first IPFS hash, where I finish setting up the stack and execute the call. Finally, I set the score multiplier equal to (msg.sender == ecrecover()) * 0x40 + 1, which means no extra branch is needed.
After coding the backdoor to a certain size, I tweeted out my Rinkeby address to get some testnet ETH from the faucet, and then hinted to anyone watching the tweet that something might be going on. Next, I deployed the contract and verified it.
All that is left to do now is to wait for someone with predestined relationship to discover the back door hidden in plain sight.
Note: Kane Wallmann, a software developer from Rocket Pool, has solved this puzzle as of press time, here is the process: https://medium.com/@kanewallmann_71759/an-untrustworthy-pinball-machine-d9dcd07882c
This article comes from Tao of Yuan Universe, reproduced with authorization.
This article comes from Tao of Yuan Universe, reproduced with authorization.


