Skip to content

Commit dd1cca5

Browse files
glambersonlamco-office
authored andcommitted
fix(pdu,graphics): harden ClearCodec and fix shortVBarYOff interpretation
Security hardening informed by FreeRDP ClearCodec CVEs (GHSA-3frr, GHSA-32q9, CVE-2026-26955, CVE-2026-23531) and three rounds of targeted code review. Fixes: - Correct shortVBarYOff interpretation: 6-bit field is an absolute end-row offset, not a pixel count. Pixel count = yOff - yOn per MS-RDPEGFX 2.2.4.1.1.2.1.1.3. (Round 2) - Validate shortVBarYOff >= shortVBarYOn and <= band_height (Round 2) - Cap residual run_length iterations to output buffer size (Round 1) - Cap decoder allocation to 8192x8192 pixels max (Round 3) - Validate glyph index range 0-3999 per spec 2.2.4.1 (Round 2) - Cap encoder input to available pixel data (Round 1) Add integration tests in ironrdp-testsuite-core (35 tests) covering codec round-trip, adversarial input (DoS, OOM, malformed streams), bands layer compositing, cache state, compression quality, and multi-frame session simulation. Inline unit tests (42 tests) in the PDU and graphics crates cover wire format decode/encode.
1 parent efff721 commit dd1cca5

4 files changed

Lines changed: 611 additions & 30 deletions

File tree

crates/ironrdp-graphics/src/clearcodec/mod.rs

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,25 @@ impl ClearCodecDecoder {
4444
let mut src = ReadCursor::new(data);
4545
let stream = ClearCodecBitmapStream::decode(&mut src)?;
4646

47-
// Validate sequence number
48-
if stream.seq_number != self.expected_seq {
49-
// Per spec, sequence mismatch means we should reset state.
50-
// In practice, some servers restart sequences so we tolerate it.
51-
}
47+
// Per spec (2.2.4.1 seqNumber): must increment by 1, wrapping 0xFF -> 0x00.
48+
// Mismatches indicate packet loss or server restart. We tolerate them
49+
// (FreeRDP does too) but re-sync to the received sequence number.
50+
// ironrdp-graphics has no tracing dependency, so callers that need
51+
// diagnostics should track sequence numbers externally.
5252
self.expected_seq = stream.seq_number.wrapping_add(1);
5353

5454
// Handle cache reset
5555
if stream.is_cache_reset() {
5656
self.vbar_cache.reset();
5757
}
5858

59+
// Validate glyph index range per spec: 0..3999 inclusive
60+
if let Some(idx) = stream.glyph_index {
61+
if idx >= GLYPH_CACHE_WRAP {
62+
return Err(invalid_field_err!("glyphIndex", "glyph index out of range 0-3999"));
63+
}
64+
}
65+
5966
let w = usize::from(width);
6067
let h = usize::from(height);
6168
let pixel_count = w
@@ -74,6 +81,15 @@ impl ClearCodecDecoder {
7481
return Ok(entry.pixels.clone());
7582
}
7683

84+
// Cap allocation to prevent OOM from adversarial dimensions.
85+
// EGFX surfaces are capped at 32767x32767 by the spec, but a single
86+
// ClearCodec tile should never approach that. 8192x8192 (256MB) is a
87+
// generous upper bound for any reasonable tile size.
88+
const MAX_DECODE_PIXELS: usize = 8192 * 8192;
89+
if pixel_count > MAX_DECODE_PIXELS {
90+
return Err(invalid_field_err!("dimensions", "pixel count exceeds decoder maximum"));
91+
}
92+
7793
// Decode composite payload
7894
let mut output = vec![0u8; pixel_count * 4];
7995

@@ -109,19 +125,25 @@ impl ClearCodecDecoder {
109125
) -> DecodeResult<()> {
110126
let w = usize::from(width);
111127

112-
// Layer 1: Residual (BGR RLE) - fills the entire output
128+
// Layer 1: Residual (BGR RLE) - fills the entire output.
129+
// Cap pixel writes to the output buffer size to prevent CPU-spin DoS
130+
// from adversarial run_length values (FreeRDP CVE GHSA-32q9-m5qr-9j2v).
113131
if !composite.residual_data.is_empty() {
114132
let segments = decode_residual_layer(composite.residual_data)?;
133+
let max_offset = output.len();
115134
let mut offset = 0;
116135
for seg in &segments {
117-
for _ in 0..seg.run_length {
118-
if offset + 3 < output.len() {
119-
output[offset] = seg.blue;
120-
output[offset + 1] = seg.green;
121-
output[offset + 2] = seg.red;
122-
output[offset + 3] = 0xFF; // Alpha
123-
offset += 4;
124-
}
136+
let pixels_remaining = (max_offset.saturating_sub(offset)) / 4;
137+
let effective_run = u32::try_from(pixels_remaining).unwrap_or(u32::MAX).min(seg.run_length);
138+
for _ in 0..effective_run {
139+
output[offset] = seg.blue;
140+
output[offset + 1] = seg.green;
141+
output[offset + 2] = seg.red;
142+
output[offset + 3] = 0xFF; // Alpha
143+
offset += 4;
144+
}
145+
if offset >= max_offset {
146+
break;
125147
}
126148
}
127149
}
@@ -295,9 +317,11 @@ impl ClearCodecDecoder {
295317
}
296318
}
297319
SubcodecId::NsCodec => {
298-
// NSCodec: deferred to Phase A7. For now, treat as opaque.
299-
// This is a valid conformance approach: the server can always
300-
// choose to use Raw or RLEX subcodecs instead.
320+
// NSCodec (MS-RDPNSC) decode not yet implemented.
321+
// Encoder avoids generating NSCodec tiles, so this path only
322+
// triggers when decoding streams from other implementations.
323+
// The region is left at its current pixel values (transparent
324+
// or whatever the residual layer filled in).
301325
}
302326
}
303327

@@ -439,6 +463,10 @@ fn bgra_to_run_segments(bgra: &[u8], pixel_count: usize) -> Vec<RgbRunSegment> {
439463
return Vec::new();
440464
}
441465

466+
// Cap to the number of complete pixels actually present in the input
467+
let available_pixels = bgra.len() / 4;
468+
let pixel_count = pixel_count.min(available_pixels);
469+
442470
let mut segments = Vec::new();
443471
let mut i = 0;
444472

crates/ironrdp-pdu/src/codecs/clearcodec/bands.rs

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ pub enum VBar<'a> {
5858
/// Inline short V-bar data from a cache miss.
5959
#[derive(Debug, Clone)]
6060
pub struct ShortVBarCacheMiss<'a> {
61-
/// First pixel row within the band where color data starts.
61+
/// First pixel row within the band where color data starts (shortVBarYOn).
6262
pub y_on: u8,
63-
/// Number of pixel rows with color data (6 bits, max 52).
63+
/// Number of pixel rows with color data (`shortVBarYOff - shortVBarYOn`).
6464
pub y_off_delta: u8,
6565
/// Raw BGR pixel data: `y_off_delta * 3` bytes.
6666
pub pixel_data: &'a [u8],
@@ -124,7 +124,7 @@ fn decode_single_band<'a>(src: &mut ReadCursor<'a>) -> DecodeResult<Band<'a>> {
124124
})
125125
}
126126

