Damp Fuchsia Buffalo
High
Malicious user can grief legitimate users by permanently locking their pre-approved ERC20 funds in the StopLimit contract
There're multiple problems with the code from which the vulnerability eventually arised:
- No access control on the
createOrder
method inStopLimit
--- whereas there should be either a check thatmsg.sender ==
(the specified)recipient
, OR that themsg.sender
is charged withtransferFrom
specifically (not the specifiedrecipient
). AutomationMaster::generateOrderId
is assumed to be a function that generates unique order IDs. However, it solely derives the order ID based on themsg.sender
and theblock.timestamp
, without even encoding any salt or nonce value along with it. For any consecutive transaction made within the block N by the EOA or smart contract "0xabc", the order ID will always be the same.StopLimit::createOrder
passes themsg.sender
as the "sender
" to theAutomationMaster
'sgenerateOrderId
function. This means that a given caller will always produce the same order ID, even with differentrecipient
s specified, as long as hecreate
sOrder
within a singleblock
, so that theblock.timestamp
remains the same.
function _createOrder(
uint256 stopLimitPrice,
uint256 takeProfit,
uint256 stopPrice,
uint256 amountIn,
IERC20 tokenIn,
IERC20 tokenOut,
address recipient,
uint16 feeBips,
uint16 takeProfitSlippage,
uint16 stopSlippage,
uint16 swapSlippage,
bool swapOnFill
) internal {
//verify both oracles exist, as we need both to calc the exchange rate
require(
address(MASTER.oracles(tokenIn)) != address(0x0) &&
address(MASTER.oracles(tokenOut)) != address(0x0),
"Oracle !exist"
);
require(
pendingOrderIds.length < MASTER.maxPendingOrders(),
"Max Order Count Reached"
);
require(
takeProfitSlippage <= 10000 &&
stopSlippage <= 10000 &&
swapSlippage <= 10000 &&
feeBips <= 10000,
"BIPS > 10k"
);
MASTER.checkMinOrderSize(tokenIn, amountIn);
uint96 orderId = MASTER.generateOrderId(msg.sender);
orders[orderId] = Order({
orderId: orderId,
stopLimitPrice: stopLimitPrice,
stopPrice: stopPrice,
takeProfit: takeProfit,
amountIn: amountIn,
tokenIn: tokenIn,
tokenOut: tokenOut,
takeProfitSlippage: takeProfitSlippage,
feeBips: feeBips,
stopSlippage: stopSlippage,
swapSlippage: swapSlippage,
recipient: recipient,
direction: MASTER.getExchangeRate(tokenIn, tokenOut) >
stopLimitPrice, //compare to stop price for this order's direction
swapOnFill: swapOnFill
});
pendingOrderIds.push(uint96(orderId));
//emit
emit OrderCreated(orderId);
}
Moreover, the approach to store orders is pathological too.
The orders[orderId]
mapping entry is never cleared even after cancelOrder
is called.
cancelOrder
will only remove 1 item from the pendingOrderIds
array: specifically, the 1st found item in that array, the orderId
of which is the target one (orderId
):
function _cancelOrder(uint96 orderId) internal returns (bool) {
Order memory order = orders[orderId];
for (uint96 i = 0; i < pendingOrderIds.length; i++) {
if (pendingOrderIds[i] == orderId) {
//remove from pending array
pendingOrderIds = ArrayMutation.removeFromArray(
i,
pendingOrderIds
);
order.tokenIn.safeTransfer(order.recipient, order.amountIn);
//emit event
emit OrderCancelled(orderId);
//short circuit loop
return true;
}
}
return false;
}
As you can see, generateOrderId
is implemented incorrectly, and it will return colliding order IDs very often!
///@notice generate a random and unique order id
function generateOrderId(address sender) external view override returns (uint96) {
uint256 hashedValue = uint256(
keccak256(abi.encodePacked(sender, block.timestamp))
);
return uint96(hashedValue);
}
The users are expected to grant StopLimit
ERC20 approvals prior to calling createOrder
.
Some users may even grant "infinite" approvals <--- this is not an explicit prerequisite for this attack though! Any approval is fine, as long as it is just there.
No response
- Alice, Stella and Marie have granted ERC20 approvals to the
StopLimit
contract --- they all want to initialize orders and use Oku. - Malicious "Ninja" detects that the
StopLimit
contract has unconsumed ERC20 approvals. - First, Ninja calls
createOrder
, specifying therecipient
as Alice, and theamountIn
as the whole amount that Alice has pre-approved the contract for. This generates an order ID asuint96(uint256(keccak256(abi.encodePacked(sender, block.timestamp))))
. Thesender
here is the Ninja's own address. (Ninja will probably be a smart contract!). The funds are transferred from Alice to theStopLimit
contract, as she gave prior approval to theStopLimit
contract before. A new item is pushed to the array, consisting solely of the generatedorderId
:pendingOrderIds.push(uint96(orderId));
. - Notice this is all done in a batch transaction manner, i.e. all within the same block.
- Ninja then calls
createOrder
within the very same block (probably in scheduling transactions in a batch manner, atomically); he specifies therecipient
as Stella, and drains the whole pre-approvedtokenIn
amountIn
that Stella has granted to theStopLimit
contract. The order for therecipient
=Stella
is recorded based on the sameorderID
(due to the flawed logic and implementation inStopLimit::generateOrderId
). A new item with the sameorderId
is pushed to the array:pendingOrderIds.push(uint96(orderId));
. - Next, Ninja proceeds with griefing Marie by creating an order via
StopLimit::createOrder
, specifying therecipient=Marie
, theamountIn
as the maximum amount oftokenIn
that Marie has approved to theStopLimit
contract (when she wanted to legitimately create an order!). Theorders[orderId]
entry is overriden. Now, due to a colliding, pseudo-unique (quasi-unique!!) order ID, theorders[orderId]
item neither points to Alice's nor Stella's created and prefunded orders --- it points to the Marie's order only! A new item is pushed to thependingOrderIds
array, though its value is still the same, oldorderId
that was generated the same way as Alice's and Stella's order IDs were, when malicious Ninja approached this attack. - Finally, Ninja calls
StopLimit::createOrder
; and he creates an order withrecipient=Ninja
andtokenIn
andamountIn
values of his choice.
The Alice's, Stella's and Marie's funded orders will now be forever lost.
Moreover, Ninja not only griefed these victims through this attack, but he can also get real monetary profit from this now.
As there're 4 items in pendingOrderIds
that point to the very same, identical orderId
, Ninja can call StopLimit::cancelOrder
4 times, and each time he will get refunds for the 4th order he created.
The check msg.sender == order.recipient
will be 100% fulfilled, because at the end orders[orderId]
was overriden with an order, the params of which were of Ninja's choice and the recipient was he himself (recipient=Ninja
).
He will get 4x the refund of the 4rth order amountIn
!
///@notice only the order recipient can cancel their order
///@notice only pending orders can be cancelled
function cancelOrder(uint96 orderId) external {
Order memory order = orders[orderId];
require(msg.sender == order.recipient, "Only Order Owner");
require(_cancelOrder(orderId), "Order not active");
}
The ArrayMutation.removeFromArray
function will go through smoothly, as it can find 4 items where indeed orderId
will be the "target" orderId
that Ninja specified --- they're all identical anyway!
function _cancelOrder(uint96 orderId) internal returns (bool) {
Order memory order = orders[orderId];
for (uint96 i = 0; i < pendingOrderIds.length; i++) {
if (pendingOrderIds[i] == orderId) {
//remove from pending array
pendingOrderIds = ArrayMutation.removeFromArray(
i,
pendingOrderIds
);
order.tokenIn.safeTransfer(order.recipient, order.amountIn);
//emit event
emit OrderCancelled(orderId);
//short circuit loop
return true;
}
}
return false;
}
library ArrayMutation {
///@param idx the element to remove from @param inputArray
function removeFromArray(
uint96 idx,
uint96[] memory inputArray
) internal pure returns (uint96[] memory newList) {
// Check that inputArray is not empty and idx is valid
require(inputArray.length > 0, "inputArray length == 0");
require(idx < inputArray.length, "index out of bounds");
// Create a new array of the appropriate size
newList = new uint96[](inputArray.length - 1);
// Copy elements before the index
for (uint96 i = 0; i < idx; i++) {
newList[i] = inputArray[i];
}
// Copy elements after the index
for (uint96 i = idx + 1; i < inputArray.length; i++) {
newList[i - 1] = inputArray[i];
}
return newList;
}
- Users can be griefed, with no limitations at all! Even any
PERMIT2
approvals granted for theStopLimit
contract can be stolen.
Please see the references above, and follow the step-by-step attack vector as explained in the Attack Path section.
- Generate unique
orderId
s, by fixing theAutomationMaster::generateOrderId
function to include nonces or salt values. - Make sure that user's approvals to
StopLimit
are not getting back-runned by malicious users. Even when the 1st issue is fixed, malicious users can still create non-benefiting, losing orders for approvers. - Moreover,
createOrder
should passrecipient
, not themsg.sender
toAutomationMaster
'sgenerareOrderId
in reality.