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: 3/26
Findings: 2
Award: $9,286.11
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: berndartmueller
Also found by: kaden
7747.7407 USDC - $7,747.74
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
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.
A sender can prevent its drip receivers from squeezing by front-running the squeeze transaction and adding a new configuration.
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: } ... // [...]
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: }
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
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.
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".
On the other hand, we must acknowledge that the attack / griefing, is limited:
sender
is the only privileged account that can perform the griefThese can be viewed as additional external requirements, which reduce the impact / likelihood of the finding
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.
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.
Given the information above, being mindful of:
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
🌟 Selected for report: berndartmueller
Also found by: 0xA5DF, 0xSmartContract, HollaDieWaldfee, IllIllI, SleepingBugs, btk, chaduke, fs0c, hansfriese, nalus, rbserver, zzzitron
1538.3717 USDC - $1,538.37
_getConfig
is unsafedripId
of receiver config is used considered while sorting receiversDripsHub
contract is pausedDrips._drippedAmt
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.
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
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.
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
.
_getConfig
is unsafeThe 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
.
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.
dripId
of receiver config is used considered while sorting receiversThe 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 amtPerSec
s, then their start
s and then their duration
s (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.
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.
DripsHub
contract is pausedThe 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.
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.
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.
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.
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.
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.
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
amtPerSec
s, then theirstart
s and then theirduration
s.
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: }
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.
amtPerCycle
variable in Drips._drippedAmt
calculationThe Drips._drippedAmt
function calculates the dripped amount. The amtPerCycle
variable is not used in the calculation and can be removed.
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