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: 6/120
Findings: 5
Award: $5,717.17
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: Lambda
Also found by: adriro, aphak5010, berndartmueller
3536.4311 USDC - $3,536.43
Neither SpigotLib.claimRevenue
nor SpigotLib._claimRevenue
check that the provided revenueContract
was registered before. If this is not the case, SpigotLib._claimRevenue
assumes that this is a revenue contract with push payments (because self.settings[revenueContract].claimFunction
is 0) and just returns the difference since the last call to claimRevenue
:
if(self.settings[revenueContract].claimFunction == bytes4(0)) { // push payments // claimed = total balance - already accounted for balance claimed = existingBalance - self.escrowed[token]; //@audit Rebasing tokens // underflow revert ensures we have more tokens than we started with and actually claimed revenue }
SpigotLib.claimRevenue
will then read self.settings[revenueContract].ownerSplit
, which is 0 for non-registered revenue contracts:
uint256 escrowedAmount = claimed * self.settings[revenueContract].ownerSplit / 100;
Therefore, the whole claimed
amount is sent to the treasury.
This becomes very problematic for revenue tokens that use push payments. An attacker (in practice the borrower) can just regularly call claimRevenue
with this token and a non-existing revenue contract. All of the tokens that were sent to the spigot since the last call will be sent to the treasury and none to the escrow, i.e. a borrower can ensure that no revenue will be available for the lender, no matter what the configured split is.
As mentioned above, the attack pattern works for arbitrary tokens where one (or more) revenue contracts use push payments, i.e. where the balance of the Spigot increases from time to time. Then, the attacker just calls claimRevenue
with a non-existing address. This is illustrated in the following diff:
--- a/contracts/tests/Spigot.t.sol +++ b/contracts/tests/Spigot.t.sol @@ -174,7 +174,7 @@ contract SpigotTest is Test { assertEq(token.balanceOf(address(spigot)), totalRevenue); bytes memory claimData; - spigot.claimRevenue(revenueContract, address(token), claimData); + spigot.claimRevenue(address(0), address(token), claimData);
Thanks to this small modification, all of the tokens are sent to the treasury and none are sent to the escrow.
Check that a revenue contract was registered before, revert if it does not.
#0 - c4-judge
2022-11-15T21:17:23Z
dmvt marked the issue as duplicate of #70
#1 - c4-judge
2022-11-17T20:12:32Z
dmvt marked the issue as selected for report
#2 - c4-sponsor
2022-11-30T15:04:53Z
kibagateaux marked the issue as sponsor confirmed
#3 - c4-judge
2022-12-06T17:20:38Z
dmvt marked the issue as satisfactory
#4 - C4-Staff
2022-12-20T06:38:26Z
liveactionllama marked the issue as not a duplicate
#5 - C4-Staff
2022-12-20T06:38:37Z
liveactionllama marked the issue as primary issue
🌟 Selected for report: Lambda
Also found by: HE1M, Trust, adriro, berndartmueller, minhquanym
1909.6728 USDC - $1,909.67
https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/modules/credit/LineOfCredit.sol#L234 https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/modules/credit/LineOfCredit.sol#L270
The functions addCredit
and increaseCredit
both ahve a mutualConsent
or mutualConsentById
modifier. Furthermore, these functions are payable
and the lender needs to send the corresponding ETH with each call. However, if we look at the mutual consent modifier works, we can a problem:
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; }
The problem is: On the first call, when the other party has not given consent to the call yet, the modifier does not revert. It sets the consent of the calling party instead.
This is very problematic in combination with sending ETH for two reasons: 1.) When the lender performs the calls first and sends ETH along with the call, the call will not revert. It will instead set the consent for him, but the sent ETH is lost. 2.) Even when the lender thinks about this and does not provide any ETH on the first call, the borrower has to perform the second call. Of course, he will not provide the ETH with this call, but this will cause the transaction to revert. There is now no way for the borrower to also grant consent, but still let the lender perform the call.
Lender Alice calls LineOfCredit.addCredit
first to add a credit with 1 ETH. She sends 1 ETH with the call. However, because borrower Bob has not performed this call yet, the function body is not executed, but the 1 ETH is still sent. Afterwards, Bob wants to give his consent, so he performs the same call. However, this call reverts, because Bob does not send any ETH with it.
Consider implementing an external function to grant consent to avoid this scenario. Also consider reverting when ETH is sent along, but the other party has not given their consent yet.
#0 - c4-judge
2022-11-15T21:32:50Z
dmvt marked the issue as duplicate of #42
#1 - c4-judge
2022-11-17T19:59:10Z
dmvt changed the severity to 3 (High Risk)
#2 - c4-judge
2022-11-17T19:59:15Z
dmvt marked the issue as selected for report
#3 - c4-sponsor
2022-11-30T15:01:40Z
kibagateaux marked the issue as sponsor confirmed
#4 - c4-judge
2022-12-06T17:07:35Z
dmvt marked the issue as satisfactory
#5 - C4-Staff
2022-12-20T06:31:35Z
liveactionllama marked the issue as not a duplicate
#6 - C4-Staff
2022-12-20T06:31:49Z
liveactionllama marked the issue as primary issue
🌟 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
In LineLib.receiveTokenOrETH
, it is only checked if msg.value
is greater or equal than amount
when the token is ETH:
if(msg.value < amount) { revert TransferFailed(); }
Therefore, when a user accidentally pays too much ETH, the additional amount will be lost. Note that this is different to ERC20 tokens, where the exact amount is enforced (by transferring the exact amount).
Alice calls LineOfCredit.addCredit
to add a credit with 1 ETH. While amount
is set to 1 ETH (10**18), she accidentally sends 2 ETH with the call. The additional ETH is lost and cannot be recovered.
Either force that the amount matches exactly or reimburse the additional ETH.
#0 - c4-judge
2022-11-15T21:30:57Z
dmvt marked the issue as duplicate of #25
#1 - c4-judge
2022-12-06T16:30:16Z
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
214.1771 USDC - $214.18
0x supports the filling of specific orders. Furthermore, you can post orders that can only get filled by a provided address. Moreover, the only requirement in SpigotedLineLib.claimAndTrade
is that the 0x call resulted in some more tokens (even if it is only 1 wei):
// 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
Furthermore, a borrower can call arbitrary 0x functions by providing the corresponding zeroExTradeData
(which is passed as calldata to 0x).
A malicious borrower can exploit this in the following way:
1.) Post a 0x order to buy the whole revenue amount that has accrued in the Spigot contract for an extremely low amount of the target token (e.g., 1 cent).
2.) Call SpigotedLine.claimAndTrade
to fill this order (by providing the corresponding call data) and get the revenue tokens for free.
An attacker can use this attack pattern continously to siphon all revenue tokens out of the spigot. Like that, he never has to pay back the spigoted line of credit.
Let's say there is a spigoted line of credit with some revenue token TOK with a value of 1,000 USD. The borrower has taken out a loan in USDC, so the token for the credit is USDC. Since the last spigot.claimEscrow
call, 100 TOK were accrued (with a value of 100,000 USD). The attacker uses the attack pattern that is described above to sell all 100 TOK (to himself via an order that he posted earlier) for 1 USDC. This leads to a loss of 100,000 USD for the lender.
Consider implementing an external function to grant consent to avoid this scenario. Also consider reverting when ETH is sent along, but the other party has not given their consent yet.
#0 - dmvt
2022-11-15T21:34:41Z
Borrower trading revenue tokens for a fake token is out of scope.
#1 - c4-judge
2022-11-15T21:34:48Z
dmvt marked the issue as unsatisfactory: Out of scope
#2 - OpenCoreCH
2022-12-08T22:30:41Z
@dmvt I think there was a mistake here, the issue is not about trading the revenue token for a fake token and I do not mention a fake token anywhere (the TOK in the PoC might have been confusing, but this is the revenue token that I use for the example as I mention there). It is about trading it for the credit token, but using malicious zeroExTradeData
(that executes a trade which is only executable by this address) to sell them for an extremely unfavorable rate. It looks like #411 describes the same issue.
#3 - dmvt
2022-12-09T00:37:00Z
Thanks for catching that. On further review, I agree. Thank you for waiting for post-judging qa to point this out.
#4 - c4-judge
2022-12-09T00:37:13Z
dmvt marked the issue as duplicate of #88
#5 - c4-judge
2022-12-09T00:37:22Z
dmvt marked the issue as satisfactory
#6 - c4-judge
2022-12-09T00:37:27Z
dmvt changed the severity to 2 (Med Risk)
#7 - 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
https://github.com/debtdao/Line-of-Credit/blob/d7ef66035ddf873b0c96804a1c9deeebb1f798ea/contracts/utils/LineLib.sol#L69 https://github.com/debtdao/Line-of-Credit/blob/f32cb3eeb08663f2456bf6e2fba21e964da3e8ae/contracts/utils/SpigotLib.sol#L38
The function LineLib.receiveTokenOrETH
that is used in multiple places assumes that the requested transfer amount is also equal to the actual transferred amount:
if(token != Denominations.ETH) { // ERC20 IERC20(token).safeTransferFrom(sender, address(this), amount); }
This is not true for fee-on-transfer tokens, where the change in balance is smaller than the requested amount. This leads to problems in multiple places. For instance, in LineOfCredit.addCredit
, the amount
parameter is used to create the credit:
LineLib.receiveTokenOrETH(token, lender, amount); bytes32 id = _createCredit(lender, token, amount);
When the contract does not contain this amount, there are two options: 1.) Payouts that should be possible are not possible (because the balance is not sufficient). 2.) Funds from other users are used for payouts.
Another big problem for the system are rebasing tokens. This can cause an underflow in SpigotLib._claimRevenue
(because the balance is now smaller than the previously cached balance), making all calls to claim revenue revert:
uint256 existingBalance = LineLib.getBalance(token); ... claimed = existingBalance - self.escrowed[token];
Check the actual amount that was transferred (i.e., the difference of the balances).
#0 - c4-judge
2022-11-15T21:30:39Z
dmvt marked the issue as duplicate of #26
#1 - c4-judge
2022-12-06T16:47:51Z
dmvt marked the issue as satisfactory
#2 - C4-Staff
2022-12-20T06:01:34Z
liveactionllama marked the issue as duplicate of #367