Skip to content

Instantly share code, notes, and snippets.

@JustUzair
Last active January 24, 2025 06:35
Show Gist options
  • Select an option

  • Save JustUzair/603696b2b6f88b85eed567f5a30600c1 to your computer and use it in GitHub Desktop.

Select an option

Save JustUzair/603696b2b6f88b85eed567f5a30600c1 to your computer and use it in GitHub Desktop.

Audit Findings - DefX Bridge

[High] Signature Replay Attack within the cancelValidatorSetUpdate(...) Function

File(s): DefxBridge.sol
Description:
The cancelValidatorSetUpdate(...) function enables the cancellation of a proposed validator set. This function takes the nonce and the validators’ signatures as inputs. The signed message by the validators includes the nonce and the string "cancelValidatorSetUpdate".
However, the nonce is not incremented after the function call. This oversight facilitates a signature replay attack, allowing an attacker to cancel the proposed validator set repeatedly. If the validators have ever canceled a proposed set at least once, an attacker can exploit this by continuously submitting the same nonce and signatures to block validator updates.

function cancelValidatorSetUpdate(
    uint64 nonce,
    Signature[] calldata signatures
) external whenNotPaused {
    //@audit-issue nonce is not updated
    bytes32 messageHash = SignatureLibrary.generateUniqueMessageHash(
        keccak256(abi.encode("cancelValidatorSetUpdate", nonce)),
        address(this)
    );
    // ...
}

Recommendation(s):
Implement the _verifyAndIncrementNonce(...) function to ensure the usage of a unique nonce within the signed message.


[Medium] Inconsistent Logic for Pause and Unpause Functionalities

File(s): DefxBridge.sol
Description:
The contract can be paused using the pause() function, which requires a minimum number of lockers (lockerThreshold) to vote for the action. Each time pause() is invoked, the locker address is added to the lockersVotingLock array. When the array reaches the specified threshold, the contract is paused.

To unpause the contract, lockers must call the unpause() function. When a locker invokes this function, its address is removed from the lockersVotingLock array. The contract will unpause if the length of the array falls below lockerThreshold, as illustrated in the code below.

However, this mechanism does not ensure that there is a minimal number of locker votes to unpause the contract.
For instance, if lockerThreshold is set to 5, meaning that five lockers are required to pause the contract, it suffices for just one locker to call unpause() to unlock it. Upon a successful call, that locker’s address is removed from the lockersVotingLock array, resulting in a length of 4. Consequently, the condition (lockersVotingLock.length < lockerThreshold) evaluates to true, leading to the contract being unpaused.

function unpause() external whenPaused {
    // ...
    for (uint64 i = 0; i < lockersVotingLock.length; i++) {
        if (lockersVotingLock[i] == msg.sender) {
            lockersVotingLock[i] = lockersVotingLock[
                lockersVotingLock.length - 1
            ];
            //@auditlocked removed from the array after voting
            lockersVotingLock.pop();
            break;
        }
    }

    //@audit Does not require the entire `lockersVotingLock` set to vote to unpause the contract
    if (lockersVotingLock.length < lockerThreshold) {
        _unpause();
        emit ContractResumed();
    }
}

Recommendation(s):
Ensure that at least lockerThreshold lockers must vote in order to unpause the contract. This can be achieved by checking if the length of the lockersVotingLock array reaches zero. Note that this approach is effective only if the whenNotPaused modifier is added to the pause() function to prevent potential DoS attacks. Alternatively, maintain an internal count of votes to manage the unpausing process.


[Medium] Wrong Dispute Period Calculation Within the isTransactionInDisputeWindow(...) Function

File(s): Utils.sol
Description:
The isTransactionInDisputeWindow(...) function determines whether a withdrawal request or a validator set update is within the dispute window. This window allows validators to cancel the request and prevent its execution if necessary.

When withdrawal requests are initiated, the current block.timestamp is stored in the requestedTime variable associated with the withdrawal request. Upon finalization of the withdrawal, the system checks whether it is still within the dispute window by passing requestedTime as the time parameter to the isTransactionInDisputeWindow(...) function.

