!! Emergency Alert!! Analysis of OpenZeppelin Arbitrary Address Spoofing Attack

Posted by AntChain Open Labs on 2023-12-08

Summary

On December 8, 2023, OpenZeppelin issued an important security alert to the community via Twitter, stating that the integration of the ERC-2771 standard with a multicall-like method (wherein the code contains a delegatecall to the contract itself with calldata that can be externally controlled by users) could lead to projects using this pattern being at risk of arbitrary address spoofing attacks. (https://twitter.com/openzeppelin/status/1732913331265036475?s=46&t=zDQbmeyWt2t9a8SGpOvbtw)

OpenZeppelin provided methods for identifying vulnerable contracts and mitigating the issue on their blog. For more details, please read the blog post: https://blog.openzeppelin.com/arbitrary-address-spoofing-vulnerability-erc2771context-multicall-public-disclosure.

image

Attack Analysis

From OpenZeppelin’s blog, it is evident that several attacks have already occurred. Here we will select one transaction for detailed analysis.

Addresses involved in the attack

Attack transaction:

https://etherscan.io/tx/0xecdd111a60debfadc6533de30fb7f55dc5ceed01dfadd30e4a7ebdb416d2f6b6

Attacker address:

0xfde0d1575ed8e06fbf36256bcdfa1f359281455a

Attack contract:

0x6980a47bee930a4584b09ee79ebe46484fbdbdd0

TIME token (Vulnerable contract):

0x4b0e9a7da8bab813efae92a6651019b8bd6c0a29

Attack Process

Attack preparation

  1. The attacker approved the Uniswap V2 Router contract to use his TIME tokens.

  2. The attacker deposited 5 ether into WETH contract and received 5 WETH.

Attack execution

  1. The attacker called the swapExactTokensForTokensSupportingFeeOnTransferTokens function of the Uniswap V2 Router contract, intending to exchange 5 WETH for TIME token. This swap was conducted in the Uniswap V2: TIME 40 pool and finnally the attacker exchanged for 3455399346.269046 TIME tokens.

  2. The attacker called the execute function of the Forwarder contract, specifying the to address as the TIME contract address and data in the parameter req. Another parameter, signature, was a signature made by the address 0xa16a5f37774309710711a8b4e83b068306b21724 (another address of the attacker), and req.from was also this address, so the signature verification of the execute function could pass smoothly. Next, the function of the TIME contract will be called with the calldata consisting of req.data and req.from. It’s important to note that the calldata was carefully constructed by the attacker and included the address of the Uniswap V2: TIME 40 pool. This pool address was where the funds were lost in the subsequent attack.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
struct ForwardRequest {
address from;
address to;
uint256 value;
uint256 gas;
uint256 nonce;
bytes data;
}

function execute(ForwardRequest calldata req, bytes calldata signature)
public
payable
returns (bool, bytes memory)
{
require(verify(req, signature), "MinimalForwarder: signature does not match request");
_nonces[req.from] = req.nonce + 1;

// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory result) = req.to.call{ gas: req.gas, value: req.value }(
abi.encodePacked(req.data, req.from)
);

if (!success) {
// Next 5 lines from https://ethereum.stackexchange.com/a/83577
if (result.length < 68) revert("Transaction reverted silently");
assembly {
result := add(result, 0x04)
}
revert(abi.decode(result, (string)));
}
// Check gas: https://ronan.eth.link/blog/ethereum-gas-dangers/
assert(gasleft() > req.gas / 63);
return (success, result);
}
  1. Observing the transaction, we can see that the function being called at this point is the multicall function of the TIME contract. Within the multicall function, a delegatecall is made to execute a function within the current contract (TIME). In this transaction, the burn function of the TIME contract was called.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;
}

function _functionDelegateCall(address target, bytes memory data) private returns (bytes memory) {
require(AddressUpgradeable.isContract(target), "Address: delegate call to non-contract");

// solhint-disable-next-line avoid-low-level-calls
(bool success, bytes memory returndata) = target.delegatecall(data);
return AddressUpgradeable.verifyCallResult(success, returndata, "Address: low-level delegate call failed");
}
  1. When the burn function is called, the address for the token to be burned is determined via the _msgSender function. Upon entering the _msgSender function, in this transaction, the msg.sender is the Forwarder contract, which is a forwarder trusted by the TIME contract, and therefore isTrustedForwarder is true. An address is extracted from the last 20 bytes of the calldata to serve as the sender. The calldata at this point is as follows. The last 20 bytes of the calldata here correspond exactly to the address of the Uniswap V2: TIME 40 pool. Ultimately, 62227259510 TIME tokens from the Uniswap V2: TIME 40 pool were burned.
1
2
3
function burn(uint256 amount) public virtual {
_burn(_msgSender(), amount);
}
1
2
3
4
5
6
7
8
9
10
function _msgSender() internal view virtual override returns (address sender) {
if (isTrustedForwarder(msg.sender)) {
// The assembly code is more direct than the Solidity version using `abi.decode`.
assembly {
sender := shr(96, calldataload(sub(calldatasize(), 20)))
}
} else {
return super._msgSender();
}
}
1
0x42966c680000000000000000000000000000000000000000c9112ec16d958e8da8180000760dc1e043d99394a10605b2fa08f123d60faf84
  1. The attacker called the sync function of the Uniswap V2: TIME 40 pool, updating the reserves of tokens to match the actual number of tokens in the pool. After the update, the reserve of TIME tokens in the pool decreased, making the price of TIME in the pool more expensive.

  2. The attacker conducted another exchange in the Uniswap V2: TIME 40 pool, this time swapping TIME tokens for WETH. As the price of TIME tokens had increased, the attacker was able to exchange for a larger amount of WETH. Ultimately, using the 3455399346.269046 TIME acquired from the first exchange, the attacker swapped for approximately 94 WETH, making a profit of over 80 ETH.

Recommendation

It is recommended that all project teams promptly utilize OpenZeppelin’s tools (https://defender.openzeppelin.com/v2/#/auth/sign-up) to check if their contracts are vulnerable to the aforementioned exploit and take immediate actions such as pausing the project and revoking trust from forwarders , etc., to mitigate this risk.

About

AntChain Open Labs

AntChain Open Labs is a research center initiated by AntChain and world leading computer scientists in the area of foundational trust technologies. It is dedicated to building a secure, transparent and reliable Web3 infrastructure driven by innovative research and aiming to advance transformative services.
Website:https://openlabs-intl.antdigital.com/home

ZAN

ZAN, powered by AntChain Open Labs, provides solutions for Web3, such as Smart Contract Review, KYT, KYC, Node Service, and more.
Website | Telegram | Discocd | Twitter | More