Streaming Protocol contest - WatchPug's results

General Information

Platform: Code4rena

Start Date: 30/11/2021

Pot Size: $100,000 USDC

Total HM: 15

Participants: 36

Period: 7 days

Judge: 0xean

Total Solo HM: 4

Id: 62

League: ETH

Streaming Protocol

Findings Distribution

Researcher Performance

Rank: 3/36

Findings: 7

Award: $7,936.26

🌟 Selected for report: 16

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: WatchPug

Also found by: 0x0x0x, ScopeLift, gpersoon, gzeon, harleythedog, hyh, jonah1005, kenzo

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

385.4189 USDC - $385.42

External Links

Handle

WatchPug

Vulnerability details

https://github.com/code-423n4/2021-11-streaming/blob/56d81204a00fc949d29ddd277169690318b36821/Streaming/src/Locke.sol#L654-L654

uint256 excess = ERC20(token).balanceOf(address(this)) - (depositTokenAmount - redeemedDepositTokens);

In the current implementation, depositTokenFlashloanFeeAmount is not excluded when calculating excess depositToken. Therefore, the stream creator can call recoverTokens(depositToken, recipient) and retrieve depositTokenFlashloanFeeAmount if there are any.

As a result:

  • When the protocol governance calls claimFees() and claim accumulated depositTokenFlashloanFeeAmount, it may fail due to insufficient balance of depositToken.
  • Or, part of users' funds (depositToken) will be transferred to the protocol governance as fees, causing some users unable to withdraw or can only withdraw part of their deposits.

PoC

Given:

  • feeEnabled: true
  • feePercent: 10 (0.1%)
  1. Alice deposited 1,000,000 depositToken;
  2. Bob called flashloan() and borrowed 1,000,000 depositToken, then repaid 1,001,000;
  3. Charlie deposited 1,000 depositToken;
  4. After endDepositLock, Alice called claimDepositTokens() and withdrawn 1,000,000 depositToken;
  5. streamCreator called recoverTokens(depositToken, recipient) and retrieved 1,000 depositToken (2,000 - (1,001,000 - 1,000,000));
  6. governance called claimFees() and retrieved another 1,000 depositToken;
  7. Charlie tries to claimDepositTokens() but since the current balanceOf depositToken is 0, the transcation always fails, and Charlie loses all the depositToken.

Recommendation

Change to:

uint256 excess = ERC20(token).balanceOf(address(this)) - (depositTokenAmount - redeemedDepositTokens) - depositTokenFlashloanFeeAmount;

#0 - brockelmore

2021-12-08T22:16:11Z

duplicates: #211, #206, #195, #182, #149, #129, #128, #120, #86

Findings Information

🌟 Selected for report: WatchPug

Also found by: Jujic, hack3r-0m

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

2175.7011 USDC - $2,175.70

External Links

Handle

WatchPug

Vulnerability details

https://github.com/code-423n4/2021-11-streaming/blob/56d81204a00fc949d29ddd277169690318b36821/Streaming/src/Locke.sol#L733-L735

