Catching a Malicious Transfer with Apollo
Catching a Malicious Transfer with Apollo
Blockchain EVM Security Forensics Smart Contracts Developer Tools
Published on 2026/06/25
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 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.
function transfer(address to, uint256 amount) external returns (bool) {<br>require(balanceOf[msg.sender] >= amount, "insufficient balance");
balanceOf[msg.sender] -= amount; // SSTORE: sender slot decremented<br>balanceOf[to] += amount; // SSTORE: recipient slot += amount
emit Transfer(msg.sender, to, amount); // event matches reality<br>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.
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
Transaction eth_getTransactionByHashthe transaction itself (from, to, input, value)<br>Receipt eth_getTransactionReceiptstatus, gas used, and the emitted logs (the events)<br>Trace debug_traceTransactionthe full opcode-level execution trace<br>Code eth_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".
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.
Your browser does not support the video tag.
Stepping into the transfer body, Apollo shows two storage writes:
an SSTORE that decrements the sender's balance slot by 50 tokens, and<br>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...