UUPS Proxies: A Double-Edged Sword – Efficient Upgrades, Hidden Risks
17 min read
February 2, 2025

Table of contents
Introduction
Welcome back! In the previous chapter, we explored Transparent Proxies, the classic method for upgrading smart contracts while keeping the same address. We saw how they work, why they’re useful, and, of course, how they can be completely wrecked if left unprotected. But just when you thought you had proxies figured out, here comes another player—UUPS Proxies.
UUPS (Universal Upgradeable Proxy Standard) proxies take a leaner, more efficient approach. Instead of making the proxy contract handle upgrades (like in Transparent Proxies), UUPS shifts that responsibility to the implementation contract itself. This cuts down the proxy’s complexity, reduces gas costs, and keeps things lightweight. However, this also means that security must be airtight, because any mistakes in the implementation contract could leave your system completely exposed.
In this chapter, we’ll break down how UUPS Proxies work, compare them to Transparent Proxies, and—because we know bad things happen—we’ll also explore what can go wrong and how to protect against it. You’ll also get hands-on with an implementation, see an upgrade in action, and, by the end, have a clear understanding of why UUPS proxies have become the preferred choice for many developers.
But UUPS isn’t the only alternative to Transparent Proxies. As we progress, we’ll also look at other proxy patterns that pop up in the blockchain world, like Beacon Proxies, Minimal Proxies (Clones), Diamond Proxies, and Static Proxies. Each has its own use case, strengths, and, of course, potential pitfalls.
So, if you’re ready to level up your proxy knowledge—and avoid catastrophic upgrade failures—let’s get started. 🚀
UUPS Proxies: What Are They?
UUPS proxies (Universal Upgradeable Proxy Standard) are a type of proxy used in Ethereum smart contracts to enable upgrades. Unlike Transparent Proxies, where the upgrade logic is managed by the proxy itself, UUPS proxies shift that responsibility to the implementation contract.
This design makes UUPS proxies simpler and more gas-efficient because the proxy only focuses on forwarding calls, while the implementation contract handles upgrades when needed. Essentially, the proxy acts as a pass-through, and the implementation contains the logic for both the contract’s functionality and its upgrade process.
In short, UUPS proxies allow you to:
- Upgrade contract logic while keeping the same address.
- Save gas by reducing the size and complexity of the proxy.
- Keep the system flexible while maintaining functionality.
It’s a cleaner, more efficient way to handle upgrades in the blockchain world, but the shift of upgrade logic to the implementation also means extra care is needed to secure it.
Breaking Down the Proxy Code
Let’s speed through the proxy implementation—after all, this is practically the same setup we used in the Transparent Proxy from the last chapter. If you’ve read that section, you’re already 90% of the way there. But let’s refresh the key pieces and point out what makes this proxy tick.
UUPS Proxy
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract UUPSProxy {
// Storage slot for the implementation address (EIP-1967-compliant)
bytes32 private constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
// Storage slot for the admin address (EIP-1967-compliant)
bytes32 private constant ADMIN_SLOT = bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);
// Constructor to initialize the implementation and admin
constructor(address initialImplementation, address adminAddress) {
require(initialImplementation != address(0), "Implementation cannot be zero address");
require(adminAddress != address(0), "Admin cannot be zero address");
_setImplementation(initialImplementation);
_setAdmin(adminAddress);
}
// Fallback function to delegate all calls to the implementation
fallback() external payable {
_delegate();
}
receive() external payable {
_delegate();
}
// Internal function to delegate the call to the implementation contract
function _delegate() internal {
address impl = _getImplementation();
require(impl != address(0), "Implementation not set");
assembly {
// Copy calldata
calldatacopy(0, 0, calldatasize())
// Delegatecall to the implementation
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
// Copy returndata
returndatacopy(0, 0, returndatasize())
// Revert or return based on the result
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
// Internal function to retrieve the implementation address
function _getImplementation() internal view returns (address impl) {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
impl := sload(slot)
}
}
// Internal function to set the implementation address
function _setImplementation(address newImplementation) internal {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
sstore(slot, newImplementation)
}
}
// Internal function to retrieve the admin address
function _getAdmin() internal view returns (address adm) {
bytes32 slot = ADMIN_SLOT;
assembly {
adm := sload(slot)
}
}
// Internal function to set the admin address
function _setAdmin(address newAdmin) internal {
bytes32 slot = ADMIN_SLOT;
assembly {
sstore(slot, newAdmin)
}
}
}
1. Storage Slots: The Foundation
Just like in the Transparent Proxy, we’re working with two storage slots defined by the EIP-1967 standard:
IMPLEMENTATION_SLOT
: Holds the address of the contract with the logic (the implementation).ADMIN_SLOT
: Stores the address of the admin who’s allowed to upgrade the implementation.
Here’s how they’re defined:
bytes32 private constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
bytes32 private constant ADMIN_SLOT = bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);
The reason for EIP-1967 is simple: it ensures these slots won’t overlap with the variables in your implementation contract. If the Transparent Proxy was your intro to this concept, then this is just a quick reminder—it’s the same approach.
2. Constructor: Setting Up the Proxy
When deploying the proxy, we initialize the implementation and admin addresses in the constructor:
constructor(address initialImplementation, address adminAddress) {
require(initialImplementation != address(0), "Implementation cannot be zero address");
require(adminAddress != address(0), "Admin cannot be zero address");
_setImplementation(initialImplementation);
_setAdmin(adminAddress);
}
This is straightforward:
- Validate the addresses.
- Store them in their respective storage slots.
3. Fallback and Receive: Delegating Calls
As with the Transparent Proxy, the fallback
and receive
functions handle all incoming calls and forward them to the implementation contract:
fallback() external payable {
_delegate();
}
receive() external payable {
_delegate();
}
These functions make the proxy a middleman, passing along requests without ever executing them itself. The real work happens in _delegate
.
4. The _delegate
Function: Forwarding the Action
Here’s where the magic happens. The _delegate
function forwards calls to the implementation, just like in the Transparent Proxy:
function _delegate() internal {
address impl = _getImplementation();
require(impl != address(0), "Implementation not set");
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
This function:
- Retrieves the implementation address from
IMPLEMENTATION_SLOT
. - Uses
delegatecall
to execute the function in the implementation’s context. - Returns the result or reverts if something goes wrong.
5. Internal Helpers: Managing the Slots
We’re reusing the same helper functions for reading and writing to the storage slots:
function _getImplementation() internal view returns (address impl) {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
impl := sload(slot)
}
}
function _setImplementation(address newImplementation) internal {
bytes32 slot = IMPLEMENTATION_SLOT;
assembly {
sstore(slot, newImplementation)
}
}
function _getAdmin() internal view returns (address adm) {
bytes32 slot = ADMIN_SLOT;
assembly {
adm := sload(slot)
}
}
function _setAdmin(address newAdmin) internal {
bytes32 slot = ADMIN_SLOT;
assembly {
sstore(slot, newAdmin)
}
}
Breaking Down the Implementation Contract
Now that we’ve covered the proxy, it’s time to dive into ImplementationV1, where the real magic happens. This contract not only contains the application’s logic but also the critical upgrade mechanism that makes this a true UUPS Proxy system. Let’s break it down step by step.
Implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract ImplementationV1 {
event UpgradeAttempt(address sender, address newImplementation);
event AdminCheck(address sender, address admin);
event DebugMessage(string message);
event DebugMessageAddress(string message, address value);
uint256 public storedValue;
function upgradeTo(address newImplementation) external onlyProxy {
emit DebugMessage("Entered upgradeTo");
emit UpgradeAttempt(msg.sender, newImplementation);
address admin = _getAdmin();
emit AdminCheck(msg.sender, admin);
require(admin == msg.sender, "Not authorized");
emit DebugMessage("Admin check passed");
require(newImplementation != address(0), "New implementation cannot be zero address");
emit DebugMessage("New implementation address valid");
// Use the EIP-1967 implementation slot
bytes32 implementationSlot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
// Log current implementation for debugging
address currentImplementation;
assembly {
currentImplementation := sload(implementationSlot)
}
emit DebugMessageAddress("Current implementation", currentImplementation);
// Update the implementation slot
assembly {
sstore(implementationSlot, newImplementation)
}
emit DebugMessage("Implementation updated successfully");
}
function getVersion() public pure returns (string memory) {
return "v1";
}
function setStoredValue(uint256 value) public onlyProxy {
storedValue = value;
}
function getStoredValue() public view returns (uint256) {
return storedValue;
}
function _getAdmin() internal view returns (address) {
bytes32 slot = bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);
address adm;
assembly {
adm := sload(slot)
}
return adm;
}
}
1. Upgrade Mechanism: The upgradeTo
Function
At the heart of this implementation is the upgradeTo
function, which allows the admin to update the proxy’s implementation address. This is what sets UUPS apart from Transparent Proxies—the implementation itself manages the upgrades.
Here’s the code:
function upgradeTo(address newImplementation) external {
emit DebugMessage("Entered upgradeTo");
emit UpgradeAttempt(msg.sender, newImplementation);
address admin = _getAdmin();
emit AdminCheck(msg.sender, admin);
require(admin == msg.sender, "Not authorized");
emit DebugMessage("Admin check passed");
require(newImplementation != address(0), "New implementation cannot be zero address");
emit DebugMessage("New implementation address valid");
// Use the EIP-1967 implementation slot
bytes32 implementationSlot = bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
// Log current implementation for debugging
address currentImplementation;
assembly {
currentImplementation := sload(implementationSlot)
}
emit DebugMessageAddress("Current implementation", currentImplementation);
// Update the implementation slot
assembly {
sstore(implementationSlot, newImplementation)
}
emit DebugMessage("Implementation updated successfully");
}
What’s Happening Here?
- Authorization Check:
The function ensures only the admin (stored in the proxy’sADMIN_SLOT
) can perform the upgrade. If the caller isn’t the admin, the call fails with aNot authorized
error. - Validation:
It checks that thenewImplementation
address is valid (notaddress(0)
). - Update the Implementation Slot:
Using low-level assembly, the function updates theIMPLEMENTATION_SLOT
in the proxy’s storage. This ensures the proxy delegates future calls to the new implementation. - Debugging Events:
The function emits several events, such as the current and new implementation addresses, to make it easier to debug during upgrades.
This mechanism ensures that the upgrade process is both flexible and secure, as long as the admin address is properly managed.
2. Versioning: The getVersion
Function
To make upgrades transparent, the contract includes a simple versioning mechanism:
function getVersion() public pure returns (string memory) {
return "v1";
}
This is useful for verifying which implementation the proxy is currently using. After an upgrade, calling getVersion
through the proxy lets you confirm that the new implementation is in place.
3. Core Logic: Business Functions
The contract also includes application-specific logic. In this case, we have two simple functions for storing and retrieving a value:
function setStoredValue(uint256 value) public {
storedValue = value;
}
function getStoredValue() public view returns (uint256) {
return storedValue;
}
How Does It Work?
setStoredValue
: Stores a value in the proxy’s storage. Remember, thanks todelegatecall
, the state is stored in the proxy, not the implementation.getStoredValue
: Retrieves the stored value, allowing users to confirm that the proxy’s state is preserved across upgrades.
4. Admin Retrieval: The _getAdmin
Function
To securely retrieve the admin address, the contract includes an internal helper function:
function _getAdmin() internal view returns (address) {
bytes32 slot = bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1);
address adm;
assembly {
adm := sload(slot)
}
return adm;
}
- The admin address is stored in the proxy’s
ADMIN_SLOT
, and this function allows the implementation to read it safely. - Without this, the implementation wouldn’t know who is authorized to perform upgrades.
Deploying the UUPS Proxy and Implementation
The deployment process for the UUPS Proxy and its implementation is managed using a simple Foundry script. The script handles everything from deploying the contracts to setting up the proxy and its admin. Let’s walk through how it works.
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
import "../../src/UUPS-Proxy/UUPS-Proxy.sol";
import "../../src/UUPS-Proxy/ImplementationUUPS.sol";
contract Deploy is Script {
function run() external {
// Start broadcasting transactions
vm.startBroadcast(vm.envUint("ADMIN_KEY"));
// Step 1: Deploy the ImplementationV1 contract
ImplementationV1 implementation = new ImplementationV1();
console.log("ImplementationUUPS deployed at:", address(implementation));
// Step 2: Deploy the Transparent Proxy contract
UUPSProxy proxy = new UUPSProxy(address(implementation), vm.envAddress("ADMIN_ADDR"));
console.log("UUPSProxy deployed at:", address(proxy));
// End broadcasting transactions
vm.stopBroadcast();
}
}
The deployment begins with the run
function, which starts broadcasting transactions. This ensures that all subsequent actions, like contract deployments, are signed and sent to the blockchain using the admin’s private key. The admin key is securely provided via environment variables, keeping sensitive information safe.
The first step in the deployment is to deploy the implementation contract. In this case, it’s ImplementationV1
, which contains all the core logic and the upgradeTo
function for future upgrades. Once deployed, the address of the implementation contract is logged for reference. This address is essential, as it will be used to link the proxy to the implementation.
Next, the UUPS Proxy is deployed. The proxy is initialized with the address of the implementation contract and the admin’s address. The admin address, also passed through environment variables, is stored in the proxy to grant the admin control over future upgrades. The address of the proxy is logged, as it will serve as the permanent entry point for interacting with the system. All user interactions with the smart contract will go through this proxy, even as the underlying implementation changes over time.
Finally, the script stops broadcasting transactions. This ensures that no unintended actions are sent to the blockchain beyond the deployments. At this point, both the proxy and the implementation contracts are deployed and linked, and the system is ready to use.
To execute this script, you simply run it with Foundry, specifying the local Anvil testnet or any desired blockchain environment. Once the script completes, the deployment is finalized, and the addresses of the proxy and implementation are logged, ensuring everything is in place for interaction or further testing.
This deployment script sets up a clean foundation for the UUPS Proxy system. With the proxy now in place, users can interact with the contract seamlessly while the admin retains the flexibility to upgrade the implementation when needed. The result is a dynamic and upgradeable system, ready for action.

