Platform: Code4rena
Start Date: 03/11/2022
Pot Size: $115,500 USDC
Total HM: 17
Participants: 120
Period: 7 days
Judge: LSDan
Total Solo HM: 1
Id: 174
League: ETH
Rank: 11/120
Findings: 8
Award: $2,480.30
π Selected for report: 0
π Solo Findings: 0
π Selected for report: Lambda
Also found by: HE1M, Trust, adriro, berndartmueller, minhquanym
1468.9791 USDC - $1,468.98
https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/modules/credit/LineOfCredit.sol#L234 https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/modules/credit/LineOfCredit.sol#L270
In LineOfCredit
contract, both functions addCredit(...)
and increaseCredit(...)
require mutual consent between lender and borrower. If lender is tricked by borrower, or by mistake, lender ETH will be locked in the contract forever.
function addCredit( uint128 drate, uint128 frate, uint256 amount, address token, address lender ) external payable override whileActive mutualConsent(lender, borrower) // @audit Lender call first will lose ETH returns (bytes32)
The way modifier mutualConsent
works for 2 entities is:
modifier mutualConsent(address _signerOne, address _signerTwo) { if(_mutualConsent(_signerOne, _signerTwo)) { // Run whatever code needed 2/2 consent _; } } function _mutualConsent(address _signerOne, address _signerTwo) internal returns(bool) { if(msg.sender != _signerOne && msg.sender != _signerTwo) { revert Unauthorized(); } address nonCaller = _getNonCaller(_signerOne, _signerTwo); // The consent hash is defined by the hash of the transaction call data and sender of msg, // which uniquely identifies the function, arguments, and sender. bytes32 expectedHash = keccak256(abi.encodePacked(msg.data, nonCaller)); if (!mutualConsents[expectedHash]) { bytes32 newHash = keccak256(abi.encodePacked(msg.data, msg.sender)); mutualConsents[newHash] = true; emit MutualConsentRegistered(newHash); return false; } delete mutualConsents[expectedHash]; return true; }
In case addCredit(...)
with native token ETH, lenders should be the one who send ETH along their transactions (lender lend to borrower). Function receiveTokenOrETH
will check if caller send enough ETH
function receiveTokenOrETH( address token, address sender, uint256 amount ) external returns (bool) { if(token == address(0)) { revert TransferFailed(); } if(token != Denominations.ETH) { // ERC20 IERC20(token).safeTransferFrom(sender, address(this), amount); } else { // ETH if(msg.value < amount) { revert TransferFailed(); } } return true; }
Applying to the logic of mutualConsent
above, there are 2 cases:
receiveTokenOrETH()
will fail and TX is reverted.Since these agreements are settled off-chain, there are always some human-errors to happen.
In addition, if borrower tricks lender into thinking that borrower did send the TX but actually let the lender be the first caller, ETH lender sent will be locked.
Manual Review
Consider adding check that sender
is actualy msg.sender
in receiveTokenOrETH()
function
function receiveTokenOrETH( address token, address sender, uint256 amount ) external returns (bool) { if(token == address(0)) { revert TransferFailed(); } if(token != Denominations.ETH) { // ERC20 IERC20(token).safeTransferFrom(sender, address(this), amount); } else { // ETH if(msg.value < amount || msg.sender != sender) { revert TransferFailed(); } } return true; }
#0 - c4-judge
2022-11-17T15:32:33Z
dmvt marked the issue as duplicate of #42
#1 - c4-judge
2022-12-06T17:07:50Z
dmvt marked the issue as satisfactory
#2 - C4-Staff
2022-12-20T06:34:56Z
liveactionllama marked the issue as not a duplicate
#3 - C4-Staff
2022-12-20T06:35:10Z
liveactionllama marked the issue as duplicate of #125
π Selected for report: aphak5010
Also found by: HE1M, Jeiwan, SmartSek, hansfriese, minhquanym, rvierdiiev
339.9637 USDC - $339.96
Judge has assessed an item in Issue #366 as M risk. The relevant finding follows:
Mutual consent works by using two TXs with the same msg.data. However, when first one call, there is no way to cancel it. First caller might send wrong msg.data or later caller change the mind in the midway.
Since it's not possible to cancel the process, later caller can take benefit and call it in the future.
Recommendation Consider allowing to cancel the mutual consent process after some time interval.
#0 - c4-judge
2022-12-06T22:38:58Z
dmvt marked the issue as duplicate of #33
#1 - c4-judge
2022-12-06T22:39:02Z
dmvt marked the issue as satisfactory
π Selected for report: 0xdeadbeef0x
Also found by: 8olidity, Ch_301, HE1M, Koolex, Lambda, Nyx, RedOneN, Ruhum, Tomo, Trust, adriro, aphak5010, ayeslick, berndartmueller, brgltd, carlitox477, cccz, codexploder, d3e4, eierina, eighty, immeas, joestakey, lotux, minhquanym, perseverancesuccess, rbserver, rvierdiiev
8.0811 USDC - $8.08
Since the interest is updated every seconds, when borrower want to repay an ETH debt, he cannot calculate exactly the amount of ETH to send.
However, these excess ETH is not sent back to borrower but locked in the contract.
Function receiveTokenOrETH()
accepts more ETH than needed but not sent back to sender
function receiveTokenOrETH( address token, address sender, uint256 amount ) external returns (bool) { if(token == address(0)) { revert TransferFailed(); } if(token != Denominations.ETH) { // ERC20 IERC20(token).safeTransferFrom(sender, address(this), amount); } else { // ETH if(msg.value < amount) { revert TransferFailed(); } // @audit can send more than needed } return true; }
Manual Review
Consider sending excess ETH to sender or adding a sweep function for LineOfCredit.
#0 - c4-judge
2022-11-17T15:40:04Z
dmvt marked the issue as duplicate of #25
#1 - c4-judge
2022-12-06T15:28:21Z
dmvt marked the issue as satisfactory
#2 - C4-Staff
2022-12-20T06:44:54Z
liveactionllama marked the issue as duplicate of #39
π Selected for report: perseverancesuccess
Also found by: 0x52, HE1M, Lambda, Trust, adriro, aphak5010, cccz, minhquanym
107.0886 USDC - $107.09
Function SpigotedLine.claimAndRepay()
is callable by both borrower and lender. Callers will provide zeroExTradeData
to help calling trade function in 0x.
Since it's repay function, borrower has no incentive to act malicious but lender has. Lender can call with trade with no slippage protection, creating front-run opportunity, also lower the revenue from spigot.
Lender can call claimAndRepay()
with arbitrary trade data
function claimAndRepay(address claimToken, bytes calldata zeroExTradeData) external whileBorrowing nonReentrant returns (uint256) { bytes32 id = ids[0]; Credit memory credit = _accrue(credits[id], id); if (msg.sender != borrower && msg.sender != credit.lender) { revert CallerAccessDenied(); } uint256 newTokens = claimToken == credit.token ? spigot.claimEscrow(claimToken) : // same asset. dont trade _claimAndTrade( // trade revenue token for debt obligation claimToken, credit.token, zeroExTradeData // @audit lender can call with no slippage protection ); uint256 repaid = newTokens + unusedTokens[credit.token]; uint256 debt = credit.interestAccrued + credit.principal; // cap payment to debt value if (repaid > debt) repaid = debt; // update unused amount based on usage if (repaid > newTokens) { // using bought + unused to repay line unusedTokens[credit.token] -= repaid - newTokens; } else { // high revenue and bought more than we need unusedTokens[credit.token] += newTokens - repaid; } credits[id] = _repay(credit, id, repaid); emit RevenuePayment(claimToken, repaid); return newTokens; }
And there is no slippage protection for the trade https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/utils/SpigotedLineLib.sol#L85
uint256 claimed = ISpigot(spigot).claimEscrow(claimToken); trade( claimed + unused, claimToken, swapTarget, zeroExTradeData ); // underflow revert ensures we have more tokens than we started with uint256 tokensBought = LineLib.getBalance(targetToken) - oldTargetTokens; if(tokensBought == 0) { revert TradeFailed(); } // ensure tokens bought
Manual Review
Adding an Oracle to calculate the slippage is overcomplicated but still a solution. Or consider only allowing lender to claim but not trade revenue from spigot.
#0 - c4-judge
2022-11-17T15:39:52Z
dmvt marked the issue as duplicate of #88
#1 - c4-judge
2022-11-17T20:47:02Z
dmvt marked the issue as selected for report
#2 - c4-judge
2022-12-08T19:53:45Z
dmvt marked the issue as satisfactory
#3 - c4-judge
2022-12-08T19:56:40Z
dmvt marked the issue as not selected for report
#4 - c4-judge
2022-12-08T19:56:50Z
dmvt marked the issue as partial-50
#5 - kibagateaux
2022-12-09T19:00:22Z
Most similar to #88 as you've marked it. I think #411 is the best canonical ticket because it gest to the core of 0x exploit (arbitrary trade data) and its the most profitable/malicious attack explained.
#6 - C4-Staff
2022-12-16T23:20:48Z
captainmangoC4 marked the issue as duplicate of #110
π Selected for report: __141345__
Also found by: Bnke0x0, Ch_301, Jeiwan, Lambda, Ruhum, aphak5010, ayeslick, cccz, codexploder, everyanykey, hansfriese, ladboy233, minhquanym, pashov, rbserver, rvierdiiev
48.8098 USDC - $48.81
Judge has assessed an item in Issue #366 as M risk. The relevant finding follows:
Every tokens of credit line will be transferred from lender to LineOfCredit first, then to borrower later. These 2-transfer steps will make the tax for some fee-on-transfer tokens accounted twice.
However, current logic is not handled it. So if lender uses fee-on-transfer tokens, contract will not have enough balance to transfer to borrower.
function addCredit( uint128 drate, uint128 frate, uint256 amount, address token, address lender ) external payable override whileActive mutualConsent(lender, borrower) // @note Lender call first will lose ETH returns (bytes32) { LineLib.receiveTokenOrETH(token, lender, amount);
bytes32 id = _createCredit(lender, token, amount); require(interestRate.setRate(id, drate, frate)); return id;
} Recommendation Consider comparing pre and after balance when receiving token.
#0 - c4-judge
2022-12-06T22:37:07Z
dmvt marked the issue as duplicate of #26
#1 - c4-judge
2022-12-06T22:37:19Z
dmvt marked the issue as satisfactory
#2 - C4-Staff
2022-12-20T06:01:34Z
liveactionllama marked the issue as duplicate of #367
π Selected for report: __141345__
Also found by: 0xdeadbeef0x, 8olidity, Amithuddar, Bnke0x0, Ch_301, Deivitto, IllIllI, KingNFT, Nyx, RaymondFam, RedOneN, Satyam_Sharma, SmartSek, Tomo, adriro, bananasboys, carlitox477, cccz, cloudjunky, codexploder, corerouter, cryptonue, d3e4, datapunk, joestakey, martin, merlin, minhquanym, pashov, peanuts, rvierdiiev
5.3388 USDC - $5.34
Sending ETH using transfer()
function might block contract from receiving ETH. It forwards a fixed gas amount, and gas cost might change in the future too. So if contract does some actions in receive()
function like require, assert, the transfer can fail because of out of gas.
Function sendOutTokenOrETH()
used transfer()
when sending ETH
function sendOutTokenOrETH( address token, address receiver, uint256 amount ) external returns (bool) { if(token == address(0)) { revert TransferFailed(); } // both branches revert if call failed if(token!= Denominations.ETH) { // ERC20 IERC20(token).safeTransfer(receiver, amount); } else { // ETH payable(receiver).transfer(amount); // @audit use call instead of transfer } return true; }
Please check out the article for more information https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now/
Manual Review
Consider using call()
instead of transfer()
when sending ETH
#0 - c4-judge
2022-11-17T15:37:24Z
dmvt marked the issue as duplicate of #14
#1 - c4-judge
2022-12-06T14:45:57Z
dmvt marked the issue as satisfactory
#2 - C4-Staff
2022-12-20T05:56:43Z
liveactionllama marked the issue as duplicate of #369
π Selected for report: berndartmueller
Also found by: 0xdeadbeef0x, Jeiwan, R2, ayeslick, minhquanym
440.6937 USDC - $440.69
LineOfCredit
is considered liquidatable when all credit lines are not closed by deadline. An lender can easily reject receiving payment (ETH or tokens with callback), blocking credit line from closing and effectively making LineOfCredit
liquidatable.
In addition, SecuredLine is inherited from LineOfCredit. When SecuredLine is liquidatable, it will send tokens to arbiter for OTC sales which introduce loss of funds for borrower.
Function LineOfCredit._healthcheck()
return LIQUIDATABLE
in case timestamp pass the deadline and count > 0
.
https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/modules/credit/LineOfCredit.sol#L121-L139
function _healthcheck() internal virtual returns (LineLib.STATUS) { // if line is in a final end state then do not run _healthcheck() LineLib.STATUS s = status; if ( s == LineLib.STATUS.REPAID || // end state - good s == LineLib.STATUS.INSOLVENT // end state - bad ) { return s; } // Liquidate if all credit lines aren't closed by deadline if (block.timestamp >= deadline && count > 0) { emit Default(ids[0]); // can query all defaulted positions offchain once event picked up return LineLib.STATUS.LIQUIDATABLE; } // if nothing wrong, return to healthy ACTIVE state return LineLib.STATUS.ACTIVE; }
Then when SecuredLine inherits LineOfCredit, _healthcheck()
will return value from LineOfCredit if that value is not ACTIVE
https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/modules/credit/SecuredLine.sol#L97-L104
function _healthcheck() internal override(EscrowedLine, LineOfCredit) returns(LineLib.STATUS) { LineLib.STATUS s = LineOfCredit._healthcheck(); if(s != LineLib.STATUS.ACTIVE) { return s; } return EscrowedLine._healthcheck(); }
However in order to remove credit line from storage, it has to call _close()
function. Value of count
is only decreased here.
https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/modules/credit/LineOfCredit.sol#L483
function _close(Credit memory credit, bytes32 id) internal virtual returns (bool) { if(credit.principal > 0) { revert CloseFailedWithPrincipal(); } // return the Lender's funds that are being repaid if (credit.deposit + credit.interestRepaid > 0) { LineLib.sendOutTokenOrETH(// @audit lender can reject receive token, make it liquidated ? credit.token, credit.lender, credit.deposit + credit.interestRepaid ); } delete credits[id]; // gas refunds // remove from active list ids.removePosition(id); unchecked { --count; } // If all credit lines are closed the the overall Line of Credit facility is declared 'repaid'. if (count == 0) { _updateStatus(LineLib.STATUS.REPAID); } emit CloseCreditPosition(id); return true; }
In the function _close()
, it return the Lender's funds that are being repaid. But if credit token is ETH or tokens with callback, receiver can reject receiving them (revert on function receive()
for ETH), it makes that credit line cannot be closed and count
always larger than 0
.
Manual Review
Consider applying Pull over Push pattern when returning Lender's funds.
#0 - c4-judge
2022-11-17T15:33:20Z
dmvt marked the issue as duplicate of #85
#1 - c4-judge
2022-11-17T20:40:51Z
dmvt changed the severity to 2 (Med Risk)
#2 - c4-judge
2022-12-06T17:34:46Z
dmvt marked the issue as satisfactory
#3 - C4-Staff
2022-12-20T05:44:39Z
liveactionllama marked the issue as duplicate of #467
π Selected for report: IllIllI
Also found by: 0x1f8b, 0xNazgul, 0xRoxas, 0xSmartContract, Awesome, Aymen0909, B2, BClabs, Bnke0x0, Deekshith99, Deivitto, Diana, Dinesh11G, Funen, HE1M, HardlyCodeMan, Josiah, Nyx, Rahoz, RaymondFam, RedOneN, ReyAdmirado, Rolezn, Saintcode_, TomJ, Trust, __141345__, a12jmx, adriro, ajtra, aphak5010, apostle0x01, brgltd, btk, bulej93, c3phas, carlitox477, catwhiskeys, ch0bu, chaduke, chrisdior4, cryptonue, cryptostellar5, csanuragjain, ctf_sec, delfin454000, djxploit, durianSausage, erictee, fatherOfBlocks, gogo, i_got_hacked, immeas, joestakey, jumpdest7d, lukris02, martin, mcwildy, merlin, minhquanym, oyc_109, pashov, peanuts, pedr02b2, rbserver, rotcivegaf, rvierdiiev, sakman, saneryee, seyni, shark, slowmoses, tnevler, trustindistrust, w0Lfrum, yurahod, zaskoh
61.3462 USDC - $61.35
Id | Title |
---|---|
1 | Not support fee-on-transfer tokens |
2 | Lender call close() will lose ETH |
3 | Implementation mismatch documentation in updateSplit() function |
4 | Unsafe cast from bytes to bytes4 in function operate() |
5 | Cannot cancel mutual consent |
Every tokens of credit line will be transferred from lender to LineOfCredit first, then to borrower later. These 2-transfer steps will make the tax for some fee-on-transfer tokens accounted twice.
However, current logic is not handled it. So if lender uses fee-on-transfer tokens, contract will not have enough balance to transfer to borrower.
function addCredit( uint128 drate, uint128 frate, uint256 amount, address token, address lender ) external payable override whileActive mutualConsent(lender, borrower) // @note Lender call first will lose ETH returns (bytes32) { LineLib.receiveTokenOrETH(token, lender, amount); bytes32 id = _createCredit(lender, token, amount); require(interestRate.setRate(id, drate, frate)); return id; }
Consider comparing pre and after balance when receiving token.
close()
will lose ETHFunction LineOfCredit.close()
allowed both borrower and lender of the credit line to call. However, if credit token is native token, lender call this function will lose ETH but there is no check for that.
Consider preventing lender from calling close()
if credit token is ETH
updateSplit()
functionIn the NatSpec comment of function updateSplit()
, it said that this function is called by arbiter and borrower only. However, there is no check for that in the implementation, anyone can call this function.
However I did not found any risk here since the values to be updated are fixed (split = defaultSplit / MAX_SPLIT
)
Consider updating the document.
bytes
to bytes4
in function operate()
In operate()
function, bytes data
is unsafe casted to bytes4
to get the function selector. However, this unsafe cast can lead to unexpected behaviour when providing wrong data.
If bytes data
is less than 4 bytes, it will pad 0
at the end when casting to bytes4
. And if it is occasionally identical to another whitelisted function, it will make the call to unwhitelisted function possible (fallback()
, receive()
) and lead to unexpected behaviour.
Consider using SafeCast library
Mutual consent works by using two TXs with the same msg.data
. However, when first one call, there is no way to cancel it. First caller might send wrong msg.data
or later caller change the mind in the midway.
Since it's not possible to cancel the process, later caller can take benefit and call it in the future.
Consider allowing to cancel the mutual consent process after some time interval.
#0 - c4-judge
2022-12-06T22:52:58Z
dmvt marked the issue as grade-b