The ERC-2535 Diamond standard is one of the most architecturally ambitious upgradeable contract patterns in the Solidity ecosystem. Where UUPS and Transparent proxies give you a single upgradeable logic contract, Diamond gives you an unbounded set of logic modules — called facets — each owning a slice of the function selector space. A single proxy contract can grow to encompass dozens of facets, each independently replaceable.
That power is real. So is the attack surface it creates.
A standard UUPS proxy has one upgrade path to protect. A Diamond has diamondCut, which can add, replace, or remove any facet and any function in the system, in a single call. Selector conflicts don't throw errors — they silently overwrite. Storage layout errors don't fail at compile time — they corrupt state at runtime. And unlike simpler proxies, the Diamond's complexity makes off-the-shelf static analyzers largely ineffective without specialized rules.
This post covers six vulnerability classes specific to ERC-2535 Diamonds: what makes each dangerous, what the vulnerable code looks like, and how to fix it.
1. Unprotected diamondCut Function
diamondCut() is the most powerful function in any Diamond deployment. It can add new facets, replace existing facet implementations, or remove facets entirely — including the facet containing diamondCut itself. A single call can rewrite the entire contract's logic.
The vulnerability is straightforward: if diamondCut lacks proper access control, anyone can replace all facets with malicious implementations.
// Vulnerable: no access control
contract DiamondCutFacet {
function diamondCut(
IDiamond.FacetCut[] calldata _diamondCut,
address _init,
bytes calldata _calldata
) external {
// Missing: onlyOwner or role check
LibDiamond.diamondCut(_diamondCut, _init, _calldata);
}
}
An attacker who finds this unguarded can call diamondCut with a custom facet that overwrites the token transfer logic, the withdrawal function, or the owner management facet. There is no undo. By the time anyone notices, funds are gone.
Weak access control is nearly as bad. An onlyOwner check collapses to a single point of failure — if the owner private key is compromised, the attacker controls the entire Diamond.
// Safer: multi-sig + timelock enforcement
contract DiamondCutFacet {
function diamondCut(
IDiamond.FacetCut[] calldata _diamondCut,
address _init,
bytes calldata _calldata
) external {
LibDiamond.enforceIsContractOwner(); // must be multi-sig
require(
ITimelock(timelock).isQueued(keccak256(abi.encode(_diamondCut))),
"DiamondCut: not queued"
);
LibDiamond.diamondCut(_diamondCut, _init, _calldata);
}
}
The fix: gate all diamondCut calls behind a multi-sig and enforce a timelock delay for non-emergency upgrades. For functions that should never change — core accounting logic, withdrawal guards — consider deploying those in an immutable facet excluded from future cuts.
2. Selector Conflict Between Facets
Every function in a Diamond is identified by its 4-byte selector, derived from the function signature. The Diamond's internal mapping routes each selector to exactly one facet address. The problem: two completely different functions can hash to the same 4-byte selector.
When a new facet is added via diamondCut and it includes a selector that already exists in the mapping, the old mapping is silently overwritten. No error is thrown. The original function becomes unreachable.
// FacetA deployed first
contract FacetA {
// selector: 0x2f54bf6e
function isOwner(address account) external view returns (bool) {
return LibAuth.storage().owner == account;
}
}
// FacetB added later — different function, same 4-byte selector
contract FacetB {
// Also resolves to selector 0x2f54bf6e due to hash collision
function noRole(address x) external view returns (bool) {
return !LibRoles.storage().roles[x];
}
}
// After adding FacetB:
// diamondCut silently replaces 0x2f54bf6e → FacetA with 0x2f54bf6e → FacetB
// isOwner() is now unreachable
This is not theoretical. Tools like sig.eth and 4byte.directory catalog thousands of known selector collisions. Protocols that name functions carelessly can walk into a collision without realizing it.
The fix is to actively check for conflicts before every diamondCut. The IDiamondLoupe interface — which the Diamond standard requires — provides exactly the introspection needed:
function checkSelectorConflicts(
IDiamondLoupe diamond,
bytes4[] memory incomingSelectors
) internal view {
IDiamondLoupe.Facet[] memory existing = diamond.facets();
for (uint i = 0; i < existing.length; i++) {
for (uint j = 0; j < existing[i].functionSelectors.length; j++) {
bytes4 existingSelector = existing[i].functionSelectors[j];
for (uint k = 0; k < incomingSelectors.length; k++) {
require(
existingSelector != incomingSelectors[k],
"Selector conflict detected"
);
}
}
}
}
Maintain a canonical registry of all selectors across all facets and run conflict checks in your deployment scripts before committing any cut to chain.
3. Facet Storage Namespace Collision
Each facet in a Diamond can define its own state variables. Without discipline, those variables land in sequential storage slots starting at slot 0 — the same slots used by every other facet. Two facets writing to slot 0 will corrupt each other's state.
// FacetA: slot 0 = admin address
contract FacetA {
address public admin; // slot 0
}
// FacetB: slot 0 = totalSupply
contract FacetB {
uint256 public totalSupply; // slot 0 — overwrites admin!
}
This is why ERC-2535 mandates the Diamond Storage pattern. Each facet defines its storage as a struct, placed at a unique storage slot derived from a keccak256 hash. Since the hash is large and domain-specific, collisions are computationally infeasible.
library LibVaultStorage {
bytes32 constant POSITION =
keccak256("diamond.storage.vault.v1");
struct Layout {
mapping(address => uint256) balances;
uint256 totalDeposits;
bool paused;
}
function layout() internal pure returns (Layout storage l) {
bytes32 position = POSITION;
assembly {
l.slot := position
}
}
}
contract VaultFacet {
function deposit() external payable {
LibVaultStorage.Layout storage s = LibVaultStorage.layout();
s.balances[msg.sender] += msg.value;
s.totalDeposits += msg.value;
}
}
Every facet gets its own library, its own POSITION constant, and its own struct. No two facets share a slot. State corruption between facets becomes structurally impossible when this pattern is applied consistently.
The risk surfaces when a team inherits a Diamond codebase where some older facets use naive sequential slot layout. Auditing slot usage across all facets before any upgrade is essential.
4. Facet Initialization Order Bug
Diamonds often deploy multiple facets simultaneously, each with its own initializer function called during diamondCut. When one facet's initializer depends on state set by another facet's initializer, order matters — and if the wrong facet initializes first, it reads uninitialized state.
// FacetA initializer: sets the protocol fee recipient
function initFacetA(address feeRecipient) external {
LibFeeStorage.layout().feeRecipient = feeRecipient;
}
// FacetB initializer: reads the fee recipient set by FacetA
function initFacetB(uint256 defaultFee) external {
LibFeeStorage.Layout storage s = LibFeeStorage.layout();
// Bug: if FacetB initializes before FacetA, feeRecipient is address(0)
require(s.feeRecipient != address(0), "FacetB: no fee recipient");
s.defaultFee = defaultFee;
}
If the deployment script calls initFacetB before initFacetA, the fee recipient is address(0) and either the require reverts or — if the check is absent — fees route to the zero address permanently.
The fix is a dependency-ordered initialization contract that enforces sequence:
contract DiamondInit {
function init(address feeRecipient, uint256 defaultFee) external {
// Explicit order: A before B
LibFeeStorage.layout().feeRecipient = feeRecipient;
// Now B's dependency is satisfied
LibFeeStorage.layout().defaultFee = defaultFee;
// Mark initialized to prevent re-runs
LibDiamond.layout().initialized = true;
}
}
Consolidate all cross-facet initialization into a single DiamondInit contract that runs in a defined sequence. Pass this contract as the _init address in your diamondCut call. Never rely on separate per-facet initializer calls in deployment scripts where order can be accidentally reversed.
5. Removed Facet Still Has Outstanding Approvals
ERC-20 and ERC-721 approvals persist on-chain indefinitely. If users approved a Diamond's facet (or approved the Diamond address itself) to spend tokens on their behalf — and that facet is later removed via diamondCut — the approval remains valid.
The danger compounds when a replacement facet is installed at the same selector slot. That new facet inherits the ability to exercise all outstanding approvals granted to the Diamond address, even though users never explicitly approved it.
// Original: users call approve(diamondAddress, amount) for TransferFacet
// TransferFacet is removed via diamondCut
// Attacker installs MaliciousFacet with matching selectors
// MaliciousFacet can now call token.transferFrom(victim, attacker, amount)
// using approvals victims granted to the diamond address years ago
This is an approval persistence attack. The Diamond's address is static; the logic behind it is not. Users who approved the Diamond for one purpose have unknowingly pre-approved every future facet installed at that address.
The fix has two parts. First, emit a rich event whenever a facet is removed, giving off-chain systems the ability to notify users:
event FacetRemoved(
address indexed facet,
bytes4[] selectors,
uint256 timestamp
);
Second, document in user-facing interfaces that Diamond approvals should be revoked before facet changes take effect, and build revocation flows directly into your protocol's UI. Consider implementing an approval registry within the Diamond that expires approvals on facet removal.
6. Loupe Functions Not Implemented (EIP-165 Compliance)
The ERC-2535 standard mandates implementation of IDiamondLoupe, which provides four functions: facets(), facetFunctionSelectors(), facetAddresses(), and facetAddress(). It also requires supportsInterface() via EIP-165.
Protocols occasionally skip loupe implementation to save gas or simplify deployment. The consequences are significant.
// Non-compliant Diamond: loupe functions absent
contract DiamondProxy {
fallback() external payable {
address facet = selectorToFacet[msg.sig];
require(facet != address(0), "No facet");
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), facet, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
// IDiamondLoupe not implemented
}
Without loupe functions, no external tool — including security scanners, block explorers, and DeFi aggregators — can enumerate what facets exist. A malicious owner can silently install a backdoor facet, and no automated monitoring will detect it. Users cannot verify what functions their approved contracts can call.
The compliant implementation is straightforward:
contract LoupeFacet is IDiamondLoupe, IERC165 {
function facets() external view returns (Facet[] memory) {
LibDiamond.DiamondStorage storage ds = LibDiamond.diamondStorage();
// return full facet + selector list from storage
}
function supportsInterface(bytes4 interfaceId)
external view returns (bool)
{
return interfaceId == type(IDiamondLoupe).interfaceId ||
interfaceId == type(IERC165).interfaceId;
}
}
Implement all four loupe functions, expose supportsInterface, and verify compliance with EIP-165 before deployment. Security tooling — including ContractScan — relies on loupe availability for deep Diamond analysis.
What ContractScan Detects
ContractScan applies specialized Diamond-aware rules that generic static analyzers miss. The table below summarizes detection coverage for each vulnerability class in this post.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Unprotected diamondCut | Access control analysis on diamondCut call sites |
Critical |
| Selector conflict between facets | Cross-facet selector registry comparison | High |
| Facet storage namespace collision | Slot layout analysis across all facets | High |
| Facet initialization order bug | Dependency graph analysis of initializer calls | Medium |
| Stale approvals after facet removal | Event emission check on FacetRemoved operations |
Medium |
| Missing loupe / EIP-165 compliance | Interface implementation verification | Medium |
ContractScan's AI engine is specifically trained on ERC-2535 patterns. When you scan a Diamond-based contract, it checks for all six vulnerability classes above, in addition to the standard vulnerability suite covering reentrancy, oracle manipulation, access control gaps, and arithmetic errors. Scan any Diamond at contractscan.io — QuickScan is free, no sign-up required.
Related Posts
- Function Selector Clash and Proxy Security
- Storage Layout Collision: Proxy Vulnerability in Solidity
ContractScan is an AI-powered smart contract vulnerability scanner. QuickScan is free and unlimited — no sign-up required. Try it now.
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.