The ghost owner: how i found a lurking vulnerability in OpenZeppelin’s cairo contracts

It was a quiet Tuesday night. I had a cup of tea going cold beside my laptop, three terminal tabs open, and the OpenZeppelin Cairo contracts source code staring back at me. I wasn’t looking for a bug — I was just trying to understand how ownership works in StarkNet’s ecosystem. But bugs don’t wait for you to look for them. Sometimes they find you.

This is the story of CVE-2024-45304 — a deceptively simple vulnerability in OpenZeppelin’s OwnableTwoStep component that could let an attacker reclaim ownership of a contract that the entire world believed was ownerless.

Let’s get some context first

If you’ve spent any time in Solidity land, you’ve probably seen the Ownable pattern a thousand times. One address gets special privileges — upgrading the contract, pausing it, withdrawing funds — the usual admin stuff. The problem with vanilla Ownable is that transferOwnership() is a one-shot deal. You type the new owner’s address, hit send, and if you fat-fingered even one character? That ownership is gone. Forever.

That’s why Ownable2Step (or OwnableTwoStep in Cairo’s naming) exists. It splits the transfer into two discrete steps:

  1. The current owner calls transfer_ownership (new_owner) — this sets new_owner as “pending”
  2. The pending owner calls accept_ownership() — this finalizes the transfer

Clean, safe, elegant. OpenZeppelin nailed this in Solidity. Their Cairo port? Not quite.

Down the rabbit hole

I was tracing the ownership flow function by function. Not fuzzing, not running automated scanners — just reading code, which honestly is still the most underrated skill in this industry.

The transfer_ownership() function sets a pending owner. Good.

The accept_ownership() function checks if caller == pending_owner, then transfers. Good.

Then I got to renounce_ownership().

This is the nuclear option. When an owner calls this, they’re saying: “I’m done. Nobody owns this contract anymore.” It’s meant to be a one-way door. Protocols use it as a trust signal — “look, we can’t rug you, nobody has admin access.”

But then I looked at what _transfer_ownership does in the OwnableTwoStep implementation, and… it only updates the owner. It doesn’t touch the pending owner.

I literally sat up straighter in my chair.

The Bug

Here’s the scenario:

  1. Alice is the owner of a contract
  2. Alice calls transfer_ownership(Bob) — Bob is now the pending owner
  3. Alice calls renounce_ownership() — owner is now zero. The contract is “ownerless”
  4. Bob calls accept_ownership()
  5. Bob is now the owner of a contract everyone thinks has no owner

That’s it. That’s the whole bug. The pending owner state survives the renunciation. It’s a ghost in the machine.

Why this is worse than it sounds?

The big deal is context changes. The renunciation is a public signal. People who deposited funds after the renunciation did so with the understanding that no one could pull admin-level actions. That assumption was wrong.

The deliberate exploitation path is even nastier:

  1. A malicious owner proposes themselves as the pending owner
  2. They publicly renounce ownership (“We believe in true decentralization!”)
  3. TVL flows in. Users trust the contract
  4. The attacker quietly calls accept_ownership()
  5. They now have full admin control. Classic rug

The solidity version handled this correctly

The Solidity implementation already had this covered. Their _transferOwnership override explicitly deletes the pending owner. The Cairo version simply didn’t port this behavior.

This is a pattern I see a lot when codebases get ported across languages. The logic gets translated, but the subtle safety invariants get lost.

The Fix

OpenZeppelin patched this in v0.16.0. The solution was simple — when _transfer_ownership is called, it now also resets the pending owner to zero.

One line. That’s all it took.

What security researchers should take away

  1. Trace State Across Function Boundaries – The bug lived between functions, in the state that one left behind for another to consume.

2. Map Every State Variable to Its Lifecycle – When is it set? When is it cleared? Is there a state where it’s set but shouldn’t be?

3. Test the “Undo” Paths – What happens if you start a transfer, then renounce? These branching sequences create unexpected state combinations.

4. Cross-Reference Implementations Across Languages – Porting introduces subtle regressions because implicit invariants don’t survive translation.

5. Ask “What If This Contract Were Malicious?” – What sequences of legitimate function calls could a malicious owner use to set a trap?

Closing Thoughts

This bug taught me something I keep re-learning: the scariest vulnerabilities aren’t in complex cryptographic primitives. They’re in the boring stuff — access control, state management, cleanup operations.

If you’re building on StarkNet with OpenZeppelin Cairo contracts, make sure you’re on v0.16.0 or later.

The ghost owner might be waiting.


CVE: CVE-2024-45304

Fix Commit: OpenZeppelin/cairo-contracts#1122

Affected Versions: < 0.15.1

Patched Version: 0.16.0

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *