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. setting allocationSetupComplete=true and/or recording the claimStart 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 to claim(), 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.

image

Function logic and safety checks:

  • First, it blocks any restricted target calls by checking _isRestrictedTarget(pool) and reverting (via a ConfusedDeputy() error) if triggered. This prevents misuse by banning certain addresses.
  • If sellToken is ETH, it computes value = (address(this).balance * bps) / BASIS. If the provided data is empty (i.e. no calldata), it requires offset==0 and then does a plain pool.call{value: value}(""), sending ETH directly. Otherwise, it adds 32 to the offset and stores value into data at that location so the pool call will use the correct ETH amount.
  • If sellToken is an ERC-20 token (address != 0), it computes amount = sellToken.balanceOf(address(this)) * bps / BASIS. It then writes amount into data at offset+32. If the token’s address is different from the pool, it calls sellToken.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 with InvalidTarget().

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.

image

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).

image

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:

image

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.

image

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.

image

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:

image

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).

image

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

image

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.

image

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)

  1. 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.
  2. How did the attacker trigger the claim? The attacker encoded a basicSellToPool action in a Settler execute() call, with the ZORA token and the claim contract address as parameters. This caused Settler to perform a low-level pool.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.
  3. 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).
  4. 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.
  5. 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.
  6. 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).
Simeon Cholakov

Simeon Cholakov

Security Researcher