Interacting with and Upgrading the Proxy
Now that we’ve deployed the UUPS Proxy and its first implementation, it’s time to interact with it and see the magic of seamless upgrades in action. This script demonstrates how to use the proxy to store a value, verify the implementation version, and even upgrade to a new implementation—all while keeping the same proxy address. Let’s break it down.
pragma solidity ^0.8.0;
import "forge-std/Script.sol";
interface IUUPSProxy {
function setStoredValue(uint256 value) external;
function getStoredValue() external view returns (uint256);
function upgradeTo(address newImplementation) external;
function getVersion() external view returns (string memory);
}
contract Interact is Script {
IUUPSProxy proxy = IUUPSProxy(0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512);
function run() external {
vm.startBroadcast(vm.envUint("ADMIN_KEY"));
proxy.setStoredValue(150);
console.log("Version: ", proxy.getVersion());
proxy.upgradeTo(0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9);
console.log("Version: ", proxy.getVersion());
console.log("The value is: ", proxy.getStoredValue());
vm.stopBroadcast();
}
}
1. Setting Up the Script
The script starts by importing Foundry’s scripting utilities and defining an interface, IUUPSProxy
. This interface allows us to interact with the proxy’s key functions, such as storing and retrieving values, checking the implementation version, and upgrading the implementation.
The script references a deployed proxy by its address:
IUUPSProxy proxy = IUUPSProxy(0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512);
This hardcoded address (0xe7f...
) is the proxy’s address from deployment. All interactions, including the upgrade, will be routed through this proxy.
2. Starting Transactions
As with the deployment script, the first step is to start broadcasting transactions:
vm.startBroadcast(vm.envUint("ADMIN_KEY"));
This ensures all actions in the script are signed with the admin’s private key (ADMIN_KEY
) provided via environment variables. Since we’re about to call functions that require admin permissions, this step is critical.
3. Interacting with the Proxy
The script first demonstrates how to use the proxy to store and retrieve values.
proxy.setStoredValue(150);
console.log("Version: ", proxy.getVersion());
Here’s what happens:
setStoredValue(150)
: Calls thesetStoredValue
function in the implementation via the proxy. The value150
is stored in the proxy’s storage (not the implementation’s).getVersion()
: Calls thegetVersion
function to check the version of the current implementation. Initially, this should return"v1"
(or whatever the first implementation specifies).
This shows how the proxy seamlessly delegates calls to the implementation while keeping its own state intact.
4. Upgrading the Implementation
The real highlight of the script is the upgrade process:
proxy.upgradeTo(0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9);
console.log("Version: ", proxy.getVersion());
Here’s how it works:
upgradeTo(0xDc64...)
: This updates the proxy to point to a new implementation contract. The address0xDc64...
represents the new implementation (ImplementationV2
, for example).getVersion()
: After the upgrade, this function checks the version of the new implementation, which should now reflect the new version (e.g.,"v2"
).
The magic here is that the proxy’s address stays the same, so users don’t need to update anything on their end. All changes happen under the hood.
5. Verifying the Upgrade
Finally, the script retrieves the stored value to confirm that the proxy’s state is intact even after the upgrade:
console.log("The value is: ", proxy.getStoredValue());
Since the proxy holds the storage, the value 150
stored earlier is still available after upgrading the implementation. This demonstrates the power of the UUPS Proxy pattern: you can enhance functionality without losing data or requiring users to switch addresses.
6. Stopping Transactions
The script ends by stopping the broadcast of transactions:
vm.stopBroadcast();
This ensures that no unintended actions are sent to the blockchain after the script completes.

