← Back to Blog

$24K, Rank 3/409 — The Move Bug That Would Have Bricked AAVE on Aptos Before Day One

A $24K finding from the AAVE Aptos V3 audit on Cantina. The bug was embarrassingly simple — data stored at one address, every getter reading from another. The entire protocol would have been dead on arrival.

✍ 0xTheBlackPanther 📅 Feb 14, 2026
$150K Total Prize Pool
409 Competitors
3rd Final Rank
$24,293 Payout (GHO)

Why This Contest Mattered

AAVE V3 on Aptos wasn't just another deployment. This was AAVE's first-ever expansion beyond EVM-compatible chains — a move from the Solidity world it was born in to Move, a completely different language with a completely different resource model. Aptos, built by former Meta/Diem engineers, offered high throughput and a fresh DeFi ecosystem. AAVE was betting big on it.

When Cantina posted the contest — $150,000 GHO prize pool, 409 auditors competing — I knew the codebase would be interesting. Porting a battle-tested protocol like AAVE to a new language is exactly the kind of work where subtle bugs hide in plain sight. I'd seen this movie before with OpenZeppelin's Cairo contracts, where implicit safety invariants got lost during the port from Solidity.

This time, the bug was even simpler. And the impact was even worse.

The Setup — Move's Resource Model

If you're coming from Solidity, Move's storage model takes a minute to get used to. In Solidity, contract state lives at the contract's address — you deploy to 0xABC, and your storage lives at 0xABC. Simple.

In Move (Aptos's smart contract language), data is stored as resources under specific account addresses. When you call move_to(account, resource), that resource lives at that account's address. To read it back, you call borrow_global<ResourceType>(address) — and you have to specify the exact same address where it was stored.

Get the address wrong, and the VM throws ERESOURCE_DNE — Resource Does Not Exist. The transaction aborts. No fallback, no default value, just a hard revert.

Keep that in mind.

Down the Rabbit Hole

I was going through the codebase module by module, starting from the data layer — the foundation everything else builds on. In AAVE Aptos, the configuration data (price feeds, underlying assets, reserve configs) lives in a dedicated module called aave-data.

The initialization function was clean and straightforward:

// aave-data/sources/v1.move fun init_module(account: &signer) {
    assert!(signer::address_of(account) == @aave_data, ...);
    move_to(account, Data { ... }); // ✅ Stores resource at @aave_data
}

Perfect. The data resource gets initialized and stored at @aave_data. That's where it lives. That's the only place it lives.

Then I went to the getter functions — the functions every other module in the protocol calls to retrieve configuration data. Price feeds, reserve configs, underlying assets — everything flows through these getters.

// Getter functions — all of them public inline fun get_price_feeds_testnet(): ... {
    &borrow_global<Data>(@aave_pool).price_feeds_testnet // ❌ Wrong address!
}

public inline fun get_underlying_assets_testnet(): ... {
    &borrow_global<Data>(@aave_pool).underlying_assets_testnet // ❌ Wrong address!
}

public inline fun get_reserves_config_testnet(): ... {
    &borrow_global<Data>(@aave_pool).reserves_config_testnet // ❌ Wrong address!
}

// ... same pattern across ALL getters

I stared at this for a good ten seconds before it clicked.

Every single getter function was reading from @aave_pool. But the data was stored at @aave_data. These are two completely different addresses.

The Mismatch — Visualized

✅ WHERE DATA IS STORED init_module() → move_to(@aave_data)

Resource lives at: @aave_data
Status: Correct
❌ WHERE GETTERS READ FROM borrow_global<Data>(@aave_pool)

Looking for data at: @aave_pool
Status: WRONG ADDRESS
What happens at runtime: Data stored → @aave_data   ≠   Getters read → @aave_pool
⚠ Result: ERESOURCE_DNE — Resource Does Not Exist. Every transaction aborts.

The Impact — Total Protocol Failure

This wasn't a subtle edge case. This wasn't a rounding error on the fifteenth decimal place. This was a complete protocol brick.

SEVERITY: HIGH — CONFIRMED
Protocol completely non-functional at launch. Zero functionality.
Every transaction involving configuration data aborts with ERESOURCE_DNE.
No deposits. No loans. No liquidations. No borrowing. Nothing.
AAVE's first non-EVM deployment would have launched completely dead.

Think about the context: AAVE is one of the most important protocols in DeFi. Their expansion to Aptos was a marquee moment — governance votes, community excitement, ecosystem partnerships lined up. And the entire deployment would have failed on the first transaction because every getter was looking for data at an address where no data existed.

