Sweet Infrared Snake
High
TicksToForward
can be arbitrarily large in fastForwardStream:StreamEscrow.sol
(within limits) that allow attacker to submit arbitrarily large values
The fastForwardStream
function in the smart contract allows the fast-forwarding of a stream of ETH. Although there is a limit to how many ticks the stream can be advanced (based on the difference between currentTick and stream.lastTick), the function does not impose a maximum limit on the ticksToForward
value within the allowed range. This opens the door for users to exploit the function by submitting arbitrarily large values of ticksToForward
(within the allowed range), manipulating the contract's internal state, draining ETH from the contract, or causing other unintended behavior.
The vulnerability arises in the following part of the fastForwardStream
function:
https://github.com/sherlock-audit/2024-11-nounsdao/blob/main/nouns-monorepo/packages/nouns-contracts/contracts/StreamEscrow.sol#L206
require(ticksToForward <= stream.lastTick - currentTick_, 'ticksToForward too large');
This line only ensures that ticksToForward
does not exceed the difference between the current tick and the stream's last tick. However, there is no further restriction on how large ticksToForward
can be within that range. An attacker could still submit a large, but valid, value of ticksToForward
, potentially causing the contract to perform excessive state updates, drain ETH from the contract, or cause performance degradation due to the large number of state changes.
Stream associated with nounId = 1.
currentTick
= 100.
lastTick
= 1000.
ethPerTick
= 10.
No response
Attacker invokes fastForwardStream(1, 900)
.
ticksToForward
value is valid, so it gets accepted by the contract.
The contract moves lastTick
from 1000 to 100 (lastTick - ticksToForward = 1000 - 900 = 100).
ETH (900 * 10 = 9000) is sent to the treasury.
Code example:
require(ticksToForward <= stream.lastTick - currentTick_, 'ticksToForward too large'); // Passes as 900 <= 900
uint32 newLastTick = stream.lastTick - ticksToForward; // newLastTick = 1000 - 900 = 100
ethStreamEndingAtTick[stream.lastTick] -= stream.ethPerTick; // Decreases ETH at tick 1000
streams[nounId].lastTick = newLastTick; // Updates lastTick to 100
ethStreamEndingAtTick[newLastTick] += stream.ethPerTick; // Increases ETH at tick 100
uint256 ethToStream = ticksToForward * stream.ethPerTick; // ethToStream = 900 * 10 = 9000
sendETHToTreasury(ethToStream); // Sends 9000 ETH to treasury
An attacker can repeatedly invoke the fastForwardStream function with large but valid values of ticksToForward, draining ETH from the contract.
The attacker successfully invokes the fastForwardStream
function with ticksToForward
= 900, which is within the valid range (since 1000 - 100 = 900).
ETH amount of 9000 (calculated as 900 * 10) is transferred to the treasury.
The contract’s internal state is updated with the new lastTick
= 100, reflecting the fast-forwarded stream.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "ds-test/test.sol";
import "openzeppelin-contracts/contracts/token/ERC721/IERC721.sol";
contract FastForwardTest is DSTest {
address attacker;
uint256 nounId = 1;
uint32 currentTick = 100;
uint32 lastTick = 1000;
// Mock Stream contract for testing purposes
mapping(uint256 => Stream) public streams;
mapping(uint32 => uint256) public ethStreamEndingAtTick;
uint256 public ethStreamedPerTick;
struct Stream {
uint256 ethPerTick;
uint32 lastTick;
}
function setUp() public {
// Setup test data for the attacker and stream
attacker = address(0x123);
// Initial stream setup
streams[nounId] = Stream({
ethPerTick: 10,
lastTick: lastTick
});
}
// Test function to simulate fast-forward attack
function testExploitableFastForward() public {
uint32 ticksToForward = 900; // Arbitrary large number within the allowed range (1000 - 100)
// Simulate the attacker calling the fastForwardStream function
fastForwardStream(nounId, ticksToForward);
// Check if the ETH has been manipulated (sent to treasury)
assertEq(ethStreamEndingAtTick[lastTick - ticksToForward], 10); // Verify the ETH manipulation at the new tick
}
// Simulated fastForwardStream function
function fastForwardStream(uint256 nounId, uint32 ticksToForward) public {
require(ticksToForward > 0, 'ticksToForward must be positive');
Stream memory stream = streams[nounId];
uint32 currentTick_ = currentTick;
require(isStreamActive(stream, currentTick_), 'stream not active');
// move last tick
require(ticksToForward <= stream.lastTick - currentTick_, 'ticksToForward too large');
uint32 newLastTick = stream.lastTick - ticksToForward;
ethStreamEndingAtTick[stream.lastTick] -= stream.ethPerTick;
streams[nounId].lastTick = newLastTick;
if (newLastTick > currentTick_) {
// stream is still active, so register the new end tick
ethStreamEndingAtTick[newLastTick] += stream.ethPerTick;
} else {
// no more ticks left, so finished the stream
ethStreamedPerTick -= stream.ethPerTick;
}
uint256 ethToStream = ticksToForward * stream.ethPerTick;
sendETHToTreasury(ethToStream); // Sends ETH to treasury
}
function isStreamActive(Stream memory stream, uint32 currentTick_) internal pure returns (bool) {
return stream.lastTick >= currentTick_;
}
function sendETHToTreasury(uint256 amount) internal {
// Simulated ETH sending logic to the treasury
ethStreamedPerTick += amount;
}
}
Add a cap for how many ticks a stream can be fast-forwarded at once to prevent an attacker from submitting excessively large values.
uint32 constant MAX_TICKS = 500; // Example cap to limit fast forwarding to a reasonable value
require(ticksToForward <= MAX_TICKS, "ticksToForward exceeds cap");