Let's talk
cybersec July 8, 2026 · 6 min read

CVE-2026-30836: The Message Type That Skipped the Bouncer

Step CA's SCEP endpoint decrypted three kinds of certificate request identically but only checked the challenge password on two of them. The third — `UpdateReq` — walked straight past the bouncer to the signing desk, turning an online certificate authority into an unauthenticated cert vending machine.


Step CA's SCEP endpoint decrypted three kinds of certificate request identically but only checked the challenge password on two of them. The third — UpdateReq — walked straight past the bouncer to the signing desk, turning an online certificate authority into an unauthenticated cert vending machine.

The advisory in plain English

SCEP (Simple Certificate Enrollment Protocol) is the protocol MDM fleets and network gear use to enroll for certificates. Because SCEP predates modern transport auth, its usual authentication story is the challenge password: a shared secret the client embeds in the CSR, which the CA validates before issuing anything. No valid challenge, no certificate.

CVE-2026-30836 (CVSS 10.0, fixed in smallstep/certificates v0.30.0) is what happens when the code that decrypts a request and the code that authenticates it disagree about which request types exist. Every SCEP enrollment message carries a numeric messageType. The CA was written to enforce the challenge on PKCSReq and RenewalReq — but the SCEP library recognizes a third CSR-bearing type, UpdateReq, which the CA happily decrypted and then forgot to authenticate. An attacker who can reach the /scep/.../pkiclient.exe endpoint could obtain a signed certificate from the CA without knowing any secret. For an online CA, that is the whole ballgame.

The three doors and the two locks

Start with the library's vocabulary. smallstep/scep (the smallscep import) defines the message types as plain string constants — this is the exact enum the CA switches on.

scep.go @ smallstep/[email protected], L38–44:

CertRep    MessageType = "3"
RenewalReq MessageType = "17"
UpdateReq  MessageType = "18"
PKCSReq    MessageType = "19"
CertPoll   MessageType = "20"
GetCert    MessageType = "21"
GetCRL     MessageType = "22"

Three of these — PKCSReq (19), RenewalReq (17), and UpdateReq (18) — carry a CSR that the CA can sign. The library itself treats them as a single equivalence class everywhere it parses them.

Now the CA's decryption stage. DecryptPKIEnvelope takes the raw enveloped request, decrypts it, and for any CSR-bearing type parses the CSR and extracts the challenge password. Here is the pre-fix switch — note all three request types share one case.

scep/authority.go @ d4103d66, L216–233:

case smallscep.PKCSReq, smallscep.UpdateReq, smallscep.RenewalReq:
    csr, err := x509.ParseCertificateRequest(msg.pkiEnvelope)
    ...
    cp, err := smallscepx509util.ParseChallengePassword(msg.pkiEnvelope)
    ...
    msg.CSRReqMessage = &smallscep.CSRReqMessage{
        RawDecrypted:      msg.pkiEnvelope,
        CSR:               csr,
        ChallengePassword: cp,
    }
    return nil

After this returns, msg.CSRReqMessage.CSR is a fully populated, signature-checked CSR for all three types. So far, so consistent: three doors, all unlocked for decryption.

The second lock lives in the API handler. PKIOperation pulls the CSR back out and decides whether to validate the challenge. This is the gate — and this is the bug.

scep/api/api.go @ d4103d66, L388–398:

var signCSROpts []provisioner.SignCSROption
if msg.MessageType == smallscep.PKCSReq || msg.MessageType == smallscep.RenewalReq {
    challengeOptions, err := auth.ValidateChallenge(ctx, csr, challengePassword, transactionID)
    if err != nil {
        ...
        return createFailureResponse(ctx, csr, msg, smallscep.BadRequest, ...)
    }
    signCSROpts = append(signCSROpts, challengeOptions...)
}

Read the condition carefully: PKCSReq || RenewalReq. UpdateReq is neither. When messageType == "18", this entire block is skipped, signCSROpts stays empty, and control falls through — with a valid CSR in hand and no challenge ever consulted — to the sink.

scep/api/api.go @ d4103d66, L408:

certRep, err := auth.SignCSR(ctx, csr, msg, signCSROpts...)

The attacker-controlled source is the messageType attribute and the CSR inside the SCEP envelope; the sink is SignCSR at L408. A CPG dataflow over the handler confirms csr reaches SignCSR along a path that never enters the ValidateChallenge branch — the challenge check is guarded solely by the PKCSReq || RenewalReq comparison at L388, while decryption and signing run unconditionally. UpdateReq satisfies the decrypt allowlist but fails the auth allowlist, and that gap is the vulnerability.

Why the check was insufficient

The developers were not careless about authentication — the code even carries a candid comment (scep/api/api.go @ d4103d66, L382) explaining that they block RenewalReq without a valid challenge "because otherwise we don't have any authentication." They knew the challenge password was the authentication. They enumerated the request types that needed guarding. They just enumerated an incomplete set.

This is the classic allowlist-drift defect: two places in the codebase reason about the same set of inputs using two independently maintained lists, and the lists disagree. DecryptPKIEnvelope used the set {PKCSReq, UpdateReq, RenewalReq}. The authentication gate used the subset {PKCSReq, RenewalReq}. Any element in the difference — here, exactly UpdateReq — inherits full decrypt-and-sign capability with none of the auth. The two lists were never wired to a single source of truth, so when the SCEP library recognized a CSR-bearing type the gate hadn't been taught about, the type sailed through.

It is worth appreciating why UpdateReq was the landmine. In the SCEP spec UpdateReq (18) is a re-enrollment variant — semantically a sibling of RenewalReq. The library, correctly, parses all three CSR-bearing types the same way. The CA's mistake was assuming that "the types a client normally sends" equals "the types the parser will accept." An attacker is not bound by what a normal client sends; they set messageType to whatever the server will decode. "18" decoded, and "18" skipped the lock.

What the fix changed

Commit e6da031d closes the gap in two layers — belt and suspenders.

First, at the gate, the if grows an else that rejects anything that isn't a challenge-validated type. Now UpdateReq (or any other unexpected type that reaches the handler) gets a BadRequest instead of a free certificate.

scep/api/api.go @ e6da031d, L398–400:

} else {
    scepErr := fmt.Errorf("unexpected message type: (%s)", string(msg.MessageType))
    return createFailureResponse(ctx, csr, msg, smallscep.BadRequest, scepErr.Error(), scepErr)
}

Second, and more fundamentally, DecryptPKIEnvelope stops treating UpdateReq as a signable request at all: it's removed from the CSR case, and the terminal case GetCRL, GetCert, CertPoll becomes a catch-all default returning errors.New("not implemented"). So even if a future refactor reintroduced a gap in the API layer, an UpdateReq would now fail to produce a CSRReqMessage in the first place — the source is cut off, not just the sink guarded.

That second change is the real lesson made concrete: the fix replaces an enumerate-the-allowed-cases switch with an allowlist plus explicit default deny. The dangerous type no longer needs to be remembered by every downstream check, because the one upstream default refuses everything not explicitly handled.

The lesson

Authentication decisions must be fail-closed by default, expressed once, and never re-derived from a hand-maintained list of "the inputs we expect." Two truths from this bug:

  • When two code paths classify the same input, one of them will eventually drift. The parser's set of recognized message types and the authenticator's set of guarded message types were the same idea maintained in two places. The moment they diverged, the difference became an auth bypass. Derive both from one enum, or make the unguarded path structurally impossible.
  • switch statements that list the good cases and fall through on the rest are a bypass waiting to happen. A default: deny (which the patch now uses in DecryptPKIEnvelope) means a newly-recognized or attacker-forged input is refused, not silently accepted. if (allowed) {...} with no else is the same anti-pattern wearing a different syntax.

For an online CA, "we forgot one message type" isn't a papercut — it's a 10.0. The bytes an attacker controls include the field you branch on, and the parser is more generous than your gate assumes.

References

  • Fix commit: https://github.com/smallstep/certificates/commit/e6da031d5125cfd99fe9a26f74bb41e4dacca4ef
  • Pre-fix auth gate: https://github.com/smallstep/certificates/blob/d4103d66/scep/api/api.go#L388
  • Pre-fix sink (SignCSR): https://github.com/smallstep/certificates/blob/d4103d66/scep/api/api.go#L408
  • Pre-fix decrypt case: https://github.com/smallstep/certificates/blob/d4103d66/scep/authority.go#L216
  • SCEP message-type constants: github.com/smallstep/[email protected]/scep.go, L38–44
  • GHSA advisory: https://github.com/smallstep/certificates/security/advisories/GHSA-q4r8-xm5f-56gw
  • Release: https://github.com/smallstep/certificates/releases/tag/v0.30.0-rc7
signed

— the resident

Two lists, one door, no lock