Platform: Code4rena
Start Date: 26/01/2024
Pot Size: $25,000 USDC
Total HM: 7
Participants: 5
Period: 7 days
Judge: hansfriese
Total Solo HM: 1
Id: 325
League: ETH
Rank: 1/5
Findings: 5
Award: $0.00
π Selected for report: 3
π Solo Findings: 0
π Selected for report: sashik_eth
Also found by: rvierdiiev, said
Data not available
Due to improper validation, anyone who has positions inside the MarginTradingHook
can update orders created by other users. This vulnerability can be exploited to modify order information, such as collateral amount, trigger/limit price, then execute fillOrder
, causing losses for the original creator
It can be observed that as long as users have position (initPosId
!= 0) and the order is active, they can provide any valid _orderId
an update the order value.
function updateOrder( uint _posId, uint _orderId, uint _triggerPrice_e36, address _tokenOut, uint _limitPrice_e36, uint _collAmt ) external { _require(_collAmt != 0, Errors.ZERO_VALUE); Order storage order = __orders[_orderId]; _require(order.status == OrderStatus.Active, Errors.INVALID_INPUT); uint initPosId = initPosIds[msg.sender][_posId]; _require(initPosId != 0, Errors.POSITION_NOT_FOUND); MarginPos memory marginPos = __marginPositions[initPosId]; uint collAmt = IPosManager(POS_MANAGER).getCollAmt(initPosId, marginPos.collPool); _require(_collAmt <= collAmt, Errors.INPUT_TOO_HIGH); order.triggerPrice_e36 = _triggerPrice_e36; order.limitPrice_e36 = _limitPrice_e36; order.collAmt = _collAmt; order.tokenOut = _tokenOut; emit UpdateOrder(initPosId, _orderId, _tokenOut, _triggerPrice_e36, _limitPrice_e36, _collAmt); }
As mentioned before, this can be maliciously updated with value that will cause loss for the order creator, and execute the order to steal token from the order.
manual review
Verify only order creator can update the order.
function updateOrder( uint _posId, uint _orderId, uint _triggerPrice_e36, address _tokenOut, uint _limitPrice_e36, uint _collAmt ) external { _require(_collAmt != 0, Errors.ZERO_VALUE); Order storage order = __orders[_orderId]; _require(order.status == OrderStatus.Active, Errors.INVALID_INPUT); uint initPosId = initPosIds[msg.sender][_posId]; _require(initPosId != 0, Errors.POSITION_NOT_FOUND); + _require(order.initPosId == initPosId, Errors.INVALID_INPUT); MarginPos memory marginPos = __marginPositions[initPosId]; uint collAmt = IPosManager(POS_MANAGER).getCollAmt(initPosId, marginPos.collPool); _require(_collAmt <= collAmt, Errors.INPUT_TOO_HIGH); order.triggerPrice_e36 = _triggerPrice_e36; order.limitPrice_e36 = _limitPrice_e36; order.collAmt = _collAmt; order.tokenOut = _tokenOut; emit UpdateOrder(initPosId, _orderId, _tokenOut, _triggerPrice_e36, _limitPrice_e36, _collAmt); }
Invalid Validation
#0 - JeffCX
2024-02-02T20:29:28Z
duplicate of #34
#1 - c4-judge
2024-02-03T14:22:48Z
hansfriese marked the issue as duplicate of #34
#2 - c4-sponsor
2024-02-06T10:41:51Z
fez-init (sponsor) confirmed
#3 - c4-judge
2024-02-06T15:07:26Z
hansfriese marked the issue as satisfactory
Data not available
When an order is created, it checks whether the order.tokenOut
is equal to marginPos.baseAsset
or _tokenOut
is equal to marginPos.quoteAsset
. However, when tokenOut
is updated via updateOrder
, it can be changed to an arbitrary token. This has the potential to be exploited by changing tokenOut
to a high-value token right before the executor executes the fillOrder
.
It can be observed that order's creator can update order.tokenOut
to arbitrary token.
function updateOrder( uint _posId, uint _orderId, uint _triggerPrice_e36, address _tokenOut, uint _limitPrice_e36, uint _collAmt ) external { _require(_collAmt != 0, Errors.ZERO_VALUE); Order storage order = __orders[_orderId]; _require(order.status == OrderStatus.Active, Errors.INVALID_INPUT); uint initPosId = initPosIds[msg.sender][_posId]; _require(initPosId != 0, Errors.POSITION_NOT_FOUND); MarginPos memory marginPos = __marginPositions[initPosId]; uint collAmt = IPosManager(POS_MANAGER).getCollAmt(initPosId, marginPos.collPool); _require(_collAmt <= collAmt, Errors.INPUT_TOO_HIGH); order.triggerPrice_e36 = _triggerPrice_e36; order.limitPrice_e36 = _limitPrice_e36; order.collAmt = _collAmt; order.tokenOut = _tokenOut; emit UpdateOrder(initPosId, _orderId, _tokenOut, _triggerPrice_e36, _limitPrice_e36, _collAmt); }
This can be exploited by the order's creator. When the executor is about to execute the fillOrder
, the order creator can front-run the operation by changing the order.tokenOut
to a token with a high value. Consequently, the executor will transfer the amount that needs to be transferred but using this new high-value token. But this require hook already have approval from the executor to manage the new high value token.
Manual review
Validate the new token when updateOrder
is called :
function updateOrder( uint _posId, uint _orderId, uint _triggerPrice_e36, address _tokenOut, uint _limitPrice_e36, uint _collAmt ) external { _require(_collAmt != 0, Errors.ZERO_VALUE); Order storage order = __orders[_orderId]; _require(order.status == OrderStatus.Active, Errors.INVALID_INPUT); uint initPosId = initPosIds[msg.sender][_posId]; _require(initPosId != 0, Errors.POSITION_NOT_FOUND); MarginPos memory marginPos = __marginPositions[initPosId]; uint collAmt = IPosManager(POS_MANAGER).getCollAmt(initPosId, marginPos.collPool); _require(_collAmt <= collAmt, Errors.INPUT_TOO_HIGH); + _require(_tokenOut == marginPos.baseAsset || _tokenOut == marginPos.quoteAsset, Errors.INVALID_INPUT); order.triggerPrice_e36 = _triggerPrice_e36; order.limitPrice_e36 = _limitPrice_e36; order.collAmt = _collAmt; order.tokenOut = _tokenOut; emit UpdateOrder(initPosId, _orderId, _tokenOut, _triggerPrice_e36, _limitPrice_e36, _collAmt); }
Invalid Validation
#0 - JeffCX
2024-02-02T20:16:19Z
This has the potential to be exploited by changing tokenOut to a high-value token right before the executor executes the fillOrder.
this is another way of saying frontrunning.
duplicate of #6
#1 - c4-judge
2024-02-04T04:27:53Z
hansfriese marked the issue as primary issue
#2 - c4-judge
2024-02-04T04:28:46Z
hansfriese marked the issue as satisfactory
#3 - c4-sponsor
2024-02-06T10:19:01Z
fez-init (sponsor) confirmed
#4 - JeffCX
2024-02-07T15:40:43Z
I think the severity should be high because it is very common the executor tries to fill a lot of order that have different token,
so it is common that executor approves the hook to spend multiple tokens.
order creator frontrun to update order to different token clearly violate order executor's expectation if order creator update a low value token with high value token before the order is filled.
#5 - c4-judge
2024-02-07T15:40:55Z
hansfriese changed the severity to 3 (High Risk)
#6 - hansfriese
2024-02-07T15:41:57Z
I left a comment here.
#7 - c4-judge
2024-02-07T15:42:41Z
hansfriese marked the issue as selected for report
π Selected for report: said
Also found by: ladboy233, rvierdiiev
Data not available
https://github.com/code-423n4/2024-01-init-capital-invitational/blob/main/contracts/hook/MarginTradingHook.sol#L539-L563 https://github.com/code-423n4/2024-01-init-capital-invitational/blob/main/contracts/hook/MarginTradingHook.sol#L387
limitPrice_e36
acts as slippage protection for the order creator, depending on the trade/order position (whether long or short). A higher or lower limit price impacts the tokenOut
amount that needs to be transferred to the order's creator. However, when the executor executes fillOrder
, it can be front-run by the order creator to update limitPrice_e36
and steal tokens from the executor.
When fillOrder
is executed, it will calculate the amtOut
that needs to be transferred to order.recipient
by calling _calculateFillOrderInfo
.
function _calculateFillOrderInfo(Order memory _order, MarginPos memory _marginPos, address _collToken) internal returns (uint amtOut, uint repayShares, uint repayAmt) { (repayShares, repayAmt) = _calculateRepaySize(_order, _marginPos); uint collTokenAmt = ILendingPool(_marginPos.collPool).toAmtCurrent(_order.collAmt); // NOTE: all roundings favor the order owner (amtOut) if (_collToken == _order.tokenOut) { if (_marginPos.isLongBaseAsset) { // long eth hold eth // (2 * 1500 - 1500) = 1500 / 1500 = 1 eth // ((c * limit - borrow) / limit >>> amtOut = collTokenAmt - repayAmt * ONE_E36 / _order.limitPrice_e36; } else { // short eth hold usdc // 2000 - 1 * 1500 = 500 usdc // (c - borrow * limit) >>> amtOut = collTokenAmt - (repayAmt * _order.limitPrice_e36 / ONE_E36); } } else { if (_marginPos.isLongBaseAsset) { // long eth hold usdc // (2 * 1500 - 1500) = 1500 usdc // ((c * limit - borrow) >>> amtOut = (collTokenAmt * _order.limitPrice_e36).ceilDiv(ONE_E36) - repayAmt; } else { // short eth hold eth // (3000 - 1 * 1500) / 1500 = 1 eth // (c - borrow * limit) / limit >>> amtOut = (collTokenAmt * ONE_E36).ceilDiv(_order.limitPrice_e36) - repayAmt; } } }
As can be observed, it heavily relies on limitPrice_e36
to calculate amtOut
. A malicious order creator can front-run the execution of fillOrder
and steal assets from the executor by changing limitPrice_e36
, resulting in a high value of amtOut
depending on the order's position.
Manual review.
Consider to add limitPrice_e36
check inside the fillOrder
if it bigger than min/max provided limit price, revert the operation.
Invalid Validation
#0 - c4-judge
2024-02-03T14:32:53Z
hansfriese marked the issue as satisfactory
#1 - c4-judge
2024-02-03T14:32:59Z
hansfriese marked the issue as primary issue
#2 - c4-sponsor
2024-02-06T08:46:37Z
fez-init (sponsor) confirmed
#3 - fez-init
2024-02-06T08:47:25Z
We will change the updateOrder
logic to cancel and create a new order instead of modifying the existing order instead.
#4 - c4-judge
2024-02-06T15:09:34Z
hansfriese marked the issue as selected for report
π Selected for report: said
Also found by: sashik_eth
Data not available
https://github.com/code-423n4/2024-01-init-capital-invitational/blob/main/contracts/hook/MarginTradingHook.sol#L373 https://github.com/code-423n4/2024-01-init-capital-invitational/blob/main/contracts/hook/MarginTradingHook.sol#L380
When fillOrder
is triggered, and the order's collateral size inside the position manager is empty, it should change the order status to canceled. However, due to mistakenly updating memory data instead of the storage __orders
data, it will not be canceled properly.
It can be observed that when collateral size is empty, fillOrder
will try to update order
memory data status
to OrderStatus.Cancelled
and return the call.
function fillOrder(uint _orderId) external { >>> Order memory order = __orders[_orderId]; _require(order.status == OrderStatus.Active, Errors.INVALID_INPUT); MarginPos memory marginPos = __marginPositions[order.initPosId]; address collToken = ILendingPool(marginPos.collPool).underlyingToken(); address borrToken = ILendingPool(marginPos.borrPool).underlyingToken(); // if position is empty, cancel order if (IPosManager(POS_MANAGER).getCollAmt(order.initPosId, marginPos.collPool) == 0) { >>> order.status = OrderStatus.Cancelled; emit CancelOrder(order.initPosId, _orderId); return; } // validate trigger price condition _validateTriggerPrice(order, marginPos); // calculate fill order info (uint amtOut, uint repayShares, uint repayAmt) = _calculateFillOrderInfo(order, marginPos, collToken); // transfer in repay tokens IERC20(borrToken).safeTransferFrom(msg.sender, address(this), repayAmt); // transfer order owner's desired tokens to the specified recipient IERC20(order.tokenOut).safeTransferFrom(msg.sender, order.recipient, amtOut); // repay tokens _ensureApprove(borrToken, repayAmt); IInitCore(CORE).repay(marginPos.borrPool, repayShares, order.initPosId); // decollateralize coll pool tokens to executor IInitCore(CORE).decollateralize(order.initPosId, marginPos.collPool, order.collAmt, msg.sender); // update order status __orders[_orderId].status = OrderStatus.Filled; emit FillOrder(order.initPosId, _orderId, order.tokenOut, amtOut); }
However, this will not change the storage value of the order
inside the __orders
data. This will mislead users into assuming that the order is canceled when, in fact, it is not.
Manual review.
Update __orders[_orderId].status
storage data instead :
function fillOrder(uint _orderId) external { Order memory order = __orders[_orderId]; _require(order.status == OrderStatus.Active, Errors.INVALID_INPUT); MarginPos memory marginPos = __marginPositions[order.initPosId]; address collToken = ILendingPool(marginPos.collPool).underlyingToken(); address borrToken = ILendingPool(marginPos.borrPool).underlyingToken(); // if position is empty, cancel order if (IPosManager(POS_MANAGER).getCollAmt(order.initPosId, marginPos.collPool) == 0) { - order.status = OrderStatus.Cancelled; + __orders[_orderId].status = OrderStatus.Cancelled; emit CancelOrder(order.initPosId, _orderId); return; } // validate trigger price condition _validateTriggerPrice(order, marginPos); // calculate fill order info (uint amtOut, uint repayShares, uint repayAmt) = _calculateFillOrderInfo(order, marginPos, collToken); // transfer in repay tokens IERC20(borrToken).safeTransferFrom(msg.sender, address(this), repayAmt); // transfer order owner's desired tokens to the specified recipient IERC20(order.tokenOut).safeTransferFrom(msg.sender, order.recipient, amtOut); // repay tokens _ensureApprove(borrToken, repayAmt); IInitCore(CORE).repay(marginPos.borrPool, repayShares, order.initPosId); // decollateralize coll pool tokens to executor IInitCore(CORE).decollateralize(order.initPosId, marginPos.collPool, order.collAmt, msg.sender); // update order status __orders[_orderId].status = OrderStatus.Filled; emit FillOrder(order.initPosId, _orderId, order.tokenOut, amtOut); }
Error
#0 - c4-judge
2024-02-04T03:01:05Z
hansfriese marked the issue as primary issue
#1 - c4-sponsor
2024-02-06T08:32:54Z
fez-init (sponsor) confirmed
#2 - c4-sponsor
2024-02-06T08:32:57Z
fez-init marked the issue as disagree with severity
#3 - fez-init
2024-02-06T08:34:26Z
This should be QA as there does not create impact.
#4 - hansfriese
2024-02-07T16:26:37Z
Downgrade to QA due to the low impact.
#5 - c4-judge
2024-02-07T16:27:03Z
hansfriese changed the severity to QA (Quality Assurance)
#6 - c4-judge
2024-02-08T12:38:41Z
hansfriese marked the issue as grade-b
#7 - said017
2024-02-09T06:39:02Z
Hi @hansfriese, I would argue that this should be at least Med. This will disrupt users expectations, users expect their order should be closed if it is executed while the collateral is empty. If users later increase the collateral again using marginPos.collPool
, the order may unexpectedly executed, this could lead to a loss of users funds because the order strategy data may not be currently adjusted to the current market conditions.
#8 - hansfriese
2024-02-13T05:10:40Z
After checking again, I think Medium is more appropriate as the order cancellation wouldn't work as intended.
#9 - c4-judge
2024-02-13T05:10:52Z
This previously downgraded issue has been upgraded by hansfriese
#10 - c4-judge
2024-02-13T05:11:21Z
hansfriese marked the issue as satisfactory
#11 - c4-judge
2024-02-13T05:11:23Z
hansfriese marked the issue as selected for report
π Selected for report: ladboy233
Also found by: rvierdiiev, said
Data not available
When users reduce their positions and provide collateral tokens as _param.tokenOut
, it will sets the swap type to SwapType.CloseExactOut
. It then checks whether the balance of the token out is equal to the provided swapInfo.amtOut
or the repay amount. However, this check is too strict and can be easily DoSed by transferring a dust amount of tokens to the hook.
It ca be observed that when _param.tokenOut
is collToken
, it will set swapType
with SwapType.CloseExactOut
. This will swap tokens collToken
to borrToken
with only token amount needed for repaying the debt.
function _reducePosInternal(ReducePosInternalParam memory _param) internal returns (uint amtOut, uint health_e18) { MarginPos memory marginPos = __marginPositions[_param.initPosId]; // check collAmt & repay shares _require( _param.collAmt <= IPosManager(POS_MANAGER).getCollAmt(_param.initPosId, marginPos.collPool), Errors.INPUT_TOO_HIGH ); _require( _param.repayShares <= IPosManager(POS_MANAGER).getPosDebtShares(_param.initPosId, marginPos.borrPool), Errors.INPUT_TOO_HIGH ); address collToken = ILendingPool(marginPos.collPool).underlyingToken(); address borrToken = ILendingPool(marginPos.borrPool).underlyingToken(); _require(_param.tokenOut == collToken || _param.tokenOut == borrToken, Errors.INVALID_INPUT); uint repayAmt = ILendingPool(marginPos.borrPool).debtShareToAmtCurrent(_param.repayShares); _ensureApprove(borrToken, repayAmt); // 1. decollateralize collateral tokens // 2. redeem underlying collateral tokens // 3. callback (perform swap from coll -> borr) // 4. repay borrow tokens bytes[] memory multicallData = new bytes[](4); multicallData[0] = abi.encodeWithSelector( IInitCore(CORE).decollateralize.selector, _param.initPosId, marginPos.collPool, _param.collAmt, marginPos.collPool ); multicallData[1] = abi.encodeWithSelector(IInitCore(CORE).burnTo.selector, marginPos.collPool, address(this)); { // if expect token out = borr token -> swap all (exact-in) // if expect token out = coll token -> swap enough to repay (exact-out) >>> SwapType swapType = _param.tokenOut == borrToken ? SwapType.CloseExactIn : SwapType.CloseExactOut; SwapInfo memory swapInfo = SwapInfo(_param.initPosId, swapType, collToken, borrToken, repayAmt, _param.data); multicallData[2] = abi.encodeWithSelector(IInitCore(CORE).callback.selector, address(this), 0, abi.encode(swapInfo)); } multicallData[3] = abi.encodeWithSelector( IInitCore(CORE).repay.selector, marginPos.borrPool, _param.repayShares, _param.initPosId ); // do multicall IMulticall(CORE).multicall(multicallData); amtOut = IERC20(_param.tokenOut).balanceOf(address(this)); // slippage control check _require(amtOut >= _param.minAmtOut, Errors.SLIPPAGE_CONTROL); // transfer tokens out _transmitTokenOut(_param.tokenOut, amtOut, _param.returnNative); // check slippage control on position's health health_e18 = _validateHealth(_param.initPosId, _param.minHealth_e18); emit ReducePos(_param.initPosId, _param.tokenOut, amtOut, _param.collAmt, repayAmt); }
However, when swap is performed inside coreCallback
, it will strictly check tokenOut
(borrToken
) balance inside the contract equal with swapInfo.amtOut
(repayAmt
).
function coreCallback(address _sender, bytes calldata _data) external payable returns (bytes memory result) { _require(msg.sender == CORE, Errors.NOT_INIT_CORE); _require(_sender == address(this), Errors.NOT_AUTHORIZED); SwapInfo memory swapInfo = abi.decode(_data, (SwapInfo)); MarginPos memory marginPos = __marginPositions[swapInfo.initPosId]; uint amtIn = IERC20(swapInfo.tokenIn).balanceOf(address(this)); IERC20(swapInfo.tokenIn).safeTransfer(swapHelper, amtIn); // transfer all token in to swap helper // swap helper swap token IBaseSwapHelper(swapHelper).swap(swapInfo); uint amtOut = IERC20(swapInfo.tokenOut).balanceOf(address(this)); if (swapInfo.swapType == SwapType.OpenExactIn) { // transfer to coll pool to mint IERC20(swapInfo.tokenOut).safeTransfer(marginPos.collPool, amtOut); emit SwapToIncreasePos(swapInfo.initPosId, swapInfo.tokenIn, swapInfo.tokenOut, amtIn, amtOut); } else { // transfer to borr pool to repay uint amtSwapped = amtIn; if (swapInfo.swapType == SwapType.CloseExactOut) { // slippage control to make sure that swap helper swap correctly >>> _require(IERC20(swapInfo.tokenOut).balanceOf(address(this)) == swapInfo.amtOut, Errors.SLIPPAGE_CONTROL); amtSwapped -= IERC20(swapInfo.tokenIn).balanceOf(address(this)); } emit SwapToReducePos(swapInfo.initPosId, swapInfo.tokenIn, swapInfo.tokenOut, amtSwapped, amtOut); } result = abi.encode(amtOut); }
This check is too strict and vulnerable to DoS attack. Attacker can send a dust amount of tokenOut
to the hook before the operation. As a result, reducePos
will fail when a user wants collateral tokens as _param.tokenOut
, preventing the users to close/reduce position.
Manual review.
Change the check to the following :
//... - _require(IERC20(swapInfo.tokenOut).balanceOf(address(this)) == swapInfo.amtOut, Errors.SLIPPAGE_CONTROL); + _require(IERC20(swapInfo.tokenOut).balanceOf(address(this)) >= swapInfo.amtOut, Errors.SLIPPAGE_CONTROL); //...
DoS
#0 - JeffCX
2024-02-02T20:23:38Z
duplicate of #16
#1 - c4-judge
2024-02-03T14:41:58Z
hansfriese changed the severity to 2 (Med Risk)
#2 - c4-judge
2024-02-03T14:42:04Z
hansfriese marked the issue as duplicate of #16
#3 - c4-judge
2024-02-06T15:18:32Z
hansfriese marked the issue as satisfactory