The time parameter is intended to be in milliseconds to ensure the correct functioning of the isTransactionInDisputeWindow(...) function. However, block.timestamp returns the time in seconds, leading to requestedTime being stored in seconds. This discrepancy in units results in an incorrect evaluation of the dispute window.

The same issue applies to the validator set update, as the epochTimestamp parameter refers to a timestamp in seconds.

function isTransactionInDisputeWindow(
    uint64 time,
    uint64 blockNumber,
    uint64 disputePeriodSeconds,
    uint64 blockDurationMillis
) internal view returns (bool) {
    bool enoughTimePassed = block.timestamp * 1000 >
        time + disputePeriodSeconds * 1000; // @audit `time` must be in milliseconds, but is currently in seconds
    if (!enoughTimePassed) {
        return true;
    }
    // ...
}

Recommendation(s):
Modify the time parameter to use milliseconds consistently or adjust the calculations to use seconds instead of milliseconds throughout the function.


[Medium] The pause(...) Function Doesn’t Use whenNotPaused Modifier, Leading to Potential DoS

File(s): DefxBridge.sol
Description:
The pause(...) function enables lockers to vote to pause the contract. Once the number of pause votes reaches the defined threshold, the contract is paused.

However, the function lacks the whenNotPaused modifier. As a result, it can be invoked even when the contract has already been paused. This allows additional locker votes to accumulate in the lockersVotingLock array, potentially preventing the contract from being unpaused.

function pause() external {
    // ...
    //@audit locker is pushed to the array
    lockersVotingLock.push(msg.sender);

    // ...
    //@audit: Function does not revert if already paused
    if (lockersVotingLock.length >= lockerThreshold && !paused()) {
        _pause();
        emit ContractPaused();
    }
}

Recommendation:
Consider adding the whenNotPaused modifier to the pause function to ensure it cannot be called when the contract is already paused.


[Low] Validators Are Not Added If Already in Previous Set in _updateValidatorSet(...)

File(s): DefxBridge.sol
Description:
The _updateValidatorSet function updates the lockers mapping by adding new validators and removing old ones.

However, an issue arises due to the order of operations. If a validator from the old set is meant to be retained in the new set, it will unintentionally be excluded because the removal of old validators occurs after the new validators have been added.

function _updateValidatorSet(...) internal {
    // ...
    // Add new validators to lockers
    for (uint64 i = 0; i < validatorsColdWallets.validators.length; i++) {
        lockers[validatorsColdWallets.validators[i]] = true;
    }

    // Remove old validators from lockers
    //@audit Validator cannot be carried over from the old set to the new set
    //because it will be deactivated here
    for (uint64 i = 0; i < oldValidatorsColdWallets.validators.length; i++) {
        lockers[oldValidatorsColdWallets.validators[i]] = false;
    }
    // ...
}

Recommendation(s):
Reverse the order of operations. First, remove the old validators from the lockers mapping, and then add the new validators to ensure that validators retained from the old set remain active.


[Low] Validators Are Not Added If Already in Previous Set in _updateValidatorSet(...)

File(s): DefxBridge.sol
Description:
The _updateValidatorSet function updates the lockers mapping by adding new validators and removing old ones.

However, an issue arises due to the order of operations. If a validator from the old set is meant to be retained in the new set, it will unintentionally be excluded because the removal of old validators occurs after the new validators have been added.

function _updateValidatorSet(...) internal {
    // ...
    // Add new validators to lockers
    for (uint64 i = 0; i < validatorsColdWallets.validators.length; i++) {
        lockers[validatorsColdWallets.validators[i]] = true;
    }

    // Remove old validators from lockers
    //@audit Validator cannot be carried over from the old set to the new set
    //because it will be deactivated here
    for (uint64 i = 0; i < oldValidatorsColdWallets.validators.length; i++) {
        lockers[oldValidatorsColdWallets.validators[i]] = false;
    }
    // ...
}

