My exam-grade token exchange hands you its ETH for free

COMMIT536605dHEAD → main
PUBLISHEDSep 20, 20223y ago
UPDATEDJul 25, 20265d ago
READING14 min2,185 words
#ethereum#solidity#security·by santiago toscanini
Ethereum·Part 5 of 6

This series has spent four posts building Ethereum up as a machine you can read byte by byte: accounts as four RLP fields, state as a Merkle-Patricia trie, the EVM as a 256-bit stack machine you can single-step, and tokens as nothing more than a mapping(address => uint256) living in one contract's storage. Every one of those posts ended the same way: no magic, just bytes and a function that moves them. This one turns that lens on code I have a personal stake in, because the code is mine, and it is broken.

In 2022, for a blockchain course at ORT in Montevideo, I wrote a token exchange: an ERC-20 contract plus an Exchange that mints, sells, and buys it, with the whole approve-then-transferFrom allowance dance from part eight. I got a grade I was happy with. Four years later I cloned my own repo, compiled it with the real solc, and read it the way I've been teaching you to read everything else. It does not do what I thought it did. The headline: sellToken pays the seller ETH and never takes the seller's tokens. You can sell the same tokens forever.

Nothing here is a mockup. The drain below deploys two real compiled contracts — my Exchange and the ERC-20 it trades — into one mini-EVM world and runs sellToken end to end: the Exchange genuinely CALLs the token contract for balanceOf, allowance, and (once fixed) the transferFrom it forgot, exactly the cross-contract hop the exploit turns on. Both were produced by solc 0.8.9 and bundled verbatim, executed instruction by instruction in the same mini-EVM you stepped through in part seven — I taught it to make real sub-calls for this post. The buggy source is quoted straight from the repo, unfixed, because leaving it broken is the whole point. These are my bugs. Let me audit my own homework.

01 · The contract I got a grade on

The scenario the exam asked for is a small economy. Carlos deploys the Exchange and becomes its owner. He publishes an ERC-20 token at a price. Juan authorizes the exchange to move his tokens — that's approve, setting an allowance. Ana buys ten tokens for ETH; the exchange mints what it's short and transfers them to her. Ana sends five to Juan. Then Juan sells three back: the exchange is supposed to transferFrom Juan's three tokens into itself and pay him ETH. Carlos withdraws his commission. Everyone's balances add up. On paper.

The selling step is where I want you to look, because selling is the mirror image of buying, and I only got one of the two mirrors right. Buying takes ETH in and sends tokens out. Selling should take tokens in and send ETH out. Both halves are two moves: check the payment, then move the goods. In buyToken I wrote both moves. In sellToken I wrote the check and the payout — and forgot the move.

Here is what "forgot the move" costs. The widget deploys both compiled contracts, then calls the real sellToken. Watch four numbers: the seller's token balance and their allowance inside the ERC-20, the exchange's ETH balance out in the world, and the tokens the exchange actually collects. Call the function and watch which of them move:

Things worth watching for:

  • The seller's balance never moves. In the buggy mode, the ERC-20's balanceOf[seller] sits frozen no matter how many times you sell — and the exchange's own token balance stays at zero, because nothing was ever pulled in. That single frozen number is the entire vulnerability: the contract CALLed the token to check that you have the tokens, computed a fair price for them, paid you — and never called transferFrom to debit them.
  • The ETH drains anyway. Every call subtracts the payout from the exchange's balance out in the world. Call it in a loop with the "drain it" button and the exchange empties into a seller who gave up nothing.
  • It stops short of empty. The last guard is require(address(this).balance > amountToTransfer) — a strict >, not >=, so it refuses the call that would take the exchange's balance down to exactly zero. A second bug, propping up the first: the off-by-one is the reason the drain can't quite reach the last wei.
  • The fix ties the two ledgers together. Flip to the fixed Exchange and the missing transferFrom comes back — a real CALL into the ERC-20 that debits your tokens before you're paid. Run the drain and it stops on its own the moment the seller runs out of tokens to give (one token short, thanks to that same strict >). That's the tell — a correct sellToken can't pay out more ETH than you have tokens to sell.

An honest line about what's real and what's staged: this genuinely runs the two contracts against each other. I extended the part-seven mini-EVM to hold several contracts in one world and to make real CALLs, so the compiled Exchange reaches into the separate compiled ERC-20 for balanceOf, allowance, and the transferFrom it forgot — each a real sub-call across a frame boundary, with its own gas budget and a storage that only commits if it succeeds. The only staged part is the setup: I seed each contract's storage slots directly — the same end state as running the constructor, publishToken, and approve — instead of scripting those three transactions, and the gas is the documented Berlin/London approximation from the EVM post, not a consensus-exact meter. Everything the drain shows — every balance, every CALL, every revert — falls out of executing the real bytes.

02 · The bugs, quoted from the repo

I could paraphrase what's wrong, but you should see it in my actual handwriting. Every line below is copied verbatim out of the repository, still unfixed. Click through the four findings — three bugs and one thing I'm quietly proud of — and toggle each between the code as written and the one-line fix:

The four, in order of how much they cost:

  1. The missing transferFrom. The whole drain, in one absence. sellToken reads balanceOf and allowance to validate the seller, then calls payable(msg.sender).transfer(amountToTransfer). Between the check and the payout there is supposed to be a transferFrom pulling the tokens in. There isn't. I checked the receipt and forgot to take the goods.
  2. > where I meant >=. Three checks — balance, allowance, contract ETH — all use strict greater-than. You can never sell your entire balance, spend an exact allowance, or drain the exact ETH. The maddening part: buyToken on line 81 uses >= correctly. I got the comparison right ten lines up and wrong three times down.
  3. The inverted require in the NFT project. A sibling exam, SwapNFT, guards its mint with require(_to == address(0)) — while the comment directly above says to throw if _to is zero. The code throws unless it's zero. As written, every real mint reverts and the only address that passes is the one you can never mint to. One character, == where != belonged.
  4. The guard I did write. WithdrawEarnings — and only this function — zeroes out profit before the transfer, with a comment in Spanish: "Para evitar ataque de reentrada." To avoid a reentrancy attack. This is checks-effects-interactions, the exact defense the DAO hack of 2016 taught the whole ecosystem, and 2022-me applied it here, deliberately, in the one place I was thinking about an attacker. I knew the pattern. I just didn't carry it into sellToken, where the real money was.

That fourth one is why I can't be too hard on the kid who wrote this. He wasn't careless everywhere. He was careful in exactly one spot and it proves he understood the danger — he just misjudged where it lived. Every auditor I've ever read says the same thing: bugs don't hide in the functions you're scared of. They hide in the ones you thought were simple.

03 · The idioms that aged

Even the parts that work are a time capsule. This code is honest 2022 Solidity, and several of its habits have quietly become anti-patterns in the four years since. Not bugs — the contract compiles and (mostly) runs — but things no one would write today. Click through the then-and-now:

The one worth internalizing is at the top. My contracts, like every 2022 tutorial, lean on the idea that you can tell a plain wallet from a contract, and that a plain wallet can't run code. As of the Pectra upgrade in 2025, EIP-7702 lets an ordinary account delegate to contract code and execute it while still signing normal transactions. The account still has its four fields; a fresh wallet's codeHash is still the hash of nothing. But "EOAs can't run code," the invariant half of these contracts assume, is no longer safe. The data structure held; the folklore around it moved.

The rest is gentler. .transfer() with its 2300-gas stipend, which I used in five places, was the recommended way to send ETH in 2022 and is discouraged now — gas repricings have made the fixed stipend fragile, and the modern answer is call{value:} plus the very checks-effects ordering I only wrote once. My ERC-20 asserts require(final - previous == value) after every transfer, which under Solidity 0.8 is redundant: the compiler reverts on overflow natively, so SafeMath and hand-rolled invariants both became unnecessary in 0.8.0. I was writing like I didn't quite trust the compiler yet. Nobody did, in 2022.

04 · What a real audit would have caught

Reading a contract line by line against the SWC registry — the catalog of known smart-contract weaknesses — is still the best way to learn what can go wrong, and it's how I found these. It's also a museum in its own right: the registry has taken no new entries since 2020, and its contents were folded into the EEA's actively maintained EthTrust Security Levels spec. Which fits, because a checklist is not how you'd secure money in 2026 either. The missing transferFrom is exactly the kind of thing a property test catches instantly: state an invariant — "the exchange's ETH out equals tokens in times price" — and let a fuzzer like Echidna or Foundry hammer at it until the invariant breaks. It breaks on the first sellToken.

That's the uncomfortable lesson of auditing my own homework. I didn't find these bugs in 2022 because I was checking whether the happy path produced the right end state, and it did: run the exam's exact script once, in order, and every balance lands where the README says. Ana ends with five, Juan with two, the exchange with three. The bug only shows up when someone does the un-scripted thing — sells the same tokens twice — which is precisely the thing an attacker does and a grader doesn't. Tests that only walk the intended path certify the intended path. The value in a contract is everything a fuzzer would try that you didn't.

05 · Bytes all the way down, both halves

That's the tour of the two machines done. Nine posts. The Bitcoin half read a blockchain as a data structure: an 80-byte header, a hash-linked list, a Merkle tree, a stack machine that decides who can spend — a git repo with a hard problem bolted on. The Ethereum half read one as a computer: accounts as rows in a database, state as a trie of tries, the EVM as a determinism engine, tokens and exchanges as ordinary programs writing ordinary storage slots. Different machines, same promise held at every layer — there was never a moment where I had to say "and then something magic happens." There was always a byte, and a rule about the byte, and you could compute the rule yourself.

Everything the series ran on is a toy you can read in an afternoon: the minichain library behind these widgets — hand-rolled SHA-256 and keccak, a secp256k1, a Merkle-Patricia trie, and the single-stepping EVM that just ran two of my contracts against each other across a real cross-contract call — is a few thousand lines with comments explaining the why. It sits next to the git series' py-git and the docker series' py-docker on the same shelf: small enough to hold in your head, real enough to reproduce every hash a block explorer will hand you. And the exam contracts are public, bugs and all, if you want to run the drain against the real thing.

Which leaves one layer I kept circling for nine posts and never opened: the wallet. Every transaction in this series arrived already signed, as if it had fallen out of the sky. How MetaMask turns a click into a signed transaction, how JSON-RPC carries it to a node, how the node you never see runs the EVM I just showed you — that's the tenth and last post, and its punchline is that a transaction has no sender. Your address appears in no field of it; the network recovers it from the signature.

But this is the right place to stop reading my code, with a contract I wrote, a bug I shipped, and a VM small enough to prove both. It really is bytes all the way down — I went and read my own, and there was no magic hiding in mine either.

$git log --oneline public/posts/ethereum-audit/
536605dNeural networks from scratch: a third post on pixels and vectorisationtoday
© 2026 · v2.0 · santiago toscanini