Common Vulnerabilities in UUPS Proxies
Now that we've covered the structure and benefits of UUPS proxies, it’s time to discuss their vulnerabilities. Since UUPS shifts the upgrade logic from the proxy to the implementation contract, it introduces new attack surfaces that must be carefully managed. While some risks overlap with Transparent Proxies, UUPS has unique weaknesses that require additional precautions.
Let’s break down the most common vulnerabilities and how to mitigate them.
Lack of Access Control (Anyone Can Upgrade the Proxy)
Since the upgrade function is in the implementation contract, poor access control can allow anyone to call upgradeTo
, replacing the implementation with a malicious one.
How It Happens:
- The
upgradeTo
function is left public or does not properly check the admin role. - A stolen admin private key is used to upgrade to a malicious contract.
- The contract lacks multi-signature (multi-sig) governance, making upgrades too easy.
Storage Collisions (Breaking State Between Upgrades)
UUPS proxies share storage with their implementations, meaning that misaligned storage layouts in new implementations can corrupt contract state.
How It Happens:
- A new implementation reorders or removes storage variables, shifting storage slots.
- Developers forget to follow EIP-1967, causing unexpected overlaps.
- The upgrade process accidentally overwrites the admin or implementation slot, making the contract unusable.
Losing Admin Control (Permanent Loss of Authority)
If the admin address is set to address(0)
, the contract becomes unmanageable, making further upgrades impossible.
How It Happens:
- The admin address is accidentally removed in an upgrade.
- A developer tries to disable upgrades but unintentionally makes the contract unmanageable.
- An attacker tricks the contract into setting an unreachable admin address.
Lack of Access Control in Implementation Functions
How It Happens:
Even if the proxy enforces strict access control, the implementation contract itself might expose sensitive functions. If the implementation is not properly secured, attackers can call its functions directly, bypassing the intended proxy restrictions.
How It Happens:
- The implementation has critical functions (e.g.,
upgradeTo
,setAdmin
,withdrawFunds
) left public or insufficiently restricted. - An attacker calls these functions directly on the implementation contract instead of through the proxy.
- Because the implementation does not store state, interactions may not behave as expected, but in some cases, they can still be dangerous.
- Critical state variables stored in the proxy may be modified, leading to unauthorized upgrades, fund withdrawals, or loss of control.
Exploring the Remaining Proxy Patterns and Their Vulnerabilities
After looking at UUPS and Transparent Proxies, it’s time to explore the other proxy patterns available. Each comes with unique strengths and use cases but also introduces specific vulnerabilities that need to be addressed. Here’s a breakdown of the major proxy types, their features, and the common pitfalls they might encounter.
Beacon Proxy: Centralized Logic for Shared Updates
The Beacon Proxy pattern introduces a central contract, called the Beacon, which stores the address of the implementation. Multiple proxies use the same Beacon to determine where to delegate calls, making it a shared brain for upgradeable logic. When you upgrade the implementation in the Beacon, every proxy connected to it is updated simultaneously.
Key Features:
- Shared implementation across multiple proxies, reducing deployment costs.
- Efficient for factory-like setups where all instances require the same logic.
Main Vulnerabilities:
- Centralization Risk: If the Beacon contract is compromised, every linked proxy becomes vulnerable. An attacker could redirect all proxies to a malicious implementation.
- Unprotected Upgrades: Without proper access control, the
updateImplementation
function in the Beacon could be exploited to point proxies to an invalid or malicious contract. - Storage Collisions: Although the Beacon handles the implementation, the proxies themselves hold state. If storage layouts between the proxies and implementation are not aligned, this can lead to corrupted or overwritten data.
Minimal Proxy (Clones): Lightweight and Cost-Effective
Minimal proxies, often implemented using the EIP-1167 standard, are hyper-efficient proxies designed for gas savings. They delegate all function calls to a single implementation contract. Their minimal bytecode reduces deployment costs, making them ideal for scenarios where you need many identical instances of a contract.
Key Features:
- Extremely low gas costs for deployment.
- Ideal for mass production of proxies with identical logic.
Main Vulnerabilities:
- No Built-in Upgradeability: Minimal proxies are not inherently upgradeable. While you could create a mechanism for upgrading the implementation they delegate to, this is not part of their default design.
- Shared Implementation Risks: Since all clones rely on the same implementation, any bug or vulnerability in the implementation impacts all proxies.
- Oversight on Initialization: Developers must manually initialize the state of each proxy, and improper initialization can lead to inconsistent or exploitable states.
Diamond Proxy (EIP-2535): Modular and Extensible
The Diamond Proxy is the most complex and flexible pattern, allowing a single proxy to delegate calls to multiple implementation contracts, called facets. Each facet handles a specific set of functions, enabling modularity and extensibility.
Key Features:
- Supports modular architecture, where functionality is split across multiple contracts.
- Allows upgrading individual facets without affecting others.
Main Vulnerabilities:
- Increased Attack Surface: Each facet introduces its own entry points and potential vulnerabilities. A single compromised facet can jeopardize the entire system.
- Complex Storage Management: Managing shared storage across multiple facets requires extreme care. A mismatch in storage layouts can corrupt data or cause unexpected behavior.
- Access Control Mismanagement: Ensuring consistent access control across all facets is challenging. If one facet has weaker controls, it could be exploited to affect the entire system.
Static Proxy: Immutable Logic, Upgradeable State
In a Static Proxy, the logic is fixed and cannot be changed. The proxy delegates calls to a single implementation that remains constant, while the proxy itself manages the state. This eliminates the risk of faulty upgrades but sacrifices flexibility.
Key Features:
- Immutable logic, making it easier to audit and secure.
- Proxy handles state, while the implementation remains static.
Main Vulnerabilities:
- Permanent Bugs: If there’s a bug in the implementation, it cannot be fixed because the logic is immutable. The entire system would need to be redeployed, potentially losing state.
- State Migration Risks: When deploying a new proxy to replace the old one, migrating the state requires careful handling. Errors in migration can lead to data loss or inconsistencies.
- Limited Flexibility: The inability to update logic makes it unsuitable for systems that need to adapt to changing requirements or add new features.
Conclusions
By now, you should have a very clear understanding of UUPS Proxies, their benefits, and the many ways they can be turned into a disaster if not handled properly. The shift of upgrade control from the proxy to the implementation makes them more efficient, but at the same time, it widens the attack surface. If access control is weak, an attacker can upgrade your contract to literally anything—and you don’t want to wake up one day to find your proxy forwarding calls to a black hole.
What have we learned?
- Upgradeability is powerful—but dangerous. Anything that changes state needs to be locked down.
- Storage collisions are real. Misalign your storage, and suddenly, variables start behaving like a drunk oracle.
- Never assume that functions will only be called through the proxy. Attackers love when implementations leave critical functions exposed.
- Losing control of an upgrade mechanism is worse than not having one at all. A misplaced
address(0)
or bad ACL, and your contract is frozen in time—or worse, permanently hijacked.
And the best part? This is just one type of proxy.
The world of proxy-based upgrades is vast, and UUPS is far from the only game in town. We’ve only scratched the surface of how developers attempt to balance upgradeability and security. Up next, we’ll tear apart some other common proxy architectures—Beacon Proxies, Minimal Proxies, Diamond Proxies, and Static Proxies—each with its own unique design flaws just waiting to be exploited.
Because at the end of the day, understanding how to break something is the best way to know how to secure it. 🚀
References
- Foundry - A Blazing Fast, Modular, and Portable Ethereum Development Framework. "Foundry Documentation." Available at: https://book.getfoundry.sh/
- Solidity - Understanding delegatecall and Low-Level Functions. "Solidity Documentation." Available at: https://docs.soliditylang.org/en/latest/introduction-to-smart-contracts.html#delegatecall-callcode-and-call
- Trail of Bits - Common Pitfalls with delegatecall. "Trail of Bits Blog." Available at: https://blog.trailofbits.com/
- Storage in Solidity - Detailed Analysis of Solidity Storage Mechanics. "Solidity Documentation." Available at: https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
- Ethereum - Open-Source Blockchain Platform for Smart Contracts. "Ethereum Whitepaper." Available at: https://ethereum.org/en/whitepaper/
- Anvil - A Local Ethereum Development Node for Testing Smart Contracts. "Anvil Documentation." Available at: https://book.getfoundry.sh/anvil/
- OpenZeppelin - Secure Smart Contract Libraries. "OpenZeppelin Contracts Documentation." Available at: https://docs.openzeppelin.com/contracts
- EIP-1967 - Standardized Storage Slots for Proxy Contracts. "Ethereum Improvement Proposals." Available at: https://eips.ethereum.org/EIPS/eip-1967
- EIP-1822 - Universal Upgradeable Proxy Standard (UUPS). "Ethereum Improvement Proposals." Available at: https://eips.ethereum.org/EIPS/eip-1822
- OpenZeppelin UUPS Proxies - Best Practices for Secure Upgradeable Contracts. "OpenZeppelin Documentation." Available at: https://docs.openzeppelin.com/contracts/4.x/api/proxy
- Smart Contract Security Best Practices - A Comprehensive Guide to Securing Upgradeable Contracts. "Consensys Diligence." Available at: https://consensys.net/diligence/blog/
Chapters

Previous chapter
Next chapter
