Nightjar

Turning a 27-byte IP fragment into an arbitrary write in U-Boot

Identifier
CVE-2022-30790
Software
Das U-Boot
Affected
All versions up to commit b85d130ea0ca
Fixed in
b85d130ea0ca (3 June 2022)
Reported by
Nicolas Guigo and Nicolas Bidron, NCC Group
Disclosed
15 June 2022

RFC 815 contains a nice idea. You're reassembling a fragmented IP datagram, fragments arrive in any order, and you need to track which byte ranges you're still missing. The obvious approach is a side list of gaps. RFC 815 points out that you already have somewhere to put that list: the gaps themselves. Those bytes are, by definition, memory nobody has written yet. Keep the linked list of holes in the holes.

U-Boot implements it faithfully. The descriptor lives in net/net.c:

c
struct hole {
	/* first_byte is address of this structure */
	u16 last_byte;	/* last byte in this hole + 1 (begin of next hole) */
	u16 next_hole;	/* index of next (in 8-b blocks), 0 == none */
	u16 prev_hole;	/* index of prev, 0 == none */
	u16 unused;
};

Eight bytes, living at the start of each hole, indexed in units of eight bytes because IP fragment offsets are counted in eight-byte units. The scheme works as long as every fragment you write lands strictly inside a hole. It also needs the descriptor for what's left to be relocated out of the way first. Nicolas Guigo and Nicolas Bidron of NCC Group found that you could send a fragment that lands exactly on top of a descriptor, and that from there you get an arbitrary write.

Why U-Boot reassembles fragments#

U-Boot is the thing that runs before Linux on a very large number of embedded boards. One of the standard ways to get a kernel onto such a board is to fetch it over TFTP. TFTP runs over UDP and kernels are bigger than an Ethernet frame, so U-Boot needs IP reassembly. It has it behind CONFIG_IP_DEFRAG. The reassembly buffer is CONFIG_NET_MAXDEFRAG, which defaults to 16 KB.

This is code running with no MMU protection worth speaking of, before any of the security machinery you're used to exists. It is also the process that decides what kernel image gets executed. A write primitive here is about as good as a write primitive gets. NCC's advisory rates it Critical, 9.6, CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H. Adjacent network, no authentication, scope changed.

Root cause#

The top of __net_defragment:

c
	/* payload starts after IP header, this fragment is in there */
	payload = (struct hole *)(pkt_buff + IP_HDR_SIZE);
	offset8 =  (ip_off & IP_OFFS);
	thisfrag = payload + offset8;
	start = offset8 * 8;
	len = ntohs(ip->ip_len) - IP_HDR_SIZE;

ip_len is the IP header's Total Length field, which covers the header as well as the payload. Subtracting IP_HDR_SIZE (20) gives the payload length. Nothing validates it first. Send a fragment claiming a total length between 21 and 27 and len comes out between 1 and 7.

Send it with a fragment offset of zero as well. Then offset8 is 0 and thisfrag == payload. The fragment claims to start at the very beginning of the datagram, which is where the first hole descriptor lives.

Now the relevant branch of the hole-list fixup:

