igneum/tools/exec-attacks/scenario1_malformed.mjs
igneum-josh 1d58fd8726 exec-attacks: execution-layer attack suite (tools + bench log)
Adversarial robustness and conformance tests of the execution layer against a
throwaway 3-node simnet on ports 27600+. Six scenarios, each a runnable command
with a design-derived pass criterion and a measured result: malformed/boundary
txs, nonce games across parallel blocks, RPC fuzz, pgas exhaustion, reorgs under
execution, and developer-registry abuse. 98 checks, 0 failures, 0 node panics.

Two findings filed in the bench-log entry: the mempool admits txs with gas_limit
above B_e (low), and an over-pgas-budget tx is executed natively in full before
being skipped for no fee (medium, griefing).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 22:57:19 +00:00

161 lines
9.4 KiB
JavaScript

// Scenario 1: malformed and boundary transactions, over eth_sendRawTransaction and inside blocks a hostile miner
// includes directly (igneum-inject). Design 1.5: state-free faults make the block invalid; state-dependent faults
// skip the transaction with no receipt and no fee; the node never panics; memory stays bounded.
//
// For each case we assert the mempool path (eth_sendRawTransaction) AND the direct-inclusion path (a hostile block)
// behave as the design says, then confirm the node is still producing blocks and its RSS has not run away.
import { toRlp, parseEther } from 'viem';
import * as k from './lib/common.mjs';
const results = { scenario: '1-malformed-and-boundary', cases: [] };
const checks = new k.Checks();
// Minimal big-endian hex for an RLP integer field ('0x' for zero).
function int(n) {
n = BigInt(n);
if (n === 0n) return '0x';
let h = n.toString(16);
if (h.length % 2) h = '0' + h;
return '0x' + h;
}
// A type-2 transaction with an attacker-chosen signature (yParity, r, s). s=0 is an invalid signature.
function type2WithSig({ nonce, maxPrio, maxFee, gas, to, value = 0n, data = '0x' }, yParity, r, s) {
const body = toRlp([int(k.CHAIN_ID), int(nonce), int(maxPrio), int(maxFee), int(gas), to, int(value), data, [], int(yParity), int(r), int(s)]);
return '0x02' + body.slice(2);
}
async function expectBlockInvalid(name, raw) {
// Mempool should reject with an error (not a crash); the hostile block should be rejected by body validation.
const m = await k.send(k.node1, raw);
const rep = k.inject([[raw]])[0];
const mempoolRejected = m.hash === null;
const blockRejected = rep.accepted === false;
checks.check(mempoolRejected, `${name}: mempool rejects (error: ${m.error ?? 'none'})`);
checks.check(blockRejected, `${name}: hostile block invalid (${rep.reject ?? rep.error ?? 'accepted!'})`);
results.cases.push({ name, class: 'state-free', mempoolRejected, blockRejected, mempoolError: m.error, blockReject: rep.reject ?? rep.error });
}
async function expectSkipped(name, rawList, hashesToCheck) {
// A hostile block with these txs must be ACCEPTED, but each named transaction gets no receipt (skipped).
const rep = k.inject([rawList])[0];
const accepted = rep.accepted === true;
checks.check(accepted, `${name}: hostile block accepted (${rep.reject ?? rep.error ?? 'ok'})`);
await k.sleep(1500);
let allReceiptless = true;
for (const h of hashesToCheck) {
const r = await k.receiptOf(h);
const st = await k.rpc(k.node1, 'igneum_getTransactionStatus', [h]);
const receiptless = r === null && (!st || st.executed === false);
if (!receiptless) allReceiptless = false;
}
checks.check(allReceiptless, `${name}: skipped tx has no receipt, not executed`);
results.cases.push({ name, class: 'state-dependent', accepted, allReceiptless });
}
async function main() {
await k.waitTip(2);
await k.fund([k.A, k.B, k.C, k.D]);
const rssBefore = k.nodeRssKib();
// ---- state-free faults: block must be invalid ----
await expectBlockInvalid('bad-rlp', '0x02deadbeef');
await expectBlockInvalid('unsupported-type-3-blob', '0x03c0');
await expectBlockInvalid('wrong-chain-id', await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, chainId: 4461 }));
await expectBlockInvalid('intrinsic-gas-above-limit', await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, data: '0x' + 'ab'.repeat(5000), gas: 21000n }));
await expectBlockInvalid('initcode-too-large', await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: null, data: '0x' + '60'.repeat(60000), gas: 20_000_000n }));
await expectBlockInvalid('invalid-signature-s-zero', type2WithSig({ nonce: 0, maxPrio: 1_000_000_000n, maxFee: 2_000_000_000n, gas: 21000n, to: k.B.address, value: 1n }, 0, 1n, 0n));
// Single gas limit above the block limit B_e: the hostile block is invalid (sum of gas limits > B_e, state-free).
// The mempool, however, has no gas-limit bound in pool.add, so it ADMITS such a transaction; it can never be
// selected (pool.select breaks on it) nor form a valid block. Recorded as an observation, not a consensus fault.
{
const raw = await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, gas: 31_000_000n, maxFeePerGas: 2_000_000_000n });
const m = await k.send(k.node1, raw);
const rep = k.inject([[raw]])[0];
checks.check(rep.accepted === false, `single-gas-limit-over-block: hostile block invalid (${rep.reject ?? rep.error})`);
results.cases.push({ name: 'single-gas-limit-over-block', class: 'state-free', blockRejected: rep.accepted === false, mempoolAdmitted: m.hash !== null, observation: m.hash !== null ? 'mempool admits a tx with gas_limit > B_e (pool.add has no gas-limit bound)' : null });
}
// Duplicate hash in one block, and same sender+nonce twice in one block (nonce not contiguous): block-level,
// so only the hostile-inclusion path applies (the mempool never offers these together).
{
const raw = await k.signTx(k.A, { nonce: await k.nonceOf(k.A), to: k.B.address, value: 1n });
const rep = k.inject([[raw, raw]])[0];
checks.check(rep.accepted === false, `duplicate-hash-in-block: invalid (${rep.reject ?? rep.error})`);
results.cases.push({ name: 'duplicate-hash-in-block', class: 'state-free', blockReject: rep.reject ?? rep.error });
}
{
const n = await k.nonceOf(k.A);
const a = await k.signTx(k.A, { nonce: n, to: k.B.address, value: 1n });
const b = await k.signTx(k.A, { nonce: n + 2, to: k.B.address, value: 2n }); // gap -> not contiguous
const rep = k.inject([[a, b]])[0];
checks.check(rep.accepted === false, `nonces-not-contiguous-in-block: invalid (${rep.reject ?? rep.error})`);
results.cases.push({ name: 'nonces-not-contiguous-in-block', class: 'state-free', blockReject: rep.reject ?? rep.error });
}
const { keccak256 } = await import('viem');
// ---- boundary: gas limit exactly at the block limit is NOT a body fault; the hostile block is accepted and the
// transaction executes (actual gas used is 21000). Injected so the mempool's own checks do not get in the way.
{
const raw = await k.signTx(k.B, { nonce: await k.nonceOf(k.B), to: k.C.address, value: 1n, gas: 30_000_000n, maxFeePerGas: 2_000_000_000n });
const rep = k.inject([[raw]])[0];
checks.check(rep.accepted === true, `gas-limit-at-block-limit: block accepted, not a body fault (${rep.reject ?? rep.error ?? 'accepted'})`);
const r = await k.waitReceipt(keccak256(raw), 20_000);
checks.check(r !== null, 'gas-limit-at-block-limit: transaction executed (has receipt)');
results.cases.push({ name: 'gas-limit-at-block-limit', class: 'boundary', accepted: rep.accepted, executed: r !== null });
}
// ---- state-dependent faults: block accepted, tx skipped, no receipt. Each uses a distinct sender so an earlier
// case never leaves a queued transaction at the nonce a later case reuses. ----
{
const n = await k.nonceOf(k.C);
const raw = await k.signTx(k.C, { nonce: n + 50, to: k.B.address, value: 1n }); // nonce far ahead
await expectSkipped('nonce-far-ahead', [raw], [keccak256(raw)]);
}
{
// Reuse a nonce that has already executed: land one through the mempool, then inject the same nonce again.
const n = await k.nonceOf(k.D);
const first = await k.signTx(k.D, { nonce: n, to: k.B.address, value: 1n });
const fr = await k.send(k.node1, first);
const got = await k.waitReceipt(fr.hash);
checks.check(got !== null, 'nonce-reuse: the first copy executed through the mempool');
const reuse = await k.signTx(k.D, { nonce: n, to: k.C.address, value: 7n });
await expectSkipped('nonce-reuse', [reuse], [keccak256(reuse)]);
}
{
const n = await k.nonceOf(k.C);
const zeroFee = await k.signTx(k.C, { nonce: n, to: k.B.address, value: 1n, maxFeePerGas: 0n, maxPriorityFeePerGas: 0n });
await expectSkipped('zero-fee-below-base', [zeroFee], [keccak256(zeroFee)]);
}
{
const n = await k.nonceOf(k.C);
const broke = await k.signTx(k.C, { nonce: n, to: k.B.address, value: parseEther('1000000'), gas: 21000n }); // more than balance
await expectSkipped('insufficient-funds', [broke], [keccak256(broke)]);
}
{
// Max fee at a u128-scale extreme: the wei budget exceeds the balance, so the tx is skipped, no crash.
const n = await k.nonceOf(k.C);
const big = await k.signTx(k.C, { nonce: n, to: k.B.address, value: 1n, maxFeePerGas: (1n << 120n), maxPriorityFeePerGas: 1n, gas: 21000n });
await expectSkipped('max-fee-extreme', [big], [keccak256(big)]);
}
// ---- liveness and memory after the barrage ----
const tipBefore = await k.rpc(k.node1, 'eth_blockNumber');
await k.sleep(4000);
const tipAfter = await k.rpc(k.node1, 'eth_blockNumber');
checks.check(BigInt(tipAfter) > BigInt(tipBefore), `node still produces blocks after the barrage (${tipBefore} -> ${tipAfter})`);
const rssAfter = k.nodeRssKib();
const maxAfter = Math.max(0, ...rssAfter);
const maxBefore = Math.max(1, ...rssBefore);
const growth = maxAfter / maxBefore;
checks.check(growth < 1.5, `node RSS bounded (max ${maxBefore} -> ${maxAfter} KiB, x${growth.toFixed(2)})`);
results.rss = { beforeKib: rssBefore, afterKib: rssAfter };
const s = checks.summary('scenario 1');
results.summary = s;
const { writeFileSync } = await import('node:fs');
writeFileSync(new URL('./results/scenario1.json', import.meta.url), JSON.stringify(results, null, 2));
process.exit(s.ok ? 0 : 1);
}
main().catch((e) => { console.error(e); process.exit(2); });