On March 27th, 2022, the staking DeFi project Revest Finance on Ethereum was attacked due to the ERC-1155 call-back mechanism. Roughly $2M worth of tokens (namely BLOCKS, ECO, LYXe, and RENA) were stolen. We analyzed the attack in the first place and tweeted our analysis on that night (UTC+8).
In fact, at the time of writing the Twitter, we still had some doubts about a function in the Revest TokenVault contract. We looked into the contract trying to understand its functionality. Later we found there exists another critical zero-day vulnerability, which can be exploited in a far more simple way and can cause the same huge losses (as the attack that has happened).
We then contacted the Revest Finance team immediately, and they responded quickly and proposed a workaround for the vulnerability. After confirming that the vulnerability cannot be exploited, we decide to release this blog.
The following of this blog consists of three parts: the mechanism of the Revest Finance, the original re-entrancy attack, and the new zero-day vulnerability.
What’s the Revest Finance FNFT
The Financial Non-Fungible Token (FNFT) of Revest Finance makes the trustless transfer of the future rights to locked assets possible. The entry contract (Revest contract) provides three different interfaces to mint FNFT by locking underlying assets:
mintTimeLock
: the underlying asset will be unlocked after a period of time.mintValueLock
: the underlying asset will be unlocked when its value rises above or falls below a prescribed value.mintAddressLock
: the underlying asset will be unlocked by a prescribed account.
The Revest contract connects with the other three contracts to lock and unlock underlying assets.
- FNFTHandler: inherited from the ERC-1155 token. It creates a new FNFT with the incrementing
fnftId
for every lock. The lock prescribes the total supply of the new FNFT at the creation. The FNFT can not be minted in another way but can be burned for unlocking underlying assets. - LockManager: records the unlocking conditions for each lock when creating and decides if the lock can be unlocked when unlocking.
- TokenVault: receives and sends the underlying assets and records the metadata for each FNFT, such as the value of a specified FNFT.
We take mintAddressLock
as an example to illustrate the process of minting FNFTs.
Figure 1
Figure 2
The above two figures basically describe how a FNFT is created, minted, and burned. Specifically, user A locks 100 WETH into Revest Finance that creates the corresponding FNFT with fnftId
as 1. Finally, it mints 100 1-FNFT to specified recipients with specified shares.
Note that, once the underlying asset is unlocked, then every 1-FNFT can be burned for receiving one (*1e18) WETH. As shown in the Figure 2, user B withdraws 25 (* 1e18) WETH by burning 25 1-FNFT.
In addition, Revest contract provides another interface, named depositAdditionalToFNFT
, that incurs two vulnerabilities that will be discussed in the following.
We first use the follow two figures to describe the normal usage of this function.
Figure 3
Figure 4
The function depositAdditionalToFNFT
is used to lock more underlying assets to an existing lock (specified by fnftId
). Reasonably(Figure 3), it requires the specified quantity to be the same as the total supply of specified FNFT and then evenly distributes the added assets to each specified FNFT.
Otherwise(Figure 4), it creates a new lock with the latest fnftId
, burns the specified quantities of old FNFT and mints the specified quantity of new FNFT, and then records the new lock's depositAmount
as the sum of the old lock's depositAmount
and the specified amount, as shown in the following code.
// Now, we transfer to the token vault
if(fnft.asset != address(0)){
IERC20(fnft.asset).safeTransferFrom(_msgSender(), vault, quantity * amount);
}ITokenVault(vault).handleMultipleDeposits(fnftId, newFNFTId, fnft.depositAmount + amount);emit FNFTAddionalDeposited(_msgSender(), newFNFTId, quantity, amount);
Since depositAmount
recorded in the TokenVault contract indicates the amount of the underlying asset one specified FNFT can withdraw, that operation transfers the value of the specified quantities of old FNFT from the old lock to the new lock. (specified quantity greater than the total supply will revert the transaction)
What’s the Re-entrancy vulnerability
In this part, we will illustrate how the re-entrancy attack works and discuss the root cause and the fix method.
Figure 5
Figure 6
Figure 7
The above three figures basically describe the whole process of the re-entrancy attack. Specifically, the attacker first locks zero RENA token to mint 2 1-FNFT that has no value. Second, the attacker locks zero RENA token again but mints 360,000 2-FNFT that also has no value (now). During the last step, the attacker re-enters the Revest contract’s depositAdditionalToFNFT function via the FNFTHandler’s call-back mechanism inherited from the ERC-1155 token standard, which over-writes the depositAmount
of the lock with fnftId
as 2 before updating of fnftId
. As a result, the attacker obtains 360,001 2-FNFT with the depositAmount
as 1e18, which means he can withdraw 360,001 * 1e18 RENA from the TokenVault contract. Besides, the only cost is 1e18 RENA.
Fix Method
The codes of Revest Finance completely in line with the classic re-entrancy pattern: use fnftId
-> external call with callback mechanism -> update fnftId
. Therefore, the most straightforward way to fix the issues is to break the pattern. The fixed code is shown in below:
function mint(
address account,
uint id,
uint amount,
bytes memory data
) external override onlyRevestController {
require(amount > 0, "Invalid amount");
require(supply[id] == 0, "Repeated mint for the same FNFT");
supply[id] += amount;
fnftsCreated += 1;
_mint(account, id, amount, data);
}
First, it moves the update operation before the external call (_mint
), which can avoid the attack. Second, since the system does not allow mint zero FNFT and repeated mint the same FNFT, it adds two checks to ensure the system work as expected, which can improve the system's safety.
the New Zero-day Vulnerability
When analyzing the code of Revest Finance, the function handleMultipleDeposits
in the TokenVault contract confuses us, the code of which is shown in below.
function handleMultipleDeposits(
uint fnftId,
uint newFNFTId,
uint amount
) external override onlyRevestController {
require(amount >= fnfts[fnftId].depositAmount, 'E003');
IRevest.FNFTConfig storage config = fnfts[fnftId];
config.depositAmount = amount;
mapFNFTToToken(fnftId, config);
if(newFNFTId != 0) {
mapFNFTToToken(newFNFTId, config);
}
}
During the call to the depositAdditionalToFNFT
function, the handleMultipleDeposits
function changes depositAmount
of the old lock or records it of the new one. When the newFNFTId
is zero, it does not record the depositAmount
of the new lock, because this is an operation to add additional assets to the existing lock.
According to our understanding of the protocol, when the newFNFTId
is not zero, it only needs to record depositAmount
of the new lock and does not need to change depositAmount
of the old one. However, the code shows us that it not only records depositAmount
of the new lock but also changes depositAmount
of the old one, which contradicts our understanding.
We believe that is a serious zero-day logic vulnerability and then write a PoC to verify that. The following three figures describe how the PoC works.
Figure 8
Figure 9
Figure 10
Specifically, the attacker first locks zero RENA to mint 360,000 1-FNFT. After that, the attacker directly invokes the depositAdditionalToFNFT
function to create a new lock. Due to the vulnerability, the TokenVault
contract incorrectly changes the depositAmount
of the old lock from zero to 1e18. As a result, the attacker gains 359,999 1-FNFT worth of 359,999 RENA. Obviously, the PoC is far more simple than the real re-entrancy attack since it does not need a re-entrant call.
The workaround to fix the vulnerability
This is a logic bug, and we recommend using the following code to fix it.
function handleMultipleDeposits(
uint fnftId,
uint newFNFTId,
uint amount
) external override onlyRevestController {
require(amount >= fnfts[fnftId].depositAmount, 'E003');
IRevest.FNFTConfig memory config = fnfts[fnftId];
config.depositAmount = amount;
if(newFNFTId != 0) {
mapFNFTToToken(newFNFTId, config);
} else {
mapFNFTToToken(fnftId, config);
}
}
Since the two vulnerable contracts: TokenVault and FNFTHandler store a lot of critical states, the project can not re-deploy the TokenVault contract and the FNFTHandler contract without migrating states. To avoid the further attack to this vulnerability, the project re-deployed a lite version of Revest contract, which disables more complex functions to reduce the surfaces available to any would-be attacker. After checking the workaround, we believe that the lite Revest contract can mitigate the possible attacks mentioned in this blog.
Takeaway
Making a DeFi project secure is not an easy job. Besides the code audit, we think the community should take a proactive method to monitor the project status, and block the attack if possible.
- Twitter: https://twitter.com/BlockSecTeam
- Medium: https://blocksecteam.medium.com
- Website: https://www.blocksecteam.com