igneum/tools/exec-attacks/lib/common.mjs
igneum-labs 11c4426a96 Kill by exact command line or pid file, never by a name: tools/ci/kill-by-name-check.sh in the gate; the 36 pgrep/pkill literals in the tree fixed
The fleet's 22:09 UK incident (a Mac-side pkill -f <log file name> matched nothing, the roll-everything script lived on and wiped a held box) and the day's two pgrep self-matches are one class. The check flags pgrep -f / pkill -f with a plain literal (every one on a line), any pgrep/pkill on a file-name shape, and ps | grep with a literal; it allows the bracket form, -x, -F pidfile, kill $(cat pidfile), a variable and a full path; 11 banned and 16 allowed shapes in its self-test; 0.15 s over the tree. The 25 pkill -f sp1-gpu-server inside bash -c bodies (which matched the calling bash) are pkill -x; the other 11 literals take the bracket form; prover-socket-check accepts both. Row R in the record; the CLAUDE.md rule names the check and covers pkill and file names.

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

144 lines
7.2 KiB
JavaScript

// Shared helpers for the execution-layer attack scenarios: viem clients against the three igneumd eth_ RPCs on
// 27690/27691/27692, the test accounts, raw-transaction crafting (valid and deliberately malformed), funding and
// small polling utilities. Keys here are for the throwaway simnet only and are never used anywhere else.
import { createPublicClient, http, parseEther, keccak256, toRlp, toHex, concatHex, serializeTransaction } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
export const CHAIN_ID = 4463;
export const URLS = (process.env.IGNEUM_RPCS ?? 'http://127.0.0.1:27690,http://127.0.0.1:27691,http://127.0.0.1:27692').split(',');
export const clients = URLS.map((u) => createPublicClient({ transport: http(u, { timeout: 20_000, retryCount: 0 }) }));
export const node1 = clients[0];
// Miner 1's EVM address is the low 20 bytes of its vote key hash; the net.sh launcher sets that from this key.
export const MINER_KEY = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d';
export const miner = privateKeyToAccount(MINER_KEY);
export const A = privateKeyToAccount('0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba');
export const B = privateKeyToAccount('0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a');
export const C = privateKeyToAccount('0x8166f546bab6da521a8369cab06c5d2b9e46670292d85c875ee9ec20e84ba4ea');
export const D = privateKeyToAccount('0xea6c44ac03bff858b476bba40716402b03e41b8e97e276d1baec7c37d42484a0');
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const rpc = (client, method, params = []) => client.request({ method, params });
export function log(...a) { console.log(new Date().toISOString().slice(11, 19), ...a); }
export class Checks {
constructor() { this.pass = 0; this.fail = 0; this.items = []; }
check(cond, msg) {
if (cond) { this.pass++; this.items.push({ ok: msg }); }
else { this.fail++; this.items.push({ fail: msg }); console.error(' FAIL:', msg); }
return cond;
}
summary(name) {
const line = `${name}: ${this.pass} passed, ${this.fail} failed`;
console.log(line);
return { name, pass: this.pass, fail: this.fail, ok: this.fail === 0, items: this.items };
}
}
// A signed, valid EIP-1559 transaction as raw EIP-2718 hex.
export async function signTx(account, { nonce, to, value = 0n, data = '0x', gas = 21000n, maxFeePerGas = 2_000_000_000n, maxPriorityFeePerGas = 1_000_000_000n, chainId = CHAIN_ID }) {
return account.signTransaction({ type: 'eip1559', chainId, nonce, to: to ?? undefined, value, data, gas, maxFeePerGas, maxPriorityFeePerGas });
}
// A legacy (type 0) transaction.
export async function signLegacy(account, { nonce, to, value = 0n, data = '0x', gas = 21000n, gasPrice = 2_000_000_000n, chainId = CHAIN_ID }) {
return account.signTransaction({ type: 'legacy', chainId, nonce, to: to ?? undefined, value, data, gas, gasPrice });
}
// Re-sign a transaction but then corrupt the signature's s value, so recovery yields a different (or no) sender.
export async function signThenBreakSig(account, fields) {
const raw = await signTx(account, fields);
// Flip the last byte of the raw bytes (inside the signature region) to invalidate recovery deterministically.
const bytes = raw.slice(2);
const flipped = bytes.slice(0, -2) + (parseInt(bytes.slice(-2), 16) ^ 0xff).toString(16).padStart(2, '0');
return '0x' + flipped;
}
export async function send(client, raw) {
try { return { hash: await rpc(client, 'eth_sendRawTransaction', [raw]), error: null }; }
catch (e) { return { hash: null, error: e.details || e.shortMessage || e.message }; }
}
export async function waitTip(minBlock = 1, timeoutMs = 60_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try { const n = BigInt(await rpc(node1, 'eth_blockNumber')); if (n >= BigInt(minBlock)) return Number(n); } catch {}
await sleep(400);
}
throw new Error('node not producing blocks');
}
export async function receiptOf(hash, client = node1) {
if (!hash) return null;
try { return await rpc(client, 'eth_getTransactionReceipt', [hash]); } catch { return null; }
}
export async function waitReceipt(hash, timeoutMs = 60_000, client = node1) {
if (!hash) return null;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const r = await receiptOf(hash, client);
if (r) return r;
await sleep(400);
}
return null;
}
export async function nonceOf(account, client = node1) {
return Number(await rpc(client, 'eth_getTransactionCount', [account.address, 'latest']));
}
export async function balanceOf(address, client = node1) {
return BigInt(await rpc(client, 'eth_getBalance', [address, 'latest']));
}
// Fund accounts from the miner's rewards; waits until the miner has enough, then for the receipts.
export async function fund(targets, amountEther = '5') {
const need = parseEther(amountEther) * BigInt(targets.length) + parseEther('1');
const start = Date.now();
while ((await balanceOf(miner.address)) < need && Date.now() - start < 120_000) await sleep(1000);
let nonce = await nonceOf(miner);
const hashes = [];
for (const t of targets) {
const raw = await signTx(miner, { nonce: nonce++, to: t.address, value: parseEther(amountEther) });
const { hash, error } = await send(node1, raw);
if (error) throw new Error('funding failed: ' + error);
hashes.push(hash);
}
for (const h of hashes) if (!(await waitReceipt(h))) throw new Error('funding receipt missing');
return hashes;
}
import { execFileSync } from 'node:child_process';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = fileURLToPath(new URL('.', import.meta.url));
export const INJECT_BIN = process.env.IGNEUM_INJECT ?? join(HERE, '..', '..', '..', 'vendor', 'igneum-node-exec-attacks', 'target', 'release', 'igneum-inject');
export const GRPC1 = process.env.IGNEUM_GRPC1 ?? 'grpc://127.0.0.1:27610';
// Submits one hostile block (array of raw hex) or several parallel blocks (array of arrays) through igneum-inject.
// Returns the parsed per-block JSON reports. Each block is built off one template so parallel blocks share a parent.
export function inject(job, { grpc = GRPC1, voteKeyHash = null } = {}) {
const dir = mkdtempSync(join(tmpdir(), 'igneum-inject-'));
const file = join(dir, 'job.json');
writeFileSync(file, JSON.stringify(job));
const args = [grpc, 'block', file, '--prefix', 'simnet'];
if (voteKeyHash) args.push('--vote-key-hash', voteKeyHash);
const out = execFileSync(INJECT_BIN, args, { encoding: 'utf8' });
return out.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
}
export function injectCmd(cmdArgs, grpc = GRPC1) {
return execFileSync(INJECT_BIN, [grpc, ...cmdArgs], { encoding: 'utf8' }).trim();
}
// Resident set size (KiB) of every igneumd process in the attack network, for the memory-bounded check.
export function nodeRssKib() {
try {
const out = execFileSync('bash', ['-c', "ps -axo rss,command | grep '[i]gneum-node-exec-attacks/target/release/igneumd --simnet' | grep -v grep | awk '{print $1}'"], { encoding: 'utf8' });
return out.trim().split('\n').filter(Boolean).map(Number);
} catch { return []; }
}