These advanced levels exhibit a number of vulnerabilities.
This level implements a scratchcard lottery application as a smart contract. Players purchase tickets via ETH and the contract randomly selects whether or not a particular scratchcard is a winner. The contract checks to see if it is being called by a wallet and will deny requests from contracts. Unfortunately, it has a fatal flaw: one that is easily made when the underlying complexity of a system such as Ethereum is hidden from the programmers who are building on top of it.
This is a pattern that should give all of us pause when we decide to make blockchain systems part of our mission-critical infrastructure.
To understand this level, recall in the HeadsOrTails
contract that we could instantiate a contract to interact with the victim to programmatically guess coin flips correctly and win the level. Since contracts have full access to the blockchain state, a contract can check to see if it's being called by another contract rather than a wallet. Specific to this level, the Scratchcard contract can view an address that is calling it and see if it has been created by another transaction. By checking the msg.sender
of the incoming transaction and refusing any addresses that are clearly contracts, the Scratchcard contract can ensure only wallets are allowed.
One way to check if an address represents a contract rather than a wallet is through a low-level EVM call that Solidity does not provide an interface to. However, Solidity does allow you to embed such calls in a contract. In the code snippet below, a Solidity library call embeds assembly to check whether an address has code associated with it via extcodesize()
. The isContract()
call takes in an address and returns a boolean that indicates whether the address is a contract by checking the return value of extcodesize()
.
library Address {
function isContract(address account) internal view returns (bool) {
uint256 size;
assembly { size := extcodesize(account) }
return size > 0;
}
}
The contract constructor is below. As the code shows, a modifier that leverages the library call is defined to check whether the caller address is a contract or not. Note that the variable cost
, which indicates the cost of a ticket, is a uint256
.
contract Scratchcard is CtfFramework{
mapping(address=>uint256) private winCount;
uint256 private cost;
using Address for address;
constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) { }
modifier notContract(){
require(!msg.sender.isContract(), "Contracts Not Allowed");
_;
}
Given this modifier, the contract call play()
, shown below, is protected to ensure only wallets can call it. The only way to win a particular round is to send in an amount of ETH equal to now % 10^8
. When one does, the winCount
for the sender is incremented by 1 and the ticket cost is returned to the sender. Winning a round is conceivably hard for wallets to do since they must commit their ETH before the current block's timestamp is known. Guessing correctly would conceivably have 1 in 10^8 chance of happening (unless you're a miner).
function play() public payable notContract ctf {
if ((now%10**8)*10**10 == msg.value) {
winCount[msg.sender] += 1;
cost = msg.value;
msg.sender.transfer(cost);
} else {
cost = 0;
winCount[msg.sender] = 0;
}
}
The main jackpot code is below. There are two functions: checkIfMegaJackpotWinner()
and collectMegaJackpot()
. The former will return a boolean value that lists whether you've won 25 rounds of play()
. The latter will reward the sender up to twice the cost of a single ticket if they have won 25 rounds and reset their winCount
to 0.
function checkIfMegaJackpotWinner() public view returns(bool) {
return(winCount[msg.sender]>=25);
}
function collectMegaJackpot(uint256 _amount) public notContract ctf {
require(checkIfMegaJackpotWinner(), "User Not Winner");
require(2 * cost - _amount > 0, "Winners May Only Withdraw Up To 2x Their Scratchcard Cost");
winCount[msg.sender] = 0;
msg.sender.transfer(_amount);
}
Unfortunately, the collectMegaJackpot()
call has a fatal flaw similar to SITokenSale.
Given this contract, answer the following questions for your lab notebook before continuing
2 * cost - _amount > 0
)?_amount
would cause the second require statement to fail?The level requires that you discover your wallet's current nonce so that you can predict the address of the next contract (the attack one) that gets instantiated. To do so, go to Etherscan and locate your wallet's transactions. Click on the most recent one.
Within the transaction output, locate the nonce that was used for it. The nonce that will be used for the next transaction (the attack contract creation one) will be one greater.
Alternatively, you may also use Metamask to find the nonce as shown below.
Once we've gotten our wallet's current nonce (e.g. 269 based on the screenshots), we can use the mk.py
script from the RainyDayFund level to calculate what our next two contract addresses will be. Why two? The current nonce will be used to add your attacking contract's address as an authorized sender to the Scratchcard contract while the next nonce (e.g. 270) will be used to launch the attacking contract.
python mk.py 0xe9e7034AeD5CE7f5b0D281CFE347B8a5c2c53504 269 271
nonce: 269 contract: 0x02b2814f9FeaD2208b35423e8bEfd5E46F9a51A2
nonce: 270 contract: 0x7452dC2D608b2D2dA92F3C41a349A1b094026Cfa
To allow the attacking contract to access the victim contract, we can then add it using MyCrypto.
Our attack must leverage the fact that the size of our contract code is undefined until after we have created the contract on the blockchain. Thus, there is a window of time as the contract is being instantiated to interact with the victim. By playing the 25 rounds during that time, we can then accrue wins as a contract by calling play()
. Then, we can collect the entire contract balance to win the level.
As part of the attacking contract, you will need to define an interface to Scratchcard, then use your Scratchcard level's address within your attacking contract before using code to invoke the play()
call. Helpful snippets are shown below that declare the interface to Scratchcard:
interface Scratchcard {
function play() external payable;
function checkIfMegaJackpotWinner() external returns(bool);
function collectMegaJackpot(uint256 _amount) external;
function () external payable;
}
and call play()
in the victim with a certain amount of ETH (val
).
scratch.play.value(val)();
As you construct your contract, you will need to ensure that the call is placed in the appropriate location in the contract code, that it is called 25 times with the correct amount specified for val
, and that you call collectMegaJackpot()
to empty out the contract. Note that you must make any function in the attacking contract that calls play()
in the Scratchcard contract a payable
function to receive the payout of each round.
Finally, if you'd like to get all of your ETH back, you should also include a call that performs a selfdestruct()
on the attacking contract which sends ETH back to your wallet.
Because the winning Scratchcard purchase value can range anywhere between 1 wei to 1 ETH, we will need to deploy our attacking contract with *at least* 1 ETH so it can issue the initial call to play().
If the attacking contract is correct, all 25 rounds will have happened. To see this, go to Metmask and click on the transaction that created the attacking contract and bring the transaction up on Etherscan.
Bring up the internal transactions for the contract. See the 25 scratchcard purchases and the call to the final collectMegaJackpot()
call.
Retrieve the balance of ETH from the attacking contract (4.5 ETH).
This is the last level and you have now acquired more knowledge about the security (and insecurity) of Solidity smart contracts than many proponents of blockchain care to learn. If you have the fortunate (or unfortunate) chance to program smart contracts for a living, hopefully you don't make the same mistakes that you've seen being made in this CTF. At a minimum, you will hopefully understand the risks involved with smart contracts.