127-
fn decode_vbar<'a>(src: &mut ReadCursor<'a>, _band_height: u16) -> DecodeResult<VBar<'a>> {
127+
fn decode_vbar<'a>(src: &mut ReadCursor<'a>, band_height: u16) -> DecodeResult<VBar<'a>> {
128128
ensure_size!(ctx: "VBar", in: src, size: 2);
129129
let first_word = src.read_u16();
130130

@@ -143,19 +143,32 @@ fn decode_vbar<'a>(src: &mut ReadCursor<'a>, _band_height: u16) -> DecodeResult<
143143
}
144144

145145
// Both top bits clear: short V-bar cache miss
146-
// first_word encodes: yOn (high 8 bits of the 14-bit field) and yOff delta (low 6 bits)
147-
// Per spec: top byte = yOn, low 6 bits = pixel count
148-
// Top 2 bits are clear (checked above), so first_word <= 0x3FFF and (first_word >> 6) <= 0xFF
146+
// Per MS-RDPEGFX 2.2.4.1.1.2.1.1.3 (SHORT_VBAR_CACHE_MISS):
147+
// bits 13:6 = shortVBarYOn (8 bits): row where Short V-Bar begins
148+
// bits 5:0 = shortVBarYOff (6 bits): row where Short V-Bar ends
149+
// Pixel count = shortVBarYOff - shortVBarYOn
149150
let y_on = u8::try_from(first_word >> 6).expect("top 2 bits are clear, so shifted value fits in u8");
150-
let y_off_delta = u8::try_from(first_word & 0x3F).expect("masked to 6 bits, always fits in u8");
151+
let y_off = u8::try_from(first_word & 0x3F).expect("masked to 6 bits, always fits in u8");
151152

152-
let pixel_byte_count = usize::from(y_off_delta) * 3;
153+
if y_off < y_on {
154+
return Err(invalid_field_err!("shortVBarCacheMiss", "shortVBarYOff < shortVBarYOn"));
155+
}
156+
157+
if u16::from(y_off) > band_height {
158+
return Err(invalid_field_err!(
159+
"shortVBarCacheMiss",
160+
"shortVBarYOff exceeds band height"
161+
));
162+
}
163+
164+
let pixel_count = y_off - y_on;
165+
let pixel_byte_count = usize::from(pixel_count) * 3;
153166
ensure_size!(ctx: "ShortVBarCacheMiss", in: src, size: pixel_byte_count);
154167
let pixel_data = src.read_slice(pixel_byte_count);
155168

156169
Ok(VBar::ShortCacheMiss(ShortVBarCacheMiss {
157170
y_on,
158-
y_off_delta,
171+
y_off_delta: pixel_count,
159172
pixel_data,
160173
}))
161174
}
@@ -195,10 +208,10 @@ mod tests {
195208

196209
#[test]
197210
fn decode_vbar_short_cache_miss() {
198-
// Both top bits clear: yOn=2 (shifted left 6), pixel_count=3
211+
// Both top bits clear: y_on=2, y_off=5, pixel_count = y_off - y_on = 3
199212
let y_on: u16 = 2;
200-
let pixel_count: u16 = 3;
201-
let first_word = (y_on << 6) | pixel_count;
213+
let y_off: u16 = 5;
214+
let first_word = (y_on << 6) | y_off;
202215
let mut data = Vec::new();
203216
data.extend_from_slice(&first_word.to_le_bytes());
204217
// 3 pixels * 3 bytes = 9 bytes BGR data
@@ -208,7 +221,7 @@ mod tests {
208221
match vbar {
209222
VBar::ShortCacheMiss(miss) => {
210223
assert_eq!(miss.y_on, 2);
211-
assert_eq!(miss.y_off_delta, 3);
224+
assert_eq!(miss.y_off_delta, 3); // pixel_count = y_off - y_on = 5 - 2 = 3
212225
assert_eq!(miss.pixel_data.len(), 9);
213226
}
214227
_ => panic!("expected ShortCacheMiss"),

0 commit comments

Comments
 (0)