Merge pull request #2103 from khanahmad4527/fix-ub286-bez-segments-null-write

fix: UB-286 — sm_CreateBezSegments writes through a null pointer for a one-span curve with an unclamped end (fuzz guard)
This commit is contained in:
bherbruck
2026-10-31 18:26:18 -04:00
committed by GitHub
4 changed files with 83 additions and 3 deletions
File diff suppressed because one or more lines are too long
@@ -53,7 +53,7 @@
//! and surface blocks; conic axes whose squared length does not overflow, else the conic
//! has no NURB to evaluate, and conic domains within [`MAX_CONIC_ANGLE`], HANG-4); the run-time state checks are the shim's.
use crate::validate::{bad, NOW};
use crate::validate::{bad, bez_segments_write_null, NOW};
use crate::{call_with_buffer, FfiError};
/// Largest conic domain end (radians) accepted (HANG-4).
@@ -494,7 +494,8 @@ impl Reader<'_> {
self.int(0, 1).map(|_| ())
}
/// `n p m cpts(x y z w) knots` with `n + p + 1 == m`, positive weights, nondecreasing
/// knots.
/// knots, and not one span with an end that is not clamped (UB-286: the curve's
/// cache writes through a null pointer in `sm_CreateBezSegments`).
fn nurb(&mut self) -> Result<(), FfiError> {
let n = self.int(1, MAX_POINTS)?;
let p = self.int(1, MAX_DEGREE)?;
@@ -509,6 +510,7 @@ impl Reader<'_> {
return Err(bad("weight"));
}
}
let mut knots = Vec::new();
let mut prev = f64::NEG_INFINITY;
for _ in 0..=m {
let u = self.finite()?;
@@ -516,6 +518,11 @@ impl Reader<'_> {
return Err(bad("knots"));
}
prev = u;
knots.push(u);
}
let p = usize::try_from(p).map_err(|_| bad("nurb counts"))?;
if bez_segments_write_null(p, &knots) {
return Err(bad("knots: one span with an unclamped end (UB-286)"));
}
Ok(())
}
+62
View File
@@ -26,6 +26,35 @@ pub(crate) fn euclid([x, y, z, w]: [f64; 4]) -> [f64; 3] {
}
}
/// Whether `sm_CreateBezSegments` (`SmNurbsCrv.cpp`) writes through its null
/// next-segment pointer `NQw` for a B-spline curve of degree `p` with `knots` (UB-286).
/// It does when the curve has exactly one non-zero span (`N_BasisGetSpanCount`: the
/// `i` in `p..m-p` with `U[i] != U[i+1]`), so `NQw` is never set, and a knot after
/// that span's end run differs from it (an end that is not clamped), so the segment
/// loop ends with `ie < m` and copies control points through `NQw`. The curve cache
/// every B-spline curve gets (`SmCurveCache`) then segfaults. The indices are
/// upstream's: the skip over a first knot's over-multiplicity, then the end run.
pub(crate) fn bez_segments_write_null(p: usize, knots: &[f64]) -> bool {
let Some(m) = knots.len().checked_sub(1) else {
return false;
};
let u = |i: usize| knots.get(i).copied();
let nsp = (p..m.saturating_sub(p))
.filter(|&i| u(i) != u(i + 1))
.count();
if nsp != 1 {
return false;
}
let mut ie = p + 1;
while ie < m && u(ie - 1) == u(ie) {
ie += 1;
}
while ie < m && u(ie) == u(ie + 1) {
ie += 1;
}
ie < m
}
/// Checks a clamped knot vector of degree `p`; returns its range.
pub(crate) fn check_knots(knots: &[f64], p: usize, what: &str) -> Result<(f64, f64), FfiError> {
let m = knots
@@ -65,3 +94,36 @@ pub(crate) fn check_knots(knots: &[f64], p: usize, what: &str) -> Result<(f64, f
}
Ok((a, b))
}
#[cfg(test)]
mod tests {
use super::*;
/// UB-286: one span and an end that is not clamped (the curve of
/// `topology.proto_topology_p2` fuzz00353), and the curves around it that
/// `sm_CreateBezSegments` handles.
#[test]
fn bez_segments_write_null_only_for_one_unclamped_span() {
let (a, b) = (-4.774869788871685, -3.774869788871685);
let fuzz00353 = [a, a, a, a, a, b, b, b, b, -0.0];
assert!(bez_segments_write_null(4, &fuzz00353));
let clamped = [a, a, a, a, a, b, b, b, b, b];
assert!(!bez_segments_write_null(4, &clamped));
// degree 2, one span [0, 1], then a knot past it
assert!(bez_segments_write_null(2, &[0.0, 0.0, 0.0, 1.0, 1.0, 2.0]));
// the domain u2..u3 is one span here too, and 2, 3 run on past it
assert!(bez_segments_write_null(2, &[0.0, 0.0, 0.0, 1.0, 2.0, 3.0]));
// two spans: the first segment sets NQw (unclamped and clamped ends)
assert!(!bez_segments_write_null(
2,
&[0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 3.0]
));
assert!(!bez_segments_write_null(
2,
&[0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0]
));
// no span (the caller refuses it with SM_ERR first), and no knots
assert!(!bez_segments_write_null(1, &[0.0, 0.0, 0.0, 0.0]));
assert!(!bez_segments_write_null(1, &[]));
}
}
@@ -0,0 +1,10 @@
#### UB-286. `sm_CreateBezSegments` writes through a null next-segment pointer for a one-span curve whose end is not clamped
- **Severity:** Crash / undefined behaviour
- **Area:** Curves
- **Location:** `source/SMLib/src/SmNurbsCrv.cpp`, `sm_CreateBezSegments` (def. `:478`), called by the B-spline branch of `SmCurveCache`'s Bezier span build (`source/SMLib/src/SmCurveCache.cpp:1299`). The function keeps `NQw = NULL` and sets it to the next segment's control points only `if( iq LT nsp-1 )`. After each segment's end knot run it copies control points through `NQw` whenever `ie LT m`: `InitCP(Qw[p],&NQw[save])` in the knot insertion branch and `InitCP(Pw[ie-p+i],&NQw[i])` after it.
- **What happens:** for a curve with exactly one non-zero span (`N_BasisGetSpanCount`: the `i` in `p..m-p` with `U[i] != U[i+1]`), `NQw` is never set. If the knots after that span's end run on to a different value (the end is not clamped), the loop leaves the run with `ie < m` and writes through the null `NQw`: SIGSEGV, a write to address 0. With two or more spans, `NQw` still points at the last segment's own control points, and the same writes overwrite them without crashing. The caller already refuses a curve without a span (`NonZeroSpanCount < 1` is `SM_ERR`).
- **Trigger:** any curve cache of such a B-spline curve. Nightly fuzz case `topology.proto_topology_p2` fuzz00353 (`--fuzz 600 --seeds 40`; base `gen0683`, mutation `/objects/2/curve/bspline/u/9 = -0.0`) gives a degree-4 curve the knots `-4.77 ×5, -3.77 ×4, -0.0`: the last knot no longer closes the end run, so `ie` stops at 8 of `m` 9. The knot vector is still non-decreasing; #1967's sweep listed the case as "non-monotone knots", which it is not.
- **How found:** #1967's fuzz sweep left it open as an FFI guard gap. Reproduced on main with the nightly command. The kernel log names the fault: `segfault at 0 ... error 6` (a write) at file offset `0xbfd3d2f` of the difftest binary. That is virtual address `0xbfd4d2f`, which `addr2line`/`nm` resolve to `sm_CreateBezSegments(curve*, SmTArray<curve*>&) + 0x54f`. Reading the function gives the condition above.
- **Confirmed:** yes, crash in the C++ reference run (prebuilt release harness at the pin, x86-64 Linux, 2026-11-01).
- **Rust port:** not affected. `nurbs::nurbs_crv::create_bez_segments` holds the next segment as an `Option` and returns an index error when it is unset, where upstream writes through `NULL`. The fuzz case returns without a panic.
- **Guard:** `solidcore_ffi::validate::bez_segments_write_null` replays `sm_CreateBezSegments`' indices: the span count, the skip over a first knot's over-multiplicity, and the first segment's end run. The proto script's curve validator (`topology.proto_topology_p1`, shared by p2 and p3) refuses exactly those curves, and the fuzz case is in `crates/difftest/tests/fuzz_corpus/topology.proto_topology_p2.json`. Other units that send B-spline curves to a C++ curve cache can use the same check.