All audits
FailedBlastIndependent

Munchables Insider Upgrade (2024)

Smart contract security audit by TierZero · 2024-03-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
2
Medium
1
Low
1
Informational

Independent research — not a commissioned audit. This is a post-mortem analysis of a publicly documented incident, based on on-chain data and public reporting. No new vulnerability in live software is disclosed here.

What the protocol did

Munchables was a GameFi/NFT-staking protocol deployed on Blast, the Ethereum L2 that pays out native yield on bridged ETH. Users locked ETH and Blast points into the protocol's "Locker" contracts in exchange for yield-bearing NFT positions ("Munchables") with game mechanics layered on top. Like most of the early Blast ecosystem, the core contracts were upgradeable proxies, deployed and maintained by a small, largely pseudonymous team — a structure common to fast-moving points-farming protocols but one that concentrates enormous trust in whoever holds deploy keys.

On March 26, 2024, roughly $62.5 million in ETH (about 17,400 ETH) was drained from Munchables' locking contracts in a single sequence of transactions. The unusual part of this case is not the drain mechanics — it's that the attacker was one of the protocol's own developers, operating under a pseudonymous identity, who had legitimate deploy/upgrade access to the contracts they later exploited. Within about a day, following public pressure and negotiation, essentially all funds were returned.

The vulnerability

This was not a reentrancy bug or a price-oracle manipulation. The bug class is insider-planted storage manipulation via privileged upgrade access — a malicious or backdoored implementation contract, pushed through a proxy upgrade by someone who already held the keys to do so, that let the attacker credit themselves a locked balance without ever depositing the underlying asset.

The general pattern looks like this:

// Illustrative reconstruction of the flawed pattern, not the actual source
contract Locker is UUPSUpgradeable, OwnableUpgradeable {
    mapping(address => uint256) public lockedTokens;

    // Added in a routine-looking upgrade, reachable by the deployer/owner key
    function migrateLock(address user, uint256 amount) external onlyOwner {
        lockedTokens[user] = amount;   // <-- no proof of a corresponding deposit
    }

    function withdraw(uint256 amount) external {
        require(lockedTokens[msg.sender] >= amount, "insufficient");
        lockedTokens[msg.sender] -= amount;
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

withdraw is written correctly against the contract's own bookkeeping — it checks the mapping, decrements it, and pays out. The flaw is upstream: nothing enforces that lockedTokens[user] can only grow when real ETH enters the contract. A privileged "migration" or "admin fix" function that writes directly to balance-accounting storage is a live invariant violation waiting for a single bad actor with deploy access.

How the exploit worked

  1. The insider held legitimate contributor privileges — either a deploy key on the proxy admin/owner, or the ability to get a new implementation contract approved and pushed with minimal independent review.
  2. A new implementation was shipped that included (or already contained, dormant) a function letting the caller set an arbitrary lockedTokens-style balance for any address, bypassing the normal deposit path entirely.
  3. The insider called this function against their own address, crediting themselves a locked balance vastly larger than anything they had actually deposited — on the order of the protocol's entire pooled ETH.
  4. They then called the standard, otherwise-correct withdraw/unlock function. It trusted the forged storage value and paid out real ETH that belonged to other users' deposits.
  5. The transactions drained essentially all withdrawable ETH from the Locker contracts in one sitting. Because the exploit rode entirely on legitimate-looking function calls from a legitimate privileged key, none of it tripped conventional monitoring built to catch external attackers.
  6. Blockchain investigators traced the flow quickly; the funds sat static in the attacker's wallet. Public pressure, plus reported identification of the developer as tied to a North Korea-linked fake-identity IT-worker scheme, led to voluntary return of the funds roughly a day later.

How an audit catches this

A pure logic review of withdraw() in isolation passes — the bug isn't there. What catches this is treating every privileged, storage-writing function as a first-class attack surface, not a trusted admin convenience:

  • Enumerate every onlyOwner/role-gated function and ask, for each one: can this write to a balance, supply, or accounting mapping without a corresponding, atomic transfer of the real asset? Any "yes" is a critical finding, full stop, regardless of who's allowed to call it.
  • Write and run an invariant test — sum(lockedTokens) <= address(this).balance — and fuzz it with every function in scope, including admin/owner-gated ones, in the actor set. This is standard practice on any protocol we cover under smart-contract audit engagements, and it's exactly the class of check that a checklist-driven code review and audit pass is meant to force onto the table even when the client insists "that function is only callable by us."
  • Require diffed, timelocked, multisig-gated upgrades — never a single EOA pushing a new implementation straight to production. If the team can't produce a diff and a second reviewer sign-off for a given upgrade, that's a process finding severe enough to block launch.
  • This is the same upgrade-key concentration risk we walk through for a different VM in our piece on upgradeable programs and loader-level risk — the chain changes, the trust-boundary problem doesn't.

Remediation

Split "propose an upgrade" from "execute an upgrade" across separate keys, with a mandatory timelock long enough for a third party to inspect the new bytecode. No accounting-mapping write should exist that isn't paired, in the same function, with a real asset movement — if a migration genuinely needs to move balances, it should move them from a verifiable source, not mint them from nothing. Rate-limit or circuit-break large withdrawals so a drain empties over hours, not one transaction, giving monitoring a chance to pause the contract. And treat insider risk as a named threat model in the audit scope, not an afterthought — the checklist we use for this is the same one behind our audit engagement checklist, adapted per chain.

If your protocol has upgrade keys held by fewer than three independent parties, that's worth a conversation before your next deploy — get in touch about a smart-contract audit.

Need your contracts audited?

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

Get an audit