ecrecover checks for Lixir Vault permit signatures

ecrecover checks for Lixir Vault permit signatures

The permit flow follows the usual EIP-2612 shape: build a digest from DOMAIN_SEPARATOR, owner, spender, value, nonce, and deadline, then recover a signer from v, r, and s. The failure sits in the final check. If validation accepts any nonzero ecrecover result and never requires recovered == owner, the permit no longer proves authorisation for the named owner.

That matters because the nonce still increments. An invalid signature can consume nonces[owner] while still writing an allowance for allowance[owner][spender]. At that point the contract has accepted a signature-shaped object, not proof of consent.

A broken branch like that is enough to turn permit into a write primitive for arbitrary approval state. In a vault clone, that is the whole game.

Reconstruct the permit flow from DOMAIN_SEPARATOR to nonce increment

The permit path starts by hashing the typed data against the vault’s domain separator. In a clone setup, that domain remains tied to the specific vault instance, so the digest changes by clone, owner, spender, value, nonce, and deadline. The intended outcome is simple enough: only the owner can authorise a spender, and the nonce stops replay.

The control that matters is the final equality check. If the code only rejects the zero address from ecrecover, then any signature that recovers to some nonzero address can pass. The caller can supply an owner field that does not match the recovered signer, and the contract still writes the allowance against that owner. The nonce then advances, which makes the state look like a normal permit path rather than a forged approval.

That is the awkward part. The storage mutation happens under the victim’s owner slot, while the signature came from somewhere else. The permit has stopped being a consent check and become a parser for arbitrary tuples.

Show how a nonzero ecrecover result still leaves the owner unverified

A nonzero recovered address proves only that the secp256k1 signature was not malformed. It does not prove that the signer controls the owner field in the permit. That distinction is the entire bug. A contract that checks require(recovered != address(0), 'INVALID_SIGNATURE') but skips recovered == owner accepts a forged approval so long as the tuple recovers to any live address.

In practice, that creates a false positive for every victim owner slot. The attacker does not need a valid signature from each owner, only a tuple that recovers cleanly. Once accepted, the allowance lands on the victim account and the delegated withdrawal path can spend it.

Reuse turns one forged tuple into repeatable vault access

One forged v, r, s tuple can be replayed across many different owners if the permit check ignores the owner match. The same tuple was reused 41 times across 35 owners and six lv_* vault clones. That is not a small slip. It is a reusable approval primitive with a very short loop.

The clone layout makes the pattern easier to miss. Each lv_* instance is a separate EIP-1167 proxy, but the permit bug repeats in the same way across them. If one clone accepts the bad branch, the attacker can walk through clone after clone without changing the shape of the exploit.

Track the same v, r, s across different owners and lv_* clones

The reused signature used v = 28, r = 0xe7c93726a865578504442b1a6827f676e0ed74bdff2be3960d1e253bbcfc4462, and s = 0x6aa772b878bc912bdbb33a0014ec507c4b3896ea85aa914b74dee9b7ac3e56da. The recovered signer changed, but it still stayed nonzero. Representative recovered addresses included 0xce81aa870fc7507997e0e88db075c90274e1c4cb and 0x91120446107ff069195ca494a1ef4774155ae6db, neither of which matched the victim owner fields used in the permits.

That is the useful failure mode for an attacker. The same signature tuple can be sprayed across owner addresses and clone contracts, with the contract itself doing the bookkeeping. The nonce increments, the allowance is written, and the transaction reads like a standard approval followed by a withdrawal.

Clone-by-clone verification matters here because proxy uniformity can hide repeated mistakes. A single checked implementation is not enough when multiple proxies carry the same logic into production.

Tie the approved allowance to withdrawETHFrom and withdrawFrom

The forged permit alone does not move funds. It writes an allowance that the delegated withdrawal path then consumes through withdrawETHFrom and withdrawFrom. In the observed sequence, there were 41 permit calls and 41 matching withdrawal calls, with withdrawETHFrom used 39 times and withdrawFrom used twice.

That split is practical, not decorative. Once the allowance exists, the attacker can choose the withdrawal function that matches the asset path for the target vault clone. The allowance does the heavy lifting. The withdrawal call just spends it.

Burned shares, collected assets, and the path out of the vault

The withdrawal chain runs through the position itself. The vault touches positions(bytes32), then burn(int24,int24,uint128), then collect(address,int24,int24,uint128,uint128), and finally WETH.withdraw(uint256) on the ETH path. That is the point where vault shares stop being an accounting entry and become underlying assets.

The attack used victim share balances and MAX_DEADLINE, which kept the forged approval reusable at the time of execution. Once the delegated call path was unlocked, the vault burned shares, collected the position’s assets, and released value through the normal Uniswap V3 and WETH flow. The chain is ordinary. The authorisation was not.

The reported extracted amounts were 4477.722585 USDC, 3609.950462 USDT, 2.588255642603989317 ETH, and 24182.560323103174179821 LIX, roughly $12.3K at the time. That kind of spread is exactly what makes a bad permit check annoying to triage. It does not fail loudly. It just starts moving balances.

The practical control set is short and unforgiving: require recovered == owner, reject signature reuse across owner slots, and verify each clone independently. If the recovered address is not the owner, the permit is dead.

Related posts

Grafana | v13.2.2

Grafana v13 2 2: security fixes CVEs 2026 15815, 76154, 79656, restores v2 query var refresh, fixes provisioning UID collision, release 2026 09 15, upgrade now

Authelia | v4.39.27

Authelia v43927: fixes consent redirect status, preserves query for Envoy authz, prevents duplicate OIDC consent, Docker images published

Immich | v3.2.2

Immich v3.2.2: fixes face reassign bug to skip faces belonging to other users, low risk patch published Sep 15 2026, upgrade recommended for multiuser setups