Solidity DAO Governance, Timelock & Voting Power Manipulation
Overview & Empirical Grounding
This security auditing skill is synthesized from 300 empirical audit contest findings (34 Critical, 266 High severity) extracted from the Zaevlad/audit-findings-dataset corpus.
Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit Governance & DAOs patterns.
Key Trigger Keywords
dao governance, timelock bypass, flash loan voting, governorbravo, vote delegation, quorum manipulation, proposal cancellation
Threat Model & Core Attack Vectors
Detailed failure modes identified across empirical audit findings:
- Unchecked Assumptions & Protocol State Discrepancy: Deviations between internal accounting state and actual balances/external conditions.
- Missing Boundary Checks & Validation: Failing to constrain user inputs, return values, or stale external data feeds.
- Execution Ordering & Invariant Breakage: Invariants momentarily violated during external calls or intermediary calculations.
- Economic & MEV Incentives: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.
Step-by-Step Audit Checklist
When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:
1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (
onlyOwner,onlyRole, custom guards) and ensure roles are initialized in constructor/initializer. - Trace all token balance state changes and external contract interactions.
2. Deep Invariant & Boundary Verification
- Input Validation: Check zero-address (
address(0)), zero-amount, array length parity, and upper/lower bounds. - State Transition Sequencing: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- External Return Handling: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- Failure Paths & DoS Hazards: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.
3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (forge test):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
contract AuditPoCTest is Test {
// Setup target protocol and mock dependencies
function setUp() public {
// Fork mainnet or deploy local environment
}
function test_exploit_vector() public {
// 1. Establish initial attacker balance
// 2. Trigger vulnerability
// 3. Assert invariant violation or balance drain
}
}
Empirical Case Studies from Zaevlad/audit-findings-dataset
The following real-world contest findings illustrate the root causes and remediation patterns:
Case Study: A malicious user can inflate his voting power via merge() Submitted by elhaj
- Severity: High (Dataset Weight: 1.00, ID: #4996)
- Vulnerability Mechanism: The merge function in the ZeroLocker contract is intended to allow users to consolidate their stakes by merging multiple NFTs into one. However, this function can be exploited to artificially inflate the voting power of an NFT, which can then be used to manipulate ongoing governance proposals. An external contract will call the function balanceOfNFT to get the voting power for a specific tokenId, when a user attempt to vote with this token:
function balanceOfNFT(uint256 _tokenId) external view override returns (uint256) {
if (ownershipChange[_tokenId] == block.number) return 0;
return _balanceOfNFT(_tokenId, block.timestamp);
}
function _balanceOfNFT(uint256 _tokenId, uint256 _t) internal view returns (uint256) {
uint256 _epoch = userPointEpoch[_tokenId];
if (_epoch == 0) {
return 0;
} else {
Point memory lastPoint = _userPointHistory[_tokenId][_epoch];
lastPoint.bias -= lastPoint.slope * int128(int256(_t) - int256(lastPoint.ts));
if (lastPoint.bias < 0) {
lastPoint.bias = 0;
}
return uint256(int256(lastPoint.bias));
}
}
A malicious user with multiple NFTs locked in the ZeroLocker contract can execute a series of merges to move their voting power into a
Proof of Concept / Vulnerable Pattern
1. Bob creates multiple locks in the ZeroLocker contract, with one major lock holding a significant amount of zeroToken and several smaller locks with minimal amounts.
2. After some time, Bob begins the exploitation process by voting with the major lock.
3. Bob then merges the major lock into one of the smaller locks using the merge function, which increases the bias of the smaller lock due to the added tokens from the major lock.
4. With the inflated bias of the newly merged lock, Bob votes again, effectively using the same tokens to increase his voting power.
5. Bob repeats this process, merging the inflated lock with another small lock and voting again, further increasing his voting power each time. This vulnerability allows a single user to have an outsized influence on the outcome of governance decisions. Check this code implementation that shows how Bob could inflate his voting power by merging:
```solidity
contract setup is Test {
uint constant supply = 1000000000 ether;
BonusPool bonus;
VestedZeroLend vestToken;
VestedZeroLend zeroToken;
ZeroLocker locker;
FeeDistributor feeDistributor;
StakingEmissions emissions;
StreamedVesting vest;
function setUp() public virtual {
// deplo
- Remediation Recommendation: Update the merge function to record a change in the ownershipChange mapping for the to NFT:
function merge(uint256 _from, uint256 _to) external override {
// prev code .....
_depositFor(_to, value0, end, _locked1, DepositType.MERGE_TYPE);
ownershipChange[_to] = block.timestamp;
}
Case Study: Fund will be stuck if a buyout is started while there are pending migration proposals
- Severity: High (Dataset Weight: 1.00, ID: #14223)
- Vulnerability Mechanism: Funds in migration proposals could potentially be stuck forever if a buyout auction on the same vault is started by other party.
Most of the functions within Migration.sol can only be executed depending on the state of buyout auction in Buyout.sol. When there is no buyout happening, a migration proposal can be made and anyone can contribute to the proposal. However, it is possible that a buyout auction is started by another party while a pending proposal is not commited yet.
When this scenario happens, there is no action that could be taken to interact with the pending proposal. All funds that have been contributed cannot be withdrawn. This is because the functions only check for the state of the buyout auction, instead of also considering whether the buyout auction’s proposer is Migration.sol:
(address token, uint256 id) = IVaultRegistry(registry).vaultToToken(_vault);
if (id == 0) revert NotVault(_vault);
// Reverts if buyout state is not inactive
(, , State current, , , ) = IBuyout(buyout).buyoutInfo(_vault);
State required = State.INACTIVE;
if (current != required) revert IBuyout.InvalidState(required, current);
Proposal contributors have to wait until
Proof of Concept / Vulnerable Pattern
* Bob made a migration proposal and contributed `0.5 eth`.
* Alice individually started a buyout auction. Buyout state is now `ACTIVE`.
* Bob can’t leave the proposal.
* Alice successfully ended the buyout auction. Buyout state is now `SUCCESS`.
* Bob can’t withdraw the funds.
Below are the test cases that show the scenarios described above.
```solidity
function testLeaveBuyoutStarted() public {
initializeMigration(alice, bob, TOTAL_SUPPLY, HALF_SUPPLY, true);
(nftReceiverSelectors, nftReceiverPlugins) = initializeNFTReceiver();
// Migrate to a vault with no permissions (just to test out migration)
address[] memory modules = new address[](1);
modules[0] = address(mockModule);
// Bob makes the proposal
bob.migrationModule.propose(
vault,
modules,
nftReceiverPlugins,
nftReceiverSelectors,
TOTAL_SUPPLY * 2,
1 ether
);
// Bob joins the proposal
bob.migrationModule.join{value: 0.5 ether}(vault, 1, HALF_SUPPLY);
// Alice started buyout
alice.buyoutModule.start{value: 1 ether}(vault);
(, , State current, , , ) = alice.buyoutModule.buyoutInfo(vault);
assert(current == State.LI
Remediation Recommendation: Modify the checks for the following functions:
leavewithdrawContribution
So users can withdraw their funds from the proposal when the buyout auction proposer is not Migration.sol.
In addition, it’s also possible that there are multiple ongoing proposals on the same vault and the buyout is started by one of them. To allow other proposals’ contributors to withdraw their fund, consider tracking the latest proposalId that started the buyout on a vault:
mapping(address => uint256) public latestCommit;
function commit(address _vault, uint256 _proposalId) {
...
if (currentPrice > proposal.targetPrice) {
...
latestCommit[_vault] = _proposalId;
}
}
For leave:
(, address proposer, State current, , , ) = IBuyout(buyout)
---
### Case Study: ERC721Votes’s delegation disables NFT transfers and burning
- **Severity**: High (Dataset Weight: 1.00, ID: #16779)
- **Vulnerability Mechanism**:
If Alice the NFT owner first delegates her votes to herself, second delegates to anyone else with delegate() or delegateBySig() then all her NFT ids will become stuck: their transfers and burning will be disabled.
The issue is _afterTokenTransfer() callback running the _moveDelegateVotes() with an owner instead of her delegate. As Alice’s votes in the checkpoint is zero after she delegated them, the subtraction _moveDelegateVotes() tries to perform during the move of the votes will be reverted.
As ERC721Votes is parent to Token and delegate is a kind of common and frequent operation, the impact is governance token moves being frozen in a variety of use cases, which interferes with governance voting process and can be critical for the project.
#### Proof of Concept / Vulnerable Pattern
```solidity
Suppose Alice delegated all her votes to herself and then decided to delegate them to someone else with either delegate() or delegateBySig() calling _delegate():
```solidity
function _delegate(address _from, address _to) internal {
// Get the previous delegate
address prevDelegate = delegation[_from];
// Store the new delegate
delegation[_from] = _to;
emit DelegateChanged(_from, prevDelegate, _to);
// Transfer voting weight from the previous delegate to the new delegate
_moveDelegateVotes(prevDelegate, _to, balanceOf(_from));
}
_moveDelegateVotes() will set her votes to 0 as _from == Alice and prevTotalVotes = _amount = balanceOf(Alice) (as _afterTokenTransfer() incremented Alice’s vote balance on each mint to her):
function _moveDelegateVotes(
address _from,
address _to,
uint256 _amount
) internal {
unchecked {
// If voting weight is being transferred:
if (_from != _to && _amount > 0) {
// If this isn't a token mint:
if (_from != address(0)) {
// Get the sender's number of checkpoints
uint256 nCheckpoints = numCheckpoints[_from]++;
- Remediation Recommendation: The root issue is _afterTokenTransfer() dealing with Alice instead of Alice’s delegate.
Consider including delegates() call as a fix:
function _afterTokenTransfer(
address _from,
address _to,
uint256 _tokenId
) internal override {
// Transfer 1 vote from the sender to the recipient
_moveDelegateVotes(delegates(_from), delegates(_to), 1);
As delegates(address(0)) == address(0) the burning/minting flow will persist:
/// @notice The delegate for an account
/// @param _account The account address
function delegates(address _account) external view returns (address) {
address current = delegation[_account];
return current == address(0) ? _account : current;
}
The Warden has shown how, due to the overlapping system handling delega
Case Study: ERC721Votes: Token owners can double voting power through self delegation
- Severity: High (Dataset Weight: 1.00, ID: #16789)
- Vulnerability Mechanism:
The owner of one or many
ERC721Votestokens can double their voting power once (and only once) by delegating to their own address as their first delegation.
Proof of Concept / Vulnerable Pattern
This exploit relies on the initial default value of the `delegation` mapping in `ERC721Votes`, which is why it will only work once per address.
First, the token owner must call `delegate` or `delegateBySig`, passing their own address as the delegate:
[`ERC721Votes#delegate`](https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/lib/token/ERC721Votes.sol#L131-L135)
```solidity
/// @notice Delegates votes to an account
/// @param _to The address delegating votes to
function delegate(address _to) external {
_delegate(msg.sender, _to);
}
This calls into the internal _delegate function, with _from and _to both set to the token owner’s address:
/// @dev Updates delegate addresses
/// @param _from The address delegating votes from
/// @param _to The address delegating votes to
function _delegate(address _from, address _to) internal {
// Get the previous dele
- Remediation Recommendation:
Make the
delegatesfunctionpublicrather thanexternal:
/// @notice The delegate for an account
/// @param _account The account address
function delegates(address _account) public view returns (address) {
address current = delegation[_account];
return current == address(0) ? _account : current;
}
Then, call this function rather than accessing the delegation mapping directly:
/// @dev Updates delegate addresses
/// @param _from The address delegating votes from
/// @param _to The address delegating votes to
function _delegate(address _from, address _to) internal {
// Get the previous delegate
address prevDelegate = delegates(_from);
---
### Case Study: CM can `delegatecall` to any address and bypass all restrictions
- **Severity**: High (Dataset Weight: 1.00, ID: #19586)
- **Vulnerability Mechanism**:
The [`GuardCM`](https://github.com/code-423n4/2023-12-autonolas/blob/main/governance/contracts/multisigs/GuardCM.sol) contract is designed to restrict the Community Multisig (CM) actions within the protocol to only specific contracts and methods. This is achieved by implementing a [`checkTransaction()`](https://github.com/code-423n4/2023-12-autonolas/blob/main/governance/contracts/multisigs/GuardCM.sol#L387) method, which is invoked by the CM `GnosisSafe` [before every transaction](https://github.com/safe-global/safe-contracts/blob/186a21a74b327f17fc41217a927dea7064f74604/contracts/GnosisSafe.sol#L147-L167). When `GuardCM` is not paused, the implementation restricts calls to the `schedule()` and `scheduleBatch()` methods in the timelock to only specific targets and selectors, performs additional checks on calls forwarded to the L2s and blocks self-calls on the CM itself, which prevents it from unilaterally removing the guard: ```solidity if (to == owner) { // No delegatecall is allowed if (operation == Enum.Operation.DelegateCall) { revert NoDelegateCall(); } // Data needs to have enough bytes at least to fit the selector if (data.length < SELECTOR_DATA_LENGTH) { revert IncorrectDa
#### Proof of Concept / Vulnerable Pattern
```solidity
We can validate the vulnerability through an additional test case for the `GuardCM.js` test suite. This test case will simulate the exploit scenario and confirm the issue by performing the following actions: 1. It sets up the guard using the `setGuard` function with the appropriate parameters. 2. It attempts to execute an unauthorized call via delegatecall to the timelock, which should be reverted by the guard as expected. 3. It deploys an exploit contract, which contains a function to delete the guard storage. 4. It calls the `deleteGuardStorage` function through a delegatecall from the CM, which will remove the guard variable from the safe’s storage. 5. It repeats the unauthorized call from step 2. This time, the call succeeds, indicating that the guard has been bypassed. A simple exploit contract could look as follows: ```solidity pragma solidity ^0.8.0; contract DelegatecallExploitContract { bytes32 internal constant GUARD_STORAGE_SLOT = 0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8; function deleteGuardStorage() public { assembly { sstore(GUARD_STORAGE_SLOT, 0) } } } ``` And the test: ```solidity it("CM can remove guard through delegatecall", async functio
- Remediation Recommendation:
Disallow
delegatecalls entirely: ```solidity @@ -397,15 +397,14 @@ contract GuardCM { bytes memory, address ) external { // No delegatecall is allowed if (operation == Enum.Operation.DelegateCall) { revert NoDelegateCall(); } // Just return if paused if (paused == 1) { // Call to the timelock if (to == owner) { // No delegatecall is allowed if (operation == Enum.Operation.DelegateCall) { revert NoDelegateCall(); } // Data needs to have enough bytes at least to fit the selector if (data.length < SELECTOR_DATA_LENGTH) {
---
### Case Study: An attacker can bypass the challenge period during LPP finalization
- **Severity**: High (Dataset Weight: 1.00, ID: #21758)
- **Vulnerability Mechanism**:
Large preimage proposals (LPP) allow submitters to prove that certain data is a fixed part of the large preimage that produces a specific Keccak-256 hash value. Since the preimage is large, the process of LPP finalization involves multiple transactions. Because the intermediate steps are not verified on-chain, LPP flow requires a challenge period during which challengers can verify the correctness of the LPP and dispute it on-chain via the `challengeLPP` and `challengeFirstLPP` functions.
The issue arises from the fact that the current implementation of the `squeezeLPP` function allows a malicious submitter to bypass the challenge period and finalize an invalid proposal. I’ve provided a detailed description of why it is possible below.
The `squeezeLPP` function checks that the challenge period is still active using this check:
```solidity
if (block.timestamp - metaData.timestamp() <= CHALLENGE_PERIOD) revert ActiveProposal();
While it looks correct, the problem here is that the timestamp is not initialized in the initLPP function. If the metadata.timestamp() is zero this check will always succeed (assuming that block.timestamp > CHALLENGE_PERIOD). The only place w
Proof of Concept / Vulnerable Pattern
```solidity
contract PreimageOracle_LargePreimageProposals_Test is Test {
...
function test_squeeze_challengePeriodActive_not_revert() public {
//! Set an appropriate value for block.timestamp.
vm.warp(1721643596);
// Allocate the preimage data.
bytes memory data = new bytes(136);
for (uint256 i; i < data.length; i++) {
data[i] = 0xFF;
}
bytes memory phonyData = new bytes(136);
// Initialize the proposal.
oracle.initLPP{ value: oracle.MIN_BOND_SIZE() }(TEST_UUID, 0, uint32(data.length));
// Add the leaves to the tree with mismatching state commitments.
LibKeccak.StateMatrix memory stateMatrix;
bytes32[] memory stateCommitments = _generateStateCommitments(stateMatrix, data);
//! The attacker doesn't set _finalize to true but pads the data correctly.
oracle.addLeavesLPP(TEST_UUID, 0, LibKeccak.padMemory(phonyData), stateCommitments, false);
// Construct the leaf preimage data for the blocks added.
LibKeccak.StateMatrix memory matrix;
PreimageOracl
- Remediation Recommendation:
Consider checking that the proposal was finalized in the
squeezeLPPfunction:
if (metaData.timestamp() == 0 || block.timestamp - metaData.timestamp() <= CHALLENGE_PERIOD) revert ActiveProposal();
Remediation & Hardening Principles
- Fail-Safe Defaults: Always favor reverting rather than proceeding with invalid or unexpected state.
- Defensive Type Safety: Use OpenZeppelin's vetted libraries (
SafeERC20,ReentrancyGuardUpgradeable,SafeCast,Math). - Two-Step Critical Actions: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- Comprehensive Testing Matrix: Pair unit tests with property-based invariant testing (Foundry
invariantor Echidna) and mutation testing.