Livepeer Onchain Treasury Upgrade - ladboy233's results

Decentralized video infrastructure protocol powering video in web3's leading social and media applications.

General Information

Platform: Code4rena

Start Date: 31/08/2023

Pot Size: $55,000 USDC

Total HM: 5

Participants: 30

Period: 6 days

Judge: hickuphh3

Total Solo HM: 2

Id: 282

League: ETH

Livepeer

Findings Distribution

Researcher Performance

Rank: 2/30

Findings: 3

Award: $5,454.09

QA:
grade-b

🌟 Selected for report: 2

πŸš€ Solo Findings: 1

Findings Information

🌟 Selected for report: ladboy233

Labels

bug
2 (Med Risk)
satisfactory
selected for report
sponsor confirmed
sufficient quality report
M-01

Awards

4961.8278 USDC - $4,961.83

External Links

Lines of code

https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/treasury/GovernorCountingOverridable.sol#L184

Vulnerability details

Impact

The logic in _handleVoteOverride to determine if an account is transcoder has issue

Proof of Concept

In the current implementation,

when a voting, the function _countVote is triggered, this function is overriden in the function GovernorCountingOverridable.sol

    _weight = _handleVoteOverrides(_proposalId, tally, voter, _account, _weight);

this is calling:

   function _handleVoteOverrides(
        uint256 _proposalId,
        ProposalTally storage _tally,
        ProposalVoterState storage _voter,
        address _account,
        uint256 _weight
    ) internal returns (uint256) {

        uint256 timepoint = proposalSnapshot(_proposalId);

        address delegate = votes().delegatedAt(_account, timepoint);

        // @audit
        // is transcoder?
        bool isTranscoder = _account == delegate;
    
        if (isTranscoder) {
            // deduce weight from any previous delegators for this transcoder to
            // make a vote
            return _weight - _voter.deductions;
        }

the logic to determine if an account is the transcoder is too simple in this line of code

// @audit
// is transcoder?
bool isTranscoder = _account == delegate;

and does not match the logic that determine if the address is an registered transcorder and an active transcoder in the bondManager.sol

In BondManager.sol, the function that used to check if a transcoder is registered is in this line of code

    /**
     * @notice Return whether a transcoder is registered
     * @param _transcoder Transcoder address
     * @return true if transcoder is self-bonded
     */
    function isRegisteredTranscoder(address _transcoder) public view returns (bool) {
        Delegator storage d = delegators[_transcoder];
        return d.delegateAddress == _transcoder && d.bondedAmount > 0;
    }

the function that used to check if a transcoder is active is in this line of code

    function isActiveTranscoder(address _transcoder) public view returns (bool) {
        Transcoder storage t = transcoders[_transcoder];
        uint256 currentRound = roundsManager().currentRound();
        return t.activationRound <= currentRound && currentRound < t.deactivationRound;
    }

Missing the check the the delegator's bond amount (delegators[_transcoder].bondeAmount > 0)

the code incorrectedly count regular delegator as transcoder and does not update the deduction power correctedly

Tools Used

Manual Review

reuse the function isRegisteredTranscoder and isActiveTranscoder to determine if an account is a registered and active transcoder when counting the voting power

Assessed type

Governance

#0 - c4-pre-sort

2023-09-09T14:51:47Z

141345 marked the issue as sufficient quality report

#1 - victorges

2023-09-15T17:48:27Z

There is in fact an issue with the inconsistency with the isRegisteredTranscodeer function. This report didn't manage to go specifically into that issue, but still pointed a valid problem which is in a sense the root cause there. FTR There is no problem with isActiveTranscoder though, since we don't give voting power only to active transcoders. That part of this report is invalid.

#2 - c4-sponsor

2023-09-15T17:48:45Z

victorges (sponsor) confirmed

#3 - c4-judge

2023-09-18T10:46:02Z

HickupHH3 marked the issue as selected for report

#4 - c4-judge

2023-09-18T10:46:06Z

HickupHH3 marked the issue as satisfactory

#5 - twicek

2023-09-23T00:40:54Z

This is true that the code counts users that self delegate but didn't join the transcoder set as transcoders, but it does not lead to voting power being calculated incorrectly. _voter.deductions will be 0 for a failed transcoder calculating the _weight correctly. I might be missing something, so correct me if I'm wrong, but if the above is true this finding should be low severity.

#6 - JeffCX

2023-09-23T01:07:49Z

This is true that the code counts users that self delegate but didn't join the transcoder set as transcoders, but it does not lead to voting power being calculated incorrectly. _voter.deductions will be 0 for a failed transcoder calculating the _weight correctly. I might be missing something, so correct me if I'm wrong, but if the above is true this finding should be low severity.

Hello, Please check the PR fix 625

https://github.com/livepeer/protocol/pull/625

#7 - JeffCX

2023-09-23T01:17:21Z

This is true that the code counts users that self delegate but didn't join the transcoder set as transcoders, but it does not lead to voting power being calculated incorrectly. _voter.deductions will be 0 for a failed transcoder calculating the _weight correctly. I might be missing something, so correct me if I'm wrong, but if the above is true this finding should be low severity.

Here

https://github.com/livepeer/protocol/pull/625/files#diff-def676c8b96f1c8a1d4524f86cf9a2de8a5322052ef804ff46fec780d457600dR402

#8 - twicek

2023-09-23T01:33:20Z

This is true that the code counts users that self delegate but didn't join the transcoder set as transcoders, but it does not lead to voting power being calculated incorrectly. _voter.deductions will be 0 for a failed transcoder calculating the _weight correctly. I might be missing something, so correct me if I'm wrong, but if the above is true this finding should be low severity.

Please check the PR fix 625

livepeer/protocol#625

Thanks for the context but I believe this PR is for #194 and #96. The comments at the end https://github.com/livepeer/protocol/pull/625#issuecomment-1731448211 confirm my reasoning.

My question is, how does voting power get affected by your issue? If you can find a scenario that is not shown in #194 and #96 that alters voting power then I agree that this should be a unique medium severity.

#9 - JeffCX

2023-09-23T01:35:42Z

Thanks for the comment

I feel like twicek's comment is the same as:

This report didn't manage to go specifically into that issue, but still pointed a valid problem which is in a sense the root cause there

Delegating to an account that is not actually a transcoder should render no voting power

in the function delegatorCumulativeStakeAt

the fix make sure when transcoder is not registered, Delegating to unregistered an account return 0

which is used the function getBondingStateAt

this function is in fact used to calculate the vote amount

emm user does relies on this function to get the correct voting power,

I think the inconsitency root from the function getVotes and the actual vote power override

fix related discussion is here

the sponsor has been act in good faith so I think the report does add value by make sure the protocol handle the voting power calculation correctedly when a address is not an registered transcoder so a medium severity is well justified :)

