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]>