Catching a Malicious Transfer with Apollo

Blockchain EVM Security Forensics Smart Contracts Developer Tools
Published on 2026/06/25
Catching a Malicious Transfer with Apollo


Events can lie

Almost every tool that tells you "who paid whom" on an EVM chain, block explorers, portfolio trackers, transfer-graph visualizers, anti-money-laundering dashboards, ultimately trusts a single source of truth: the Transfer event. When a token moves, the contract emits Transfer(from, to, value), indexers pick it up, and an arrow appears on a graph.

There is a subtle, dangerous assumption baked into that pipeline: that the event reflects reality. It does not have to. An event is just a log line that a contract chooses to emit. The EVM never checks that a Transfer event corresponds to an actual change in balances. The only thing that is real, the only thing that other transactions and the consensus actually depend on, is storage.

Events can lie. Storage cannot.

In this post we make that gap concrete. First we walk an honest token transfer end to end and confirm that what it announces in its event is exactly what it writes to storage, the transfer graph and the EVM agree. Then we introduce a contract that produces a byte-for-byte identical event while silently rerouting the money to a hidden address, and we use Apollo, Functori's free, web-based EVM transaction debugger, to read storage and catch it.

If you have not met Apollo yet, start with our introduction: Debugging EVM Transactions with Apollo. The short version: Apollo reconstructs the full opcode-level execution trace of any transaction and lets you step through it, inspecting the stack, memory, and storage at every instruction.

The Apollo debugger interface

The honest transfer, end to end

To isolate the mechanism, we strip an ERC-20 down to its essence: a balanceOf mapping and a transfer function that emits the canonical Transfer event. We deploy it on a local Anvil chain and run one scenario: Alice transfers 50 tokens to Bob (transaction 0xec0f98cd512c6ba12d3edf0a42e059a86c7665231cab89848477a57b93cf6061).

The contract

Good does exactly what its event claims. It debits the sender and credits the recipient, and the value in the event is exactly the value that lands in the recipient's storage slot.

The Good contract: event matches reality

function transfer(address to, uint256 amount) external returns (bool) {
    require(balanceOf[msg.sender] >= amount, "insufficient balance");

    balanceOf[msg.sender] -= amount;   // SSTORE: sender slot decremented
    balanceOf[to]         += amount;   // SSTORE: recipient slot += amount

    emit Transfer(msg.sender, to, amount); // event matches reality
    return true;
}

What the transfer graph shows

Any event-based indexer reconstructs the same picture from this transaction: Alice sent 50 tokens to Bob. A clean arrow on the graph.

Good transaction transfer graph

What Apollo shows

Now let us verify that the graph is telling the truth, by going below the event layer and reading what actually happened in storage.

Apollo normally fetches a transaction directly from an RPC endpoint, but it also has an offline mode that is perfect for forensic work: you load a transaction from four raw RPC response files, no live node required.

FileRPC methodWhat it contains
Transactioneth_getTransactionByHashthe transaction itself (from, to, input, value)
Receipteth_getTransactionReceiptstatus, gas used, and the emitted logs (the events)
Tracedebug_traceTransactionthe full opcode-level execution trace
Codeeth_getCodethe deployed bytecode of the contract

In Apollo, click JSON, and the "Load from JSON files" modal lets you upload the four files to "debug a transaction offline".

Loading the honest transaction into Apollo

Once loaded, Apollo reconstructs the trace and we step through execution. The panel we care about most is Storage, which surfaces every SSTORE the transaction performs: the slot written, its new value, and the program counter / opcode that wrote it. Newly written slots are highlighted, so the actual money movement is impossible to miss.

Stepping into the transfer body, Apollo shows two storage writes:

  1. an SSTORE that decrements the sender's balance slot by 50 tokens, and
  2. an SSTORE that increments the recipient's balance slot by 50 tokens.

The slot credited in step 2 is the storage slot of balanceOf[Bob], computed by Solidity as keccak256(Bob . 0) for the mapping at slot 0. Cross-check it against the Transfer event in the receipt: the event says 50 tokens went to Bob, and storage shows Bob's slot gaining exactly 50 tokens.

The graph and Apollo agree. The event announced "Alice → Bob, 50 tokens", and the EVM actually credited Bob's slot with 50 tokens. This is what an honest transfer looks like under the microscope, and it is the baseline we will measure the next transaction against.

The problem: a graph only sees what the contract says

Here is the uncomfortable part. The transfer graph above was not built by reading storage. It was built by reading the Transfer event, the log line the contract chose to emit. We just happened to verify, with Apollo, that the event was honest.

But nothing forces a contract to be honest. A contract is a black box: it can perform any storage writes it likes and then emit whatever event it wants. The event and the storage writes are completely independent. An indexer that only reads events has no way to know whether the arrow it draws corresponds to where the money really went.

So the natural question is: what does a malicious contract's transfer look like to a graph, and can Apollo tell the difference?

The malicious transfer, end to end

Same scenario, same amount: Alice transfers 50 tokens to Bob (transaction 0xfc23d9c8458458eef3dd91cca4b512bdc2c06888a08d1ae7f2f9d7570bf69d76). Only the contract is different.

The contract

Evil has the same signature, the same require check, and the same event as Good. But look at the storage writes: the recipient's slot is never touched. The full amount is debited from the sender and credited to a hardcoded thief address, then the event is emitted naming the recipient who received nothing.

The Evil contract: the event lies about where the money went

address public constant thief = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8;

function transfer(address to, uint256 amount) external returns (bool) {
    require(balanceOf[msg.sender] >= amount, "insufficient balance");

    balanceOf[msg.sender] -= amount;   // SSTORE: full amount leaves sender
    balanceOf[thief]      += amount;   // SSTORE: ALL of it goes to the thief
    // NOTE: balanceOf[to] is NEVER written. Bob receives exactly zero.

    emit Transfer(msg.sender, to, amount); // THE LIE: names `to`, not the thief
    return true;
}

The crucial line is the absence of one: balanceOf[to] is never written. The named recipient is a decoy that exists only in the event. This is not a contract trying to survive an audit, it is deliberately, clearly labeled, so the mechanism is visible. A real attacker would compute the redirect address to hide it; the point here is the forensic gap, not the disguise.

In production, this exact pattern is how a "fake deposit" scam works: a victim sees a Transfer event crediting their address on a block explorer, believes they have been paid, releases goods or off-chain credit, and only later discovers their on-chain balance never changed.

What the transfer graph shows

This is where it gets dangerous. Here is the transfer graph reconstructed from the malicious transaction:

Evil transaction transfer graph

It is identical to the honest one. Same sender, same recipient, same amount, same event signature. Bob appears as the clean recipient of 50 tokens. A transfer graph, a block explorer, an AML heuristic looking at the logs, all of them are fooled, because all of them read the same lying event. The thief never appears.

What Apollo shows

We load the four files for the malicious transaction exactly as before.

Loading the malicious transaction into Apollo

And we step through to the transfer body.

Apollo again surfaces exactly two storage writes, but they tell a different story:

  1. an SSTORE that decrements the sender's balance slot by 50 tokens, exactly as before, and
  2. an SSTORE that increments a different slot by 50 tokens, a slot that is not balanceOf[Bob].

That second slot is keccak256(thief . 0), the balance slot of the hidden thief address 0x7099…79C8, not Bob's. And the smoking gun is the absence: there is no SSTORE to keccak256(Bob . 0) anywhere in the trace. The recipient named in the event received exactly zero.

Compare the two sources of truth side by side:

SourceWhat it says
Transfer event (the log)Alice → Bob, 50 tokens
Storage (what actually happened)Alice → thief 0x7099…79C8, 50 tokens. Bob's slot untouched.

The event named Bob. Storage credited the thief. Apollo read storage, and the lie collapsed.

Making it legible with aliases

Raw storage slots are 32-byte keccak hashes, not the most readable forensic evidence. Apollo's alias feature (Settings → Alias) lets you attach human-readable labels to addresses such as "Alice", "Bob", and "Thief", and they persist across sessions. With aliases on, the redirected destination stops being an opaque hash and reads plainly as the thief.

For a fully automated catch, you can also set a storage breakpoint on the slot you expect to be credited, keccak256(Bob . 0). In the honest transaction, execution pauses on that write. In the malicious one, it never fires, because Bob's slot is never written. The breakpoint that should have triggered staying silent is itself the alarm.

The graph can be manipulated, the storage cannot

Step back and look at what just happened. We ran two transactions that are indistinguishable to every tool that reads events: same transfer graph, same explorer entry, same amount, same recipient. One paid Bob. The other paid a thief and never touched Bob at all.

The transfer graph is only as trustworthy as the events it is built from, and those events are emitted by a black-box contract that has no obligation to tell the truth. Any malicious contract can paint a clean "Alice → Bob" arrow on every graph in the ecosystem while routing the funds wherever it likes. This is not a bug in the graph tools; it is a fundamental limit of reasoning about value flow from logs alone.

Apollo closes that gap. By replaying the transaction and reading the SSTORE writes, it shows the real transfer, the one the EVM actually enforced, regardless of what the contract claimed in its event. The same principle scales directly to real incident response:

  • Fake-deposit and honeypot scams rely on exactly this gap between emitted events and real balances.
  • Proxy and delegatecall exploits often produce events that misrepresent which contract's storage actually changed.
  • AML and compliance pipelines built purely on event logs can be poisoned by contracts that emit misleading Transfer events to launder the apparent flow of funds.

Any analysis that stops at the event layer is trusting the very contract it is supposed to be investigating. Apollo lets you drop below that layer to the only thing the EVM actually enforces, storage, and verify that what a transaction claimed matches what it did.

Reproduce it yourself

Everything here runs locally and for free:

  1. Spin up a local chain with anvil.
  2. Deploy the Good and Evil contracts and send a transfer(Bob, 50 ether) from Alice on each.
  3. Capture the four RPC responses per transaction (eth_getTransactionByHash, eth_getTransactionReceipt, debug_traceTransaction, eth_getCode).
  4. Open app.apollotrace.com, click JSON, and load the four files.
  5. Step to the transfer body and read the Storage panel. Watch where the money really goes.

Apollo is free, requires no account, and works on any EVM-compatible chain, see the full feature tour in Debugging EVM Transactions with Apollo. If you have feedback or feature requests, reach out to us at functori.com.