Closed sherlock-admin closed 9 months ago
1 comment(s) were left on this issue during the judging contest.
takarez commented:
valid because { After carefull review; this turns out to be a valid issue as the fron-tun is possible despite the onlyAuthorized modifier in the retrieve function; the issue 092 explained how a malicious user with NFT can bypassed the onlyAuthorized modifier and call the retrieve and im also making it the best report among the others even though its a bit different than the others; but the underlying cause of zero balance is the same}
Tricko
high
Holder of
CouncilMember
NFT can DoS theCouncilMember
contract.Summary
Because
SablierV2ProxyTarget.withdrawMax()
reverts when withdrawable tokens from the corresponding stream is zero, a malicious council member can exploit this vulnerability usingCouncilMember.retrieve()
to DoS the majority ofCouncilMember
functionalities such asCouncilMember.claim()
,CouncilMember.mint()
,CouncilMember.burn()
, andCouncilMember.removeFromOffice()
.Vulnerability Detail
Before any significant state changes in the
CouncilMember
contract, the internal method_retrieve()
is called. The purpose of_retrieve()
is to retrieve funds from the Sablier stream and distribute them among council members (holders ofCouncilMember
NFTs). To obtain the accrued funds from the stream, it utilizes theSablierV2ProxyTarget.withdrawMax()
function, internally callingSablierV2Lockup.withdrawMax()
andSablierV2Lockup.withdraw()
. As the name suggests, this function withdraws all available funds from the specified stream.If
SablierV2ProxyTarget.withdrawMax()
is called again in the same block, the amount of withdrawable tokens will be zero because of the previous call towithdrawMax()
. However, examining the Sablier codebase reveals that ifSablierV2Lockup.withdraw()
is called withamount = 0
, the call reverts with the errorErrors.SablierV2Lockup_WithdrawAmountZero(streamId)
.Therefore, if
CouncilMember._retrieve()
is called once in a block, withdrawing all available funds from the stream, subsequent calls toCouncilMember._retrieve()
within the same block will result in a revert. A malicious council member can exploit this behavior to indefinitely execute a DoS attack on the majority ofCouncilMember
functionality, affecting methods such asCouncilMember.claim()
,CouncilMember.mint()
,CouncilMember.burn()
, andCouncilMember.removeFromOffice()
.The DoS attack would proceed as follows:
GOVERNANCE_COUNCIL_ROLE
callsCouncilMember.removeFromOffice(...)
.GOVERNANCE_COUNCIL_ROLE
in the mempool and attempts to front-run it by callingCouncilMember.retrieve()
.If the front-run is successful and
retrieve()
call is executed first, the remaining funds on the stream will be zero during that block. Consequently, the transaction byGOVERNANCE_COUNCIL_ROLE
reverts due to the issues described above.It's essential to note that the attack's cost is minimal, requiring only the gas to call the
CouncilMember.retrieve()
function. The malicious council member can repetitively execute this attack every block, continuously DoSing theCouncilMember
contract. Even attempts byGOVERNANCE_COUNCIL_ROLE
to remove the malicious council member can be thwarted, as the attacker can also DoS their own removal.Impact
A malicious council member can indefinitely DoS the majority of
CouncilMember
functionality.Code Snippet
https://github.com/sherlock-audit/2024-01-telcoin/blob/0954297f4fefac82d45a79c73f3a4b8eb25f10e9/telcoin-audit/contracts/sablier/core/CouncilMember.sol#L267-L295
Tool used
Manual Review
Recommendation
Consider using the
withdrawableAmountOf()
function exposed bySablierV2Lockup
to verify if withdrawable amount is non-zero during the execution ofCouncilMember._retrieve()
. If the withdrawable amount is zero, consider bypassing the fund withdrawal process.Duplicate of #47