Reverse Grand Prix
Zero-cost AST nodes defeat solc's inliner size heuristic, compressing a 157-second compile bomb into 637 bytes.
NNS{th3_in1ineR_c0p1es_WHat_th3_pRun3R_Wou1D_H4ve_Del3ted} Challenge
The web app takes an uploaded solve.yul and races it through two solc
configurations, timing each compile:
# control - zero optimiser steps
solc --strict-assembly --optimize --yul-optimizations ':' --bin solve.yul
# optimized - exactly one optimiser step (FullInliner)
solc --strict-assembly --optimize --yul-optimizations 'i:' --bin solve.yul
To qualify you must satisfy both conditions at once:
- the bytecode must be byte-identical across the two lanes
(
outputs_equivalent: true), and optimized_time - control_time > 60 seconds, the optimised lane has to lose the race by more than a minute.
In --yul-optimizations 'i:', i is FullInliner, a single optimiser step.
':' is an empty step sequence, so despite --optimize nothing actually runs
beyond parsing and codegen, that lane is always fast.
So the task reduces to: write a Yul program that makes FullInliner alone
burn more than 60 seconds, while leaving the emitted bytecode untouched.
Vulnerability
I read the optimiser sources in libyul/optimiser/ looking for an algorithmic
complexity bomb rather than a memory-safety bug.
1. Size-zero functions bypass the size limit
FullInliner::shallInline() decides whether to inline by looking at a function
“size” computed by CodeSize:
// FullInliner.cpp - shallInline()
size_t size = m_functionSizes.at(calledFunction->name);
if (size <= 1)
return true; // "very, very small" functions always inline
if (m_pass == Pass::InlineTiny)
return false;
...
That size is scored by CodeWeights in Metrics.h, and several node kinds cost
zero:
// Metrics.h - CodeWeights
size_t variableDeclarationCost = 0; // let bindings
size_t assignmentCost = 0;
size_t blockCost = 0; // { }
size_t identifierCost = 0;
size_t literalZeroCost = 0; // the literal 0
So a statement like let a0 := 0 exists as a real AST node but scores size 0.
Stack enough of them and the function still measures size <= 1, which means the
6/8/12/16 size thresholds are bypassed entirely and the function is inlined
unconditionally, no matter how much AST it actually carries.
2. Exponential doubling chain
With the size limit gone, build a chain where each function calls the one below it twice:
function f0() { let a0 := 0 } // size 0, but the node is real
function f1() { f0() f0() }
function f2() { f1() f1() }
...
function f20() { f19() f19() }
The inliner has to materialise 2^D copies of the leaf body. The source grows linearly in D while the work grows exponentially, a 637-byte file that costs 157 seconds to inline.
3. Keeping the bytecode identical
The second qualification condition is the interesting half. If the bomb chain is dead code, declared but never called from the program body, the inliner still walks and copies all of it, but none of it reaches codegen. The emitted bytecode is whatever the real body compiles to, identical in both lanes.
The actual program body is a single sstore(0, 1).
Generator (gen4.js):
const fs = require('fs');
const D = parseInt(process.argv[2] || '20'); // doubling depth
const K = parseInt(process.argv[3] || '1'); // let-bindings in the leaf body
let body = ''; for (let i = 0; i < K; i++) body += 'let a' + i + ' := 0\n';
let s = `function f0() { ${body} }\n`;
for (let i = 1; i <= D; i++) s += `function f${i}() { f${i-1}() f${i-1}() }\n`;
const call = 'sstore(0,1)'; // the bomb chain is never called
fs.writeFileSync('zero.yul', `{\n${call}\n${s}}`);
Calibrating against the server
The submission API is:
POST /race/starttakesmultipart/form-data, fieldfile→{"job_id": ...}(202)GET /race/status/<job_id>?cursor=<n>is polled; theresultevent carries each lane’stime_ms,outputs_equivalent,qualificationandflag.
One thing worth recording: my local solc.exe (Windows/MSVC) was roughly 30x
slower than the server’s Linux build on this workload, at D=14, K=2 it took
95 s locally versus 3 s server-side. Tuning D against local timings would have
produced a payload that qualified on my machine and failed on theirs, so I swept
D directly against the server instead:
| D | source bytes | control | optimized | diff | result |
|---|---|---|---|---|---|
| 16 | 513 | 23 ms | 6,207 ms | 6.2 s | failed |
| 18 | 575 | 21 ms | 31,705 ms | 31.7 s | failed |
| 20 | 637 | 24 ms | 157,490 ms | 157.5 s | passed |
Each level multiplies server-side time by roughly 5x, so D=20 clears the 60 s threshold with a comfortable margin.
D=20 K=1 bytes=637 ctrl=24ms opt=157490ms diff=157466ms equiv=True qual=passed
flag=NNS{th3_in1ineR_c0p1es_WHat_th3_pRun3R_Wou1D_H4ve_Del3ted}
Sources read
FullInliner.cpp/.h, Metrics.cpp/.h, NameCollector.cpp/.h,
CallGraphGenerator.cpp/.h, Semantics.h (LeaveFinder), ASTCopier.cpp,
SSAValueTracker.cpp, CommonData.h (iterateReplacing).
Takeaways
- Zero-cost nodes lie to size heuristics.
let x := 0, empty blocks and bare identifiers are free underCodeWeightsbut are still real AST the optimiser must copy. - Bypassing
size <= 1plus a doubling chain gives linear source and exponential work, enough to fit a 60-second bomb inside a 16 KB upload. - Leaving the bomb as dead code buys bytecode equivalence for free: the inliner spends its time copying, and the output never changes.