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: 13/120
Findings: 5
Award: $1,981.42
π 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/utils/LineLib.sol#L71
If a lender would like to lend ERC20 token, the protocol works properly. The lender gives allowance to the line of credit and calls addCredit
to sign the consent. Later, the borrower calls this function, and signs the consent. Since two signatures are provided, the credit will be created and the ERC20 token will be transferred from lender to the line of credit contract. Everything is working as expected.
But if the lender would like to lend ETH, and call addCredit
earlier than borrower, the lender will lose the fund completely. Please read the proof of concept section regarding this scenario.
Based on the document:
To create a credit line within the Line of Credit facility, a Lender needs to make a first deposit. The deposit can only succeed if both Borrower and Lender consent and mutually sign a 2/2 transaction as evidence of agreement.
Suppose a lender decides to make a first ETH deposit. So, s/he calls addCredit
and transfers some amount of ETH. Since, the borrower has not yet called this function, the lender's call only transfers the ETH and sign the consent in the mapping mutualConsents[newHash] = true
. But the credit will not be created, because the modifier mutualConsent
needs two signatures to proceed with credit creation.
function addCredit( uint128 drate, uint128 frate, uint256 amount, address token, address lender ) external payable override whileActive mutualConsent(lender, borrower) returns (bytes32) { LineLib.receiveTokenOrETH(token, lender, amount); bytes32 id = _createCredit(lender, token, amount); require(interestRate.setRate(id, drate, frate)); return id; }
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; }
So, the ETH is withdrawn from lender and received by LineOfCredit, but no credit is created yet.
Later when borrower calls addCredit
, it will be reverted, because msg.value
is not equal to amount
.
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; }
The situation can be even worse, if the borrower tricks the lenders and tell them that s/he (borrower) has already called addCredit
, so the lenders will assume that it is safe to transfer ETH trough calling addCredit(...)
, but in reality the borrower has not called it yet. So, the lenders call this function, and will lost their ETH. They can not even withdraw it, because no credit is created for lenders.
There can be some solutions:
pendingDeposit[lender] = msg.value
. So that if borrower later calls this function, it checks this mapping, and proceeds to the next step. It should be also claimable by the lender.#0 - dmvt
2022-11-15T15:30:43Z
This is rather hard to understand, IMO, so I'm going to add some color. Because the modifier does not revert in a scenario where the borrower has not yet signed consent, the transaction is successful even though _mutualConsent
returns false. This results in the guarded function addCredit
not being executed, but the ETH is still transferred.
#1 - c4-judge
2022-11-15T15:30:54Z
dmvt marked the issue as primary issue
#2 - c4-sponsor
2022-11-30T15:07:55Z
kibagateaux marked the issue as sponsor confirmed
#3 - c4-judge
2022-12-06T17:07:37Z
dmvt marked the issue as satisfactory
#4 - C4-Staff
2022-12-20T06:32:32Z
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
There is no mechanism to cancel a consent. This can happen if a lender decides to not consent on an agreement OR the parameters sent in function addCredit
are not correct.
If for example a lender consented already by invoking the function addCredit
, then there is no mechanism to cancel this consent. Because, any function with theses modifiers mutualConsent
and mutualConsentById
will set the consent in the mapping mutualConsents
, and there is no way to delete it except if other party (borrower) executes this consent (that is a pity for the lender who wanted to cancel).
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; }
https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/utils/MutualConsent.sol#L38 https://github.com/debtdao/Line-of-Credit/blob/e8aa08b44f6132a5ed901f8daa231700c5afeb3a/contracts/modules/credit/LineOfCredit.sol#L223
This applies to all functions that contain these modifiers.
A simple function just to delete a consent should be added.
#0 - c4-judge
2022-11-15T15:03:33Z
dmvt marked the issue as duplicate of #33
#1 - c4-judge
2022-12-06T16:51:15Z
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
4.0405 USDC - $4.04
Judge has assessed an item in Issue #35 as M risk. The relevant finding follows:
No. 2 Any smart contract that uses transfer() or send() is taking a hard dependency on gas costs by forwarding a fixed amount of gas: 2300. If gas costs are subject to change, then smart contracts canβt depend on any particular gas costs.
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); } return true; }
#0 - c4-judge
2022-12-06T16:52:41Z
dmvt marked the issue as duplicate of #25
#1 - c4-judge
2022-12-06T16:52:47Z
dmvt marked the issue as partial-50
#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
During trading claimToken
with targetToken
, the calldata provided for the 0x router is an arbitrary data that can be provided by the borrower or lender.
https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/utils/SpigotedLineLib.sol#L135
If lender provides the calldata so that that more claimToken
is consumed than expected, the borrower is impacted (loses some fund).
If a lender (malicious) would like to claim and repay, the flow is as follows:
claimAndRepay(address claimToken, bytes calldata zeroExTradeData)
=> function _claimAndTrade(address claimToken, address targetToken, bytes calldata zeroExTradeData)
=> function claimAndTrade(address claimToken, address targetToken, address payable swapTarget, address spigot, uint256 unused, bytes calldata zeroExTradeData)
=> function trade(uint256 amount, address sellToken, address payable swapTarget, bytes calldata zeroExTradeData)
https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/modules/credit/SpigotedLine.sol#L93 https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/modules/credit/SpigotedLine.sol#L187 https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/utils/SpigotedLineLib.sol#L53 https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/utils/SpigotedLineLib.sol#L120
Assuming the targetToken
is DAI, and claimToken
is USDC, if the calldata zeroExTradeData
is something that calls the function buy 100 DAI for any amount of USDC
instead of buy 100 DAI for maximum amount of 105 USDC
, it is highly possible that more than 105 USDC will be consumed to trade for 100 DAI. In this case, the borrower loses more claimToken
than expected.
In summary, since the calldata is arbitrary, the lender can put these data buy X targetToken for any amount of claim token
or sell X claimToken for any amount of targetToken
, in both cases the borrower may lose fund. While the correct call data is: buy X targetToken for max Y claimToken
or sell X claimToken for min Y targetToken
.
The calldata zeroExTradeData
should be set in advance that which function of the router is going to be called. For example in case of uniswap router, it should be abi.encodeWithSignature("swapExactTokensForTokens(uint256,uint256,address[],address,uint256)", amountIn, amountOutMin, path, to, dedline)
. In which the amountIn
and amountOutMin
should be a factor of each other so the borrower will not lose revenue token a lot.
#0 - c4-judge
2022-11-17T15:46:51Z
dmvt marked the issue as duplicate of #88
#1 - c4-judge
2022-11-17T20:47:15Z
dmvt changed the severity to 2 (Med Risk)
#2 - c4-judge
2022-12-08T19:53:11Z
dmvt marked the issue as partial-50
#3 - C4-Staff
2022-12-16T23:20:48Z
captainmangoC4 marked the issue as duplicate of #110
π 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
Better to check that if an ERC20 token is going to be transferred to the protocol, no ETH should be sent:
function receiveTokenOrETH( address token, address sender, uint256 amount ) external returns (bool) { if(token == address(0)) { revert TransferFailed(); } if(token != Denominations.ETH) { // ERC20 // this line should be added: require(msg.value == 0); IERC20(token).safeTransferFrom(sender, address(this), amount); } else { // ETH if(msg.value < amount) { revert TransferFailed(); } } return true; }
Any smart contract that uses transfer()
or send()
is taking a hard dependency on gas costs by forwarding a fixed amount of gas: 2300. If gas costs are subject to change, then smart contracts canβt depend on any particular gas costs.
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); } return true; }
It is possible to apply grieving attack. When a borrower is going to call rollover(address newLine)
, the attacker calls the function init()
in the address of new line, so the status of the new line will change to LineLib.STATUS.ACTIVE
.
https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/modules/credit/SecuredLine.sol#L48
Now, the borrower transaction for rolling over will be reverted, because the status of new line is not LineLib.STATUS.UNINITIALIZED
.
https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/modules/credit/SecuredLine.sol#L57
Line 234 will never be called, it can be removed: https://github.com/debtdao/Line-of-Credit/blob/c1037d7f873cfb2af48fdaf00e44cd55c7737fc1/contracts/utils/SpigotedLineLib.sol#L234
Maybe better to use staticcall
to get decimals
:
https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/utils/CreditLib.sol#L142
#0 - c4-judge
2022-12-06T16:51:46Z
dmvt marked the issue as grade-b