The story of CVE-2024-45304 — a deceptively simple vulnerability in OpenZeppelin's Cairo contracts that could let an attacker reclaim ownership of a contract the entire world believed was ownerless.
I was exploring the Cairo language for an audit contest ArkProject hosted on CodeHawks. It was a quiet 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.
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:
transfer_ownership(new_owner) — this sets new_owner as "pending."
accept_ownership() — this finalizes the transfer.
Clean, safe, elegant. OpenZeppelin nailed this in Solidity. Their Cairo port? Not quite.
I was tracing the ownership flow function by function. Not fuzzing, not running automated scanners — just reading code, which 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.
Here's the scenario:
transfer_ownership(Bob) — Bob is now the pending owner.renounce_ownership() — owner is now zero. The contract is "ownerless."accept_ownership().⚠ 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.
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:
accept_ownership().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.
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.
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. 👻