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
Rank: 2/30
Findings: 3
Award: $5,454.09
π Selected for report: 2
π Solo Findings: 1
π Selected for report: ladboy233
4961.8278 USDC - $4,961.83
The logic in _handleVoteOverride to determine if an account is transcoder has issue
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
Manual Review
reuse the function isRegisteredTranscoder and isActiveTranscoder to determine if an account is a registered and active transcoder when counting the voting power
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
#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
#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
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 !
429.3889 USDC - $429.39
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.
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 :
bondedAmount
to 0, but he still has delegatedAmount
to his address since nothing happens this variable
https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/bonding/BondingManager.sol#L402-L418_getVotes
from GovernorVotesUpgradeable.sol
https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable/blob/f34a3a7e5a1d698d87d517fda698d48286310bee/contracts/governance/extensions/GovernorVotesUpgradeable.sol#L55-L61_getVotes
calls getPastVotes
on BondingVotes.sol
https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/bonding/BondingVotes.sol#L167-L170 which returns the amount of weight specific to this transcoder and as you can see, because the transcoder has a bondedAmount
equal to 0, the first if statement will be true and 0 will be returned https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/bonding/BondingVotes.sol#L372-L373_countVote
which will then be passed into _handleVoteOverrides
https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/treasury/GovernorCountingOverridable.sol#L151slashTranscoder
function, or any other function the transcoder delegateAddress
gets changed, so this if statement will be true https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/treasury/GovernorCountingOverridable.sol#L182-L184, which will try to deduct the weight from any previous delegators https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/treasury/GovernorCountingOverridable.sol#L184-L189for
, against
, abstain
, but since 0 weight is passed no changed will be made.delegatorCumulativeStakeAt
gets called https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/bonding/BondingVotes.sol#L459-L487 which will return most of the time his bondedAmount
, amount which is greater than 0, since he didn't unbound._handleVoteOverrides
gets called in _countVote
, to override the vote, this if statement will be true, since the transcoder voted already, but with 0 weight https://github.com/code-423n4/2023-08-livepeer/blob/a3d801fa4690119b6f96aeb5508e58d752bda5bc/contracts/treasury/GovernorCountingOverridable.sol#L195 and the delegator weight gets subtracted from the support that the transcoder used in his votedelegatedAmount
, which will make the subtraction viable, since the weight of the delegator should be included in the full delegatedAmount
of that specific transcoder, but since the transcoder voted with 0 weight, the subtraction would be done from other delegators/transcoders votes.abstain
and every other voter will vote on for
or against
, every time one of his delegators want to change the vote the subtraction can revert, which will force those delegators out of the vote, until they will change their transcoderManual 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.
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:
_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.
π Selected for report: Proxy
Also found by: Banditx0x, DavidGiladi, favelanky, ladboy233, nadin, rvierdiiev
62.8682 USDC - $62.87
Detailed description of the impact of this finding.
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
Manual Review
add a function to bump treasury address as well
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":
LivepeerGovernor
, used for everything else (including the riskier proposal interations)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:
Controller
holds the actually desired treasury:
updateTimelock
setContractInfo
to the old treasury while the voting is happening, and then call setContractInfo
to the new address after it is doneLivepeerGovernor
holds the actually desired treasury:
setContractInfo
immediatelyrelay()
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