Recommendation(s):
Reverse the order of operations. First, remove the old validators from the lockers mapping, and then add the new validators to ensure that validators retained from the old set remain active.


[Info] Inconsistency in the permit(...) Functions Across Different Networks

File(s): DefxBridge.sol
Description:
The batchDepositWithPermit(...) function utilizes a permit(...) function that lacks consistency across various blockchain networks. This inconsistency can lead to functionality issues when interacting with certain ERC20 tokens.

For instance, consider the DAI token on Ethereum and Arbitrum networks, which has different signatures:

  • Ethereum Mainnet DAI
    function permit(address holder, address spender, uint256 nonce,
                    uint256 expiry, bool allowed, uint8 v, bytes32 r, bytes32 s);
  • Arbitrum DAI
    function permit(address owner, address spender, uint256 value,
                    uint256 deadline, uint8 v, bytes32 r, bytes32 s);

The batchDepositWithPermit(...) function is currently using the Arbitrum version of permit(...). As a result, this implementation is incompatible with the Ethereum Mainnet DAI, where the permit(...) function has a different signature. This discrepancy prevents the batchDepositWithPermit(...) function from operating correctly on Ethereum for DAI tokens. Note that other tokens can also implement the permit(...) function differently.

Recommendation(s):
Ensure the support of the permit(...) signature before allowing a token to be used within the bridge. Alternatively, implement a deposit mechanism where the user can directly deposit tokens without relying on the permit(...) function.

Audit Findings - Mangrove Vault

[Medium] Missing Check for Stale Data from Chainlink Oracle

File(s): ChainlinkConsumer.sol
Description:
The getTick(...) function is designed to calculate the tick value for a base/quote pair using the price fetched from the Chainlink oracle. This function internally calls getPrice(...) to retrieve the latest price from Chainlink. However, it currently fails to check the updatedAt timestamp of the returned price, which may lead to the acceptance of stale prices.
Moreover, the function does not check whether the L2 Arbitrum sequencer is down. In such a scenario, the oracle data is not up to date, and the returned price will become stale.

function getPrice(AggregatorV3Interface _aggregator) internal view returns (uint256) {
    if (address(_aggregator) == address(0)) return 1;

    (,int256 price,,,) = _aggregator.latestRoundData();
    //@audit missing check for stale prices
    if (price < 0) revert MangroveVaultErrors.OracleInvalidPrice();
    return uint256(price);
}

Recommendation(s):
Consider implementing additional checks to ensure the price is not stale. The revised implementation could be:

function getPrice(AggregatorV3Interface _aggregator) internal view returns (uint256) {
    (,int256 price,, uint256 updatedAt, uint80 answeredInRound) = _aggregator.latestRoundData();
    require(answeredInRound >= roundId, "Stale price!");
    require(updatedAt != 0, "Round not complete!");
    require(block.timestamp - updatedAt <= VALID_TIME_PERIOD, "Stale price data");

    if (price < 0) revert MangroveVaultErrors.OracleInvalidPrice();
    return uint256(price);
}

Additionally, ensure the L2 Sequencer is up, following the recommendation by the Chainlink team:
Chainlink L2 Sequencer Feeds.


[Low] The Function _swap Allows Arbitrary Calls and Is Under-constrained

File(s): MangroveVault.sol
Description:
The _swap(...) function allows the contract owner to rebalance the vault by executing swaps between quote and base tokens. However, this function poses some risks due to its ability to make arbitrary calls to the target contract, which can lead to malicious behaviors.
For instance, the target address can be set to the addresses of the BASE or QUOTE tokens. A direct call to these token contracts allows for a direct transfer of the vault assets outside the contract. Additionally, there is no control on the swap rate, which can result in unfair swaps or the depletion of contract funds. This behavior is possible as the amountInMin parameter, which is defined by the caller, could be set to zero or an excessively low value.

