All audits
FailedEthereumIndependent

Hedgey Flash-Claim (2024)

Smart contract security audit by TierZero · 2024-04-15

Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.

Findings summary

1
Critical
2
High
1
Medium
1
Low
1
Informational

Independent research — not a commissioned audit.

What the protocol did

Hedgey Finance runs token-vesting and airdrop infrastructure: projects lock up tokens for investors, employees, or claimants under time-based unlock schedules, and recipients pull their allocation via a claim contract when it vests. The relevant surface for this incident is ClaimCampaigns.sol, the contract handling one-off token distribution campaigns (as opposed to the longer-running TokenLockupPlans/VotingTokenLockupPlans vesting NFTs). Instead of requiring campaign creators to deposit tokens into escrow up front, the design let a funder grant the contract a standing ERC20 approve() and then reference that allowance when a campaign was created, pulling tokens via transferFrom at claim time. That "approve now, pull later" pattern is convenient — and it is exactly the kind of standing-authorization surface we flag whenever we review approval flows, the same class of footgun we cover in our piece on Permit2-style gasless approvals and EVM sniping bots.

In April 2024, that pattern was turned against the protocol. Attackers drained multiple campaigns, with publicly reported losses reaching well into eight figures across the affected token treasuries — widely cited near $44 million, though we treat that headline number as directional rather than verified line-by-line, since post-incident accounting across chains and campaigns was still being reconciled by various outlets in the days after.

The vulnerability

The root cause was a missing binding between who authorized tokens to move and who the claim actually deducted from. Campaign-creation and claim functions accepted a caller-supplied address as the token source/funder rather than deriving it strictly from msg.sender, and the claim path trusted attacker-controlled parameters (recipient, amount, and effectively the funder reference) without re-validating them against an immutable, per-campaign record of what had actually been committed.

A simplified illustration of the flawed shape:

// simplified — illustrates the pattern, not the literal source
struct Campaign {
    address tokenAddress;
    address funder;      // attacker-influenced at creation time
    uint256 totalAmount;
}

function createCampaign(Campaign calldata c, ...) external {
    campaigns[campaignId] = c;   // funder not forced to == msg.sender
}

function claimTokens(uint256 campaignId, uint256 amount, ...) external {
    Campaign storage c = campaigns[campaignId];
    // pulls from c.funder's standing approval, not an escrowed balance
    IERC20(c.tokenAddress).transferFrom(c.funder, msg.sender, amount);
}

Any address that had ever left a large or unlimited approve() on the ClaimCampaigns contract — for a legitimate, unrelated vesting campaign — was a viable funder reference for a brand-new, attacker-created campaign. The contract had no invariant tying "tokens claimable under campaign X" to "tokens actually escrowed for campaign X." Allowance became a shared, un-metered resource instead of a commitment scoped to one specific distribution.

How the exploit worked

  1. The attacker enumerated addresses that had granted ClaimCampaigns a live ERC20 allowance — token issuers who had set up legitimate vesting or airdrop campaigns and left the approval in place afterward.
  2. For a target with a large standing allowance, the attacker called the campaign-creation function, supplying the victim's address as the funder and themselves as the sole recipient, with a large claimable amount.
  3. Because campaign creation did not require a signature, deposit, or on-chain proof of intent from the named funder, the malicious campaign was accepted as valid.
  4. The attacker immediately called the claim function against their own campaign. transferFrom executed against the victim's allowance, moving real tokens to the attacker in the same or a follow-up transaction — the "flash-claim" pattern: create, then drain, before anyone could revoke the approval.
  5. This was repeated across every address with a sufficiently large outstanding approval, compounding losses across unrelated projects that had used Hedgey purely as vesting infrastructure and had no reason to expect their allowance could be redirected by a stranger's campaign.

How an audit catches this

This is a textbook case for the checks we run on any contract that couples ERC20 approve/transferFrom with user-defined data structures:

  • Funder-authenticity check: flag any code path where a transferFrom source address is taken from calldata or a struct field rather than msg.sender or a value cryptographically tied to the actual approver (signature, merkle leaf, or prior on-chain deposit event).
  • Escrow-vs-allowance test: write a fork test that creates a campaign referencing an unrelated address's pre-existing approval and asserts the claim reverts. If it doesn't, the allowance is shared state, not scoped state.
  • Invariant: "total claimable across all campaigns funded by address F must never exceed F's actual commitment," enforced on-chain, not just assumed off-chain.
  • Access-control matrix: every state-mutating entry point gets a table of "who should be allowed to set this field," and any field controlling fund movement that a non-owner can set is an automatic finding.

This is the same discipline behind formal invariant checks we describe for trading systems in our write-up on formal verification for a Solana trading program — the chain differs, but "prove the invariant holds for every reachable state, not just the happy path" is portable. It's core to what we do in a smart contract audit and in narrower code review engagements focused on approval and custody logic.

Remediation

  • Require deposit-based escrow for campaign funding — tokens move into the contract at campaign creation, not pulled later against a standing allowance.
  • If approval-based funding must be kept for UX reasons, bind the approved amount cryptographically to a specific campaign ID at approval time (e.g., via a commit-reveal or signed authorization), and zero out any leftover allowance once a campaign closes.
  • Add reentrancy- and front-run-resistant guards around create-then-claim flows so a malicious campaign cannot be created and drained atomically.
  • Cap and time-box allowances; treat any indefinite approve(MAX_UINT256, ...) as a finding, not a convenience.

Teams shipping vesting, claim, or payout logic that touches standing ERC20 approvals should get that logic in front of an independent smart contract audit before mainnet, not after a campaign has already been drained.

Need your contracts audited?

Manual review + tooling across TON, Solana and EVM — certificate and full report included.

Get an audit