Skip to content

Commit b0961ad

Browse files
mjcEugeny
authored andcommitted
parser: reject trailing KEX and channel-open payloads
1 parent 32fd46f commit b0961ad

5 files changed

Lines changed: 587 additions & 8 deletions

File tree

russh/src/client/kex.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::kex::dh::groups::DhGroup;
1414
use crate::kex::{KEXES, KexAlgorithm, KexAlgorithmImplementor, KexCause, KexProgress};
1515
use crate::keys::key::parse_public_key;
1616
use crate::negotiation::{Names, Select};
17+
use crate::parsing::ensure_end;
1718
use crate::session::Exchange;
1819
use crate::sshbuffer::PacketWriter;
1920
use crate::{CryptoVec, Error, SshId, msg, negotiation, strict_kex_violation};
@@ -195,6 +196,7 @@ impl ClientKex {
195196

196197
let prime = Mpint::decode(&mut r)?;
197198
let generator = Mpint::decode(&mut r)?;
199+
ensure_end(&r)?;
198200
debug!("received gex group: prime={prime}, generator={generator}");
199201

200202
let group = DhGroup {
@@ -287,7 +289,10 @@ impl ClientKex {
287289
})?;
288290

289291
let signature = Bytes::decode(r)?;
290-
let signature = Signature::decode(&mut &signature[..])?;
292+
let mut signature_reader = &signature[..];
293+
let signature = Signature::decode(&mut signature_reader)?;
294+
ensure_end(&signature_reader)?;
295+
ensure_end(r)?;
291296

292297
if let Err(e) =
293298
signature::Verifier::verify(&server_host_key, hash.as_ref(), &signature)
@@ -338,6 +343,8 @@ impl ClientKex {
338343
);
339344
return Err(Error::Kex);
340345
}
346+
let r = &input.buffer[1..];
347+
ensure_end(&r)?;
341348

