#821·btrfs

send: WRITE offsets wrong for extents with nonzero extent offset (silent corruption on receive); OOB reads; unchecked buffer growth; uint16 path wraparound

Author: xfcyhuangCreated Sep 4, 2026Updated Sep 4, 2026

Audit findings against master @ a0648190 (v1.10). Five bugs in src/send.c, all in the code path that generates a send stream.

1. WRITE command offsets/lengths forget - se->data.offset — silently corrupts received data

For regular extents the emit loop iterates off from se->data.offset (src/send.c:2305), and the read side is correct, because for such extents se->data.offset is the offset within the physical extent:

c
uint64_t addr = se->data.disk_bytenr + off;          // send.c:2308 — correct read address
...
offset = se->offset + off;                           // send.c:2379 — WRONG file offset
send_add_tlv(context, BTRFS_SEND_A_FILE_OFFSET, &offset, sizeof(uint64_t));
length = (uint16_t)min(context->lastinode.size - se->offset - off, length);   // send.c:2382 — WRONG length

The WRITE target should be se->offset + (off - se->data.offset). The compressed branch has the identical pattern (send.c:2488-2519, at 2516/2519), and the inline branch likewise computes offset = se->offset + off (send.c:2285). For any extent whose data.offset != 0 — the normal state after reflinks/dedupe or punching a hole in front — every emitted WRITE lands data.offset bytes too far into the file and is truncated by data.offset bytes. The disk data read is fine; only the command in the stream is wrong, so btrfs receive produces silently corrupted files. This one is reachable with a plain reflink/copy_file_range on a mounted filesystem.

2. look_for_collision advances di before subtracting its header — OOB read

c
di = (struct btrfs_dir_item*)((uint8_t*)&di[1] + di->data_len + di->name_len);   // send.c:1168 — advance first
len -= (uint16_t)sizeof(struct btrfs_dir_item) + di->data_len + di->name_len;    // send.c:1169 — reads the NEXT item's fields

The decrement must use the old di's data_len/name_len; as written it reads the following item's header before validating that any following item exists. When the previous entry was the last one, this reads past the end of the tree item data, and the corrupted len accounting lets the loop compare further out-of-bounds entries. The xattr loops below (e.g. send.c:2813-2815) subtract-then-advance in the correct order, which suggests this is just an unfortunate swap.

3. send_xattr / pending_rmdirs loops never check the buffer fill level — send buffer overflow

context->data is allocated as SEND_BUFFER_LENGTH + 2*MAX_SEND_WRITE (1 MB + 96 KB of "wiggle room", send.c:3773). The WRITE loops call wait_for_flush whenever context->datalen > SEND_BUFFER_LENGTH, but:

  • the xattr loops (send.c ~2801-2817 and the tp2 variant at ~2827+) emit one BTRFS_SEND_C_SET_XATTR per dir item with no fill check at all — each command can carry up to ~64 KB of xattr data (di->data_len is uint16) plus a path TLV;
  • the pending_rmdirs loop in send_inode (send.c ~2581-2608) emits one rmdir per pending directory, also with no check in between;
  • and even a single WRITE command can exceed the 96 KB margin on its own: MAX_SEND_WRITE (48 KB) of DATA plus a PATH TLV of up to ~64 KB (path lengths are uint16) sums to more than the wiggle room.

With long paths and/or large xattrs, context->datalen can therefore be driven past the end of context->data — a pool overflow while generating the stream.

4. Path lengths accumulated in uint16_t wrap — reservation smaller than the write

find_path_len (send.c:560-569) sums namelen + parent->namelen + 1 + ... in a uint16_t, so any assembled path over 65535 bytes wraps. send_add_tlv_path (send.c:586-593) reserves the TLV space using that wrapped value, but find_path (send.c:571-584) accumulates the same sum in a ULONG and writes the true length — writing up to ~64 KB past the reservation. Reaching it needs a genuinely huge accumulated path (very deep nesting, or a crafted image in combination with #814), but nothing rejects that case.

5. INLINE extent handling uses ram_bytes as the payload length — OOB read

  • send.c:2170-2171 — when both parent and send sides have INLINE extents, RtlCompareMemory(&se->data.disk_bytenr, &se2->data.disk_bytenr, se->data.ram_bytes) compares ram_bytes bytes. For a compressed INLINE extent, ram_bytes is the uncompressed logical size, which is larger than the payload actually stored in the tree item, so the comparison reads past the item data.
  • divide_ext (send.c:1705-1721) makes the same assumption when splitting INLINE extents: it allocates and copies ram_bytes - len bytes from &ext->data.disk_bytenr.

Both read past tree item buffers (and can send the overread bytes down the stream).

Suggested fixes: compute WRITE offsets/lengths relative to se->data.offset; swap subtract/advance in look_for_collision; add datalen > SEND_BUFFER_LENGTH flush checks to the xattr and rmdir loops (and account for path+data per command in the margin); accumulate path lengths in ULONG and fail cleanly above 0xFFFF; and use the actual inline payload size (derived from the item size) instead of ram_bytes.