Drips Protocol contest - 0xA5DF's results

An Ethereum protocol for streaming and splitting funds.

General Information

Platform: Code4rena

Start Date: 25/01/2023

Pot Size: $90,500 USDC

Total HM: 3

Participants: 26

Period: 9 days

Judge: GalloDaSballo

Id: 209

League: ETH

Drips Protocol

Findings Distribution

Researcher Performance

Rank: 4/26

Findings: 3

Award: $6,946.01

QA:
grade-a
Gas:
grade-a

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: 0xA5DF

Also found by: 0xbepresent, ladboy233

Labels

bug
2 (Med Risk)
disagree with severity
downgraded by judge
primary issue
selected for report
M-02

Awards

4648.6444 USDC - $4,648.64

External Links

Lines of code

https://github.com/code-423n4/2023-01-drips/blob/9fd776b50f4be23ca038b1d0426e63a69c7a511d/src/Caller.sol#L104-L119

Vulnerability details

Impact

The Caller contract enables users to authorize other users to execute tx on their behalf. This option enables the authorized/delegated user to add more users to the authorized users list. In case the original user is trying to remove an authorized user (i.e. run unauthorize()), the delegated user can simply front run that tx to add another user, then after unauthorized() is executed the delegated can use the added user to gain his authority back.

This would allow the malicious delegated user to keep executing txs on behalf of the original user and cause them a loss of funds (e.g. collecting funds on their behalf and sending it to the attacker's address).

Proof of Concept

The test below demonstrates such a scenario:

  • Alice authorizes Bob
  • Bob becomes malicious and Alice now wants to remove him
  • Bob noticed the tx to unauthorize him and front runs it by authorizing Eve
  • Alice unauthorize() tx is executed
  • Bob now authorizes himself back again via Eve's account

Front running can be done either by sending a tx with a higher gas price (usually tx are ordered in a block by the gas price / total fee), or by paying additional fee to the validator if they manage to run their tx without reverting (i.e. by sending additional ETH to block.coinbase, hoping validator will notice it). It's true that Alice can run unauthorize() again and again and needs to succeed only once, but:

  • Bob can keep adding other users and Alice would have to keep removing them all
  • This is an ongoing battle that can last forever, and Alice might not have enough knowledge, resources and time to deal with it right away. This might take hours or days, and in the meanwhile Alice might be receiving a significant amount of drips that would be stolen by Bob.
diff --git a/test/Caller.t.sol b/test/Caller.t.sol
index 861b351..3e4be22 100644
--- a/test/Caller.t.sol
+++ b/test/Caller.t.sol
@@ -125,6 +125,24 @@ contract CallerTest is Test {
         vm.expectRevert(ERROR_UNAUTHORIZED);
         caller.callAs(sender, address(target), data);
     }
+    
+    function testFrontRunUnauthorize() public {
+        bytes memory data = abi.encodeWithSelector(target.run.selector, 1);
+        address bob = address(0xbab);
+        address eve = address(0xefe);
+        // Bob is authorized by Alice
+        authorize(sender, bob);
+
+        // Bob became malicious and Alice now wants to remove him
+        // Bob sees the `unauthorize()` call and front runs it with authorizing Eve
+        authorizeCallAs(sender, bob, eve);
+
+        unauthorize(sender, bob);
+
+        // Eve can now give Bob his authority back
+        authorizeCallAs(sender, eve, bob);
+
+    }
 
     function testAuthorizingAuthorizedReverts() public {
         authorize(sender, address(this));
@@ -257,6 +275,13 @@ contract CallerTest is Test {
         assertTrue(caller.isAuthorized(authorizing, authorized), "Authorization failed");
     }
 
+    function authorizeCallAs(address originalUser,address delegated, address authorized) internal {
+        bytes memory data = abi.encodeWithSelector(Caller.authorize.selector, authorized);
+        vm.prank(delegated);
+        caller.callAs(originalUser, address(caller),  data);
+        assertTrue(caller.isAuthorized(originalUser, authorized), "Authorization failed");
+    }
+
     function unauthorize(address authorizing, address unauthorized) internal {
         vm.prank(authorizing);
         caller.unauthorize(unauthorized);

Don't allow authorized users to call authorize() on behalf of the original user. This can be done by replacing _msgSender() with msg.sender at authorize(), if the devs want to enable authorizing by signing I think the best way would be to add a dedicated function for that (other ways to prevent calling authorize() from callAs() can increase gas cost for normal calls, esp. since we'll need to cover all edge cases of recursive calls with callBatched()).

#0 - c4-judge

2023-02-09T11:26:20Z

GalloDaSballo marked the issue as primary issue

#1 - xmxanuel

2023-02-09T14:59:35Z

Known behavior. I think it is debatable. The authorize means an other address is completely trusted in terms of access to funds. In most cases this will be the same actor like Alice. Just another address of Alice.

#2 - CodeSandwich

2023-02-10T21:16:42Z

[disagree with severity: low] Like Manuel said, this is known, authorizing somebody gives them extreme privileges anyway, like stealing all the funds available for the user in the protocol.

I disagree that it's a high severity issue, because the attacked user with some effort can defend themselves. In a single batch they need to authorize a contract that would iterate over all addresses returned by allAuthorized and for each of them call unauthorize on behalf of the attacked user.

I think that the above emergency defense mechanism could be built right into Caller's API to make it cheaper and easier to run, basically add an unauthorizeAll function that will clear the authorized addresses list. That's why I agree that it is an issue to solve, but a low severity one, it's only a quality of life improvement.

The proposed mitigation of banning authorized user from authorizing others is too heavy handed, because it hurts use cases like a slow DAO authorizing a trusted operator, who then needs to rotate keys or authorize a "script contract" performing automated operations on Drips protocol. Authorization by signing hurts all non-EOA addresses like DAOs or multisigs which don't have private keys.

#3 - c4-sponsor

2023-02-13T10:34:15Z

CodeSandwich marked the issue as disagree with severity

#4 - xmxanuel

2023-02-13T10:34:19Z

We recommend: low.

#5 - GalloDaSballo

2023-02-22T09:17:24Z

Known behavior. I think it is debatable. The authorize means an other address is completely trusted in terms of access to funds. In most cases this will be the same actor like Alice. Just another address of Alice.

Can you please link to the known finding disclosure to ensure it was known to wardens?

#6 - GalloDaSballo

2023-02-23T12:15:22Z

Because of the fact that the only requirement for the attack is for the authorized to be malicious, I have considered High Severity.

After further reflection, because of the ability to remove the malicious attacker via a Macro, per the Sponsor's comment above, I believe Medium Severity to be the most appropriate.

The approval should be considered an extremely dangerous operation, which can cause drastic losses, however, a malicious approved can eventually be unapproved via a macro contract

#7 - c4-judge

2023-02-23T12:15:28Z

GalloDaSballo changed the severity to 2 (Med Risk)

#8 - c4-judge

2023-02-23T12:15:36Z

GalloDaSballo marked the issue as selected for report

#9 - xmxanuel

2023-02-23T12:24:13Z

The approval should be considered an extremely dangerous operation, which can cause drastic losses, however, a malicious approval can eventually be unapproved via a macro contract

Yes, we agree here. We envision it more like the same actor/organization is using multiple addresses.

Like the multi-sig of an organization is used as the main address (address with the ens name) but not all actions might require multiple signatures. Therefore, they can approve another address. etc.

However, we assume full trust.

#10 - 0xA5DF

2023-02-24T12:57:27Z

because the attacked user with some effort can defend themselves

It's true, but for the simple user that might take hours or even a day or two to understand what's going on and come up with a solution (if this issue wasn't reported, now that it's reported obviously you can prepare the solution in advance and let the users know). In the meantime attacker would be able to steal funds. I think this makes it a high severity under C4's definition.

Like the multi-sig of an organization is used as the main address (address with the ens name) but not all actions might require multiple signatures. Therefore, they can approve another address. etc. However, we assume full trust.

Consider the following scenario - the org set the main address to a 3/5 multisig address, all 5 addresses have been added to the authorized list. The org counts on the unauthorize() function to remove those addresses in case anything goes wrong. At some point one of the addresses' wallet gets compromised - either the owner goes rogue or an attacker steals it. The org is now attempting to remove it via the multisig but they notice it's not as easy as they thought.

PS

In a single batch they need to authorize a contract that would iterate over all addresses returned by allAuthorized and for each of them call unauthorize on behalf of the attacked user.

there are 2 caveats:

  • Attacker can fail that by authorizing that contract before the tx (then the attempt to authorize it by the user would fail the entire tx). This can be solved by the user adding another call to the batch, for the contract to try to remove itself if it's authorized.
  • The attacker can also fail that by adding 3000 authorized users, since unauthorizing a user costs 2 sstores and each one costs 5K (gas refunds come only at the end of the tx), that would make it more than the block gas limit. The user can slowly remove the authorized users, and it'll be pretty expensive for the attacker to keep adding users, however this is still doable by the attacker if the received drips are worth it.

#11 - xmxanuel

2023-02-24T13:44:43Z

I think we have some similarities to a classic ERC20 approve pattern here.

If I approve another address with type(uint).max a compromised address can steal all funds.

In our case, the attacker can in addition: start to approve new addresses as well. It might be hard to stop as you described.

This basically would allow the attacker to continue to steal potential future funds (incoming streams).

Obviously, both things are bad.

However, I am trying to understand the reasoning here. Let's say we wouldn't have the functionality to add new addresses with approve.

We still have the same scenario a compromised wallet can steal all funds. Would you classify that as high severity? (Basically, the ERC20 approve pattern).

Here, we only have the additional fact that the compromised wallet potentially can also steal incoming future funds with a highly sophisticated attack.

Most likely the loss of the funds already waiting in the user config might be higher than in the future (incoming streams).

Again both things are obviously bad a compromised wallet will result in huge problems.

My point here is if you think this is a high severity why is the classical ERC20 approve pattern, not a high severity?

On the other hand, we could remove this approve other address power to at least stop the potential stealing of incoming funds and the account takeover.

I can see the point but am not sure about the servity.

#12 - 0xA5DF

2023-02-24T14:52:38Z

I think it all comes down to the user's expectations. Since the unauthorize() function exists I think that sets the expectation that the owner can remove an authorized address at any time. And as long as there's no clear note to the user that it might not be easy to remove a malicious address I think that's an issue.

The main reason I believe this to be high is that the mitigation requires the user to write an external code in order to remove the attacker's address. The hypothetical victim might not be a web3 dev and contacting the sponsor (who might be living in a different timezone or on vacation) or finding a dev that would come up with the solution can take hours or even days and in the meanwhile asset can be stolen. I think relying on external code (that wasn't developed yet, and would take some dev time to come up with) to fix the issue isn't much different than relying on an upgrade to mitigate the issue.

#13 - GalloDaSballo

2023-02-26T19:25:39Z

Confirming Med severity because:

  • The risk is not in giving the approval, that is the feature offered
  • The risk is in not being able to revoke an approval, due to a lack of revokeAll or similar functionality, which can create a temporary grief

Because that can cause a denial of functionality which is limited in time, I believe Medium Severity to be the most appropriate

Findings Information

Labels

bug
grade-a
QA (Quality Assurance)
edited-by-warden
Q-10

Awards

1183.3628 USDC - $1,183.36

External Links

User might loose ETH in callBatched() if they send more than needed

callBatched() doesn't verify that the amount of value the user sent matches the amount of value. In case a user sent more ETH than spent in the calls it can be stolen by another user. Consider the following scenario:

  • Bob wants to drip WETH to some receivers, so he sends some ETH to convert it to WETH
  • Bob intended to convert 0.1 ETH, but set the msg.value to 1 ETH
  • Alice operates a bot that monitors the Caller address, and right away executes via callBatched() a call that would simply transfer the remaining 0.9 ETH to her As a result, Bob lost 0.9 ETH do to a typo

Mitigation

Verify that msg.value is equal to the total value used in the batched calls. You can also add an unsafe version callBatchedUnsafe() for users who want to save that extra gas, or for users who are worried that an insignificant difference would cause a revert.

FoT and rebase tokens

It should be noted that those kind of tokens can cause issues, since the contract might end up having less balance than it assumes it has, and the last users to collect their funds wouldn't be able to do it.

Using uint32 for timestamp might cause issues at 2106

This has been raised in past contest and isn't considered a significant issue (since projects usually don't care about 85 years from now), but it's worth mentioning it. (this would cause issues mostly about redeeming old drips after that timestamp has past)