#10 - twicek

2023-09-23T02:27:37Z

Ok, it makes sense. Thanks for taking the time to explain your reasoning. Good job @JeffCX !

Findings Information

🌟 Selected for report: ladboy233

Also found by: Vagner

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
primary issue
satisfactory
selected for report
sponsor confirmed
sufficient quality report
M-02

Awards

429.3889 USDC - $429.39

External Links

Lines of code

https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/treasury/GovernorCountingOverridable.sol#L181-L189

Vulnerability details

Impact

If a transcoder gets slashed fully he can still vote with 0 amount of weight making any other delegated user that wants to change his vote to subtract their weight amount from other delegators/transcoders.

Proof of Concept

In BondingManager.sol any transcoder can gets slashed by a specific percentage, and that specific transcoder gets resigned and that specific percentage gets deducted from his bondedAmount https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/bonding/BondingManager.sol#L394-L411 If any bondedAmount will remain then the penalty will also gets subtracted from the delegatedAmount, if not, nothing happens https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/bonding/BondingManager.sol#L412-L417 After that the penalty gets burned and the fees gets paid to the finder, if that is the case https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/bonding/BondingManager.sol#L420-L440 The problem relies in the fact that a fully slashed transcoder, even if it gets resigned, he is still seen as an active transcoder in the case of voting. Let's assume this case :

