The Chainswap Hack — More than Meets the Eye

Oren Fine
SphereX Technologies
5 min readApr 24, 2023

During our journey in SphereX, we encountered the Chainswap hack and discovered an unexpected behavior. Chainswap, the Alameda backed “cross-chain hub for all ecosystems,” was hacked twice, resulting in a loss of over $5M in less than a week. This post, regarding the second hack, highlights that what is visible on Etherscan does not necessarily reflect what is happening on-chain. While the source code for the verified implementation is correct, the call was delegated to a different, unverified contract. The supposed exploit is quite different from what was originally reported.

What are the odds of lightning striking twice? And what are the chances of a DeFi protocol being hacked twice within a week? This article delves into an untold story of the second Chainswap protocol hack, in which $4.4 million was stolen on July 11th, 2021, just a week after an $800k theft on July 2nd.

Background

The Chainswap hack incident has been covered by numerous blog posts and articles. Rekt.news mentioned a “sloppy auth check,” while others referred to a critical bug with the same root cause, along with code snippets. In their official post mortem, Chainswap describe the second hack as follows:

“a bug in the token cross-chain quota code… due to a logical flaw in code, this led to an exploit by allowing invalid addresses which weren’t whitelisted to automatically increase the amount.”

In theory

Let’s try to locate the bug by examining one of the attacker’s transactions. The attacker sends the transaction to an Etherscan-verified proxy contract, invoking the receive() function. As per Etherscan:

“ABI for the implementation contract at 0x3c894caf21f18f42d8d06daf26983c4b6a32fc1c, likely using a custom proxy implementation.”

This implementation contract is also Etherscan-verified. Moreover, we came across an audited version of the implementation contract that was identical.

We did not identify any issues with the signature verification process. The verification process is correct, and the signing address is then passed to _decreaseAuthQuota(). It must have adequate quota; otherwise, it would revert when the quota is deducted:

function _decreaseAuthQuota(address signatory, uint decrement) virtual internal returns (uint quota) {
quota = authQuotaOf[signatory].sub(decrement);
authQuotaOf[signatory] = quota;
emit DecreaseAuthQuota(signatory, decrement, quota);
}

The definition of authQuotaOf mapping:

mapping (address *=> uint) public authQuotaOf; // signatory => quota*

Any function that increases the quota is protected by a permissioned modifier, either onlyFactory or onlyAuthorty [sic], code is audited, storage defaults to zero, quotas can only be increased by onlyFactory or onlyAuthorty, and a signer with no quota shouldn’t be able to withdraw anything. It appears to be safe.

In practice

However, when you try to query authQuotaOf() with any random address, you get a surprising answer. Rather than the default zero, you get 10²² for every random EOA address. (practically) Unlimited quota for everyone?! Let’s try to find out where this is coming from.

Tracing the transaction, we reach the point where the function productImplementations(bytes32) is invoked in the factory contract with the string “TokenMapped”. It is then delegated to the factory implementation, which returns the address of a closed-source contract.

 return a contract address. From tenderly.co
productImplementations return a contract address. From tenderly.co
Closed source contract (March 7th, 2023). From Etherscan.io

The call is then delegated to that address with the original input parameters.

 to closed source contract, with original input parameters. From tenderly.co
delegatecall to closed source contract, with original input parameters. From tenderly.co

To summarize: Etherscan directs us to a verified and supposedly correct implementation, though eventually we’re directed to a closed-source contract.

“In theory, theory and practice are the same. In practice, they are not.” Albert Einstein

Using TrueBlocks we found the transaction that updated the implementation’s address to the closed source contract. here it is, updated by Chainswap’s deployer address a week prior to the attack.

Since we don’t have access to the source code from this point, we are navigating without a map. Our next step is to investigate the closed-source contract to find out what’s going on.

Based on the trace, the closed source contract calls the factory contract’s getConfig() five times using the input strings: “fee”, “feeTo”, “minSignatures”, “autoQuotaRatio” and “autoQuotaPeriod”. The first three strings appear in the original implementation contract code, whereas the latter two do not.

Looking for those two missing strings, we were able to find another “TokenMapped” verified contract, containing those strings. Here, authQuotaOf()is not a simple mapping anymore but a function that calls getConfig() in the factory contract with the config string “autoQuotaRatio”, and that’s where the 10²² value comes from.

function authQuotaOf(address signatory) virtual public view returns (uint quota) {
quota = _authQuotas[signatory];
uint ratio = autoQuotaRatio != 0 ? autoQuotaRatio : Factory(factory).getConfig(_autoQuotaRatio_);
uint period = autoQuotaPeriod != 0 ? autoQuotaPeriod : Factory(factory).getConfig(_autoQuotaPeriod_);
if(ratio == 0 || period == 0 || period == uint(-1))
return quota;
uint quotaCap = cap().mul(ratio).div(1e18);
uint delta = quotaCap.mul(now.sub(lasttimeUpdateQuotaOf[signatory])).div(period);
return Math.max(quota, Math.min(quotaCap, quota.add(delta)));
}

This behavior corresponds with what we have observed on-chain, but we are unable to confirm if it is exactly the code we are looking for. decompiling the closed-source contract, indicated that the contracts are not identical.

Bottom lines:

  1. In theory, we have a correct and verified implementation on Etherscan (though Etherscan issues a warning about a “likely” custom proxy implementation). In practice, we ended up using a closed-source implementation version that was deployed several days before the attack. What you see on Etherscan is not necessarily what you get.
  2. The bug that facilitated the attack is the default high quota assigned to every address. None of the publicly available posts regarding the attack mentioned this, including the official post-mortem report. This vulnerability was present and available for anyone to exploit a week prior to the attack.
  3. DYOR is always good advice, but it must be acknowledged that it would have been practically impossible for anyone without in-depth technical knowledge to identify the issues with Chainswap. As Web3 strives towards mass adoption, it necessitates more advanced tools and capabilities to make DYOR more effective.

This post is based on joint work with Yoav Weiss. We thank him for the fruitful collaboration!

--

--