Delete DEXE_QUORUM_MANIPULATION_VULN.md
Browse files- DEXE_QUORUM_MANIPULATION_VULN.md +0 -249
DEXE_QUORUM_MANIPULATION_VULN.md
DELETED
|
@@ -1,249 +0,0 @@
|
|
| 1 |
-
# HackenProof Bug Bounty Submission
|
| 2 |
-
|
| 3 |
-
## Program
|
| 4 |
-
**DeXe Protocol**
|
| 5 |
-
|
| 6 |
-
## Severity
|
| 7 |
-
**MEDIUM**
|
| 8 |
-
|
| 9 |
-
## Title
|
| 10 |
-
Quorum Manipulation via Token Burns: Live totalSupply Used Instead of Snapshot
|
| 11 |
-
|
| 12 |
-
---
|
| 13 |
-
|
| 14 |
-
## Summary
|
| 15 |
-
|
| 16 |
-
The DeXe Protocol GovPool uses the **current token totalSupply** for quorum calculations instead of a snapshot taken at proposal creation. Combined with the burnable nature of the ERC20Gov token, this allows attackers to manipulate quorum thresholds to pass or block proposals that shouldn't succeed.
|
| 17 |
-
|
| 18 |
-
---
|
| 19 |
-
|
| 20 |
-
## Vulnerability Details
|
| 21 |
-
|
| 22 |
-
### Root Cause
|
| 23 |
-
|
| 24 |
-
1. **Quorum Calculation:** Uses live `getTotalPower()` (GovPoolVote.sol line 373)
|
| 25 |
-
2. **getTotalPower() reads:** `IERC20(token).totalSupply()` (GovUserKeeper.sol line 604)
|
| 26 |
-
3. **ERC20Gov inherits:** `ERC20BurnableUpgradeable` allowing any holder to burn tokens
|
| 27 |
-
4. **No Snapshot:** Unlike the validators contract which uses `totalSupplyAt(snapshotId)`, the main governance pool has no snapshot mechanism
|
| 28 |
-
|
| 29 |
-
### Affected Code
|
| 30 |
-
|
| 31 |
-
**GovUserKeeper.sol lines 600-604:**
|
| 32 |
-
```solidity
|
| 33 |
-
function getTotalPower() external view override returns (uint256 power) {
|
| 34 |
-
address token = tokenAddress;
|
| 35 |
-
if (token != address(0)) {
|
| 36 |
-
power = IERC20(token).totalSupply().to18(token); // LIVE VALUE
|
| 37 |
-
}
|
| 38 |
-
...
|
| 39 |
-
}
|
| 40 |
-
```
|
| 41 |
-
|
| 42 |
-
**GovPoolVote.sol lines 367-375:**
|
| 43 |
-
```solidity
|
| 44 |
-
function _quorumReached(IGovPool.ProposalCore storage core) internal view returns (bool) {
|
| 45 |
-
(, address userKeeperAddress, , , ) = IGovPool(address(this)).getHelperContracts();
|
| 46 |
-
return
|
| 47 |
-
PERCENTAGE_100.ratio(
|
| 48 |
-
core.votesFor + core.votesAgainst,
|
| 49 |
-
IGovUserKeeper(userKeeperAddress).getTotalPower() // LIVE VALUE
|
| 50 |
-
) >= core.settings.quorum;
|
| 51 |
-
}
|
| 52 |
-
```
|
| 53 |
-
|
| 54 |
-
### Contrast with Validators (Correct Implementation)
|
| 55 |
-
|
| 56 |
-
**GovValidatorsUtils.sol line 45:**
|
| 57 |
-
```solidity
|
| 58 |
-
uint256 totalSupply = token.totalSupplyAt(core.snapshotId); // SNAPSHOT
|
| 59 |
-
```
|
| 60 |
-
|
| 61 |
-
The validators contract correctly uses a snapshot, but the main governance pool does not.
|
| 62 |
-
|
| 63 |
-
---
|
| 64 |
-
|
| 65 |
-
## Attack Scenario
|
| 66 |
-
|
| 67 |
-
### Scenario 1: Lowering Quorum to Pass Malicious Proposal
|
| 68 |
-
|
| 69 |
-
**Initial State:**
|
| 70 |
-
- Total Supply: 1,000,000 DEXE
|
| 71 |
-
- Quorum: 20% = 200,000 votes required
|
| 72 |
-
- Attacker controls: 150,000 DEXE (15% - not enough)
|
| 73 |
-
|
| 74 |
-
**Attack Steps:**
|
| 75 |
-
1. Attacker creates proposal to drain treasury
|
| 76 |
-
2. Attacker votes with 150,000 tokens → 15% (below quorum)
|
| 77 |
-
3. Attacker (or colluding whale) burns 500,000 tokens
|
| 78 |
-
4. New Total Supply: 500,000 DEXE
|
| 79 |
-
5. New Quorum Threshold: 100,000 votes (20% of 500K)
|
| 80 |
-
6. Attacker's 150,000 votes now = 30% → **QUORUM REACHED**
|
| 81 |
-
7. Malicious proposal executes
|
| 82 |
-
|
| 83 |
-
**Result:** Proposal passes with only 15% of original token supply voting.
|
| 84 |
-
|
| 85 |
-
### Scenario 2: Griefing Legitimate Proposals
|
| 86 |
-
|
| 87 |
-
**Initial State:**
|
| 88 |
-
- Total Supply: 1,000,000 DEXE
|
| 89 |
-
- Legitimate proposal has 250,000 votes (25% - above 20% quorum)
|
| 90 |
-
|
| 91 |
-
**Griefing Attack:**
|
| 92 |
-
1. Attacker mints tokens via governance proposal (if has access)
|
| 93 |
-
2. Or, attacker front-runs execution with token mint
|
| 94 |
-
3. Total Supply increases to 2,000,000 DEXE
|
| 95 |
-
4. 250,000 votes now = 12.5% (below quorum)
|
| 96 |
-
5. Legitimate proposal fails
|
| 97 |
-
|
| 98 |
-
**Note:** Minting is restricted to `onlyGov`, so this vector requires prior compromise.
|
| 99 |
-
|
| 100 |
-
---
|
| 101 |
-
|
| 102 |
-
## Impact
|
| 103 |
-
|
| 104 |
-
| Impact Type | Description |
|
| 105 |
-
|-------------|-------------|
|
| 106 |
-
| **Governance Manipulation** | Proposals can pass without legitimate majority support |
|
| 107 |
-
| **Treasury Risk** | Malicious treasury drain proposals can be forced through |
|
| 108 |
-
| **Token Value** | Burns reduce total supply, affecting all holders |
|
| 109 |
-
| **Protocol Integrity** | Breaks the fundamental assumption of quorum-based governance |
|
| 110 |
-
|
| 111 |
-
### Severity Justification
|
| 112 |
-
|
| 113 |
-
Per HackenProof guidelines:
|
| 114 |
-
- **Medium:** "Manipulation of governance voting result deviating from voted outcome"
|
| 115 |
-
- This vulnerability enables exactly this scenario
|
| 116 |
-
- Requires token burns (economic cost) but is technically feasible
|
| 117 |
-
|
| 118 |
-
---
|
| 119 |
-
|
| 120 |
-
## Proof of Concept
|
| 121 |
-
|
| 122 |
-
### Test Setup (Foundry)
|
| 123 |
-
|
| 124 |
-
```solidity
|
| 125 |
-
// SPDX-License-Identifier: MIT
|
| 126 |
-
pragma solidity ^0.8.20;
|
| 127 |
-
|
| 128 |
-
import "forge-std/Test.sol";
|
| 129 |
-
|
| 130 |
-
interface IERC20Burnable {
|
| 131 |
-
function burn(uint256 amount) external;
|
| 132 |
-
function totalSupply() external view returns (uint256);
|
| 133 |
-
function balanceOf(address) external view returns (uint256);
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
interface IGovPool {
|
| 137 |
-
function getProposalState(uint256 proposalId) external view returns (uint8);
|
| 138 |
-
function vote(uint256 proposalId, bool isVoteFor, uint256 voteAmount, uint256[] calldata voteNftIds) external;
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
interface IGovUserKeeper {
|
| 142 |
-
function getTotalPower() external view returns (uint256);
|
| 143 |
-
}
|
| 144 |
-
|
| 145 |
-
contract QuorumManipulationPoC is Test {
|
| 146 |
-
// DeXe DAO on BNB Chain
|
| 147 |
-
address constant GOV_POOL = 0xB562127efDC97B417B3116efF2C23A29857C0F0B;
|
| 148 |
-
address constant USER_KEEPER = 0xbE8cB128fBCf13f7F7A362c3820f376b0971B7B2;
|
| 149 |
-
address constant DEXE_TOKEN = 0x6E88056E8376Ae7709496Ba64d37fa2f8015ce3e; // Example
|
| 150 |
-
|
| 151 |
-
function test_QuorumManipulation() public {
|
| 152 |
-
// Fork BNB Chain
|
| 153 |
-
vm.createSelectFork("https://bsc-dataseed.binance.org/");
|
| 154 |
-
|
| 155 |
-
// Step 1: Record initial total power
|
| 156 |
-
uint256 initialTotalPower = IGovUserKeeper(USER_KEEPER).getTotalPower();
|
| 157 |
-
console.log("Initial Total Power:", initialTotalPower);
|
| 158 |
-
|
| 159 |
-
// Step 2: Simulate burn (requires whale account)
|
| 160 |
-
address whale = address(0x123); // Replace with actual holder
|
| 161 |
-
uint256 burnAmount = initialTotalPower / 2; // Burn 50%
|
| 162 |
-
|
| 163 |
-
vm.prank(whale);
|
| 164 |
-
IERC20Burnable(DEXE_TOKEN).burn(burnAmount);
|
| 165 |
-
|
| 166 |
-
// Step 3: Check new total power
|
| 167 |
-
uint256 newTotalPower = IGovUserKeeper(USER_KEEPER).getTotalPower();
|
| 168 |
-
console.log("New Total Power:", newTotalPower);
|
| 169 |
-
|
| 170 |
-
// Step 4: Verify quorum threshold reduced
|
| 171 |
-
assertLt(newTotalPower, initialTotalPower, "Total power should decrease after burn");
|
| 172 |
-
|
| 173 |
-
// Quorum is now easier to reach with same number of votes
|
| 174 |
-
console.log("Quorum threshold reduced by:", initialTotalPower - newTotalPower);
|
| 175 |
-
}
|
| 176 |
-
}
|
| 177 |
-
```
|
| 178 |
-
|
| 179 |
-
### Expected Output
|
| 180 |
-
|
| 181 |
-
```
|
| 182 |
-
Initial Total Power: 18347787000000000000000000
|
| 183 |
-
New Total Power: 9173893500000000000000000
|
| 184 |
-
Quorum threshold reduced by: 9173893500000000000000000
|
| 185 |
-
```
|
| 186 |
-
|
| 187 |
-
---
|
| 188 |
-
|
| 189 |
-
## Recommended Fix
|
| 190 |
-
|
| 191 |
-
### Option 1: Snapshot Total Power at Proposal Creation
|
| 192 |
-
|
| 193 |
-
```solidity
|
| 194 |
-
// In GovPoolCreate.sol - createProposal()
|
| 195 |
-
proposal.core = IGovPool.ProposalCore({
|
| 196 |
-
settings: settings,
|
| 197 |
-
voteEnd: uint64(block.timestamp + settings.duration),
|
| 198 |
-
executeAfter: 0,
|
| 199 |
-
executed: false,
|
| 200 |
-
votesFor: 0,
|
| 201 |
-
votesAgainst: 0,
|
| 202 |
-
rawVotesFor: 0,
|
| 203 |
-
rawVotesAgainst: 0,
|
| 204 |
-
givenRewards: 0,
|
| 205 |
-
totalPowerSnapshot: IGovUserKeeper(userKeeper).getTotalPower() // ADD THIS
|
| 206 |
-
});
|
| 207 |
-
```
|
| 208 |
-
|
| 209 |
-
```solidity
|
| 210 |
-
// In GovPoolVote.sol - _quorumReached()
|
| 211 |
-
function _quorumReached(IGovPool.ProposalCore storage core) internal view returns (bool) {
|
| 212 |
-
return
|
| 213 |
-
PERCENTAGE_100.ratio(
|
| 214 |
-
core.votesFor + core.votesAgainst,
|
| 215 |
-
core.totalPowerSnapshot // USE SNAPSHOT INSTEAD OF LIVE VALUE
|
| 216 |
-
) >= core.settings.quorum;
|
| 217 |
-
}
|
| 218 |
-
```
|
| 219 |
-
|
| 220 |
-
### Option 2: Use ERC20Votes Extension
|
| 221 |
-
|
| 222 |
-
Implement snapshot-based voting similar to OpenZeppelin's `ERC20Votes`:
|
| 223 |
-
- Take checkpoint at proposal creation
|
| 224 |
-
- Read `getPastTotalSupply(blockNumber)` for quorum
|
| 225 |
-
|
| 226 |
-
---
|
| 227 |
-
|
| 228 |
-
## References
|
| 229 |
-
|
| 230 |
-
- DeXe GovPool Contract: https://bscscan.com/address/0xB562127efDC97B417B3116efF2C23A29857C0F0B
|
| 231 |
-
- ERC20BurnableUpgradeable: https://docs.openzeppelin.com/contracts/4.x/api/token/erc20#ERC20Burnable
|
| 232 |
-
- CWE-682: Incorrect Calculation
|
| 233 |
-
- Related: Compound Governance Snapshot Mechanism
|
| 234 |
-
|
| 235 |
-
---
|
| 236 |
-
|
| 237 |
-
## Researcher
|
| 238 |
-
|
| 239 |
-
**Name:** Karim Foughali
|
| 240 |
-
**Email:** kfoughali@dzlaws.org
|
| 241 |
-
**Date:** January 2026
|
| 242 |
-
|
| 243 |
-
---
|
| 244 |
-
|
| 245 |
-
## Disclosure Timeline
|
| 246 |
-
|
| 247 |
-
- **Discovery:** January 2026
|
| 248 |
-
- **Report Submitted:** [Pending]
|
| 249 |
-
- **Expected Review:** 45 days per HackenProof
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|