Tools Used

Manual review

If a transcoder gets fully slashed and resigned, delete his address from delegateAddress so he will not appear as a transcoder in the mechanism of counting the votes. If he still wants to participate in the system he can act as a delegator to another transcoder. Another solution would be to not let 0 weight votes happen anyways, since they don't modify the vote state at all.

Assessed type

Governance

#0 - c4-pre-sort

2023-09-09T14:49:13Z

141345 marked the issue as sufficient quality report

#1 - c4-sponsor

2023-09-14T20:44:51Z

victorges marked the issue as disagree with severity

#2 - victorges

2023-09-14T20:49:06Z

Slashing is turned off in the protocol, so this specific issue is not that important. It did point towards an issue in BondingVotes tho in the way that the getBondingState function checks if transcoder is a transcoder or not and defaults to 0 when bondedAmount is 0. There are other ways that this logic can be triggered (not through slashTranscoder) that can show inconsistencies as well, so I'm flagging this as valid for the underlying issue that it hints to, even though it does not describe a realistic scenario about how this issue could happen.

#3 - c4-sponsor

2023-09-14T20:49:11Z

victorges (sponsor) confirmed

#4 - HickupHH3

2023-09-18T04:20:23Z

I found the POC hard to follow. A coded instance would have tremendously helped, but what I gather is:

  • fully slashed transcoder that votes will do so with 0 weight
  • any delegator that delegated their votes to this specific transcoder intending to change their vote may face an issue with sub overflow because _handleVoteOverrides() uses the slashed transcoder's bondedAmount instead of the 0 weight. so this issue seems valid, although its premise is that the slashing functionality is in place.

The README did not indicate that slashing was turned off, though I see it's mentioned a couple of times in the Discord channel. Should've been added in the "Known caveats / limitations" section.

Hence, IMO it isn't entirely fair to wardens if I invalidate their issue because they weren't aware of this fact.

Leaning towards a downgrade to M because of the external requirement of the active slashing mechanism, but am open to discussion.

#5 - c4-judge

2023-09-18T10:38:52Z

HickupHH3 changed the severity to 2 (Med Risk)

#6 - c4-judge

2023-09-18T10:40:38Z

HickupHH3 marked the issue as primary issue

#7 - c4-judge

2023-09-18T10:41:00Z

HickupHH3 marked the issue as selected for report

#8 - c4-judge

2023-09-18T10:41:04Z

HickupHH3 marked the issue as satisfactory

#9 - HickupHH3

2023-09-24T09:30:14Z

Revisiting this issue, IMO it's unreasonable to expect the sponsor to explicitly list out all the gotchas / limitations of the protocol. Some will only pop into mind through discussions with wardens, which seemed to be the case here when the slashing deprecation was mentioned a couple of times in the Discord channel.

On the flip side, the fact remains that the slashing mechanism is part of the audit scope, and was counted towards the sLOC, plus the points I raised above. There wasn't an indication in the code that slashing had been deprecated.

It's a difficult decision here, considering the different parties' POV. There could be some wardens that didn't submit issues related to slashing because of what was brought up in the Discord channel, and those that did (as one can see referenced above) because they didn't monitor the channel.

Findings Information

🌟 Selected for report: Proxy

Also found by: Banditx0x, DavidGiladi, favelanky, ladboy233, nadin, rvierdiiev

Labels

bug
downgraded by judge
grade-b
QA (Quality Assurance)
sponsor acknowledged
sufficient quality report
Q-01

Awards

62.8682 USDC - $62.87

External Links

Lines of code

https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/treasury/LivepeerGovernor.sol#L63

Vulnerability details

Impact

Detailed description of the impact of this finding.

Proof of Concept

In Governor contract,

we have a function

    function bumpGovernorVotesTokenAddress() external {
 			token = votes();
    }

