#10: ThirdWeb Incident: Incompatibility Between Trusted Modules Exposes Vulnerability

This blog shows the vulnerability and attack caused by Incompatibility of commonly used modules.

#10: ThirdWeb Incident: Incompatibility Between Trusted Modules Exposes Vulnerability

Summary

On December 5, 2023, Web3 development platform Thirdweb reported security vulnerabilities in their pre-built smart contracts. This flaw impacted all ERC20, ERC721, and ERC1155 tokens deployed using these contracts. In the following days, tokens deployed with the vulnerable contracts were progressively exploited in attacks.

Vulnerability Analysis

ERC-2771

This EIP defines a contract-level protocol for Recipient contracts to accept meta-transactions through trusted Forwarder contracts. No protocol changes are made. Recipient contracts are sent the effective msg.sender (referred to as _msgSender()) and msg.data (referred to as _msgData()) by appending additional calldata.

In practice, OpenZeppelin's implementation ERC2771Context is widely used. Specifically, the last 20 bytes of calldata from the trusted forwarder are treated as _msgSender(). For common library users, it seems they only need to replace all uses of msg.sender with _msgSender().

function _msgSender() internal view virtual override returns (address) {
    uint256 calldataLength = msg.data.length;
    uint256 contextSuffixLength = _contextSuffixLength();
    if (isTrustedForwarder(msg.sender) && calldataLength >= contextSuffixLength) {
        return address(bytes20(msg.data[calldataLength - contextSuffixLength:]));
    } else {
        return super._msgSender();
    }
}

function _msgData() internal view virtual override returns (bytes calldata) {
    uint256 calldataLength = msg.data.length;
    uint256 contextSuffixLength = _contextSuffixLength();
    if (isTrustedForwarder(msg.sender) && calldataLength >= contextSuffixLength) {
        return msg.data[:calldataLength - contextSuffixLength];
    } else {
        return super._msgData();
    }
}

Multicall

Users can utilize multicall to integrate multiple calls into one single call. The calldatas of multiple calls are extracted from the calldata of a single call. In practice, OpenZeppelin's MulticallUpgradeable is widely used. (The bug is fixed now)

function multicall(bytes[] calldata data) external virtual returns (bytes[] memory results) {
    results = new bytes[](data.length);
    for (uint256 i = 0; i < data.length; i++) {
        results[i] = _functionDelegateCall(address(this), data[i]);
    }
    return results;
}

ERC-2771 + Multicall

The issue arises due to inconsistencies in how calldata is packed and unpacked between these two components (ERC-2771 and Multicall). As per ERC-2771, the trusted forwarder should pack the message data and sender information together. The contract then uses _msgData() and _msgSender() to unpack the message data and sender information, respectively.

However, the multicall function is not compatible with how ERC-2771 packs data. If implemented correctly, Multicall should use _msgSender() to unpack the sender information and append it to each message's call data so that it can be unpacked properly in subsequent calls. But this step in the actual implemeantion is missed.

Meanwhile, the contract following ERC-2771 will try to unpack message data and sender information using _msgData() and _msgSender(). However, without the sender information appended to calldata, the sender information is unpacked as the last 20 bytes of _msgData(), which the attacker can control. This allows an attacker to construct manipulated calldata that executes malicious logic with an arbitrary sender information, violating the expectations set by the specifications.

Attack Analysis

Let's take one instance of an attack transaction as an example.

  • Step 1: Swap 5 $WETH for 3,455,399,346 $TIME.
  • Step 2: Invoke trusted forwarder with carefully crafted data, after being parsed by multicall, the burn function is called with Uniswap Pool as the _msgSender(). The $TIME balance of pool is decreased.
  • Step 3: Sync the pool, the price of $TIME is lifted.
  • Step 4: Swap 3,455,399,346 $TIME for 94 $WETH.

The step 2 is the key of attack. Attacker invoke Forwarder.execute to forward data:

However, it is parsed as bytes[] of length 1, then used as data to call the contract.

Highlight & Lesson

In the DeFi space, it is crucial to pay attention to the security of third-party libraries. Unfortunately, these libraries can sometimes interact with each other in unexpected and covert ways, posing a threat to the security of the contract. This highlights the importance of thorough auditing and monitoring to mitigate any potential vulnerabilities that may arise from such interactions.

Read other articles in this series:

Sign up for the latest updates