Normal view

There are new articles available, click to refresh the page.
Before yesterdayMain stream

Term Labs $8.5M Governance Takeover Exploit (Explained)

7 September 2026 at 09:49

On August 23, 2026, an attacker used roughly half an ETH to acquire majority governance control over Term Labs Meta Vaults, then passed a routine-looking proposal that disabled the vault’s transaction delay and drained six vaults. No key was stolen and no core vault code was broken: with almost no one else voting, the attacker simply became the governance, extracting 2,841.74 WETH and 1,679,639 USDC, about $8.5 million, later swapped to DAI.

Protocol Background

Term’s Strategy Vaults are ERC-4626 vaults built on Yearn V3 infrastructure, governed through Aragon TokenVoting. Voting power isn’t tied to vault deposits directly: to get it, a depositor has to wrap their vault shares into a separate governance token, an extra opt-in step almost nobody took. A Zodiac Delay module was meant to sit between an approved governance proposal and its execution, giving roughly a week’s cooldown before anything it authorized could actually run.

Hack Analysis

Term’s voting power came from wrapping vault shares into a separate governance token, and almost no one bothered. On the ETH Meta Vault the total wrapped supply was just 0.5352 tokens, across the USDC vaults it was similarly thin. A depositor putting in about 0.5 ETH and wrapping the resulting shares ended up holding 0.4852 of that ETH Meta Vault supply, about 90.7%, while a separate wallet held all of the active voting power across all seven USDC vault proposals it opened.

Because the minimum proposer voting power was set to zero, opening a proposal cost nothing beyond gas. The attacker filed a proposal titled Veto strategy vault parameter change, using the exact wording the curator used for routine parameter updates, so it read on the surface like an ordinary item up for a veto vote rather than an attack.

Underneath that title sat 17 actions. The first three reset the Zodiac Delay module’s roughly seven-day cooldown and expiration to zero and handed control of it to an attacker-controlled executor. The rest recalled capital from all four of the ETH Meta Vault’s real strategies, deployed a new strategy called Fixed Recipient WETH Exit Strategy, gave it a debt ceiling of uint256 max, and routed the vault's balance into it.

Six days later, with the voting window closed and almost nobody having voted against a majority the attacker already held, the proposal became executable. At about 06:25 UTC on August 23, the attacker called executeProposal(), recalling WETH from four strategies and pulling roughly 2,841.74 WETH out through the planted strategy contract.

Twenty-two minutes later, a second attacker wallet ran the identical playbook against five USDC vaults in a single transaction, where it held all of the voting power across every proposal it had opened on those vaults. That transaction drained approximately 1,679,639 USDC, which was later swapped into DAI.

Root Cause

This wasn’t a bug in Term’s core vault code. The root failure is that voting power depended on an opt-in wrapping step almost nobody took, so a deposit worth a few hundred dollars was enough to become the effective government of vaults holding millions, and that governance had the authority to disable its own safety delay.

The formal governance settings, a 50% support threshold, 5% minimum participation, and a roughly six-day voting window, weren’t reckless on their own, but they meant nothing once one wallet held almost all the active voting power. A zero minimum proposer-power requirement meant opening the proposal cost nothing, and the proposal’s own opening actions could reset the Zodiac Delay module’s cooldown and expiration to zero, removing the one control meant to slow exactly this kind of action before it executed.

Whether the delay module’s exposure to governance was an intentional design choice or a distinct authorization failure hasn’t been publicly explained.

How QuillAudits Governance Review Could Have Prevented This

Governance participation and concentration monitoring. A review should flag when a governance token’s actively-wrapped supply is thin enough that a small deposit can cross a majority threshold, and require a minimum active-participation floor before proposals gain force, not just a percentage-of-supply threshold.

Scope-limit what governance can touch. The Zodiac Delay module existed specifically to slow dangerous actions, but the same governance process could reset its own cooldown and expiration. A review would flag any proposal-executable action that can modify the safeguard meant to gate proposal-executable actions, and wall that off behind a separate, higher-friction control.

Title and content review for proposals, not just code review. A malicious proposal disguised as a routine curator veto item passed unnoticed for six days. Requiring a structured, machine-checkable diff of what a proposal actually changes, surfaced independently of its title, would have caught the delay-module reset regardless of what the proposal was called.

Funds Flow After Attack

2,841.74 WETH and 1,679,639 USDC(swapped to DAI) drained from the vaults converged at a single address, 0xD5183d8BfC65a50863C62aF2538198A8288FFc13.

Stolen USDC was swapped into DAI and then transfer to another address 0x9210130f81c84d028DB83701fF379A79c9365135, and then swapped to ETH and deposited into tornado cash.

Since then, major ETH didn’t moved from attacher wallet, 300 of it moved out of the consolidation address to 0xC14007663A5bb9F13d4d2AEE8c6FE9075eF1d83e, and deposited to tornado cash.

Post-Attack Mitigation

Term Labs posts its first public acknowledgment, confirming a governance exploit hit its vaults, without giving a loss figure or technical explanation.

