The Ethernaut CTF Solutions | 26 - Double Entry Point

The Ethernaut CTF Solutions | 26 - Double Entry Point

Securing Contracts: Employing Forta Alerts Bots to Mitigate Contract Exploits

ยท

7 min read

Goals

The Contract

A pretty long code base this time, which is a good exercise to divide the task into smaller parts so we don't get overwhelmed. This is closer to a proper security review with multiple contracts and interactions between them. It also introduces us to the Forta bots, which are an important tool to increase the safety of the overall ecosystem.

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

// import "openzeppelin-contracts-08/access/Ownable.sol";
// import "openzeppelin-contracts-08/token/ERC20/ERC20.sol";
import {Ownable} from "../helpers/Ownable-05.sol";
import {ERC20} from "@openzeppelin/contracts/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;
    }
}

The hack

The goal of the DoubleEntryPoint level is to find the bug in CryptoVault contract and to protect it from being drained by implementing a Forta bot.

Find the bug

The code given is a bit complex, so let's break it done for clarity. We have 4 contracts to work with:

  1. Forta - Related to the AlertBot we need to set up.

  2. CryptoVault - The contract we need to protect. Handles the underlying token.

  3. LegacyToken - A modified ERC20 token (LGT).

  4. DoubleEntryPoint - The instance we are given and the so-called underlying ERC20 token (DET).

We know that the CryptoVault is vulnerable. It contains 100 LGT and 100 DET.

Let's forget the Forta contract for now. We will come back to it later.

Hard to say what's wrong with the CryptoVaults contract at first glance. So let's focus on the token contracts.

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

    // ... access protected functions

    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);
        }
    }
}

The LGT token has a custom transfer function that delegates the transfer to another contract if delegate variable is set, the delegate being the DoubleEntryPoint contract.

In the DET token, we have a modifier and a delegateTransfer() function (we don't need anything related to Forta at this point):

contract DoubleEntryPoint is ERC20("DoubleEntryPointToken", "DET"), DelegateERC20, Ownable {

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

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

The modifier is making sure that only the delegatedFrom contract can call the delegateTransfer function. The delegateTransfer function is the one that is called by the LegacyToken contract.

Now, back to the CryptoVault contract. There is nothing really interesting except the sweeppToken() function.

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

This function allows to transfer the whole balance of any token from the CryptoVault contract to the sweptTokensRecipient, as long as it is not the underlying token.

OK. But... what if we try to transfer the LGT token instead?

The CryptoVault::sweepToken will call the LegacyToken::transfer which will forward the call to the DoubleEntryPoint::delegateTransfer.

At this point, msg.sender is the CryptoVault contract and value is the CryptoVault LGT's balance (equal to the DET balance). So this will bypass the onlyDelegateFrom check. Only this is not transferring the LGT token anymore, but the underlying DET token since we are inside the DET contract!

Here is the chain of events illustrated:

  1. CryptoVault::sweepToken(LGT)

  2. LegacyToken::transfer({ to: sweptTokenRecipient, value: LGT.balanceof(CryptoVault) })

  3. DoubleEntryPoint::delegateTransfer({ to: sweptTokenRecipient, value: LGT.balanceof(CryptoVault), origSender: CryptoVault })

Implement the Forta bot

To mitigate this vulnerability, we need to implement a Forta AlertBot that will trigger an alert if origSender == cryptoVault in the delegateTransfer function, via the fortaNotify modifier.

In the Forta contract, there is a setDetectionBot function that we can use to implement our bot. And we have the following IDetectionBot interface:

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

So we have to create the logic for the handleTransaction() function that will call the IForta.raiseAlert function if origSender == cryptoVault.

DoubleEntryPoint::fortaNotify() is passing msg.data to the Forta::notify() function.

  • Initially, msg.data is the data for the function delegateTransfer(address to, uint256 value, address origSender) function.

  • Then, in Forta::notify(), the data is updated to the data of the handleTransaction(user, msgData) function.

So that will look something like this:

handleTransaction(user, msgData);

Where msgData == delegateTransfer(to, value, origSender);

So to access the origSender variable, we need to understand how a calldata is structured.

PositionBytes/LengthTypeValue
0x004bytes4selector of handleTransaction()
0x0432addressuser address
0x2432uint256Offset of msgData
0x4432uint256Length of msgData
0x644bytes4selector of delegateTransfer()
0x6832addressto address
0x8832uint256value parameter
0xa832address\=> origSender address

We can use a bit of assembly to access the origSender variable:

assembly {
    origSender := calldataload(0xa8)
}

Solution

Here is the full solution for the AlertBot contract:

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

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

interface IForta {
    function raiseAlert(address user) external;
}

contract AlertBot is IDetectionBot {
    address private immutable cryptoVault;

    constructor(address _cryptoVault) public {
        cryptoVault = _cryptoVault;
    }

    function handleTransaction(
        address user,
        bytes calldata msgData
    ) external override {
        address origSender;
        assembly {
            origSender := calldataload(0xa8)
        }

        if (origSender == cryptoVault) {
            IForta(msg.sender).raiseAlert(user);
        }
    }
}

Next, let's write the script that we will use to deploy our contract:

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

import {Script} from "forge-std/Script.sol";
import {AlertBot} from "../src/26_DoubleEntryPoint.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

interface IDoubleEntryPoint {
    function cryptoVault() external view returns (address);
    function forta() external view returns (IForta);
}

interface IForta {
    function setDetectionBot(address detectionBotAddress) external;
}

contract PoC is Script {
    // Replace with your DoubleEntryPoint instance
    IDoubleEntryPoint doubleEntryPoint =
        IDoubleEntryPoint(0x1Ac3aD234aFEE64572c28242BF0197E1e8CaA717);

    function run() external {
        uint256 deployer = vm.envUint("PRIVATE_KEY");
        vm.startBroadcast(deployer);

        address cryptoVault = doubleEntryPoint.cryptoVault();
        AlertBot alertBot = new AlertBot(cryptoVault);
        doubleEntryPoint.forta().setDetectionBot(address(alertBot));

        vm.stopBroadcast();
    }
}

Then you can run the script with the following command:

forge script script/26_DoubleEntryPoint.s.sol:PoC --rpc-url sepolia --broadcast --verify --etherscan-api-key $ETHERSCAN_API_KEY

๐ŸŽ‰ Level completed ๐ŸŽ‰

Takeaway

  • Detection Bots can be used to monitor any activity on the blockchain and trigger alerts.

Reference


You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/

ย