function arbitraryCall(address who, bytes memory data) public lock externallyGoverned {
    // cannot have an active incentive for the callee
    require(incentives[who] == 0, "inc");
    ...

When an incentiveToken is claimed after endStream, incentives[who] will be 0 for that incentiveToken.

If the protocol gov is malicious or compromised, they can call arbitraryCall() with the address of the incentiveToken as who and transferFrom() as calldata and steal all the incentiveToken in the victim's wallet balance up to the allowance amount.

PoC

  1. Alice approved USDC to the streaming contract;
  2. Alice called createIncentive() and added 1,000 USDC of incentive;
  3. After the stream is done, the stream creator called claimIncentive() and claimed 1,000 USDC;

The compromised protocol gov can call arbitraryCall() and steal all the USDC in Alice's wallet balance.

Recommendation

Consider adding a mapping: isIncentiveToken, setting isIncentiveToken[incentiveToken] = true in createIncentive(), and require(!isIncentiveToken[who], ...) in arbitraryCall().

#0 - brockelmore

2021-12-08T22:32:56Z

duplicates: #242, #229

Findings Information

🌟 Selected for report: gpersoon

Also found by: WatchPug

Labels

bug
duplicate
3 (High Risk)

Awards

3626.1684 USDC - $3,626.17

External Links

Handle

WatchPug

Vulnerability details

In the current implementation, ts.lastUpdate will only be updated when ts.tokens > 0. Thus, ts.lastUpdate can be outdated for an exited user who deposits again.

As a result, by the next time updateStreamInternal() is called, ts.tokens will be reduced more than expected mistakenly.

https://github.com/code-423n4/2021-11-streaming/blob/56d81204a00fc949d29ddd277169690318b36821/Streaming/src/Locke.sol#L197-L250

modifier updateStream(address who) {
    // save bytecode space by making it a jump instead of inlining at cost of gas
    updateStreamInternal(who);
    _;
}

function updateStreamInternal(address who) internal {
    ...
    // update users unstreamed balance
    uint32 acctTimeDelta = uint32(block.timestamp) - ts.lastUpdate;
    if (acctTimeDelta > 0 && ts.tokens > 0) {
        // some time has passed since this user last interacted
        // update ts not yet streamed
        ts.tokens -= uint112(acctTimeDelta * ts.tokens / (endStream - ts.lastUpdate));
        ts.lastUpdate = uint32(block.timestamp);
    }
    ...

PoC

Given:

  • startTime: 1/1/2022
  • endStream: 1/1/2023
  1. On 1/1/2022: Alice calls stake() and exit() right after, ts.lastUpdate is 1/1/2022;
  2. On 1/12/2022: Alice calls stake(1000e18). At L226, as ts.tokens == 0, ts.lastUpdate remains 1/1/2022;
  3. Right after step 2, Alice calls stake(1 wei):
    • Expected Result: ts.tokens remains 1000e18, and almost all of the 1000e18 can be withdrawn;
    • Actual Result: Since ts.lastUpdate is not updated at step 2. At L225, acctTimeDelta will be ~11 mos. Thus, at L229 ts.tokens will be reduced to 83.33e18, and only 83.33e18 can be withdrawn.

Recommendation

Change to:

// update users unstreamed balance
uint32 acctTimeDelta = uint32(block.timestamp) - ts.lastUpdate;
if (acctTimeDelta > 0 && ts.tokens > 0) {
    // some time has passed since this user last interacted
    // update ts not yet streamed
    ts.tokens -= uint112(acctTimeDelta * ts.tokens / (endStream - ts.lastUpdate));
}
ts.lastUpdate = uint32(block.timestamp);

#0 - brockelmore

2022-01-05T17:07:35Z

duplicate #123

Findings Information

🌟 Selected for report: egjlmn1

Also found by: WatchPug, itsmeSTYJ, toastedsteaksandwich

Labels

bug
duplicate
2 (Med Risk)

Awards

440.5795 USDC - $440.58

External Links

Handle

WatchPug

Vulnerability details

https://github.com/code-423n4/2021-11-streaming/blob/56d81204a00fc949d29ddd277169690318b36821/Streaming/src/LockeERC20.sol#L86-L92

function approve(address spender, uint256 amount) public virtual returns (bool) {
    allowance[msg.sender][spender] = amount;

    emit Approval(msg.sender, spender, amount);

    return true;
}

Using approve() to manage allowances opens yourself and users of the token up to frontrunning. Best practice, but doesn't usually matter.

Explanation of this possible attack vector

See also: 0xProject/0x-monorepo#850

A potential fix includes preventing a call to approve if all the previous tokens are not spent through adding a check that the allowed balance is 0:

require(allowed[msg.sender][_spender] == 0).

#0 - 0xean

2022-01-16T14:06:17Z

duplicate of #74

Findings Information

🌟 Selected for report: harleythedog

Also found by: WatchPug, csanuragjain, gpersoon, hubble

Labels

bug
duplicate
2 (Med Risk)

Awards

317.2172 USDC - $317.22

External Links

Handle

WatchPug

Vulnerability details

unstreamed is a public variable, and it's been actively managed in stake(), updateStreamInternal(). However, since users can also withdraw unstreamed depositToken, the global variable unstreamed should be updated in withdraw() as well.

For example:

  1. Alice deposits 10,000 depositToken;
  2. Alice withdraws 10,000 depositToken right after step 1.
  • Expected Result: unstreamed to be 0;
  • Actual Result: unstreamed to be 10,000.

https://github.com/code-423n4/2021-11-streaming/blob/56d81204a00fc949d29ddd277169690318b36821/Streaming/src/Locke.sol#L455-L479

function withdraw(uint112 amount) public lock updateStream(msg.sender) {
    require(amount > 0, "amt");

    // checked in updateStream
    // is the stream still going on? thats the only time a depositer can withdraw
    // require(block.timestamp < endStream, "withdraw:!stream");
    TokenStream storage ts = tokensNotYetStreamed[msg.sender];

    require(ts.tokens >= amount, "amt");
    ts.tokens -= amount;

    uint256 virtualBal = dilutedBalance(amount);
    ts.virtualBalance -= virtualBal;
    totalVirtualBalance -= virtualBal;
    depositTokenAmount -= amount;
    if (!isSale) {
        _burn(msg.sender, amount);
    } else {
    }

    // do the transfer
    ERC20(depositToken).safeTransfer(msg.sender, amount);

    emit Withdrawn(msg.sender, amount);
}

Recommendation

Change to:

function withdraw(uint112 amount) public lock updateStream(msg.sender) {
    require(amount > 0, "amt");

    // checked in updateStream
    // is the stream still going on? thats the only time a depositer can withdraw
    // require(block.timestamp < endStream, "withdraw:!stream");
    TokenStream storage ts = tokensNotYetStreamed[msg.sender];

    require(ts.tokens >= amount, "amt");
    ts.tokens -= amount;
    unstreamed -= amount;

    uint256 virtualBal = dilutedBalance(amount);
    ts.virtualBalance -= virtualBal;
    totalVirtualBalance -= virtualBal;
    depositTokenAmount -= amount;
    if (!isSale) {
        _burn(msg.sender, amount);
    } else {
    }

    // do the transfer
    ERC20(depositToken).safeTransfer(msg.sender, amount);

    emit Withdrawn(msg.sender, amount);
}

#0 - 0xean

2022-01-16T00:27:23Z

dupe of #118

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