DeltaPrime Prime Account DiamondBeacon init() Selector Re-initialization Hijack (2024-07-23)

  • Name: DeltaPrime Prime Account DiamondBeacon init() selector re-initialization hijack
  • Date: 2024-07-23
  • Protocol: DeltaPrime
  • Chain: Arbitrum
  • Category: unprotected initializer / re-initialization ownership takeover / storage-layout collision / proxy selector exposure
  • Loss: about 900k later returned and the remaining ~$100k covered by DeltaPrime
  • Summary: DeltaPrime’s leveraged user vaults (Prime Accounts) used a DiamondBeaconProxy-style architecture where account contracts stored user state while shared implementation contracts were reached through delegatecall. A deployment-only DiamondBeacon.init() path with selector 0xe1c7392a was mistakenly left reachable through live Prime Accounts even though the accounts were meant to expose only their own initialize() path (0xc4d66de8). The two initializer routines checked different _initialized flags in different custom storage slots, so calling the beacon’s init() logic from an already-initialized Prime Account bypassed the account’s real one-time-init guard while still writing a new owner into the Prime Account state slot. After seizing ownership of 13 victim Prime Accounts, the attacker repaid each account’s debt, unwound positions, and withdrew the collateral, draining about 290.3 ETH before DeltaPrime paused Prime Accounts.
  • Trigger / entry point:
    • DeltaPrime Prime Accounts were proxy-style storage contracts that resolved function selectors through a shared DiamondBeacon singleton.
    • The beacon still exposed a deployment-time init() method with selector 0xe1c7392a after production launch.
    • Prime Accounts did not implement that selector themselves, so selector resolution forwarded the call into DiamondBeacon logic and executed it inside the Prime Account storage context via delegatecall.
    • The beacon initializer checked its own _initialized flag in beacon storage namespace rather than the Prime Account initializer flag in the account’s general-state namespace.
  • Root cause:
    • DeltaPrime left a deployment-only initializer mounted on the live shared-implementation surface instead of removing it once setup was complete.
    • The beacon-side init() and account-side initialize() routines both tried to enforce one-time initialization, but they read different _initialized booleans from different custom storage slots.
    • Because delegatecall executed beacon code against Prime Account storage, the beacon initializer’s guard looked at the wrong slot, saw false, and proceeded.
    • That same call path still wrote the owner field into the Prime Account’s general-state slot, so a function that should only have been usable during system bootstrap became a live ownership-transfer primitive.
  • Exploit path:
    1. The attacker first rehearsed the bug against their own Prime Accounts to confirm that init() could illegitimately change account ownership without tripping the account’s normal initialization guard.
    2. Starting around 2024-07-23 22:15 UTC+2, the attacker called the stray init() selector on victim Prime Accounts, causing beacon logic to run in each account’s storage context and overwrite the account owner with an attacker-controlled address.
    3. Once owner of a victim Prime Account, the attacker interacted with ordinary Prime Account methods to unwind leveraged positions and repay borrowed funds. DeltaPrime emphasized that the attacker still had to respect the remaining solvency and withdrawal guards; the ownership hijack did not disable those checks.
    4. After repaying the debt, the attacker withdrew the freed collateral from each hijacked Prime Account.
    5. Across 13 accounts, the attacker extracted about 290.3 ETH before DeltaPrime confirmed the exploit and paused Prime Accounts at 2024-07-24 00:10 UTC+2.
  • What the attacker needed to be true:
    • Prime Accounts had to resolve unknown selectors through a shared beacon / facet map instead of rejecting unrecognized calls outright.
    • The production system had to keep the deployment-time init() selector live on the beacon.
    • The beacon initializer had to check a different _initialized slot from the one actually used to prove a Prime Account was already initialized.
    • The initializer logic also had to retain write access to the live Prime Account owner field.
    • Victim Prime Accounts had to hold withdrawable collateral that could be released after debt repayment and position unwind.
  • Technical anatomy worth remembering:
    • This is a useful hybrid of re-initialization and storage-layout / storage-namespace failure. The bug was not merely initializer left callable; it was wrong initializer left callable through a proxy selector surface, checking the wrong storage namespace, while still mutating the right ownership namespace.
    • DeltaPrime’s postmortem identified two custom storage slots that matter. Prime Account general state, including owner and its own _initialized flag, lived at 0x8d5bb42e0ac1496a2c326edc9c00758985246e6c2bb146d6c2f4a0d509e0960a. DiamondBeacon-specific state, including selector mappings and a different _initialized flag, lived at 0xc8fcad8db84d3cc18b4c41d551ea0ee66dd599cde068d998e57d5e09332c131c.
    • The memorable selector pair is init() -> 0xe1c7392a versus initialize() -> 0xc4d66de8. The first should have been extinguished after deployment; the second was the only initializer a Prime Account was supposed to honor.
    • The exploit shows why custom storage slots do not save a proxy architecture by themselves. They only help if every reachable implementation is explicitly scoped to the contracts that are supposed to call it.
    • It also shows why delegatecall systems need an explicit policy for decommissioning bootstrap selectors. Leaving them reachable turns deployment code into production authority.
  • Representative transactions and addresses:
    • First publicly cited victim-drain transaction: 0xc4350cf22cabfd88e4221d3496e84cd28841682d60ef89020622844e5879b99f
    • DeltaPrime’s onchain negotiation message to the attacker: 0xf09f71303f56d72b107c1d169b9a6fbe2633e4c02bd1c5e77470663fefdd2015
    • Prime Account state slot: 0x8d5bb42e0ac1496a2c326edc9c00758985246e6c2bb146d6c2f4a0d509e0960a
    • DiamondBeacon state slot: 0xc8fcad8db84d3cc18b4c41d551ea0ee66dd599cde068d998e57d5e09332c131c
    • Stray beacon initializer selector: 0xe1c7392a
    • Intended Prime Account initializer selector: 0xc4d66de8
  • Response / mitigation:
    • DeltaPrime paused Prime Accounts once the attack was confirmed, while leaving Savings accounts open because the lending side was not directly at risk.
    • The team timelocked and executed a fix removing the stray init() path from the DiamondBeacon, then worked with auditors to search for similar selector / storage-slot collisions.
    • DeltaPrime said two adjacent vectors were also found and fixed during the emergency review.
    • The attacker returned about $900k after onchain negotiation; DeltaPrime used its stability pool to make affected users whole for the remaining loss.
    • The protocol also added a 24-hour withdrawal delay after Prime Account owner changes, planned another full external audit, increased bug-bounty payouts, and said it would move toward whitelisting call sequences.