342349
Ok(KexProgress::Done {
343350
newkeys,

russh/src/negotiation.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use crate::kex::{
2626
EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT, EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER, KexCause,
2727
};
2828
use crate::keys::key::safe_rng;
29+
use crate::parsing::ensure_end;
2930
#[cfg(not(target_arch = "wasm32"))]
3031
use crate::server::Config;
3132
use crate::sshbuffer::PacketWriter;
@@ -343,6 +344,8 @@ pub(crate) trait Select {
343344
String::decode(&mut r)?; // languages server-to-client
344345

345346
let follows = u8::decode(&mut r)? != 0;
347+
u32::decode(&mut r)?;
348+
ensure_end(&r)?;
346349
Ok(Names {
347350
kex: kex_algorithm,
348351
key: key_algorithm,

russh/src/parsing.rs

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ use crate::msg;
44

55
use crate::map_err;
66

7+
/// Require a decoded known-message payload to be fully consumed.
8+
///
9+
/// SSH RFCs and implemented OpenSSH extensions define exact field layouts for
10+
/// known message types. Callers use this after decoding those fields so
11+
/// malformed packets with trailing payload bytes are rejected instead of being
12+
/// treated as canonical messages.
13+
pub(crate) fn ensure_end(reader: &impl Reader) -> Result<(), crate::Error> {
14+
if reader.is_finished() {
15+
Ok(())
16+
} else {
17+
Err(ssh_encoding::Error::TrailingData {
18+
remaining: reader.remaining_len(),
19+
}
20+
.into())
21+
}
22+
}
23+
724
#[derive(Debug)]
825
pub struct OpenChannelMessage {
926
pub typ: ChannelType,
@@ -13,6 +30,12 @@ pub struct OpenChannelMessage {
1330
}
1431

1532
impl OpenChannelMessage {
33+
/// Parse an SSH `CHANNEL_OPEN` payload.
34+
///
35+
/// Known channel types are parsed according to their fixed layouts and must
36+
/// not contain trailing bytes. Unknown extension channel types remain
37+
/// intentionally opaque so applications can implement extension-specific
38+
/// parsing and compatibility behavior.
1639
pub fn parse<R: Reader>(r: &mut R) -> Result<Self, crate::Error> {
1740
// https://tools.ietf.org/html/rfc4254#section-5.1
1841
let typ = map_err!(String::decode(r))?;
@@ -21,24 +44,46 @@ impl OpenChannelMessage {
2144
let maxpacket = map_err!(u32::decode(r))?;
2245

2346
let typ = match typ.as_str() {
24-
"session" => ChannelType::Session,
47+
"session" => {
48+
ensure_end(r)?;
49+
ChannelType::Session
50+
}
2551
"x11" => {
2652
let originator_address = map_err!(String::decode(r))?;
2753
let originator_port = map_err!(u32::decode(r))?;
54+
ensure_end(r)?;
2855
ChannelType::X11 {
2956
originator_address,
3057
originator_port,
3158
}
3259
}
33-
"direct-tcpip" => ChannelType::DirectTcpip(TcpChannelInfo::decode(r)?),
60+
"direct-tcpip" => {
61+
let info = TcpChannelInfo::decode(r)?;
62+
ensure_end(r)?;
63+
ChannelType::DirectTcpip(info)
64+
}
3465
"direct-streamlocal@openssh.com" => {
35-
ChannelType::DirectStreamLocal(StreamLocalChannelInfo::decode(r)?)
66+
let info = StreamLocalChannelInfo::decode(r)?;
67+
String::decode(r)?; // originator address/reserved
68+
u32::decode(r)?; // originator port/reserved
69+
ensure_end(r)?;
70+
ChannelType::DirectStreamLocal(info)
71+
}
72+
"forwarded-tcpip" => {
73+
let info = TcpChannelInfo::decode(r)?;
74+
ensure_end(r)?;
75+
ChannelType::ForwardedTcpIp(info)
3676
}
37-
"forwarded-tcpip" => ChannelType::ForwardedTcpIp(TcpChannelInfo::decode(r)?),
3877
"forwarded-streamlocal@openssh.com" => {
39-
ChannelType::ForwardedStreamLocal(StreamLocalChannelInfo::decode(r)?)
78+
let info = StreamLocalChannelInfo::decode(r)?;
79+
String::decode(r)?; // reserved
80+
ensure_end(r)?;
81+
ChannelType::ForwardedStreamLocal(info)
82+
}
83+
"auth-agent@openssh.com" => {
84+
ensure_end(r)?;
85+
ChannelType::AgentForward
4086
}
41-
"auth-agent@openssh.com" => ChannelType::AgentForward,
4287
_ => ChannelType::Unknown { typ },
4388
};
4489

@@ -177,3 +222,51 @@ impl Decode for ChannelOpenConfirmation {
177222
})
178223
}
179224
}
225+
226+
#[cfg(test)]
227+
mod tests {
228+
use super::{ChannelType, OpenChannelMessage};
229+
use crate::tests::raw_no_crypto::{channel_open_payload, encode_string, push_u32};
230+
231+
#[test]
232+
fn known_channel_open_with_trailing_bytes_is_rejected() {
233+
let mut payload = channel_open_payload(b"session");
234+
payload.push(0);
235+
236+
assert!(
237+
OpenChannelMessage::parse(&mut payload.as_slice()).is_err(),
238+
"known channel-open type accepted trailing bytes"
239+
);
240+
}
241+
242+
#[test]
243+
fn unknown_channel_open_with_extra_payload_stays_permissive() {
244+
let mut payload = channel_open_payload(b"unknown@example.com");
245+
payload.extend_from_slice(b"opaque");
246+
247+
let parsed = OpenChannelMessage::parse(&mut payload.as_slice())
248+
.expect("unknown channel-open payload should remain opaque");
249+
250+
assert!(matches!(parsed.typ, ChannelType::Unknown { .. }));
251+
}
252+
253+
#[test]
254+
fn openssh_streamlocal_channel_open_reserved_fields_are_consumed() {
255+
let mut direct = channel_open_payload(b"direct-streamlocal@openssh.com");
256+
encode_string(&mut direct, b"/tmp/socket");
257+
encode_string(&mut direct, b"");
258+
push_u32(&mut direct, 0);
259+
260+
let parsed = OpenChannelMessage::parse(&mut direct.as_slice())
261+
.expect("direct streamlocal reserved fields should be consumed");
262+
assert!(matches!(parsed.typ, ChannelType::DirectStreamLocal(_)));
263+
264+
let mut forwarded = channel_open_payload(b"forwarded-streamlocal@openssh.com");
265+
encode_string(&mut forwarded, b"/tmp/socket");
266+
encode_string(&mut forwarded, b"");
267+
268+
let parsed = OpenChannelMessage::parse(&mut forwarded.as_slice())
269+
.expect("forwarded streamlocal reserved field should be consumed");
270+
assert!(matches!(parsed.typ, ChannelType::ForwardedStreamLocal(_)));
271+
}
272+
}

russh/src/server/kex.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use crate::kex::dh::biguint_to_mpint;
1313
use crate::kex::{KexAlgorithm, KexAlgorithmImplementor, KexCause, KEXES};
1414
use crate::keys::key::PrivateKeyWithHashAlg;
1515
use crate::negotiation::{is_key_compatible_with_algo, Names, Select};
16+
use crate::parsing::ensure_end;
1617
use crate::{msg, negotiation};
1718

1819
thread_local! {
@@ -173,7 +174,9 @@ impl ServerKex {
173174
}
174175

175176
#[allow(clippy::indexing_slicing)] // length checked
176-
let gex_params = GexParams::decode(&mut &input.buffer[1..])?;
177+
let mut r = &input.buffer[1..];
178+
let gex_params = GexParams::decode(&mut r)?;
179+
ensure_end(&r)?;
177180
debug!("client requests a gex group: {gex_params:?}");
178181

179182
let Some(dh_group) = handler.lookup_dh_gex_group(&gex_params).await? else {
@@ -236,6 +239,7 @@ impl ServerKex {
236239
self.exchange
237240
.client_ephemeral
238241
.extend_from_slice(&Bytes::decode(&mut r).map_err(Into::into)?);
242+
ensure_end(&r)?;
239243

240244
let exchange = &mut self.exchange;
241245
kex.server_dh(exchange, &input.buffer)?;
@@ -324,6 +328,8 @@ impl ServerKex {
324328
);
325329
return Err(Error::Kex.into());
326330
}
331+
let r = &input.buffer[1..];
332+
ensure_end(&r)?;
327333

328334
debug!("new keys received");
329335
Ok(KexProgress::Done {
@@ -372,3 +378,21 @@ fn compute_keys(
372378
session_id: session_id_cv,
373379
})
374380
}
381+
382+
#[cfg(test)]
383+
mod tests {
384+
use crate::tests::raw_no_crypto::{
385+
assert_rejected, kexinit_payload, raw_kex_signal, timeout,
386+
};
387+
388+
#[tokio::test]
389+
async fn kexinit_with_trailing_bytes_rejected_by_server() {
390+
let result = timeout(raw_kex_signal(|payload| {
391+
payload.extend_from_slice(&kexinit_payload("none"));
392+
payload.push(0);
393+
}))
394+
.await;
395+
396+
assert_rejected(result, "server accepted a kexinit with trailing bytes");
397+
}
398+
}

0 commit comments

Comments
 (0)