Phantom Functions and the Billion-dollar No-op
By the Dedaub team
On Jan. 10 we made a major vulnerability disclosure to the Multichain project (formerly “AnySwap”). Multichain has made a public announcement that focuses on the impact on their clients and mitigation. The announcement was followed by attacks and a flashbots war. The total value of funds currently lost is around 0.5% of those directly exposed initially.
[ADVISORY: If you have ever used Multichain/Anyswap, check/revoke your approvals for vulnerable tokens. Make sure to check all chains and read the full instructions if anything is unclear.]
We will document the attacks and defense in a separate chronology, to be published after the threat is fully mitigated. This brief writeup instead intends to illustrate the technical elements of the vulnerability, i.e., the attack vector.
The attack vector is, to our knowledge, novel. The Solidity/EVM developer and security community should be aware of the threat.
In the particular case of Multichain contracts, the attack vector led to two separate, major vulnerabilities, one mainly in the WETH (“Wrapped ETH”) liquidity vault contract (an instance of AnyswapV5ERC20) and one in the router contract (AnyswapV4Router) that forwards tokens to other chains. The threat was enormous and multi-faceted — almost “as big as it gets” for a single protocol:
- On Ethereum alone, $431M in WETH would be stolen in a single, direct transaction, from just 3 victim accounts. We demonstrated this on a local fork before the disclosure. (Balances and valuations are as of the time of original writing of this explanation, on Jan.12. The main would-be victim account, the AnySwap Fantom Bridge, was holding over $367M by itself. At the time of publication of this article, the same contract held $1.2B.)
- The same contracts have been deployed for different tokens and on several blockchains, including Polygon, BSC, Avalanche, Fantom. (Liquidity contracts for other wrapped native tokens, such as WBNB, WAVAX, WMATIC are also vulnerable.) The risk on these other networks was later estimated at around $40M.
- The main would-be victim account, the AnySwap Fantom Bridge, escrows tokens that have moved to the Fantom blockchain. This means that an attacker could move any sum to Fantom and then steal it back on Ethereum, together with the current $367M of the bridge (and the many tens of millions from other victims, separately). The moved tokens would still be alive (and valuable) in Fantom, or anywhere else they have since moved to. This makes the potential impact of the attack theoretically unbounded (“infinite”): any amount “invested” can be doubled, in addition to the $431M amount stolen from Ethereum victims and however much on other chains.
- Close to 5000 different accounts had given infinite approval for WETH to the vulnerable contracts (on Ethereum). This number has since dropped substantially (especially among accounts with holdings), but there is still a threat: any WETH these accounts ever acquire is vulnerable, until approvals are revoked.
Given the above, the potential practical impact (had the vulnerability been fully exploited) is arguably in the billion-dollar range. This would have been one of the largest hacks ever—given the theoretically unbounded threat, we are not getting into more detailed comparisons.
Phantom Functions | Attack Vector
Briefly:
Callers should not rely on permit
reverting for arbitrary tokens.
The call token.permit(...)
never reverts for tokens that
- do not implement
permit
- have a (non-reverting) fallback function.
Most notably, WETH — the ERC-20 representation of ETH — is one such token.
We call this pattern a phantom function— e.g., we say “WETH has a phantom permit
” or “permit
is a phantom function for the WETH contract”. A contract with a phantom function does not really define the function but accepts any call to it without reverting. On Ethereum, other high-valuation tokens with a phantom permit
are BNB and HEX. Native-equivalent tokens on other chains (e.g., WBNB, WAVAX) are likely to also exhibit a phantom permit
.
In more detail:
Smart contracts in Solidity can contain a fallback
function. This is the code to be called when any function f()
is invoked on a contract but the contract does not define f()
.
In current Solidity, fallback functions are rather exotic functionality. In older versions of Solidity, however, including fallback functions was common, because the fallback function was also the code to call when the contract received ETH. (In newer Solidity versions, an explicit receive
function is used instead.) In fact, the fallback function used to be nameless: just function()
. For instance, the WETH contract contains fallback functionality defined as follows:
function() public payable {
deposit();
}
function deposit() public payable {
balanceOf[msg.sender] += msg.value;
Deposit(msg.sender, msg.value);
}
This function is called when receiving ETH (and just deposits it, to mint wrapped ETH with it) but, crucially, is also called when an undefined function is invoked on the WETH contract.
The problem is, what if the undefined function is relied upon for performing important security checks?
In the case of AnySwap/MultiChain code, the simplest vulnerable contract contains code such as:
function deposit() external returns (uint) {
uint _amount = IERC20(underlying).balanceOf(msg.sender);
IERC20(underlying).safeTransferFrom(msg.sender, address(this), _amount);
return _deposit(_amount, msg.sender);
}
...
function depositWithPermit(address target, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s, address to) external returns (uint) {
IERC20(underlying).permit(target, address(this), value, deadline, v, r, s);
IERC20(underlying).safeTransferFrom(target, address(this), value);
return _deposit(value, to);
}
This means that the regular deposit path (function deposit
) transfers money from the external caller (msg.sender
) to this contract, which needs to have been approved as a spender. This deposit
action is always safe, but it lulls clients into a false sense of security: they approve the contract to transfer their money, because they are certain that it will only happen when they initiate the call, i.e., they are the msg.sender
.
The second path to depositing funds, function depositWithPermit
, however, allows depositing funds belonging to someone else (target
), as long as the permit
call succeeds.
For ERC-20 tokens that support it, permit
is an alternative to the standard approve
call: it allows an off-chain secure signature to be used to register an allowance. The permitter is approving the beneficiary to spend their money, by signing the permit request. The permit
approach has several advantages: there is no need for a separate transaction (spending gas) to approve a spender, allowances have a deadline, transfers can be batched, and more.
The problem in this case, as discussed earlier, is that the WETH token has a phantom permit
, so the call to it is a non-failing no-op. Still, this should be fine, right? How can a no-op hurt? The permit
did not take place, so no approval/allowance to spend the target
’s money should exist.
Unfortunately, however, the contract already has the approvals of all clients that have ever used the first deposit path (function deposit
)!
All WETH of all such clients can be stolen, by a mere depositWithPermit
followed by a withdraw
call. (To avoid front-running, an attacker might split these two into different transactions, so that the gain is not immediately apparent.)
Phantom Functions | Notes:
Two separate vulnerabilities are based on the above attack vector. The first was outlined above. The second, on AnySwap router contracts, is a little harder to exploit — requires impersonating a token of a specific kind. We do not illustrate in detail because the purpose of this quick writeup is to inform the community of the attack vector, rather than to illustrate the specifics of an attack.
We have exhaustively searched for other services with similar vulnerable code and exposure. This includes vulnerable contracts with approvals over tokens with phantom permits other than WETH . Although we have found other instances of the vulnerable code patterns, the contracts currently have very low or zero approvals on Ethereum. (This kind of research is exactly what our contract-library.com analysis infrastructure lets us do quickly.) On other chains, our search has not been as exhaustive, since we have no readily indexed repository of all deployed contracts. However, our best indicators suggest that there is no great exposure outside the AnySwap/Multichain contracts.
Concluding
We have been awarded Multichain’s maximum published bug bounty of $1M for each of the two vulnerability disclosures. (Thank you for the generous recognition of this extraordinary threat!)
This was an attack discovered by first suspecting the pattern and then looking for it in actual deployed contracts. Although in hindsight the attack vector is straightforward, it was far from straightforward when we first considered it. In fact, our initial exchange, at 2:30am on a Sunday, was literally:
“I had a crazy idea for a vulnerability. Want to sanity check the basics?”
Crazy, indeed, how this could lead to one of the largest hacks in history.