
On most blockchains, everything a contract stores is public. Anyone can read any variable out of any contract at any time. This is why most crypto protocols are publicly auditable.
On Oasis Sapphire, storage is confidential, so a contract can hold a value that nobody outside it can read, including the people who deployed it. That's what allows a contract to hold and use private keys without anyone ever seeing it.
Now add the thing that pretty much every protocol features: upgradeable contracts. A proxy holds the storage, a separate implementation holds the logic, and the logic can be updated later. Every update is public which is what makes it trustworthy in the first place.
But put these facts together, and it invites potential problems.
Background
Back in May, while out on a walk, it occurred to me that an upgrade doesn't have to be submitted to be executed. You can simulate it. And a simulated upgrade runs attacker supplied code against live confidential storage while leaving nothing behind.
It took me a while to work out whether you could get anything useful out of that. Turns out you can. In a nutshell, given 256 attempts, you can reconstruct all 256 private key bits without a single transaction ever reaching the chain.
Below is a rundown of how this attack works and the upgrade we implemented to fix it - now in the Sapphire contracts library for anyone to use.
The Upgrade Path
The mechanism underneath all of this is DELEGATECALL. Contract A hands its storage to contract B, then B's logic executes against A's state, and the caller gets a result as though A had done the work itself. A is the proxy, formalized in ERC-1967.
From the outside, calling a proxy looks identical to calling a plain contract.
ERC-1822, the Universal Upgradeable Proxy Standard, builds upgrades on top of that. OpenZeppelin's UUPSUpgradeable is the implementation almost everyone uses, and two of its functions matter here:
- upgradeToAndCall(address newImplementation, bytes data), called by whoever holds upgrade rights
- _authorizeUpgrade(address newImplementation), which the developer implements to decide who that is
The data argument is an initializer. It runs immediately after the swap, in the new implementation's code, against the proxy's storage. It exists for migrations, so new code doesn’t need to carry knowledge of an old storage layout.
That initializer is basically the whole attack.
Simulation Attacks
The simulation attacks of this kind have been known for a while and are even described in our docs.
For instance, a contract offers to reveal a secret if you send it a million dollars. Rather than sending anything, you simulate the call with eth_call. The contract can't distinguish a simulated call from a real one, so it checks the msg.value, sees what it expects, and reveals, while the attacker never pays.
Simulation is free, unlogged execution against live state. On a transparent chain it costs you nothing, because the state was readable anyway.
Confidentiality is what makes this an attack surface.
The Attack
I was thinking about a contract holding private keys for vaults, and whether someone with upgrade rights could reach into it unnoticed.
First, the upgrader signs upgradeToAndCall and simulates it instead of submitting. During that simulation, an implementation they wrote has full access to proxy storage, confidential keys included, with no onchain record.
But the problem for the attacker is how to extract anything. Simply adding a getSecretKey() method doesn't help, because calling it would be a second transaction and the simulation is over by then. Calling it inside OpenZeppelin's upgradeToAndCall also doesn’t work because the helper doesn’t forward any initializer's return values either, so dead ends there, too.
However there is one possibility. The upgrade call either reverts or it succeeds, and the attacker is standing outside watching which one happens.
One bit is enough.

After 256 simulated upgrades, each probing a single bit, the key is fully reconstructed. Nothing recorded onchain, so there’s no evidence to review.
The cost is 256 signed upgrade transactions, tedious but well within reach for anyone holding multisig keys.
This gets worse if the master vault key is obtained or the keys aren't regularly rotated. The attack only has to succeed once, at any point in the past, and the vault is compromised from then on. Auditing the current deployment tells you nothing about whether it already happened.
We caught this issue internally and held all deployments until it was properly assessed and remediated.
Solution One: Disable Initializer Calls
The obvious solution is to stop the upgrader from calling the initializer. Drop the data argument, permit the implementation update and nothing else. No attacker code runs, so there's nothing to observe.

On review, it seems this doesn’t really solve the problem. It holds only if the upgrader is an externally owned account. We use a Safe, and a smart account can batch calls. Upgrade in the first call, invoke the malicious function in the second, all inside the same simulated batch.
So the result is the same outcome, one step further along.
Solution Two: The Block Boundary
The one thing a simulation genuinely cannot do is cross a block. So we made the upgrade take two steps that can't both happen inside one block.

At execution, the contract checks that the proposed implementation address and runtime hash match what's actually being executed, and that the proposed block number has been reached.
A simulated attacker can propose. They can never arrive at the block where that proposal becomes executable, so the probe never runs.
Within the contract itself, there are a few key changes. OpenZeppelin’s UUPSUpgradeable becomes UPUPSUpgradeable, and _authorizeUpgrade gains a sibling, _authorizeProposeUpgrade, where you also decide who is allowed to propose.

Deployment picks up an extra step, and multisig signers now sign twice, once for the proposal and once for the upgrade.
We packaged it as UPUPS, the Universal “Proposeable” Upgradeable Proxy Standard. It was simplified for general use in a follow up, and is now part of the official sapphire-contracts library in 0.2.18. The Privana services that prompted the work run on it now.
Wrapping up
Simulation attacks are native to confidential chains. They don't exist on transparent ones, so a lot of what counts as settled practice elsewhere has to be worked out again here. That work is ongoing, and UPUPSUpgradeable is the latest piece of it, now in sapphire-contracts for anyone building with Oasis infra.
If you take it elsewhere, verify the one thing it rests on: that your execution environment can't evaluate a query across two blocks. Sapphire doesn't expose eth_simulateV1, which can simulate 256 blocks in a single request. Another chain may not make the same choice.
Anywhere a contract reads state its caller can't, the question is the same: what can an attacker execute that leaves no record, and how much do they learn from nothing more than whether it succeeded.