Pattern tags

Why the exploit worked

  • DeltaPrime let live Prime Accounts inherit more selector reach than their product surface implied. The account contract itself did not expose the dangerous deployment-only init() path, but selector fallback through the shared DiamondBeacon still made that bootstrap authority callable in production.
  • The decisive guard failure was not simply initializer missing. The beacon-side init() routine did check _initialized, but it checked the wrong lifecycle object: DiamondBeacon storage state rather than the already-funded Prime Account state whose owner field the call was about to overwrite.
  • Because the call executed through delegatecall, the same invocation could read freshness from one namespace and write ownership into another. That split let a function that should only have ever configured the beacon during rollout become a live ownership-reset primitive for user vaults.
  • DeltaPrime’s own safeguards then helped frame the exploit cleanly. The attacker still had to unwind positions, repay debt, and pass ordinary withdrawal logic after takeover, which proves the real exploit primitive was authority capture rather than some separate solvency bypass.
  • The pre-attack rehearsal also mattered. DeltaPrime’s postmortem says the attacker first changed ownership of several of their own accounts, generating ownership-change alerts that initially appeared benign. That is worth preserving because selector-surface exploits often arrive as looks like a valid admin action on the wrong object before the malicious pattern becomes obvious.

Detection and audit pivots

  • Build a full selector-reachability map for every proxy-style user account, not just an ABI list for the storage contract. The critical DeltaPrime path was Prime Account fallback -> DiamondBeacon selector lookup -> deployment-only init logic, and teams that only review the user-facing ABI will miss exactly that class of bug.
  • Separate three questions during review: can this initializer still be reached, which storage namespace proves it is already spent, and which privileged fields can it still mutate under delegatecall. DeltaPrime only fails once all three are composed.
  • Treat deployment-only or migration-only selectors as live admin surfaces until they are provably extinguished. Nobody is supposed to call this anymore is not a control if production selector routing can still reach it.
  • Monitor for rehearsal-shaped anomalies, not only confirmed drains. Repeated owner changes on attacker-controlled accounts, especially through rare selectors or unusual call traces, can be the dress rehearsal for a broader takeover campaign rather than harmless user behavior.
  • After any owner change on a leveraged account, ask what immediate value-extraction sequence becomes possible: debt repayment, position unwind, collateral withdrawal, module installation, or cross-account approvals. DeltaPrime’s added withdrawal delay is a strong signal that ownership changes themselves deserve post-event quarantine.

