cre/veto/bytecode.ts202 lines · 8672 bytes
| 1 | // Static bytecode analysis (malicious implementations are never source-verified, so only bytecode is available). |
| 2 | // One opcode walker (skips PUSH data and, only when it is provably not code, the solc CBOR metadata tail): |
| 3 | // - extractSelectors: every PUSH1-PUSH4 that the solc dispatcher compares against calldata, left-padded to 4 bytes |
| 4 | // (PUSH4 <sel> [DUPn] EQ|GT|LT; shorter pushes also need DUPn PUSHk <sel> [DUPn] EQ|GT|LT PUSHm <dest> JUMPI). |
| 5 | // Over-inclusion is harmless: an extra selector is one more probe that hits the fallback. |
| 6 | // - dangerousOpcodes (R9): opcodes that let code behave differently on a real block than in a simulation (block |
| 7 | // and transaction context: time, number, randomness, fees, gas, blobs, origin) and external-call / create / |
| 8 | // selfdestruct opcodes. An eth_simulateV1 call without validation runs with basefee and gas price 0, so code |
| 9 | // that branches on them can pass every simulated invariant and still misbehave once it is live. |
| 10 | // |
| 11 | // The metadata tail. solc appends CBOR {ipfs|bzzr0|bzzr1: hash, solc: version, experimental: bool} and its 2-byte |
| 12 | // length. Those bytes are still code to the EVM, so skipping them is only safe when nothing can execute them. The |
| 13 | // walker therefore skips a tail only when all of these hold, and otherwise walks the whole code (fail closed): |
| 14 | // 1. the tail decodes as an exact solc CBOR map: 0xa1-0xa4, keys among ipfs, bzzr0, bzzr1, solc, experimental (each |
| 15 | // at most once), each value with its exact type and length, and it ends exactly where the declared length says; |
| 16 | // 2. no instruction before it runs into it: the last instruction before the tail ends at the tail and halts |
| 17 | // (STOP, JUMP, RETURN, REVERT, INVALID, SELFDESTRUCT), so execution cannot fall through into it; |
| 18 | // 3. jumps into it are followed: every JUMPDEST the EVM accepts inside the tail is an entry point, and the |
| 19 | // walker scans from each one until a halting opcode. A hash that happens to contain a reachable JUMPDEST |
| 20 | // followed by, say, 0x42 (TIMESTAMP) is therefore reported; approving that codehash exempts it from R9. |
| 21 | const PUSH1 = 0x60 |
| 22 | const PUSH32 = 0x7f |
| 23 | const EQ = 0x14 |
| 24 | const GT = 0x11 |
| 25 | const LT = 0x10 |
| 26 | const DUP1 = 0x80 |
| 27 | const DUP16 = 0x8f |
| 28 | const JUMPI = 0x57 |
| 29 | const JUMPDEST = 0x5b |
| 30 | |
| 31 | /** Opcodes after which execution never continues with the next byte. Undefined opcodes are not listed: the set of |
| 32 | * defined opcodes grows with forks, so the walker treats every other byte as one that may fall through. */ |
| 33 | const HALTS = new Set([0x00 /* STOP */, 0x56 /* JUMP */, 0xf3 /* RETURN */, 0xfd /* REVERT */, 0xfe /* INVALID */, 0xff /* SELFDESTRUCT */]) |
| 34 | |
| 35 | export const DANGEROUS_OPCODES: Record<number, string> = { |
| 36 | 0x32: 'ORIGIN', |
| 37 | 0x3a: 'GASPRICE', |
| 38 | 0x40: 'BLOCKHASH', |
| 39 | 0x41: 'COINBASE', |
| 40 | 0x42: 'TIMESTAMP', |
| 41 | 0x43: 'NUMBER', |
| 42 | 0x44: 'PREVRANDAO', |
| 43 | 0x45: 'GASLIMIT', |
| 44 | 0x48: 'BASEFEE', |
| 45 | 0x49: 'BLOBHASH', |
| 46 | 0x4a: 'BLOBBASEFEE', |
| 47 | 0x5a: 'GAS', |
| 48 | 0xf0: 'CREATE', |
| 49 | 0xf1: 'CALL', |
| 50 | 0xf2: 'CALLCODE', |
| 51 | 0xf4: 'DELEGATECALL', |
| 52 | 0xf5: 'CREATE2', |
| 53 | 0xfa: 'STATICCALL', |
| 54 | 0xff: 'SELFDESTRUCT', |
| 55 | } |
| 56 | /** The R9 opcode names (what `allowedOpcodes` in the config may list). */ |
| 57 | export const DANGEROUS_OPCODE_NAMES = Object.values(DANGEROUS_OPCODES).sort() as [string, ...string[]] |
| 58 | |
| 59 | export function codeBytes(hex: string): Uint8Array { |
| 60 | const h = hex.startsWith('0x') ? hex.slice(2) : hex |
| 61 | const out = new Uint8Array(h.length >> 1) |
| 62 | for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(h.substr(i * 2, 2), 16) |
| 63 | return out |
| 64 | } |
| 65 | |
| 66 | const pushLen = (op: number) => (op >= PUSH1 && op <= PUSH32 ? op - PUSH1 + 1 : 0) |
| 67 | |
| 68 | /** The value of one solc metadata key starting at `p`; returns the position after it, or -1 if it is malformed. */ |
| 69 | function metadataValue(code: Uint8Array, key: string, p: number, end: number): number { |
| 70 | const b = code[p] |
| 71 | switch (key) { |
| 72 | case 'ipfs': // bytes(34): a sha2-256 multihash |
| 73 | return b === 0x58 && code[p + 1] === 0x22 ? p + 2 + 34 : -1 |
| 74 | case 'bzzr0': |
| 75 | case 'bzzr1': // bytes(32) |
| 76 | return b === 0x58 && code[p + 1] === 0x20 ? p + 2 + 32 : -1 |
| 77 | case 'solc': { |
| 78 | if (b === 0x43) return p + 1 + 3 // release: bytes(3) major, minor, patch |
| 79 | // prerelease: the full version as a text string ("0.8.31-develop.2025.1.1+commit.abcdef12") |
| 80 | let len: number |
| 81 | let q: number |
| 82 | if (b >= 0x61 && b <= 0x77) [len, q] = [b - 0x60, p + 1] |
| 83 | else if (b === 0x78 && p + 1 < end) [len, q] = [code[p + 1], p + 2] |
| 84 | else return -1 |
| 85 | if (len < 5 || q + len > end) return -1 |
| 86 | for (let i = q; i < q + len; i++) if (code[i] < 0x21 || code[i] > 0x7e) return -1 |
| 87 | if (code[q] < 0x30 || code[q] > 0x39) return -1 |
| 88 | return q + len |
| 89 | } |
| 90 | case 'experimental': // CBOR false / true |
| 91 | return b === 0xf4 || b === 0xf5 ? p + 1 : -1 |
| 92 | default: |
| 93 | return -1 |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /** Start of the CBOR metadata tail if the code ends in an exact solc metadata map and its declared length; else -1. */ |
| 98 | export function solcMetadataStart(code: Uint8Array): number { |
| 99 | const n = code.length |
| 100 | if (n < 3) return -1 |
| 101 | const len = (code[n - 2] << 8) | code[n - 1] |
| 102 | const start = n - 2 - len |
| 103 | const end = n - 2 |
| 104 | if (len < 1 || start < 1) return -1 // a tail at offset 0 would be the first code the EVM runs |
| 105 | const head = code[start] |
| 106 | if (head < 0xa1 || head > 0xa4) return -1 |
| 107 | const seen = new Set<string>() |
| 108 | let p = start + 1 |
| 109 | for (let i = 0; i < head - 0xa0; i++) { |
| 110 | const kh = code[p] |
| 111 | if (kh === undefined || kh < 0x61 || kh > 0x77) return -1 // keys are short text strings |
| 112 | const klen = kh - 0x60 |
| 113 | if (p + 1 + klen > end) return -1 |
| 114 | let key = '' |
| 115 | for (let k = p + 1; k < p + 1 + klen; k++) key += String.fromCharCode(code[k]) |
| 116 | if (seen.has(key)) return -1 |
| 117 | seen.add(key) |
| 118 | p = metadataValue(code, key, p + 1 + klen, end) |
| 119 | if (p < 0 || p > end) return -1 |
| 120 | } |
| 121 | return p === end ? start : -1 |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * How the walker sees `code`: the executable part [0, end) and, when a metadata tail is skipped, the entry points |
| 126 | * into it (JUMPDESTs the EVM accepts there). end === code.length means nothing is skipped. |
| 127 | */ |
| 128 | export function codeLayout(code: Uint8Array): { end: number; tailEntries: number[] } { |
| 129 | const whole = { end: code.length, tailEntries: [] } |
| 130 | const start = solcMetadataStart(code) |
| 131 | if (start < 0) return whole |
| 132 | let pc = 0 |
| 133 | let last = -1 |
| 134 | while (pc < start) { |
| 135 | last = code[pc] |
| 136 | pc += 1 + pushLen(last) |
| 137 | } |
| 138 | // a PUSH whose data runs into the tail, or an instruction that falls through into it: the tail is code |
| 139 | if (pc !== start || !HALTS.has(last)) return whole |
| 140 | // the EVM's JUMPDEST analysis continues through the tail from the same instruction boundary |
| 141 | const tailEntries: number[] = [] |
| 142 | for (pc = start; pc < code.length; pc += 1 + pushLen(code[pc])) if (code[pc] === JUMPDEST) tailEntries.push(pc) |
| 143 | return { end: start, tailEntries } |
| 144 | } |
| 145 | |
| 146 | function walkOpcodes(code: Uint8Array, cb: (op: number, pc: number, pushLen: number, end: number) => void) { |
| 147 | const { end, tailEntries } = codeLayout(code) |
| 148 | for (let pc = 0; pc < end; pc++) { |
| 149 | const op = code[pc] |
| 150 | const n = pushLen(op) |
| 151 | cb(op, pc, n, end) |
| 152 | pc += n |
| 153 | } |
| 154 | // reachable parts of a skipped tail: from each entry until execution halts |
| 155 | for (const entry of tailEntries) { |
| 156 | for (let pc = entry; pc < code.length; pc++) { |
| 157 | const op = code[pc] |
| 158 | const n = pushLen(op) |
| 159 | cb(op, pc, n, code.length) |
| 160 | if (HALTS.has(op)) break |
| 161 | pc += n |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | const isDup = (b: number) => b >= DUP1 && b <= DUP16 |
| 167 | const isCmp = (b: number) => b === EQ || b === GT || b === LT |
| 168 | |
| 169 | export function extractSelectors(codeHex: string): string[] { |
| 170 | const code = codeBytes(codeHex) |
| 171 | const found = new Set<string>() |
| 172 | let prevOp = -1 |
| 173 | walkOpcodes(code, (op, pc, n, end) => { |
| 174 | const before = prevOp |
| 175 | prevOp = op |
| 176 | if (n < 1 || n > 4 || pc + n + 1 >= end) return |
| 177 | let k = pc + n + 1 |
| 178 | if (isDup(code[k]) && k + 1 < end) k++ |
| 179 | if (!isCmp(code[k])) return |
| 180 | if (n < 4) { |
| 181 | if (!isDup(before)) return |
| 182 | const j = code[k + 1] |
| 183 | if (!(j >= PUSH1 && j <= PUSH1 + 2)) return |
| 184 | if (code[k + 1 + (j - PUSH1 + 1) + 1] !== JUMPI) return |
| 185 | } |
| 186 | let bytes = '' |
| 187 | for (let i = pc + 1; i < pc + 1 + n; i++) bytes += code[i].toString(16).padStart(2, '0') |
| 188 | const sel = bytes.padStart(8, '0') |
| 189 | if (sel !== 'ffffffff' && sel !== '00000000') found.add(`0x${sel}`) |
| 190 | }) |
| 191 | return [...found].sort() |
| 192 | } |
| 193 | |
| 194 | export function dangerousOpcodes(codeHex: string): string[] { |
| 195 | if (!codeHex || codeHex === '0x') return [] |
| 196 | const found = new Set<string>() |
| 197 | walkOpcodes(codeBytes(codeHex), (op) => { |
| 198 | const name = DANGEROUS_OPCODES[op] |
| 199 | if (name) found.add(name) |
| 200 | }) |
| 201 | return [...found].sort() |
| 202 | } |