Strict Ether Balance Check
Overview
- Severity: Low
- Confidence: High
- Affected Versions: All
What is the Strict Ether Balance Check vulnerability?
The Ethereum Virtual Machine allows for querying of the ether balance of any address in the platform's address space. Smart contract developers can then use ether balances of addresses in the logic of their code, for example to ensure that a balance meets some requirement before a function will fully execute. However, ether balances are subject to potential manipulation by attackers, and thus it is possible to force a situation where a strict equality check will always be false, potentially subverting the intent of a smart contract application.
The reason why Ether balances may always be manipulated is in fact due to a mechanism deliberately included in Ethereum that allows a smart contract to send its balance to any destination address when it is destroyed, so that ether held by deprecated smart contracts need not be lost or trapped forever in an unused part of the platform. An attacker can thus create a smart contract with a function that specifies a target funds address on self-destruct, and force the send of ether to that address, potentially taking advantage of the Strict Ether Balance Check vulnerability.
A real-world example: the Edgeware Lockdrop smart contract
In 2019, the Edgeware project launched a so-called "lockdrop," a means of distributing Edgeware's own tokens to users who locked up ether for a period of time. The mechanism included a Lockdrop
contract which was responsible for creating individual Lock
contracts for users who elected to participate in the lockdrop by locking a balance of ether for a specified term. The creation of the Lock
contracts was handled by the lock()
function on the Lockdrop
contract, reproduced below:
function lock(Term term, bytes calldata edgewareAddr, bool isValidator)
external
payable
didStart
didNotEnd
{
uint256 eth = msg.value;
address owner = msg.sender;
uint256 unlockTime = unlockTimeForTerm(term);
// Create ETH lock contract
Lock lockAddr = (new Lock).value(eth)(owner, unlockTime);
// ensure lock contract has all ETH, or fail
assert(address(lockAddr).balance == msg.value);
emit Locked(owner, eth, lockAddr, term, edgewareAddr, isValidator, now);
}
Line 13 of the function asserts that the ether balance of a Lock
contract must be equal to the amount that was sent to this function to create the "lockbox," which on the surface seems like a reasonable assumption. However, the mechanism for forcibly sending ether to an address as described above does not require the address to have been used before, meaning the address of a to-be-created smart contract can hold an ether balance before the contract itself gets deployed. This quirk of Ethereum, combined with the fact that smart contract address generation in Ethereum is deterministic (and can thus be calculated in advance by an adversary for new Lock
contracts) could cause the assertion to fail and thus cause the lock()
function to revert, and in this case, to become permanently unusable as the next contract address to be generated would not change due to the failure of the function call.
Further reading: Gridlock (a smart contract bug)
Technical example of vulnerable code
// SPDX-License-Identifier: Unlicense
pragma solidity 0.4.0;
contract VulnerableCrowdsale {
mapping(address => uint256) public reservationBalances;
uint8 public conversionFactor = 10;
function reserveTokens() public payable {
assert(msg.value > 0);
reservationBalances[msg.sender] = msg.value * conversionFactor;
}
function fundingReached() public returns(bool) {
return this.balance == 100 ether;
}
function obtainTokens() public {
assert(fundingReached());
// logic to mint crowdsale tokens based on reservation balance
}
// additional smart contract functionality
}
In the above example, the VulnerableCrowdsale
contract is intended to take reservations for tokens to be minted, paid for in ether. When the crowdsale has accumulated 100 ether, the users who have reserved tokens can cause the tokens to be credited to their addresses by calling the obtainTokens()
function. However, the strict balance check on line 14 in the fundingReached()
function can be trivially exploited by an attacker once the crowdsale nears completion, disallowing any user from claiming the tokens that they had reserved.
Technical example of how to fix the vulnerability
// SPDX-License-Identifier: Unlicense
pragma solidity 0.4.0;
contract UpdatedCrowdsale {
mapping(address => uint256) public reservationBalances;
uint8 public conversionFactor = 10;
function reserveTokens() public payable {
assert(msg.value > 0);
reservationBalances[msg.sender] = msg.value * conversionFactor;
}
function fundingReached() public returns(bool) {
return this.balance >= 100 ether;
}
function obtainTokens() public {
assert(fundingReached());
// logic to mint crowdsale tokens based on reservation balance
}
// additional smart contract functionality
}
In the corrected example above, the fix is simply perfoming a non-strict bounds check on the ether balance of the UpdatedCrowdsale
smart contract. This ensures that the balance check cannot be manipulated by an attacker in a way that would lock the reservation ether inside the contract.