Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / cre / veto / bytecode.ts
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.
21const PUSH1 = 0x60
22const PUSH32 = 0x7f
23const EQ = 0x14
24const GT = 0x11
25const LT = 0x10
26const DUP1 = 0x80
27const DUP16 = 0x8f
28const JUMPI = 0x57
29const 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. */
33const HALTS = new Set([0x00 /* STOP */, 0x56 /* JUMP */, 0xf3 /* RETURN */, 0xfd /* REVERT */, 0xfe /* INVALID */, 0xff /* SELFDESTRUCT */])
34
35export 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). */
57export const DANGEROUS_OPCODE_NAMES = Object.values(DANGEROUS_OPCODES).sort() as [string, ...string[]]
58
59export 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
66const 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. */
69function 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. */
98export 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 */
128export 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
146function 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
166const isDup = (b: number) => b >= DUP1 && b <= DUP16
167const isCmp = (b: number) => b === EQ || b === GT || b === LT
168
169export 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
194export 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}