Hi,
in my software that uses this crate i build a series of e2e tests. I think I found (with Claude) a small bug. Below is the report Claude generated for this.
SNMPv3 GET fails against RFC 3414-compliant agents: build() emits non-empty msgAuthenticationParameters for noAuthNoPriv
TL;DR
AsyncSession/SyncSession SNMPv3 requests built by v3::build() always reserve a
truncation_len-byte (zero-filled) msgAuthenticationParameters field, even when the
security level is noAuthNoPriv (no authentication). Per RFC 3414 §2.4 / §7.1, this field
must be empty (zero length) when the Auth flag is not set. Standards-compliant agents
reject the malformed message and reply with a usmStatsWrongDigests Report PDU, which the
client then surfaces as Error::AsnWrongType ("ASN.1 wrong type").
The discovery probe (build_init()) builds the field correctly (empty), so engine discovery
succeeds but the subsequent GET fails. net-snmp performing the same noAuthNoPriv GET against
the same agent succeeds, confirming the agent is compliant and the defect is client-side.
Affected versions
- Reproduced on the
dobermai/snmp2 fork rev 7b339e8 (reported as version 0.5.1), built with
default-features = false, features = ["tokio", "v3", "crypto-rust"].
- The defective code path (
v3::build()) is unchanged from upstream roboplc/snmp2, so released
0.5.0 is expected to be affected as well.
- SNMPv3 GET also fails on
0.4.x (observed as a hang/timeout rather than AsnWrongType), i.e.
the v3 GET path has not been working for a while; this report pins down the 0.5.x mechanism.
Symptom
A noAuthNoPriv GET returns:
Error::AsnWrongType // Display: "ASN.1 wrong type"
raised from AsyncSession::get() → Pdu::validate(MessageType::Response, ...) (the parsed
response is a Report, not a Response). Engine discovery (init()) completes normally just
before this.
Root cause
src/v3.rs, in build():
let truncation_len = security.auth_protocol.truncation_length();
...
sec_buf_seq.push_sequence(|buf| {
buf.push_octet_string(&priv_params); // priv params
let l0 = buf.len() - priv_params.len();
buf.push_octet_string(&vec![0u8; truncation_len]); // auth params <-- BUG
let l1 = buf.len() - l0;
buf.push_octet_string(security.username()); // user name
buf.push_integer(security.engine_time()); // time
buf.push_integer(security.engine_boots()); // boots
buf.push_octet_string(security.engine_id()); // engine ID
auth_pos = buf.len() - l1;
sec_buf_len = buf.len();
});
...
auth_pos += buf.len() - inner_len;
if (auth_pos + truncation_len) > buf.len() {
return Err(Error::ValueOutOfRange);
}
if security.need_auth() {
let hmac = security.calculate_hmac(buf)?;
buf[auth_pos..auth_pos + truncation_len].copy_from_slice(&hmac[..truncation_len]);
}
msgAuthenticationParameters is written as vec![0u8; truncation_len] unconditionally.
AuthProtocol has no "none" variant, so for a noAuthNoPriv session it takes its default
(Md5), whose truncation_length() is 12. The result is a 12-byte all-zero auth-params field
in a message whose msgFlags auth bit is 0 — which is malformed.
For contrast, build_init() builds the same field correctly:
sec.push_octet_string(&[]); // auth params (empty — correct)
Evidence (captured wire bytes)
Captured by instrumenting build()/send_and_recv() and parse_v3() to dump hex.
Outgoing GET request (noAuthNoPriv), 136 bytes
308185020103300e020101020300ffe3040104020103043a3038041580004fb805656531
6634376534306539390001c700020102020200dc04087573722d6e6f6e65040c00000000
0000000000000000040030 3404 1580004fb8056565316634376534306539390001c700
0400a019020101020100020100300e300c06082b060102010101000500
Decoded (relevant parts):
30 0e msgGlobalData SEQUENCE
02 01 01 msgID = 1
02 03 00 ff e3 msgMaxSize = 65507
04 01 04 msgFlags = 0x04 (REPORTABLE; AUTH=0, PRIV=0)
02 01 03 msgSecurityModel = 3 (USM)
04 3a msgSecurityParameters OCTET STRING
30 38 USM SEQUENCE
04 15 80004fb8...c700 msgAuthoritativeEngineID
02 01 02 msgAuthoritativeEngineBoots = 2
02 02 00 dc msgAuthoritativeEngineTime = 220
04 08 7573722d6e6f6e65 msgUserName = "usr-none"
04 0c 00 00 00 00 00 00 00 00 00 00 00 00 msgAuthenticationParameters <-- 12 zero bytes; should be 04 00
04 00 msgPrivacyParameters = empty
30 34 scopedPDU
04 15 80004fb8...c700 contextEngineID
04 00 contextName = empty
a0 19 GetRequest
02 01 01 request-id = 1
02 01 00 error-status = 0
02 01 00 error-index = 0
30 0e 30 0c 06 08 2b060102010101 00 05 00 varbind: OID 1.3.6.1.2.1.1.1.0 = NULL
The msgFlags auth bit is 0, yet msgAuthenticationParameters carries 12 bytes.
Agent response: a Report PDU, 126 bytes
307c020103300e020101020300ffe3040104020103042e302c041580004fb80565653166
34376534306539390001c700020102020200dc04087573722d6e6f6e6504000400303704
1580004fb8056565316634376534306539390001c7000400 a81c 02010002010002010030
11300f060a2b060106030f01010500410103
Decoded scopedPDU:
a8 1c Report-PDU <-- not Response (0xa2)
02 01 00 request-id = 0
02 01 00 error-status = 0
02 01 00 error-index = 0
30 11 30 0f
06 0a 2b060106030f010105 00 OID 1.3.6.1.6.3.15.1.1.5.0 = usmStatsWrongDigests
41 01 03 Counter32 = 3
get() validates MessageType::Response, gets Report → Error::AsnWrongType.
Cross-check with net-snmp
The same agent (snmpsim) answers a net-snmp noAuthNoPriv GET correctly:
$ snmpget -v3 -l noAuthNoPriv -u usr-none 127.0.0.1:1162 1.3.6.1.2.1.1.1.0
SNMPv2-MIB::sysDescr.0 = STRING: <value>
net-snmp sends msgAuthenticationParameters as an empty OCTET STRING, so the agent accepts it.
Reproduction
-
Run an RFC-compliant SNMPv3 agent with a noAuthNoPriv user, e.g. snmpsim-lextudio:
snmpsim-command-responder \
--process-user nobody --process-group nogroup \
--v3-engine-id auto \
--v3-user usr-none \
--data-dir ./recordings \
--agent-udpv4-endpoint 0.0.0.0:1161
(./recordings/.snmprec containing e.g. 1.3.6.1.2.1.1.1.0|4|test)
-
Client:
use snmp2::{AsyncSession, Oid};
use snmp2::v3::{Auth, Security};
let oid = Oid::from(&[1,3,6,1,2,1,1,1,0]).unwrap();
let security = Security::new(b"usr-none".as_slice(), &[]).with_auth(Auth::NoAuthNoPriv);
let mut session = AsyncSession::new_v3(addr, 0, security).await?;
session.init().await?; // succeeds (discovery)
let pdu = session.get(&oid).await?; // Err(AsnWrongType)
init() succeeds; get() returns Err(Error::AsnWrongType).
Proposed fix
msgAuthenticationParameters must be empty when authentication is not in use, and the HMAC
back-fill (and its bounds check) must be skipped accordingly. In src/v3.rs::build():
let truncation_len = security.auth_protocol.truncation_length();
+ let auth_len = if security.need_auth() { truncation_len } else { 0 };
...
sec_buf_seq.push_sequence(|buf| {
buf.push_octet_string(&priv_params); // priv params
let l0 = buf.len() - priv_params.len();
- buf.push_octet_string(&vec![0u8; truncation_len]); // auth params
+ buf.push_octet_string(&vec![0u8; auth_len]); // auth params (empty unless authenticating)
let l1 = buf.len() - l0;
...
});
...
auth_pos += buf.len() - inner_len;
- if (auth_pos + truncation_len) > buf.len() {
- return Err(Error::ValueOutOfRange);
- }
-
if security.need_auth() {
+ if (auth_pos + truncation_len) > buf.len() {
+ return Err(Error::ValueOutOfRange);
+ }
let hmac = security.calculate_hmac(buf)?;
buf[auth_pos..auth_pos + truncation_len].copy_from_slice(&hmac[..truncation_len]);
}
Rationale: with auth_len == 0 the field is encoded as an empty OCTET STRING (04 00), matching
build_init() and net-snmp. auth_pos is only used to back-fill the HMAC, which happens only
when need_auth() is true, so moving the bounds check inside that branch keeps the authenticated
path identical while making the noAuth path well-formed. (Proposed; not yet validated end-to-end.)
Notes
- This affects every request built via
build() (GET / GETNEXT / GETBULK / SET / INFORM) at the
noAuthNoPriv security level — not just GET.
authNoPriv / authPriv are likely unaffected (the auth bit is set and the field length is
expected), but were not separately reproduced here.
- Engine discovery is unaffected because it uses
build_init(), which already emits an empty
auth-params field.
Hi,
in my software that uses this crate i build a series of e2e tests. I think I found (with Claude) a small bug. Below is the report Claude generated for this.
SNMPv3 GET fails against RFC 3414-compliant agents:
build()emits non-emptymsgAuthenticationParametersfor noAuthNoPrivTL;DR
AsyncSession/SyncSessionSNMPv3 requests built byv3::build()always reserve atruncation_len-byte (zero-filled)msgAuthenticationParametersfield, even when thesecurity level is noAuthNoPriv (no authentication). Per RFC 3414 §2.4 / §7.1, this field
must be empty (zero length) when the
Authflag is not set. Standards-compliant agentsreject the malformed message and reply with a
usmStatsWrongDigestsReport PDU, which theclient then surfaces as
Error::AsnWrongType("ASN.1 wrong type").The discovery probe (
build_init()) builds the field correctly (empty), so engine discoverysucceeds but the subsequent GET fails.
net-snmpperforming the same noAuthNoPriv GET againstthe same agent succeeds, confirming the agent is compliant and the defect is client-side.
Affected versions
dobermai/snmp2fork rev7b339e8(reported as version0.5.1), built withdefault-features = false, features = ["tokio", "v3", "crypto-rust"].v3::build()) is unchanged from upstreamroboplc/snmp2, so released0.5.0is expected to be affected as well.0.4.x(observed as a hang/timeout rather thanAsnWrongType), i.e.the v3 GET path has not been working for a while; this report pins down the
0.5.xmechanism.Symptom
A noAuthNoPriv GET returns:
raised from
AsyncSession::get()→Pdu::validate(MessageType::Response, ...)(the parsedresponse is a
Report, not aResponse). Engine discovery (init()) completes normally justbefore this.
Root cause
src/v3.rs, inbuild():msgAuthenticationParametersis written asvec![0u8; truncation_len]unconditionally.AuthProtocolhas no "none" variant, so for a noAuthNoPriv session it takes its default(
Md5), whosetruncation_length()is12. The result is a 12-byte all-zero auth-params fieldin a message whose
msgFlagsauth bit is0— which is malformed.For contrast,
build_init()builds the same field correctly:Evidence (captured wire bytes)
Captured by instrumenting
build()/send_and_recv()andparse_v3()to dump hex.Outgoing GET request (noAuthNoPriv), 136 bytes
Decoded (relevant parts):
The
msgFlagsauth bit is0, yetmsgAuthenticationParameterscarries 12 bytes.Agent response: a Report PDU, 126 bytes
Decoded scopedPDU:
get()validatesMessageType::Response, getsReport→Error::AsnWrongType.Cross-check with net-snmp
The same agent (snmpsim) answers a
net-snmpnoAuthNoPriv GET correctly:net-snmp sends
msgAuthenticationParametersas an empty OCTET STRING, so the agent accepts it.Reproduction
Run an RFC-compliant SNMPv3 agent with a noAuthNoPriv user, e.g.
snmpsim-lextudio:(
./recordings/.snmpreccontaining e.g.1.3.6.1.2.1.1.1.0|4|test)Client:
init()succeeds;get()returnsErr(Error::AsnWrongType).Proposed fix
msgAuthenticationParametersmust be empty when authentication is not in use, and the HMACback-fill (and its bounds check) must be skipped accordingly. In
src/v3.rs::build():Rationale: with
auth_len == 0the field is encoded as an empty OCTET STRING (04 00), matchingbuild_init()and net-snmp.auth_posis only used to back-fill the HMAC, which happens onlywhen
need_auth()is true, so moving the bounds check inside that branch keeps the authenticatedpath identical while making the noAuth path well-formed. (Proposed; not yet validated end-to-end.)
Notes
build()(GET / GETNEXT / GETBULK / SET / INFORM) at thenoAuthNoPriv security level — not just GET.
authNoPriv/authPrivare likely unaffected (the auth bit is set and the field length isexpected), but were not separately reproduced here.
build_init(), which already emits an emptyauth-params field.