At ImmutableSplitsDriver if user sends to himself the funds will be stuck forever

Not sure to what extent the protocol needs to protect users from dumb mistakes, but it might be worth adding a check at ImmutableSplitsDriver.createSplits() that the user isn't sending to himself, since in that case there will always be some funds that will be stuck in the user's splittable balance.

NFTDriver.burn() might cause the user to loose access to funds

The burn() function here doesn't seem to be very useful, the only case it can be useful is to make a permanent splittable receivers, and for that we have already ImmutableSplitsDriver. Consider removing it, or adding checks that the user splits config sends it all to other users.

#0 - GalloDaSballo

2023-02-15T14:31:29Z

User might loose ETH in callBatched() if they send more than needed

L

FoT and rebase tokens

L

Using uint32 for timestamp might cause issues at 2106

L

At ImmutableSplitsDriver if user sends to himself the funds will be stuck forever

L

NFTDriver.burn() might cause the user to loose access to funds

L

5L

1L from dups

6L

#1 - GalloDaSballo

2023-02-24T10:45:58Z

1L from dups

5L

#2 - c4-judge

2023-02-24T10:55:37Z

GalloDaSballo marked the issue as grade-b

#3 - c4-judge

2023-02-28T16:34:13Z

GalloDaSballo marked the issue as grade-a

#4 - GalloDaSballo

2023-02-28T16:34:23Z

Upgraded to A after downgrades

Findings Information

Labels

bug
G (Gas Optimization)
grade-a
G-07

Awards

1114.0079 USDC - $1,114.01

External Links

Use a 'bitmap' to mark which amtDeltas entries have non-zero values

This one is significant and can save millions of gas units, esp. if the protocol uses a short cycle. The only downside here is that it will increase the gas cost of setting drips.

The idea is taken from Uniswap V3. The idea is to use a uint256 variable to mark which amtDelta entries are 'full' (i.e. have non-zero values). This way, if we call receiveDrips() over 1000 cycles and only 8 of them have deltas - instead of reading 1000 slots we need to read only 12 slots (8 deltas + 4 bitmap entries). In other words 25.2K gas instead of 2.1M (!). (To be more precise the deletion of the entries gives us some gas refund, so we'll actually by paying ~2.4K instead of ~2.9M)

From sender's point the gas will increase by the following (per amtDelta written):

  • If the amtDelta entries are already non-zero - than it'd cost only 100 gas units (there's no need to read the bitmap entry, just to read the amtDelta entry before writing to it)
  • If the amtDelta entry is zero:
    • If the bitmap entry is non-zero it'll cost 5K
      • Unless that entry was already modified in the current tx, in that case it'll be only 100
    • If the bitmap entry is zero it'll cost 22.1K

