Platform: Code4rena
Start Date: 06/06/2023
Pot Size: $60,500 USDC
Total HM: 5
Participants: 50
Period: 8 days
Judge: gzeon
Id: 246
League: ETH
Rank: 1/50
Findings: 3
Award: $9,090.03
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: auditor0517
3346.4803 USDC - $3,346.48
https://github.com/code-423n4/2023-06-llama/blob/9d641b32e3f4092cc81dbac7b1c451c695e78983/src/strategies/LlamaRelativeQuorum.sol#L223 https://github.com/code-423n4/2023-06-llama/blob/9d641b32e3f4092cc81dbac7b1c451c695e78983/src/strategies/LlamaRelativeQuorum.sol#L242
In LlamaRelativeQuorum
, the governance result might be incorrect as it counts the wrong approval/disapproval.
The LlamaRelativeQuorum
uses approval/disapproval thresholds that are specified as percentages of total supply and the approval/disapproval supplies are set at validateActionCreation()
during the action creation.
function validateActionCreation(ActionInfo calldata actionInfo) external { LlamaPolicy llamaPolicy = policy; // Reduce SLOADs. uint256 approvalPolicySupply = llamaPolicy.getRoleSupplyAsNumberOfHolders(approvalRole); if (approvalPolicySupply == 0) revert RoleHasZeroSupply(approvalRole); uint256 disapprovalPolicySupply = llamaPolicy.getRoleSupplyAsNumberOfHolders(disapprovalRole); if (disapprovalPolicySupply == 0) revert RoleHasZeroSupply(disapprovalRole); // Save off the supplies to use for checking quorum. actionApprovalSupply[actionInfo.id] = approvalPolicySupply; actionDisapprovalSupply[actionInfo.id] = disapprovalPolicySupply; }
As we can see, actionApprovalSupply
and actionDisapprovalSupply
are set using getRoleSupplyAsNumberOfHolders
which means the total number of role holders.
But while counting for totalApprovals/totalDisapprovals
in getApprovalQuantityAt()/getDisapprovalQuantityAt()
, it adds the quantity instead of role holders(1 for each holder).
function getApprovalQuantityAt(address policyholder, uint8 role, uint256 timestamp) external view returns (uint128) { if (role != approvalRole && !forceApprovalRole[role]) return 0; uint128 quantity = policy.getPastQuantity(policyholder, role, timestamp); return quantity > 0 && forceApprovalRole[role] ? type(uint128).max : quantity; //@audit should return supply, not quantity }
So the governance result would be wrong with the below example.
LlamaRelativeQuorum
strategy, actionApprovalSupply = 3
and there should be 2 approved holders at least when minApprovalPct = 51%
.getApprovalQuantityAt()
will be 2 and the action will be approved with only one approval.It's because getApprovalQuantityAt()
return the quantity although actionApprovalSupply
equals NumberOfHolders
.
Manual Review
getApprovalQuantityAt()
and getDisapprovalQuantityAt()
should return 1 instead of quantity
for the positive quantity.
I think we can modify these functions like below.
function getApprovalQuantityAt(address policyholder, uint8 role, uint256 timestamp) external view returns (uint128) { if (role != approvalRole && !forceApprovalRole[role]) return 0; uint128 quantity = policy.getPastQuantity(policyholder, role, timestamp); if (quantity > 1) quantity = 1; return quantity > 0 && forceApprovalRole[role] ? type(uint128).max : quantity; } function getDisapprovalQuantityAt(address policyholder, uint8 role, uint256 timestamp) external view returns (uint128) { if (role != disapprovalRole && !forceDisapprovalRole[role]) return 0; uint128 quantity = policy.getPastQuantity(policyholder, role, timestamp); if (quantity > 1) quantity = 1; return quantity > 0 && forceDisapprovalRole[role] ? type(uint128).max : quantity; }
Governance
#0 - c4-pre-sort
2023-06-19T11:50:20Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-06-20T22:00:24Z
AustinGreen marked the issue as sponsor confirmed
#2 - c4-sponsor
2023-06-21T16:30:16Z
AustinGreen marked the issue as sponsor disputed
#3 - AustinGreen
2023-06-21T16:36:40Z
This is actually how we intend this strategy to work but we're open to feedback! Here's an example:
In this system quantity can be used to provide granular approval weights to role holders.
#4 - c4-sponsor
2023-06-21T18:30:27Z
AustinGreen marked the issue as disagree with severity
#5 - gzeon-c4
2023-07-02T14:34:49Z
This is actually how we intend this strategy to work but we're open to feedback! Here's an example:
- An instance has 10 role holders and a 50% min approval percentage. Each role holder's quantity is 1, so 5 role holders can approve this action.
- 2 of the role holders have their quantity increased to 2.
- This means that if each of these role holders cast approvals, then their approval power will count as 4. That means just one other role holder is needed to cast approval to approve the action.
In this system quantity can be used to provide granular approval weights to role holders.
I don't think this make sense. Sure, if each holder's quantity is 1, then getRoleSupply
is same as getRoleSupplyAsNumberOfHolders
and what you said is valid. However, if you have 10 holders each with quantity 10 at snapshot, then your actionApprovalSupply
is set to 10 (number of holder) and any of their approval (10 quantity) would hit quorum.
#6 - AustinGreen
2023-07-02T19:40:52Z
This is actually how we intend this strategy to work but we're open to feedback! Here's an example:
- An instance has 10 role holders and a 50% min approval percentage. Each role holder's quantity is 1, so 5 role holders can approve this action.
- 2 of the role holders have their quantity increased to 2.
- This means that if each of these role holders cast approvals, then their approval power will count as 4. That means just one other role holder is needed to cast approval to approve the action.
In this system quantity can be used to provide granular approval weights to role holders.
I don't think this make sense. Sure, if each holder's quantity is 1, then
getRoleSupply
is same asgetRoleSupplyAsNumberOfHolders
and what you said is valid. However, if you have 10 holders each with quantity 10 at snapshot, then youractionApprovalSupply
is set to 10 (number of holder) and any of their approval (10 quantity) would hit quorum.
Yes that’s exactly how the design is intended to work!
#7 - gzeon-c4
2023-07-03T04:34:13Z
This is actually how we intend this strategy to work but we're open to feedback! Here's an example:
- An instance has 10 role holders and a 50% min approval percentage. Each role holder's quantity is 1, so 5 role holders can approve this action.
- 2 of the role holders have their quantity increased to 2.
- This means that if each of these role holders cast approvals, then their approval power will count as 4. That means just one other role holder is needed to cast approval to approve the action.
In this system quantity can be used to provide granular approval weights to role holders.
I don't think this make sense. Sure, if each holder's quantity is 1, then
getRoleSupply
is same asgetRoleSupplyAsNumberOfHolders
and what you said is valid. However, if you have 10 holders each with quantity 10 at snapshot, then youractionApprovalSupply
is set to 10 (number of holder) and any of their approval (10 quantity) would hit quorum.Yes that’s exactly how the design is intended to work!
This sounds weird, is this design documented anywhere? From what I can see in the code comments it seems to be hard for anyone (including potential user/dao) to understand such logic.
In the code, there is a comment
Minimum percentage of
totalApprovalQuantity / totalApprovalSupplyAtCreationTime
required for the action to be queued
I think it is fair for one to assume totalApprovalQuantity
and totalApprovalSupplyAtCreationTime
would be using the same metric, instead of one using the raw count and the other using AsNumberOfHolders
.
#8 - c4-judge
2023-07-06T13:51:38Z
gzeon-c4 marked the issue as selected for report
#9 - AustinGreen
2023-07-21T22:01:14Z
Although this is the intended design for this strategy, we decided to create an additional strategy that Llama instances can adopt that follows the warden's recommendations. It uses total (dis)approval quantity for the quorum calculation as specified.
🌟 Selected for report: ktg
Also found by: auditor0517, dirk_y
5296.7399 USDC - $5,296.74
The governance result might be manipulated seriously because the approval/disapproval supplies can be changed anytime by an attacker.
The LlamaRelativeQuorum
uses approval/disapproval thresholds that are specified as percentages of total supply and the approval/disapproval supplies are set at validateActionCreation()
during the action creation.
function validateActionCreation(ActionInfo calldata actionInfo) external { LlamaPolicy llamaPolicy = policy; // Reduce SLOADs. uint256 approvalPolicySupply = llamaPolicy.getRoleSupplyAsNumberOfHolders(approvalRole); if (approvalPolicySupply == 0) revert RoleHasZeroSupply(approvalRole); uint256 disapprovalPolicySupply = llamaPolicy.getRoleSupplyAsNumberOfHolders(disapprovalRole); if (disapprovalPolicySupply == 0) revert RoleHasZeroSupply(disapprovalRole); // Save off the supplies to use for checking quorum. actionApprovalSupply[actionInfo.id] = approvalPolicySupply; //@audit can be changed again after creation actionDisapprovalSupply[actionInfo.id] = disapprovalPolicySupply; }
After that, these supplies are used to check if the action is approved/disapproved.
function isActionApproved(ActionInfo calldata actionInfo) public view returns (bool) { Action memory action = llamaCore.getAction(actionInfo.id); return action.totalApprovals >= _getMinimumAmountNeeded(actionApprovalSupply[actionInfo.id], minApprovalPct); } /// @inheritdoc ILlamaStrategy function isActionDisapproved(ActionInfo calldata actionInfo) public view returns (bool) { Action memory action = llamaCore.getAction(actionInfo.id); return action.totalDisapprovals >= _getMinimumAmountNeeded(actionDisapprovalSupply[actionInfo.id], minDisapprovalPct); }
But validateActionCreation()
can be called by any user to change these supplies and it might bring the wrong governance result.
actionApprovalSupply = 10, minApprovalPct = 5100(51%)
at the creation time.validateActionCreation()
with the current action id.actionApprovalSupply
will be changed to 9 and the action can be approved immediately because 5 >= 9 * 51%(=5)
.Like this comment explains, actionApprovalSupply
shouldn't be changed again.
Manual Review
We should add a modifier like onlyLlamaCore
to validateActionCreation()
so that this function can be called during the action creation only.
Access Control
#0 - c4-pre-sort
2023-06-19T12:01:10Z
0xSorryNotSorry marked the issue as duplicate of #62
#1 - c4-judge
2023-07-02T15:24:12Z
gzeon-c4 marked the issue as satisfactory
446.8103 USDC - $446.81
The revoking logic wouldn't work properly by an expired role holder.
_setRoleHolder()
is used to set/revoke policy roles and it calls _assertNoActionCreationsAtCurrentTimestamp()
to check if there is no action creation at the same time.
function _assertNoActionCreationsAtCurrentTimestamp() internal view { if (llamaExecutor == address(0)) return; // Skip check during initialization. address llamaCore = LlamaExecutor(llamaExecutor).LLAMA_CORE(); uint256 lastActionCreation = LlamaCore(llamaCore).getLastActionTimestamp(); if (lastActionCreation == block.timestamp) revert ActionCreationAtSameTimestamp(); //@audit possible DOS }
It's introduced to prevent checkpoint manipulation as the current protocol uses block.timestamp
instead of block.timestamp - 1
.
But it brings a DoS opportunity for some policyholders.
revokeExpiredRole()
but Alice wants to keep her role.revokeExpiredRole()
is called, Alice creates an action with meaningless settings by frontrunning, and revokeExpiredRole()
will revert.Manual Review
As one mitigation, I think we can prevent for the expired roles to create a new action. Then it would reduce the DoS opportunity to keep the expired roles by creating a new action.
DoS
#0 - c4-pre-sort
2023-06-19T11:45:10Z
0xSorryNotSorry marked the issue as primary issue
#1 - c4-sponsor
2023-06-21T21:19:48Z
AustinGreen marked the issue as sponsor acknowledged
#2 - c4-sponsor
2023-06-21T21:19:56Z
AustinGreen marked the issue as disagree with severity
#3 - AustinGreen
2023-06-21T21:21:10Z
This is an edge case that requires a policyholder breaking their trusted role and a lot of expense/technical expertise to ensure the DoS action lands in every single block during the execution period. This makes medium severity feel too high but it is a valid finding. We are discussing if we want to address it.
#4 - gzeon-c4
2023-07-02T11:13:06Z
Keeping this as M given this is a valid DOS vector and the cost to DOS is linear. There are EVM chains with low enough gas fee which can make this a feasible attack.
#5 - c4-judge
2023-07-02T11:14:21Z
gzeon-c4 marked the issue as satisfactory
#6 - c4-judge
2023-07-02T11:17:21Z
gzeon-c4 marked issue #64 as primary and marked this issue as a duplicate of 64