For documentation purposes, this is a double post of a critical bug found in some Cosmos-sdk chains.
It does NOT affect FunctionX as we have discovered and patched it and currently informing the greater Cosmos community too.
Background
This vulnerability is found in precompiled contract module. It allows attackers to infinitely increase their delegation without deducting their token balance, in other words, the attacker is able to mint tokens infinitely.
Who was affected (not anymore)
This vulnerability has been found and fixed in Evmos and FunctionX/PundiX.
FunctionX/PundiX discovered this bug and patched it in June 2024. Evmos team discovered this bug independently and patched it yesterday on 4 July 2024.
Another precompile implementor chain Cronos do NOT have this bug at all.
Other chains using similar implementations are strongly advised to check and patch.
Who is still affected
If a Cosmos-SDK chain implements a staking precompiled contract with delegate function, they might be affected.
As of 5 July GMT+8 there are at least two other Cosmos-SDK mainnet known to have this bug, they are comparitively less big projects. Even if projects did NOT fork from Evmos or FunctionX/PundiX it is still possible to have this bug if precompile staking is implemented, as chances are dev will use try/catch to handle error.
Causes
Before we go deep into the vulnerability, let’s understand 2 keys functions that caused the possible exploit.
Staking precompiled contract is a custom EVM extension that is built into some Cosmos-sdk’s Ethereum Virtual Machine (EVM) basic feature set. It offers staking(delegation) to validators, which can be used to build complex smart contract and improve interoperability between Cosmos and Ethereum.
Staking Precompiled Contract
Address: 0x0000000000000000000000000000000000001003
Method
delegate -> Delegate token to validator, get shares and reward
function delegate(string memory _val) external payable returns (uint256 _shares, uint256 _reward);
* msg.value: payable method, the amount of the token to be delegated
* _val: the validator address
* _shares: the shares of the delegate token
_reward: the reward of the delegate
end of code example
In v0.6.0, addition of try/catch help solidity’s move away from the purist “all-or-nothing” approach in a transaction lifecycle to help handle complex external call failures without having to roll back an entire transaction.(state changes in the try/catch called function are still rolled back, but the ones in the calling function are not)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract HelloWorldMsg {
string public message;
bytes public errorData;
function saveMessage() external returns (string memory) {
message = "Hello, World!";
revert("testing");
}
function helloWorld() public {
try this.saveMessage() {
// do something
} catch Error(string memory reason) {
errorMessage = reason;
}
}
end of code example
Attack Scenarios
An attacker contract that holds X amount of token balances allows the attacker contract to delegate X amount of tokens to Y validator.
This exploit arises when attacker contract’s function makes an external call to the precompiled contract’s delegate function to update Cosmos delegation state changes and follow up with a revert function in a try/catch
statement to roll back all the EVM state changes in the called function, which means all delegated tokens will be refunded back to the attacker contract from EVM side.
The external call failure(delegate) will be handled by try/catch
, committing a successful EVM transaction. As a result, the delegation state changes in Cosmos are not rolled back to the pre-transaction state.
This allows the attacker to manipulate the state of the delegation but doesn’t decrease their tokens balance.
function tryCatchStaking() public payable {
require(msg.value == 1000000000000000000, "require msg.value = 1");
try this.delegateRevert() {
emit Log("call success");
} catch {
emit Log("call failed");
}
}
function delegateRevert() public payable {
this.delegate{value: 1000000000000000000}("validator", 1000000000000000000);
revert("revert");
}
end of code example
Mitigations
-
Temporary solution: revert the whole EVM transaction when revert happens in EVM transaction. This will revert Cosmos state change in the transaction.
-
Future solution: Rolling back the Cosmos state change whenever a revert happens within an EVM transaction, this implementation is used in Crypto.com’s Cronos.
Mitigation Example (EVMOS):
- EVMOS Precompiled contract - Bug fixed in commit GHSA-68fc-7mhg-6f6c.
- Use cacheCtx on stateDB and precompile calls. It allows to commit the current journal entries to get the updated state for the precompile call.
- Update dirty state to keeper using the cacheCtx. This function is used before any precompiled call to make sure the cacheCtx is updated with the latest changes within the tx.(StateDB’s journal entries)
- MaxPrecompileCalls has been limited to 7 within a transaction to prevent the creation of excessive cached context.
- Add revert test case for distr precompile. Snapshot contains all state and events previous to the precompile call to allow revert the changes during the EVM execution.