Badger eBTC Audit + Certora Formal Verification Competition - nobody2018's results

Use stETH to borrow Bitcoin with 0% fees | The only smart contract based #BTC.

General Information

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

eBTC Protocol

Findings Distribution

Researcher Performance

Rank: 10/52

Findings: 2

Award: $2,964.08

QA:
grade-a

๐ŸŒŸ Selected for report: 1

๐Ÿš€ Solo Findings: 0

Findings Information

๐ŸŒŸ Selected for report: nobody2018

Also found by: 0xRobocop, BARW, Stormy

Labels

bug
2 (Med Risk)
high quality report
primary issue
satisfactory
selected for report
sponsor confirmed
M-05

Awards

2846.57 USDC - $2,846.57

External Links

Lines of code

https://github.com/code-423n4/2023-10-badger/blob/main/packages/contracts/contracts/LeverageMacroBase.sol#L173

Vulnerability details

Impact

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:

  1. The check that should be passed cannot be passed, causing tx revert and waste of gas.
  2. The check that should not have been passed was passed, which may result in funds losses from this contract or caller.

Proof of Concept

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:

  1. initialCdpIndex = 1, which is from sortedCdps.cdpCountOf(address(this)).

  2. open a new Cdp: B.

  3. The current linked list has 11 CDPs, and its topology is as follows:

    head                         tail  
    cdp1->A->cdp2->cdp3->B->...->cdp9
  4. 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.

Tools Used

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").

Assessed type

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

  • Marking as HQ for the efforts of the warden
  • Might be OOS, but leaving for sponsor/judge review.

https://gist.github.com/GalloDaSballo/a0f9766bf7bac391f49d2d167e947de0#incorrect-sorting-due-to-pending-debt-and-yield CleanShot 2023-11-17 at 9โ€ฏ 26 17

#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

Awards

117.508 USDC - $117.51

Labels

bug
disagree with severity
downgraded by judge
grade-a
high quality report
primary issue
QA (Quality Assurance)
sponsor acknowledged
Q-21

External Links

Lines of code

https://github.com/code-423n4/2023-10-badger/blob/main/packages/contracts/contracts/LeverageMacroBase.sol#L143

Vulnerability details

Impact

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).

Proof of Concept

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:

  1. _currentCdpId == dummyId means that head has been traversed.
  2. Whether expected iterations exceeds 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:

  1. After traversing 1000 items for the first time, we got initialCdpIndex = 1(L143).

  2. Execute borrowerOperations.openCdp to get the new CDP(B).

  3. 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
  4. Then, at L173 sortedCdps.cdpOfOwnerByIndex(address(this), 1) will traverse the entire linked list again.

Tools Used

Manual Review

Should not traverse the entire linked list on-chain.

Assessed type

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

  • Marking as HQ because of the effort the warden put into the report
  • Leaving for judge/sponsor review whether this is QA or not.

#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:

  1. [The first time](https://github.com/code-423n4/2023-10-badger/blob/main/packages/contracts/contracts/LeverageMacroBase.sol#L143): iterate through all items in the entire linked list to calculate the number of its own CDPs, which is what each U2 needs to perform each time it creates a CDP. We assume that OOG will occur when the length of the linked list exceeds the threshold (T). It's not that the attacker made the linked list too long, but that the protocol is becoming more and more popular. This situation is very likely to happen.
  2. [The second time](https://github.com/code-423n4/2023-10-badger/blob/main/packages/contracts/contracts/LeverageMacroBase.sol#L173): The number of iterations depends on the NICR of the newly created CDP. The larger the NICR, the closer the newly created CDP is to the head of the linked list, and the closer the number of iterations is to the length of the linked list. As we all know, each user has his own risk preference, and the NICR of CDPs created by conservative users is high.

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:

  1. The owner of U2, that is 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.
  2. LeverageMacroBase integrates all functions for managing CDPs, and 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

  1. The current length of the Liquity treasury queue on the mainnet is approximately 900+. Liquity has been running since early 2021, and we can consider this as a reference.
  2. When the number of CDPs reaches approximately 10k, certain functionalities will become unavailable. However, before reaching this threshold, any impact on the protocol will only be reflected in the gas cost.

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

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