The PoC — Proving It

The proof of concept was almost comically simple. In a contest where PoCs were mandatory, this one took about four lines:

// Proof of Concept #[test_only]
module aave_data::simple_verification {
    #[test]
    fun verify_bug_exists() {
        assert!(@aave_data != @aave_pool, 999);
        // If this passes: Data stored at @aave_data,
        // but read from @aave_pool = BUG CONFIRMED
    }
}

The test passes. The addresses are different. The bug is real. That's it.

Sometimes the most impactful findings have the simplest proofs.

The Backstory — CI/CD Caught It Too, But After Us

Here's an interesting detail about this finding. After we reported it, the AAVE team confirmed it and shared something worth noting:

The AAVE team had independently caught this through their CI/CD pipeline — but it was discovered after we had already submitted the report. The finding was in scope of the audit, it was valid, and it was confirmed as a High severity issue.

This is worth reflecting on for a moment. Even a team as experienced as AAVE, with automated testing pipelines and multiple prior audits on the EVM side, shipped this to a public contest before their own CI/CD caught it. That's not a knock on them — it's a reminder that cross-language ports introduce subtle mismatches that are easy to miss and hard to catch automatically.

It's also a powerful argument for public audit contests. The crowd found it in parallel with the team's own processes. Redundancy in security isn't wasteful — it's essential.

The Pattern — Why Cross-Language Ports Are Bug Magnets

If you've been reading my previous posts, this should sound familiar. I wrote about the exact same class of issue in OpenZeppelin's Cairo contracts (CVE-2024-45304), where a safety invariant from the Solidity version didn't survive the port to Cairo.

Here's the pattern: when a protocol gets ported from one language to another, the logic gets translated but the implicit assumptions get lost. In Solidity, you don't think about storage addresses because your contract's state lives at your contract's address automatically. In Move, you have to be explicit about it — and that's where the mismatch crept in.

This is now a recurring theme in my findings. Cross-language ports are bug magnets. If you're auditing a protocol that was ported from one language to another, start by mapping every implicit assumption the original language makes, and verify that each one is explicitly handled in the new version.

The Fix

The fix was exactly what you'd expect — change @aave_pool to @aave_data in every getter function. A global find-and-replace that takes 30 seconds.

That's the thing about bugs like this. The fix is trivial. The impact is catastrophic. And the gap between the two is where $24K payouts live.

What Security Researchers Should Take Away

  1. Start from the data layer. Before you trace any business logic, understand where data is stored and how it's retrieved. Address mismatches, wrong storage slots, incorrect resource locations — these foundation-level bugs have maximum blast radius.
  2. Cross-language ports are goldmines. Every time a codebase moves from one language to another, implicit assumptions get lost. Map the assumptions the original language makes, and verify each one in the new version.
  3. Simple bugs have the highest impact. This wasn't a flash loan exploit or a complex reentrancy chain. It was a wrong constant. Don't skip the "obvious" stuff because you think it's too simple to be wrong.
  4. Always check address constants and references. In Move especially, but in any language — verify that storage writes and reads reference the same locations. This is a five-minute check that can uncover protocol-breaking bugs.
  5. PoC even the obvious. A four-line test proved a High severity finding. You don't need a 200-line Foundry script for every bug. Match the proof to the claim.
  6. Understand the new language's model. Move's resource model is fundamentally different from Solidity's storage. If you're auditing Move code with Solidity instincts, you'll miss things that are specific to how Move handles state.

Closing Thoughts

AAVE's move to Aptos is genuinely exciting for DeFi. Taking a battle-tested protocol beyond EVM boundaries is the kind of expansion this space needs. But every bridge to a new ecosystem carries risk — not from the business logic being wrong, but from the translation itself introducing cracks.

This finding reinforced something I keep coming back to: read the code from the ground up. Don't start with the flashy stuff — the liquidation engine, the oracle integration, the governance module. Start with the boring stuff. Where is data stored? How is it retrieved? Do the addresses match?

In this case, they didn't. And that one mismatch would have bricked a $150K contest protocol on day one.

The simplest bugs often have the loudest impact. Never skip the boring code. 🔥

Contest: AAVE Aptos V3 on Cantina
Prize Pool: 150,000 GHO
Competitors: 409
Rank: 3 / 409
Payout: 24,293.09 GHO
Findings: 1 High, 1 Medium
Duration: May 19 – June 10, 2025
Severity: High — Confirmed
Profile: cantina.xyz/u/0xTheBlackPanther
Follow: @thepantherplus