Skip to content

SNMPv3 auth fail with no auth #33

Description

@macwilam

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 ReportError::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

  1. 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)

  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions