Liquity v2 ~ Governance (1st audit)
Smart Contract Security Assessment
Aug 12, 2024
SUMMARY
ABSTRACT
Dedaub was commissioned to perform a security audit of the Governance protocol of Liquity v2. The codebase was accompanied by the protocol’s expected specifications and invariants along with relevant documentation. However, various important issues were found affecting significantly the protocol and the way the voting power was calculated. Some of the most important issues involve rounding errors that affect the entire protocol’s bookkeeping. Thus, a major refactoring was deemed necessary for the issues to be addressed which was implemented and reviewed in separate audits.
The protocol was also accompanied by a test suite, but with not extensive coverage as some of the issues could have been easily identified by the tests. It is highly recommended to add more unit and invariant tests to ensure that the protocol operates as intended under all circumstances.
SYSTEM OVERVIEW
Liquity’s Modular Initiative-based Governance protocol was designed so that it incentivizes the LQTY holders to be active in the system and also be eligible to earn portions of Liquity’s earned revenue. Namely, 25% of Liquity’s revenue from borrowing activities is routed to the Governance protocol.
The protocol allows arbitrary addresses, called Initiatives, to be registered as beneficiaries and receive part of the accrued revenue if voted. Every Initiative registration is charged with a fixed fee of 100 BOLD tokens and is a permissionless operation. Both EOAs and contracts can become Initiatives.
Users can get voting power by staking their LQTY tokens in Liquity v1 through the Governance. They are then allowed to either vote or veto Initiatives so that these become eligible to receive part of the revenues or be blocked from participating in the reward distribution. The protocol isolates each user’s bookkeeping by deploying a UserProxy
contract for each one which is responsible for handling their staked LQTY.
The voting power of the users increases linearly over time. The protocol uses a mechanism with average timestamps to represent an ideal point in time at which the users could have staked and get the voting power they currently have accrued. When users stake more LQTY, their voting power remains the same at the time of staking but it starts accruing faster from that point onwards. As a result, the average timestamp gets moved forward in time to represent the ideal timestamp that if the user had staked the entire currently staked LQTY amount, they would have the same voting power but with a faster accrual rate. On the other hand, when users unstake their LQTY, they immediately lose part of their voting power as the average timestamp remains the same.
Users voting for or vetoing Initiatives allocate part of their LQTY to them. This is accomplished by attributing part of their staked LQTY and average timestamp records to the Initiatives which are then removed and locked on their personal accounting. The Initiatives need to keep track of their votes and vetos separately.
In order for an Initiative to become eligible for rewards it should have received more votes than vetos and their votes be above the maximum of either the 3% of the total positive votes in the system or the minimum votes required for the Initiative to get a payout of at least 500 BOLD tokens.
The Initiatives can also be removed from the system permissionlessly if they have received 3x more vetos than votes or if they are stale (i.e. haven’t become claimable) for more than 4 consecutive epochs.
The protocol provides a base implementation for the Initiatives in the BribeInitiative
contract. The Initiatives are allowed to implement custom logic in various hooks that are called by the Governance after every important operation (i.e. registration, vote allocation, unregistration, reward claimimg, etc.). The existing sample hooks are responsible for updating the Initiative’s internal accounting when users update their allocations on that Initiative. The contract also utilizes a mechanism to further incentivize and attract positive votes. Anyone can send to the Initiative bribe tokens which are then distributed to the various voters of the Initiative.
The protocol operates in epochs. Currently, each epoch lasts 7 days. The users are allowed to vote for an Initiative during the first 6 days but on the last day of the epoch (EPOCH_VOTING_CUTOFF
period), the users are only allowed to veto the Initiatives. This design prevents last-minute vote allocation by bad-faith actors to Initiatives that are misaligned with Liquity’s ecosystem. The short veto period gives other stakers a chance to veto such bad-faith Initiatives.
In conclusion, the core functionality of the protocol is implemented in the Governance
contract which is responsible for keeping track of all the users and Initiatives while also ensuring that bad actors and adversaries cannot manipulate or exploit the system for their own benefit. The BribeInitiative
contract provides a base implementation for the Initiatives which they can extend and adapt to their protocol’s needs.
SETTING & CAVEATS
This audit report mainly covers the contracts of the at-the-time private repository liquity/v2-Governance of the Governance Protocol of Liquity v2 at commit fbd4d69ae3ed0b39345ab28f41f8b72f62fc69e6
.
Two auditors worked on the codebase for 5 days on the following contracts:
- BribeInitiative.sol
- CurveV2GaugeRewards.sol
- Governance.sol
- UniV4Donations.sol
- UserProxy.sol
- UserProxyFactory.sol
- interfaces/
- utils/
The fixes of the issues included in the report were mostly reviewed as part of a separate full re-audit of the codebase which can be found here: Liquity v2 ~ Governance (2nd audit) - Nov 11, 2024.
The audit’s main target is security threats, i.e., what the community understanding would likely call "hacking", rather than the regular use of the protocol. Functional correctness (i.e. issues in "regular use") is a secondary consideration. Typically it can only be covered if we are provided with unambiguous (i.e. full-detail) specifications of what is the expected, correct behavior. In terms of functional correctness, we often trusted the code’s calculations and interactions, in the absence of any other specification. Functional correctness relative to low-level calculations (including units, scaling and quantities returned from external protocols) is generally most effectively done through thorough testing rather than human auditing.
PROTOCOL-LEVEL CONSIDERATIONS
The protocol was accompanied by some specifications that suggested the following:
-
“Initiative can be added permissionlessly, requiring the payment of a 100 BOLD fee, and in the following epoch become active for voting. [...] Initiatives failing to be eligible for a claim during four consecutive epochs may be deregistered permissionlessly, requiring reregistration to become eligible for voting again.”
-
“Users may split their votes for and veto votes across any number of initiatives. But cannot vote for and veto vote the same Initiative.”
However, neither of these assumptions seems to be followed in the implementation. More specifically:
-
The
Governance::registerInitiative
function requires the requested initiative not to have an active registration in the system which is determined by the non-zero value ofregisteredInitiatives
mapping. Upon registration, this gets the value of the epoch in which the registration happened. However, when unregistering an initiative this value is not reset back to0
to allow for re-registration effectively disallowing any failed initiative from being registered again for voting which deviates from the described specification. -
The
Governance::allocateLQTY
function is responsible for voting or vetoing specific initiatives. However, the way it is implemented does not prevent someone from defining a positive_deltaLQTYVotes
and a positive_deltaLQTYVetos
which effectively means that they can both vote and veto the same initiative contrary to the specification.
VULNERABILITIES & FUNCTIONAL ISSUES
This section details issues affecting the functionality of the contract. Dedaub generally categorizes issues according to the following severities, but may also take other considerations into account such as impact or difficulty in exploitation:
- User or system funds can be lost when third-party systems misbehave.
- DoS, under specific conditions.
- Part of the functionality becomes unusable due to a programming error.
- Breaking important system invariants but without apparent consequences.
- Buggy functionality for trusted users where a workaround exists.
- Security issues which may manifest when the system evolves.
Issue resolution includes “dismissed” or “acknowledged” but no action taken, by the client, or “resolved”, per the auditors.
CRITICAL SEVERITY
Resolved
Reentrancy guards are a common method for protecting against reentrancy attacks and are used in various functions of the contract (eg depositLQTY
, depositLQTYViaPermit
, allocateLQTY
, etc).
On the other hand, the “Checks Effects Interactions” (CEI) pattern, in which all external calls are performed at the end of the function after all checks and state updates, is also a common way of avoiding reentrancy attacks, without the cost of reentrancy guards. Other functions of the contract opt for this method instead (eg. registerInitiative
, unregisterInitiative,
etc).
Although both methods can be effective when used exclusively, combining them is not always safe. The reason is that an adversary can:
- First, call a method protected by a guard
- Regain execution control via an external call
- Then call a CEI method, which does not have a guard so it can be called
- Then return in the guarded method which might perform updates after the call, leaving the contract in an invalid state.
We have identified at least one scenario in which this type of attack is applicable to the governance contract:
-
The adversary creates and registers a malicious initiative that he controls
-
Then calls
allocateLQTY
for that initiative (which isnonReentrant
) -
allocateLQTY
calls_snapshotVotes
at the start of its execution and stores the resulting state in memory.function allocateLQTY(...) external nonReentrant {
(, GlobalState memory state) = _snapshotVotes();
} -
Later it calls
IInitiative(initiative).onAfterAllocateLQTY
which transfers control to the malicious initiative -
From there the adversary can call
unregisterInitiative
for any initiative (not necessarily the malicious one), which is a CEI function not protected by a guard -
unregisterInitiative
changes the contract state and returns back toallocateLQTY
-
Finally, at the end of its execution,
allocateLQTY
saves theglobalState
:globalState = state;
userStates[msg.sender] = userState; -
However,
state
is a memory variable which has not been affected by the nested call, so changes toglobalState
made byunregisterInitiative
will be lost!
This call leaves the contract in an invalid state, in which initiativeStates
has been updated for the unregistered contract, but the changes to countedVoteLQTY
and countedVoteLQTYAverageTimestamp
are lost. Note also that any CEI function can be called by any nonReentrant
function, so there could be other problematic reentrancy scenarios, either in the current or in future versions of the contract (eg in a potential change to withdrawLQTY
discussed in L1).
For the above reasons, we recommend using reentrancy guards in all functions, to protect against reentrancies in a simple and consistent manner.
Resolved
In the BribeInitiative
contract, the claimBribes
function is supposed to provide a way for the users who have voted for an initiative to claim their allocated bribes. However, the function allows the caller to define the _user
from whom they want to claim while the final token transfers are done using the msg.sender
value.
function claimBribes(address _user, ClaimData[] calldata _claimData)
external
returns (uint256 boldAmount, uint256 bribeTokenAmount)
{
...
// Dedaub: There is no connection between _user and msg.sender below
if (boldAmount != 0) bold.safeTransfer(msg.sender, boldAmount);
if (bribeTokenAmount != 0)
bribeToken.safeTransfer(msg.sender, bribeTokenAmount);
}
This introduces a critical vulnerability in which any adversary can call BribeInitiative::claimBribes
providing anyone who has allocated their LQTY on that initiative stealing their bribes.
C3
Bribes of future epochs can be claimed without voting the initiative when no active bribes in the current epoch exist
Resolved
onAfterAllocateLQTY
hook was refactored and made more explicit on the cases it covers to ensure that each one is treated appropriately. Moreover, changes were also made to the DoubleLinkedList
library updating the semantics and the ordering of the items from head -> tail
to tail -> head
.The idea behind bribes is to incentivize the LQTY holders to vote for a specific initiative in exchange for a portion of the total bribe offered by that initiative. This means that to be eligible for claiming the bribes, you have to allocate part of your voting power to that initiative until the end of the current epoch.
However, a specific call path allows anyone to become eligible to claim the bribes without having active votes for that initiative!
The steps are as follows:
-
A new initiative is registered and becomes active for voting. The important note here is that it should not have active bribes for that epoch just yet.
-
A user allocates their voting power on the initiative by calling
Governance::allocateLQTY
with a non-zerodeltaLQTYVotes
and a zerodeltaLQTYVetos
. -
The
BribeInitiative::onAfterAllocateLQTY
hook is triggered and since this is the first-ever allocation of that user on that initiative, they will get the first entry added to theirlqtyAllocationByUserAtEpoch
records equal todeltaLQTYVotes
. -
Right after that call, they call the
allocateLQTY
function again, but now with adeltaLQTYVotes_1 = -deltaLQTYVotes
. -
However, this time the
onAfterAllocateLQTY
hook will be a no-op since all the top-level checks will evaluate tofalse
as there is no active bribe at the moment andmostRecentEpoch
is not0
anymore because the records have been updated by the 1st call toallocateLQTY
already. -
At some point during the current epoch, the initiative owner decides to add some bribes to attract more votes.
-
Then, after leaving the necessary epochs to pass, the user who had voted and then removed their votes, will be eligible for claiming part of the bribe that was added without them ever supporting the initiative.
Resolved
DoubleLinkedList
library also resolved that issue.This issue is an alternative exploitation of C3 above. The core difference here is that the property of having no active bribes in the current epoch is not required and anyone can exploit this vulnerability at any moment.
The issue lies in how the DoubleLinkedList
is defined and how onAfterAllocateLQTY
references it. The double-linked list is ordered with the head on the left (next to the null item with id 0
) and the tail on the right. Moreover, when a new item is inserted at position 0
it gets added to the tail of the list.
The onAfterAllocateLQTY
hook, however, gets the head element of the list expecting it to be the most recent entry and acts based on this element, but at the same time, it inserts items at the tail of the list. As a result, the mostRecentEpoch
variable will always get the value of the oldest entry rather than the most recent as it should.
This condition allows anyone to deallocate their LQTY from an Initiative, but still be able to claim their portion of the bribes from the Initiative.
The steps to reproduce this vulnerability are as follows:
-
A user allocates their voting power on an initiative with active bribes by calling
Governance::allocateLQTY
with a non-zerodeltaLQTYVotes
and a zerodeltaLQTYVetos
. -
The
BribeInitiative::onAfterAllocateLQTY
hook is triggered and since this is the first-ever allocation of that user on that initiative, they will get the first entry added to theirlqtyAllocationByUserAtEpoch
records equal todeltaLQTYVotes
. -
Now, contrary to the other scenario in C3, the user lets the epoch to conclude and becomes eligible to claim the first part of the bribes legitimately so far.
-
On this new epoch or in some other in the future the initiative owner decides to add some more bribes for attracting votes.
-
The user now makes a trivial call to
allocateLQTY
with bothdeltaLQTYVotes
anddeltaLQTYVetos
equal to0
just for updating their records in theBribeInitiative
contract and become eligible for claiming part of this new bribe, as the0
values are a no-op for their allocated votes, but they do trigger an update to the Initiative via theonAfterAllocateLQTY
. -
This trivial call adds a new entry at the tail of the user’s double linked list.
-
At that point, the user calls the
allocateLQTY
with adeltaLQTYVotes_1 = -deltaLQTYVotes
, so as to remove their votes from that initiative completely. -
The call triggers the hook again, but now the
DoubleLinkedList::getHead
is used to get themostRecentEpoch
value which, however, returns the timestamp of the first and oldest entry (head) rather than the timestamp of the one that resulted by the trivial call toallocateLQTY
described above (tail). -
This means that the execution will enter the branch of the if statement that tries to insert a new entry to the user’s linked list, but since there is already an entry for that epoch in the list (made by the trivial call) and no duplicates are allowed, the execution will revert failing to remove the bribe allocation from the user.
-
This revert, however, will have no effect and go unnoticed since the call is enclosed in a try-catch block.
-
As a result, the final state is a user with no active votes for the initiative, but with active bribe allocation in the
BribeInitiative
‘s records which enables them to claim portion of the bribes without having active votes for that initiative.