Smart Contract에서 DoS 공격은 기본적으로 해당 컨트랙트가 정상적으로 작동하지 않도록 고장을 나게 하는 공격이다.
기본적으로 owner
가 아닌 주소를 등록했을 때, 해당 주소와 external
한 interaction이 있을 때 발생하는 문제이다.
아래의 예시들을 살펴보자
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");
아래의 예시를 보자
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
함수의 기능이 아예 막혀버리게 된다.
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이고, 해당 contract의 receive()함수에서 만약 무한 루프가 돌아서 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 양을 명시적으로 제한해놓거나,
아니면 gasLimit
의 1/64
만큼의 gas로도 아래 코드들이 동작하게끔 만들어 피할 수 있다.
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 wei
씩 put()
함수를 수없이 호출했다면?
distributeTokens
의 for 문이 돌다가 out of gas로 revert가 나게 될 것이다.
(해당 ETH는 영원히 묶이게 된다.)
위의 예시에서 보면 수시로 바뀔 수 있는 address
변수들이 악의적인 contract일 경우, 여러 가지 공격이 가능하게 된다.
따라서 항상 최악의 상황을 가정해서 로직을 만들어 나가야 한다.
환불/반환 등의 로직은 받고싶은 사람이 알아서 요청하도록 해 두어야 이렇게 DoS꼴이 나는 것을 막을 수 있다.
누구일지도 모르는 address에 냅다가 call
을 때려버리면 나머지 코드가 더이상 실행이 되지 않을 수도 있다.
무조건 gas
의 양을 제한하도록 하자.
(최악의 경우 63/64
를 해당 callee가 다 소모해 버릴 수도 있다.)
배열의 길이가 임의로 늘어날 수 있을 경우에는, 배열의 순회 자체가 불가능해질 수 있다.
따라서 이 부분을 조심하여야 한다.