Stratax Contracts

First Flight #57
Beginner FriendlyDeFi
100 EXP
Submission Details
Impact: high
Likelihood: medium

In-flight open/unwind transactions can execute against new implementation after beacon upgrade

Author Revealed upon completion

Root + Impact

Description

  • Describe the normal behavior in one or more sentences

  • Normal behavior: Users submitting openPosition or unwindPosition may assume their transaction will run with the implementation that was current when they signed or submitted it. In-flight transactions would complete against the old logic.

  • The proxy does not pin the implementation for in-flight transactions. The implementation is resolved at execution time on every call. When the beacon is updated before a user's transaction is mined, that transaction runs against the new implementation. BeaconProxy does not cache the implementation address; each external call invokes _implementation(), which reads from the beacon. So the code that runs is whatever the beacon returns at the moment the call is executed. In-flight open/unwind transactions do not complete safely against the old logic—they can execute against the new implementation mid-flight.

// OpenZeppelin BeaconProxy.sol
// @> Implementation is resolved on every call; no pinning to "old" logic for in-flight txs
function _implementation() internal view virtual override returns (address) {
return IBeacon(_getBeacon()).implementation();
}
// Proxy.sol — every delegation uses current _implementation()
// @> Next call after beacon upgrade will delegate to new implementation
function _fallback() internal virtual {
_delegate(_implementation());
}

Risk

Likelihood:

  • Upgrades occur without a timelock, so the beacon can be updated at any time. Any pending open/unwind transaction in the mempool or in a later block will use the implementation current at execution time.

  • Block ordering and MEV allow the beacon owner's upgrade transaction to be mined in the same block or before a user's already-submitted transaction, so the user's call runs against the new implementation.

Impact:

  • A malicious or buggy new implementation can steal funds, change fees, or alter behavior so that the user's open/unwind does something they did not intend. The user signed for one set of semantics but execution uses another.

  • A new implementation with different storage layout or function semantics can cause the in-flight call to revert, leave storage inconsistent, or corrupt state, leading to stuck positions or loss of funds.

  • Users and integrators may assume in-flight txs complete against the old logic; that guarantee does not hold. With no timelock, there is no on-chain window to cancel or avoid execution under the new implementation.

Proof of Concept

The PoC shows that the implementation is resolved at execution time. We upgrade the beacon to a new implementation, then call the proxy in the same test. The proxy's next call is served by the new implementation—there is no pinning to the implementation that was current when the "logical" request was made. This simulates an in-flight tx: a user's transaction that is executed after an upgrade runs against the new implementation. Run: forge test --match-contract PoC_InFlightTxNewImplementation -vvv.

// test/unit/PoC_InFlightTxNewImplementation.t.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import {Test} from "forge-std/Test.sol";
import {Stratax} from "../../src/Stratax.sol";
import {StrataxOracle} from "../../src/StrataxOracle.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
import {ConstantsEtMainnet} from "../Constants.t.sol";
/**
* @title PoC: In-flight txs execute against new implementation after beacon upgrade
* @notice Proof of Concept for finding "In-flight open/unwind can run new implementation"
* @dev Run with: forge test --match-contract PoC_InFlightTxNewImplementation -vvv
*/
contract PoC_InFlightTxNewImplementation is Test, ConstantsEtMainnet {
Stratax public implV1;
UpgradeableBeacon public beacon;
BeaconProxy public proxy;
Stratax public stratax;
StrataxOracle public strataxOracle;
address public beaconOwner;
function setUp() public {
beaconOwner = address(0x1);
vm.mockCall(USDC_PRICE_FEED, abi.encodeWithSignature("decimals()"), abi.encode(uint8(8)));
vm.mockCall(WETH_PRICE_FEED, abi.encodeWithSignature("decimals()"), abi.encode(uint8(8)));
strataxOracle = new StrataxOracle();
strataxOracle.setPriceFeed(USDC, USDC_PRICE_FEED);
strataxOracle.setPriceFeed(WETH, WETH_PRICE_FEED);
implV1 = new Stratax();
vm.prank(beaconOwner);
beacon = new UpgradeableBeacon(address(implV1), beaconOwner);
bytes memory initData = abi.encodeWithSelector(
Stratax.initialize.selector,
AAVE_POOL,
AAVE_PROTOCOL_DATA_PROVIDER,
INCH_ROUTER,
USDC,
address(strataxOracle)
);
proxy = new BeaconProxy(address(beacon), initData);
stratax = Stratax(address(proxy));
}
/// @dev PoC: Next call after upgrade runs new implementation (simulates in-flight tx running new code)
function test_PoC_NextCallAfterUpgradeUsesNewImplementation() public {
address implBefore = beacon.implementation();
assertEq(implBefore, address(implV1), "Beacon starts at V1");
Stratax implV2 = new Stratax();
vm.prank(beaconOwner);
beacon.upgradeTo(address(implV2));
// Simulate: user's tx is mined after upgrade. Proxy resolves impl at execution time.
(bool ok,) = address(proxy).call(abi.encodeWithSignature("owner()"));
assertTrue(ok, "Call succeeds");
// Implementation used is V2 (beacon.implementation() now returns V2)
assertEq(beacon.implementation(), address(implV2), "Beacon points to V2");
// So this call was served by V2 — in-flight tx did not run against "old" logic
assertEq(stratax.owner(), address(this), "Proxy state intact; call was delegated to new impl");
}
}

Recommended Mitigation

Use a timelock as the beacon owner so upgrades take effect only after a delay, giving users time to avoid submitting new txs or to exit before the new implementation is active. Document that the implementation is resolved at execution time and that pending txs are not pinned to the implementation current at submit time.

// Deployment: use TimelockController as beacon owner (same as FINDING_BEACON_CENTRALIZED_UPGRADE_NO_TIMELOCK)
+ import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
strataxImplementation = new Stratax();
+ uint256 minDelay = 2 days;
+ address[] memory proposers = new address[](1);
+ proposers[0] = beaconAdmin;
+ address[] memory executors = new address[](1);
+ executors[0] = beaconAdmin;
+ TimelockController timelock = new TimelockController(minDelay, proposers, executors, address(0));
- beacon = new UpgradeableBeacon(address(strataxImplementation), beaconOwner);
+ beacon = new UpgradeableBeacon(address(strataxImplementation), address(timelock));
proxy = new BeaconProxy(address(beacon), initData);
+ // Document: implementation is resolved at execution time; in-flight txs are not pinned to old logic.

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!