c
	} else if (h >= thisfrag) {
		/* overlaps with initial part of the hole: move this hole */
		newh = thisfrag + (len / 8);
		*newh = *h;
		h = newh;

This is the relocation step, and CVE-2022-30790 lives in it. The fragment covers the front of the hole, so the descriptor has to move forward past the incoming data, to thisfrag + (len / 8). Integer division. With len under 8 that's thisfrag + 0. The descriptor gets copied to exactly where it already was, and the code has now convinced itself the metadata is safely out of the way.

len = 16: the descriptor moves clear newh = thisfrag + 16/8 fragment data desc rest of the hole len = 6: it stays where it was newh = thisfrag + 6/8 = thisfrag relocated by zero blocks rest of the hole the six bytes land on the descriptor itself
Sixteen bytes of payload push the descriptor two eight-byte blocks along. Six bytes push it nowhere.

Then, at the bottom of the function:

c
	/* finally copy this fragment and possibly return whole packet */
	memcpy((uchar *)thisfrag, indata + IP_HDR_SIZE, len);

Which writes the attacker's bytes over the descriptor. NCC's advisory:

With a len value of 6, last_byte, next_hole, and prev_hole of the first_hole all end-up attacker-controlled.

Six bytes, three u16 fields, one for one.

struct hole, at datagram offset 0 rest of the 16 KB buffer last_byte next_hole prev_hole unused memcpy writes six attacker bytes across the red cells the list index the code follows next is now a number the attacker picked
Three sixteen-bit fields, six bytes of payload, one for one.

That's the setup packet. The write happens with the second one: an ordinary-looking fragment whose offset and length just have to be consistent with the fake hole the first packet installed. The reassembly code walks the list it thinks it owns, arrives at an index the attacker chose, and copies fragment data there.

Their proof-of-concept is a short scapy script run against a Raspberry Pi 4 booting U-Boot 2022.04 over TFTP. The second packet lands the board in a synchronous abort with elr: 000000000000ffff. 0xFFFF is the prev_hole value in the fake descriptor they send.

There is a second, duller CVE in the same three lines. CVE-2022-30552, High, 7.1: set ip_len below 20 and len goes negative, and memcpy's third parameter is a size_t. Their proof-of-concept for that one is a single packet with len=19. NCC's advisory:

attempting to make a copy of nearly 4 gigabytes in a buffer that's designed to hold CONFIG_NET_MAXDEFRAG bytes at most, which leads to a DoS.

Both bugs need local network access. A datagram with a total length of 19 or 27 bytes is malformed. Most routing equipment will drop it long before it reaches the target. On a device that network-boots off a switch it shares with other people, that isn't much of a limit.

Fixing it twice#

NCC reported it on 18 May 2022. Fabio Estevam sent a patch on 26 May, quoting both of NCC's write-ups verbatim into the commit message. It landed on 3 June as commit b85d130. Two hunks, five lines:

c
#define IP_MIN_FRAG_DATAGRAM_SIZE	(IP_HDR_SIZE + 8)
c
	if (ip->ip_len < IP_MIN_FRAG_DATAGRAM_SIZE)
		return NULL;

Read that second one next to the code a few lines below it, which says len = ntohs(ip->ip_len) - IP_HDR_SIZE. The check is missing the ntohs. It compares a big-endian 16-bit field against 28 as though it were host order.

a big-endian field read in host order bytes on the wire value the check saw compared with 28 result 00 1b read as 6912 6912 < 28: false passes attacker fragment, ip_len = 27 the arbitrary write is still reachable 05 00 read as 5 5 < 28: true rejected normal fragment, ip_len = 1280 a working transfer stalls instead
The check kept out the fragments it should have passed and passed the one it was written to stop.

Nobody noticed for four months. It was found by Rasmus Villemoes, and not by looking for it:

I hit a strange problem with v2022.10: Sometimes my tftp transfer would seemingly just hang. It only happened for some files. [...] But then it struck me that 1280 is 5*256, so one of the two bytes on-the-wire is 0 and the other is 5, and when then looking at the code again the lack of endianness conversion becomes obvious.

On a little-endian target, a datagram of exactly 1280 bytes has ip_len bytes 0x05 0x00, read back as 0x0005, which is less than 28. So the patch rejected any fragment whose length was a multiple of 256 and broke TFTP semi-randomly. Villemoes gives the other half too: an attacker's 27-byte packet reads back as 6912, sails through the check, and the CVE is still there.

He also points out the check was too strict in the direction it did work. The last fragment of a datagram is allowed to have a payload that isn't a multiple of eight. So the rework he sent in October, merged that November, replaces it with a bounds check plus a separate multiple-of-eight requirement. That second part applies only when the more-fragments bit is set:

c
	if (ntohs(ip->ip_len) <= IP_HDR_SIZE)
		return NULL;
	...
	/* All but last fragment must have a multiple-of-8 payload. */
	if ((len & 7) && (ip_off & IP_FLAGS_MFRAG))
		return NULL;

Which makes len / 8 exact in every path that can reach the relocation, so the descriptor really moves. His commit message is titled "net: (actually/better) deal with CVE-2022-{30790,30552}".

Sources

  1. 1Updated technical advisory and PoCs: multiple U-Boot vulnerabilities (CVE-2022-30790, CVE-2022-30552)nccgroup.com