Blockchain security isn't optional.
Protect your smart contracts and DeFi protocols with Three Sigma, a trusted security partner in blockchain audits, smart contract vulnerability assessments, and Web3 security.
Get a Quote Today
Introduction to Zora
Zora is a decentralized content-creator protocol focused on NFTs and creator-driven markets. In Zora’s model, creators mint and sell NFTs with built-in royalties on an open marketplace. The recent “$ZORA” token launch on Base was positioned as a content coin for the community: a 10-billion–token “for fun” meme token (with no governance rights) that would be airdropped retroactively to early users of the Zora protocol. In theory, eligible users (creators, collectors, developers, etc.) could claim their ZORA allocations via the official claim contract (ZoraTokenCommunityClaim
) at the address 0x0000000002ba96C69b95E32CAAB8fc38bAB8B3F8. The Zora team communicated that claims must be made through the official site (claim.zora.co) to that contract, which would transfer the user’s allocated ZORA tokens to them.
Architecture Overview
ZoraTokenCommunityClaim (Base Airdrop Distribution)
The ZoraTokenCommunityClaim
contract was deployed to hold and distribute the 10% of ZORA’s supply reserved for the retroactive airdrop. In practice, the ZORA team transferred the allotted tokens into this contract and set up each eligible wallet’s allocation off-chain. The contract’s role is to enforce that only eligible users can claim the pre-allocated amount once claim processing is enabled. Internally it tracks state such as allocationSetupComplete
(a boolean that flips true once all allocations are loaded), claimStart
(a timestamp or block at which claiming is allowed), and a mapping accountClaims
to record which accounts have already claimed. (For scale: the airdrop totaled about 1,000,000,000 ZORA distributed to ~2.4 million addresses.)
Key functions include:
setAllocations(...)
– an owner-only call to register batches of recipient addresses and their token amounts (used during setup).completeAllocationSetup()
– finalizes the allocation phase (e.g. settingallocationSetupComplete=true
and/or recording theclaimStart
time) so that claiming can begin.claim()
– for an eligible user to claim their tokens; it checks that allocations are set up, that the sender has not yet claimed (accountClaims[msg.sender] == false
), then marks them claimed and transfers the tokens to the specified address.claimWithSignature(...)
– similar toclaim()
, but allows a third party to claim on behalf of a user by providing an EIP-712 signature from the user. This function takes an extra_claimTo
address parameter so tokens can be sent to any designated recipient.
Basic (0x Settler) Contract and basicSellToPool
The 0x “Settler” Basic flow is a generic one-step swap routine used for many on-chain liquidity sources. In this design, the Settler contract first ensures it can move the user’s tokens, then calls the target DEX/pool directly: the pool contract pulls the sell-side tokens and sends back the buy-side tokens. In code, the function basicSellToPool(IERC20 sellToken, uint256 bps, address pool, uint256 offset, bytes memory data)
orchestrates this. The argument sellToken
is the token being sold (use a special ETH sentinel for native ETH). The bps
(basis points) parameter indicates what fraction of the contract’s current balance to sell. The pool
is the address of the DEX contract to call, and data
is the encoded call data for that pool’s swap function. The offset
tells the Settler where in data
to overwrite the amount field.

Function logic and safety checks:
- First, it blocks any restricted target calls by checking
_isRestrictedTarget(pool)
and reverting (via aConfusedDeputy()
error) if triggered. This prevents misuse by banning certain addresses. - If
sellToken
is ETH, it computesvalue = (address(this).balance * bps) / BASIS
. If the provideddata
is empty (i.e. no calldata), it requiresoffset==0
and then does a plainpool.call{value: value}("")
, sending ETH directly. Otherwise, it adds 32 to the offset and storesvalue
intodata
at that location so the pool call will use the correct ETH amount. - If
sellToken
is an ERC-20 token (address != 0), it computesamount = sellToken.balanceOf(address(this)) * bps / BASIS
. It then writesamount
intodata
atoffset+32
. If the token’s address is different from the pool, it callssellToken.safeApproveIfBelow(pool, amount)
so the pool can pull the tokens (this is the approval logic). - Finally the contract executes the call:
(success, returnData) = pool.call{value: value}(data)
. It reverts on failure. After the call returns, it checks that if no data was returned (meaning a non-contract may have been called) then it reverts withInvalidTarget()
.
Security Audit Status
While Zora’s claim‑site FAQ states “Have the contracts been audited? Yes, by Zellic.”, no public audit report has been released. Zellic’s own list of published audits contains no entry for Zora and the verified source page for ZoraTokenCommunityClaim
on BaseScan shows “No Contract Security Audit Submitted.” Consequently, the community has no way to review the audit’s scope, findings, or remediation status.

Attack Analysis
A flaw in the ZORA community-claim contract allowed an attacker to hijack tokens allocated to 0x’s address. The attacker crafted a call to the 0x Settler (execute()
) that triggered a basicSellToPool
action with the ZORA claim contract as the target. Because the claim contract’s internal _claimTo(address user, address to)
function did not require msg.sender == user
, the Settler call caused the contract to issue 0x’s allotted ZORA tokens to the attacker’s address instead. In other words, the attacker sent a transaction to Settler’s execute()
, causing Settler to “sell” ZORA to the claim contract, which in turn executed the hidden _claimTo(attacker, attacker)
. The result was that ZORA tokens intended for 0x ended up in the attacker’s wallet.
Detailed Breakdown of the Attack
1) Attacker calls Settler.execute(): The attacker sent a transaction to the 0x Settler V1.10 contract (address 0x5C9bdC80...
) invoking the execute(...)
function. The transaction input data encoded one action: basicSellToPool
. In this payload, the sellToken
was set to the ZORA token address, the pool
was set to the ZoraTokenCommunityClaim contract address, and the data
field was an ABI-encoded call to ZoraTokenCommunityClaim._claimTo(attacker, attacker)
.

