Platform: Code4rena
Start Date: 29/07/2022
Pot Size: $50,000 USDC
Total HM: 6
Participants: 75
Period: 5 days
Judge: GalloDaSballo
Total Solo HM: 3
Id: 149
League: ETH
Rank: 2/75
Findings: 2
Award: $7,836.03
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: xiaoming90
https://github.com/code-423n4/2022-07-axelar/blob/3729dd4aeff8dc2b8b9c3670a1c792c81fc60e7c/contracts/AxelarGateway.sol#L262 https://github.com/code-423n4/2022-07-axelar/blob/3729dd4aeff8dc2b8b9c3670a1c792c81fc60e7c/contracts/auth/AxelarAuthWeighted.sol#L86 https://github.com/code-423n4/2022-07-axelar/blob/9c4c44b94cddbd48b9baae30051a4e13cbe39539/contracts/auth/AxelarAuthWeighted.sol#L36
The administrator will call AxelarAuthWeighted.transferOperatorship
function to transfer the operatorship to a new set of {Operators/Weights/Threshold}.
However, it was observed that after transferring the operatorship to a new set of {Operators/Weights/Threshold}, the previous sets of {Operators/Weights/Threshold} are still able to generate a valid proof, and subsequently execute the command.
The following piece of code shows that as long as valid proof is submitted, the commands will be executed by the system.
function execute(bytes calldata input) external override { (bytes memory data, bytes memory proof) = abi.decode(input, (bytes, bytes)); bytes32 messageHash = ECDSA.toEthSignedMessageHash(keccak256(data)); // TEST auth and getaway separately bool currentOperators = IAxelarAuth(AUTH_MODULE).validateProof(messageHash, proof); ..SNIP.. }
The following piece of code shows that the past 16 sets of {Operators/Weights/Threshold} are considered valid and can be used within the AxelarAuthWeighted._validateSignatures
function. Thus, the past 16 sets of {Operators/Weights/Threshold} are able to sign and submit a valid proof, and the proof will be accepted by the AxelarAuthWeighted.validateProof
that allows them to execute the commands.
uint8 internal constant OLD_KEY_RETENTION = 16; function validateProof(bytes32 messageHash, bytes calldata proof) external view returns (bool currentOperators) { (address[] memory operators, uint256[] memory weights, uint256 threshold, bytes[] memory signatures) = abi.decode( proof, (address[], uint256[], uint256, bytes[]) ); bytes32 operatorsHash = keccak256(abi.encode(operators, weights, threshold)); uint256 operatorsEpoch = epochForHash[operatorsHash]; uint256 epoch = currentEpoch; if (operatorsEpoch == 0 || epoch - operatorsEpoch >= OLD_KEY_RETENTION) revert InvalidOperators(); _validateSignatures(messageHash, operators, weights, threshold, signatures); currentOperators = operatorsEpoch == epoch; }
Understood from the team that the reason for allowing past 16 sets of {Operators/Weights/Threshold} is that after a transfer of operatorship, the commands that were signed recently but have not been executed yet will not become invalid. Further understood from the team the operatorship transfer is performed when there is a significant change in stake distribution on the Axelar network.
It makes sense for commands that were already signed recently by past operators before the operatorship transfer to be executable. However, based on the current design, it is also possible for the past 16 sets of {Operators/Weights/Threshold} to submit a new valid proof/signature for new commands to be executed after the operatorship transfer, and the AxelarAuthWeighted._validateSignatures
function will happily accept the proof/signature, which should not be allowed.
It was understood that the operatorship transfer is performed when there is a significant change in stake distribution on the Axelar network, therefore, it does not make sense for all the past 16 sets of {Operators/Weights/Threshold} to be still able to sign and execute new commands after the operatorship transfer, because they follow the old stake distribution that is no longer considered as valid.
Only the current set of operators and its stake distribution should be used to verify any new command signed and issued after the operatorship transfer.
Assuming that there are 3 validators (Alice, Bob and Charles)
At T1, the following is the state:
currentEpoch = 1
hashForEpoch[Epoch 1] = {operators: [Alice, Bob, Charles], weights: [0.5, 0.25, 0.25], threshold: 0.5} convert to hash
At T1, Alice could submit the following input to AxelarGateway.execute(bytes calldata input)
function to execute the commands:
input = {
​ bytes memory data = commands to be executed
​ bytes memory proof = {operators: [Alice, Bob, Charles], weights: [0.5, 0.25, 0.25], threshold: 0.5, signatures: [Alice's signature]}
}
Since Alice's signature weight is 0.5, having Alice's signature alone is sufficient to meet the threshold of 0.5 and the commands will be executed.
At T2, the Axelar administrator decided to change the stake distribution. The admin called the AxelarAuthWeighted.transferOperatorship
and change the state to as follows:
currentEpoch = 2
hashForEpoch[Epoch 2] = {operators: [Alice, Bob, Charles], weights: [0.25, 0.4, 0.4], threshold: 0.5} convert to hash <== newly added
hashForEpoch[Epoch 1] = {operators: [Alice, Bob, Charles], weights: [0.5, 0.25, 0.25], threshold: 0.5} convert to hash
At T2, Alice's weight has reduced from 0.5
to 0.25
. As per the current stake distribution, Alice's signature alone is not sufficient to meet the threshold of 0.5
. Thus, she is not able to execute any new command without an additional signature from Bob or Charles.
However, note that the past 16 sets of {operators/weights/threshold} are considered valid by the system, so in another word, all the past 16 stake distributions are considered valid too.
Thus, Alice simply needs to re-use back to the previous set of {operators/weights/threshold} in Epoch 1 and she can continue to execute new commands without the signature of Bob or Charles, thus bypassing the current stake distribution.
At T2, Alice could still submit the following input to AxelarGateway.execute(bytes calldata input)
function with only Alice's signature to execute the command:
input = {
​ bytes memory data = commands to be executed
​ bytes memory proof = {operators: [Alice, Bob, Charles], weights: [0.5, 0.25, 0.25], threshold: 0.5, signatures: [Alice's signature]}
}
No additional signature from Bob or Charles is needed.
Following is from Epoch 1
{operators: [Alice, Bob, Charles], weights: [0.5, 0.25, 0.25], threshold: 0.5
Noted from the discord channel the following clarification from the team.
Based on couple of questions I have received, I'd like to clarify one assumption we are making for the contracts (which is enforced at the axelar proof of stake network): Operators correspond to validators on the Axelar network. However, the operator address for a given epoch is derived from the validator key along with a nonce that is unique for each operator epoch. i.e Whenever operatorship is being transferred, an honest validator will always generate a new operator address (and not reuse their old one) due to a nonce.
With this control in place, even if the validator has generated a new operator address after the operatorship has been transferred, it is still possible for the validator to re-use back the old operator address and sign the command as the validator is aware of the private key needed to sign on behalf of the old operator address. Thus, the above issue still exists.
Additionally, there is always a risk of a "dishonest" validator not generating a new operator address after operatorship is being transferred if the new stake distribution does not benefit them. In the above example, Alice who has its weightage reduced from 0.5 to 0.25 do not see the benefit of the new stake distribution can decide not to generate a new operator address and continue to use the old operator address that allowed her to sign and execute any command without an additional signature from Bob or Charles.
Current stake distribution can be bypassed.
Consider updating the system to ensure that the following requirements are followed:
#0 - sseefried
2022-08-04T10:02:05Z
Duplicate of #98
#1 - re1ro
2022-08-05T08:44:23Z
Good spot. I think we could include the timestamps to prevent old operators to sing any new commands
#2 - GalloDaSballo
2022-08-28T23:28:35Z
@re1ro you "confirmed" this one but disputed #98
Would you like to dispute this one as well?
#3 - GalloDaSballo
2022-09-04T21:46:50Z
Not dup of #98 this is a different finding, 98 just though that the old validators being able to execute is vulnerability, which is not correct.
This report talks about when validator sign commands being important for validation
#4 - GalloDaSballo
2022-09-04T21:52:00Z
Per the Warden POC - Stake Distribution from past epochs is not changed, meaning a transferOperatorship
called by the owner
with the goal of reducing weights for a specific operator will be circumventable.
This implies:
The "need to remove a bad operator" is definitely contingent on setup, so Medium Severity is definitely fair.
I'll think about raising or keeping as Med
#5 - GalloDaSballo
2022-09-06T18:55:33Z
In contract to other reports, this submission shows a reasonable path forward to invalidate old operators, while allowing them to re-try old commands
For this reason I think this is distinct from #19 etc
🌟 Selected for report: oyc_109
Also found by: 0x1f8b, 0x52, 0xNazgul, 0xSmartContract, 0xf15ers, 8olidity, Aymen0909, Bnke0x0, CertoraInc, Chom, CodingNameKiki, Deivitto, Dravee, ElKu, IllIllI, JC, Lambda, Noah3o6, NoamYakov, RedOneN, Respx, ReyAdmirado, Rohan16, Rolezn, Ruhum, Sm4rty, TomJ, Twpony, Waze, Yiko, __141345__, ajtra, apostle0x01, ashiq0x01, asutorufos, bardamu, benbaessler, berndartmueller, bharg4v, bulej93, c3phas, cccz, ch13fd357r0y3r, codexploder, cryptonue, cryptphi, defsec, djxploit, durianSausage, fatherOfBlocks, gogo, hansfriese, horsefacts, ignacio, kyteg, lucacez, mics, rbserver, robee, sashik_eth, simon135, sseefried, tofunmi, xiaoming90
136.7488 USDC - $136.75
operators
and weights
Array Length Is The SameThe AxelarAuthWeighted._validateSignatures
function did not validate that the operators
and weights
array length are the same.
function _validateSignatures( bytes32 messageHash, address[] memory operators, uint256[] memory weights, uint256 threshold, bytes[] memory signatures ) internal pure { uint256 operatorsLength = operators.length; uint256 operatorIndex = 0; uint256 weight = 0; // looking for signers within operators // assuming that both operators and signatures are sorted for (uint256 i = 0; i < signatures.length; ++i) { address signer = ECDSA.recover(messageHash, signatures[i]); // looping through remaining operators to find a match for (; operatorIndex < operatorsLength && signer != operators[operatorIndex]; ++operatorIndex) {} // checking if we are out of operators if (operatorIndex == operatorsLength) revert MalformedSigners(); // return if weight sum above threshold weight += weights[operatorIndex]; // weight needs to reach or surpass threshold if (weight >= threshold) return; // increasing operators index if match was found ++operatorIndex; } // if weight sum below threshold revert MalformedSigners(); }
Implement the following check to ensure that the operators
and weights
array length are the same.
require(operators.length == weights.length, "operators and weights length not the same")
#0 - re1ro
2022-08-05T09:12:17Z
Not an issue.
It's checked in _transferOperatorship
Dup #16
#1 - GalloDaSballo
2022-08-31T21:23:29Z
Per the sponsor comment, as well as: https://github.com/code-423n4/2022-07-axelar/blob/9c4c44b94cddbd48b9baae30051a4e13cbe39539/contracts/auth/AxelarAuthWeighted.sol#L103-L104
if (operatorIndex == operatorsLength) revert MalformedSigners();
If the length surpasses operatorsLength
we'll get a revert, meaning that while the check will help with an earlier revert, it wont' cause any vulnerability.
I think because early failure is a coding convention, the finding is a valid Refactoring but not a vulnerability