Bound DGN element reads that a crafted file could abuse

A V8 B-spline header shorter than 110 bytes no longer panics: the order
and closure word is read only when the element holds it.

A V7 cell takes only the components its total length covers, and cells
nested deeper than 16 levels are left out, so a long run of complex
elements no longer recurses once per element until the stack overflows.
A nested cell also stops swallowing the sibling elements after it.

Only the chosen model's control stream is inflated for its reference
attachments, within what is left of the budget the models were read
under, instead of every model's stream without a total limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-16 23:10:27 +03:00
1 parent 2b807a7654
commit 6cbe62921b
2 files changed
+59 -34

No files matched your search

+19 -9
View File
@@ -172,11 +172,14 @@ struct Instance {
origin: [f64; 2],
}
/// How deep cells inside cells are read.
const MAX_CELL_DEPTH: usize = 16;
/// Reads a run of elements into raw geometry: plain elements, complex
/// chains and shapes (joined), B-splines, text, cells and shared cell
/// instances. In a 3D file (`d3`) points have a z and planar elements their
/// plane; the drawn geometry is their plan.
fn read_run(run: &[&[u8]], colors: &[[u8; 3]], d3: bool) -> Raw {
fn read_run(run: &[&[u8]], colors: &[[u8; 3]], d3: bool, depth: usize) -> Raw {
let mut raw = Raw::default();
// An open B-spline curve: order, pole count, colour, closed, and the
// header as the element it imports as.
@@ -280,14 +283,21 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]], d3: bool) -> Raw {
let plan = |p: [f64; 3]| [p[0], p[1]];
let step = if d3 { 12 } else { 8 };
match kind {
// A cell: its components follow as complex elements; they are
// already placed, so the cell keeps them as they are.
// A cell: its components follow as complex elements, as many as
// its total length (36, the words after its first 38 bytes)
// covers; they are already placed, so the cell keeps them as
// they are. Nesting past `MAX_CELL_DEPTH` is left out.
2 => {
let mut left = (38 + uint16(e, 36).unwrap_or(0) as usize * 2).saturating_sub(e.len());
let start = index;
while index < run.len() && run[index][0] & 0x80 != 0 {
while left > 0 && index < run.len() && run[index][0] & 0x80 != 0 {
left = left.saturating_sub(run[index].len());
index += 1;
}
let inner = read_run(&run[start..index], colors, d3);
if depth >= MAX_CELL_DEPTH {
continue;
}
let inner = read_run(&run[start..index], colors, d3, depth + 1);
raw.paths.extend(inner.paths);
raw.texts.extend(inner.texts);
raw.instances.extend(inner.instances);
@@ -836,14 +846,14 @@ pub fn model(bytes: &[u8], hidden: &[String]) -> Option<Sheet> {
while j < all.len() && all[j][0] & 0x80 != 0 {
j += 1;
}
defs.insert(name, read_run(&all[i + 1..j], &colors, is_3d(bytes)));
defs.insert(name, read_run(&all[i + 1..j], &colors, is_3d(bytes), 0));
i = j;
continue;
}
top.push(e);
i += 1;
}
let raw = read_run(&top, &colors, is_3d(bytes));
let raw = read_run(&top, &colors, is_3d(bytes), 0);
let per_unit = u.uor_per_sub * u.sub_per_master;
let m: Affine = [
1.0 / per_unit,
@@ -910,7 +920,7 @@ pub fn import(bytes: &[u8]) -> Option<DgnImport> {
while j < all.len() && all[j][0] & 0x80 != 0 {
j += 1;
}
let def = read_run(&all[i + 1..j], &colors, is_3d(bytes)).elements;
let def = read_run(&all[i + 1..j], &colors, is_3d(bytes), 0).elements;
cells.push((name, scaled_elements(&def, s, [0.0, 0.0])));
i = j;
continue;
@@ -918,7 +928,7 @@ pub fn import(bytes: &[u8]) -> Option<DgnImport> {
top.push(e);
i += 1;
}
let raw = read_run(&top, &colors, is_3d(bytes));
let raw = read_run(&top, &colors, is_3d(bytes), 0);
let levels = (0..64u32)
.map(|l| {
let name = names.get(&(l as u8)).cloned().unwrap_or_else(|| l.to_string());
+40 -25
View File
@@ -209,16 +209,22 @@ impl<'a> Cfb<'a> {
}
}
/// Far above any real element stream; stops a crafted one from inflating
/// until memory runs out.
const STREAM_LIMIT: u64 = 512 << 20;
/// A DGN stream: raw, or zlib data after a 16-byte header.
fn inflate(data: &[u8]) -> Vec<u8> {
// Far above any real element stream; stops a crafted one from
// inflating until memory runs out.
const STREAM_LIMIT: u64 = 512 << 20;
inflate_at_most(data, STREAM_LIMIT)
}
/// [`inflate`] stopping after `limit` bytes.
fn inflate_at_most(data: &[u8], limit: u64) -> Vec<u8> {
for skip in [16usize, 0] {
if let Some(z) = data.get(skip..) {
let mut out = Vec::new();
if flate2::read::ZlibDecoder::new(z)
.take(STREAM_LIMIT)
.take(limit)
.read_to_end(&mut out)
.is_ok()
&& !out.is_empty()
@@ -997,7 +1003,10 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw {
27 => {
// B-spline curve header: order and closure in the word at
// 108, pole count 112; its poles follow (type 21).
let flags = u16::from_le_bytes([e[108], e[109]]);
let Some(&[lo, hi]) = e.get(108..110) else {
continue;
};
let flags = u16::from_le_bytes([lo, hi]);
spline = Some((
((flags & 0x0f) + 2) as usize,
u32_at(e, 112).unwrap_or(0) as usize,
@@ -1149,6 +1158,11 @@ fn units(header: &[u8]) -> (f64, f64) {
(master, sub)
}
/// How many bytes the streams of one file may inflate to in all.
fn inflate_budget(cfb: &Cfb) -> usize {
cfb.b.len().saturating_mul(64).max(256 << 20)
}
/// Models of the file: (name, graphic stream, header stream).
fn models(cfb: &Cfb) -> Vec<(String, Vec<u8>, Vec<u8>)> {
let streams = cfb.streams();
@@ -1163,7 +1177,7 @@ fn models(cfb: &Cfb) -> Vec<(String, Vec<u8>, Vec<u8>)> {
// scales with the file (×64 covers any real compression ratio, so
// legitimate files never trip it) with a 256 MiB floor for small ones;
// models past the budget are dropped, the earlier ones still load.
let total_limit = cfb.b.len().saturating_mul(64).max(256 << 20);
let total_limit = inflate_budget(cfb);
let mut total = 0usize;
let mut out = Vec::new();
for dir in dirs {
@@ -1400,24 +1414,28 @@ pub fn design_model_names(bytes: &[u8]) -> Option<Vec<String>> {
(!names.is_empty()).then_some(names)
}
/// The control stream (Dgn^C) of each model, in the order `models` lists
/// them.
fn control_streams(cfb: &Cfb) -> Vec<Vec<u8>> {
/// The control stream (Dgn^C) of model `k` in the order `models` lists
/// them, inflated within what is left of the file's budget (`used` bytes
/// are taken by the models already read).
fn control_stream(cfb: &Cfb, k: usize, used: usize) -> Vec<u8> {
let streams = cfb.streams();
let mut dirs: Vec<String> = streams
let mut dirs: Vec<&str> = streams
.iter()
.filter_map(|(p, _)| p.strip_suffix("/Dgn^G/$1").map(|d| d.to_string()))
.filter_map(|(p, _)| p.strip_suffix("/Dgn^G/$1"))
.collect();
dirs.sort();
dirs.iter()
.map(|dir| {
streams
.iter()
.find(|(p, _)| *p == format!("{dir}/Dgn^C/$1"))
.map(|(_, e)| inflate(&cfb.stream(e)))
.unwrap_or_default()
})
.collect()
let Some(dir) = dirs.get(k) else {
return Vec::new();
};
let left = inflate_budget(cfb).saturating_sub(used) as u64;
if left == 0 {
return Vec::new();
}
streams
.iter()
.find(|(p, _)| *p == format!("{dir}/Dgn^C/$1"))
.map(|(_, e)| inflate_at_most(&cfb.stream(e), left.min(STREAM_LIMIT)))
.unwrap_or_default()
}
/// Every string of an element stored after the "ff fe 01 00" marker, with
@@ -1476,7 +1494,6 @@ fn references(control: &[u8]) -> Vec<DgnReference> {
pub fn import(bytes: &[u8], name: &str) -> Option<DgnImport> {
let cfb = Cfb::open(bytes)?;
let all = models(&cfb);
let controls = control_streams(&cfb);
let designs: Vec<usize> = (0..all.len()).filter(|k| is_design(&all[*k].2)).collect();
let k = designs
.iter()
@@ -1536,10 +1553,8 @@ pub fn import(bytes: &[u8], name: &str) -> Option<DgnImport> {
(Some(n), Some(d)) if n > 0.0 && d > 0.0 => unit_name(d / n),
_ => String::new(),
};
let references = controls
.get(k)
.map(|c| references(c))
.unwrap_or_default()
let used = all.iter().map(|m| m.1.len() + m.2.len()).sum();
let references = references(&control_stream(&cfb, k, used))
.into_iter()
.map(|r| DgnReference {
origin: [