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: 32/120
Findings: 3
Award: $497.58
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
https://github.com/debtdao/Line-of-Credit/blob/master/contracts/utils/LineLib.sol#L71
Any excess ETH sent is unaccounted for. It will be locked up in the contract.
receiveTokenOrETH()
only reverts is msg.value
is less than amount
. It allows msg.value
to be greater than 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; }
Usage of receiveTokenOrETH()
where the excess amount is unaccounted for:
none
There's no scenario where the user is expected to send more than necessary. Thus, it shouldn't be allowed. Change receiveTokenOrETH()
to:
if(msg.value != amount) { revert TransferFailed(); }
#0 - c4-judge
2022-11-17T11:23:04Z
dmvt marked the issue as duplicate of #25
#1 - c4-judge
2022-12-06T16:29:33Z
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: adriro
Also found by: Jeiwan, Ruhum, berndartmueller, bin2chen, rvierdiiev
440.6937 USDC - $440.69
https://github.com/debtdao/Line-of-Credit/blob/master/contracts/utils/SpigotLib.sol#L61-L80
The operator role in the Spigot contract can call whitelisted functions in operate()
. But, functions are whitelisted across contracts. If two contracts share the same function signature, the operator will be able to call it. This can cause unforeseen consequences with a loss of funds being likely.
For example, let's say your revenue contract has a function transfer(address to, uint amount)
. The operator explains to the owner why that function is necessary for their contract's usage. The owner audits the function and agrees it's necessary while there's no risk to their loan. They whitelist the function. Because function signatures are whitelisted across contracts, the same transfer(address, uint)
function can be called on any ERC20 contract. Thus, the operator is able to drain funds stored in the Spigot contract.
The vulnerability depends on external requirements (owner whitelisting the function). But, I think this is quite likely to happen. I don't think anybody would expect the whitelist to work across contracts. You have to read the Spigot contract itself to figure that out.
Operator can call operate()
to execute a call
to a user-provided contract with user-provided calldata:
function operate(SpigotState storage self, address revenueContract, bytes calldata data) external returns (bool) { if(msg.sender != self.operator) { revert CallerAccessDenied(); } // extract function signature from tx data and check whitelist bytes4 func = bytes4(data); if(!self.whitelistedFunctions[func]) { revert BadFunction(); } // cant claim revenue via operate() because that fucks up accounting logic. Owner shouldn't whitelist it anyway but just in case // also can't transfer ownership so Owner retains control of revenue contract if( func == self.settings[revenueContract].claimFunction || func == self.settings[revenueContract].transferOwnerFunction ) { revert BadFunction(); } (bool success,) = revenueContract.call(data); if(!success) { revert BadFunction(); } return true; }
The function only verifies that the called function's signature is whitelisted. It doesn't verify which contract is called with it.
none
Operator should only be allowed to call a specific contract for which that function was whitelisted.
#0 - c4-judge
2022-11-17T11:22:40Z
dmvt marked the issue as duplicate of #71
#1 - c4-judge
2022-11-17T20:16:38Z
dmvt marked the issue as partial-50
#2 - c4-judge
2022-12-06T17:23:29Z
dmvt marked the issue as full credit
#3 - c4-judge
2022-12-06T17:23:33Z
dmvt marked the issue as satisfactory
#4 - C4-Staff
2022-12-20T06:29:36Z
liveactionllama marked the issue as not a duplicate
#5 - C4-Staff
2022-12-20T06:29:45Z
liveactionllama marked the issue as duplicate of #312
🌟 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
https://github.com/debtdao/Line-of-Credit/blob/master/contracts/utils/LineLib.sol#L71
When fee-on-transfer tokens are used, the LineLib.receiveTokenOrETH()
won't account for that. The internal bookkeeping will show a higher balance than the contract actually owns.
In the LineOfCredit contract that might cause a credit line not to be closable.
receiveTokenOrETH()
doesn't verify the number of tokens it got:
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; }
Let's take the most basic example. There's a LineOfCredit contract with a single credit line. There's no liquidity left and the borrower wants to repay and close the credit.
They call LineOfCredit.depositAndClose()
:
function depositAndClose() external payable override whileBorrowing onlyBorrower returns (bool) { bytes32 id = ids[0]; Credit memory credit = _accrue(credits[id], id); // Borrower deposits the outstanding balance not already repaid uint256 totalOwed = credit.principal + credit.interestAccrued; LineLib.receiveTokenOrETH(credit.token, msg.sender, totalOwed); // Borrower clears the debt then closes and deletes the credit line _close(_repay(credit, id, totalOwed), id); return true; }
The function calculates the total owed amount (principal + interest) and pulls those tokens from the user:
uint256 totalOwed = credit.principal + credit.interestAccrued; LineLib.receiveTokenOrETH(credit.token, msg.sender, totalOwed);
If the credit token takes fees on transfer, totalOwed
will be larger than the actual amount of tokens the contract received.
Then, it uses thetotalOwed
amount to call _repay()
and _close()
:
_close(_repay(credit, id, totalOwed), id);
In repay()
, the credit data is updated.
function repay( ILineOfCredit.Credit memory credit, bytes32 id, uint256 amount ) external returns (ILineOfCredit.Credit memory) { unchecked { if (amount <= credit.interestAccrued) { credit.interestAccrued -= amount; credit.interestRepaid += amount; emit RepayInterest(id, amount); return credit; } else { uint256 interest = credit.interestAccrued; uint256 principalPayment = amount - interest; // update individual credit line denominated in token credit.principal -= principalPayment; credit.interestRepaid += interest; credit.interestAccrued = 0; emit RepayInterest(id, interest); emit RepayPrincipal(id, principalPayment); return credit; } } }
We reach the following state:
principal = 0 deposit = x interest = y balance = b, where b < x + y
Now, _close()
is called:
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( 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; }
The contract tries to send out the credit tokens to the lender. That's exactly deposit + interestRepaid
. But, because of the fees when the borrower sent the tokens to the contract, it doesn't have enough tokens. The transfer fails and the credit can't be closed.
none
LineLib.receiveTokenOrETH()
should handle fee-on-transfer tokens properly by checking the balance after receiving the tokens.
#0 - c4-judge
2022-11-17T11:27:05Z
dmvt marked the issue as duplicate of #26
#1 - c4-judge
2022-12-06T16:46:01Z
dmvt marked the issue as satisfactory
#2 - C4-Staff
2022-12-20T06:00:28Z
liveactionllama marked the issue as duplicate of #367