Term Labs follows up, confirming all Term Meta Vaults have been shut down and their DAO governance roles revoked, an irreversible step that blocks new deposits while leaving withdrawals open.

Relevant Addresses and Transactions

Attacker Wallets / EOAs

Key Transactions

Conclusion

No key was stolen and no line of core vault code was broken. Almost nobody wrapped their shares into Term’s governance token, so a deposit worth a few hundred dollars was enough to become the majority, and that majority had the authority to disable the one mechanism built to slow it down. The vault executed exactly what its governance authorized, the governance itself was the vulnerability. A safeguard that governance can switch off isn’t a safeguard, it’s a formality waiting for someone to notice nobody’s watching.

Originally Posted at Quillaudits


Term Labs $8.5M Governance Takeover Exploit (Explained) was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

EIP-712 Explained: Sign and Verify Typed Data with ethers.js and Solidity

17 August 2026 at 12:43

Imagine your application wants a user to authorize an off-chain action:

Transfer 100 USDC
to 0x...
nonce 42
deadline ...

You could serialize that data into a string and ask the wallet to sign it. But then subtle questions appear: Which serialization is canonical? Is 100 a string or an integer? Which contract is allowed to consume the signature? Can the same signature work on another chain?

EIP-712 solves the encoding side of this problem by defining a deterministic way to hash and sign typed structured data. Instead of signing arbitrary JSON text, the wallet signs a digest derived from explicit Solidity-like types, the message, and an application-specific domain.

That makes EIP-712 particularly useful for permits, meta-transactions, order protocols, delegated actions, and other off-chain authorizations that are later verified on-chain.

Why signing plain strings is not enough

Ethereum wallets can sign arbitrary messages using mechanisms such as personal_sign. This works well when the thing being signed really is a human-readable message.

Structured application data is different.

Suppose two applications serialize this object differently:

{"to":"0x...","amount":"100"}

and:

{
"amount": "100",
"to": "0x..."
}

They may represent the same intent to a developer, but they are different byte strings.

Plain message signing also does not inherently describe Solidity types. A wallet sees bytes or text rather than an explicit structure such as address to, uint256 amount, and uint256 nonce.

EIP-712 defines a typed encoding and hashing scheme instead. Wallets can use that structure to present meaningful fields to the user rather than an opaque serialized blob.

EIP-712 Explained: Domain, Types, and Message

Consider a transfer authorization:

const domain = {
name: "ExampleApp",
version: "1",
chainId: 1,
verifyingContract: "0x1234567890123456789012345678901234567890",
};
const types = {
Transfer: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const value = {
to,
amount,
nonce,
deadline,
};

There are four important pieces.

Domain identifies the application context in which the signature is valid.

Types define the exact structure and Solidity-compatible types being signed.

Primary type is the root structure — Transfer in this example.

Message is the actual set of values.

The domain is what gives EIP-712 its domain separation. Two applications can sign structurally identical Transfer messages without necessarily producing interchangeable signatures.

For production authorizations, two domain fields are especially important:

chainId
verifyingContract

chainId binds the signature to a network, while verifyingContract binds it to a particular contract address. Both become part of the EIP-712 domain separator and therefore affect the final digest.

Without appropriate domain separation, a signature intended for one context may be meaningful in another.

How the EIP-712 Hash Is Built

EIP-712 does not sign your JavaScript object or its JSON serialization.

Conceptually, the final digest is:

keccak256(
0x1901 ||
domainSeparator ||
hashStruct(message)
)

The 0x1901 prefix comes from the signed-data encoding scheme used by EIP-712.

For our Transfer structure, its encoded type is effectively:

Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)

Hashing this definition produces the typeHash:

keccak256(
"Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)"
)

The message’s structHash is then calculated from the type hash and encoded field values.

Conceptually:

keccak256(
abi.encode(
TRANSFER_TYPEHASH,
to,
amount,
nonce,
deadline
)
)

The domain is hashed using the same EIP-712 struct-hashing rules to produce the domain separator. Finally, the message struct hash and domain separator are combined into one digest.

This digest — not JSON.stringify(value) — is what ultimately gets signed.

That distinction is the reason JavaScript and Solidity can independently reconstruct exactly the same value.

At this point, the full EIP-712 flow looks like this:

Signing EIP-712 Typed Data in JavaScript

With ethers v6, the high-level API is signer.signTypedData(domain, types, value). ethers handles the EIP-712 encoding and hashing internally.

A browser example can stay compact:

import { BrowserProvider } from "ethers";

