Closed sherlock-admin closed 1 year ago
2 comment(s) were left on this issue during the judging contest.
141345 commented:
d
panprog commented:
invalid because owner is trusted
Escalate
This function claimReward is called by the actual user who have rewards accumulated during market operation. So, there is no meaning for the comment invalid because owner is trusted
.
Lets see the contract flow to know why the actual user can be the caller here.
When we look at the MultiInvoker contract's invoke function, different type of perennial actions is done by the user by sending the payment (check this function is payable).
function invoke(Invocation[] calldata invocations) external payable {
for(uint i = 0; i < invocations.length; ++i) {
Invocation memory invocation = invocations[i];
if (invocation.action == PerennialAction.UPDATE_POSITION) {
(
IMarket market,
UFixed6 newMaker,
UFixed6 newLong,
UFixed6 newShort,
Fixed6 collateral,
bool wrap
) = abi.decode(invocation.args, (IMarket, UFixed6, UFixed6, UFixed6, Fixed6, bool));
_update(market, newMaker, newLong, newShort, collateral, wrap); ------------>> follow this function
} else if (invocation.action == PerennialAction.UPDATE_VAULT) {
......
/// @notice Updates market on behalf of msg.sender
/// @param market Address of market up update
/// @param newMaker New maker position for msg.sender in `market`
/// @param newLong New long position for msg.sender in `market`
/// @param newShort New short position for msg.sender in `market`
/// @param collateral Net change in collateral for msg.sender in `market`
function _update(
IMarket market,
UFixed6 newMaker,
UFixed6 newLong,
UFixed6 newShort,
Fixed6 collateral,
bool wrap
) internal {
// collateral is transferred from this address to the market, transfer from msg.sender to here
if (collateral.sign() == 1) _deposit(collateral.abs(), wrap);
market.update(msg.sender, newMaker, newLong, newShort, collateral, false); ----->>> called the Market's update where the msg.sender is the user.
// collateral is transferred from the market to this address, transfer to msg.sender from here
if (collateral.sign() == -1) _withdraw(msg.sender, collateral.abs(), wrap);
}
.....
Now, let look at the Market's update
function,
function update(
address account,
UFixed6 newMaker,
UFixed6 newLong,
UFixed6 newShort,
Fixed6 collateral,
bool protect
) external nonReentrant whenNotPaused {
Context memory context = _loadContext(account);
_settle(context, account);
_update(context, account, newMaker, newLong, newShort, collateral, protect);
_saveContext(context, account); ----------------------------------------------------->> follow this function now.
}
https://github.com/sherlock-audit/2023-07-perennial/blob/120bcfef4028654de83477ffe992805ddada1043/perennial-v2/packages/perennial/contracts/Market.sol#L319-L322
For each account's the local context is updated in the _locals[account].store(context.local);
mapping(address => LocalStorage) private _locals;
function _saveContext(Context memory context, address account) private {
_global.store(context.global);
_locals[account].store(context.local); -------------------------------------->> for caller, the local context is updated.
}
Now, lets look at the claimReward
function,
https://github.com/sherlock-audit/2023-07-perennial/blob/120bcfef4028654de83477ffe992805ddada1043/perennial-v2/packages/perennial/contracts/Market.sol#L154-L162
function claimReward() external {
Local memory newLocal = _locals[msg.sender].read(); ---------->> for each caller, local context is read.
reward.push(msg.sender, UFixed18Lib.from(newLocal.reward)); ---------->> reward is send to the msg.sender
emit RewardClaimed(msg.sender, newLocal.reward);
newLocal.reward = UFixed6Lib.ZERO; -------------->> reward is set to zero.
_locals[msg.sender].store(newLocal); ------------>> state is update.
}
It is clear, that the user is the caller and they can call this function to claim the reward.
since the claimReward function update the state after making the transfer, this is not safe from the reentrancy attack.
This leas to loss funds. So the impact could be High.
I would request to consider this as High.
Thanks!
Escalate
This function claimReward is called by the actual user who have rewards accumulated during market operation. So, there is no meaning for the comment
invalid because owner is trusted
.Lets see the contract flow to know why the actual user can be the caller here.
When we look at the MultiInvoker contract's invoke function, different type of perennial actions is done by the user by sending the payment (check this function is payable).
function invoke(Invocation[] calldata invocations) external payable { for(uint i = 0; i < invocations.length; ++i) { Invocation memory invocation = invocations[i]; if (invocation.action == PerennialAction.UPDATE_POSITION) { ( IMarket market, UFixed6 newMaker, UFixed6 newLong, UFixed6 newShort, Fixed6 collateral, bool wrap ) = abi.decode(invocation.args, (IMarket, UFixed6, UFixed6, UFixed6, Fixed6, bool)); _update(market, newMaker, newLong, newShort, collateral, wrap); ------------>> follow this function } else if (invocation.action == PerennialAction.UPDATE_VAULT) { ......
/// @notice Updates market on behalf of msg.sender /// @param market Address of market up update /// @param newMaker New maker position for msg.sender in `market` /// @param newLong New long position for msg.sender in `market` /// @param newShort New short position for msg.sender in `market` /// @param collateral Net change in collateral for msg.sender in `market` function _update( IMarket market, UFixed6 newMaker, UFixed6 newLong, UFixed6 newShort, Fixed6 collateral, bool wrap ) internal { // collateral is transferred from this address to the market, transfer from msg.sender to here if (collateral.sign() == 1) _deposit(collateral.abs(), wrap); market.update(msg.sender, newMaker, newLong, newShort, collateral, false); ----->>> called the Market's update where the msg.sender is the user. // collateral is transferred from the market to this address, transfer to msg.sender from here if (collateral.sign() == -1) _withdraw(msg.sender, collateral.abs(), wrap); } .....
Now, let look at the Market's
update
function,function update( address account, UFixed6 newMaker, UFixed6 newLong, UFixed6 newShort, Fixed6 collateral, bool protect ) external nonReentrant whenNotPaused { Context memory context = _loadContext(account); _settle(context, account); _update(context, account, newMaker, newLong, newShort, collateral, protect); _saveContext(context, account); ----------------------------------------------------->> follow this function now. }
https://github.com/sherlock-audit/2023-07-perennial/blob/120bcfef4028654de83477ffe992805ddada1043/perennial-v2/packages/perennial/contracts/Market.sol#L319-L322 For each account's the local context is updated in the
_locals[account].store(context.local);
mapping(address => LocalStorage) private _locals;
function _saveContext(Context memory context, address account) private { _global.store(context.global); _locals[account].store(context.local); -------------------------------------->> for caller, the local context is updated. }
Now, lets look at the
claimReward
function, https://github.com/sherlock-audit/2023-07-perennial/blob/120bcfef4028654de83477ffe992805ddada1043/perennial-v2/packages/perennial/contracts/Market.sol#L154-L162function claimReward() external { Local memory newLocal = _locals[msg.sender].read(); ---------->> for each caller, local context is read. reward.push(msg.sender, UFixed18Lib.from(newLocal.reward)); ---------->> reward is send to the msg.sender emit RewardClaimed(msg.sender, newLocal.reward); newLocal.reward = UFixed6Lib.ZERO; -------------->> reward is set to zero. _locals[msg.sender].store(newLocal); ------------>> state is update. }
It is clear, that the user is the caller and they can call this function to claim the reward.
since the claimReward function update the state after making the transfer, this is not safe from the reentrancy attack.
This leas to loss funds. So the impact could be High.
I would request to consider this as High.
Thanks!
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
Escalate
You're fully correct that generally speaking, this is vulnerable to reentrancy and reentrancy path is very easy and clear, allowing any user to steal all rewards the smart contract has. However, the reward token is set by admin who is trusted. He is supposed to only set trusted tokens (which can't be used to re-enter). If admin sets a token which allows re-entrancy, this is an admin mistake, which is invalid by Sherlock rules. So this should be low.
Escalate
You're fully correct that generally speaking, this is vulnerable to reentrancy and reentrancy path is very easy and clear, allowing any user to steal all rewards the smart contract has. However, the reward token is set by admin who is trusted. He is supposed to only set trusted tokens (which can't be used to re-enter). If admin sets a token which allows re-entrancy, this is an admin mistake, which is invalid by Sherlock rules. So this should be low.
You've created a valid escalation!
To remove the escalation from consideration: Delete your comment.
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
I don't see any protection other than re-entrant check in the token world. Any token that is set can be re-entered when the msg.sender is another contract.
There are no reference about the type of tokens that can be set by the admin. Its really vague to make such assumption that admin has configured everything and it can not be deemed as issue. if that is the case, entire contracts are developed by admin so the issues are not actual issues.
For above reasons, I do stand by the escalation.
Reward token is not arbitrary trading pair token, when admin set the reward token, the likelihood that it has transfer hook is quite low.
From best practice aspect, CEI pattern is recommended.
The severity of low seems more appropriate.
Adding function call flow for reward.push(msg.sender, UFixed18Lib.from(newLocal.reward));
/**
* @notice Transfers `amount` tokens from the caller to the `recipient`
* @param self Token to transfer
* @param recipient Address to transfer tokens to
* @param amount Amount of tokens to transfer
*/
function push(Token18 self, address recipient, UFixed18 amount) internal {
IERC20(Token18.unwrap(self)).safeTransfer(recipient, UFixed18.unwrap(amount)); ---------------->> OZ safeERC20.sol
}
Lets look the OZ SafeERC20 flow
function safeTransfer(IERC20 token, address to, uint256 value) internal {
_callOptionalReturn(token, abi.encodeCall(token.transfer, (to, value)));
}
function _callOptionalReturn(IERC20 token, bytes memory data) private {
// We need to perform a low level call here, to bypass Solidity's return data size checking mechanism, since
// we're implementing it ourselves. We use {Address-functionCall} to perform this call, which verifies that
// the target address contains contract code and also asserts for success in the low-level call.
bytes memory returndata = address(token).functionCall(data);
if (returndata.length != 0 && !abi.decode(returndata, (bool))) {
revert SafeERC20FailedOperation(address(token));
}
}
function functionCall(address target, bytes memory data) internal returns (bytes memory) {
return functionCallWithValue(target, data, 0);
}
function functionCallWithValue(address target, bytes memory data, uint256 value) internal returns (bytes memory) {
if (address(this).balance < value) {
revert AddressInsufficientBalance(address(this));
}
(bool success, bytes memory returndata) = target.call{value: value}(data); ----------->> uses the low level call to puch the reward token.
return verifyCallResultFromTarget(target, success, returndata);
}
Above code flow is sufficient to understand this issue.
The question here is not whether reentrancy is possible, but whether the admin will set some token with transfer hook as the reward. Most mainstream token does not have the hook, so the likelihood is quite low for this issue.
Yes. The possibility is high.
I can say this by looking at the Perennial V1 contract reward token and how the implementation is done to claim the rewards.
Here is perennial statement about the reward token.
/**
* @dev Reward ERC20 token contract
* @notice Perennial does not support non-standard ERC20s as reward tokens for incentive programs, including,
but not limited to: fee on transfer and rebase tokens. Using such a non-standard token will likely
result in loss of funds.
*/
Token18 token;
Now, lets look at the claim reward functionality. https://github.com/equilibria-xyz/perennial-mono/blob/a71759d63d817847e4f7ec49d9975a3fd575fdbb/packages/perennial/contracts/incentivizer/Incentivizer.sol#L138-L144
The claim
function has the nonReentrant
check which is sufficient to say that the reward token would allow for reentrancy.
function claim(IProduct product, uint256[] calldata programIds)
external
nonReentrant
{
_claimProduct(msg.sender, product, programIds);
}
function _claimProduct(address account, IProduct product, uint256[] calldata programIds)
private
isProduct(product)
notPaused
settleForAccount(account, product)
{
for (uint256 i; i < programIds.length; i++) {
_claimProgram(account, product, programIds[i]);
}
}
Also, when we follow the function flow, the state update is done prior to transferring the tokens. As a continuation, Perennial V2 could use the same reward token. I think, I have show enough evidence senior.... : )
@141345 @aktech297
Currently, the readme mentions only DSU and USDC
. How is this issue relevant to the above tokens?
@141345 @aktech297 Currently, the readme mentions only
DSU and USDC
. How is this issue relevant to the above tokens?
Yes, the discussion is hypothetical that the admin change the reward token in the future. DSU and USDC
do not have any problem.
I am not sure that DSU and USDC are reward tokens. I searched for that and didn't find any reference. If you find any mention about reward token, can your pls share the link here. Also, the V1 mention that's reward token is a standard. But V2 mentioned about the USDC as non standard. May be you can check about the reward token with sponser. Its really dangerous to assume on the safer side on this , since there is real loss of asset involved. Also, in V2 , the reward token can be changed anytime. So, it's really hard to assume on this one. Another question is , why do the V1 has non re enetrant protection.?
The reward token could be any theoretical token the admin wants but we would consider them trusted since they're set by a trusted entity in the system. This would be similar to how a coordinator setting parameters in malicious ways is not a valid issue unless it can negatively affect other markets or the protocol overall.
Looking for @hrishibhat final judging.. sponser mentioned any token with trusted.. so possibilities are hanging for both kind of token , i.e, tokens which allow re entrancy and tokens that will not.
I think it's pretty clear that expected reward token is standard ERC-20 token, which doesn't have any transfer hooks and thus can't be used to re-enter. If the admin sets a reward token which does allow re-entrancy, this is admin mistake and should be invalid.
Posting here to everyone to know more about reentrancy.
Result: Low Unique This is a low issue as there are no tokens mentioned in the readme that would consider this, in addition this would be admin mistake if such tokens are used. https://github.com/sherlock-audit/2023-07-perennial-judging/issues/135#issuecomment-1700986744
ak1
high
Market.sol :
claimReward
need to have reentrancy protection, since the state update is done after making the external call.Summary
Market.sol
has claimReward external function. Any valid user can claim any available reward which is accrued. But it updates the state after making the transfer. This is dangerous since it opens for reentrancy attack.Vulnerability Detail
Lets look at the claimReward function,
Impact
The call re-entered before updating the state till the funds are drained out.
Code Snippet
https://github.com/sherlock-audit/2023-07-perennial/blob/main/perennial-v2/packages/perennial/contracts/Market.sol#L154C28-L162
Tool used
Manual Review
Recommendation
Use the reentrancy protection for
ClaimReward
function.