Blockchain

Mezo: a verified signature, a mistaken identity

Mezo's veBTC holders lost 37 positions worth about 5.52 BTC on October 5. The attacker signed its own request but was treated as the asset owner inside an external library. The project has since disabled this relaying path through an executed upgrade.

Warm-paper conceptual illustration of a verified call passing into a contract module while its identity attachment is left outside and an ordinary insert is mistaken for identity.
In this article

At 18:54:52 UTC on October 5, veBTC NFT 826 changed hands on Mezo. Its previous holder was 0xb11c…5091, and the recipient was 0x1833…0491. Sixty-two seconds later, the recipient called withdraw(826). The NFT was burned and its approximately 3.3667 BTC was paid into the recipient's wallet. Both transactions succeeded. The first had even passed through a forwarder specifically designed to verify signatures.

The forwarder had verified the recipient's own request. Inside the NFT library, the request's sender was mistaken for the previous holder. Identity changed midway through execution, so an ownership check could run normally and still authorize the transfer. A relaying feature intended to make transactions convenient had become a route to somebody else's locked assets.

1 An NFT carries the right to withdraw BTC

Mezo is a blockchain supporting EVM contracts. Its veBTC locking system accepts BTC and gives the depositor an NFT representing the position. That position also carries voting power determined partly by lock duration. Once the lock expires, an authorized holder can withdraw the principal and the NFT is burned. Expiry alone sends nothing back to the wallet. An expired position awaiting redemption still represents assets held by the contract.

The history of NFT 826 records both steps. The first transaction emitted an ERC-721 Transfer from the original holder to the attack address. The second transaction emitted Withdraw with the raw quantity 3366676916317053701. At 18 decimals, this is 3.366676916317053701 BTC, matching the position amount reported to nine decimal places in Mezo's affected-holder table.

The withdrawal code retains several checks. In the old implementation's contracts/ve/Escrow.sol, withdraw():679–718, it checks the caller's ownership or approval, position type, voting status and expiry. It then burns the NFT, clears the lock and transfers the asset. Having first acquired the NFT, the attacker could pass the ownership check on a direct withdrawal. The expiry check explains the choice of positions that could be cashed out immediately.

2 How the recipient learns who signed

Relayed transactions solve a practical problem. A user signs an operation off-chain, and someone else pays to submit it. The target contract sees the forwarder as its immediate caller, while the user who signed is represented in the request. Under ERC-2771, a trusted forwarder verifies the request and appends the user's 20-byte address to the call data. The recipient checks that the caller is trusted before reading that suffix.

Mezo's actual forwarder is 0xE6F9…7085. Its verified contracts/forwarder/MezoForwarder.sol has execute():71–104 check the signature and nonce before appending the address with abi.encodePacked(req.data, req.from). In _verifySig():171–195, the recovered signing address is compared with req.from. The nonce is a sender-specific request counter that prevents the same request from being executed again.

For NFT 826's transfer, req.from was the attack address 0x1833…0491, and req.to was the veBTC proxy. A proxy provides a stable entry address and delegates execution to an upgradeable implementation. Its verified @openzeppelin/contracts/proxy/Proxy.sol:22–31 copies the complete call data before delegating, preserving the forwarder's appended suffix at this step. The identity delivered to veBTC was the attacker's. A valid signature establishes that this address authorized its request. The veBTC contract still has to decide whether that address may move somebody else's NFT.

The old contracts/ve/VeERC2771Context.sol:20–32 extracts that identity. After checking the trusted forwarder and data length, it reads an address from the end of the current call data. For a direct call, it returns the original msg.sender. The phrase “current call data” becomes important at the next step, when it refers to a different byte sequence.

// Sender resolution in the old implementation
if (msg.data.length >= 20 && isTrustedForwarder(self, msg.sender)) {
    assembly {
        sender := shr(96, calldataload(sub(calldatasize(), 20)))
    }
} else {
    return msg.sender;
}

3 The library receives new arguments without the identity suffix

veBTC divides its logic among external Solidity libraries, reducing the code that must fit into one deployed contract. NFT transfers go to the NFT library. The old contracts/VotingEscrow.sol:218–224 accepts safeTransferFrom(from, to, tokenId, data) and calls self.safeTransferFrom(...). The library function is declared external. The compiler constructs its argument buffer and executes a DELEGATECALL.

Under Solidity's external-library rules, this preserves the caller's storage context, msg.sender and msg.value. However, the new frame's msg.data contains the arguments encoded for the library function. The extra 20 bytes appended by the forwarder have no corresponding declared parameter, so the compiler does not pass them as an argument. The library still sees the trusted forwarder in msg.sender, but the end of its data now belongs to the newly encoded parameters.

The outer call ends with the verified sender. The NFT library receives re-encoded arguments, retains the trusted forwarder as caller and instead finds user-controlled data at the end.
Figure 1. The trusted caller survives while the call data changes. Reading an address from the end now reads a different object. View full size.

