# Solidity Proxy Upgradeability Storage

> Audit UUPS, Transparent, and Beacon upgradeable smart contracts for uninitialized implementation contracts, storage slot layout collisions, clashing function selectors, constructor vs initializer pitfalls, and unauthorized selfdestruct calls.

- Skill: `netvar1337/solidity-proxy-upgradeability-storage` (Agent Skill)
- Install (CLI): `npx skillmds@latest add netvar1337/solidity-proxy-upgradeability-storage`
- Raw SKILL.md: https://api.skillmd.com/api/skills/netvar1337/solidity-proxy-upgradeability-storage/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: netvar1337 (https://skillmd.com/u/netvar1337)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/netvar1337/solidity-proxy-upgradeability-storage

---


# Solidity Upgradeable Proxies & Storage Collision Vulnerabilities

## Overview & Empirical Grounding
This security auditing skill is synthesized from **287 empirical audit contest findings** (53 Critical, 234 High severity) extracted from the `Zaevlad/audit-findings-dataset` corpus.

Auditors and security reviewers must use this methodology when reviewing smart contracts that exhibit **Upgrades & Proxy Architecture** patterns.

### Key Trigger Keywords
`uups proxy`, `transparent proxy`, `storage collision`, `uninitialized implementation`, `initializer reentrancy`, `erc1967`, `delegatecall storage`

---

## Threat Model & Core Attack Vectors

Detailed failure modes identified across empirical audit findings:
1. **Unchecked Assumptions & Protocol State Discrepancy**: Deviations between internal accounting state and actual balances/external conditions.
2. **Missing Boundary Checks & Validation**: Failing to constrain user inputs, return values, or stale external data feeds.
3. **Execution Ordering & Invariant Breakage**: Invariants momentarily violated during external calls or intermediary calculations.
4. **Economic & MEV Incentives**: Opportunities for adversaries to extract value through arbitrage, flash loans, or sandwiching.

---

## Step-by-Step Audit Checklist

When reviewing contracts in this domain, follow this rigorous step-by-step verification pipeline:

### 1. Architectural & Entry Point Reconnaissance
- Map all external and public entry points that interact with the vulnerable component.
- Verify access control modifiers (`onlyOwner`, `onlyRole`, custom guards) and ensure roles are initialized in constructor/initializer.
- Trace all token balance state changes and external contract interactions.

### 2. Deep Invariant & Boundary Verification
- **Input Validation**: Check zero-address (`address(0)`), zero-amount, array length parity, and upper/lower bounds.
- **State Transition Sequencing**: Verify Checks-Effects-Interactions (CEI) order. All storage updates must precede external invocations.
- **External Return Handling**: Check return values for external ERC-20 transfers, oracle queries, and delegatecalls.
- **Failure Paths & DoS Hazards**: Ensure reverts in third-party calls do not lock protocol funds or halt liquidation/withdrawal queues.

### 3. Proof of Concept (PoC) Development Guide
When drafting a reproducible exploit in Foundry (`forge test`):
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";

contract AuditPoCTest is Test {
    // Setup target protocol and mock dependencies
    function setUp() public {
        // Fork mainnet or deploy local environment
    }

    function test_exploit_vector() public {
        // 1. Establish initial attacker balance
        // 2. Trigger vulnerability
        // 3. Assert invariant violation or balance drain
    }
}
```

---

## Empirical Case Studies from Zaevlad/audit-findings-dataset

The following real-world contest findings illustrate the root causes and remediation patterns:

### Case Study: Plugins can be maliciously overridden by colliding signatures
- **Severity**: Critical (Dataset Weight: 1.00, ID: #5933)
- **Vulnerability Mechanism**:
Plugins are saved as a mapping from a 4byte selector to a given plugin address:
```solidity
mapping(bytes4 method => IPRBProxyPlugin plugin) public plugins;
```
When new plugins are installed, we call methodList() on the plugin, and then iterate through the list of selectors, and save the plugin address for each selector:
```solidity
function installPlugin(IPRBProxyPlugin plugin) external override {
    // Get the method list to install.
    bytes4[] memory methodList = plugin.methodList();
    // The plugin must have at least one listed method.
    uint256 length = methodList.length;
    if (length == 0) {
        revert PRBProxy_NoPluginMethods(plugin);
    }
    // Enable every method in the list.
    for (uint256 i = 0; i < length;) {
        plugins[methodList[i]] = plugin;
        unchecked {
            i += 1;
        }
    }
    // Log the plugin installation.
    emit InstallPlugin(plugin);
}
```
As a result, an innocent looking plugin can be crafted to intentionally override an existing plugin. When this happens, it will replace the existing plugin as the place where control flow is sent when this plugin in called. This is extremely dangerous, as it allows an attacker to

#### Proof of Concept / Vulnerable Pattern
```solidity
There are many ways this could cause harm in various protocols using PRBProxy, but the simplest is to look at the Sablier integration. When a stream is cancelled by a receiver, Sablier sends the refund to the sender and then calls onStreamCanceled() on the sender contract. The Sablier defined plugin is used to forward these funds along to the owner of the proxy. However, a malicious plugin could be installed with a colliding 4byte selector that, instead, sends the funds to an attacker. Even worse, once the attacker has control flow on behalf of the plugin, they would be able to cancel all other active streams and steal the refundable amount of all of them, which could be devastating. Here is a test that can be dropped into periphery/test/integration/plugin/on-stream-canceled that emulates installing an innocent plugin to collect fees from an unrelated protocol, but results in the refund being sent to an attacker when a stream is canceled. As you will see, the test_PluginOverride function emulates the exact behavior of test_OnStreamCanceled in your own integration tests, but because the malicious plugin is installed first, an attacker is able to steal the funds.
```solidity
// SPDX-
```

