Llama - auditor0517's results

A governance system for onchain organizations.

General Information

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

Llama

Findings Distribution

Researcher Performance

Rank: 1/50

Findings: 3

Award: $9,090.03

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: auditor0517

Also found by: 0xnev, T1MOH, Toshii, kutugu

Labels

bug
3 (High Risk)
disagree with severity
primary issue
selected for report
sponsor disputed
H-01

Awards

3346.4803 USDC - $3,346.48

External Links

Lines of code

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

Vulnerability details

Impact

In LlamaRelativeQuorum, the governance result might be incorrect as it counts the wrong approval/disapproval.

Proof of Concept

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.

  1. There are 3 role holders(Alice, Bob, Charlie) and Alice has 2 quantities, others have 1.
  2. During the action creation with the LlamaRelativeQuorum strategy, actionApprovalSupply = 3 and there should be 2 approved holders at least when minApprovalPct = 51%.
  3. But if Alice approves the action, the result of 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.

Tools Used

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;
  }

Assessed type

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:

  • 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.

#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 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.

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 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.

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 totalApprovalQuantityand 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.

Findings Information

🌟 Selected for report: ktg

Also found by: auditor0517, dirk_y

Labels

bug
3 (High Risk)
satisfactory
duplicate-62

Awards

5296.7399 USDC - $5,296.74

External Links

Lines of code

https://github.com/code-423n4/2023-06-llama/blob/9d641b32e3f4092cc81dbac7b1c451c695e78983/src/strategies/LlamaRelativeQuorum.sol#L199

Vulnerability details

Impact

The governance result might be manipulated seriously because the approval/disapproval supplies can be changed anytime by an attacker.

Proof of Concept

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.

  1. An action was created and actionApprovalSupply = 10, minApprovalPct = 5100(51%) at the creation time.
  2. During the approval period, 5 policyholders approved the action and totalApprovals = 5 which doesn't still meet the approval threshold.
  3. At that time, 1 policyholder of the approval role was revoked and one user who wants to execute the proposal called validateActionCreation() with the current action id.
  4. Then 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.

Tools Used

Manual Review

We should add a modifier like onlyLlamaCore to validateActionCreation() so that this function can be called during the action creation only.

Assessed type

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

Findings Information

🌟 Selected for report: ktg

Also found by: 0xnev, Atree, BLOS, Toshii, auditor0517, xuwinnie

Labels

bug
2 (Med Risk)
disagree with severity
satisfactory
sponsor acknowledged
duplicate-64

Awards

446.8103 USDC - $446.81

External Links

Lines of code

https://github.com/code-423n4/2023-06-llama/blob/9d641b32e3f4092cc81dbac7b1c451c695e78983/src/LlamaPolicy.sol#L408

Vulnerability details

Impact

The revoking logic wouldn't work properly by an expired role holder.

Proof of Concept

_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.

  1. Alice had a role to create an action and the role was expired.
  2. Now her role can be revoked by anyone using revokeExpiredRole() but Alice wants to keep her role.
  3. So when revokeExpiredRole() is called, Alice creates an action with meaningless settings by frontrunning, and revokeExpiredRole() will revert.
  4. As it doesn't cost to create an action for Alice(just high gas cost for frontrunning), it's very likely to keep the expired role.

Tools Used

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.

Assessed type

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

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