On October 22nd, infamous whitehat samczsun posted a tweet containing nothing but an Ethereum address. It didn’t take long for people to track it to an address on the Rinkeby network where the address was used to deploy a contract named “Pinball”. The source code was verified on Etherscan and the game was on.
The goal, as confirmed in a tweet by paradigm_ctf, was to achieve the highest score possible by playing this game that resembled a pinball machine where players can flip flippers to move a ball around and receive various score increments and multipliers by solving certain puzzles and other requirements.
In the first half of this write-up, I will explain the general rules of the game and how I went about solving some of the puzzles. After that, I will go through the process I used to discover a hidden exploit and achieve a much higher score than what would be possible playing within the rules. So if you already know how to play, feel free to skip this first section.
The Advertised Rules
The pinball machine lives on the Rinkeby test network at the following address: 0xffb9205c84d0b209c215212a3cdfc50bf1cfb0e0
I’ve saved a copy of the source code at the time of playing here: https://gist.github.com/kanewallmann/bd5b2543323fe0c53a3b53a0e5730f7c The reason for this will become clear later.
To play you must call the `play` method on the contract with a specially crafted 512 byte array called a “ball”. A ball consists of a header, an array of commands, and some extra data used to control the program flow in a desirable way to produce the highest score possible.
There is a “location” value that can be thought of as an X co-ordinate of a pinball in a pinball machine between 0 and 99 inclusive. 0–32 represent a value on the left hand side of the machine where the left flipper would be, 33–65 is in the middle where the ball would fall through the flippers and 66–99 is on the right. The location value is initialised on game start to a known value (67) and based on the current location value you have a few choice as to how to play. After each command the location is updated in a deterministic pseudo-random manner. Depending on which commands you execute and what state the machine is in, you will be rewarded with increased scores and score multipliers.
Some of the commands require you to solve a puzzle or have certain bytes in certain locations in memory in order to maximise your score or progress through the available “missions”. For example, to complete a mission you have to apply the right flipper and then solve this little puzzle:
You need to find 10 uint16 values whose product is 0x020c020c. If you perform prime factorisation on this value you will find that one of the distinct prime factors is greater than the maximum value a uint16 can encode. Meaning there is no way to multiply any number of uint16 values together to arrive at this value.
The unchecked block should make it immediately obvious that you need to rely on an integer overflow to solve this one. I initially solved this by hand with a bit of brute forcing but it is interesting to see the different methods used by other players to solve this.
adietrichs used a hand rolled prime factorisation script written in python:
karmacoma_eth used the symbolic execution engine of hevm (provided by dapptools)
And just for fun, I later wrote a python script that uses the Z3 solver which can be found here: https://gist.github.com/kanewallmann/17bfd525234dc224c5ed428bc08d56cc
Based on the general rules above, you should be able to see the way to win at this game is to search through the possible program paths to find one that leads to the highest score. And it’s relatively easy to get a score on the board simply brute forcing your way through. This is how I got the first few high scores in the first days following it’s deployment.
It wasn’t long though before adietrichs blew my hand-crafted submissions out of the water by automating what I was doing (sluggishly) by hand:
At this stage I was ready to concede defeat as I knew any improvement I could make would be marginal and he had already hit all but one of the milestones of the machine (completing all the missions and getting all the available score multipliers). Or so I thought.
A rumor began spreading that there was still an exploit yet to be taken advantage of and when samczsun confirmed the rumor and added that it would result in “orders of magnitudes” greater score potential, I was once again motivated to continue playing.
The Hidden Rule
Knowing that there was an intentional exploit in the contract, I set out to look for all the usual suspects.
The contract was compiled with solc v0.8.9. So integer overflows are impossible by default. Only arithmetic operations within “unchecked” blocks can overflow without reverting. Analysing the two unchecked blocks came up with nothing. Overflowing here was intentional and unexploitable.
The contract contains no external or delegate calls so there is no possibility of a reentrancy exploit or delegatecall shenanigans. The contract has no payable methods and does not operate on msg.value in any way. Storage is used minimally to store submissions and keep track of the top submission.
In 2 situations, it was possible to read memory values outside of the bounds of your ball. Directly after the ball in memory was msg.sender and keccak256(ball), both values that we have control over. So I spent a lot of time poking at this flaw but once again found nothing exploitable.
I scoured over the game logic to look for non-obvious paths and states but everything came up squeaky clean.
At this time I was beginning to suspect that samczsun was trolling and there was no exploit to be found or something fishy was at play. I had ruled out all the obvious options and all that remained was an absurd one — a 0day exploit in the EVM or solc.
“When you have eliminated all which is impossible, then whatever remains, however improbable, must be the truth” — Sherlock Holmes
Sam is exceptional and a bit of a trickster, but I couldn’t fathom him holding back on disclosing a critical 0day exploit in something as important as the EVM in order to run a CTF game for entertainment. Well, turns out I was half right in that assessment.
Suspecting an EVM or solc bug, I loaded up the contract’s bytecode to look for anything odd. I wanted to make sure my compiler version and settings where all identical (optimization settings result in different bytecode for example) so I took my locally compiled bytecode and ran it through a disassembler. I then took the deployed contract code and ran it through the same thing. I took both results and ran a diff check on it. And low and behold…
On the right is my locally compiled version and on the left is the deployed code.
The STATICCALL lit up like a Christmas tree. There’s no references to any external contracts or libraries in the contract source so why is there a STATICCALL op code?
I tracked the STATICCALL operation down to where it would be in the original source and by all accounts, it should be within this little block of code that I had earlier chalked up to be a red herring:
I got a hit of adrenaline as I was certain this is where the exploit was hiding and if I’ve found it then surely one of my competitors had already found it or were not far behind. The only thing left was to work out what was going on in the deployed bytecode and how to leverage it to increase my score.
First, I worked out what address the STATICCALL was being made to. The bytecode leading up to the call was:
And as you can see from this incredibly useful opcode reference document, the second argument to STATICCALL is the address to call:
That means the contract is calling address 0x01. If you’re not familiar with the EVM you might not know that low addresses starting from 0x1 are used for “precompiles” which are additional functionality added to the EVM without adding a new opcode. They act like any other deployed library but are built into the EVM itself. Address 0x01 is the precompile used to implement the “ecrecover” function in solidity.
Looking at the decompilation result of the bytecode, we can see this in a slightly more human digestible format.
Lines 4–6 are preparing the memory for the ecrecover call. msg.sender is placed into memory[var3:var3 + 0x20] then keccak256 is performed on that same memory location with the result overwriting it. Then importantly, msg.data[0x44:0xa4] is placed after the hashed address.
So we have keccak256(msg.sender) followed by 96 bytes loaded from msg.data. For reference, ecrecover has the signature: ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) which matches.
Line 10 shows the STATICCALL to address(0x01) with the memory prepared above and the return value of that call is stored in memory[0x00:0x20].
Lines 11 is mutating memory[arg0 + 0x0140:arg0 +0x0140 + 0x20]. arg0 in this situation is the pointer to the game state struct shown below. And the offset of 0x0140 (320) lines up with the scoreMultiplier element.
Thus, line 11 is setting scoreMultiplier to (msg.sender == memory[0x00:0x20]) * 0x40 + 0x01.
Simply put, if the result of ecrecover(keccak256(msg.sender), v, r, s) where v, r and s are loaded from msg.data is equal to msg.sender then scoreMultiplier will be set to 0x41 (65). A very large multiplier indeed!
Therefore, in order to perform this exploit we need to hash msg.sender, sign it with msg.sender’s private key and place it in the correct spot in the calldata. This step required a little extra work because the point in calldata that it was looking for the signature is after the selector and array of arguments and before the bulk data. It was just a matter of pushing the 512 ball bytes back and slipping the signature in between.
I took my previous ball with a paltry score of 61,354. Modified it slightly to execute the hidden functionality, tacked on a required signature to the calldata and out popped a score of 695,210.
OK, But How?
So how was this seemingly hidden functionality in the bytecode but not shown in the source code?
Well, recall to when I said I was half right in my assessment of samczsun. He did in fact find an 0day exploit but it was in Etherscan’s source code verification function not the EVM. So the code the smart contract was executing was not the code we were analysing to play the game. Sneaky.
He discovered a flaw that allowed him to modify data between two markers emitted by solc to identify metadata. Etherscan ignored anything between these two markers as it incorrectly believed there was nothing of concern between them — certainly not executable op codes. The markers are 0xa2646970667358221220 and 0x64736f6c6343XXXXXX0033 (Xs are a variable version number) which can be seen hiding in plain sight at the end of the tx.origin check address and the start of the msg.sender check:
There are 32 bytes between the markers (normally an IPFS hash with the 0x1220 being the leading bytes). One byte was needed for a PUSH operation to make the bytecode valid. With those 31 bytes and an additional 31 bytes in the spot the hash normally goes, Sam was able to expertly pack his signature verification and bonus multiplier code into a mere 62 bytes. You can read more about it in his article.
By the time you are reading this, Etherscan will have patched the issue and visiting the source code for the pinball contract might not show the original source code us players were using to solve the challenge. You can imagine the damage this bug could have caused if it were in the wrong hands. A similar situation occurred in 2019 which resulted in 26.7m TRX being stolen. Users trusted the verified source code on a website that was very popular at the time but ultimately shut down after this incident. The stakes are a lot higher on Ethereum in 2021.
Thanks for reading. Follow me on twitter if you want. And a final thanks to samczsun for creating such a fun and rewarding game.
Contract on Etherscan https://rinkeby.etherscan.io/address/0xffb9205c84d0b209c215212a3cdfc50bf1cfb0e0
Original source code
Z3 Solver for overflow factoring
EVM op code reference