Drips Protocol contest - berndartmueller'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: 3/26

Findings: 2

Award: $9,286.11

QA:
grade-a

🌟 Selected for report: 2

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: berndartmueller

Also found by: kaden

Labels

bug
2 (Med Risk)
disagree with severity
primary issue
satisfactory
selected for report
M-01

Awards

7747.7407 USDC - $7,747.74

External Links

Lines of code

https://github.com/code-423n4/2023-01-drips/blob/9fd776b50f4be23ca038b1d0426e63a69c7a511d/src/Drips.sol#L411 https://github.com/code-423n4/2023-01-drips/blob/9fd776b50f4be23ca038b1d0426e63a69c7a511d/src/Drips.sol#L461

Vulnerability details

Squeezing drips from a sender requires providing the sequence of drips configurations (see NatSpec description in L337-L338):

/// It can start at an arbitrary past configuration, but must describe all the configurations /// which have been used since then including the current one, in the chronological order.

The provided history entries are hashed and verified against the sender's dripsHistoryHash.

However, the sender can prevent a receiver from squeezing drips by front-running the squeeze transaction and adding a new configuration. Adding a new configuration updates the current dripsHistoryHash and invalidates the historyHash provided by the receiver when squeezing. The receiver will then fail the drips history verification in L461 and the squeeze will fail.

Impact

A sender can prevent its drip receivers from squeezing by front-running the squeeze transaction and adding a new configuration.

Proof of Concept

Drips.sol#L411

