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.
Objective of CTF:
- Prevent the exploit via Forta detection bot.
Target contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@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;
}
}
Our task in this level is to find the bug in CryptoVault
contract, and protect it from being drained out of tokens. Anyways, what is this vault about, and what is sweepToken
?
CryptoVault
is constructed with a recipient address argument.- A
setUnderlying
function sets a token address as the underlying token. This is a one time operation, as per therequire
line in it checking for the initial value ofunderlying
. - A
sweepToken
function takes a token address as parameter, and transfers the balance ofCryptoVault
to the recipient. "Sweeping" here is to transfer the entire balance ofCryptoVault
about any token other than the underlying token to the recipient. This is commonly done so that the user can get mistakenly sent tokens.
The contract
object in our console is of the DoubleEntryPoint
contract, judging by its properties. We wonder what is the underlying token? We can find it as follows:
// find the CryptoVault address from DoubleEntryPoint
const cryptoVaultAddress = await contract.cryptoVault();
// access "IERC20 public underlying;" variable
await web3.eth.getStorageAt(cryptoVaultAddress, 1);
// 0x00000000000000000000000025047168b9c737a03a111ec039438403e73b7507
We got the address of DET token, the one we are supposed to protect! so we want to prevent transfer of DET token; but, can we really?
If we look at the transfer function of LegacyToken
contract:
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);
}
}
it is actually calling the delegateTransfer
of some delegate
. I wonder what is that delegate
? We can get the address similar to before, but using call
instead of getStorageAt
:
// find the LegacyToken address from DoubleEntryPoint
const legacyTokenAddress = await contract.delegatedFrom();
// call the getter of "DelegateERC20 public delegate;"
await web3.eth.call({
from: player,
to: legacyTokenAddress,
data: '0xc89e4361', // delegate()
});
// 0x00000000000000000000000025047168b9c737a03a111ec039438403e73b7507
Oh boy, they are the same... This is bad for the underlying token because if someone were to call sweepToken
with LegacyToken
as the address, it will cause DET to be swept! Let us do so:
// get the addresses from DoubleEntryPoint
const cryptoVaultAddress = await contract.cryptoVault();
const legacyTokenAddress = await contract.delegatedFrom();
// check initial balance
await contract.balanceOf(cryptoVaultAddress).then(b => b.toString());
// call sweepToken of CryptoVault with LegacyToken as the parameter
const _function = {
inputs: [
{
name: 'token',
type: 'address',
},
],
name: 'sweepToken',
type: 'function',
};
const _parameters = [legacyTokenAddress];
const _calldata = web3.eth.abi.encodeFunctionCall(_function, _parameters);
await web3.eth.sendTransaction({
from: player,
to: cryptoVaultAddress,
data: _calldata,
});
// check balance again to see it be 0
await contract.balanceOf(cryptoVaultAddress).then(b => b.toString());
Boom, DET has been swept.
Now, we will prevent this attack with a Forta detection bot. We must look at the Forta contract for this. In particular, our bot must follow the IDetectionBot
interface, which requests the implementation of a function handleTransaction(address user, bytes calldata msgData) external
. Indeed, this function is called within the notify
function of Forta contract. To raise an alert, the bot must call raiseAlert
function of it's caller (accessed via msg.sender
) which will be the Forta contract.
How should we prevent this? Well, the attack was made by calling the sweepToken
function of CryptoVault
contract with LegacyToken
contract as the address. Then, a message call to DoubleEntryPoint
contract is made for the delegateTransfer
function. That message's data is the one our bot will receive on handleTransaction
, because delegateTransfer
is the one with fortaNotify
modifier. Regarding that function, the only thing we can use for our need is the origSender
, which will be the address of CryptoVault
during a sweep. So, our bot can check that value within the calldata and raise an alert if it is the address of CryptoVault
.
At this point, we need to put special effort into understanding how the calldata will be structured. We are calling delegateTransfer
but that is not the calldata our bot will receive. You see, this function has a modifier fortaNotify
. The modifier is not a message call, but simply replaces code with respect to the execution line (_;
). During notify
, the msg.data
is passed as a parameter, so this has the structure of the calldata shown in the table above.
After notify
, our detection bot's handleTransaction
is called with the same msg.data
passed to notify
. So, during handleTransaction
, the calldata will have the actual calldata to call that function, and the delegateCall
calldata as an argument.
position | bytes | type | value |
---|---|---|---|
0x00 |
4 | bytes4 |
Function selector of handleTransaction which is 0x220ab6aa |
0x04 |
32 | address (padded) |
user parameter |
0x24 |
32 | uint256 |
offset of msgData parameter, 0x40 in this case |
0x44 |
32 | uint256 |
length of msgData parameter, 0x64 in this case |
0x64 *** |
4 | bytes4 |
Function selector of delegateTransfer which is 0x9cd1a121 |
0x68 *** |
32 | address (padded) |
to parameter |
0x88 *** |
32 | uint256 |
value parameter |
0xA8 *** |
32 | address (padded) |
origSender parameter the one we want |
0xC8 |
28 | padding | zero-padding as per the 32-byte arguments rule of encoding bytes |
The *** marks the original calldata when delegateTransfer
is called. For more information on how this is calculated, see to the end of this post where I create a toy-contract to find out the calldata.
Anyways, it is time to write our bot! We will copy-paste the IDetectionBot
interface, as well as IForta
interface and Forta
contract which we will use within to raise an alert.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
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 {
require(address(usersDetectionBots[msg.sender]) == address(0), "DetectionBot already set");
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 MyDetectionBot is IDetectionBot {
address public cryptoVaultAddress;
constructor(address _cryptoVaultAddress) {
cryptoVaultAddress = _cryptoVaultAddress;
}
// we can comment out the variable name to silence "unused parameter" error
function handleTransaction(address user, bytes calldata /* msgData */) external override {
// extract sender from calldata
address origSender;
assembly {
origSender := calldataload(0xa8)
}
// raise alert only if the msg.sender is CryptoVault contract
if (origSender == cryptoVaultAddress) {
Forta(msg.sender).raiseAlert(user);
}
}
}
Upon deploying the detection bot with the correct CryptoVault
address, we must set the detection bot at the Forta
contract:
const fortaAddress = await contract.forta();
const detectionBotAddress = '0x63b2a2028E10025843c90DF9dEF2748565f495F0'; // your address here
// call setDetectionBot of Forta with your detection bot address as the parameter
const _function = {
inputs: [
{
name: 'detectionBotAddress',
type: 'address',
},
],
name: 'setDetectionBot',
type: 'function',
};
const _parameters = [detectionBotAddress];
const _calldata = web3.eth.abi.encodeFunctionCall(_function, _parameters);
await web3.eth.sendTransaction({
from: player,
to: fortaAddress,
data: _calldata,
});
Done!
It took me some time to wrap my head around how exactly the calldata was calculated, so I wrote a toy-contract that acts the same:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract TestBot {
bytes public calldataAtHandleTransaction;
bytes public parameterAtHandleTransaction;
function handleTransaction(address user, bytes calldata msgData) external {
calldataAtHandleTransaction = msg.data;
parameterAtHandleTransaction = msgData;
}
}
contract TestDet {
address botAddress;
constructor(address _botAddress) {
botAddress = _botAddress;
}
function delegateTransfer(
address addr1, // 0x1111111111111111111111111111111111111111
uint256 val1, // 10
address addr2 // 0x2222222222222222222222222222222222222222
) external {
TestBot(botAddress).handleTransaction(addr1, msg.data);
}
}
If you call delegateTransfer
of TestDet
contract, and then check the public variables of TestBot
you will have a clear look on the calldata and msgData
argument during handleTransaction
. Looking at the documentation for ABI Specification, we see that
bytes
, of lengthk
(which is assumed to be of typeuint256
):enc(X) = enc(k) pad_right(X)
, i.e. the number of bytes is encoded as auint256
followed by the actual value ofX
as a byte sequence, followed by the minimum number of zero-bytes such thatlen(enc(X))
is a multiple of 32.
Looking at the calldata of delegateTransfer
, we have:
- 4 bytes function selector
- 32 bytes address
- 32 bytes unsigned integer
- 32 bytes address
A total of 100 bytes, which is 0x64
in hex. So, in the calldata of handleTransaction
the length value for msgData
will be 0x64
. What about the offset value?
... for the dynamic types
uint32[]
andbytes
, we use the offset in bytes to the start of their data area, measured from the start of the value encoding (i.e. not counting the first four bytes containing the hash of the function signature)
At the table above we saw that the position of msgData
length is at 0x44
. That includes the function signature of handleTransaction
, so after ignoring it we get the offset value 0x40
.