These advanced levels exhibit a number of vulnerabilities.
This level implements a Raffle ticket contract. Users purchase tickets that add ETH to the contract, then an administrator closes the Raffle. The closing event triggers logic that randomly selects, a winner who is then awarded all of the contract's ETH.
The catch is that the administrator who closes the account might have an advantage that will tilt the odds so that the tickets he/she purchased wins the Raffle. To address this, the contract automatically disqualifies the account that closes the raffle.
This level exposes the nuances of calling a function in multiple ways. Some invocations change the msg.sender
of a call while others do not. When access control is done via the value of msg.sender
, this can lead to security vulnerabilities. In addition, the level exposes issues in wrapping complex calls with an abstraction that often hides what is going on underneath to a developer, leading to disastrous consequences. In this case, there are times when a call to blockhash(uint)
will actually return 0 rather than the expected hash of a given block number.
The level contract's constructor is shown below. It has several variables defined including:
fee
: 0.1 ether to playadmin:
address that creates the contract and is set in the constructorwinningTicket
: a 4-byte value that is calculated when Raffle is closedraffleStopped
: a bool that determines whether the Raffle is closedpotentialWinner
mapping to disqualify user who closes RaffleticketNumbers
for users calculated and stored in mappingcontract Raffle is CtfFramework {
uint256 constant fee = 0.1 ether;
address private admin;
bytes4 private winningTicket;
uint256 private blocknum;
uint256 public ticketsBought;
bool public raffleStopped;
mapping(address=>uint256) private rewards;
mapping(address=>bool) private potentialWinner;
mapping(address=>bytes4) private ticketNumbers;
constructor(address _ctfLauncher, address _player) public payable CtfFramework(_ctfLauncher, _player) {
rewards[address(this)] = msg.value;
admin = msg.sender;
}
The purchasing function, buyTicket()
, collects the fee from the user and calculates the user's ticketNumber
as a function of ETH sent. Note that it also clears out the winningTicket
so that it can be sure the value is generated fresh when the raffle closes. It also sets the blocknum
to the current block.number
plus 1. This will eventually be used to generate the random numbers that will determine the Raffle winner.
function buyTicket() external payable ctf{
if(msg.value >= fee){
winningTicket = bytes4(0);
blocknum = block.number+1;
ticketsBought += 1;
raffleStopped = false;
rewards[msg.sender] += msg.value;
ticketNumbers[msg.sender] = bytes4((msg.value - fee)/10**8);
potentialWinner[msg.sender] = true;
}
}
This function closes the raffle, calculates the winningTicket, and disqualifies the account that closes the Raffle from winning. Note that it also requires that the current block.number
exceed the blocknum
value (which is the block.number
of the last ticket purchased). Unfortunately, there is something wrong with how winningTicket
is being calculated. It's what caused the hack of the SmartBillions lottery which you can read about here and here.
function closeRaffle() external ctf{
require(ticketsBought > 0);
require(!raffleStopped);
require(blocknum != 0);
require(block.number > blocknum);
require(winningTicket == bytes4(0));
require(msg.sender==admin || rewards[msg.sender] > 0);
winningTicket = bytes4(blockhash(blocknum));
potentialWinner[msg.sender] = false;
raffleStopped = true;
}
The collectReward()
call can only be invoked after closeRaffle()
is called and raffleStopped
is set to true
. Only participants who have not been disqualified can collect the reward.
function collectReward() external payable ctf{
require(raffleStopped);
require(potentialWinner[msg.sender]);
rewards[address(this)] += msg.value;
if(winningTicket == ticketNumbers[msg.sender]){
msg.sender.transfer(rewards[msg.sender]);
msg.sender.transfer(rewards[address(this)]);
rewards[msg.sender] = 0;
rewards[address(this)] = 0;
}
}
Finally, the payable fallback function is defined. If no ETH is sent, the raffle is closed. Note that rather than call the function as an internal function call, the call is invoked via this.closeRaffle()
. When the call is made in this manner, msg.sender
becomes the contract address within the closeRaffle()
call due to "this
" being used. As a result, the original sender is not disqualified from Raffle. If ETH is sent to the fallback function and the amount exceeds the fee
for buying a ticket, then buyTicket()
is called. Otherwise, the contract calls collectReward()
to attempt to send the proceeds to the raffle winner.
function () public payable ctf{
if(msg.value>=fee) {
this.buyTicket();
} else if(msg.value == 0) {
this.closeRaffle();
}
else {
this.collectReward(); }
}
}
First, purchase a ticket through the fallback function by sending enough ETH to the contract. Sending exactly the cost of the fee (0.1 ETH) will ensure that the calculated ticketNumber
(msg.value-fee / 10^8
) is 0. This is the value of the blockhash for blocks that are more than 256 blocks old.
As described in the fallback function step, by using "this
" in the call this.closeRaffle()
, the msg.sender
changes from the address invoking the fallback to the contract's address itself. As with previous levels, access to the contract is initially allowed for only 2 addresses: the CTF launcher and the player. Thus, this call will be denied because the contract, isn't authorized to call itself externally. To allow this call to happen, we must call ctf_challenge_add_authorized_sender(address)
to add the contract as its own authorized sender.
We must now wait 256 blocks from the block that the ticket was purchased, before we can close the raffle in a way so that the winningTicket
matches our ticketNumber
as a result of blockhash(blocknum)
will also be 0. For example, if the ticket was purchased on block 5689703 then wait until block 5689958 has been confirmed so that blockhash(blocknum)
will return 0 and match the ticketNumber.
You must not buy additional tickets while waiting otherwise the blocknum
will be updated and you'll need to wait another 256 blocks.
Sending 0 ETH to the contract or calling a non-existent function with 0 ETH will result in invoking the fallback function and cause the contract to issue a this.closeRaffle()
on itself. By setting msg.sender
to the contract address itself (instead of your wallet), the contract disqualifies itself from collecting the reward rather than your wallet. Note that because the contract address initially contains a balance, it passes the following restriction:
require(msg.sender==admin || rewards[msg.sender] > 0);
Use any of the methods below to have the Raffle contract call closeRaffle()
via its fallback function
Sending 0 ETH to the contract or calling a non-existent function with 0 ETH will result in invoking the fallback function and cause the contract to issue a this.closeRaffle()
on itself. By setting msg.sender
to the contract address itself (instead of your wallet), the contract disqualifies itself from collecting the reward rather than your wallet. Note that because the contract address initially contains a balance, it passes the following restriction:
Go back to MyCrypto and bring up the contract. View the raffleStopped
boolean flag to validate that the raffle has been stopped.
If you are unable to send 0 ETH to the contract address within Metamask, you can copy and paste the contract code into Remix, compile it, then in the "Deploy & Run Transactions", fill in the "At Address" box with your level contract's address, and click on the "At Address" button.
Then, open the drop-down for the contract and click on the fallback.
Calling a non-existent function with 0 ETH will also cause the fallback function to execute with 0 ETH. Within the CTF, bring up the address and ABI of the Raffle contract and use them to bring the contract up on MyCrypto. After pasting in the ABI, modify one of the calls (e.g. closeRaffle) to create a bogus function call in the ABI.
Then, access the contract. In the dropdown, select the bogus call and send a transaction into the Raffle contract with it. This will invoke the fallback.
Since we have purchased a ticket that has given us a ticketNumber
of 0 and because blockhash(blocknum)
will also be 0 after 256 blocks have passed, when closeRaffle()
is called, we will have the winning ticket. To collect our reward, we only need to call collectReward()
to complete the level.
As before,
You've identified and exploited a smart contract that suffers from a number of security issues including those that revolve around the use of the "this
" keyword and the use of blockhash()
for generating random numbers . While the scenario may seem contrived, errors like these have brought down actual smart contracts, leading to significant financial loss.
One can actually solve the level programmatically using a single contract. To do so, define an interface that allows you to call into the original Raffle contract.
interface Raffle {
function buyTicket() external payable;
function closeRaffle() external;
function collectReward() external payable;
function skimALittleOffTheTop(uint256 _value) external;
function () external payable;
}
Then, implement calls that perform the steps above, waiting 256 blocks between buying a ticket and closing the raffle. Note that you must add the attacking contract's address as an authorized sender to the Raffle contract in order for this to work.
contract RaffleAttack {
address owner;
address raffleContractAddr = 0xF5...fe9;
Raffle raffle = Raffle(raffleContractAddr);
constructor() public {
owner = msg.sender;
}
function buy() external payable {
require(msg.value == 0.1 ether);
raffle.buyTicket.value(0.1 ether)();
}
function close() external {
raffleContractAddr.call.value(0 ether)();
}
function collect() external {
raffle.collectReward();
}
function get_money() external {
selfdestruct(owner);
}
function () public payable {
}
}