Estimated Savings: > $250,000 in fraudulent credits and withdrawals prevented
Executive Summary
Cyvocate uncovered a critical vulnerability in a decentralized application’s (dApp) Web3-integrated payment verification pipeline. The platform—built using a React frontend, Ethers.js client library, MetaMask/WalletConnect wallets, and a Node.js/Express backend API—was designed to issue digital credits upon confirming ERC-20 token or Ether transfers to a custom Solidity smart contract.
The architecture suffered from a classic validation failure: it trusted client-side blockchain responses rather than performing robust server-side verification directly on the blockchain ledger. Malicious actors could forge transactions and intercept client-side payloads, generating unlimited account credits without spending any real tokens. Cyvocate's intervention secured their transaction pipelines, saving over $250,000 in direct fraud losses.
The Tech Stack
- Frontend: React.js / TypeScript, Ethers.js (v6) client library, MetaMask API (
window.ethereum) - Smart Contracts: Solidity (ERC-20 token contract)
- Backend API: Node.js / Express
- Blockchain Node: Infura / Alchemy Ethereum JSON-RPC nodes
The Challenge
When integrating modern decentralized wallets like MetaMask into a web application, developers must handle payment workflows carefully.
In this application, the frontend initiated a transaction using ethers.BrowserProvider to trigger MetaMask, which sent a transfer request to the Solidity smart contract. However, the critical architectural mistake was that after receiving the transaction receipt in the browser, the frontend was solely responsible for posting this confirmation back to the Node.js API database to credit the user’s account.
Because the server-side logic did not independently verify the transaction details using an off-chain oracle or a trusted Ethereum node RPC provider, it was entirely dependent on client-trusted data. This is a common flaw in dApps looking to save on server-side compute or optimize user experience.
The Exploit Scenario
Through controlled white-box testing, Cyvocate’s security engineers demonstrated how an attacker could bypass the payment gateway:
- Initiate Transaction: The attacker starts a payment flow in the React UI.
- Intercept & Modify: Instead of executing a transaction via MetaMask, the attacker intercepts the outgoing HTTP POST request to the
/api/payments/verifyendpoint using a local interception proxy (e.g., Burp Suite). - Forge Client Receipt: The attacker crafts a valid-looking transaction receipt JSON, complete with a fake transaction hash, and signs it or populates standard parameters (blockNumber, txreceipt_status).
- Credit Injection: The Node.js server parses the client payload, matches the status flag, and directly updates the PostgreSQL database to credit the account without verifying if the transaction hash actually existed on-chain or transferred funds to the platform's wallet address.
This vulnerability essentially allowed unlimited credit creation for zero cost.
Proof of Concept
1. Intercepting Client-Side Ethers.js Payload
The platform’s React application posted this JSON object directly to the Express backend endpoint upon MetaMask's local callback:
{
"status": "1",
"message": "OK",
"result": [
{
"blockNumber": "18954321",
"timeStamp": "1716720788",
"hash": "0xdecafbadbeef1234567890abcdef1234567890abcdef1234567890abcdef1234",
"from": "0x[attacker_wallet]",
"to": "0x[platform_wallet]",
"value": "38912322754369850",
"txreceipt_status": "1",
"confirmations": "12"
}
]
}

2. The Forged Request
We modified the payload in transit, injecting an arbitrary transaction hash and setting txreceipt_status to "1" (representing a successful Solidity transaction).

3. Account Credited Successfully
The platform’s API server accepted the forged receipt as absolute proof of payment, updating our account balance.


Code Remedy: Insecure vs. Secure Validation
To assist web3 developers searching for a definitive fix, here is the technical comparison of how the vulnerability occurred and how Cyvocate resolved it.
❌ The Insecure Implementation (Node.js/Express)
In the insecure model, the backend trusted the client-side payload parameters directly:
// INSECURE: Backend trusts parameters sent directly from the React client
app.post('/api/payments/verify', async (req, res) => {
const { transactionHash, userId, amount } = req.body;
// CRITICAL VULNERABILITY: Directly trusting client data without on-chain validation
if (req.body.txreceipt_status === "1") {
await db.creditUserBalance(userId, amount);
return res.status(200).json({ success: true, message: "Credits applied!" });
}
return res.status(400).json({ success: false, message: "Payment failed" });
});
The Secure Implementation (Node.js + Ethers.js v6)
To secure the application, Cyvocate refactored the verification middleware. The backend now takes only the transaction hash from the client and independently queries a trusted Ethereum RPC node (via Alchemy/Infura) to verify the logs, confirmations, sender, recipient, and amount:
import { ethers } from 'ethers';
// Initialize a secure RPC provider (Ethereum Mainnet or testnet)
const provider = new ethers.JsonRpcProvider(process.env.ETHEREUM_RPC_URL);
const PLATFORM_WALLET = process.env.PLATFORM_WALLET_ADDRESS.toLowerCase();
const REQUIRED_CONFIRMATIONS = 6;
app.post('/api/payments/verify', async (req, res) => {
const { transactionHash, userId } = req.body;
try {
// 1. Fetch transaction details directly from the blockchain
const tx = await provider.getTransaction(transactionHash);
if (!tx) {
return res.status(404).json({ error: "Transaction not found on-chain." });
}
// 2. Fetch the transaction receipt to verify success execution state
const receipt = await provider.getTransactionReceipt(transactionHash);
if (!receipt || receipt.status !== 1) {
return res.status(400).json({ error: "Transaction reverted or failed in Solidity contract." });
}
// 3. Ensure the transaction has sufficient confirmations to avoid double-spend/reorgs
const currentBlock = await provider.getBlockNumber();
const confirmations = currentBlock - receipt.blockNumber;
if (confirmations < REQUIRED_CONFIRMATIONS) {
return res.status(400).json({ error: `Insufficient confirmations (${confirmations}/${REQUIRED_CONFIRMATIONS}).` });
}
// 4. Validate transaction parameters match intended recipient and values
if (tx.to.toLowerCase() !== PLATFORM_WALLET) {
return res.status(400).json({ error: "Invalid payment recipient address." });
}
// 5. Convert Ethers BigInt to human readable and update database
const paymentAmountEth = ethers.formatEther(tx.value);
await db.creditUserBalance(userId, paymentAmountEth, transactionHash);
return res.status(200).json({ success: true, message: "Transaction securely verified on-chain!" });
} catch (error) {
console.error("Web3 Verification Error: ", error);
return res.status(500).json({ error: "Internal payment validation error." });
}
});
Impact & Risks
Left unmitigated, client-side trust vectors lead to immediate financial collapse for dApps:
- Direct Token Depletion: Attackers drain platform products, subscriptions, or asset inventories for $0.
- Arbitrage and Liquidity Crises: Synthetic balance creation allows actors to perform withdrawals of real platform assets.
- Compliance Violations: Bypassing state tracking evades automated AML/KYC filters embedded in smart contract middleware.
Cyvocate's Security Recommendations
- Zero Trust in Client-Side Web3 State: Never trust browser inputs (MetaMask states, React client hooks). The frontend is exclusively for presentation. All state transitions must be verified by backend servers or oracles.
- RPC Ledger Auditing: Ensure transaction hashes are audited directly from the network ledger (JSON-RPC) using strict matching criteria.
- Implement Confirmations Buffering: Set block confirmation buffers (e.g., minimum 6 to 12 blocks depending on the chain speed) to protect against block reorg attacks.
- Database idempotency: Restrict the SQL schema to enforce a
UNIQUEconstraint on transaction hashes, preventing transaction replay attacks where a single valid hash is submitted multiple times.