Skip to main content

TECH VEDA

Linux kernel & Device drivers starts on 31st Oct 2026 enrollingCorporate on-site training - Submit proposal Pick your modulesSharpen your kernel skills: deep dives, drivers, Yocto, CVEs, careers — updated daily. Read the blog →
Tutorials

Building Linux for a RISC-V Board — Part 5: Booting a Signed FIT over TFTP

Booting a signed FIT over TFTP on QEMU RISC-V: why verification does not care where the bytes came from, and what the control devicetree still decides.

Building Linux for a RISC-V Board — Part 5: Booting a Signed FIT over TFTP

Booting a signed FIT over TFTP changes nothing about verification itself, because U-Boot checks the image once it is in RAM and has no idea where the bytes came from. The network path changes everything around it: the board needs a virtio network device, virtio scan must run before any network command finds an interface, and the public key still has to be in the control devicetree U-Boot is running. That last point is the real limit: fetching over the network moves the thing you have to trust from the disk to the bootloader.

In Part 3 the board read a boot script from a virtio block device, and Part 4 replaced that script with a signed FIT image holding the kernel, the devicetree and the script together. Both assumed the image was already on the disk. This part fetches the same fit.itb as a signed FIT over TFTP, so the board boots an image it never stored, and looks at what that does to the trust boundary.

What you need before you start

Everything Part 4 produced, in the same ~/rv directory: fit.itb, the key pair, and the patched copy of QEMU’s devicetree carrying the public key under /signature. Nothing new has to be built and no U-Boot option turned on. Host figures are QEMU 8.2.2 and u-boot-tools 2025.10; quoted source is U-Boot v2026.10.

Why a signed FIT over TFTP is not a new verification problem

Network boot looks like a separate security problem from disk boot, because the image crosses a network nobody controls. In U-Boot it is not, and the code says why. boot/image-board.c holds image_locate_script(), where the source command ends up. Its first argument is a plain pointer:

int image_locate_script(void *buf, int size, const char *fit_uname,
                        const char *confname, char **datap, uint *lenp)

That is an address in RAM. By the time it runs, the transfer is finished and the FIT is a block of memory like any other. The function reads the verify environment variable, checks the FIT format, looks up the configuration node and calls fit_config_verify(). Nothing records how the bytes arrived, so a FIT from ext4, raw flash or TFTP reaches the same three lines.

That is the right design: a signature covers content, not delivery. So the work here is all plumbing.

Giving the board a network interface QEMU already described

The virt machine has no network device by default, and adding one revives a problem from Part 4. Because the board implies OF_HAS_PRIOR_STAGE, OF_BOARD defaults to y, so U-Boot runs the devicetree QEMU hands it. Part 4 dumped that tree with dumpdtb=, added the public key with mkimage -K, and passed the result back with -dtb. That dumped file is a snapshot, so adding a device should make it stale.

It does not go stale, and the reason is in how the machine model builds its tree. create_fdt_virtio() in hw/riscv/virt.c loops a fixed number of times:

