Security Limitations of the Prividium's Permissioning Layer
Learn about the inherent security limitations of Prividium's permissioning layer and how to avoid deploying smart contracts that expose side-channel or authorization bypass vulnerabilities.
Overview
Prividium's architecture is centered around the Proxy RPC, the Prividium API, and role/function-level permissions. Policy is enforced before a request reaches the chain — it does not imply confidential execution inside contract code.
For every incoming RPC request, Prividium checks the top-level to address and the 4-byte function selector extracted
from the calldata. If the caller is authorized for that contract + method combination, the request is forwarded to the
sequencer.
However, once the transaction reaches the EVM, Prividium has no visibility into what happens next:
- Internal
CALL,DELEGATECALL, andSTATICCALLopcodes execute without additional permission checks - Gas consumption is determined by the full EVM execution path and returned to the caller
- Execution timing is observable through response latency
These are inherent architectural constraints of proxy-layer permissioning. The sections below describe five categories of risk that arise from these constraints, along with concrete examples and mitigations.
1. Multicall and Arbitrary Call Bypass
The Risk
Prividium checks the to address and function selector of the top-level RPC call only. If a whitelisted contract
contains multicall or arbitrary call functionality — such as address.call(data), OpenZeppelin's Multicall mixin,
Governor.execute(), or a fallback() function that forwards via delegatecall — then any user authorized to call
that function can use it as a trampoline to reach any other contract on the network, completely bypassing
Prividium's per-function permissions.
Bad Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/// UNSAFE: Any user authorized to call executeCall() can reach
/// ANY contract on the network, bypassing Prividium permissions.
contract UnsafeRouter {
function executeCall(
address target,
bytes calldata data
) external returns (bytes memory) {
(bool success, bytes memory result) = target.call(data);
require(success, "Call failed");
return result;
}
}Other patterns to flag during contract review:
- Generic
call/delegatecall— any function that accepts an arbitraryaddress+bytes calldataand forwards a low-level call (e.g., OpenZeppelinMulticall, governanceexecute(address, uint256, bytes)) - Module systems — ERC-2535 Diamond proxies, ERC-7579 modules, or plugin architectures where external modules can be installed and called through a single entry point
- Fallback dispatchers — contracts with a
fallback()that routes calls based on selector lookup tables or forwards viadelegatecallto a registry-resolved implementation - User-controlled target addresses — any function where the caller supplies the
toaddress for an internal call, even if the selector is fixed - Factories / CREATE2 helpers — contracts that deploy other contracts on behalf of the caller (the deployed contract's constructor can call anything, same as the deployment bypass in Section 3)
- Upgradeable proxies — proxies where the implementation can be changed behind an allowed selector; the selector stays the same but the underlying logic changes to call arbitrary contracts after an upgrade
Good Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/// SAFE: Forwarding is restricted to a single known target and method.
contract SafeRouter {
address public immutable allowedTarget;
bytes4 public immutable allowedSelector;
constructor(address _target, bytes4 _selector) {
allowedTarget = _target;
allowedSelector = _selector;
}
function forwardCall(bytes calldata data) external returns (bytes memory) {
require(bytes4(data[:4]) == allowedSelector, "Selector not allowed");
(bool success, bytes memory result) = allowedTarget.call(data);
require(success, "Call failed");
return result;
}
}Mitigations
- Audit all contracts for
call,delegatecall, andstaticcallusage before approving deployment - Reject contracts with arbitrary call forwarding patterns (any function accepting
address+bytes calldatathat forwards a low-level call) - If a contract uses multicall internally, verify that the targets and selectors are hardcoded or restricted to a known safe set
- If batch execution is required, restrict targets to a hardcoded allow-list within the contract itself
- Avoid inheriting OpenZeppelin
Multicallunless all callable targets are explicitly safe
2. Gas Estimation Oracle
The Risk
When a user calls eth_estimateGas, Prividium checks authorization for the top-level call and then delegates the
request to the sequencer. The sequencer executes the full EVM code and returns the gas estimate. If the contract's
execution path branches on secret state — for example, comparing user input to a hidden value — the gas estimate will
differ depending on which branch executes. An attacker can systematically probe different inputs and infer the secret
from the observed gas differences.
Bad Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/// UNSAFE: Gas consumption reveals whether the guess is correct.
/// An attacker can call eth_estimateGas with different inputs and
/// observe the gas difference to find the secret number.
contract UnsafeGuessing {
uint256 private secretNumber;
mapping(address => bool) public winners;
constructor(uint256 _secret) {
secretNumber = _secret;
}
function guess(uint256 _number) external {
if (_number == secretNumber) {
// This branch consumes significantly more gas:
// storage write + ETH transfer
winners[msg.sender] = true;
payable(msg.sender).transfer(1 ether);
}
// The no-match branch does nothing — much cheaper
}
}Good Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/// SAFE: Commit-reveal ensures that the comparison phase has
/// constant gas cost regardless of whether the guess is correct.
/// The secret is never compared directly to user input.
contract SafeGuessing {
bytes32 private secretHash;
mapping(address => bytes32) public commitments;
constructor(bytes32 _secretHash) {
secretHash = _secretHash;
}
/// Phase 1: Commit a hash of your guess. Constant gas cost.
function commit(bytes32 _commitHash) external {
commitments[msg.sender] = _commitHash;
}
/// Phase 2: Reveal your guess. Gas cost is constant regardless
/// of correctness — both branches perform the same storage writes.
function reveal(uint256 _guess, bytes32 _salt) external {
require(
commitments[msg.sender] == keccak256(abi.encodePacked(_guess, _salt)),
"Invalid reveal"
);
bool correct = keccak256(abi.encodePacked(_guess)) == secretHash;
// Always write to storage — same gas cost either way
commitments[msg.sender] = bytes32(0);
if (correct) {
payable(msg.sender).transfer(1 ether);
}
}
}Mitigations
- Flag contracts where user input is directly compared to stored secret values
- Require commit-reveal schemes for any guessing, matching, or auction-style logic
- Ensure that both branches of any comparison perform the same storage operations (constant gas cost)
- Avoid designs where a single function's gas cost varies based on hidden state
3. Contract Deployment Must Be Privileged
The Risk
When a contract is deployed, its constructor executes as part of a single transaction sent to the null address.
Prividium verifies that the deploying account has the contract_deployment system permission, but it cannot inspect
what the constructor does internally. A malicious constructor can make arbitrary calls to any contract on the network
— reading secret data, executing unauthorized transactions, or exfiltrating information into the newly deployed
contract's storage.
Bad Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/// ATTACK: A user with deployment permission deploys this contract.
/// The constructor calls a restricted contract that the deployer
/// has no direct permission to access via normal RPC calls.
contract MaliciousDeployer {
bytes public stolenData;
constructor(address secretContract, bytes memory callData) {
// This call executes inside the deployment transaction.
// Prividium only checked that the user can deploy —
// it has no visibility into what the constructor calls.
(bool success, bytes memory result) = secretContract.call(callData);
require(success, "Exfiltration failed");
stolenData = result;
}
}After deployment, the attacker reads stolenData from the new contract's storage — data that was never accessible to
them through normal RPC calls.
Good Pattern
There is no "safe" way to allow untrusted users to deploy contracts while preventing constructor abuse. The mitigation is entirely operational.
See System Permissions for how to configure deployment permissions.
Mitigations
- Restrict
contract_deploymentto a dedicated deployer role with a small number of trusted accounts - Never grant
contract_deploymentto end-user roles — it is not a "nice to have" permission, it is full network access during deployment - Require contract source code review before each deployment
- Understand that deployment on Prividium requires elevated trust and governance review
4. Timing Side-Channel
The Risk
This is closely related to the gas estimation oracle (Section 2) but exploits wall-clock execution time instead of
gas cost. If a smart contract's execution time varies based on non-public state — for example, iterating over a
variable-length array or performing heavier computation on a match — the response time of eth_call leaks information
about that state.
A single call may produce a negligible difference, but JSON-RPC batching allows sending many identical calls in one HTTP request, amplifying the timing difference to a clearly measurable level.
Bad Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/// UNSAFE: Execution time scales with the number of entries.
/// By measuring eth_call response time, the caller can infer
/// the size of the private data.
contract UnsafeRegistry {
mapping(address => address[]) private connections;
function getConnectionCount(address user) external view returns (uint256) {
address[] storage conns = connections[user];
uint256 count = 0;
for (uint256 i = 0; i < conns.length; i++) {
if (conns[i] != address(0)) {
count++;
}
}
return count;
}
}Another vulnerable pattern — early returns that reveal whether (and where) a value exists:
/// UNSAFE: Early return on match means execution time reveals
/// whether the value exists and its approximate position.
function findEntry(bytes32 key) external view returns (bool) {
for (uint256 i = 0; i < entries.length; i++) {
if (entries[i] == key) return true;
}
return false;
}Good Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
/// SAFE: Constant-time operation — a single storage read (SLOAD)
/// regardless of how many connections exist.
contract SafeRegistry {
mapping(address => uint256) private connectionCount;
mapping(address => mapping(uint256 => address)) private connections;
function addConnection(address target) external {
uint256 idx = connectionCount[msg.sender];
connections[msg.sender][idx] = target;
connectionCount[msg.sender] = idx + 1;
}
function getConnectionCount(address user) external view returns (uint256) {
return connectionCount[user];
}
}Mitigations
- Flag view functions that iterate over variable-length arrays or perform computation that scales with hidden data
- Prefer O(1) storage patterns: pre-computed counters, indexed mappings, avoid iteration over dynamic arrays
- Avoid early-return patterns in lookups over private data
- Prividium caps JSON-RPC batches at 1000 requests per HTTP request, which bounds this amplification
5. Selective Disclosure Integrity Limits (eth_call Disclosures)
The Risk
The eth_call-based selective-disclosure methods — prividium_tokenSupplyDisclosure and
prividium_tokenBalanceDisclosure — return a call result together with cryptographic proof material that a verifier
replays on a clean, trusted node. The proof and replay bind the disclosed result to on-chain state, but they do not
guarantee it reflects the contract's real behaviour on the Prividium chain: a contract deployer who controls the
disclosed contract's code can construct a contract whose disclosed result differs from what the chain actually returns,
in a way the verification steps cannot detect. Disclosures are also unsigned and self-presented, so a verifier has no
cryptographic assurance of who produced them.
The consequence: a token-supply or balance disclosure should be trusted only when the disclosed contract's code is itself trusted. It is not a defense against a malicious contract author.
Account-data disclosure (prividium_accountDataDisclosure) is not affected — it proves account properties directly
against the L1-bound state commitment without replaying any call.
Mitigations
- Selective disclosure is disabled by default (
DISCLOSURE_METHODS_ENABLED=falseon the Permissions API,VITE_DISCLOSURE_METHODS_ENABLED=falseon the Admin Panel). Leave it off unless you have a concrete need - Only enable it when every account holding the
contract_deploymentpermission is fully trusted — the deployer of a disclosed contract controls what its disclosures can be made to say - Review a contract's source before enabling supply or balance disclosure for it, and only disclose contracts whose code you have vetted
- As a consumer of a disclosure, treat an unsigned, self-presented disclosure as a claim by the presenter, not an attestation by the zone operator
Related Documentation
- Minimizing Contract ABI Exposure — reducing information leakage from bundled ABIs
- Selective Disclosure — configuring public access to specific contract data
- Contract Deployment and Scripting — deployment methods and prerequisites
- Contract Permissions — configuring per-function access rules
- System Permissions — managing deployment and full-access permissions