igneum/tools/exec-attacks/scenario5_rpcfuzz.mjs
igneum-labs 72d7bff0ee 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

120 lines
6.7 KiB
JavaScript

// Scenario 5: RPC fuzz. Every eth_* and igneum_* method with junk params, huge arrays and deep JSON nesting, plus
// a concurrent flood of eth_call at 50x the honest rate from one client. Design 8.2 surface. Criterion: the node
// returns errors, never crashes, and an honest client's latency stays under 200 ms during the flood.
import * as k from './lib/common.mjs';
const RPC_URL = k.URLS[0];
const REGISTRY = '0x0000000000000000000000000000000000000210';
const results = { scenario: '5-rpc-fuzz', malformed: [], flood: {} };
const checks = new k.Checks();
// Raw JSON-RPC POST that tolerates any body and never throws on a non-2xx; returns {status, json|text, ms, threw}.
async function raw(body, { timeoutMs = 5000 } = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
const start = performance.now();
try {
const res = await fetch(RPC_URL, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: ctrl.signal });
const text = await res.text();
let json = null; try { json = JSON.parse(text); } catch {}
return { status: res.status, json, text: text.slice(0, 200), ms: performance.now() - start, threw: false };
} catch (e) {
return { status: 0, json: null, text: String(e).slice(0, 120), ms: performance.now() - start, threw: true };
} finally { clearTimeout(t); }
}
const METHODS = [
'eth_chainId', 'eth_blockNumber', 'eth_gasPrice', 'eth_maxPriorityFeePerGas', 'eth_feeHistory', 'eth_getBalance',
'eth_getTransactionCount', 'eth_getCode', 'eth_getStorageAt', 'eth_getBlockByNumber', 'eth_getBlockByHash',
'eth_getBlockTransactionCountByNumber', 'eth_getBlockReceipts', 'eth_getTransactionByHash', 'eth_getTransactionReceipt',
'eth_getTransactionByBlockNumberAndIndex', 'eth_getLogs', 'eth_sendRawTransaction', 'eth_call', 'eth_estimateGas',
'eth_syncing', 'net_version', 'web3_clientVersion', 'eth_accounts', 'eth_mining', 'net_listening', 'net_peerCount',
'igneum_getTransactionStatus', 'igneum_getSegment', 'igneum_getBudgets', 'igneum_exportSegments',
];
const JUNK_PARAMS = [
[],
[null],
[123, 'two', { x: 1 }, [1, 2, 3]],
['0xnothex'],
['0x' + 'f'.repeat(2000)],
[{ to: '0xdeadbeef', data: 'nothex', gas: -1 }],
[true, false, 3.14159],
['latest', 'latest', 'latest', 'latest'],
[Array.from({ length: 50_000 }, (_, i) => i)], // huge array
];
function deepNest(depth) {
// A params value that is depth-deep nested arrays, as a raw JSON string.
let s = '';
for (let i = 0; i < depth; i++) s += '[';
s += '1';
for (let i = 0; i < depth; i++) s += ']';
return `{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[${s}]}`;
}
async function main() {
await k.waitTip(2);
// 1. Every method with several junk param shapes: each must come back as JSON (status 200) and not hang/crash.
let malformedOk = true;
for (const method of METHODS) {
for (const params of JUNK_PARAMS) {
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params });
const r = await raw(body);
// Acceptable: a JSON-RPC envelope (result or error). Not acceptable: a thrown/aborted request or a 5xx with no JSON.
const good = !r.threw && r.json !== null && (('result' in r.json) || ('error' in r.json));
if (!good) { malformedOk = false; results.malformed.push({ method, params: JSON.stringify(params).slice(0, 60), status: r.status, text: r.text, threw: r.threw }); }
}
}
checks.check(malformedOk, `every method with junk params returns a JSON-RPC envelope (${results.malformed.length} bad)`);
// 2. Deep JSON nesting and a few outright-broken bodies: a parse error, not a crash.
const broken = [deepNest(5000), '{not json', '', '[]', '{"jsonrpc":"2.0"}', JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'does_not_exist', params: [] })];
let brokenOk = true;
for (const b of broken) {
const r = await raw(b);
const good = !r.threw; // the server responds (even an HTTP 400 with a body is fine) rather than dropping the connection
if (!good) { brokenOk = false; results.malformed.push({ body: b.slice(0, 40), status: r.status, threw: r.threw }); }
}
checks.check(brokenOk, 'deep nesting and broken bodies are handled without dropping the connection');
// 3. Honest-latency baseline, then under a 50x eth_call flood from one client.
const sampleHonest = async () => { const r = await raw(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_getBalance', params: [k.miner.address, 'latest'] })); return r.ms; };
const baseline = [];
for (let i = 0; i < 20; i++) { baseline.push(await sampleHonest()); await k.sleep(50); }
const p = (a, q) => a.slice().sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor(a.length * q))];
// Honest rate ~20 req/s (one every 50 ms). 50x = ~1000 eth_call/s. Fire a sustained burst with bounded concurrency.
const floodCall = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'eth_call', params: [{ to: REGISTRY, data: '0x' }, 'latest'] });
let stop = false, floodCount = 0, floodErr = 0;
const worker = async () => { while (!stop) { const r = await raw(floodCall, { timeoutMs: 4000 }); floodCount++; if (r.threw || !r.json) floodErr++; } };
const CONCURRENCY = 50;
const workers = Array.from({ length: CONCURRENCY }, worker);
// Measure honest latency during the flood for ~4 s.
const during = [];
const floodStart = performance.now();
for (let i = 0; i < 40; i++) { during.push(await sampleHonest()); await k.sleep(50); }
stop = true;
await Promise.allSettled(workers);
const floodSecs = (performance.now() - floodStart) / 1000;
const rate = floodCount / floodSecs;
const honestP95 = p(during, 0.95), honestMax = Math.max(...during);
checks.check(rate >= 20 * 20, `flood sustained >= ~400 eth_call/s (actual ${rate.toFixed(0)}/s over ${floodSecs.toFixed(1)}s)`);
checks.check(honestP95 < 200, `honest p95 latency under 200 ms during flood (p95 ${honestP95.toFixed(1)} ms, max ${honestMax.toFixed(1)} ms)`);
// 4. Node still alive and advancing after all of it.
const t0 = Number(await k.rpc(k.node1, 'eth_blockNumber'));
await k.sleep(3000);
const t1 = Number(await k.rpc(k.node1, 'eth_blockNumber'));
checks.check(t1 > t0, `node still advancing after fuzz (${t0} -> ${t1})`);
results.flood = { baselineP50: p(baseline, 0.5), baselineMax: Math.max(...baseline), honestP50: p(during, 0.5), honestP95, honestMax, floodCount, floodErr, ratePerSec: Math.round(rate), floodSecs: +floodSecs.toFixed(1) };
const s = checks.summary('scenario 5');
results.summary = s;
const { writeFileSync } = await import('node:fs');
writeFileSync(new URL('./results/scenario5.json', import.meta.url), JSON.stringify(results, null, 2));
process.exit(s.ok ? 0 : 1);
}
main().catch((e) => { console.error(e); process.exit(2); });