The data argument to safeTransferFrom is a user-supplied byte array normally used to pass information to an NFT-receiving contract. The actual transaction for NFT 826 supplied 32 bytes: twelve leading zero bytes followed by the previous holder's 20-byte address. This fills an ABI encoding word, leaving the complete address in the library call's final 20 bytes. Supplying only 20 bytes would introduce trailing padding that changes what the extraction reads. Inside the library frame, the old _msgSender() therefore obtained the previous holder. The attacker's verified address, appended earlier by the forwarder, was absent from this library call's data.

The forwarder established the signing identity in the outer request. The library's extraction depended on that outer data layout surviving. Delegation preserved msg.sender, while the compiler's newly constructed library arguments replaced msg.data. The proxy had copied the original data unchanged. At the library boundary, the caller was still the trusted forwarder, but the trailing address now came from a user argument. Their original relationship was gone.

The wrong address then changes asset ownership. In contracts/ve/NFT.sol:50–58, 88–113 and 185–195, safeTransferFrom() passes the resolved sender to _transferFrom(). That function checks _isApprovedOrOwner(), removes the NFT from its previous holder's list and adds it to the recipient's. The ownership check knows the correct owner. The address supplied for comparison has already been substituted.

A neighboring entry point makes the distinction concrete. The same old VotingEscrow.sol:200–205 resolves self._msgSender() inside transferFrom() and passes that result explicitly as _sender to the internal transfer function. The affected safe-transfer entry point postpones sender resolution until inside the external library. The word safe primarily refers to checking whether a receiving contract can accept the NFT. It supplies no automatic identity propagation across execution frames.

4 Disable relaying and keep ordinary ownership checks

The attacker had bridged the proceeds out of Mezo at 19:09 UTC on October 5. The official timeline places detection at 08:45 the next day, a bridge-withdrawal pause at 09:17 and a chain-transaction halt at 09:27, with service restored after the repair. The bridge-pause transaction succeeded at 09:17:34. These emergency controls addressed further exploitation. Funds that had left the previous evening still required separate recovery or reimbursement.

The permanent repair disables meta-transactions, the off-chain-signature relaying mechanism described above. The new contracts/ve/VotingEscrowState.sol:275–283 reduces _msgSender() to returning msg.sender. NFT, lock and approval libraries use this implementation and stop extracting identity from trailing arguments. A call from the old forwarder is now checked under the forwarder's own address. User-controlled arguments cannot turn that caller into the NFT owner.

// Sender resolution in the fixed implementation
function _msgSender(Storage storage) internal view returns (address) {
    return msg.sender;
}

The repair also addresses the deployed proxy's old configuration. The fixed implementation's contracts/VotingEscrow.sol:84–94 and contracts/VeBTC.sol:21–26 expose initializeV2(), which sets the stored trustedForwarder to zero. Retaining the field preserves the proxy's storage layout. Clearing its value also prevents reward contracts created for new managed positions from inheriting the old forwarder setting.

The upgrade was executed. The upgrade transaction for proxy 0x3D4b…f279 succeeded in block 12330216 at 15:01:23 UTC on October 6. The same proxy emitted Upgraded pointing to implementation 0x41d0…5aE3, followed by Initialized(2), consistent with initializeV2() in the batch call. The previous implementation was 0x2a05…e65c. Both implementations still return 1.0.0 from version(), so that string alone hides the meaningful repair distinction. Rolling back to the old implementation would also restore its trailing-data identity logic, making it an unsafe recovery target.

There is a functional cost. Integrations depending on the old relaying route must use direct user calls so the permission check sees the user's own address. Mezo's incident report says the feature had no production use at the time. Disabling an idle route was consequently a direct option. A project actively offering relaying would need to resolve identity at the outer boundary, pass it explicitly through subsequent logic and reestablish authorization tests for every entry point.

5 Follow the test through the ownership change

If unit tests cover only an owner's direct NFT transfer, msg.sender is already correct and the identity error stays hidden. An invalid signature is rejected at the forwarder before the library's identity extraction runs. Repair acceptance needs the path between those cases: a valid signature belongs to an ordinary address, the NFT belongs to somebody else, and the call must still be rejected after reaching the real external library.

A regression test needs that request to reach the library's authorization decision, then verify that both NFT ownership and the locked balance remain unchanged. A direct call from the owner supplies a positive control for normal transfers and expired-lock withdrawals. With a contract recipient, the test should also inspect the operator and previous-holder values delivered to onERC721Received, catching identity errors propagated downstream. A top-level transaction-success check covers neither asset state nor callback arguments.

Entry-point differences deserve their own controls. Safe transfers with and without data, ordinary transferFrom(), approvals, withdrawals and managed-position operations each resolve identity somewhere in their call path. Passing one route does not exercise the others. Changes in function visibility or a refactor from internal code to an external library should reopen that review because they change execution frames and argument encoding.

6 A repaired contract still leaves balances to reconcile

Mezo reports 36 holders, 37 positions and 5.519271401 BTC in total. Its October 7 announcement committed protocol-owned funds to reimbursement, with BTC sent directly to affected wallets and no separate claim site.

That announcement contains no completed reimbursement reconciliation covering all 36 wallets. Affected holders can check whether their wallets received the corresponding amount of BTC. A page demanding a wallet connection or a claim signature is outside this announced process.