fhevm-security-audit
FHE Security Audit
Use this skill when reviewing FHEVM contract code for correctness and security issues. FHE contracts fail differently from plaintext Solidity. Many bugs are silent — no revert, no obvious error, just wrong encrypted state that nobody can detect until decryption.
When To Use
- Auditing a new FHEVM contract before deployment
- Reviewing a pull request that modifies encrypted state logic
- Investigating unexpected behavior in an existing FHEVM contract
- Verifying that ACL grants are complete across all code paths
- Checking that unwrap or decryption flows cannot be exploited
- Validating that events do not leak private information
Core Mental Model
An FHEVM audit traces handles, not values. You cannot inspect what an encrypted value contains. Instead, you trace the lifecycle of each handle: where it is created, what operations consume it, what new handles those operations produce, who gets ACL access, and where the handle is stored or discarded.
Every handle that exists without ACL access is a dead value. Every FHE.select with an
undocumented false branch is a latent bug. Every plaintext used after an encrypted check
without proof verification is a security hole.
Hard Constraints
require()cannot branch onebool. Any code that attempts this is broken.FHE.selectdoes not revert on the false branch. It silently returns the fallback (usually zero).- Encrypted division requires a plaintext divisor. Encrypted divisors revert or produce incorrect results.
- Every FHE operation returns a new handle. The old handles still exist but are not the result.
- Handles without ACL grants are inaccessible. They exist on the coprocessor but cannot be decrypted or used cross-transaction.
Audit Procedure: ACL Completeness
For every function that performs FHE operations:
- List every FHE operation call (
FHE.add,FHE.sub,FHE.mul,FHE.select, etc.) - For each, identify the returned handle
- Verify the handle receives appropriate ACL grants:
FHE.allowThis(handle)if the contract needs it in future callsFHE.allow(handle, address)for each party that must decrypt or reuse it
- Verify the handle is stored in contract state if it persists across transactions
// AUDIT: trace handles
euint64 newBalance = FHE.sub(balance, amount); // new handle created
FHE.allowThis(newBalance); // contract can reuse
FHE.allow(newBalance, account); // user can decrypt
balances[account] = newBalance; // stored in state
// PASS: handle is granted, stored, and accessible
For a balance-style handle (written to state, read back next transaction, decrypted by the owner), missing any of these grants is a bug. For handles that are write-only or never reused, scope each grant to the access that is actually needed. The severity depends on who loses access.
Audit Procedure: Silent Fallback Analysis
For every FHE.select call:
- Identify the condition (
ebool) - Identify the true-branch value and the false-branch value
- Document what happens downstream when the false branch executes
- Determine whether the silent zero (or other fallback) creates an exploitable state
ebool hasEnough = FHE.ge(balance, amount);
euint64 actual = FHE.select(hasEnough, amount, FHE.asEuint64(0));
euint64 newBalance = FHE.sub(balance, actual);
// False branch: actual = 0, newBalance = balance (unchanged)
// Question: does the caller know the transfer silently failed?
// Question: does any event or state change misleadingly suggest success?
If the false branch produces a misleading success signal, flag it.
Audit Procedure: Two-Step Unwrap Verification
Any flow that converts encrypted state to plaintext assets must use two steps.
Step 1 (onchain):
- User submits encrypted request
- Contract computes the encrypted releasable amount using
FHE.select - Contract stores the result handle and grants ACL for decryption
Off-chain:
- An off-chain caller requests public decryption through the relayer SDK
- The caller obtains plaintext plus cryptographic proof
Step 2 (onchain):
- User submits plaintext plus proof
- Contract verifies proof matches the stored encrypted result
- Only then does the contract release public assets
The critical check: does the contract verify the proof BEFORE using the plaintext? If it uses the user-claimed plaintext without proof verification, the user can claim any amount regardless of their actual encrypted balance.
Top 10 Common Footguns
The bugs below show up repeatedly in FHEVM code review. Scan every change against this list before approving.
| # | Footgun | Severity | Impact |
|---|---|---|---|
| 1 | Single-step unwrap (no proof verification) | Critical | User can claim any amount, drains funds |
| 2 | Trust user-supplied plaintext after encrypted check | Critical | Bypasses FHE.select protection entirely |
| 3 | Emit decrypted plaintext in events | Critical | Permanently publishes private data onchain |
| 4 | Missing or bypassed input-proof binding | Critical | Attacker submits arbitrary ciphertext or wrong-context input |
| 5 | Missing FHE.allow(handle, user) after balance update |
High | User's previous ACL pointed at a stale handle; decrypt silently fails |
| 6 | Missing FHE.allowThis on stored handles |
High | Contract cannot reuse its own computed values in later transactions |
| 7 | Encrypted divisor in FHE.div / FHE.rem |
High | Reverts or produces incorrect results — not supported |
| 8 | require() on ebool |
High | Will not compile, or caller bypasses the intended check |
| 9 | Undocumented FHE.select false branch creates misleading success |
High | Silent zero looks like a successful operation to downstream code and UI |
| 10 | Leak balance through request amount on failed unwrap | Medium-High | Public request + zero result implies balance < request, leaks a bound |
When a finding maps to one of these, cite the row by number in the audit report — it gives reviewers a shared vocabulary and makes repeated patterns obvious across audits.
Audit Procedure: Input Validation
For every function that accepts externalEuint64 plus inputProof:
- Verify the proof is validated against
msg.sender(the submitter) - Verify the proof is validated against the contract address
- Check that the encrypted input is not reusable across contracts or users
If inputProof validation is missing, an attacker can submit arbitrary ciphertext.
Audit Procedure: Event Privacy
For every event emission: verify no plaintext values derived from encrypted state appear
in event parameters. Emitting handles as uint256 is safe (opaque). Emitting decrypted
amounts, balances, or comparison results permanently leaks private data onchain.
Audit Procedure: Arithmetic Overflow
For every multiplication chain: calculate maximum operand values, verify the product fits
euint64 (2^64 - 1) or that euint128 is used, and confirm the contract intentionally
handles wraparound if it relies on raw FHE arithmetic.
Code Review Red Flags
Quick-scan patterns that should trigger deeper investigation:
| Red Flag | What to Investigate |
|---|---|
require near any ebool or euint* variable |
Likely attempting to branch on encrypted value |
FHE.div with a variable (not constant) second argument |
May be encrypted divisor |
State assignment without nearby FHE.allowThis |
Missing ACL on stored handle |
| Single function that accepts plaintext and releases assets | Possible single-step unwrap vulnerability |
Event with uint256 parameter in encrypted context |
May be emitting plaintext from encrypted state |
FHE.select without NatSpec comment |
Undocumented silent fallback path |
FHE.mul of two non-scalar operands without euint128 |
Potential overflow |
No FHE.allow(handle, msg.sender) after balance update |
User cannot decrypt their own balance |
Finalize function that trusts msg.sender without binding pending state |
Public-decryption follow-up tx can be submitted by any caller or proxy unless the contract ties state to the intended beneficiary |
Handle passed to external contract without FHE.allow |
Cross-contract handle will be inaccessible |
Anti-Patterns
Anti-Pattern 1: Trust User-Supplied Plaintext After Encrypted Check
The contract runs FHE.select to validate, then uses a separate user-supplied plaintext
for the actual operation. This bypasses the encrypted check entirely.
Anti-Pattern 2: Grant ACL Only to Contract, Not to User
FHE.allowThis without FHE.allow(handle, user). The user cannot decrypt their own
balance. Computed correctly but invisible.
Anti-Pattern 3: Emit Decrypted Values in Events
Logging plaintext from async public-decryption finalization permanently publishes private data onchain.
Anti-Pattern 4: Reuse Input Proof Across Contracts Or Users
Each inputProof is bound to a specific msg.sender and contract address. Reusing it under a
different user or contract context breaks the intended flow. Reviewers should verify the code
relies on FHE.fromExternal rather than trying to bypass proof binding.
Review Checklist
- Every FHE operation's returned handle has appropriate ACL grants
- Every
FHE.selecthas documented false-branch behavior - No
require()is called oneboolvalues - Every unwrap/decryption flow is two-step with proof verification before asset release
- Every
FHE.div/FHE.remuses a plaintext divisor - Every
inputProofis validated againstmsg.senderand contract address - No event emits plaintext values derived from encrypted state
- Overflow bounds are documented for multiplication chains
-
FHE.allowThisis called for handles the contract reuses across transactions - No user-supplied plaintext is trusted without proof verification
Output Expectations
When applying this skill, produce an audit report structured as:
- handle lifecycle trace: creation, ACL, storage, consumption
- silent fallback inventory: every
FHE.selectand its false-branch consequence - trust boundary map: where plaintext enters, where proof verification happens
- findings: severity (Critical / High / Medium / Low), location, description, fix
Related Skills
skills/fhevm-acl-lifecycle/SKILL.md— ACL completeness is the most common audit findingskills/fhevm-control-flow/SKILL.md— silent fallback analysisskills/fhevm-public-decryption/SKILL.md— two-step unwrap verificationskills/fhevm-privacy-constraints/SKILL.md— whether the promised privacy is actually delivered
More from z-korp/fhevm-cookbook
fhevm-router
Routes Zama FHEVM tasks to the right official docs path and next step
10fhevm-testing
Use when writing, structuring, or debugging tests for FHEVM contracts. Covers mocked mode vs real protocol, Hardhat decrypt helpers, input encryption in tests, and the false-confidence gap between local and testnet behavior.
10fhevm-acl-lifecycle
Use when granting, auditing, or debugging ACL permissions on encrypted handles in FHEVM. Covers FHE.allow, FHE.allowThis, FHE.allowTransient, and the critical rule that new handles do not inherit prior persistent ACL grants.
10fhevm-control-flow
Use when replacing if/else, require, or any conditional logic that depends on encrypted values in FHEVM. Covers FHE.select as the inline branching primitive, fallback semantics on encrypted conditions, and async public decryption when logic must branch back to plaintext state.
10oz-utils-safemath
Use when you need overflow-safe encrypted arithmetic on euint64 values. Covers the OpenZeppelin FHESafeMath library (tryIncrease, tryDecrease, tryAdd, trySub), uninitialized-handle semantics, and when to prefer it over raw FHE.add / FHE.sub.
10fhevm-public-decryption
Use when implementing two-step public decryption for state-changing operations in FHEVM. Covers makePubliclyDecryptable, off-chain proof retrieval, onchain verification with checkSignatures, and the critical single-step unwrap bug.
10