Re: [Security][Ceph] OSDMap new_state decode overflow causes out-of-bounds read in kernel client
Viacheslav Dubeyko <[email protected]> Fri, 24 Jul 2026 20:24:43 -0700
| Newsgroups | org.kernel.vger.ceph-devel |
|---|---|
| Message-ID | <[email protected]> |
Hi Federico, Please, send a formal patch to [email protected]. I have no means to review attachments and I never open email's attachment from the unknown persons. So, please, send the patch in plain text. Thanks, Slava. On Sat, 2026-07-25 at 00:19 -0300, Federico Kirschbaum wrote: > Hi, >=20 > We would like to report a remotely triggerable OSDMap decoding issue > in the Linux Ceph client that can cause an out-of-bounds read when > processing crafted incremental maps. >=20 > Summary > decode_new_up_state_weight() decodes an incremental OSDMap in two > passes. In the first "skip" pass it multiplies the monitor-supplied > new_state entry count by the per-entry size and stores the product > back into a u32, where a count large enough that count * per-entry- > size overflows u32 truncates the product =E2=80=94 so the ceph_decode_nee= d() > length guard is satisfied for a zero-sized region and the pointer is > never advanced. The second pass then re-reads the true (large) count > and walks the new_state array with no per-iteration bounds check, > unlike the sibling new_weight loop directly above it. A kernel client > that mounts CephFS or maps an rbd image from that monitor receives > the crafted incremental map over the wire and the decode walks *p off > the end of the message buffer. Confirmed under a KASAN VM against the > unmodified decode path. >=20 > Root Cause Analysis > net/ceph/osdmap.c =E2=80=94 decode_new_up_state_weight(). The first pass > reads the new_state count and skips over it: >=20 > =C2=A0new_state =3D *p; > =C2=A0ceph_decode_32_safe(p, end, len, e_inval); /* [1] len =3D Ns (u32), > attacker-controlled */ > =C2=A0len *=3D sizeof(u32) + (struct_v >=3D 5 ? sizeof(u32) : sizeof(u8))= ; /* > [2] product truncated back into u32 len */ > =C2=A0ceph_decode_need(p, end, len, e_inval); /* [3] guard checks the > truncated (small) len */ > =C2=A0*p +=3D len; > [1] len is u32 and comes straight from the message; struct_v >=3D 5 > selects a per-entry size of sizeof(u32) + sizeof(u32) =3D 8. > [2] sizeof(...) is size_t, so the multiply is evaluated in 64-bit, > but the result is assigned back into the 32-bit len. A count Ns large > enough that Ns * 8 exceeds 0xFFFFFFFF truncates the product back to a > small value (a whole 32-bit multiple truncates to 0) in len. > [3] ceph_decode_need(p, end, 0) therefore always succeeds and *p +=3D 0 > =E2=80=94 the length guard that is supposed to prove the whole new_state > array is present in the buffer has been defeated, and the saved > new_state pointer still refers to the true (huge) count. > The second pass rewinds to that pointer, re-reads the true count, and > iterates with no bounds check: >=20 > =C2=A0/* new_state (up/down) */ > =C2=A0*p =3D new_state; > =C2=A0len =3D ceph_decode_32(p); /* [4] re-reads the TRUE (large) Ns */ > =C2=A0while (len--) { > =C2=A0s32 osd; > =C2=A0u32 xorstate; >=20 > =C2=A0osd =3D ceph_decode_32(p); /* [5] read osd id ... */ > =C2=A0if (osd >=3D map->max_osd) > =C2=A0goto e_inval; >=20 > =C2=A0if (struct_v >=3D 5) > =C2=A0xorstate =3D ceph_decode_32(p); /* [6] ... then read xorstate =E2= =80=94 no > ceph_decode_need */ > =C2=A0else > =C2=A0xorstate =3D ceph_decode_8(p); > =C2=A0... > =C2=A0} > [4] The loop trip count is the untruncated Ns, so the loop intends to > consume Ns * 8 bytes that [3] never proved are present. > [5]/[6] Each iteration advances *p by 8 bytes with no > ceph_decode_need(p, end, ...). This is the sole difference from the > new_weight loop immediately above it, which performs > ceph_decode_need(p, end, 2*sizeof(u32), e_inval) on every iteration > and is therefore safe. Once *p reaches end, the ceph_decode_32() at > [5]/[6] reads past the end of the message buffer. > The osd >=3D map->max_osd check at [5] does not contain the walk: the > over-read is the next ceph_decode_32() on the same or a following > iteration, and while the in-buffer trailing bytes decode to small osd > ids (< max_osd) the loop keeps advancing and reading past end before > any osd value happens to exceed max_osd. > KASAN report (VM-confirmed) > Device: mainline Linux 7.2.0-rc2 (origin/master 0e35b9b6ec0f), arm64, > built with CONFIG_KASAN_GENERIC=3Dy CONFIG_KASAN_VMALLOC=3Dy > CONFIG_CEPH_LIB=3Dm, booted under a QEMU virt VM (the running kernel > reports the module vermagic 7.0.0). The crafted incremental-OSDMap > bytes are the exact byte stream handle_one_map() decodes off the > wire; they are delivered to the unmodified osdmap_apply_incremental() > =E2=86=92 decode_new_up_state_weight() via a small out-of-tree module > (poc/poc.c). The vulnerable function body is byte-for-byte identical > to the pristine tree (verified by md5sum of the function); the only > source edits are three EXPORT_SYMBOL lines added outside the function > (ceph_osdmap_alloc, ceph_osdmap_destroy, osdmap_apply_incremental) so > the already header-declared helpers are loadable-module-callable. > Full log in evidence/kasan_oob.log. >=20 > =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D= =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D= =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D > BUG: KASAN: slab-out-of-bounds in > decode_new_up_state_weight+0x3f8/0x900 [libceph] > Read of size 4 at addr ffff000019d1b2e4 by task insmod/267 >=20 > CPU: 0 UID: 0 PID: 267 Comm: insmod Tainted: G =C2=A0 =C2=A0B =C2=A0 =C2= =A0 =C2=A0OE =C2=A0 =C2=A0 =C2=A0 > 7.0.0 #5 > Call trace: > =C2=A0show_stack+0x24/0x50 (C) > =C2=A0dump_stack_lvl+0x80/0xc0 > =C2=A0print_report+0x164/0x4e8 > =C2=A0kasan_report+0xb0/0x128 > =C2=A0kasan_check_range+0x114/0x200 > =C2=A0__asan_loadN+0x20/0x48 > =C2=A0decode_new_up_state_weight+0x3f8/0x900 [libceph] > =C2=A0osdmap_apply_incremental+0x3d4/0xa28 [libceph] > =C2=A0poc_init+0x270/0xf68 [ceph_osdmap_poc] > =C2=A0do_one_initcall+0xb4/0x6a0 >=20 > Allocated by task 267: > =C2=A0__kvmalloc_node_noprof+0x23c/0x7f0 > =C2=A0poc_init+0xd4/0xf68 [ceph_osdmap_poc] >=20 > The buggy address belongs to the object at ffff000019d1b280 > =C2=A0which belongs to the cache kmalloc-rnd-10-96 of size 96 > The buggy address is located 12 bytes to the right of > =C2=A0allocated 88-byte region [ffff000019d1b280, ffff000019d1b2d8) > =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D= =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D= =3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D=3D > libceph: corrupt inc osdmap (-22) epoch 1 off 104 > The faulting Read of size 4 at object +88+12 is the xorstate =3D > ceph_decode_32(p) at [6], reading past end of the 88-byte > incremental-OSDMap buffer allocated by the harness > (__kvmalloc_node_noprof from poc_init). > The PC decode_new_up_state_weight+0x3f8, called from > osdmap_apply_incremental+0x3d4, is the new_state loop; the trailing > corrupt inc osdmap (-22) ... off 104 is the loop finally bailing at > e_inval after it has already over-read the 88-byte region out to > offset 104. > Impact > A sequential kernel-heap out-of-bounds read walking *p past the end > of the incremental-OSDMap message buffer. Both the read length =E2=80=94 = the > untruncated new_state count that drives the loop =E2=80=94 and the > incremental-OSDMap message buffer size are controlled by the ceph > monitor, and no per-iteration bound stops the walk. The libceph front > buffer is kvmalloc'd up to CEPH_MSG_MAX_FRONT_LEN (16 MiB); when a > large front is backed by vmalloc the over-read crosses adjacent > kernel memory and a guard page. A single crafted MSG_OSD_MAP over the > wire reaches this decode in every kernel client that mounts CephFS or > maps an rbd image from that monitor. This is a monitor-controlled, > remotely-reachable kernel memory-safety violation (CWE-125) =E2=80=94 an = out- > of-bounds read / information-disclosure-class primitive over adjacent > kernel-heap bytes past the message buffer. >=20 > Patch > Add the missing per-iteration bounds check at the top of the > new_state loop, mirroring the sibling new_weight loop, so each 8-byte > (struct_v >=3D 5) or 5-byte entry is proven present before it is > decoded. This directly and minimally closes the walk regardless of > the first-pass truncation. As a defence in depth the skip-pass length > should also be accumulated in a type wide enough not to truncate > (e.g. compute the product as size_t and compare against end - *p), so > the guard at [3] is not silently satisfied by a wrapped length. >=20 > =C2=A0/* new_state (up/down) */ > =C2=A0*p =3D new_state; > =C2=A0len =3D ceph_decode_32(p); > =C2=A0while (len--) { > =C2=A0s32 osd; > =C2=A0u32 xorstate; >=20 > =C2=A0ceph_decode_need(p, end, > =C2=A0sizeof(u32) + (struct_v >=3D 5 ? sizeof(u32) : sizeof(u8)), > =C2=A0e_inval); > =C2=A0osd =3D ceph_decode_32(p); > =C2=A0if (osd >=3D map->max_osd) > =C2=A0goto e_inval; > =C2=A0... > =C2=A0} > Full diff in patch/fix.patch (applies cleanly to origin/master > 0e35b9b6ec0f). With the per-iteration ceph_decode_need() in place the > new_state loop can no longer read past end =E2=80=94 exactly like the sib= ling > new_weight loop that was already guarded =E2=80=94 so the crafted increme= ntal > map is rejected with -EINVAL instead of walking off the buffer. >=20 > Credit >=20 > Discovered by XBOW, triaged by Baul Lee <[email protected]>