for (i = 0; i < VIRTIO_COUNT; i++) {

VIRTIO_COUNT is 8, and the loop runs whether or not anything is attached. The machine always publishes eight virtio_mmio slots at 0x10001000 through 0x10008000, one page apart, and -device virtio-net-device occupies one of them. Dumping the tree both ways on QEMU 8.2.2 confirms it: the two blobs are the same 4806 bytes, and the decompiled trees differ on exactly one line, rng-seed under /chosen, which QEMU regenerates for every dump.

That has a consequence beyond this tutorial: two devicetree dumps from the same machine never hash the same, so a checksum cannot tell you whether the hardware description changed. Comparing the decompiled trees and ignoring /chosen/rng-seed is the only check worth running, and it is what shows Part 4’s patched file is still valid.

Turning on QEMU’s built-in TFTP server

The user-mode network backend has a TFTP server inside it, so nothing has to be installed on the host. The QEMU documentation describes tftp=dir as activating a built-in TFTP server rooted at dir, with the client in binary mode. The addresses are fixed defaults: the guest network is 10.0.2.0/24, the guest gets 10.0.2.15 from the built-in DHCP server, and the host is the second address, 10.0.2.2, which is also the TFTP server.

raghu@techveda.org:~$ mkdir -p ~/rv/tftp
raghu@techveda.org:~$ cp ~/rv/fit.itb ~/rv/tftp/
raghu@techveda.org:~$ cd ~/rv
raghu@techveda.org:~$ qemu-system-riscv64 -machine virt -smp 2 -m 2G -nographic \
  -dtb qemu-virt-signed.dtb -kernel u-boot.bin \
  -netdev user,id=net0,tftp=/home/raghu/rv/tftp \
  -device virtio-net-device,netdev=net0

Give the TFTP root as an absolute path: QEMU does not expand a shell tilde inside an option value, and a relative path resolves against whatever directory QEMU was started in.

Why U-Boot says it found no ethernet and then works anyway

With the device attached and the driver built in, U-Boot still prints this:

Net:   No ethernet found.

The message is correct and the configuration fine. initr_net() in common/board_r.c calls eth_initialize(), which looks for the first device in the Ethernet uclass. A virtio network device is not in that uclass yet. It joins only when the virtio bus above it is probed, because virtio_uclass_post_probe() binds the child driver:

snprintf(dev_name, sizeof(dev_name), "%s#%d", name, dev_seq(udev));
ret = device_bind_driver(udev, name, str, &vdev);

Nothing probes the virtio buses during startup: drivers/virtio/virtio-uclass.c has a virtio_init() function and the sequence in common/board_r.c never calls it. Part 3 hit the storage half of this. The same virtio scan fixes the network, and it does more than virtio_init(): besides walking the buses it probes each bus’s children, which is what binds the driver.

The sequence recovers because eth_get_dev() in net/eth-uclass.c does not trust the cached result from startup:

if (!uc_priv->current) {
        eth_errno = uclass_get_device_by_seq(UCLASS_ETH, 0,
                                             &uc_priv->current);
        if (eth_errno)
                eth_errno = uclass_first_device_err(UCLASS_ETH,
                                                    &uc_priv->current);

Startup left current at NULL, so the next network command enumerates again and finds what virtio scan bound. The warning is harmless if the scan comes first.

Fetching and running the signed FIT over TFTP

Three commands replace Part 4’s:

=> virtio scan
=> dhcp ${scriptaddr} fit.itb
=> source ${scriptaddr}#conf-1

dhcp is present without any configuration change: CONFIG_DISTRO_DEFAULTS selects BOOT_DEFAULTS, which selects BOOT_DEFAULTS_CMDS, which contains select CMD_DHCP if CMD_NET && NET, and both default to y. It takes the same arguments as tftpboot, [loadAddress] [[hostIPaddr:]bootfilename], and differs only in asking for an address first. With a static address set, tftpboot ${scriptaddr} fit.itb does the same fetch and is more predictable on hardware, where a DHCP server may not answer.

${scriptaddr} is 0x8c100000 here, from the memory layout in include/configs/qemu-riscv.h, well clear of kernel_addr_r at 0x84000000, so the script’s own bootm can unpack the kernel out of the same image without writing over it.

The transfer reports itself from net/tftp.c and net/net.c: Using virtio-net#n device, where the digit is the sequence number of the virtio bus it landed on, then TFTP from server 10.0.2.2; our IP address is 10.0.2.15, Filename 'fit.itb'., Load address: 0x8c100000, a Loading: line filling with # marks, done, and Bytes transferred =.

Keep the #conf-1 form of source, for the reason Part 4 gave. cmd/source.c splits on the separator: fit_parse_conf() handles #, fit_parse_subimage() handles :, and only the configuration form reaches the branch in image_locate_script() that calls fit_config_verify(). The subimage form checks the script’s hash and nothing else.

What the network path changes in the verification code

Two things differ, neither cryptographic. The first: U-Boot knows the image’s real length and does not use it.

if (fit_check_format(fit_hdr, IMAGE_SIZE_INVAL)) {

IMAGE_SIZE_INVAL is (-1UL), so no size is supplied. fit_check_format() in boot/image-fit.c falls back to the image’s own header, and the upstream comment is candid about it:

/*
 * 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);

On a disk load there was little choice. On a TFTP load there is: the transfer counted the bytes into filesize, and source does not pass it. What bounds the damage is CONFIG_FIT_SIGNATURE_MAX_SIZE, default 0x10000000, whose help text says 256MB protects corrupted structures from overlapping device memory. That guard, rather than a measured length, is what holds the line on a path where the bytes came from elsewhere.

The second difference is about process: one variable governs both paths.

verify = env_get_yesno("verify");

env_get_yesno() returns −1 when the variable is unset, which the if (verify && ...) tests treat as true, so verification is on by default. Set it to n and every check on this path is skipped with nothing printed. Network boot makes that worse: a setup fetching images with verify off will boot whatever answers the request.

When the bootloader itself is what you have to trust

Moving the image off the disk takes it out of the trust chain. It also makes plain something already true in Part 4: on this board CONFIG_OF_BOARD=y, so the public key is not in the bootloader at all. It is in a file named on the QEMU command line.

That file, qemu-virt-signed.dtb, is an input to the emulator in the same way u-boot.bin is. Anyone who can change it can replace the key, sign their own image, and watch U-Boot verify it correctly. The verification code is doing its job; the anchor it checks against is not protected.

On real hardware the key goes into u-boot.dtb, which is appended to the bootloader binary, so replacing the key means replacing the bootloader. That only helps if something earlier refuses to run a replaced bootloader, which is what CONFIG_SPL_FIT_SIGNATURE is for: it turns on FIT signature verification inside SPL, pulling in SPL_FIT, SPL_CRYPTO, SPL_HASH and SPL_FIT_FULL_CHECK, so the first-stage loader verifies U-Boot proper before handing over. SPL in turn is verified by the part you cannot change: the SoC boot ROM, checking a signature against a fused key.

Network boot narrows the problem rather than solving it. The chain runs from an immutable boot ROM, through a signed SPL, through a signed U-Boot carrying the key, to the signed FIT over TFTP, and every link above the ROM is replaceable. A board whose key sits in a command-line file, or in an unsigned bootloader on writable flash, has a verified boot that verifies the image and little else. Working out where a chain is anchored is most of what our training tracks cover.

How to check this on your own board

Confirm the signature check is armed. printenv verify should print nothing, or a value starting with 1, y or t. A value of n or 0 means no signature is checked on any path.

Confirm the key is in the tree U-Boot is running, not the one you patched: fdt addr ${fdtcontroladdr} then fdt list /signature. An empty or missing node is the Part 4 failure repeating.

Confirm the network commands exist and an interface was found:

=> virtio scan
=> printenv ethact
=> ping ${serverip}

On the host, confirm where the TFTP root really is; a wrong root shows up only as a failed transfer.

raghu@techveda.org:~$ ls -l ~/rv/tftp/
raghu@techveda.org:~$ pgrep -af qemu-system-riscv64 | grep -o 'tftp=[^ ]*'

And where the bootloader sits in writable flash, grep SPL_FIT_SIGNATURE over your .config answers half of whether anything verifies it; the vendor’s fuse documentation answers the rest.

Limits, and what was not verified

The devicetree comparison was run on QEMU 8.2.2; the configuration symbols come from a U-Boot v2026.10 tree configured from qemu-riscv64_smode_defconfig plus CONFIG_FIT_SIGNATURE=y.

The boot sequence itself was not executed for this article. The console lines above are the print statements from the named source files with the verified values filled in, so the addresses are right, but the byte count, the hash marks, the transfer rate and the virtio sequence digit will be whatever your image and host produce. Treat the transcript as the shape of the output, not a captured session.

Two scope limits: the VIRTIO_COUNT result belongs to the QEMU RISC-V virt machine, and the IMAGE_SIZE_INVAL observation applies to the source path rather than to other entry points into FIT verification, which were not checked.

Key takeaways

  • U-Boot verifies a FIT once it is in RAM, so a signed FIT over TFTP gets the same check as one read from a disk.
  • The virt machine always publishes eight virtio_mmio slots, so an earlier devicetree dump still describes the new device. Two dumps never hash the same, because /chosen/rng-seed is regenerated.
  • No ethernet found. at startup is expected here; virtio scan binds the driver and the next network command re-enumerates.
  • Keep the #conf-1 form of source; the subimage form skips the configuration signature. setenv verify n skips every check silently.
  • Network boot leaves the bootloader, and the key it carries, as the thing to protect.
Was this worth your time?

Frequently asked questions

Does fetching a FIT over the network weaken the signature check?
No. image_locate_script() takes a pointer to the image in RAM and never records how it arrived, so the network path reaches the same fit_config_verify() call as a disk load.

Why does U-Boot report no ethernet when the driver is built in?
A virtio network device joins the Ethernet uclass only when its virtio bus is probed, and nothing probes those buses during startup. Run virtio scan first; the next network command enumerates again and finds it.

Do I need to re-dump QEMU’s devicetree after adding a network device?
No. create_fdt_virtio() creates all eight virtio_mmio nodes whether or not devices are attached, so an earlier dump still describes the slot the network device occupies. On QEMU 8.2.2 the two dumps differ only in /chosen/rng-seed.

Is the public key safe on a board that boots over the network?
Not by itself. Here the key is in a devicetree file passed on the QEMU command line, so anyone who can change that file can replace it. On hardware the key goes into u-boot.dtb, and CONFIG_SPL_FIT_SIGNATURE plus a boot ROM check are what stop the bootloader being replaced.

Further reading

RB
Raghu Bharadwaj

Founder, TECH VEDA — 20+ years teaching the Linux kernel, device drivers and embedded systems.

Follow on LinkedIn

Get new posts by email

Kernel, embedded Linux and AI-era engineering — a few sharp reads a month. No spam.

We email occasionally and never share your address.