Back to all stories
Reports
Incident Analysis
Time Token Exploit
12/8/2023
Time Token Exploit

Summary

On 7 December TIME token was exploited leading to a loss of 89.5 ETH. The exploiter was able to burn a majority of TIME tokens via the vulnerable Forwarder contract entrusted to execute a transaction from an arbitrary sender address. The attacker gained 84.6 ETH($188K)

Vulnerability

This vulnerability is a framework-level vulnerability and doesn't just affect the TIME project and we can see several additional attacks as follows. Below are a few additional examples that have leveraged this vulnerability.

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

https://polygonscan.com/tx/0x1b0e27f10542996ab2046bc5fb47297bcb1915df5ca79d7f81ccacc83e5fe5e4

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

An error in the integration of standard ERC-2771 with Multicall and differences in the handling of calldata between them resulted in this type of attack incident.

Attack Flow

The following outlines the attack flow on the TIME token:

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

  1. The attacker first converted 5 ETH to 5 WETH

image-20231207-031408

  1. The attacker then swapped 5 WETH to 3,455,399,346 TIME on dex 0x760d...af84.

image-20231208-041640

  1. The attacker called the "execute()" function of the Forwarder contract:

image-20231208-035907 截屏2023-12-08 12.00.22-20231208-040119

First, the attacker needed to construct a ‘req' (calldata) that met the specified requirements and provide the corresponding signature. The “verify()” function uses the signature to verify whether the entire req is signed by req.from which is declared in the req.

The attacker arbitrarily used address 0xa16a5f37774309710711a8b4e83b068306b21724, which was under his control (with the guarantee that this address’ nonce is eligible), and signed the req data with its private key. This allowed the submitted req data & signature to pass the verification.

The "execute()" function will then call the “call()” function and pack the req.data and req.from as “call()” function parameters. The screenshot below shows the calldata from combining the req.data and req.from.

Screenshot 2023-12-08 at 14.56.05

The req.to is 0x4b0e9a7da8bab813efae92a6651019b8bd6c0a29 (TIME contract) and 0xac9650d8 is the selector of the multicall(bytes[]) function.

So the “call()” function here (screenshot above) is equivalent to calling the “multicall(bytes[])” function of the TIME contract, the screenshot below is the parameters passed to “multicall(bytes[])” function.

Screenshot 2023-12-08 at 14.58.02

Since the TIME contract (0x4b0E...0a29) is a Minimal Proxy, the specific logic is implemented in the TokenERC20 contract (0x303A...066a). The multicall(bytes[]) function is implemented as follows:

image-20231208-040605

The multicall function passes the calldata parameter directly to the delegatecall() function.

image-20231208-040648

Then there is a problem here. Let’s see the parameters passed to the “multicall()” function above.

During the parsing process, we can see the first element of the bytes array (data[0]) is only 0x38 long, which means it is not Including the req.from value (0000000000000000a16a5f37774309710711a8b4e83b068306b21724) spliced after req.data by the “execute()” function in the Forward contract.

The developer originally intended to pass the verified req.from value as part of the calldata to the TIME contract to achieve permission control. However, they ignored the parsing logic of parameters in calldata and mistakenly thought that they only need to splice req.data and req.from to pass req.from as part of the calldata to the TIME contract.

In fact, req.from is directly truncated by “multicall()” function because it does not comply with the calldata parsing logic of the multicall function. The calldata actually passed to the TIME contract is as follows:

Screenshot 2023-12-08 at 14.59.57

This then leads to a serious error. The verified req.from value is not passed to the TIME contract as expected. The TIME contract mistakenly obtained an address that is controlled by the attacker and has not been verified. This caused the TIME contract to execute burn logic on the wrong target.

  1. When the burn() function of the TIME contract is called, the _ msgSender function will determine whether msg.sender is a Forwarder contract. If so, the 20 bytes at the end of the calldata will be returned as the _ burn() function’s first parameter.

Screenshot 2023-12-08 at 15.01.17

Based on previous analysis, the address that is actually passed to the TIME contract is 760dc1e043d99394a10605b2fa08f123d60faf84 (the last 20 bytes of calldata), which is an address that is controlled by the attacker, and is also the address of the target pool that the attacker wishes to attack. This caused the TIME contract to burn 62,227,259,510 TIME tokens on 0x760d...af84. After that, there were only 9,999,999 Time tokens left in the pool, while the destruction ratio was as high as 99.9%.

image-20231208-043721

The attacker called the pool's sync function to synchronize reserves in order to manipulate the price, and at the same time exchanges 3,455,399,346 TIME for 94.5 WETH in the pool. They eventually converted all the WETH to ETH, of which 4.9 ETH is used to bribe Flashbots.

截屏2023-12-08 12.12.24-20231208-041307

;