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
Rank: 4/26
Findings: 3
Award: $6,946.01
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: 0xA5DF
Also found by: 0xbepresent, ladboy233
4648.6444 USDC - $4,648.64
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).
The test below demonstrates such a scenario:
unauthorize()
tx is executedFront 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:
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:
sstore
s 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:
revokeAll
or similar functionality, which can create a temporary griefBecause that can cause a denial of functionality which is limited in time, I believe Medium Severity to be the most appropriate
🌟 Selected for report: berndartmueller
Also found by: 0xA5DF, 0xSmartContract, HollaDieWaldfee, IllIllI, SleepingBugs, btk, chaduke, fs0c, hansfriese, nalus, rbserver, zzzitron
1183.3628 USDC - $1,183.36
callBatched()
if they send more than neededcallBatched()
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:
msg.value
to 1 ETHCaller
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 typoVerify 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.
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.
uint32
for timestamp might cause issues at 2106This 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)
ImmutableSplitsDriver
if user sends to himself the funds will be stuck foreverNot 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 fundsThe 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
callBatched()
if they send more than neededL
L
uint32
for timestamp might cause issues at 2106L
ImmutableSplitsDriver
if user sends to himself the funds will be stuck foreverL
NFTDriver.burn()
might cause the user to loose access to fundsL
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
🌟 Selected for report: descharre
Also found by: 0xA5DF, 0xSmartContract, Aymen0909, Deivitto, NoamYakov, ReyAdmirado, Rolezn, chaduke, cryptostellar5, matrix_0wl
1114.0079 USDC - $1,114.01
amtDeltas
entries have non-zero valuesThis 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):
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)amtDelta
entry is zero:
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.
amtDelta
entriesGas 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.
amtDelta
to memoryGas 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.
state.nextReceivableCycle
state.nextReceivableCycle
can be read twice here.
It should be cached to memory first in order to save ~100 gas units.
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