티스토리 뷰

1. 문제

 

The Ethernaut

The Ethernaut is a Web3/Solidity based wargame played in the Ethereum Virtual Machine. Each level is a smart contract that needs to be 'hacked'. The game is 100% open source and all levels are contributions made by other players.

ethernaut.openzeppelin.com

This level features a `CryptoVault` with special functionality, the `sweepToken` function. This is a common function used to retrieve tokens stuck in a contract. The `CryptoVault` operates with an `underlying` token that can't be swept, as it is an important core logic component of the `CryptoVault`. Any other tokens can be swept.

The underlying token is an instance of the DET token implemented in the `DoubleEntryPoint` contract definition and the `CryptoVault` holds 100 units of it. Additionally the `CryptoVault` also holds 100 of `LegacyToken LGT`.

In this level you should figure out where the bug is in `CryptoVault` and protect it from being drained out of tokens.

The contract features a `Forta` contract where any user can register its own `detection bot` contract. Forta is a decentralized, community-based monitoring network to detect threats and anomalies on DeFi, NFT, governance, bridges and other Web3 systems as quickly as possible. Your job is to implement a `detection bot` and register it in the `Forta` contract. The bot's implementation will need to raise correct alerts to prevent potential attacks or bug exploits.

Things that might help:

- How does a double entry point work for a token contract?

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

import "openzeppelin-contracts-08/access/Ownable.sol";
import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";

interface DelegateERC20 {
  function delegateTransfer(address to, uint256 value, address origSender) external returns (bool);
}

interface IDetectionBot {
    function handleTransaction(address user, bytes calldata msgData) external;
}

interface IForta {
    function setDetectionBot(address detectionBotAddress) external;
    function notify(address user, bytes calldata msgData) external;
    function raiseAlert(address user) external;
}

contract Forta is IForta {
  mapping(address => IDetectionBot) public usersDetectionBots;
  mapping(address => uint256) public botRaisedAlerts;

  function setDetectionBot(address detectionBotAddress) external override {
      usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
  }

  function notify(address user, bytes calldata msgData) external override {
    if(address(usersDetectionBots[user]) == address(0)) return;
    try usersDetectionBots[user].handleTransaction(user, msgData) {
        return;
    } catch {}
  }

  function raiseAlert(address user) external override {
      if(address(usersDetectionBots[user]) != msg.sender) return;
      botRaisedAlerts[msg.sender] += 1;
  } 
}

contract CryptoVault {
    address public sweptTokensRecipient;
    IERC20 public underlying;

    constructor(address recipient) {
        sweptTokensRecipient = recipient;
    }

    function setUnderlying(address latestToken) public {
        require(address(underlying) == address(0), "Already set");
        underlying = IERC20(latestToken);
    }

    /*
    ...
    */

    function sweepToken(IERC20 token) public {
        require(token != underlying, "Can't transfer underlying token");
        token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
    }
}

contract LegacyToken is ERC20("LegacyToken", "LGT"), Ownable {
    DelegateERC20 public delegate;

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }

    function delegateToNewContract(DelegateERC20 newContract) public onlyOwner {
        delegate = newContract;
    }

    function transfer(address to, uint256 value) public override returns (bool) {
        if (address(delegate) == address(0)) {
            return super.transfer(to, value);
        } else {
            return delegate.delegateTransfer(to, value, msg.sender);
        }
    }
}

contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {
    address public cryptoVault;
    address public player;
    address public delegatedFrom;
    Forta public forta;

    constructor(address legacyToken, address vaultAddress, address fortaAddress, address playerAddress) {
        delegatedFrom = legacyToken;
        forta = Forta(fortaAddress);
        player = playerAddress;
        cryptoVault = vaultAddress;
        _mint(cryptoVault, 100 ether);
    }

    modifier onlyDelegateFrom() {
        require(msg.sender == delegatedFrom, "Not legacy contract");
        _;
    }

    modifier fortaNotify() {
        address detectionBot = address(forta.usersDetectionBots(player));

        // Cache old number of bot alerts
        uint256 previousValue = forta.botRaisedAlerts(detectionBot);

        // Notify Forta
        forta.notify(player, msg.data);

        // Continue execution
        _;

        // Check if alarms have been raised
        if(forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
    }

    function delegateTransfer(
        address to,
        uint256 value,
        address origSender
    ) public override onlyDelegateFrom fortaNotify returns (bool) {
        _transfer(origSender, to, value);
        return true;
    }
}

2. 풀이

 문제를 요약하면, 'IDetectionBot을 구현한 뒤 Forta 컨트랙트에 등록하여 자금이 유출되는 것을 막아라!'입니다. 문제를 해결하려면 어디에 취약점이 있는지 먼저 파악해야겠죠?

취약점

 CryptoVault 컨트랙트는 100개의 DET 토큰과 100개의 LGT 토큰을 보관하고 있습니다. 이 중 DET 토큰은 절대로 인출이 되어서는 안 된다고 문제에 명시되어 있습니다. 다른 토큰은 괜찮다고 하네요.

 그러면 우선 LGT를 털어버립시다. sweepToken 함수를 호출할 때 파라미터 token은  DET만 아니면 됩니다. 그러면 token의 transfer 함수를 호출하여 sweptTokensRecipient(player)에게 컨트랙트의 모든 잔액 100 LGT를 전송합니다. 이걸로 CrytoVault가 가진 모든 LGT를 탈취한 걸까요? 아직 실행되어야 할 로직이 많이 남아있으니 조금 더 지켜봅시다.

function sweepToken(IERC20 token) public {
    require(token != underlying, "Can't transfer underlying token");
    token.transfer(sweptTokensRecipient, token.balanceOf(address(this)));
}

 LGT의 transfer 함수는 통상적인 transfer 함수와 동작이 조금 다릅니다. delegate가 zero address가 아니라면 delegate의 delegateTransfer 함수를 호출합니다. 여기서 LGT의 delegate는 DET로 설정되어 있습니다. 따라서 DET의 delegateTransfer 함수를 호출하게 됩니다.

DelegateERC20 public delegate;

function transfer(address to, uint256 value) public override returns (bool) {
    if (address(delegate) == address(0)) {
        return super.transfer(to, value);
    } else {
        return delegate.delegateTransfer(to, value, msg.sender);
    }
}

 DET의 delegateTransfer는 내부 함수 _transfer를 origSender로부터 to에게 value 만큼의 토큰을 전송합니다. 문제는 이 함수가 호출되기만 하면 origSender의 허가 없이도 origSender의 자금을 빼갈 수 있다는 것입니다. LGT의 transfer 함수에서 DET의 delegateTransfer를 호출할 때 전달된 인수는 player의 주소(to), 100 * 10 ** 18(value), 그리고 CryptoVault의 주소(origSender)입니다. 따라서 CryptoVault가 가진 100 DET를 player에게 전송하게 됩니다. 아까 DET는 절대로 인출되면 안 된다고 했는데 이렇게 손쉽게 인출이 가능합니다. 

function delegateTransfer(
    address to,
    uint256 value,
    address origSender
) public override onlyDelegateFrom fortaNotify returns (bool) {
    _transfer(origSender, to, value);
    return true;
}

 delegateTransfer 함수에는 2개의 변경자가 붙어 있습니다. onlyDelegateFrom을 먼저 살펴보면, 함수를 호출한 sender가 delegateFrom이어야만 함수 로직을 실행하도록 구현되어 있습니다. 여기서 delegateFrom이 LGT로 초기화되어 있기 때문에 LGT가 호출한 delegateTransfer는 아무 문제 없이 실행이 됩니다.

modifier onlyDelegateFrom() {
    require(msg.sender == delegatedFrom, "Not legacy contract");
    _;
}

 그 다음으로 fortaNotify 변경자입니다. 먼저 Forta 컨트랙트에 등록된 player의 탐지봇을 가져와서 함수의 로직을 실행하기 전에 누적된 경고수 previousValue를 기록합니다. 그리고 player에게 msg.data를 알리고 함수 로직을 실행합니다. 마지막으로 누적된 경고수를 새로 가져와 함수 로직을 실행하기 전에 기록된 previouseValue보다 커졌는지, 경고가 발생했는지 확인하고 경고가 발생했다면 트랜잭션을 revert 합니다.

modifier fortaNotify() {
    address detectionBot = address(forta.usersDetectionBots(player));

    // Cache old number of bot alerts
    uint256 previousValue = forta.botRaisedAlerts(detectionBot);

    // Notify Forta
    forta.notify(player, msg.data);

    // Continue execution
    _;

    // Check if alarms have been raised
    if(forta.botRaisedAlerts(detectionBot) > previousValue) revert("Alert has been triggered, reverting");
}
contract Forta is IForta {
  mapping(address => IDetectionBot) public usersDetectionBots;
  mapping(address => uint256) public botRaisedAlerts;

  function setDetectionBot(address detectionBotAddress) external override {
      usersDetectionBots[msg.sender] = IDetectionBot(detectionBotAddress);
  }

  function notify(address user, bytes calldata msgData) external override {
    if(address(usersDetectionBots[user]) == address(0)) return;
    try usersDetectionBots[user].handleTransaction(user, msgData) {
        return;
    } catch {}
  }

  function raiseAlert(address user) external override {
      if(address(usersDetectionBots[user]) != msg.sender) return;
      botRaisedAlerts[msg.sender] += 1;
  } 
}

 문제 해결의 실마리가 보였습니다. IDetectionBot을 구현하여 Forta 컨트랙트에 등록하고 notify 함수가 호출되었을 때 Forta 컨트랙트의 raiseAlert 함수를 호출하면 트랙잭션이 revert 되므로 누구도 DET를 탈취할 수 없을 것입니다.

IDetectionBot 구현

 구현은 굉장히 간단합니다. Forta 컨트랙트의 notify 함수가 호출되고 FakeDetectionBot의 handleTransaction 함수가 호출되었을 때 Forta 컨트랙트의 raiseAlet 함수를 호출하면 됩니다.

contract FakeDetectionBot is IDetectionBot {
    IForta public forta;

    constructor(address fortaAddress) {
        forta = IForta(fortaAddress);
    }

    function handleTransaction(address user, bytes calldata) external override {
        forta.raiseAlert(user);
    }
}

Foundry script 실행

 FakeDetectionBot 컨트랙트를 배포하고, 배포된 컨트랙트 주소를 Forta 컨트랙트에 등록합니다. 이때 privateKey는 인스턴스를 생성할 때 사용한 계정의 프라이빗 키와 동일해야 합니다.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Script, console} from "forge-std/Script.sol";
import  "../src/26.DoubleEntryPoint.sol";

contract DoubleEntryPointScript is Script {
    function setUp() public {}

    function run() public {
        uint256 privateKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(privateKey);

        DoubleEntryPoint target = DoubleEntryPoint(0x3192Cc8BC30bd4F6e0D99DCa4659b5C3c1732E2B);

        Forta forta = target.forta();

        FakeDetectionBot fakeDetectionBot = new FakeDetectionBot(address(forta));

        forta.setDetectionBot(address(fakeDetectionBot));

        vm.stopBroadcast();
    }
}
$ forge script script/26.DoubleEntryPoint.sol:DoubleEntryPointScript --rpc-url sepolia --broadcast -vvvv

인스턴스 제출

 

최근에 올라온 글
최근에 달린 댓글
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Total
Today
Yesterday
글 보관함