Platform: Code4rena
Start Date: 24/10/2023
Pot Size: $149,725 USDC
Total HM: 7
Participants: 52
Period: 21 days
Judge: ronnyx2017
Total Solo HM: 2
Id: 300
League: ETH
Rank: 10/52
Findings: 2
Award: $2,964.08
๐ Selected for report: 1
๐ Solo Findings: 0
๐ Selected for report: nobody2018
2846.57 USDC - $2,846.57
After LeverageMacroBase.doOperation is used to open a new CDP, there will be a POST CALL CHECK, which is used to check the new CDP. However, the current implementation incorrectly assumes that the index of the new CDP is the last one. In this case, there may be two impacts:
this
contract or caller.doOperation
is used to open a new CDP, and there are roughly three steps: setup for POST CALL CHECK, openCdp, POST CALL CHECK for the created CDP.
File: packages\contracts\contracts\LeverageMacroBase.sol 118: function doOperation( 119: FlashLoanType flType, 120: uint256 borrowAmount, 121: LeverageMacroOperation calldata operation, 122: PostOperationCheck postCheckType, 123: PostCheckParams calldata checkParams 124: ) external { ...... 139: uint256 initialCdpIndex; 140: if (postCheckType == PostOperationCheck.openCdp) { 141: // How to get owner 142: // sortedCdps.existCdpOwners(_cdpId); 143:-> initialCdpIndex = sortedCdps.cdpCountOf(address(this)); 144: } ......//do the operation 169: if (postCheckType == PostOperationCheck.openCdp) { 170: // How to get owner 171: // sortedCdps.existCdpOwners(_cdpId); 172: // initialCdpIndex is initialCdpIndex + 1 173:-> bytes32 cdpId = sortedCdps.cdpOfOwnerByIndex(address(this), initialCdpIndex);. 174: 175: // Check for param details 176: ICdpManagerData.Cdp memory cdpInfo = cdpManager.Cdps(cdpId); 177:-> _doCheckValueType(checkParams.expectedDebt, cdpInfo.debt); 178:-> _doCheckValueType(checkParams.expectedCollateral, cdpInfo.coll); 179: require( 180: cdpInfo.status == checkParams.expectedStatus, 181: "!LeverageMacroReference: openCDP status check" 182: ); 183: } ...... 211: }
L143, initialCdpIndex = sortedCdps.cdpCountOf(address(this))
, which is used to count the number of CDPs already owned by this
.
L145-168, Creats a CDP, the code here is not helpful in describing the issue and is therefore omitted.
L173, sortedCdps.cdpOfOwnerByIndex(address(this), initialCdpIndex)
is used to get the id of the initialCdpIndex-th CDP owned by this
. That's the problem. Because initialCdpIndex
is the number of CDPs owned before the new CDP was created (from L143), that is to say, the index of the created CDP is considered to be the last one.
Let's look at the code of cdpOfOwnerByIndex
:
File: packages\contracts\contracts\SortedCdps.sol 140: function cdpOfOwnerByIndex( 141: address owner, 142: uint256 index 143: ) external view override returns (bytes32) { 144:-> (bytes32 _cdpId, ) = _cdpOfOwnerByIndex(owner, index, dummyId, 0); 145: return _cdpId; 146: } ...... 173: function _cdpOfOwnerByIndex( 174: address owner, 175: uint256 index, 176: bytes32 startNodeId, 177: uint maxNodes 178: ) internal view returns (bytes32, bool) { 179: // walk the list, until we get to the indexed CDP 180: // start at the given node or from the tail of list 181:-> bytes32 _currentCdpId = (startNodeId == dummyId ? data.tail : startNodeId); 182:-> uint _currentIndex = 0; 183: uint i; 184: 185: while (_currentCdpId != dummyId) { 186: // if the current CDP is owned by specified owner 187: if (getOwnerAddress(_currentCdpId) == owner) { 188: // if the current index of the owner CDP matches specified index 189:-> if (_currentIndex == index) { 190:-> return (_currentCdpId, true); 191: } else { 192: // if not, increment the owner index as we've seen a CDP owned by them 193:-> _currentIndex = _currentIndex + 1; 194: } 195: } 196: ++i; 197: 198: // move to the next CDP in the list 199: _currentCdpId = data.nodes[_currentCdpId].prevId; 200: 201: // cut the run if we exceed expected iterations through the loop 202: if (maxNodes > 0 && i >= maxNodes) { 203: break; 204: } 205: } 206: // if we reach maximum iteration or end of list 207: // without seeing the specified index for the owner 208: // then maybe a new pagination is needed 209: return (_currentCdpId, false); 210: }
L144, _cdpOfOwnerByIndex(owner, index, dummyId, 0)
is called.
L181, _currentCdpId = data.tail due to startNodeId = dummyId
.
L189-194, If _currentIndex == index
, the target cdpId
is found and the loop exits. Otherwise, _currentIndex
is increased by 1.
To better describe the problem, consider the following scenario:
this
already has 1 CDP: A, and the entire linked list currently has 10 CDPs. Its topology is as follows:
head tail cdp1->A->cdp2->...->cdp8->cdp9
New CDP is created via doOperation
:
initialCdpIndex = 1
, which is from sortedCdps.cdpCountOf(address(this))
.
open a new Cdp: B.
The current linked list has 11 CDPs, and its topology is as follows:
head tail cdp1->A->cdp2->cdp3->B->...->cdp9
cdpId = sortedCdps.cdpOfOwnerByIndex(address(this), 1)
. cdpId
was expected to be B's id, but A's id was returned.
The root cause of this case is that the linked list is in descending order of NICR, and the NICR of the newly created CDP may be lower than the NICR of the already created CDP. In this case, the wrong cdpId
will be returned.
Manual Review
In _openCdpCallback, borrowerOperations.openCdp
returns the created cdpId
, and we should specify a slot to store this value. Afterwards, read this slot directly in POST CALL CHECK and reset it to byte32(0)
. The slot can be obtained by using a method similar to keccak256("Badger.LeverageMacroBase.OpenCdpId")
.
Context
#0 - c4-pre-sort
2023-11-16T06:43:33Z
bytes032 marked the issue as high quality report
#1 - c4-pre-sort
2023-11-16T06:43:42Z
bytes032 marked the issue as primary issue
#2 - bytes032
2023-11-17T08:10:10Z
#3 - GalloDaSballo
2023-11-20T09:26:08Z
Would have liked a coded POC of 2 cdps and the last one not being last
#4 - GalloDaSballo
2023-11-22T13:13:05Z
Team says that to find the specific CdpID we would need to use something like this: <img width="984" alt="Screenshot 2023-11-22 at 14 11 58" src="https://github.com/code-423n4/2023-10-badger-findings/assets/13383782/cb5fb981-e138-4dd5-bb72-e69d8e24c6bc">
The finding seems valid, but I believe it would result in reverts on usage (no loss of funds) And I believe it was missed because the check always works for the first Cdp, it will cause issue for the 2nd cdp onwards
#5 - c4-sponsor
2023-11-22T13:13:10Z
GalloDaSballo (sponsor) confirmed
#6 - c4-judge
2023-11-25T02:44:40Z
jhsagd76 marked the issue as satisfactory
#7 - c4-judge
2023-11-27T10:38:50Z
jhsagd76 marked the issue as selected for report
#8 - CodingNameKiki
2023-12-05T22:22:40Z
Would have liked a coded POC of 2 cdps and the last one not being last
Looks like the third duplicate has a POC, but might be too late to use now. : D
๐ Selected for report: SpicyMeatball
Also found by: 0xBeirao, 7ashraf, LokiThe5th, OMEN, TrungOre, alexzoid, alpha, bdmcbri, ether_sky, fatherOfBlocks, ge6a, hihen, hunter_w3b, jasonxiale, ladboy233, lsaudit, niroh, nobody2018, nonseodion, peanuts, prapandey031, shaka, twcctop, twicek, wangxx2026
117.508 USDC - $117.51
When LeverageMacroBase.doOperation is used to open a CDP and set postCheckType to PostOperationCheck.openCdp
, the code here will count all CDPs owned by this
via sortedCdps.cdpCountOf(address(this))
, which will traverse the entire doubly linked list. If the number of all items in this linked list are huge, the gas consumed by traversing the entire linked list may exceed the block limit. This situation is very likely to exist in a large protocol, where a large number of users open a large number of CDPs. In this case, callers who use LeverageMacroBase to open an CDP will not only suffer DOS but also gas losses (gas is expensive on the mainnet).
When doOperation
is to open a new CDP, the caller can set its argument postCheckType
to PostOperationCheck.openCdp
, which means that some necessary checks need to be done after opening a CDP. doOperation
will setup for post call check before executing the operation, where it will count the number of CDPs that this
has opened:
File: packages\contracts\contracts\LeverageMacroBase.sol 118: function doOperation( 119: FlashLoanType flType, 120: uint256 borrowAmount, 121: LeverageMacroOperation calldata operation, 122: PostOperationCheck postCheckType, 123: PostCheckParams calldata checkParams 124: ) external { ...... 139: uint256 initialCdpIndex; 140: if (postCheckType == PostOperationCheck.openCdp) { 141: // How to get owner 142: // sortedCdps.existCdpOwners(_cdpId); 143:-> initialCdpIndex = sortedCdps.cdpCountOf(address(this)); 144: }
If postCheckType == PostOperationCheck.openCdp
at L140, L143's sortedCdps.cdpCountOf(address(this))
will be called.
File: packages\contracts\contracts\SortedCdps.sol 216: function cdpCountOf(address owner) external view override returns (uint256) { 217:-> (uint256 _cnt, ) = _cdpCountOf(owner, dummyId, 0); 218: return _cnt; 219: } ...... 237: function _cdpCountOf( 238: address owner, 239: bytes32 startNodeId, 240: uint maxNodes 241: ) internal view returns (uint256, bytes32) { 242: // walk the list, until we get to the count 243: // start at the given node or from the tail of list 244: bytes32 _currentCdpId = (startNodeId == dummyId ? data.tail : startNodeId); 245: uint _ownedCount = 0; 246: uint i = 0; 247: 248: while (_currentCdpId != dummyId) { 249: // if the current CDP is owned by specified owner 250: if (getOwnerAddress(_currentCdpId) == owner) { 251: _ownedCount = _ownedCount + 1; 252: } 253: ++i; 254: 255: // move to the next CDP in the list 256: _currentCdpId = data.nodes[_currentCdpId].prevId; 257: 258: // cut the run if we exceed expected iterations through the loop 259: if (maxNodes > 0 && i >= maxNodes) { 260: break; 261: } 262: } 263: return (_ownedCount, _currentCdpId); 264: }
L217, call _cdpCountOf
with parameters: startNodeId=dummyId
, maxNodes=0
.
L244, _currentCdpId
will be assigned to data.tail
because startNodeId
is dummyId
.
L248-262, traverse the linked list in reverse direction. The two conditions for exiting the while loop are:
_currentCdpId == dummyId
means that head has been traversed.maxNodes
. If maxNodes
is 0, then this check is skipped (L259).As can be seen from the above analysis, the cdpCountOf
function will traverse the entire linked list. For a large protocol with many users, the number of CDPs must be in the thousands. It is very likely that DOS opens CDP's tx viaย doOperation
.
The above analysis is only the first traversal, and there will be another traversal in a special case, which makes the problem more serious.
File: packages\contracts\contracts\LeverageMacroBase.sol 169: if (postCheckType == PostOperationCheck.openCdp) { 170: // How to get owner 171: // sortedCdps.existCdpOwners(_cdpId); 172: // initialCdpIndex is initialCdpIndex + 1 173:-> bytes32 cdpId = sortedCdps.cdpOfOwnerByIndex(address(this), initialCdpIndex); ...... 183: }
The logic of traversing the linked list in sortedCdps.cdpOfOwnerByIndex is similar to cdpCountOf
.
Give an example to express this issue better:
Assume that there are 1000 CDPs in the linked list, and the current calling contract (this) has 1 CDP: A. The current topology of the linked list is as follows:
head tail cdp1->cdp2->....->A->cdp500...->cdp999
Call doOperation
to open a new CDP, the flow is as follows:
After traversing 1000 items for the first time, we got initialCdpIndex = 1
(L143).
Execute borrowerOperations.openCdp
to get the new CDP(B).
There are currently 2 CDPs: index0 = A, index1 = B. The new topology of the linked list is as follows:
head tail B->cdp1->cdp2->....->A->cdp500...->cdp999
Then, at L173 sortedCdps.cdpOfOwnerByIndex(address(this), 1)
will traverse the entire linked list again.
Manual Review
Should not traverse the entire linked list on-chain.
DoS
#0 - c4-pre-sort
2023-11-17T06:44:10Z
bytes032 marked the issue as high quality report
#1 - c4-pre-sort
2023-11-17T06:44:14Z
bytes032 marked the issue as primary issue
#2 - bytes032
2023-11-17T06:44:32Z
#3 - c4-sponsor
2023-11-20T09:41:03Z
GalloDaSballo marked the issue as disagree with severity
#4 - c4-sponsor
2023-11-20T09:41:08Z
GalloDaSballo (sponsor) acknowledged
#5 - GalloDaSballo
2023-11-20T09:41:34Z
basically if you own 20k CDPs or w/e you can't open the N+1th
No loss would happen either, I think QA
#6 - jhsagd76
2023-11-27T08:51:55Z
It's a valid partial DOS, but the attacker would incur significant costs. So qa
#7 - c4-judge
2023-11-27T08:53:14Z
jhsagd76 changed the severity to QA (Quality Assurance)
#8 - c4-judge
2023-11-27T08:53:25Z
jhsagd76 marked the issue as grade-a
#9 - c4-judge
2023-11-27T08:53:29Z
jhsagd76 marked the issue as selected for report
#10 - c4-judge
2023-11-28T10:31:27Z
jhsagd76 marked the issue as not selected for report
#11 - securitygrid
2023-12-05T11:09:45Z
It's a valid partial DOS, but the attacker would incur significant costs.
@jhsagd76 The report never described the problem as being caused by a malicious user. There is all about normal users.
As Badger grows and becomes popular, more users means more CDPs. Then the number of items in the doubly linked list in sortedCdps will become larger and larger. Users are divided into two categories: EOA (U1) and contract (U2). LeverageMacroBase.sol was developed for U2 to facilitate CDP management.
When postCheckType == PostOperationCheck.openCdp
, a new CDP is created via LeverageMacroBase.doOperation
. The current implementation requires two traverses of the linked list:
Therefore, if the length of the linked list is greater than T, then OOG will be triggered during the first traversal. If the length of the linked list is [T/2, T], OOG may also occur the second time (the gas consumption of creating CDP is not considered).
The occurrence of OOG has two impacts:
tx.origin
, will lose gas. The protocol is deployed on the mainnet, so this is a loss for the caller due to expensive gas. More importantly, this affects all callers using LeverageMacroBase.the function of the protocol can be impacted due to this issue
. If the length of the linked list is greater than T, it means that Badger has grown into a large protocol, and the length is likely to continue to grow. Then the duration of DOS may be quite long. According to the rules of C4, if the function of the protocol is affected, it can be considered M, right?In summary, please reconsider this issue. Thanks.
#12 - jhsagd76
2023-12-06T09:13:36Z
So I believe this algorithm needs improvement because when the CDP queue reaches 1k, the gas consumed for traversal becomes significantly high. However, this does not have a substantial impact on the protocol, and I cannot classify it as MED.
#13 - securitygrid
2023-12-09T17:36:08Z
If a bull market comes, the number of CDPs can easily reach thousands. Then opening a new CDP will be a very expensive operation. Opening a CDP costs hundreds or thousands of dollars extra, which is definitely unacceptable. The fix for this issue has removed traversing the linked list. If the sponsor doesn't think this is likely to happen, why fix it? @GalloDaSballo
#14 - GalloDaSballo
2023-12-09T17:50:10Z
We fix QA issues so they are not chained into bigger problems