- **Remediation Recommendation**:
When plugins are installed, ensure that the 4byte selectors do not collide with any existing plugins.
```solidity
function installPlugin(IPRBProxyPlugin plugin) external override {
    // Get the method list to install.
    bytes4[] memory methodList = plugin.methodList();
    // The plugin must have at least one listed method.
    uint256 length = methodList.length;
    if (length == 0) {
        revert PRBProxy_NoPluginMethods(plugin);
    }
    // Enable every method in the list.
    for (uint256 i = 0; i < length;) {
        if (plugins[methodList[i]] != address(0)) revert PRBProxy_SelectorCollision(methodList[i]);
        plugins[methodList[i]] = plugin;
        unchecked {
            i += 1;
        }
    }
    // Log the plugin installation.
    emit InstallPlugin(plugin);
}
```

---
### Case Study: Loss of fee refund due to premature state deletion in `PerpetualVault::_handleReturn` function
- **Severity**: High (Dataset Weight: 1.00, ID: #9775)
- **Vulnerability Mechanism**:
The Gamma protocol will refund the excess execution fee to the user when the flow is finalized. However, the PerpetualVault::_handleReturn function has a flaw logic that does not properly refund fees, which causes any flow that ends with this function to be affected by this vulnerability.

The PerpetualVault::_handleReturn function is used in the withdraw and signal change flows. This function comprises the burn and refund processes. However, the burn process is called before the refund process, which causes the depositInfo[depositId] to be deleted before the refund process. Consequently, the depositInfo[depositId].executionFee is always 0, causing the refund condition to never be met.

```solidity
  function _handleReturn(uint256 withdrawn, bool positionClosed, bool refundFee) internal {
    (uint256 depositId) = flowData;
    uint256 shares = depositInfo[depositId].shares;
    uint256 amount;
    if (positionClosed) {
      amount = collateralToken.balanceOf(address(this)) * shares / totalShares;
    } else {
      uint256 balanceBeforeWithdrawal = collateralToken.balanceOf(address(this)) - withdrawn;
      amount = withdrawn + balanceBeforeWithdrawal * shares / totalShares;
    

#### Proof of Concept / Vulnerable Pattern
```solidity
This PoC demonstrates withdrawing on the long one leverage vault which is one of the example flow that call the PerpetualVault::_handleReturn function to refund the execution fee.
Copy the getParaSwapDataIndexTo_Collateral function to the test/mock/MockData.sol for support swap index token back to collateral token for withdraw flow.
Copy the following test case to the test/PerpetualVault.t.sol file
Run test with forge test --mt testLossOfGasFeeRefundWhen_Withdraw --rpc-url arbitrum

```solidity
  function getParaSwapDataIndexTo_Collateral(address receiver) external pure returns (bytes memory) {
    bytes memory rev = abi.encodePacked(receiver);
    bytes memory original = hex'000000000000000000000000def171fe48cf0115b1d80b88dc8eab59176fee57000000000000000000000000000000000000000000000000287a7d29bb1d81ed000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000007c446c67b6d000000000000000000000000000000000000000000000000000000000000002000000000000000000000000082af49447d8a07e3bd95bd0d56f35241523fbab1000000000000000000000000000000000000000000000000287a7d29bb1d81ed00000000000000000000000000000000000000000000000000000002440
```

- **Remediation Recommendation**:
Within the PerpetualVault::_handleReturn function, bring the burn process to execute after refunding the execution fee process.

```diff
  function _handleReturn(uint256 withdrawn, bool positionClosed, bool refundFee) internal {
    (uint256 depositId) = flowData;
    uint256 shares = depositInfo[depositId].shares;
    uint256 amount;
    if (positionClosed) {
      amount = collateralToken.balanceOf(address(this)) * shares / totalShares;
    } else {
      uint256 balanceBeforeWithdrawal = collateralToken.balanceOf(address(this)) - withdrawn;
      amount = withdrawn + balanceBeforeWithdrawal * shares / totalShares;
    }
    if (amount > 0) {
      _transferToken(depositId, amount);
    }
    emit Burned(depositId, depositInfo[depositId].recipient, depositInfo[depositId].shares, amount);

---
### Case Study: `AuraVault` inherits `AccessControl` BUT does not call the `_setupRole
- **Severity**: High (Dataset Weight: 1.00, ID: #21698)
- **Vulnerability Mechanism**:
The `AuraVault` contract inherits OpenZeppelin’s `AccessControl` contract to implement role-based access control. The issue is that the `AuraVault` contract does not call the `_setupRole()` function in it’s constructor to set the `DEFAULT_ADMIN_ROLE`, `VAULT_ADMIN_ROLE` or `VAULT_CONFIG_ROLE` roles.

Since this is not done in the constructor it is impossible to call `grantRole()` since it has the `onlyRole(getRoleAdmin(role))` modifier. It is important to note that no roles have been set; therefore, there is no address that can call this function to set roles. Therefore, it is impossible to set the `VAULT_ADMIN_ROLE` and `VAULT_CONFIG_ROLE` roles.

The minor impact is that the `setParameter()` function can never be called to change the `feed` or `auraPriceOracle` because it has the `onlyRole(VAULT_CONFIG_ROLE)` modifier. The critical impact is because `setVaultConfig()` function can never be called to initialize the `vaultConfig`.

The uninitialized `vaultConfig` struct will default all the variables to 0:
```solidity
struct VaultConfig {
    /// @notice The incentive sent to claimer (in bps)
    uint32 claimerIncentive;
    /// @notice The incentive sent to lockers (in bps)
    ui

#### Proof of Concept / Vulnerable Pattern
```solidity
Here is a fully coded POC with a mainnet fork:

1. Add the following `MockAuraPool.sol` to `/src/test/MockAuraPool.sol`:
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

interface IPool {
    function asset() external view returns (address);
    function balanceOf(address) external view returns (uint256);
    function deposit(uint256, address) external returns (uint256);
    function withdraw(uint256, bool) external;
    function withdraw(uint256, address, address) external;
    function redeem(uint256 shares, address receiver, address owner) external returns (uint256);
    function getReward() external returns (bool);
    function extraRewardsLength() external view returns (uint256);
    function rewardToken() external view returns (address);
    function earned(address account) external view returns (uint256);
}

contract MockAuraPool is IPool, ERC20 {
    IERC20 public immutable _asset;
    IERC20 public immutable _rewardToken;
    mapping(address => uint256) public userRewards;

    constructor(address asset_, address 
```

- **Remediation Recommendation**:
Modify the `AuraVault` constructor as follows:
```solidity
constructor(
    address rewardPool_,
    address asset_,
    address feed_,
    address auraPriceOracle_,
    uint32 maxClaimerIncentive_,
    uint32 maxLockerIncentive_,
    string memory tokenName_,
    string memory tokenSymbol_,
    address vaultAdminRole,
    address vaultConfigRole

) ERC4626(IERC20(asset_)) ERC20(tokenName_, tokenSymbol_) {
    rewardPool = rewardPool_;
    feed = feed_;
    auraPriceOracle = auraPriceOracle_;
    maxClaimerIncentive = maxClaimerIncentive_;
    maxLockerIncentive = maxLockerIncentive_;

    _setupRole(VAULT_ADMIN_ROLE, vaultAdminRole);
    _setupRole(VAULT_CONFIG_ROLE, vaultConfigRole);
}
```

---
### Case Study: An attacker can bypass the challenge period during LPP finalization
- **Severity**: High (Dataset Weight: 1.00, ID: #21758)
- **Vulnerability Mechanism**:
Large preimage proposals (LPP) allow submitters to prove that certain data is a fixed part of the large preimage that produces a specific Keccak-256 hash value. Since the preimage is large, the process of LPP finalization involves multiple transactions. Because the intermediate steps are not verified on-chain, LPP flow requires a challenge period during which challengers can verify the correctness of the LPP and dispute it on-chain via the `challengeLPP` and `challengeFirstLPP` functions.

The issue arises from the fact that the current implementation of the `squeezeLPP` function allows a malicious submitter to bypass the challenge period and finalize an invalid proposal. I’ve provided a detailed description of why it is possible below.

The `squeezeLPP` function checks that the challenge period is still active using this check:
    
```solidity
if (block.timestamp - metaData.timestamp() <= CHALLENGE_PERIOD) revert ActiveProposal();
```

While it looks correct, the problem here is that the timestamp is not initialized in the `initLPP` function. If the `metadata.timestamp()` is zero this check will always succeed (assuming that `block.timestamp > CHALLENGE_PERIOD`). The only place w

#### Proof of Concept / Vulnerable Pattern
```solidity
```solidity
contract PreimageOracle_LargePreimageProposals_Test is Test {
        ...
        function test_squeeze_challengePeriodActive_not_revert() public {
            //! Set an appropriate value for block.timestamp.
            vm.warp(1721643596);

            // Allocate the preimage data.
            bytes memory data = new bytes(136);
            for (uint256 i; i < data.length; i++) {
                data[i] = 0xFF;
            }
            bytes memory phonyData = new bytes(136);

            // Initialize the proposal.
            oracle.initLPP{ value: oracle.MIN_BOND_SIZE() }(TEST_UUID, 0, uint32(data.length));

            // Add the leaves to the tree with mismatching state commitments.
            LibKeccak.StateMatrix memory stateMatrix;
            bytes32[] memory stateCommitments = _generateStateCommitments(stateMatrix, data);
            //! The attacker doesn't set _finalize to true but pads the data correctly.
            oracle.addLeavesLPP(TEST_UUID, 0, LibKeccak.padMemory(phonyData), stateCommitments, false); 

            // Construct the leaf preimage data for the blocks added.
            LibKeccak.StateMatrix memory matrix;
            PreimageOracl
```

- **Remediation Recommendation**:
Consider checking that the proposal was finalized in the `squeezeLPP` function:
    
```solidity
if (metaData.timestamp() == 0 || block.timestamp - metaData.timestamp() <= CHALLENGE_PERIOD) revert ActiveProposal();
```

---
### Case Study: Wrong parameter when retrieving causes a DoS in CouncilMember contract
- **Severity**: High (Dataset Weight: 1.00, ID: #22387)
- **Vulnerability Mechanism**:
A wrong parameter in the _retrieve() prevents the protocol from properly interacting with Sablier, causing a Denial of Service in all functions calling _retrieve().  
The CouncilMember contract is designed to interact with a Sablier stream. As time passes, the Sablier stream will unlock more TELCOIN tokens which will be available to be retrieved from CouncilMember.  
The _retrieve() internal function will be used in order to fetch the rewards from the stream and distribute them among the Council Member NFT holders (snippet reduced for simplicity):  
// CouncilMember.sol  
```solidity
function _retrieve() internal {
...
// Execute the withdrawal from the _target, which might be a Sablier stream or another protocol
_stream.execute(
_target,
abi.encodeWithSelector(
ISablierV2ProxyTarget.withdrawMax.selector,
_target,
_id,
address(this)
)
);
...
}
```
The most important part in _retrieve() regarding the vulnerability that we’ll dive into is the _stream.execute() interaction and the params it receives. In order to understand such interaction, we first need understand the importance of the _stream and the _target variables.  
Sablier allows developers to integrate Sablier via Periphery c

#### Proof of Concept / Vulnerable Pattern
```solidity
Because the current Telcoin repo does not include actual tests with the real Sablier contracts (instead, a TestStream contract is used, which has led to not unveiling this vulnerability), [I’ve created a repository](https://github.com/0xadrii/telcoin-proof-of-concept) where the poc can be executed (the repository will be public after how any interaction (in this case, a call to the mint() function) will fail because the proper Sablier contracts are used (PRBProxy and proxy target):  
```solidity
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test, console2} from "forge-std/Test.sol";
import {SablierV2Comptroller} from "@sablier/v2-core/src/SablierV2Comptroller.sol";
import {SablierV2NFTDescriptor} from "@sablier/v2-core/src/SablierV2NFTDescriptor.sol";
import {SablierV2LockupLinear} from "@sablier/v2-core/src/SablierV2LockupLinear.sol";
import {ISablierV2Comptroller} from "@sablier/v2-core/src/interfaces/ISablierV2Comptroller.sol";
import {ISablierV2NFTDescriptor} from "@sablier/v2-core/src/interfaces/ISablierV2NFTDescriptor.sol";
import {ISablierV2LockupLinear} from "@sablier/v2-core/src/interfaces/ISablierV2LockupLinear.sol";
import {CouncilMember, IPRBPr
```

- **Remediation Recommendation**:
```solidity
function _retrieve() internal {
...
// Execute the withdrawal from the _target, which might be a Sablier stream or another protocol
_stream.execute(
_target,
abi.encodeWithSelector(
ISablierV2ProxyTarget.withdrawMax.selector,
actualStream,
_id,
address(this)
)
);
...
}
```

---
### Case Study: Claim functions don't validate if the epoch is
- **Severity**: High (Dataset Weight: 1.00, ID: #22571)
- **Vulnerability Mechanism**:
Both claim functions fail to validate if the epoch for the request has been already settled, leading to loss of funds when claiming requests for the current epoch. The issue is worsened as claimAndRequestDeposit() can be used to claim a deposit on behalf of any account, allowing an attacker to wipe other's requests. When the vault is closed, users can request a deposit, transfer assets and later claim shares, or request a redemption, transfer shares and later redeem assets. Both of these processes store the assets or shares, and later convert these when the epoch is settled. For deposits, the core of the implementation is given by _claimDeposit(): ```solidity function _claimDeposit( address owner, address receiver ) internal returns (uint256 shares) { shares = previewClaimDeposit(owner); uint256 lastRequestId = lastDepositRequestId[owner]; uint256 assets = epochs[lastRequestId].depositRequestBalance[owner]; epochs[lastRequestId].depositRequestBalance[owner] = 0; _update(address(claimableSilo), receiver, shares); emit ClaimDeposit(lastRequestId, owner, receiver, assets, shares); } ``` ```solidity function previewClaimDeposit(address owner) public view returns (uint256) { uint256 las

#### Proof of Concept / Vulnerable Pattern
```solidity
The following proof of concept demonstrates the scenario in which a user claims their own deposit during the current epoch: ```solidity function test_ClaimSameEpochLossOfFunds_Scenario_A() public { asset.mint(alice, 1_000e18); vm.prank(alice); vault.deposit(500e18, alice); // vault is closed vm.prank(owner); vault.close(); // alice requests a deposit vm.prank(alice); vault.requestDeposit(500e18, alice, alice, ""); // the request is successfully created assertEq(vault.pendingDepositRequest(alice), 500e18); // now alice claims the deposit while vault is still open vm.prank(alice); vault.claimDeposit(alice); // request is gone assertEq(vault.pendingDepositRequest(alice), 0); } ``` This other proof of concept illustrates the scenario in which an attacker calls claimAndRequestDeposit() to wipe the deposit of another account. ```solidity function test_ClaimSameEpochLossOfFunds_Scenario_B() public { asset.mint(alice, 1_000e18); vm.prank(alice); vault.deposit(500e18, alice); // vault is closed vm.prank(owner); vault.close(); // alice requests a deposit vm.prank(alice); vault.requestDeposit(500e18, alice, alice, ""); // the request is successfully created assertEq(vault.pendingDepositReques
```

- **Remediation Recommendation**:
Check that the epoch associated with the request is not the current epoch. ```solidity function _claimDeposit( address owner, address receiver ) internal returns (uint256 shares) { uint256 lastRequestId = lastDepositRequestId[owner]; if (isCurrentEpoch(lastRequestId)) revert(); shares = previewClaimDeposit(owner); uint256 assets = epochs[lastRequestId].depositRequestBalance[owner]; epochs[lastRequestId].depositRequestBalance[owner] = 0; _update(address(claimableSilo), receiver, shares); emit ClaimDeposit(lastRequestId, owner, receiver, assets, shares); } ``` ```solidity function _claimRedeem( address owner, address receiver ) internal whenNotPaused returns (uint256 assets) { uint256 lastRequestId = lastRedeemRequestId[owner]; if (isCurrentEpoch(lastRequestId)) revert(); assets = previewCla


---

## Remediation & Hardening Principles

- **Fail-Safe Defaults**: Always favor reverting rather than proceeding with invalid or unexpected state.
- **Defensive Type Safety**: Use OpenZeppelin's vetted libraries (`SafeERC20`, `ReentrancyGuardUpgradeable`, `SafeCast`, `Math`).
- **Two-Step Critical Actions**: Enforce commit-reveal or timelock mechanisms for governance and high-impact parameter changes.
- **Comprehensive Testing Matrix**: Pair unit tests with property-based invariant testing (Foundry `invariant` or Echidna) and mutation testing.