when the token address is updated, the token can be sycned

However, if the controller owner update the treasury address by calling in controller code

    /**
     * @notice Register contract id and mapped address
     * @param _id Contract id (keccak256 hash of contract name)
     * @param _contractAddress Contract address
     */
    function setContractInfo(
        bytes32 _id,
        address _contractAddress,
        bytes20 _gitCommitHash
    ) external override onlyOwner {
        registry[_id].contractAddress = _contractAddress;
        registry[_id].gitCommitHash = _gitCommitHash;

        emit SetContractInfo(_id, _contractAddress, _gitCommitHash);
    }

the governor contract treasury address and controller treasury address is out of sync

Tools Used

Manual Review

add a function to bump treasury address as well

Assessed type

Governance

#0 - c4-pre-sort

2023-09-09T14:56:54Z

141345 marked the issue as sufficient quality report

#1 - victorges

2023-09-15T18:04:23Z

Not sure if we'll make this change, but it is indeed a valid report as well!

#2 - c4-sponsor

2023-09-15T18:04:27Z

victorges (sponsor) acknowledged

#3 - c4-judge

2023-09-18T10:54:35Z

HickupHH3 marked the issue as selected for report

#4 - c4-judge

2023-09-18T10:54:39Z

HickupHH3 marked the issue as satisfactory

#5 - victorges

2023-09-18T15:42:12Z

@HickupHH3 re-reviewing this, I think this should be QA as well

#6 - HickupHH3

2023-09-19T01:46:17Z

the reason why I left it at M is because of the edge case that the treasury gets hacked, but I assume it's a timelock contract, so the probability is very low, & there isn't as many expected calls to the treasury as compared to the BondingManager.

downgrading to QA (L) then.

#7 - c4-judge

2023-09-19T01:46:23Z

HickupHH3 changed the severity to QA (Quality Assurance)

#8 - HickupHH3

2023-09-19T01:46:42Z

1L

#9 - c4-sponsor

2023-09-20T23:57:09Z

victorges (sponsor) disputed

#10 - victorges

2023-09-20T23:59:20Z

@HickupHH3 as I was looking into a mitigation for this, I realized that the report is actually invalid.

The GovernorTimelockUpgradeable extension already provides an updateTimelock function, which can only be called by governance proposals and updates the address of the timelock (Treasury): https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/release-v4.9/contracts/governance/extensions/GovernorTimelockControlUpgradeable.sol#L163

So this is actually not an issue.

#11 - c4-judge

2023-09-21T09:55:10Z

HickupHH3 marked the issue as grade-c

#12 - JeffCX

2023-09-22T12:33:49Z

Sorry I should add more detail in my original report:

The admin of the livepeer contract is call setContractInfo

    function setContractInfo(
        bytes32 _id,
        address _contractAddress,
        bytes20 _gitCommitHash
    ) external override onlyOwner {
        registry[_id].contractAddress = _contractAddress;
        registry[_id].gitCommitHash = _gitCommitHash;

        emit SetContractInfo(_id, _contractAddress, _gitCommitHash);
    }

and then other contract read contract address by calling get contract

function getContract(bytes32 _id) public view override returns (address) {
	return registry[_id].contractAddress;
}

if we take a look at the bondManager contract, every time the code tries to retrieve the treasury contract here and here, the code read the storage data from the controller directedly

so even the treasury address change, the updated address is still used in the BondManager

function treasury() internal view returns (address) {
	return controller.getContract(keccak256("Treasury"));
}

but if the admin call setContractInfo to update treasury address

the external integration that calls LivepeerGovernor.sol#treasury is getting the updated treasury address

    function treasury() internal view returns (Treasury) {
        return Treasury(payable(controller.getContract(keccak256("Treasury"))));
    }

but in fact, the old / outdated is still used in the governance contract

the old treasury is init here in this line of code

 __GovernorTimelockControl_init(treasury());

which calls this line of code to set the timelock contract

the timelock contract is used to determine the state of proposal, and incorrect return the state of proposal heavily break external integration (user may think a proposal pass but it is not)

