# How to Audit a dApp: What Actually Breaks Beyond the Smart Contract When someone says they audited a dApp, most people picture a smart contract review. In practice, the contract is maybe 30% of the real attack surface. I recently finished a black box assessment of a Solana prediction market platform. Every meaningful finding lived in the seams between the contract, the backend, the browser, and the wallet. None of them were in the contract itself. This article walks through the ones that matter: what they were, why they happen, and how to test for them. The project is anonymized. The techniques are not. ## Contents 1. [The mental model: two systems glued together](#the-mental-model-two-systems-glued-together) 2. [The question that finds the serious bugs](#the-question-that-finds-the-serious-bugs) 3. [Wallet auth is not magically safer than password login](#wallet-auth-is-not-magically-safer-than-password-login) 4. [On chain off chain reconciliation](#on-chain-off-chain-reconciliation) 5. [Uploads are a Web3 blind spot](#uploads-are-a-web3-blind-spot) 6. [A working audit playbook](#a-working-audit-playbook) 7. [What developers should take away](#what-developers-should-take-away) 8. [References](#references) ## The mental model: two systems glued together A dApp is a web application and a blockchain transaction engine. Those two systems have fundamentally different consistency models, and the interesting bugs live in the translation layer between them. The web side has sessions, APIs, object IDs, uploads. Classic OWASP territory. The chain side is async. Solana's `sendTransaction` only means "the RPC accepted the transaction." It does not mean the transaction will be processed or finalized. A transaction stays valid for roughly 150 slots (about 60 to 90 seconds at 400 to 600ms per slot). The client polling during that window may see a different commitment level than the chain has actually reached. Teams usually secure one side well and under secure the other. The contract enforces reality, so the backend quietly trusts the client. Or the API auth is tight, but transaction reconciliation strands user funds when the browser closes at the wrong moment. Go in with a pure smart contract mindset and you will miss half the attack surface. ## The question that finds the serious bugs On every dApp review I start with one question: **what does the backend trust from the browser that it should not trust?** That question alone produced the most severe finding in this engagement. The platform had an endpoint whose job was to "register" a newly created market in the backend index so the UI could display it. The team's mental model was that this endpoint was indexing plumbing. The contract already created the market, so this was just a prettier mirror. The endpoint accepted unauthenticated requests. Any client could: * modify real market records (creator address, category, oracle, image URLs, breaking market flag), * create entirely fake markets using forged `eventPda` and transaction signature values, * update those fake records repeatedly. Worse, the `eventPda` and transaction signature for every open market were exposed on a public API endpoint. The exploit reduced to copy paste: ```javascript // Paste in DevTools console, no auth, no cookies fetch(`${API_BASE}/api/v1/events/registry`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ eventPda: "<REAL_EVENT_PDA_FROM_PUBLIC_LIST>", transactionHash: "<REAL_TX_SIGNATURE_FROM_PUBLIC_LIST>", creatorAddress: "<ATTACKER_CONTROLLED_ADDRESS>", category: "Politics", oracle: "rigged-oracle", isBreakingMarket: true, images: ["https://attacker.example/banner.png"] }) }).then(r => r.text()).then(console.log); // 200 OK. Real market record silently mutated. ``` This is textbook [**OWASP API1:2023, Broken Object Level Authorization (BOLA)**](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/). An endpoint accepts an object ID from the client and touches that object without verifying the caller has the right to touch it. BOLA is the #1 API risk year after year because it's invisible in happy path testing and obvious the moment you think adversarially. The anti pattern is worth naming because it shows up in almost every dApp I review: **teams mentally classify "indexing" or "registry" endpoints as harmless metadata plumbing.** *"The contract already enforces reality, so the backend is just caching a prettier version."* That framing is how integrity bugs ship to production. A rule of thumb: **if a public endpoint returns a primary key identifier, and any other endpoint accepts that identifier from the client as input to a write operation, those two endpoints are a single attack surface.** **Fixes:** * Require authentication on every registry or index write endpoint. * Enforce object level authorization on every write that references an object ID. * Verify `eventPda` and transaction signature correspond to a real on chain record before creating or updating an index entry. * Treat the chain as the source of truth. The backend is a cache, not an authority. ## Wallet auth is not magically safer than password login Teams treat wallet auth as a more secure replacement for passwords. It isn't. It's different, and in one important way more dangerous. A password can be rotated. A signed message, once captured, is valid forever unless your server invalidates it. In this engagement, the wallet auth endpoint issued a fresh valid session token every time the same signed payload was replayed: ```javascript fetch(`${API_BASE}/api/v1/users/wallet-auth`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ walletAddress: "<CAPTURED_ADDRESS>", message: "<CAPTURED_LOGIN_MESSAGE>", signature: "<CAPTURED_SIGNATURE>", username: "testreplay123" }) }).then(r => r.json()).then(console.log); // 200 OK, { token: "<fresh valid session token>" } // The replay issued token worked against protected endpoints. ``` The signed message was functioning as a reusable bearer credential: an unintended offline equivalent of a captured password. The fix is a well established standard. [**EIP-4361 (Sign-In with Ethereum, SIWE)**](https://eips.ethereum.org/EIPS/eip-4361), or its chain agnostic sibling **CAIP-122 (Sign-In with X)**. Despite the Ethereum name, SIWE patterns are widely used on Solana too. The spec isn't decorative. It exists specifically to prevent what this endpoint allowed. It requires: * a **server issued nonce** per login attempt (at least 8 alphanumeric characters), * **domain binding** so a signature for `a.example` cannot be replayed at `b.example`, * an **expiry timestamp** inside the signed message, * **chain ID binding**, * **nonce invalidation on first use**. Once consumed, dead forever. What went wrong here: no server issued nonce, no domain binding, no expiry check. And the cherry on top, the endpoint accepted a `username` field in the same call. That mixes authentication with profile mutation. A replay didn't just mint a new token. It could rewrite identity. Two rules that should be non negotiable for wallet auth: 1. **The signed message is a one time challenge, not a proof of identity.** It authenticates one login attempt. After that, it's dead. 2. **Authentication endpoints mutate only authentication state.** A successful login should not be able to rename the user. **Fixes:** * Implement EIP-4361 or CAIP-122. Don't roll your own. * Generate a server side nonce per login. Invalidate on first successful verification. * Bind the signed message to wallet address, domain, chain ID, and an expiry window. * Separate wallet auth from profile mutation. ## On chain off chain reconciliation Many important dApp bugs aren't about whether a transaction is valid. They're about whether the product correctly interpreted what the chain did. This is the most Solana specific bug class I see, and the engagement had a clean example. Here's what happened. 1. A user initiated market creation. 2. They signed the wallet transaction. The program ran on chain, deducted the creation fee and required initial stake, and created the market account. 3. The browser tab was closed immediately after signing. (Alternatively: the follow up call to the backend registry was blocked in DevTools.) 4. The backend index was never notified. The market existed on chain but not in the UI. 5. The user reopened the app. Funds gone. No market visible. 6. The user tried to recreate the same market. The program reverted. The market PDA was already occupied. That's not a visual bug. It's the chain and the index disagreeing about reality, with the user holding the bill. The root cause is structural. Solana's transaction lifecycle is async in ways Ethereum's fee market UX has partially papered over: * `sendTransaction` returns immediately. The RPC accepted the transaction, nothing more. * A transaction stays valid for about 150 slots (60 to 90 seconds). Inside that window, the network may or may not confirm it. The client polling the RPC may see a different commitment level than the chain has reached. * If a client times out at 30 seconds and declares "failed," the transaction may still land 30 seconds later. Any dApp that reconciles backend state from *"the client finished the happy path"* is betting against this timeline. And that bet loses in boring, routine ways: the user closes the tab, their connection drops, the browser backgrounds the tab, an ad blocker kills the callback, the frontend has a race condition in the finalization path. ### How to test this class of bug * Approve a wallet transaction, then **close the tab immediately.** * Approve, then **block the follow up registry URL** in DevTools. * Approve, then **disconnect Wi Fi** until the blockhash window passes, then reconnect. * Approve, then **refresh the page** right after signing. In each case, ask: did the platform know what happened? Is the new state visible? Can the user recover without support intervention? The answers reveal whether the team understood that the chain is the source of truth, or whether they trusted the client. **Fixes:** * **Do not treat client side timeouts as authoritative failure signals.** The chain's opinion is the only one that matters. * Use `getSignatureStatuses` and `lastValidBlockHeight` properly. If the signature shows `confirmed` or `finalized`, it landed, no matter what the client says. * Reconcile backend state from **chain events** (account subscriptions, program log scraping, RPC polling), not client callbacks. * Build a **recovery path** for stranded creations. Users in this state shouldn't have to file a ticket. ## Uploads are a Web3 blind spot When dApp teams think about Web3 risk they think about signatures, approvals, and contracts. They don't think about image uploads. In this engagement, the upload endpoint accepted SVG files containing active JavaScript and hosted them publicly under the platform's CDN: ```javascript const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:h="http://www.w3.org/1999/xhtml" viewBox="0 0 100 100"> <h:script>alert(document.cookie)</h:script> </svg>`; const fd = new FormData(); fd.append("file", new File([svg], "x.svg", { type: "image/svg+xml" })); fetch(`${APP_URL}/api/upload-image`, { method: "POST", body: fd }) .then(r => r.json()).then(console.log); // 200 OK, { url: "https://res.cloudinary.com/..." } // Visiting the URL directly executes the script. ``` SVG is not an image in the interesting sense. It's an XML document that browsers parse as a live DOM, including `<script>` elements and event handlers like `onload`. When a file is served back with `Content-Type: image/svg+xml` and a user visits the URL directly, it's stored XSS territory. The mitigation matrix is short and opinionated: | Approach | Safe against direct visit XSS? | |---|---| | Reject SVG uploads entirely | Yes | | Rasterize to PNG/JPG server side | Yes | | Sanitize SVG (strip `<script>`, event handlers, `<foreignObject>`) | Only if the sanitizer is bulletproof. Most aren't. | | Serve with `Content-Disposition: attachment` | Forces download instead of inline render | | Render only inside `<img src="...">` | Safe in `<img>`, not in direct visits | If your product needs SVG, rasterize server side. Otherwise, reject it. Everything else is an invitation. ## A working audit playbook Compressed into a checklist: 1. **Start with auth.** How does the app prove identity? How long does the proof live? Can it be replayed? 2. **Map the object model.** List every public identifier (PDAs, transaction signatures, market IDs). For each, find every backend endpoint that accepts it as input. Each pair is a potential BOLA. 3. **Test transaction interruption.** Approve a wallet action, then sabotage the client. What does the platform know? Can the user recover? 4. **Replay everything.** Auth, registry calls, finalize calls. Mutate one field and replay. Watch where the backend trusts the client. 5. **Check uploads.** If users can upload, rasterize. Don't trust file extensions. Only after all of that should you feel comfortable saying you audited the dApp, rather than just the contract. ## What developers should take away The headline isn't *"watch out for this specific class of bug."* It's structural. Dev teams build dApps as if the contract is the source of truth and the rest is harmless glue. In production, **the glue is where the bugs live.** Not because the glue is inherently insecure, but because teams stop threat modeling the moment they cross the on chain boundary. * If your backend can mutate records without authorization, that matters, regardless of what the contract does. * If your wallet auth signature is replayable, it is a password without rotation. * If your transaction reconciliation trusts the client to survive the happy path, you will strand user funds. Not maybe. You will. * If your upload pipeline accepts SVG because *"it's just an image,"* you have stored XSS on the feature roadmap. The single most productive question I ask on dApp reviews is: **what does the backend trust from the browser that it should not trust?** In most engagements that question points straight at the backend endpoints wrapped around chain actions. The *"registry," "indexer," "finalizer," "sync"* endpoints that teams build because the frontend needs a fast mirror. Those are the endpoints where trust goes to die. The contract is part of the system. The dApp is the system. Auditing the first and calling it done is how real, user impacting bugs ship to production. Build it that way. Audit it that way. ### References * [OWASP API1:2023, Broken Object Level Authorization](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/) * [OWASP File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html) * [EIP-4361, Sign-In with Ethereum (SIWE)](https://eips.ethereum.org/EIPS/eip-4361) * [Solana: Transaction Confirmation and Expiration](https://solana.com/developers/guides/advanced/confirmation) * [Solana: `sendTransaction` RPC reference](https://solana.com/docs/rpc/http/sendtransaction)