2) _dispatch and basicSellToPool: When execute() ran, Settler’s internal _dispatch logic routed this action to basicSellToPool(...). In the Settler code, basicSellToPool checks that the pool address is allowed, then performs a low-level call:

3) Here, pool was the ZORA claim contract and data was the encoded _claimTo(attacker, attacker). Because the Settler contract does not restrict calling arbitrary contracts, it forwarded the call.

4) Claim contract executes _claimTo: The low-level call invoked ZoraTokenCommunityClaim._claimTo(attacker, attacker). Inside the claim contract, _claimTo authorizes the transfer of the user’s ZORA allocation to the specified address. Because _claimTo does not check that msg.sender equals the user, it simply processed the claim. As a result, ZORA tokens were transferred from the claim contract to the attacker’s address.

How the Attack Could Have Been Prevented
The core issue was the lack of access control in the claim contract. A simple fix is to require that the caller of _claimTo
is the account benefiting from the claim. For example, adding a require(msg.sender == _user, ...)
check in _claimTo(user, to)
would ensure only the actual recipient can trigger their claim. Alternatively, the claim contract could have only exposed a claim()
function for the sender’s own address, or required valid Merkle proofs signed by the recipient. As it stood, Settler could invoke _claimTo
as an arbitrary caller.
On the 0x Settler side, the contract is a general-purpose executor, so its behavior was not inherently faulty – it did exactly as programmed. (In principle, Settler could blacklist known “claim” contracts as restricted targets, but that is impractical to maintain for every airdrop.) In short, the vulnerability lay in the claim contract’s design. Strictly enforcing msg.sender
in the claim logic would have prevented the exploit, since Settler’s call would then have failed.
The 0x documentation explicitly warns:

Consequences
The attacker effectively diverted ZORA tokens from 0x’s community allocation into their own wallet (roughly $128k by some estimates) were taken from 0x’s allotment. Beyond the financial loss, this incident undermined confidence in the airdrop. The chaotic launch (during which even BaseScan briefly went down).

Subsequent exploit contributed to price volatility – ZORA’s market cap plunged over 60% in the hours after the airdrop launch.

The Protocol’s Response
As of this writing, no formal public post-mortem or patch announcement from Zora Labs is available.
The 0x Project team did clarify on social media that the Settler contract itself was not “hacked” or faulty – it simply executed as designed when tokens were present. In effect, 0x personnel noted that the exploit was due to token allocation, not a Settler code bug.

Addresses
- Settler V1.10 (0x Protocol) – 0x5C9bdC801a600c006c388FC032dCb27355154cC9 (0x: Settler v1.10 contract on Base)
- ZORA Token – (the ZORA ERC20 on Base; used in the airdrop)
- ZoraTokenCommunityClaim – 0x0000000002ba96C69b95E32CAAB8fc38bAB8B3F8 (Zora’s Base claim contract)
- Attacker’s Address – 0xC834496f208f0D2929f7aaFDDa7b0f66Fd616f70 (received the stolen ZORA)
- 0x: Deployer (Zora) – 0xBEBE537eFb8377629A1dFB1aC5c0568036E32712 (deployed the claim contract)
Frequently Asked Questions (FAQ)
- Was there a bug in the 0x Settler contract? No. The 0x Settler executor worked as designed. The exploit was possible only because the ZORA claim contract itself let anyone call
_claimTo(...)
on behalf of any user. In other words, 0x’s code was not at fault – the issue was that the airdrop contract accepted the Settler’s call and transferred tokens as if it were a valid claim. - How did the attacker trigger the claim? The attacker encoded a
basicSellToPool
action in a Settlerexecute()
call, with the ZORA token and the claim contract address as parameters. This caused Settler to perform a low-levelpool.call(...)
into the claim contract, which executed the hidden_claimTo(attacker, attacker)
call. No permission or signature was needed because the contract didn’t check the caller. - How much was stolen? Exact totals are still being tallied. Each claim transferred only a few ZORA tokens, but the attacker could repeat it. On-chain evidence shows the attacker receiving ZORA repeatedly (for example, 4.2417 ZORA in one transaction). Reports suggest roughly $100–$200K USD worth of ZORA ended up in the attacker’s wallet (corresponding to 0x’s entire airdrop allocation).
- Could this happen to others? Only if the other project’s claim contract has a similar bug. Any smart contract that allows an external caller to “claim to” an arbitrary address without checking
msg.sender
would be vulnerable. In fact, airdrop contracts should generally require that only the recipient’s own address can be claimed. Projects should review their claim logic to prevent this. - Will stolen tokens be recovered? Unlikely on-chain. Blockchain transactions are irreversible. Unless the attacker voluntarily returns the tokens or is forced by off-chain action, the stolen ZORA are probably gone. The affected project (Zora) may decide to compensate 0x or burn/mint additional tokens to balance out the loss, but that is a governance decision, not a smart-contract fix.
- What’s the main lesson? Security of auxiliary contracts (like airdrop claimers) is critical. Even if a core protocol contract is secure, it can be tricked by interacting with poorly designed external code. In this case, the permission model in the claim contract was the root issue. Developers should assume that any call they expose could be invoked by a contract, not just a user wallet, and code accordingly (e.g. enforce
msg.sender
== owner checks).
