The Ethernaut CTF Solutions | 26 - Double Entry Point
Securing Contracts: Employing Forta Alerts Bots to Mitigate Contract Exploits
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:
Forta
- Related to the AlertBot we need to set up.CryptoVault
- The contract we need to protect. Handles theunderlying
token.LegacyToken
- A modified ERC20 token (LGT).DoubleEntryPoint
- The instance we are given and the so-calledunderlying
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:
CryptoVault::sweepToken(LGT)
LegacyToken::transfer({ to: sweptTokenRecipient, value: LGT.balanceof(CryptoVault) })
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 thefunction delegateTransfer(address to, uint256 value, address origSender)
function.Then, in
Forta::notify()
, the data is updated to the data of thehandleTransaction(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.
Position | Bytes/Length | Type | Value |
0x00 | 4 | bytes4 | selector of handleTransaction() |
0x04 | 32 | address | user address |
0x24 | 32 | uint256 | Offset of msgData |
0x44 | 32 | uint256 | Length of msgData |
0x64 | 4 | bytes4 | selector of delegateTransfer() |
0x68 | 32 | address | to address |
0x88 | 32 | uint256 | value parameter |
0xa8 | 32 | address | \=> 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
Forta bot: https://docs.forta.network/en/latest/
CallData structure: https://docs.soliditylang.org/en/latest/abi-spec.html#abi
You can find all the codes, challenges and their solutions on my GitHub: https://github.com/Pedrojok01/Ethernaut-Solutions/