it is true the admin can update the timelock contract by executing this code

    /**
     * @dev Public endpoint to update the underlying timelock instance. Restricted to the timelock itself, so updates
     * must be proposed, scheduled, and executed through governance proposals.
     *
     * CAUTION: It is not recommended to change the timelock while there are other queued governance proposals.
     */
    function updateTimelock(TimelockControllerUpgradeable newTimelock) external virtual onlyGovernance {
        _updateTimelock(newTimelock);
    }

but it is not as easy as calling setContractInfo

the core issue is we can say calling updateTimelock when updating the timelock contract, does not call setContractInfo to update the treasury address,

we can say calling setContractInfo does not update the timelock contract, but either way, the timelock contract address and the treasury address can out of sync

the precondition is indeed admin calls setContractInfo directly to update treasury without care,

but I think given that the treasury address is used to determine the proposal state and there maybe external integration that read the treasury address from governance contract (read the timelock contract directedly)

as for comments

the reason why I left it at M is because of the edge case that the treasury gets hacked

emm the consider that the treausry address is not ugpradeable, maybe the livepeer governance wants to build a new feature on treasury address,

it can cause change of treasury address for dev or techical reason as well (just like the projects want to add additional feature like reward cut and vote override)

emm this bug report submission, based on the comment above, help sponsor realize that the timelock contract can be changed by executing governance proposal updateTimelock (apology I would have add this point in original submission)

and in other words, this bug report, if not found, the likehood that admin directedly call setContractInfo to update treasury contract and not changing timelock contract is high

I politely think that a medium severity is ok

I fully respect judge's expertise and respect judge's final decision

Sorry I should add more detail in my original report:

The admin of the livepeer contract is call setContractInfo

    function setContractInfo(
        bytes32 _id,
        address _contractAddress,
        bytes20 _gitCommitHash
    ) external override onlyOwner {
        registry[_id].contractAddress = _contractAddress;
        registry[_id].gitCommitHash = _gitCommitHash;

        emit SetContractInfo(_id, _contractAddress, _gitCommitHash);
    }

and then other contract read contract address by calling get contract

function getContract(bytes32 _id) public view override returns (address) {
	return registry[_id].contractAddress;
}

if we take a look at the bondManager contract, every time the code tries to retrieve the treasury contract here and here, the code read the storage data from the controller directedly

so even the treasury address change, the updated address is still used in the BondManager

function treasury() internal view returns (address) {
	return controller.getContract(keccak256("Treasury"));
}

but if the admin call setContractInfo to update treasury address

the external integration that calls LivepeerGovernor.sol#treasury is getting the updated treasury address

    function treasury() internal view returns (Treasury) {
        return Treasury(payable(controller.getContract(keccak256("Treasury"))));
    }

but in fact, the old / outdated is still used in the governance contract

the old treasury is init here in this line of code

 __GovernorTimelockControl_init(treasury());

which calls this line of code to set the timelock contract

the timelock contract is used to determine the state of proposal,

    function state(uint256 proposalId) public view virtual override(IGovernorUpgradeable, GovernorUpgradeable) returns (ProposalState) {
        ProposalState currentState = super.state(proposalId);

        if (currentState != ProposalState.Succeeded) {
            return currentState;
        }

        // core tracks execution, so we just have to check if successful proposal have been queued.
        bytes32 queueid = _timelockIds[proposalId];
        if (queueid == bytes32(0)) {
            return currentState;
        } else if (_timelock.isOperationDone(queueid)) {
            return ProposalState.Executed;
        } else if (_timelock.isOperationPending(queueid)) {
            return ProposalState.Queued;
        } else {
            return ProposalState.Canceled;
        }
    }

and incorrect return the state of proposal heavily break external integration (user may think a proposal pass but it is not)