392: function _squeezeDripsResult(
393:     uint256 userId,
394:     uint256 assetId,
395:     uint256 senderId,
396:     bytes32 historyHash,
397:     DripsHistory[] memory dripsHistory
398: )
399:     internal
400:     view
401:     returns (
402:         uint128 amt,
403:         uint256 squeezedNum,
404:         uint256[] memory squeezedRevIdxs,
405:         bytes32[] memory historyHashes,
406:         uint256 currCycleConfigs
407:     )
408: {
409:     {
410:         DripsState storage sender = _dripsStorage().states[assetId][senderId];
411:         historyHashes = _verifyDripsHistory(historyHash, dripsHistory, sender.dripsHistoryHash);
412:         // If the last update was not in the current cycle,
413:         // there's only the single latest history entry to squeeze in the current cycle.
414:         currCycleConfigs = 1;
415:         // slither-disable-next-line timestamp
416:         if (sender.updateTime >= _currCycleStart()) currCycleConfigs = sender.currCycleConfigs;
417:     }
...      // [...]

Drips.sol#L461

444: function _verifyDripsHistory(
445:     bytes32 historyHash,
446:     DripsHistory[] memory dripsHistory,
447:     bytes32 finalHistoryHash
448: ) private pure returns (bytes32[] memory historyHashes) {
449:     historyHashes = new bytes32[](dripsHistory.length);
450:     for (uint256 i = 0; i < dripsHistory.length; i++) {
451:         DripsHistory memory drips = dripsHistory[i];
452:         bytes32 dripsHash = drips.dripsHash;
453:         if (drips.receivers.length != 0) {
454:             require(dripsHash == 0, "Drips history entry with hash and receivers");
455:             dripsHash = _hashDrips(drips.receivers);
456:         }
457:         historyHashes[i] = historyHash;
458:         historyHash = _hashDripsHistory(historyHash, dripsHash, drips.updateTime, drips.maxEnd);
459:     }
460:     // slither-disable-next-line incorrect-equality,timestamp
461:     require(historyHash == finalHistoryHash, "Invalid drips history");
462: }

Tools Used

Manual review

Consider allowing a receiver to squeeze drips from a sender up until the current timestamp.

#0 - GalloDaSballo

2023-02-09T11:53:31Z

See #218 and #107, separate for now

#1 - xmxanuel

2023-02-10T12:11:08Z

Technically the described attack would work.

It is also related to the trust assumption between the sender an receiver. Which are anyway given in a certain form.

I am unsure about the proposed solutions. Currently, we only store the latest dripsHistory hash on-chain. For allowing to squeeze until a specific timestamp, it might be necessary to have the full list of historic hashes on-chain.

#2 - CodeSandwich

2023-02-12T11:28:00Z

[disagree with severity: QA] This attack is in fact possible, but it only allows postponing collecting funds until the end of the cycle. The proposed solution would probably require a lot of storage, so it's probably not worth introducing.

#3 - c4-sponsor

2023-02-13T10:51:29Z

CodeSandwich marked the issue as disagree with severity

#4 - GalloDaSballo

2023-02-22T08:24:33Z

<img width="413" alt="Screenshot 2023-02-22 at 09 24 15" src="https://user-images.githubusercontent.com/13383782/220563524-de9d9819-683b-43e6-8cec-93d20cccc9d2.png">

I believe this boils down to the same underlying issue highlighted in #107

#5 - GalloDaSballo

2023-02-23T12:44:04Z

This is basically the other side of #107, I'll think about it, but due to logical equivalency and the underlying issue being the same, I think I'll duplicate this one as well

#6 - c4-judge

2023-02-23T12:55:11Z

GalloDaSballo marked the issue as duplicate of #201

#7 - c4-judge

2023-02-26T18:59:00Z

GalloDaSballo marked the issue as not a duplicate

#8 - GalloDaSballo

2023-02-26T18:59:42Z

During Post-Judging QA it was brought to my attention that this is a separate finding, have looked at this one again

#9 - c4-judge

2023-02-28T15:54:16Z

GalloDaSballo marked the issue as satisfactory

#10 - GalloDaSballo

2023-03-01T14:50:48Z

In order to judge this issue I spoke with 4 other judges as well as the Sponsor.

This is a difficult decision because of the unclear expectations as to whether the sender is bening towards the receiver or not.

Because of the uncertainty, and the possibility of performing the grief, the finding is valid.

The issue is in terms of determining it's severity.

The attacker doesn't gain anything

One of the most interesting arguments is the idea that "the attacker doesn't gain anything", which can be falsified in scenarios in which squeezing would have helped reach a threshold of tokens in circulation, out of vesting or for similar purposes.

If we try hard enough, through multiple people's effort, we can come up with a scenario in which squeezing could make a difference between having enough votes for a Governance Operation, or could offer enough collateral to avoid a liquidation or some level of risk.

Those scenarios, while unlikely, can help discredit the idea that "the attacker doesn't gain anything".

The grief is limited

On the other hand, we must acknowledge that the attack / griefing, is limited:

  • The sender is the only privileged account that can perform the grief
  • Squeezing is denied until the next cycle, which based on configuration, will be known, and will be between 1 week and 1 month.

These can be viewed as additional external requirements, which reduce the impact / likelihood of the finding

The Squeeze is Squoze

In spite of the external requirements, the finding is showing how the functionality of Squeezing can be denied by the sender.

I believe that if anybody could grief squeezing, we would not hesitate in awarding Medium Severity and perhaps we'd be discussing around Med / High.

However, in this case, the only account that can perform the grief is the sender.

At the same time, the goal of the system is to allow Squeezing, which per the discussion above, given the finding can be prevented until a drip has moved to a newer crycle.

Last Minute Coding

I went into the tests and wrote the following illustratory cases

function testSenderCanStopAfter() public {
        uint128 amt = cycleSecs;
        setDrips(sender, 0, amt, recv(receiver, 1), cycleSecs);
        DripsHistory[] memory history = hist(sender);
        skip(1);
        squeezeDrips(receiver, sender, history, 1);
        setDrips(sender, amt - 1, 0, recv(receiver, 1), 0);

        // Squeeze again
        DripsHistory[] memory history2 = hist(history, sender);
        squeezeDrips(receiver, sender, history2, 0);
        
        skipToCycleEnd();
        receiveDrips(receiver, 0);
    }

    function testSenderCannotChangeTheirMind() public {
        uint128 amt = cycleSecs;
        setDrips(sender, 0, amt, recv(receiver, 1), cycleSecs);
        DripsHistory[] memory history = hist(sender);
        skip(1);
        setDrips(sender, amt - 1, 0, recv(receiver, 1), 0);

        // Squeeze First time
        DripsHistory[] memory history2 = hist(history, sender);
        squeezeDrips(receiver, sender, history2, 1);
        
        skipToCycleEnd();
        receiveDrips(receiver, 0);
    }

Ultimately we can see that what is owed to the recipient can never be taken back, however the sender can, through the grief shown in the above finding, prevent the tokens from being received until the end.

Conclusion

Given the information above, being mindful of:

  • A potentially severe risk, with very low likelyhood
  • The temporary breaking of the functionality of squeezing, which was meant to allow by-the-second claims

I agree with Medium Severity

#11 - c4-judge

2023-03-01T14:51:31Z

GalloDaSballo marked the issue as selected for report

#12 - c4-judge

2023-03-01T14:51:51Z

GalloDaSballo marked the issue as primary issue

Findings Information

Labels

bug
grade-a
QA (Quality Assurance)
selected for report
Q-04

Awards

1538.3717 USDC - $1,538.37

External Links

QA Report

Table of Contents

Low-Risk Findings

[L-01] An authorized user can unauthorize other authorized users of the same sender

A user can grant authorization to another address to make calls on their behalf via the Caller.callAs function. The Caller.unauthorize function allows the user to revoke the authorization of another address.

An authorized user can revoke the authorization of another authorized user of the same sender. This is because the authorized user can call the Caller.unauthorize function on behalf of the sender.

Findings

Caller.sol#L114-L118

114: function unauthorize(address user) public {
115:     address sender = _msgSender();
116:     require(_authorized[sender].remove(user), "Address is not authorized");
117:     emit Unauthorized(sender, user);
118: }

Consider preventing calls to the Caller contract address from within the _call function

[L-02] Lack of reasonable boundaries for cycle secs

If _cycleSecs is set too low, for example, to a value less than the Ethereum block time of 12 sec, it will not be possible to squeeze. If set too high, e.g., larger than the current block.timestamp, it will not be possible to receive drips - only squeezing will be possible.

Findings

Drips.sol#L221

219: constructor(uint32 cycleSecs, bytes32 dripsStorageSlot) {
220:     require(cycleSecs > 1, "Cycle length too low");
221:     _cycleSecs = cycleSecs;
222:     _dripsStorageSlot = dripsStorageSlot;
223: }

Consider adding an appropriate upper limit for _cycleSecs.

[L-03] Loading a drip configuration with assembly in _getConfig is unsafe

The Drips._getConfig loads a drips configuration and returns the amtPerSec, start and end values. The configs parameter is a uint256[] array, containing the drips config.

As seen in Drips.create, the drip config has the dripId in the 32 most significant bits, followed by the amtPerSec in the next 160 bits.

Even though the dripId is not used in the contract and it's even stripped out (see Drips.sol#L805) when preprocessing the drips receiver in Drips._addConfig, it could cause serious trouble in the future if the dripId is used in the contract and not taken into account when loading with Drips._getConfig.

Findings

Drips.sol#L825

815: function _getConfig(uint256[] memory configs, uint256 idx)
816:     private
817:     pure
818:     returns (uint256 amtPerSec, uint256 start, uint256 end)
819: {
820:     uint256 val;
821:     // slither-disable-next-line assembly
822:     assembly ("memory-safe") {
823:         val := mload(add(32, add(configs, shl(5, idx))))
824:     }
825:     return (val >> 64, uint32(val >> 32), uint32(val));
826: }

Consider truncating the uint256 value to uint160 before returning it.

[L-04] Unused dripId of receiver config is used considered while sorting receivers

The Drips._isOrdered function compares two given receivers. The receiver config can include the prefixed dripId in the 32 most significant bits. This allows using the dripId for changing the order of receivers when setting a new drips configuration without violating the sorting requirement, First compares their amtPerSecs, then their starts and then their durations (see Drips.sol#L93).

While no harmful effects are expected, it can be used in the Drips._setDrips function to control if delta amounts of receivers are first removed, added, or updated, are expected.

Findings

Drips.sol#L1069

1061: function _isOrdered(DripsReceiver memory prev, DripsReceiver memory next)
1062:     private
1063:     pure
1064:     returns (bool)
1065: {
1066:     if (prev.userId != next.userId) {
1067:         return prev.userId < next.userId;
1068:     }
1069:     return prev.config.lt(next.config);
1070: }

Consider omitting the dripId from the receiver config when comparing the receivers.

[L-05] Collecting funds should be usable while the DripsHub contract is paused

The DripsHub.collect function is only callable when the DripsHub contract is not paused. This prevents a user from collecting accumulated funds. The admin of the DripsHub contract can potentially pause the contracts at any time, locking users out of their honestly earned funds.

Findings

DripsHub.sol#L388

386: function collect(uint256 userId, IERC20 erc20)
387:     public
388:     whenNotPaused
389:     onlyDriver(userId)
390:     returns (uint128 amt)
391: {
392:     amt = Splits._collect(userId, _assetId(erc20));
393:     _decreaseTotalBalance(erc20, amt);
394:     erc20.safeTransfer(msg.sender, amt);
395: }

Consider removing the whenNotPaused modifier to allow collecting funds while the DripsHub contract is paused.

[L-06] An immutable split is unable to collect funds if it has itself set as a split receiver

An immutable split cannot collect funds if it has itself set as a split receiver. This is because the ImmutableSplitsDriver contract lacks the functionality to collect available funds.

Findings

ImmutableSplitsDriver.sol#L66

53: function createSplits(SplitsReceiver[] calldata receivers, UserMetadata[] calldata userMetadata)
54:     public
55:     whenNotPaused
56:     returns (uint256 userId)
57: {
58:     userId = nextUserId();
59:     StorageSlot.getUint256Slot(_counterSlot).value++;
60:     uint256 weightSum = 0;
61:     for (uint256 i = 0; i < receivers.length; i++) {
62:         weightSum += receivers[i].weight;
63:     }
64:     require(weightSum == totalSplitsWeight, "Invalid total receivers weight");
65:     emit CreatedSplits(userId, dripsHub.hashSplits(receivers));
66:     dripsHub.setSplits(userId, receivers);
67:     if (userMetadata.length > 0) dripsHub.emitUserMetadata(userId, userMetadata);
68: }

Consider preventing setting one of the receivers to the user ID of the newly created immutable split.

[L-07] Precision issues caused by division before multiplication in Drips._drippedAmt

The Drips._drippedAmt function calculates the amount of dripped funds for a given time interval and amtPerSec.

However, by dividing the result of mul(cycleSecs, amtPerSec) by _AMT_PER_SEC_MULTIPLIER before multiplying it by endedCycles, the precision of the result is reduced. This can cause the amount of dripped funds to be lower if the multiplication would have been performed first.

Findings

Drips.sol#L1107

1093: function _drippedAmt(uint256 amtPerSec, uint256 start, uint256 end)
1094:     private
1095:     view
1096:     returns (uint256 amt)
1097: {
1098:     // This function is written in Yul because it can be called thousands of times
1099:     // per transaction and it needs to be optimized as much as possible.
1100:     // As of Solidity 0.8.13, rewriting it in unchecked Solidity triples its gas cost.
1101:     uint256 cycleSecs = _cycleSecs;
1102:     // slither-disable-next-line assembly
1103:     assembly {
1104:         let endedCycles := sub(div(end, cycleSecs), div(start, cycleSecs))
1105:         // slither-disable-next-line divide-before-multiply
1106:         let amtPerCycle := div(mul(cycleSecs, amtPerSec), _AMT_PER_SEC_MULTIPLIER) line
1107:         amt := mul(endedCycles, div(mul(cycleSecs, amtPerSec), _AMT_PER_SEC_MULTIPLIER)) // @audit-info Division before before multiplication
1108:         // slither-disable-next-line weak-prng
1109:         let amtEnd := div(mul(mod(end, cycleSecs), amtPerSec), _AMT_PER_SEC_MULTIPLIER)
1110:         amt := add(amt, amtEnd)
1111:         // slither-disable-next-line weak-prng
1112:         let amtStart := div(mul(mod(start, cycleSecs), amtPerSec), _AMT_PER_SEC_MULTIPLIER)
1113:         amt := sub(amt, amtStart)
1114:     }
1115: }

Consider multiplying mul(cycleSecs, amtPerSec) by endedCycles before dividing by _AMT_PER_SEC_MULTIPLIER in line 1107 to avoid precision issues.

Non-Critical Findings

[NC-01] The NatSpec comment of the DripsConfigImpl.lt function neglects to sorting of dripId

According to the NatSpec comment of the DripsConfigImpl.lt function, the DripsConfig struct is sorted by amtPerSec, then start and then duration. However, the dripId is not mentioned in the comment:

/// First compares their amtPerSecs, then their starts and then their durations.

Findings

Drips.sol#L93

92: /// @notice Compares two `DripsConfig`s.
93: /// First compares their `amtPerSec`s, then their `start`s and then their `duration`s.
94: function lt(DripsConfig config, DripsConfig otherConfig) internal pure returns (bool) {
95:     return DripsConfig.unwrap(config) < DripsConfig.unwrap(otherConfig);
96: }

Drips.sol#L1069

1061: function _isOrdered(DripsReceiver memory prev, DripsReceiver memory next)
1062:     private
1063:     pure
1064:     returns (bool)
1065: {
1066:     if (prev.userId != next.userId) {
1067:         return prev.userId < next.userId;
1068:     }
1069:     return prev.config.lt(next.config); // @audit-info `dripId` of receiver config (The 32 most significant bits) is considered while sorting receivers
1070: }

Consider updating the NatSpec comment to include the dripId in the sorting order.

[NC-02] Unused amtPerCycle variable in Drips._drippedAmt calculation

The Drips._drippedAmt function calculates the dripped amount. The amtPerCycle variable is not used in the calculation and can be removed.

Findings

Drips.sol#L1106

1093: function _drippedAmt(uint256 amtPerSec, uint256 start, uint256 end)
1094:     private
1095:     view
1096:     returns (uint256 amt)
1097: {
1098:     // This function is written in Yul because it can be called thousands of times
1099:     // per transaction and it needs to be optimized as much as possible.
1100:     // As of Solidity 0.8.13, rewriting it in unchecked Solidity triples its gas cost.
1101:     uint256 cycleSecs = _cycleSecs;
1102:     // slither-disable-next-line assembly
1103:     assembly {
1104:         let endedCycles := sub(div(end, cycleSecs), div(start, cycleSecs))
1105:         // slither-disable-next-line divide-before-multiply
1106:         let amtPerCycle := div(mul(cycleSecs, amtPerSec), _AMT_PER_SEC_MULTIPLIER) // @audit-info Unused variable
1107:         amt := mul(endedCycles, div(mul(cycleSecs, amtPerSec), _AMT_PER_SEC_MULTIPLIER))
1108:         // slither-disable-next-line weak-prng
1109:         let amtEnd := div(mul(mod(end, cycleSecs), amtPerSec), _AMT_PER_SEC_MULTIPLIER)
1110:         amt := add(amt, amtEnd)
1111:         // slither-disable-next-line weak-prng
1112:         let amtStart := div(mul(mod(start, cycleSecs), amtPerSec), _AMT_PER_SEC_MULTIPLIER)
1113:         amt := sub(amt, amtStart)
1114:     }
1115: }

Consider removing the unused variable.

#0 - GalloDaSballo

2023-02-14T20:12:16Z

[L-01] An authorized user can unauthorize other authorized users of the same sender L

[L-02] Lack of reasonable boundaries for cycle secs L

[L-03] Loading a drip configuration with assembly in _getConfig is unsafe I believe that because of the codebase, we'd need more proof to verify this statement From what I'm seeing the add will avoid reading that part of memory, making the operation safe

[L-04] Unused dripId of receiver config is used considered while sorting receivers L

[L-05] Collecting funds should be usable while the DripsHub contract is paused L (per judging of Admin Privilege)

[L-06] An immutable split is unable to collect funds if it has itself set as a split receiver L

[L-07] Precision issues caused by division before multiplication in Drips._drippedAmt Disputing because it will be recovered later

[NC-01] The NatSpec comment of the DripsConfigImpl.lt function neglects to sorting of dripId R

[NC-02] Unused amtPerCycle variable in Drips._drippedAmt calculation R

5L 2R

4L +3 from dups

9L 2R +3

#1 - GalloDaSballo

2023-02-24T10:55:05Z

Summing up all findings, this report is by far the most interesting, well done!

#2 - c4-judge

2023-02-24T10:55:11Z

GalloDaSballo marked the issue as selected for report

#3 - c4-judge

2023-02-24T10:55:19Z

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