Learning Objectives
By the end of this module, you will:- Identify FHE-specific attack vectors and vulnerabilities
- Apply the select pattern to prevent information leakage
- Implement proper ACL management for all encrypted values
- Validate encrypted inputs and handle edge cases
- Implement the LastError pattern for secure user feedback
- Conduct a security audit on FHE contracts
FHE Security is Different
Security in traditional smart contracts focuses on reentrancy, integer overflow, access control, and front-running. The data itself is always public — anyone can read any storage slot. The security model is about protecting logic, not data. FHE contracts flip this entirely. The data is encrypted. Nobody can read storage slots and extract meaningful values. This is a massive privacy win, but it introduces a completely new attack surface. These metadata channels can reveal information about encrypted values. The core discipline of FHE security is ensuring that no metadata leaks correlate with encrypted state.Vulnerability 1: Information Leakage via Gas Consumption
The Problem
In traditional Solidity, branching on a condition is harmless because the condition is already public. In FHE contracts, branching on an encrypted condition creates a gas side channel. Consider this vulnerable pattern:- ~400k gas consumed → the sender had sufficient balance
- ~30k gas consumed → the sender had insufficient balance
The Fix: Uniform Execution with FHE.select()
FHE.select() is the FHE equivalent of a ternary operator, but it executes both paths and selects the result based on the encrypted condition. Gas consumption is identical regardless of which value is selected.
The Rule
Never branch (if/else, while, for with encrypted bounds) on an encrypted condition. Always use FHE.select().
Additional Gas Leak Vectors
Beyond if/else, watch for these subtle gas leaks:- Early returns:
if (condition) return;— the return vs. continuation has different gas - Dynamic loops:
for (uint i = 0; i < encryptedCount; i++)— loop count reveals the encrypted value - Storage writes: Conditional storage writes (writing to a slot vs. not) have different gas costs
- External calls: Calling another contract conditionally reveals the condition
Vulnerability 2: Missing ACL Permissions
The Problem
Every FHE operation produces a new ciphertext with a new handle. The new handle has an empty ACL — no address has access, not even the contract that created it. If you forget to set the ACL:- The contract cannot use the value in future transactions (operations on it will fail)
- The user cannot decrypt their own data (decryption requests are rejected)
The Fix: ACL After Every State Update
The ACL Checklist
After every FHE operation that writes to state, you must:Common ACL Mistakes
| Mistake | Consequence |
|---|---|
Forgot allowThis after FHE.add() | Contract cannot use the sum in the next tx |
Forgot allow(h, user) after transfer | Recipient cannot decrypt their new balance |
| Set ACL on old handle, not new handle | ACL is per-handle, not per-variable |
Used allow for temporary inter-contract call | Wasted gas; use allowTransient instead |
| Forgot to re-set ACL in every branch of select | Only one branch’s ACL is applied |
ACL After Select
A subtle case: when you useFHE.select(), the result is a new handle regardless of which branch was selected. You must set ACL on the result:
Vulnerability 3: Unvalidated Encrypted Inputs
The Problem
When users submit encrypted inputs to your contract, you must validate them before use. An unvalidated input could be a malformed ciphertext, an uninitialized handle, or a handle from a different contract’s ACL domain.The Fix: Always Validate
Validation Checklist
FHE.fromExternal(encValue, inputProof)— Always use this to convert external inputs. It validates the ZK proof that the ciphertext was correctly formed.FHE.isInitialized(handle)— Check that the handle is valid before use. This catches zero handles and uninitialized state.- Check state variables too — Before using a stored encrypted value, verify it has been initialized:
Vulnerability 4: Denial of Service via Unbounded FHE Operations
The Problem
FHE operations are computationally expensive. Each operation costs between 50,000 and 600,000 gas depending on the type and operand size. A function with unbounded FHE operations inside a loop is a DoS vector.The Fix: Bound All Loops and Rate-Limit
Additional DoS Mitigations
Rate limiting per user
Rate limiting per user
Require deposits for expensive operations
Require deposits for expensive operations
Charge a fee proportional to the FHE computation cost. This economically discourages DoS.
Pagination
Pagination
For operations over large datasets, process items in pages rather than all at once.
Vulnerability 5: Encrypted Error Handling
The Problem
In traditional Solidity, you communicate errors viarequire() and revert(). But in FHE contracts, reverting based on an encrypted condition leaks information.
The Fix: The LastError Pattern
Instead of reverting, always succeed and store an encrypted error code that only the user can decrypt.When Can You Still Revert?
You can safelyrequire() and revert() on plaintext conditions:
The rule is: revert on plaintext conditions, use LastError for encrypted conditions.
LastError Design Patterns
Define clear error codes as contract constants:Vulnerability 6: Front-Running and MEV
The Problem
Even though FHE encrypts the values, certain metadata is always visible on-chain:- Function selector — Everyone knows which function you called
- Recipient address — In a transfer,
tois usually a plaintext parameter - Transaction timing — When you submitted relative to other transactions
- Sender address — Always visible as
msg.sender
transfer(encryptedAmount, proof, bobAddress) even though the amount is encrypted. They know:
- Alice is sending tokens to Bob
- The transfer is happening right now
- If they can observe Alice’s balance changing (via ACL or public decryption), they learn the amount
Mitigations
Use encrypted recipients when possible
Use encrypted recipients when possible
Commit-reveal for time-sensitive operations
Commit-reveal for time-sensitive operations
Batch operations
Batch operations
Process multiple operations in a single transaction to reduce the information leaked per operation.
Avoid makePubliclyDecryptable on individual data
Avoid makePubliclyDecryptable on individual data
Only reveal aggregate values, not per-user data.
What FHE Does NOT Protect
| Visible to Everyone | Protected by FHE |
|---|---|
| Function selector (which function) | Encrypted parameter values |
| Sender address (msg.sender) | Internal encrypted state |
| Recipient address (if plaintext param) | Comparison results |
| Gas consumption | Encrypted error codes |
| Block number / timestamp | Encrypted intermediate computations |
| Transaction success / failure | Encrypted storage values |
Vulnerability 7: Misuse of makePubliclyDecryptable
The Problem
FHE.makePubliclyDecryptable() is a powerful but dangerous function. It makes an encrypted value decryptable by any address. This is irreversible — once called, the ciphertext’s plaintext value is effectively public.
Safe Uses
- Revealing aggregate values: total supply, vote tallies, auction results
- Revealing game outcomes after the game ends
- Revealing non-sensitive protocol parameters
Unsafe Uses
- Individual user balances
- Personal votes before tallying
- Private bids before auction closes
- Any per-user sensitive data
The Rule
Never call makePubliclyDecryptable on individual user data. Only use it for aggregate or non-sensitive values that are meant to become public.If you need to grant access to specific parties, use
FHE.allow() instead:
Security Audit Checklist for FHE Contracts
Use this checklist when reviewing any FHE contract:ACL Management
- Every FHE operation that writes to state is followed by
FHE.allowThis() - Every user-facing encrypted value has
FHE.allow(handle, user)set -
FHE.allowTransient()is used for inter-contract calls (notFHE.allow()) - View functions returning encrypted handles check
FHE.isSenderAllowed() - No overly permissive ACL (granting access to addresses that don’t need it)
Information Leakage
- No
if/elseorwhilebranching on encrypted conditions - All conditional logic uses
FHE.select() - No
require()orrevert()on encrypted conditions - No early returns based on encrypted state
- Gas consumption is uniform across all execution paths
Input Validation
- All external encrypted inputs validated with
FHE.fromExternal() -
FHE.isInitialized()checked afterfromExternal()and before using stored handles - No use of unvalidated handles in arithmetic operations
DoS Prevention
- All loops with FHE operations have bounded iteration counts
- Batch operations have maximum size limits
- Rate limiting on expensive FHE operations (per-user cooldowns)
- Gas costs estimated for worst-case loop execution
Error Handling
- LastError pattern used for encrypted conditions (no reverts)
- Error codes are encrypted and ACL-protected
- Plaintext
require()only for plaintext conditions (ownership, zero address, etc.)
Privacy
-
FHE.makePubliclyDecryptable()not used on individual user data - Only aggregate/non-sensitive values are made publicly decryptable
- Encrypted recipients considered where applicable
Access Control
- Admin functions protected by
onlyOwneror role-based modifiers - Ownership transfer function exists and is protected
- No public functions that modify critical encrypted state without authorization
- Custom errors used for clear failure reasons on plaintext conditions
The Seven Rules of FHE Security
Never branch on encrypted conditions
Use
FHE.select() for uniform execution.Always set ACL after every FHE state update
FHE.allowThis() + FHE.allow() on every new handle.Always validate encrypted inputs
FHE.fromExternal() + FHE.isInitialized() on every external input.Bound all FHE loops
Cap iterations, rate-limit, and require deposits for expensive operations.
Use LastError, not revert
Encrypted error codes preserve privacy for encrypted conditions.
Never reveal individual data
Only use
makePubliclyDecryptable() for aggregate values.Protect admin functions
Access control is still essential — encryption doesn’t replace authorization.
Mental Model
Think of your FHE contract as a black box. An observer can see:- What goes in (function call, sender, gas limit)
- What comes out (success/failure, gas used, events emitted)
- How long it takes (block inclusion timing)
Reference Contracts
- SecurityPatterns.sol (
contracts/16-security/SecurityPatterns.sol:1) — Reference implementation of all patterns - VulnerableDemo.sol (
contracts/16-security/VulnerableDemo.sol:1) — Educational contract showing common mistakes