LIVE ANALYSIS June 24, 2026

CVE-2026-56111: OOB Write - Writing Past the Mesh in Marlin Firmware

How three copies of one G-code command revealed an out-of-bounds write reachable from a single serial line, confirmed on a real STM32 target.

#Security Research #Firmware #CVE-2026-56111 #Out-of-bounds Write #3D Printing #Embedded Security

Writing past the mesh: an out-of-bounds write in Marlin (CVE-2026-56111)

3D printer firmware is a strange attack surface. No MMU, no privilege rings, no ASLR. Everything runs flat on a microcontroller. A single global array sits next to another global array, and the only thing protecting one from the other is the code that writes to them. So when a write forgets to check its bounds, there is nothing underneath to catch it.

This is the story of one such write. It lives in the M421 G-code command. It is reachable with a single line over the serial port. And it took about ten minutes to find, once I was looking at the right thing.

The right thing to look at

I was not fuzzing. I was reading.

Marlin supports three kinds of bed leveling: ABL (automatic), UBL (unified), and MBL (mesh). Each one stores a grid of Z heights and lets you edit a single point with the same command, M421. Same command, three implementations. That is the smell I follow: when the same feature is written three times, the three versions drift, and the weakest one is the bug.

So I opened the three handlers side by side.

ABL, in Marlin/src/gcode/bedlevel/abl/M421.cpp:

if (WITHIN(ix, -1, GRID_MAX_POINTS_X - 1) && WITHIN(iy, -1, GRID_MAX_POINTS_Y - 1)) {

UBL, in Marlin/src/gcode/bedlevel/ubl/M421.cpp:

else if (!WITHIN(ij.x, 0, GRID_MAX_POINTS_X - 1) || !WITHIN(ij.y, 0, GRID_MAX_POINTS_Y - 1))

MBL, in Marlin/src/gcode/bedlevel/mbl/M421.cpp:

else if (ix < 0 || iy < 0)

Read those three lines again. ABL checks the upper bound. UBL checks the upper bound. MBL checks only that the index is not negative. The upper bound is gone.

Two siblings out of three agree on what the check should be. The third forgot half of it. That is not a design decision. That is a missing line.

Following the index to the write

A missing check is only a bug if the unchecked value reaches something dangerous. So I followed the index.

Here is the MBL handler:

void GcodeSuite::M421() {
  const bool hasX = parser.seen('X'), hasI = parser.seen('I');
  const int8_t ix = hasI ? parser.value_int() : ... ;
  const bool hasY = parser.seen('Y'), hasJ = parser.seen('J');
  const int8_t iy = hasJ ? parser.value_int() : ... ;
  ...
  else if (ix < 0 || iy < 0)
    SERIAL_ERROR_MSG(STR_ERR_MESH_XY);
  else
    bedlevel.set_z(ix, iy, parser.value_linear_units() + (hasQ ? bedlevel.z_values[ix][iy] : 0));
}

ix and iy come straight from the command. They are int8_t, so they top out at 127. The only gate is ix < 0 || iy < 0. Anything from 0 to 127 walks straight through.

Then set_z. It is in Marlin/src/feature/bedlevel/mbl/mesh_bed_leveling.h:

static void set_z(const int8_t px, const int8_t py, const_float_t z) { z_values[px][py] = z; }

There it is. A raw array write. No bound check inside the sink either. The caller was supposed to validate, and the caller only validated half.

z_values is a 3x3 grid of floats. That is 36 bytes. Send M421 I5 J0 and you write to z_values[5][0], which is 60 bytes into a 36 byte array. You are 24 bytes past the end, into whatever lives next.

The value you write is the Z parameter, a full 32 bit float you control. So you choose what to write, and the index chooses where. The where is bounded, not arbitrary: the offset is (ix*3 + iy)*4, capped around 2 KB past the array. It is a constrained write, not a write-what-where. But it is controlled, and it is enough.

Proving it, twice

A static read is a hypothesis. I wanted a crash.

First, the mechanism. I lifted the exact M421 body and set_z from the tree, stubbed the parser, and built with AddressSanitizer. Default grid is 3x3, so index 3 is one past the end.

$ ./poc 3 3
==43==ERROR: AddressSanitizer: global-buffer-overflow ...
WRITE of size 4 at 0x... thread T0
    #0 ... in mesh_bed_leveling::set_z(signed char, signed char, float)
    #1 ... in GcodeSuite::M421()
    #2 ... in main
0x... is located 12 bytes after global variable 'z_values' of size 36
SUMMARY: AddressSanitizer: global-buffer-overflow in mesh_bed_leveling::set_z

WRITE of size 4, twelve bytes past z_values, inside set_z, called from M421. The hypothesis holds.

But ASan on x86 proves the logic, not the target. A 3D printer is an STM32, not a laptop. So I cross-built the full firmware for an STM32F103 board with mesh leveling on, and read the symbol table to see what actually sits after z_values on the real chip.

$ arm-none-eabi-nm -n -C firmware.elf | grep -A6 z_values
200013e4 B mesh_bed_leveling::z_values
20001408 B mesh_bed_leveling::z_offset
2000140c B bedlevel
...
20001420 B parser
20001421 B GCodeParser::param

This is the part I like. The map is not abstract anymore. z_values is at 0x200013e4, 36 bytes long. The next thing in memory is z_offset, the bed height offset, at exactly +36. Then the parser object at +60. Do the arithmetic:

M421 I3 J0  ->  (3*3 + 0)*4 = 36 bytes  ->  0x20001408  ->  z_offset
M421 I5 J0  ->  (5*3 + 0)*4 = 60 bytes  ->  0x20001420  ->  parser

So M421 I3 J0 Z<value> does not corrupt random memory. It writes your float into the bed height offset. M421 I5 J0 writes into the command parser. Named, live firmware state, on the real target, from one G-code line.

What it does to a printer

The serial port is always listening. Marlin reads it even mid print, because that is how you send M105 to check temperature or M524 to cancel. get_available_commands always calls get_serial_commands. There is no allowlist. So M421 is accepted while the machine is hot and moving, with nobody watching.

z_offset is added to every move (planner.cpp, raw.z += get_z_offset()). Corrupt it and the printer believes it is at a height it is not. The nozzle can dig into the bed. Send a NaN as the float and the bad value propagates into the motion math, and the firmware hangs.

I want to be precise about what I did not do. I did not turn this into code execution. I looked for a function pointer in the 2 KB window. There is none in that .bss region. The parser holds pointers, but command_ptr is reassigned at the start of every parse, so corrupting it leads nowhere. No control flow hijack. This is a controlled out-of-bounds write reaching live state. It is denial of service and state corruption. It is not RCE, and the report says so.

The fix

One line. Make MBL check the upper bound, the way ABL and UBL already do:

else if (!WITHIN(ix, 0, GRID_MAX_POINTS_X - 1) || !WITHIN(iy, 0, GRID_MAX_POINTS_Y - 1))
  SERIAL_ERROR_MSG(STR_ERR_MESH_XY);

WITHIN and GRID_MAX_POINTS are already used by the two sibling files. No new include. The bug was never a hard problem. It was a line that two other files had and this one did not.

Takeaway

The find was not clever. It was structural. Three copies of one command, read together, and the odd one out is the bug. No fuzzer would have told me why it was wrong. The diff between the siblings did.

On flat-memory firmware, a missing upper bound is not a warning. It is a write into the next variable. Here that variable was the bed offset and the parser. The cost of the bug was one missing WITHIN. The cost of finding it was reading the same function three times instead of once.

Reported through VulnCheck, assigned CVE-2026-56111, fixed in commit 1f255d1.