it is true the admin can update the timelock contract by executing this code

    /**
     * @dev Public endpoint to update the underlying timelock instance. Restricted to the timelock itself, so updates
     * must be proposed, scheduled, and executed through governance proposals.
     *
     * CAUTION: It is not recommended to change the timelock while there are other queued governance proposals.
     */
    function updateTimelock(TimelockControllerUpgradeable newTimelock) external virtual onlyGovernance {
        _updateTimelock(newTimelock);
    }

but it is not as easy as calling setContractInfo

the core issue is we can say calling updateTimelock when updating the timelock contract, does not call setContractInfo to update the treasury address,

we can say calling setContractInfo does not update the timelock contract, but either way, the timelock contract address and the treasury address can out of sync

the precondition is indeed admin calls setContractInfo directly to update treasury without care,

but I think given that the treasury address is used to determine the proposal state and there maybe external integration that read the treasury address from governance contract (read the timelock contract directedly)

as for comments

the reason why I left it at M is because of the edge case that the treasury gets hacked

emm the consider that the treausry address is not ugpradeable, maybe the livepeer governance wants to build a new feature on treasury address,

it can cause change of treasury address for dev or techical reason as well (just like the projects want to add additional feature like reward cut and vote override)

emm this bug report submission, based on the comment above, help sponsor realize that the timelock contract can be changed by executing governance proposal updateTimelock (apology I would have add this point in original submission)

and in other words, this bug report, if not found, the likehood that admin directedly call setContractInfo to update treasury contract and not changing timelock contract is high

I politely think that a medium severity is ok

I fully respect judge's expertise and respect judge's final decision

#13 - victorges

2023-09-22T20:49:37Z

@JeffCX Those are some fair points. I think this can be summarized as "multiple sources of truth for treasury address":

  • Primary: the one in LivepeerGovernor, used for everything else (including the riskier proposal interations)
  • Duplicate: The one in Controller, only used by BondingManager to make treasury contributions [1]

I agree that the treasury() function on the LivepeerGovernor can be misleading though, since it does not return the actual timelock being used by the governor in case the 2 sources of truth are inconsistency. This could be a QA consideration.

Although, the biggest problem on an inconsistency between these 2 values is that the treasury contribution will go to an address not currently used by the LivepeerGovernor to execute its proposals. This can cause a small headache, but can be fixed by admins with no funds lost:

  • If the Controller holds the actually desired treasury:
    • Regular flow for updating treasury address, by making a proposal to:
      • Transfer all funds from the current treasury to the new one
      • updateTimelock
    • If necessary to keep treasury contributions during the proposal, the admin can setContractInfo to the old treasury while the voting is happening, and then call setContractInfo to the new address after it is done
  • If the LivepeerGovernor holds the actually desired treasury:
    • Admin calls setContractInfo immediately
    • Make a proposal to send the funds from the old treasury to the new one using the relay() function (cause only LivepeerGovernor will have the right roles to execute stuff)

[1]

and incorrect return the state of proposal heavily break external integration (user may think a proposal pass but it is not)

This cannot happen as the duplicate source of truth is only used to send more LPT to the treasury. It will not affect the LivepeerGovernor functioning.

So IMO this is mostly an admin error with no permanent loss to the protocol if it happens, which is consistent with a QA issue. I'll change our current stance here from disputed but not sure if we'll make any changes about it.

#14 - c4-sponsor

2023-09-22T20:50:11Z

victorges (sponsor) acknowledged

#15 - victorges

2023-09-22T20:51:29Z

@HickupHH3 Changed this back to acknowledged but I can't reopen the issue I think, maybe you can. As explained above, I think it's mostly an "admin error" (QA) as updating the treasury address has to be done in 2 places but is not catastrophic if a mistake is made.

#16 - JeffCX

2023-09-22T21:36:10Z

Agree with sponsor! Thanks for the reply!

#17 - HickupHH3

2023-09-25T02:17:26Z

Acknowledged as QA(L)

#18 - HickupHH3

2023-09-25T02:53:57Z

#178: QA(R)

1L 1R

#19 - c4-judge

2023-09-25T02:54:13Z

HickupHH3 marked the issue as grade-b

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter