[SCH] DoS Attack

frenchkebab·2023년 5월 13일
0

sch

목록 보기
3/3

Smart Contract에서 DoS 공격은 기본적으로 해당 컨트랙트가 정상적으로 작동하지 않도록 고장을 나게 하는 공격이다.

기본적으로 owner가 아닌 주소를 등록했을 때, 해당 주소와 external한 interaction이 있을 때 발생하는 문제이다.

아래의 예시들을 살펴보자

DoS Attack

Example 1) external call - transfer (receive 없는 contract로)

https://solidity-by-example.org/hacks/denial-of-service/
여기서 자세한 예시를 볼 수 있다.

contract KingOfEther {
    address public king;
    uint public balance;

    function claimThrone() external payable {
        require(msg.value > balance, "Need to pay more to become the king");

        (bool sent, ) = king.call{value: balance}("");
        require(sent, "Failed to send Ether");

        balance = msg.value;
        king = msg.sender;
    }
}

contract Attack {
    KingOfEther kingOfEther;

    constructor(KingOfEther _kingOfEther) {
        kingOfEther = KingOfEther(_kingOfEther);
    }

    // You can also perform a DOS by consuming all gas using assert.
    // This attack will work even if the calling contract does not check
    // whether the call was successful or not.
    //
    // function () external payable {
    //     assert(false);
    // }

    function attack() public payable {
        kingOfEther.claimThrone{value: msg.value}();
    }
}

Attack 컨트랙트가 king에 등록되고 나면, 문제가 발생한다.
receive함수가 없어, ETH송금을 받을 수 없기 때문이다.

claimThrone을 호출할 때마다 이 부분에서 revert가 나게 될 것이다.

        (bool sent, ) = king.call{value: balance}("");
        require(sent, "Failed to send Ether");

Example 2) ERC20 Direct Transfer

아래의 예시를 보자

uint256 public poolBalance; // deposit 함수를 호출하면 증가

function deposit(uint256 amount) external {
  	require(amount > 0);
	token.transferFrom(msg.sender, address(this), amount);
  	poolBalance = poolBalance + amount;
}


function flashLoan(uint256 amount) external {
	require(amoutn > 0);
  
  	uint256 balanceBefore = token.balanceOf(address(this)));
  	require(balnaceBefore >= amount);
  
	require(poolBalance == balanceBefore);
  
	// ...  (대출 및 상환 확인 로직)
}

여기서 deposit을 이용하지 않고 ERC20 transfer를 이용하여 이 contract로 토큰을 전송한다면 token.balanceOf(address(this)));는 증가하지만 poolBalance는 그대로이다.

	require(poolBalance == balanceBefore);

따로 해당 컨트랙트에서 토큰을 꺼내는 로직이 존재하지 않는다면,
이 부분에서 계속 revert가 나게 되고 해당 flashLoan 함수의 기능이 아예 막혀버리게 된다.

Example 3) 63/64 Rule (EIP-150)

63/64 Rule 이라고 불리는 EIP-150에 대해서는 이 글을 참조하면 된다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Denial {

    address public partner; // withdrawal partner - pay the gas, split the withdraw
    address 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 / 100;
        // perform a call without checking return
        // The recipient can revert, the owner will still get their share
        partner.call{value:amountToSend}("");
        payable(owner).transfer(amountToSend);
        // keep track of last withdrawal time
        timeLastWithdrawn = block.timestamp;
        withdrawPartnerBalances[partner] +=  amountToSend;
    }

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

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

출처 : Ethernaut

위의 Example 2의 공격을 피하기 위해, call 과정에서 revert가 나더라도 계속되게끔 (call의 return value에 대해 체크하지 않으므로) 코드가 짜여있다.

하지만 partner에 등록된 주소가 contract이고, 해당 contractreceive()함수에서 만약 무한 루프가 돌아서 gas를 소진하게 되면 아래의 코드가 동작하지 않게 된다.
(out of gas)

EIP-150에 따르면 CALL을 하였을 때 Callee에게는 최대 Caller에게 남은 gas * 63/64 만큼 전달을 할 수 있다.
(따로 forward해줄 gas양을 명시하지 않았을 경우)

따라서, partner.call{value:amountToSend}(""); 에서 63/64 만큼이 소진되고 그 아래의 코드에서 남아있는 1/64 만큼의 gas 이상을 소비하게 되면 이 함수는 영원히 revert가 나게 될 것이다.

이는 call 호출 시 전달해줄 gas 양을 명시적으로 제한해놓거나,
아니면 gasLimit1/64만큼의 gas로도 아래 코드들이 동작하게끔 만들어 피할 수 있다.

Example 4) gas limit

ETH로 deposit을 받아서 해당 개수만큼 token을 나눠주는 contract의 예시를 들어보자.


address[] userList;
uint256[] tokenAmountList;

function put() external payable {
 	require(msg.value > 0);
	list.push(msg.sender);
  	tokenAmountList.push(msg.value);
}

function distributeTokens() external {
	require(msg.sender == owner);
  	
  	for(uint256 i = 0; i < userList.length; i++) {
      	token.transfer(userList[i], tokenAmountList[i]);
    }
}

여기서 만약 공격자가 1 weiput()함수를 수없이 호출했다면?

distributeTokensfor 문이 돌다가 out of gas로 revert가 나게 될 것이다.
(해당 ETH는 영원히 묶이게 된다.)

Prevention - 아무도 믿지 마!!

1. owner가 아닌 address 변수는 무조건 attacker라고 생각할 것

위의 예시에서 보면 수시로 바뀔 수 있는 address 변수들이 악의적인 contract일 경우, 여러 가지 공격이 가능하게 된다.

따라서 항상 최악의 상황을 가정해서 로직을 만들어 나가야 한다.

2. withdraw는 무조건 external 함수로 따로 빼 놓을것

환불/반환 등의 로직은 받고싶은 사람이 알아서 요청하도록 해 두어야 이렇게 DoS꼴이 나는 것을 막을 수 있다.

3. external call에서는 반드시 gas 제한을 둘것

누구일지도 모르는 address에 냅다가 call을 때려버리면 나머지 코드가 더이상 실행이 되지 않을 수도 있다.

무조건 gas의 양을 제한하도록 하자.
(최악의 경우 63/64를 해당 callee가 다 소모해 버릴 수도 있다.)

4. unbound array를 쓰지 말 것

배열의 길이가 임의로 늘어날 수 있을 경우에는, 배열의 순회 자체가 불가능해질 수 있다.

따라서 이 부분을 조심하여야 한다.

profile
Blockchain Dev Journey

0개의 댓글