const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const chainId = (await provider.getNetwork()).chainId;
const domain = {
name: "ExampleApp",
version: "1",
chainId,
verifyingContract: "0x1234567890123456789012345678901234567890",
};
const types = {
Transfer: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const value = {
to: "0x0000000000000000000000000000000000000000",
amount: 100_000_000n, // 100 USDC with 6 decimals
nonce: 42n,
deadline: BigInt(Math.floor(Date.now() / 1000) + 3600),
};
const signature = await signer.signTypedData(
domain,
types,
value
);

Notice that the frontend does not manually hash individual fields. That is intentional.

Unless you are implementing infrastructure specifically around EIP-712 encoding, use the library implementation instead of recreating the algorithm yourself.

The important part is that the frontend schema must exactly match the Solidity schema.

Verifying an EIP-712 Signature in Solidity

On-chain, OpenZeppelin’s EIP712 contract reconstructs the domain-aware digest, while ECDSA performs signer recovery. OpenZeppelin explicitly documents the _hashTypedDataV4(structHash) plus ECDSA.recover pattern.

Here is the Solidity side of the same example:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {EIP712} from
"@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {ECDSA} from
"@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
contract TransferAuthorizer is EIP712 {
struct Transfer {
address to;
uint256 amount;
uint256 nonce;
uint256 deadline;
}
bytes32 private constant TRANSFER_TYPEHASH =
keccak256(
"Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)"
);
mapping(address => mapping(uint256 => bool))
public usedNonces;
constructor() EIP712("ExampleApp", "1") {}
function authorizeTransfer(
address expectedSigner,
Transfer calldata transfer,
bytes calldata signature
) external {
require(
block.timestamp <= transfer.deadline,
"Authorization expired"
);
require(
!usedNonces[expectedSigner][transfer.nonce],
"Nonce already used"
);
bytes32 structHash = keccak256(
abi.encode(
TRANSFER_TYPEHASH,
transfer.to,
transfer.amount,
transfer.nonce,
transfer.deadline
)
);
bytes32 digest = _hashTypedDataV4(structHash);
address recoveredSigner =
ECDSA.recover(digest, signature);
require(
recoveredSigner == expectedSigner,
"Invalid signature"
);
usedNonces[expectedSigner][transfer.nonce] = true;
// Execute the authorized business operation here.
}
}

_hashTypedDataV4 combines the message struct hash with the contract's EIP-712 domain separator and produces the final digest expected by the signature verification logic.

Using OpenZeppelin is preferable to manually maintaining EIP-712 domain and ECDSA recovery code. Apart from reducing code, it avoids subtle mistakes around domain construction and signature handling.

One additional production nuance: ECDSA.recover verifies signatures from EOAs. If your application must support smart contract wallets, account abstraction, or multisigs, you should also account for ERC-1271-style contract signatures rather than assuming every signer has an ECDSA private key.

EIP-712 Does Not Prevent Replays for You

EIP-712 gives you deterministic structured signing and domain separation. It does not automatically make an authorization single-use.

Consider this perfectly valid signature:

Alice authorizes transfer X

If the contract accepts it today and nothing in contract state marks it as consumed, an attacker may simply submit the same authorization again.

That is why production messages commonly contain a nonce:

uint256 nonce;

and the contract consumes it:

mapping(address => mapping(uint256 => bool))
public usedNonces;

A deadline prevents an authorization from remaining usable indefinitely.

Meanwhile, chainId and verifyingContract provide domain-level protection against signatures being reused in different EIP-712 domains.

These controls solve related but different problems:

nonce              -> prevents repeated execution
deadline -> limits validity in time
chainId -> binds the domain to a chain
verifyingContract -> binds the domain to a contract

The core rule is simple:

A valid signature is not necessarily a valid authorization forever.

Signature verification proves who signed a digest. Your contract still decides whether that authorization is currently acceptable.

Common EIP-712 Mistakes in Production

Most EIP-712 bugs are not cryptography bugs. They are schema or authorization bugs.

1. Different field order between frontend and Solidity.
Transfer(address to,uint256 amount,...) must match exactly on both sides.

2. Type mismatches.
uint256, address, bytes32, and string are not interchangeable representations.

3. Different domain name or version.
ExampleApp version 1 and ExampleApp version 2 intentionally produce different domains.

4. Missing nonce.
A correctly signed authorization may be replayable if contract state does not consume something unique.

5. Missing deadline.
Without expiration, an unused signature may remain actionable much longer than intended.

6. Using abi.encodePacked for the struct hash.
The standard EIP-712 struct encoding corresponds to the ABI-encoded values; OpenZeppelin's documented pattern uses abi.encode.

7. Incorrect domain assumptions around deployments or proxies.
Your frontend must sign against the same effective EIP-712 domain the verification contract reconstructs. Contract addresses, chain changes, and domain-version changes should be treated as protocol changes, not frontend details.

Conclusion

EIP-712 turns Ethereum signatures from loosely defined byte-string signing into deterministic, typed, domain-separated authorization.

The flow is straightforward once the layers are separated:

JavaScript object

typed EIP-712 structure

domain separator + struct hash

EIP-712 digest

wallet signature

Solidity rebuilds digest

recover signer

apply nonce/deadline/business rules

The standard handles how structured data becomes a signable digest. Your application still owns what that signature authorizes, when it expires, and whether it has already been used.

That separation is the key to implementing EIP-712 correctly in production.


EIP-712 Explained: Sign and Verify Typed Data with ethers.js and Solidity was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

❌
❌