Platform: Code4rena
Start Date: 30/05/2023
Pot Size: $300,500 USDC
Total HM: 79
Participants: 101
Period: about 1 month
Judge: Trust
Total Solo HM: 36
Id: 242
League: ETH
Rank: 24/101
Findings: 1
Award: $2,539.07
đ Selected for report: 1
đ Solo Findings: 0
2539.0711 USDC - $2,539.07
This analysis report delves into various sections and modules of the MaiaDAO protocol that the RED-LOTUS team audited, specific sections covered are listed within the table of contents above.
Our approach included a thorough examination and testing of the codebase, research on wider security implications applicable to the protocol and expanded discussion of potential out of scope/known issues from the audit contest.
A number of potential issues were identified related to centralization and inherent systemic risks associated with specific functionality of the protocol. We supplied feedback on specific sections of architecture and give other recommendations as relevant.
Throughout our analysis, we aimed to provide a comprehensive understanding of the codebase, suggesting areas for possible improvement. To validate our insights, we supplemented our explanations with illustrative figures, demonstrating robust comprehension of Maia's internal code functionality and interaction with 3rd party services.
Over the course of the 36-day contest, we dedicated approximately 540+ hours to auditing the codebase. Our ultimate goal is to provide a report that will give the project team wider perspective and value from our research conducted to strengthen the security posture, usability and efficiency of the protocol.
This analysis report and included diagrams are free to be shared with other parties as the project team see fit.
admin
, pendingAdmin
, and functions for administrative transitions (_setPendingAdmin
, _acceptAdmin
).add256
and sub256
are redundant and lead to unnecessary gas consumption.getChainIdInternal
function uses inline assembly to fetch the chain ID. While this is a standard operation, caution is required as it bypasses Solidityâs safety checks.admin
role introduces a certain level of centralization risk. If the admin
key is compromised, it could pose a serious threat to the system.GovernorAlpha
and timelock
contracts. If any of these contracts have vulnerabilities, it would affect this contract.add256
and sub256
functions for gas optimization since overflow and underflow protections are already present in Solidity 0.8.x.function propose(address[] memory targets,uint256[] memory values,string[] memory signatures,bytes[] memory calldatas,string memory description) public returns (uint256)
The propose
function allows token holders to propose new proposals for changes to the platform's protocol. However, there are certain requirements that need to be met before a proposal can be submitted. The function checks if the Governor Bravo is active, if the proposer has enough votes above the proposal threshold, and if the proposal contains the necessary information. Once these conditions are met, the function creates a new proposal and assigns it a unique proposal ID.
propose
function is called by token holders who want to propose a new proposal .The propose
function indirectly interacts with the following functions within the vMaia Governor Bravo contract:
totalSupply()
: This function is called in the getQuorumVotesAmount()
function to calculate the total supply of the governance token .govToken.totalSupply()
: This function is called in the getQuorumVotesAmount()
function to get the total supply of the governance token .govToken.getPriorVotes()
: This function is called in the require
statement to check if the proposer has enough votes above the proposal threshold .isWhitelisted()
: This function is called in the require
statement to check if the proposer is whitelisted .add256()
: This function is called to calculate the startBlock
and endBlock
values for the new proposal .emit ProposalCreated()
: This event is emitted after a new proposal is successfully created .These functions are not directly called within the propose
function, but they are referenced or used in the requirements and checks performed by the propose
function.
function castVoteBySig(uint256 proposalId, uint8 support, uint8 v, bytes32 r, bytes32 s) external
The castVoteBySig
function is an external function within the vMaia Governor Bravo contract. It allows for casting a vote for a proposal using an EIP-712 signature. This function accepts the proposal ID, the support (yes or no), and the signature parameters (v, r, s). It verifies the signature and then casts the vote for the specified proposal.
castVoteBySig
function is an external function that can be called by anyone who wants to cast a vote for a proposal using a signature .The castVoteBySig
function does not have any explicit checks on the signatory variable. Therefore, anyone who has access to a valid signature can potentially cast a vote using this function. The function relies on the validity of the signature to ensure that the vote is authorized.
Idle ProposalCreated Voting PendingExecution Executed AdminChangingVotingParameters AdminChangingWhitelist AdminTransfer
Idle -> ProposalCreated
_propose
*- Attack vectors:
ProposalCreated -> Voting *- Transition happens automatically after the voting delay has passed.
Voting -> PendingExecution
- Function: castVote
*(continuously through the voting period)
PendingExecution -> Executed
*- Function: _*execute
Idle -> AdminChangingVotingParameters
_setVotingDelay, _*setVotingPeriod, *_setProposalThreshold
Idle -> AdminChangingWhitelist
*- Function: _*setWhitelistAccountExpiration, _setWhitelistGuardian
*- Attack vectors:
Idle -> AdminTransfer
*- Function: _*setPendingAdmin
AdminTransfer -> Idle
_acceptAdmin
*- Attack vectors:
bHermes
contract managing three different token contracts (bHermesBoost
, bHermesGauges
, bHermesVotes
), each with different functionalities (boost, gauges, votes) is unique.onlybHermes
modifier to restrict access.bHermes
) managing these seems to be a good design choice. It isolates functionalities and makes the codebase cleaner and easier to manage.bHermesBoost
, bHermesGauges
, and bHermesVotes
contracts have the onlybHermes
modifier, which implies that only the bHermes
contract can call certain functions. While this can provide security, it does centralize control to the bHermes
contract. Also, the bHermes
contract is owned by _gaugeBoost
in UtilityManager
, making the _gaugeBoost
address the ultimate controller of the system.bHermes
contract as well.bHermes
contract can be a systemic risk if the contract has bugs or is compromised.Front-running Issue
ERC20MultiVotes
contract, users have the ability to delegate their tokens to another user.undelegate
function. If a delegatee is alerted to an impending undelegate
transaction in the mempool, they may opt to front-run this transaction.undelegate
transaction will revert.Deprecated Gauges
decrementGaugeBoost
function does not operate identically to the decrementGaugeBoostIndexed
function, particularly with regards to deprecated gauges.
decrementGaugeBoost
, a capability not afforded by decrementGaugeBoostIndexed
.States:
Uninitialized -> Idle
constructor
gaugeWeight
, gaugeBoost
, and governance
contracts does not get front-ran.Idle -> Minting
_mint
Idle -> Claiming
claimOutstanding
, claimMultiple
, claimMultipleAmounts
, claimWeight
, claimBoost
, claimGovernance
Idle -> Forfeiting
forfeitMultiple
, forfeitMultipleAmounts
, forfeitWeight
, forfeitBoost
, forfeitGovernance
Idle -> Transferring
transfer
, transferFrom
stakeFlag
, flywheel
, and boostAggregator
) and interacting with external contracts (IUniswapV3Pool
, BoostAggregator
, FlywheelCoreInstant
), which is good for separating concerns.performUpkeep
function for better off-chain monitoring and tracking of state changes.strategyManager
and owner
addresses, which introduce a certain level of centralization risk. If these addresses are controlled by a malicious entity, they could disrupt Talosâs operations or perform actions that are not in the best interests of other stakeholders.BoostAggregator
contract. If there are bugs or vulnerabilities in those contracts, or if they change their behavior, it could impact the functioning of Talos.owner
instead of directly setting it via constructor arguments.Uniswap Interactions
init
, withdrawAll
, and deposit
, there is no slippage check with amount0Min
and amount1Min
. This could result in a loss of funds under certain scenarios and edge cases.redeem
allows users to specify amount0Min
and amount1Min
. This is good; however, there is no check to ensure that these values won't result in a loss of funds. In the hands of an inexperienced user, this is no different from setting them to 0.deadline
is utilizing block.timestamp
. Although through proof of stake, it is more difficult to manipulate the block.timestamp
, it is not impossible.(amount0, amount1)
is overwritten with the fees. This does not present a vulnerability; however, in the future, it may cause issues if there are changes.Share Conversions
nonfungiblePositionManager.increaseLiquidity
does not use all of the tokens allotted to it. Instead, it adds disproportionate values of each token in a way that keeps the pool balanced. Due to this, Talos is able to utilize the total liquidity added to the pool (liquidityDifference
) for their share calculations without measuring the value of each token specifically. (E.g., User A calls deposit
with 10e18
for both amount0Desired
and amount1Desired
. Suppose the pool only uses 3 of token0 to pair up with 9 of token 1, then liquidityDifference = 12
, and we can carry on with the share calculation.Idle -> CheckingUpkeep
checkUpkeep
CheckingUpkeep -> Rebalancing
performUpkeep
getRebalance(strategy) == true
strategy.rebalance
).CheckingUpkeep -> Reranging
performUpkeep
getRerange(strategy) == true
strategy.rerange
).Rebalancing -> Idle
strategy.rebalance()
Reranging -> Idle
strategy.rerange()
Initialized -> NotStaked
constructor
NotStaked -> Staked
_stake
stakeFlag
state.Staked -> NotStaked
_unstake
stakeFlag
state.Idle -> FeesAccumulating
FeesAccumulating -> FeesEarned
_earnFees
beforeRedeem
) or deposit (beforeDeposit
), or before reranging the position (beforeRerange
).FeesEarned -> FeesCompounded
_compoundFees
beforeRedeem
) or deposit (beforeDeposit
).FeesCompounded -> PositionRebalanced
PositionRebalanced -> Idle
_normalizeDecimals()
and _denormalizeDecimals()
.test/ulysses-omnichain/BranchBridgeAgentTest.t.sol
to use 6 decimals instead of 18:
underlyingToken = new MockERC20("underlying token", "UNDER", 6);
forge test --match-contract BranchBridgeAgentTest
will result in multiple test failures.testCallOutWithDeposit()
, providing insight into the scenario that caused the failure.BaseBranchRouter::callOutAndBridge()
through the testCallOutWithDeposit()
function, it is forwarded to BranchBridgeAgent::performCallOutAndBridge
, which in turn calls the internal function _callOutAndBridge
.packedData
variable._depositAndCall()
.packedData
, but it is passed as is to _depositAndCall()
._depositAndCall()
are subsequently passed to _createDepositSingle()
, which performs the following actions:
_deposit
value.BranchPort::bridgeOut()
to retrieve tokens from the user.bridgeOut()
, when the contract pulls the tokens, it denormalizes the _deposit
value (which was never normalized). This leads to an underflow, resulting in the entire transaction reverting and preventing the user from depositing the token._normalizeDecimals()
and _denormalizeDecimals()
to produce the correct output._deposit
value when passing it to BranchBridgeAgent::_depositAndCall()
._deposit
value when storing it in the getDeposit
mapping.Multichainâs AnyCall router is at its version 7 release. What is convenient is that it is an cross-chain solution that can work on both L1s and L2s without needing the cross-chain infrastructure implemented by dApps themselves.
What is challenging with AnyCall involves 3 issues:
Part of the consequence of the poor documentation and MultiChainâs poor communication (mainly reachability on the Discord server) seems to have led to the following problem. Because itâs not clear how to implement the protocol correctly, and developer relations on the server is difficult to find so that confusion can be cleared, the following outcome happens
anyCall
accepts 2 different execution gas payment schemes:
The documentation and intention (image below) of the ulysses omnichain protocol remark that "pay on destination" is how the system is made to execution gas payments. However the opposite is true. Weâll see that this issue goes a bit deeper. In BranchBridgeAgent.sol
, the call to anyCall
for the branch chain system is configured with the equivalent flag for "pay on source", FLAG_ALLOW_CALLBACK
:
/** * @notice Internal function performs call to AnycallProxy Contract for cross-chain messaging. * @param _calldata ABI encoded function call. */ function _performCall(bytes memory _calldata) internal virtual { //Sends message to AnycallProxy IAnycallProxy(localAnyCallAddress).anyCall( rootBridgeAgentAddress, _calldata, rootChainId, AnycallFlags.FLAG_ALLOW_FALLBACK, "" ); }
AnycallV7Upgradeable.sol
and AnycallV7Config.sol
See the screenshots below. They show the relevant code for what happens when IAnycallProxy.anyCall
is executed.
For #1, we can see the actual on-chain anyCall
function call. The key line is underlined in red. All calls here can't pass without paying fees (through _paySrcFees
) on the source chain. The calculation can be seen in [1].
What happens next in _paySrcFees
is in image #2, underlined in red. There's a requirement require(msg.value >= fees, "no enough src fee")
. This is the trap. There won't be a msg.value
in the call to IAnycallProxy.anyCall
, as seen in image #3.
Yes, but they use a mock with a fake anyCall
[2], not the actual contracts though a forking test. What we get in the tests are placeholder addresses and mocks that return pre-determined values. For that reason it was likely missed that the misconfigured integration with AnyCall was missed.
The current testing strategy involves creating mocks for a large number of system objects. That includes 3rd-party tokens, internal contracts, and external protocols such as AnyCall itself. While it is helpful to be able to control the execution environment within a testing suite, the lack of exposure to actual systems in tests - especially those of cornerstone systems such as AnyCall in the Ulysses Omnichain tests - exposes more risk to failed assumptions than is necessary.
Using the Foundry toolkitâs forking tests is a useful mitigation to this. Without needing to deploy local chains and relevant 3rd-party contracts, tests can be created that can utilize and instrument deployed versions of important systems, with the full control of EVM parameters. These can be combined with the mocking system in Foundry to create tests that are closer to real-world parameters, and they help expose and correct assumptions and bugs in the codebase under test.
There are many instances where possible reentrancy is potentially introduced in the codebase. A large number of external-facing functions in BranchBridgeAgent.sol
alone demonstrates this case. Using Slither on the codebase, we can see this more clearly:
INFO:Slither:./src/ulysses-omnichain/*.sol analyzed (268 contracts with 5 detectors), 145 result(s) found
145 possible cases of reentrancy were flagged in the src/ulysses-omnichain/
folder alone.
While most code paths were not able to be explored in the time allotted for the audit, there is a greather-than-zero chance that viable reentrancy is introduced due to violations of the check-effects-interactions pattern.
@inheritdoc
The copious use of NatSpec in the codebase has been very welcome. In general it provides good documentation for the intention of each function.
What could be improved on is the use of @inheritdoc
in the implementations of various interfaces. While the files containing the interfaces themselves have the documentation, the implementation files are likely to lack useful NatSpec in lieu of @inheritdoc
tags, making the process of auditing those files more tedious, since switching contexts between the two files is necessary, and the fact there is no built-in command apparent in the project to generate documentation from the NatSpec comments themselves.
It can be observed that the use or deposit of poisoned or weird ERC-20 tokens was marked as a known issue by the project team. It is stated by the project team in the C4 Contest details that:
âOur protocol has permissionless factories where anyone can create with poison 20 tokens or add poison erc20 tokens. While contracts generated by these are not in scope, if it does affect other contracts or other balances, it is in scope.â
However, we believe this functionality should be thoroughly investigated and mitigating controls implemented.
What would occur if a user did deposit malicious or âweirdâ ERC20 tokens and what strategies can be utilized to protect the protocol from such malicious use cases?
Although there are multiple variations of malicious or weird ERC20 tokens that can deposited, there are a selection of potential hazardous implementations. Specifically, Fee on Transfer, Rebasing, Flash-Mintable, Tokens with Blacklists such as USDC and USDT are viewed as potentially hazardous to the wider functionality of Ulysses and reputation of the protocol if unsophisticated users accidentally utlize them.
Branch Ports serve as a vault with a single-asset pool for each Omnichain token active in a given chain. Due to the burn and mint process in a swap, if any of the hazardous tokens mentioned above were deposited, it could disrupt accurate accounting between pools during swaps.
It is well understood that an on-chain, contract-level allow-list of known good tokens is not a viable mitigating control due the permission-less nature of Ulyssess Omnichian. It is recommended that an off-chain allow-list in the official UI to protect unsophisticated users from utilizing underlying or associated hTokens that violate the expectations of how the Omnichain protocol should function.
While exploring the Ulysses-Omnichain codebase, it was helpful to diagram the architecture. Here are some diagrams that resulted.
NOTE: these diagrams were generated with PlantUML. Source can be provided by request.
540 hours
#0 - c4-judge
2023-07-11T14:17:12Z
trust1995 marked the issue as grade-a
#1 - c4-judge
2023-07-11T14:17:24Z
trust1995 marked the issue as satisfactory
#2 - c4-judge
2023-07-11T16:52:04Z
trust1995 marked the issue as selected for report
#3 - 0xBugsy
2023-07-13T12:26:49Z
Very complete and well structured analysis!
#4 - c4-sponsor
2023-07-13T12:48:09Z
0xBugsy marked the issue as sponsor confirmed