Why this incident matters

  • DeltaPrime is a sharp reminder that proxy / diamond incidents do not need a classic storage-slot collision between old and new implementations to become dangerous. It is enough for one reachable bootstrap function to authenticate against the wrong storage namespace while still mutating live authority.
  • It is also a strong example of why one-time initializer is not a property of a function name. In modular proxy systems, the real question is which initializer, checked against which slot, reachable through which selector table, in which storage context?
  • The healthy boundary that failed here was not abstract. Production Prime Accounts needed the beacon’s deployment-time init() selector to be extinguished or directly deauthorized once rollout was over, rather than left reachable through live selector routing.
  • The case is especially worth preserving because the attacker still had to use normal account flows after takeover. The exploit primitive was not arbitrary withdrawal; it was ownership capture of user-controlled leveraged accounts, which the protocol’s own withdrawal logic then honored.

Failure-prevention checklist

  • Extinguish deployment-only selectors once rollout is complete. If a shared beacon, facet registry, or singleton still exposes init()-style setup verbs, make sure live user accounts cannot route to them through selector fallback or facet lookup.
  • Keep one-time guards in the same storage namespace as the authority they protect. If owner lives in Prime Account state, the initializer bit that gates owner assignment must be read from that same effective state domain under delegatecall.
  • Inventory every selector a user-storage contract can resolve through shared dispatcher logic. The account does not implement this function itself is not a safety argument if selector routing can still source the logic elsewhere.
  • Test direct and indirect initialization paths against already-funded live instances. DeltaPrime’s dangerous path was not the intended initialize() entrypoint but the wrong initializer reachable through a different selector surface.
  • Deauthorize shared implementation code by context, not by convention. A beacon initializer should either be impossible to run from user-account context or provably unable to mutate user-account ownership even if it is reached.
  • Treat owner-change aftermath as a separate risk surface. DeltaPrime’s new 24-hour withdrawal delay is a useful reminder that high-impact authority changes should trigger temporary withdrawal quarantine even if the nominal owner-change feature remains valid.
  • Monitor for impossible production signals: post-launch init() selector hits, bursts of account-owner changes, selector routes that were only meant for deployment, and ownership changes immediately followed by debt repayment plus collateral withdrawal.
  • Review emergency fixes for sibling selector / storage-namespace collisions, not just the first confirmed exploit path. DeltaPrime’s follow-on audit found two adjacent vectors during the same incident window.

Audit prompts

  • Which selectors can a live Prime Account or proxy-style user vault resolve through shared beacon / facet / library routing, even if the storage contract itself does not define them?
  • For every initializer-like function reachable anywhere in that routing graph, what exact storage slot proves already initialized, and is it the same effective state domain that holds owner, admin, or module-install rights?
  • Can a deployment-only helper still be invoked from production user storage through fallback delegatecall, selector table lookup, or upgrade-era compatibility shims?
  • If two components both use _initialized, are reviewers proving semantic equality of the guard, or just noticing the same variable name in different storage namespaces?
  • What prevents a shared beacon or implementation initializer from writing privileged fields into user-account state when executed under delegatecall?
  • After an owner change, what controls delay or narrow immediate value extraction: withdrawal cooldowns, alerting, per-account pauses, or mandatory re-approval of risky call sequences?
  • Which runtime alerts would distinguish user legitimately migrated wallet ownership from many accounts are suddenly changing owners through the same unusual selector path quickly enough to matter?
  • During upgrades and incident-response patches, who re-runs the full selector-reachability map so deployment-time or legacy setup functions do not quietly become live again?

Lessons that should already be standard

  • In proxy and diamond systems, selector reachability is part of authorization. A function can be absent from the user-facing contract and still be a live production admin surface.
  • Initializer protected means nothing unless the guard authenticates against the same storage reality the privileged write will mutate.
  • Deployment-only code is not harmless just because the team is done deploying. If the selector still resolves in production, the authority still exists.
  • Shared storage-slot discipline is not only about avoiding random corruption. It is also about making sure lifecycle guards and privilege writes talk about the same object under delegatecall.
  • Ownership-transfer features deserve post-change friction when the account can custody meaningful collateral. A short withdrawal quarantine can turn ownership hijack from immediate drain into detectable incident.
  • Incident review should search sideways, not just forward. Once one wrong-selector / wrong-slot initializer path exists, nearby selectors and sibling storage domains deserve immediate suspicion.

Internal linkages

Control surface