Events are Solidity's publish-subscribe mechanism — cheaper than storage, permanent on-chain, and relied on by every major indexer and backend as the authoritative record of what a contract did. That reputation for reliability is exactly what makes events dangerous when developers treat them carelessly.
The attack surface is wider than most developers realize. Events cannot be deleted or altered, but they can be spoofed, replayed, injected with malicious data, or simply never fired. Backends that trust logs without verifying on-chain state are building on sand. Monitoring that cannot filter events by key fields is partially blind. Any contract that emits sensitive data — even in a "private" field — publishes it permanently. This post covers six event vulnerabilities that appear repeatedly in audits, with vulnerable patterns, attack explanations, and hardened fixes.
1. Off-Chain Systems Trusting Unverified Events
The most costly event vulnerability is not in Solidity at all — it is in the backend system that listens to events. Emitting a PaymentReceived event is not the same as a payment being received. Events are cheap to emit, and a contract can emit any event at any time regardless of whether underlying state actually changed.
Vulnerable pattern:
// VULNERABLE: event emitted before state is committed or validated
contract PaymentGateway {
event PaymentReceived(address indexed payer, uint256 amount, bytes32 orderId);
function initiatePayment(bytes32 orderId) external payable {
// Emit first — backend triggers fulfillment immediately on this event
emit PaymentReceived(msg.sender, msg.value, orderId);
// State update happens after; if this reverts, event already fired in a failed tx
// but the event is part of the failed tx and won't appear — however,
// a malicious contract can call this and revert after, or the backend
// may process a different contract's identical event signature
}
}
// VULNERABLE backend — triggers irreversible action on event alone
provider.on(filter, async (log) => {
const { payer, amount, orderId } = iface.parseLog(log).args;
await fulfillOrder(orderId); // payment dispatched without verifying on-chain state
});
The attack: a malicious contract implements the same event signature and emits PaymentReceived with a victim's order ID. If the backend only filters by event topic and does not verify the emitting address or query on-chain state, the order is fulfilled for free.
Fixed pattern:
contract PaymentGateway {
mapping(bytes32 => bool) public orderPaid;
event PaymentReceived(address indexed payer, uint256 amount, bytes32 indexed orderId);
function pay(bytes32 orderId) external payable {
require(!orderPaid[orderId], "already paid");
orderPaid[orderId] = true;
emit PaymentReceived(msg.sender, msg.value, orderId);
}
}
// Safe backend — verifies on-chain state before acting
provider.on(filter, async (log) => {
// Only accept events from the known contract address
if (log.address.toLowerCase() !== GATEWAY_ADDRESS.toLowerCase()) return;
const { orderId } = iface.parseLog(log).args;
// Query on-chain state to confirm
const isPaid = await contract.orderPaid(orderId);
if (!isPaid) return;
await fulfillOrder(orderId);
});
Always verify the emitting contract address, confirm on-chain state after receiving an event, and treat events as hints — not authoritative proofs.
2. Missing indexed on High-Cardinality Fields
Solidity allows up to three indexed parameters per event. Indexed parameters are stored as topics in the log, making them filterable and searchable by any Ethereum node, indexer, or monitoring tool. Non-indexed parameters are ABI-encoded in the data field and are opaque to filter queries.
Vulnerable pattern:
// VULNERABLE: no indexed fields — impossible to filter efficiently
contract Token {
event Transfer(address from, address to, uint256 amount);
event Approval(address owner, address spender, uint256 amount);
function transfer(address to, uint256 amount) external {
// ...
emit Transfer(msg.sender, to, amount);
}
}
Without indexed, a monitoring system must download and decode every Transfer event ever emitted and filter client-side. On a high-volume token contract this is infeasible. Security alerting, exploit detection, and real-time dashboards are all degraded or completely broken — and incident response teams have missed exploit windows because they couldn't filter events fast enough to catch the first malicious transfer.
Fixed pattern:
// CORRECT: follows ERC-20 standard — from and to are indexed
contract Token {
event Transfer(address indexed from, address indexed to, uint256 amount);
event Approval(address indexed owner, address indexed spender, uint256 amount);
function transfer(address to, uint256 amount) external {
// ...
emit Transfer(msg.sender, to, amount);
}
}
Index every field that monitoring tools or backends will filter on. For token contracts: from, to, and token IDs. For access control events: account address and role. The three-topic limit is rarely a constraint — most events have fewer than three high-cardinality fields.
3. Event Spoofing via Chain Reorganization
Blockchain reorganizations (reorgs) replace one chain tip with a competing fork — every event emitted in orphaned blocks silently disappears. Systems that acted on those events are left in an inconsistent state with no automatic rollback.
Vulnerable pattern:
contract Bridge {
event DepositConfirmed(address indexed user, uint256 amount, uint256 depositId);
function deposit(uint256 amount) external {
// Transfer tokens in, emit event
token.transferFrom(msg.sender, address(this), amount);
emit DepositConfirmed(msg.sender, amount, nextDepositId++);
// Backend mints wrapped tokens on L2 when it sees this event
}
}
// VULNERABLE: acts on event after only 1 confirmation
provider.on(depositFilter, async (log) => {
const { user, amount, depositId } = iface.parseLog(log).args;
await mintOnL2(user, amount, depositId); // irreversible cross-chain action
});
The attack: an attacker deposits, the event fires, the backend mints on L2. A reorg then orphans the block containing the deposit transaction. The tokens on L1 are returned to the attacker (the deposit tx never existed on the canonical chain), but the wrapped tokens on L2 are already minted.
Fixed pattern:
// Safe: wait for sufficient confirmations before acting
const REQUIRED_CONFIRMATIONS = 12; // adjust per chain finality guarantees
provider.on(depositFilter, async (log) => {
const currentBlock = await provider.getBlockNumber();
const confirmations = currentBlock - log.blockNumber;
if (confirmations < REQUIRED_CONFIRMATIONS) {
scheduleRetry(log, REQUIRED_CONFIRMATIONS - confirmations);
return;
}
// Also verify the tx receipt still exists on the canonical chain
const receipt = await provider.getTransactionReceipt(log.transactionHash);
if (!receipt || receipt.status !== 1) return;
await mintOnL2(...);
});
For L2 bridges and high-value cross-chain operations, use finality guarantees rather than confirmation counts. Never take irreversible actions based on unfinalized events.
4. Log Injection via Crafted String and Bytes Parameters
Event parameters of type string and bytes are user-controlled. If downstream systems parse these fields without sanitization — inserting them into log files, databases, or API responses — an attacker can inject content that corrupts those systems.
Vulnerable pattern:
// VULNERABLE: user-supplied string emitted verbatim
contract Marketplace {
event ListingCreated(
address indexed seller,
uint256 indexed tokenId,
string description // attacker controls this
);
function createListing(uint256 tokenId, string calldata description) external {
// No sanitization
emit ListingCreated(msg.sender, tokenId, description);
}
}
A backend log aggregator that writes event fields into a structured log file is vulnerable to newline injection:
description = "Normal item\n[CRITICAL] Admin private key: 0xdeadbeef..."
Or into a JSON API response:
description = "item\", \"adminKey\": \"0xdeadbeef\", \"x\": \""
These injections corrupt log parsers, trigger false SIEM alerts, poison analytics databases, and in the worst case manipulate downstream systems that parse log output as commands.
Fixed pattern:
// BETTER: hash the description on-chain, store content off-chain with validation
contract Marketplace {
event ListingCreated(
address indexed seller,
uint256 indexed tokenId,
bytes32 descriptionHash // hash of content; content stored/validated off-chain
);
function createListing(uint256 tokenId, string calldata description) external {
bytes32 hash = keccak256(bytes(description));
emit ListingCreated(msg.sender, tokenId, hash);
}
}
When you must emit raw strings, sanitize them before inserting into any structured format. Use parameterized queries for databases. Never interpolate raw event string fields into log lines, shell commands, or JSON responses.
5. Sensitive Data Emitted in Plaintext
Ethereum events are permanently public. Every event ever emitted by every contract is readable by anyone with access to an archive node — which is to say, by anyone. There is no delete. There is no access control. Developers sometimes emit data they believe to be private because it is not stored in a public state variable, but events are stored in transaction receipts and are equally public.
Vulnerable pattern:
// VULNERABLE: sensitive data permanently on-chain
contract PrivateMessaging {
event MessageSent(
address indexed from,
address indexed to,
string plaintextMessage, // permanently public
bytes encryptionKey // permanently public — defeats the purpose
);
function sendMessage(address to, string calldata message, bytes calldata key) external {
emit MessageSent(msg.sender, to, message, key);
}
}
Real-world variants include: emitting seed phrases or private keys during initialization, logging user PII in event fields, and emitting "encrypted" data alongside the decryption key. Once emitted, data cannot be removed — even upgrading or selfdestructing the contract leaves the historical log intact.
Fixed pattern:
// CORRECT: emit only commitments and identifiers; sensitive content stays off-chain
contract PrivateMessaging {
event MessageSent(
address indexed from,
address indexed to,
bytes32 messageHash, // commitment — proves a message exists
bytes32 envelopeId // off-chain retrieval key for encrypted payload
);
function sendMessage(address to, bytes32 messageHash, bytes32 envelopeId) external {
// Encrypted content stored off-chain (e.g., IPFS with access control)
emit MessageSent(msg.sender, to, messageHash, envelopeId);
}
}
Before emitting any field, ask: "Would I post this publicly and permanently?" If not, emit a hash or identifier instead, and store sensitive content in an access-controlled off-chain system.
6. Events That Never Fire on Error Paths
Events document what a contract did. When a success event fires before validation completes, or is skipped on error paths, the event log diverges from actual contract state. Indexers, dashboards, and monitoring tools build their world model from events — a missing or incorrectly emitted event leaves those systems with a false picture.
Vulnerable pattern:
// VULNERABLE: event emitted before validation; some error paths skip it silently
contract Vault {
mapping(address => uint256) public balances;
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
function withdraw(uint256 amount) external {
emit Withdrawn(msg.sender, amount); // emitted BEFORE balance check
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
function batchDeposit(address[] calldata users, uint256[] calldata amounts) external payable {
for (uint i = 0; i < users.length; i++) {
if (amounts[i] == 0) continue; // silently skips — no event, no revert
balances[users[i]] += amounts[i];
emit Deposited(users[i], amounts[i]);
}
// If msg.value doesn't cover total, function succeeds but balances are wrong
}
}
In the first case the require will revert the entire transaction including the event — but emitting before validating trains developers to read events as preconditions rather than postconditions. In the second case, silent skips mean the log understates actual deposits.
Fixed pattern:
contract Vault {
mapping(address => uint256) public balances;
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
function withdraw(uint256 amount) external {
// Validate first, emit last
require(balances[msg.sender] >= amount, "insufficient balance");
balances[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
emit Withdrawn(msg.sender, amount); // only fires if everything succeeded
}
function batchDeposit(address[] calldata users, uint256[] calldata amounts) external payable {
uint256 total;
for (uint i = 0; i < users.length; i++) {
total += amounts[i];
}
require(msg.value == total, "value mismatch");
for (uint i = 0; i < users.length; i++) {
balances[users[i]] += amounts[i];
emit Deposited(users[i], amounts[i]); // fires for every entry, no silent skips
}
}
}
Always emit events after state changes and all validation has passed. Treat the event as final confirmation that an action succeeded, not an announcement that it started.
What ContractScan Detects
ContractScan analyzes Solidity source and bytecode to surface event-related vulnerabilities automatically. The table below shows how each vulnerability in this post maps to detection capabilities in the scanner.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Off-chain systems trusting unverified events | Flags events used as sole state proof in backend integration patterns; checks for on-chain verification absence | High |
Missing indexed on high-cardinality fields |
Static analysis: address, uint256 ID fields in events without indexed modifier |
Medium |
| Event spoofing via reorg | Detects bridge/cross-chain deposit patterns with sub-finality confirmation logic | High |
| Log injection via crafted string/bytes | Identifies events with raw string or bytes user-input parameters lacking sanitization |
Medium |
| Sensitive data in plaintext events | Pattern matches private keys, seed phrases, PII-adjacent field names in emitted events | Critical |
| Events not fired on all execution paths | Control-flow analysis: success events on paths that revert, or missing events on silent-skip branches | Medium |
Related Posts
For complementary coverage of smart contract security patterns, see:
- Reentrancy Attack Prevention: From The DAO to Euler Finance — how reentrancy vulnerabilities work at the call level, with the same checks-effects-interactions pattern that prevents premature event emission.
- Oracle Price Manipulation and Flash Loan Attacks — off-chain data trust issues that parallel the off-chain event trust problem: never treat external data as ground truth without on-chain verification.
\n\n## Important Notes\n\nThis post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.