Since each receiver adds 1 delta range that modifies 2 amtDelta entries that means 200 to 44.2K gas units per receiver. On the average case though it'd probably be 200-10K. The more activity the receiving user will have the more it'll decrease the cost for the senders (senders will probably also converge around the cycles of other users in order to save gas for them).

On the other hand, for receivers with less activity it might be costly for the senders but will also save much more for receivers (since they have more amtDelta empty entries).

If a cycle of a week will be chosen it probably wouldn't be that relevant, but in case of shorter cycles (1 hour or 1 day) it can save lots of gas.

Don't delete empty amtDelta entries

Gas saved: 100 units times the amount of empty entries

(this is in case the above suggestion isn't accepted) At _receiveDrips() all old entries are deleted, even the empty ones. This imposes an additional 100 gas units cost per empty entry. I think the simplest solution might be to implement a non-view version of _receiveDripsResult() that will delete non-empty entries.

Read amtDelta to memory

Gas saved: ~100 units per cycle

At _receiveDripsResult() each amtDelta entry is simply a single slot, reading the different fields of this struct separately takes an additional sload instruction that costs about 100 gas units (plus some additional cost to calculate the slot value). Reading the whole struct to memory and reading from there can save that gas.

         for (uint32 cycle = fromCycle; cycle < toCycle; cycle++) {
-            amtPerCycle += state.amtDeltas[cycle].thisCycle;
+            AmtDelta memory amtDelta = state.amtDeltas[cycle];
+            amtPerCycle += amtDelta.thisCycle;
             receivedAmt += uint128(amtPerCycle);
-            amtPerCycle += state.amtDeltas[cycle].nextCycle;
+            amtPerCycle += amtDelta.nextCycle;
         }

Note: this should also be relevant to the _addDelta(), but for some reason the test results didn't show a decrease in gas cost. I didn't have the time to get the bottom of it and it might be worth looking into it.

Cache state.nextReceivableCycle

state.nextReceivableCycle can be read twice here. It should be cached to memory first in order to save ~100 gas units.

Have a built-in address driver

Using any driver costs an additional sload per tx - since it requires the DripsHub contract to check the driverAddresses map + the cost of calling an additional contract (calling a cold address - 2.1K units). It may be worth to embed the functionality of common drivers like the address driver (reserve a driver ID to it, and at _assertCallerIsDriver() send it to the embedded functionality), or alternately register the driver's address in an immutable variable. This might cost a few extra units (probably 10-20, just an additional condition check) for other drivers, but might save a significant amount of gas for common drivers.

#0 - GalloDaSballo

2023-02-15T15:17:24Z

Use a 'bitmap' to mark which amtDeltas entries have non-zero values Very interesting idea, will think about how to score

Don't delete empty amtDelta entries 100 per entry

Read amtDelta to memory 100 per cycle

Cache state.nextReceivableCycle 100 per call

Have a built-in address driver At least 2.1k

Let's mark as 2k+ for now

#1 - GalloDaSballo

2023-02-24T10:58:22Z

While on paper the bitmap would save the most gas, I believe #81 offered savings that are more easily implemented, for this reason am going to award extra points to this one, but will mark it as second best.

#2 - c4-judge

2023-02-24T10:58:27Z

GalloDaSballo marked the issue as grade-a

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter