Address poisoning is the cheapest attack in this series and one of the most expensive to suffer. It doesn’t exploit your contracts, your keys or your webhook handler. It exploits the moment an operator copies an address from a screen they trust and pastes it into a payout form. If your business sends stablecoins to suppliers, partners, exchanges or its own cold wallet, someone is already seeding your transaction history with lookalikes.
The guide covers the short version. This deep dive covers how the attack is built, why internal tooling makes it worse, and the controls that make a copy-paste mistake impossible rather than unlikely.
No. 01How the attack works
The attacker needs two things: an address that looks like one you pay, and a way to put it in front of you.
- The lookalike. Wallets, explorers and most internal dashboards shorten addresses to something like
0x8f3A…91cE. Attackers run vanity generators until they find a key whose address matches the first and last few characters of your counterparty’s. Each extra matching character multiplies the work, so matches of four to six characters at each end are common and full matches are impossible. - The placement. The lookalike has to appear in your history, next to the real one. Three techniques do it:
- Dust transfers. The attacker sends you a tiny amount of a real token from the lookalike address.
- Zero-value
transferFrom. On ERC-20 contracts including USDC and USDT on Ethereum, anyone can calltransferFrom(victim, lookalike, 0). Zero never exceeds the zero allowance, so the call succeeds and the contract emits aTransferevent that shows your address sending to the lookalike. You never signed anything, but your history now says you paid them. - Fake-token transfers. The attacker deploys a token called “USDC” or “USDT” and emits transfers that mirror your real ones, with the same amount and the lookalike as recipient. The fake token deposits deep dive covers the same trick aimed at your deposit watcher.
Attacks are automated. Bots watch for stablecoin transfers and poison both sides within minutes, often in the next block. A measurement study presented at USENIX Security 2025 (Tsuchiya, Dong, Soska and Christin) found 270 million poisoning attempts against 17 million victims on Ethereum and BNB Chain over two years, with at least 83.8 million dollars lost across 6,633 incidents. Almost every attempt fails. The economics work because the few that succeed are large.
No. 02What it has cost
- May 2024: 1,155 WBTC, about 68 million dollars. A holder sent almost the entire balance of a wallet to a lookalike planted by a zero-value transfer. The attacker returned most of the funds a week later after negotiation, an outcome that was widely reported as luck rather than a recovery process anyone can rely on.
- December 2025: about 50 million USDT. The victim did the textbook thing first: a 50 USDT test transfer to the correct address. The attacker poisoned the history straight after it. The victim then copied the lookalike, which matched the first and last few characters, for the remaining 49,999,950 USDT.
- Tron. Address poisoning is heavy on Tron, where most USDT payments move. Attackers dust victims with small amounts of TRX or tokens from base58 addresses that share the leading and trailing characters of a real counterparty.
The December case matters for businesses because it defeats the control most ops teams rely on. A test transfer proves that one specific address works. It proves nothing about the address you paste five minutes later.
No. 03Why businesses are good targets
Individuals get poisoned, but payout operations have every property the attacker wants:
- Repeated, predictable counterparties. Weekly settlement to the same exchange deposit address or supplier wallet gives the bot a stable target and you a habit.
- Large, round amounts. One success pays for millions of failed attempts.
- Several people with send rights. The person who pastes the address is often not the person who set the counterparty up, so nobody remembers what the real address looked like.
- Internal tools built on indexers. Admin panels that list “recent transfers” straight from an indexer or explorer API show poisoned entries with the same weight as real ones. Many teams build a “send again” button on top of that list.
Code review doesn’t catch this because nothing in the code is wrong. The flaw is a workflow where an address can enter a payout from anywhere other than a controlled record.
No. 04Make the address book the only source
The core control is structural: a payout can only reference an address book entry by ID, never carry a raw address. If operators can’t paste addresses into payout forms, they can’t paste the wrong one.
-- One row per counterparty address, per chain and token.
CREATE TABLE payout_destinations (
id uuid PRIMARY KEY,
counterparty text NOT NULL,
chain_id text NOT NULL, -- "eip155:8453", "tron:mainnet", ...
address text NOT NULL, -- stored in canonical form
status text NOT NULL DEFAULT 'pending', -- pending | active | revoked
source text NOT NULL, -- how it was obtained: signed letter, call-back, portal
created_by text NOT NULL,
approved_by text,
CHECK (approved_by IS NULL OR approved_by <> created_by),
UNIQUE (chain_id, address)
);
- Obtained out of band. The address comes from the counterparty through a channel the attacker doesn’t control: their authenticated portal, a signed document, or a call-back to a known phone number. Never from a block explorer, an email thread or your own transaction history.
- Two people. One person proposes the entry, a different person approves it after checking the source. The database enforces the difference, not a policy document.
- Per chain. An EVM address valid on Base says nothing about whether the counterparty controls it on Arbitrum. Store the chain with the address.
- Changes are new entries. A counterparty “updating their wallet” is a new address with a new approval, and a classic social engineering pretext. Treat it as one.
Then make the signer, not the application, enforce it. Put the destination allowlist in the policy layer in front of the key, as described in the key management deep dive, so a bug or a compromised admin panel can’t route around it.
No. 05Compare full addresses, in code
Humans compare the ends of an address; that is exactly what the attack targets. Every comparison that matters should be done by code, on the full canonical value:
import { ethers } from "ethers";
// EVM: getAddress validates and checksums. Throws on malformed input.
function sameEvmAddress(a, b) {
return ethers.getAddress(a) === ethers.getAddress(b);
}
// Resolve a payout destination. The request carries an ID, never an address.
async function resolveDestination(db, destinationId, chainId) {
const d = await db.one(
"SELECT * FROM payout_destinations WHERE id = $1 AND status = 'active'",
[destinationId]
);
if (d.chain_id !== chainId) throw new Error("destination not approved for this chain");
return d.address;
}
A valid EIP-55 checksum does not protect you. The lookalike is a real, correctly checksummed address; the checksum only catches typos. Tron addresses carry a base58check checksum with the same limitation.
Where a human must look at an address, for example when approving an address book entry, show it in full, split into groups of four, with the middle visible. Never render the shortened form in an approval screen.
No. 06Keep poison out of internal tools
Your indexer and admin panels decide what operators see. Filter at ingestion so poisoned entries never reach a screen:
// Pinned token contracts per chain (see the fake token deep dive).
function isDisplayableTransfer(log, chainId, ourAddresses, knownTokens) {
if (!knownTokens[chainId]?.has(log.address.toLowerCase())) return false; // unknown token
if (log.value === 0n) return false; // zero-value transferFrom
// A transfer "from" us that our payout system did not send is not ours to show.
if (ourAddresses.has(log.from.toLowerCase()) && !log.matchedPayoutId) return false;
return true;
}
- Unknown tokens. Only show transfers of token contracts you pinned. Everything else goes to a separate audit view.
- Zero-value transfers. Hide them by default, as Etherscan has done since April 2023.
- Outgoing transfers you didn’t send. Any “outgoing” entry that doesn’t match a payout record in your system is either poison or a serious incident. Neither belongs in a list operators copy from. The second case should page someone.
- No “send again” from history. Repeat payments start from the address book entry, not from a past transaction.
Dust is harder: a 0.01 USDC transfer from a lookalike is a real transfer of a real token. Show incoming transfers only from counterparties you know or deposits you expect, and route the rest to review.
No. 07Use test transfers correctly
Test transfers are still worth doing for a new destination. They catch wrong-chain mistakes and confirm the counterparty actually controls the address. But the December 2025 case shows the order of operations that makes them safe:
- Create the address book entry out of band and get it approved.
- Send the test transfer to that entry.
- Ask the counterparty to confirm receipt through a separate channel, quoting the amount and the transaction hash.
- Mark the entry active. The main payout references the same entry ID. Nobody copies the address again.
If the test and the main payment are two separate copy-paste operations, the test protects nothing.
No. 08Treat poisoning attempts as an early warning
A poisoning attempt against your treasury address is a free signal: someone is watching your flows and has picked a target. Detect it:
// Flag transfers involving addresses that resemble an approved destination.
function looksLike(candidate, real, n = 4) {
const c = candidate.toLowerCase(), r = real.toLowerCase();
return c !== r && c.slice(0, n + 2) === r.slice(0, n + 2) && c.slice(-n) === r.slice(-n);
}
for (const t of newTransfersTouching(ourAddresses)) {
const other = ourAddresses.has(t.from.toLowerCase()) ? t.to : t.from;
const target = approvedDestinations.find((d) => looksLike(other, d.address));
if (target) alert(`possible poisoning of ${target.counterparty}`, { tx: t.hash, other });
}
The n + 2 skips the 0x prefix on EVM addresses; adjust for Tron’s leading T. When the alert fires, tell the people who send payouts which counterparty is being targeted, add the lookalike to a blocklist in the signer policy, and check that no payout went to it. The same check, run at payout time against the blocklist and against all approved destinations, is a cheap last line of defence.
Test planTen tests for your payout workflow
- The payout API rejects a request that carries a raw address instead of an address book ID.
- An address book entry created and approved by the same user cannot be activated.
- A destination approved on one chain is rejected for a payout on another chain.
- The signer refuses a destination that is not on its allowlist, even when the application asks.
- A zero-value
transferFromfrom your hot wallet, sent on testnet by a third party, does not appear in any operator-facing list. - A transfer of a fake token named “USDC” to or from your addresses does not appear in operator lists.
- An outgoing transfer with no matching payout record raises an alert within minutes.
- A lookalike address matching the first and last four characters of an approved destination triggers a poisoning alert.
- The approval screen for a new destination shows the full address, not a shortened form.
- A repeat payment to a counterparty cannot be started from a past transaction, only from the address book.
Want a second pair of eyes?
I run this review on your code, before launch.
Fixed scope, fixed fee, a written report with fixes, and a re-test once you’ve shipped them. Most reviews take five to ten working days.