Platform: Code4rena
Start Date: 25/10/2022
Pot Size: $50,000 USDC
Total HM: 18
Participants: 127
Period: 5 days
Judge: 0xean
Total Solo HM: 9
Id: 175
League: ETH
Rank: 10/127
Findings: 3
Award: $1,586.88
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: RaoulSchaffranek
Also found by: carlitox477
1529.0309 USDC - $1,529.03
https://github.com/code-423n4/2022-10-inverse/blob/main/src/DBR.sol#L287
While a user borrows DOLA, his debt position in the DBR contract accrues more debt over time. However, Solidity contracts cannot update their storage automatically over time; state updates must always be triggered by externally owned accounts. For this reason, the DBR contract cannot accurately represent a user's debt position in its storage at all times. Instead, the contract offers a method accrueDueTokens
that, when called, updates the internal storage with the debts that accrued since the last update. This method is called before all critical financial operations that depend on an accurate value of the accumulated deficit in the contract's storage. On top, this method can also be invoked permissionless at any time. Suppose a borrower manages to call this function periodically and keep the time difference between updates short. In that case, a rounding error in the computation of the accrued debt can cause the expression to round down to zero. In this case, the user successfully avoided paying interest on his debt.
For reference, here is the affected code:
function accrueDueTokens(address user) public { uint debt = debts[user]; if(lastUpdated[user] == block.timestamp) return; uint accrued = (block.timestamp - lastUpdated[user]) * debt / 365 days; dueTokensAccrued[user] += accrued; totalDueTokensAccrued += accrued; lastUpdated[user] = block.timestamp; emit Transfer(user, address(0), accrued); }
The problem is that the function updates the lastUpdated[user]
storage variable even when accrued
is 0
.
Let's assume that the last update occurred at t_0
.
Further assume that the next update occurs at t_1
with t_1 - t_0 = 12s
. (12s
is the current Ethereum block time)
Suppose that the user's recorded debt
position at t_0 is
1,000,000 wei`.
Then the accrued debt formula gives us the following:
accrued = (t_1 - t_0) * debt / 365 days = 12 * 1,000,000 / 31,536,000 = 1,000,000 / 31,536,000 = 0 (because unsigned integer division rounds down)
The accrued debt formula rounds towards zero if we have (t_1 - t_0) * debt < 365 days
.
This gives us a method to compute the maximal debt that we can deposit to make the attack more efficient:
debt_max = 365 days / 12s -1 = 2,627,999
Notice that an attacker is not limited to these small loans. He can split a massive loan into multiple small loans, capped at 2,627,999. To borrow X tokens (where X is given in WEI), we can compute the number of needed loans as:
#loans = X / 2,627,999
For example, to borrow 1 DOLA:
#loans = 10^18 / 2,627,999 = 380517648599
To borrow 1,000,000 DOLA we would thus need 380,517,648,599,000,000 small loans.
The attack would be economically feasible if the costs of the attack were lower than the interest that accrued throughout the successful attack. The dominating factor of the attack costs is the gas costs which the attacker needs to pay to update the accrued interest of the small loans every second. A clever attacker would batch as many updates into a single transaction as possible to minimize the gas overhead of the transaction. Still, at the current block time (12s), gas price (7 gwei), block gas limit (30,000,000), and current ETH price ($1,550.80), it's hardly imaginable that this attack is economically feasible at the moment.
However, all these values could change in the future. And if we look at other networks, Layer2 or EVM compatible Layer1, the parameters might be different today.
Also, notice that if the contract were used to borrow a different asset than DOLA, the numbers would look drastically different. The risk increases with the asset's price and becomes bigger the fewer decimals the token uses. For example, to borrow 1 WBTC (8 decimals), we would only need 39 small loans:
#loans = 10^8 / 2,627,999 ~39
And to borrow WBTC worth $1,000,000 at a price of 20,746$/BTC, we would need 1864 small loans.
#loans ~= 49*10^8 / 2,627,999 ~= 1864
The following test demonstrates how to avoid paying interest on a loan for 1h. A failing test means that the attack was successful.
$ git diff src/test/DBR.t.sol diff --git a/src/test/DBR.t.sol b/src/test/DBR.t.sol index 3988cf7..8779da7 100644 --- a/src/test/DBR.t.sol +++ b/src/test/DBR.t.sol @@ -25,6 +25,20 @@ contract DBRTest is FiRMTest { vm.stopPrank(); } + function testFail_free_borrow() public { + uint borrowAmount = 2_627_999; + + vm.prank(address(market)); + dbr.onBorrow(user, borrowAmount); + + for (uint i = 12; i <= 3600; i += 12) { + vm.warp(block.timestamp + 12); + dbr.accrueDueTokens(user); + } + assertEq(dbr.deficitOf(user), 0); + } + + function testOnBorrow_Reverts_When_AccrueDueTokensBringsUserDbrBelow0() public { gibWeth(user, wethTestAmount); gibDBR(user, wethTestAmount);
Output:
$ forge test --match-test testFail_free_borrow -vv [â †] Compiling... [â Š] Compiling 1 files with 0.8.17 [â ¢] Solc 0.8.17 finished in 2.62s Compiler run successful Running 1 test for src/test/DBR.t.sol:DBRTest [FAIL. Reason: Assertion failed.] testFail_free_borrow() (gas: 1621543) Test result: FAILED. 0 passed; 1 failed; finished in 8.03ms Failing tests: Encountered 1 failing test in src/test/DBR.t.sol:DBRTest [FAIL. Reason: Assertion failed.] testFail_free_borrow() (gas: 1621543) Encountered a total of 1 failing tests, 0 tests succeeded
Classified as a high medium because the yields can get stolen/denied. It's not high risk because I don't see an economically feasible exploit.
VSCode, Wolramapha, Foundry
lastUpdated
timestamp of the user if the computed accrued amount was zero.#0 - 0xean
2022-11-04T23:03:06Z
debatable if his even qualifies as M, leaning towards QA / LOW and will leave open for sponsor review
#1 - 08xmt
2022-11-15T19:03:31Z
#2 - c4-sponsor
2022-11-15T19:03:39Z
08xmt marked the issue as sponsor confirmed
#3 - c4-judge
2022-11-28T19:35:37Z
0xean marked the issue as satisfactory
#4 - c4-judge
2022-12-01T16:03:53Z
0xean marked the issue as selected for report
#5 - carlitox477
2022-12-06T18:15:41Z
After revising bugs reported in the same line of code I saw that my issue was marked as QA, however the same issue was marked as medium too.
First of all, 2 more issues related to this lines of code were downgraded. I guess it is because they focused just on the rounding error:
However this issue was marked as invalid, and although the report is far better, my report indicates basically the same bug and the same impact, also the mitigation step I pruposed is the same that one selected for the final report:
I beg the judge to reconsider this report as medium as the one which I compare with in this comment
#6 - 0xean
2022-12-06T18:27:41Z
Your issue was closed due to overinflated severity, I think that is accurate still and appreciate the comment but don't plan on changing the judging as its well past QA and Sponsor review as well.
#7 - c4-judge
2022-12-06T18:54:02Z
0xean marked the issue as primary issue
🌟 Selected for report: trustindistrust
Also found by: 0xbepresent, Jujic, Lambda, RaoulSchaffranek, c7e7eff, catchup, codexploder, cryptonue, d3e4, eierina, jwood, pashov, peanuts, pedroais, simon135
33.634 USDC - $33.63
https://github.com/code-423n4/2022-10-inverse/blob/main/src/Oracle.sol#L61 https://github.com/code-423n4/2022-10-inverse/blob/main/src/Oracle.sol#L53
The governance account controlling the oracle can change the underlying price feed at any time. This capability can be accidentally misused or intentionally abused to harm the protocol in different ways, including the possibility of permanently halting the protocol and draining all assets from escrows. The same security risks also apply to attackers who gain control over a Chainlink feed in a way that allows them to report false price information or causes price lookups to revert.
Classified as a medium risk because assets can be stolen and temporarily or permanently locked into escrows, but the attack can only be executed by privileged accounts.
Certain functions depend on price information from the oracle. If the respective lookup functions fail, the entire transaction will revert. In particular, this makes the following market functions unavailable: withdrawing, borrowing, replenishing, and liquidations. This can, for example, happen if the price feed address points to the zero address. Notice, that this can also occur accidentally by an honestly operating governance account because there is no input validation when setting the price feed address.
It should be noted that the governance account has permission to call the pause functions on markets. When paused, the market should make it impossible to borrow, but withdrawing, replenishing, and liquidations should still be possible. By abusing the privilege to change the chainlink feed, the governance account can circumvent this restriction and essentially pause all market operations - the only exception being deposits.
The governance account has complete control over the prices used within the system. It can either set fixed prices for collateral assets which will take priority over price oracles, or it could install a malicious price oracle. Consequently, the governance can trigger undue liquidations by increasing the price and stealing the collateral from the lenders. It could also decrease the cost of borrowing at a cheaper rate.
VSCode
Perform a sanity check when changing the oracle. For example, try calling latestRoundData
and decimals
and check if the return values are coherent. Notice that does not prevent an attacker from switching intentionally to a malicious oracle. Still, it can avoid certain cases where an honestly operating governance account accidentally sets the chainlink feed to the wrong address.
Consider adding a timelock to critical protocol parameters, such as fixed prices and Chainlink feeds in the protocol. For example, when the governance wants to change the oracle feed, the protocol could wait for X hours before the effects are put into action. This allows users to respond to the proposed change and take immediate action before the new price feed is activated. Document the behavior transparently and prominently, and publicly announce intended changes to critical protocol parameters early.
Also, monitor the Chainlink feeds for malicious behavior.
#0 - c4-judge
2022-11-05T22:43:33Z
0xean marked the issue as duplicate
#1 - Simon-Busch
2022-12-05T15:36:36Z
Issue marked as satisfactory as requested by 0xean
#2 - c4-judge
2022-12-07T08:22:05Z
Simon-Busch marked the issue as duplicate of #301
🌟 Selected for report: adriro
Also found by: 8olidity, BClabs, CertoraInc, Chom, Franfran, Lambda, RaoulSchaffranek, Ruhum, codexploder, cryptphi, eierina, joestakey, kaden, neumo, pashov, rvierdiiev, sorrynotsorry
24.2165 USDC - $24.22
https://github.com/code-423n4/2022-10-inverse/blob/main/src/Oracle.sol#L87 https://github.com/code-423n4/2022-10-inverse/blob/main/src/Oracle.sol#L121
The Oracle contract always tries to normalize prices to DOLA. More precisely, the oracle has to account for different decimals in the price feed, the asset token, and the DOLA token. It uses the following formula:
uint8 decimals = 36 - feedDecimals - tokenDecimals; uint normalizedPrice = price * (10 ** decimals)
The problem is that the formula underflows when feedDecimals + tokenDecimals > 36
. In this case, the viewPrice
and getPrice
functions will revert.
One critical consequence is that withdrawals, liquidations, replenishment, and borrows on markets no longer work. In particular, collateral inside escrows cannot be unlocked.
Notice that the governance can recover from this situation by switching to a different price feed or setting a fixed price for the underlying collateral.
Classified as a medium risk because assets are not directly at risk, but the availability of the protocol is affected.
https://github.com/code-423n4/2022-10-inverse/blob/main/src/Oracle.sol#L87 https://github.com/code-423n4/2022-10-inverse/blob/main/src/Oracle.sol#L121
VSCode
#0 - c4-judge
2022-11-04T23:44:37Z
0xean marked the issue as duplicate
#1 - c4-judge
2022-11-28T15:48:54Z
0xean marked the issue as not a duplicate
#2 - c4-judge
2022-11-28T15:49:02Z
0xean marked the issue as primary issue
#3 - c4-judge
2022-11-28T19:30:38Z
0xean marked the issue as duplicate of #540
#4 - Simon-Busch
2022-12-05T15:33:14Z
Issue marked as satisfactory as requested by 0xean
#5 - c4-judge
2022-12-07T08:18:20Z
Simon-Busch marked the issue as duplicate of #533