// @audit `amountInMin` can be set to a zero or very low value
function _swap(address target, bytes calldata data, uint256 amountOut, uint256 amountInMin, bool sell) internal {
    //@audit `allowedSwapContracts` does not exclude the base and quote token addresses
    if (!allowedSwapContracts[target]) {
        revert MangroveVaultErrors.UnauthorizedSwapContract(target);
    }
    //...
    //@audit Arbitrary call to the `target` address
    target.functionCall(data);
    //...
}

Recommendation(s):
Reinforce checks within the _swap(...) function to prevent potential misuse that could lead to the depletion of vault funds or unfair asset swaps. Ensure additional constraints on the target address, verify the swap rate, and prevent setting amountInMin to zero or excessively low values.


[Info] Use of Transfer Instead of Low-level Call to Send Native Assets

File(s): MangroveVault.sol
Description:
The withdrawNative(...) function in MangroveVault currently uses transfer() to send native ether to the contract owner. This approach can result in several issues, particularly with multisig wallets that may implement complex fallback functions or additional logic which exceeds the gas limit imposed by transfer(). Consequently, the usage of transfer() could result in failed ether transfers in such scenarios.

Recommendation(s):
To avoid the limitations of transfer(), consider using a low-level call function to transfer native ether.

function withdrawNative() external onlyOwner {
    // - payable(msg.sender).transfer(address(this).balance);
    (bool success, bytes memory result) = payable(msg.sender).call{value: address(this).balance}("");
    require(success, "Transfer failed");
}

Audit Findings - Swell SymbioticAdapter

[LOW] -setAeraVault(...) Does Not Revoke VAULT_ROLE from Previous Vault

File(s): SymbioticAdapter.sol
Description:
The setAeraVault(...) function is responsible for updating the aeraVault storage variable and assigning the VAULT_ROLE to the new vault address.
The function currently fails to revoke the VAULT_ROLE from the previous vault, as it incorrectly attempts to revoke the role after updating aeraVault. This results in the role being revoked from the newly set vault rather than the previous one.

function setAeraVault(address _newAeraVault) external onlyRole(VAULT_ROLE) {
    require(_newAeraVault != address(0), "nullAddress");
    aeraVault = _newAeraVault;
    _revokeRole(VAULT_ROLE, aeraVault); // @audit revoke from the new vault
    _grantRole(VAULT_ROLE, _newAeraVault);
    emit AeraVaultSet(_newAeraVault);
}

Recommendation(s):
Reorder the operations to revoke the VAULT_ROLE from the current vault before updating the aeraVault variable with the new address.


[LOW] - Incorrect Epoch Limit Check in the withdraw(...) Function

File(s): SymbioticAdapter.sol
Description:
The SymbioticAdapter stores epochs for outstanding withdrawals in the withdrawableEpochs array. Each epoch represents a time period to which the withdrawal request was assigned, and once this epoch passes, a user can claim assets.
The adapter enforces a limit on the number of epochs that can be stored in the array:

require(withdrawalEpochs.length <= MAX_WITHDRAWAL, "claimBeforeWithdraw");

By default, MAX_WITHDRAWAL is set to five, meaning only five epochs should be allowed in the withdrawableEpochs array. However, the current implementation incorrectly uses the <= comparison operator, which enables the array to contain one more epoch than intended. When the array length reaches five, the condition still holds true, allowing a sixth element to be added.

Recommendation(s):
Change the comparison operator in the withdraw(...) function from <= to < to correctly enforce the maximum number of withdrawals.


[INFO] - Missing Initialization of Upgradeable Contracts

File(s): SymbioticAdapter.sol
Description:
The SymbioticAdapter contract inherits OpenZeppelin’s upgradeable contracts: ReentrancyGuardUpgradeable and AccessControlUpgradeable. These contracts require initialization through their respective functions, __ReentrancyGuard_init() and __AccessControl_init(). However, these initialization functions are not called within the initialize(...) function of SymbioticAdapter, potentially leading to improper setup of access control and reentrancy guard features.

Recommendation(s):
Update the initialize() function in SymbioticAdapter to include calls to __ReentrancyGuard_init() and __AccessControl_init() to correctly initialize the inherited upgradeable contracts.


Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment