Allow a server to offer Kerberos as well as NTLM - #303
Conversation
The SPNEGO NegTokenInit a server sends to advertise its authentication mechanisms was built inside the NTLM authenticator, with a mechTypes list hardcoded to a single OID_NTLMSSP. A comment there noted the limitation: "this is only NTLMSSP (as opposed to SPNEGO + NTLMSSP)". Because the token was owned by the NTLM provider, no other mechanism had a way to contribute to the advertisement, so a server could never offer a client anything but NTLM. Move the NegTokenInit construction to Gss.gss_neg_token_init, which takes the mechTypes to advertise, and add Provider::Base#mech_types so a provider declares what it handles. NTLM declares OID_NTLMSSP, so the token it emits is byte identical to the one it built before. Also define the Kerberos v5 mechanism OIDs, both the RFC 4121 OID and the legacy Microsoft variant, since clients may offer or select either. No behaviour change: this only moves ownership of the mechanism list from the NTLM provider to the providers themselves.
A server held exactly one GSS provider, so it could only ever offer a client a single authentication mechanism. SPNEGO exists to let the two sides agree on a mechanism, but with one on offer there is nothing to negotiate. Add Provider::Multi, which holds an ordered list of providers, advertises the mechanisms of all of them, and routes each request to whichever one understands the mechanism the client selected. A NegTokenInit names the mechanism, so that is where the routing decision is made; a NegTokenResp carries no mechanism OID and is treated as a continuation of the exchange already under way. Routing happens in the authenticator rather than at the call sites, so it covers SMB1 and SMB2/3 alike: every request already funnels through ServerClient#process_gss. Sub-authenticators are built lazily, so a mechanism that is advertised but never selected is never instantiated, and the session key of whichever mechanism actually authenticated is exposed to the server for signing. Wrapping a single provider produces a byte identical advertisement and an identical authentication result, so existing servers are unaffected.
A Kerberos AP-REQ is encrypted to the service the client believes it is talking to, so a server that does not hold that service's key cannot read it. Provider::Kerberos therefore does not try: it advertises the Kerberos mechanisms, and hands the mechanism token to a handler that decides how to reply. That is enough for a server to observe or forward Kerberos authentication, and it keeps Kerberos message parsing out of this library, so no new dependency is introduced and the token is never altered in transit. A handler receives the bytes exactly as the client sent them, which matters for anything that forwards the ticket elsewhere. Both the RFC 4121 mechanism OID and the legacy Microsoft variant are advertised, since clients may select either, and the RFC 4121 token identifiers are exposed so a handler can tell an AP-REQ from an AP-REP or a KRB-ERROR without decoding the payload. With no handler set the attempt is refused rather than silently accepted, since nothing here can validate a ticket. Accepting Kerberos properly, by decrypting the ticket with a service key and validating the PAC, is a separate concern and is not implemented here.
Lab testing against a Windows domain controller showed the documentation
here was wrong about the shape of the token a client sends.
The mechanism token is a GSS-API InitialContextToken (RFC 2743 section
3.1), which wraps the mechanism OID and the token identifier around the
Kerberos message:
60 82 0c 0e InitialContextToken
06 09 2a 86 48 .. the mechanism OID
01 00 the token id, here KRB_AP_REQ
6e 82 0b fd .. the AP-REQ itself
So the token id follows the OID rather than starting the token, which is
what the previous comment implied, and the framing around it is not valid
ASN.1, so OpenSSL::ASN1.decode cannot read it.
Correct the documentation and add Kerberos.token_id, which locates the
identifier by walking the lengths, so a handler can tell an AP-REQ from an
AP-REP or a KRB-ERROR without decoding the payload or guessing at offsets.
The provider itself was already handing up the token unaltered, which is
what matters for anything forwarding it; only the description of it was
wrong.
|
Took this to a lab rather than trusting the specs, since the whole point is what a real Windows client does when you finally offer it something other than NTLM. Short version: it works, and it turned up one thing my specs had no chance of catching. Setup was a Server 2022 DC in The client negotiated SMB 3.1.1, picked Kerberos, and handed over a ticket: Decoding it confirms it's a genuine service ticket for the decoy, not something incidental that happened to be lying around: Full setup and run, if you want to reproduce itDecoy SPN and a DNS record pointing the name at the attacker host: The server, built straight from this branch: kerberos = RubySMB::Gss::Provider::Kerberos.new
kerberos.on_mech_token do |token, _authenticator|
id = RubySMB::Gss::Provider::Kerberos.token_id(token)
# ... inspect, forward, whatever ...
RubySMB::Gss::Provider::Result.new(nil, WindowsError::NTStatus::STATUS_LOGON_FAILURE)
end
RubySMB::Server.new(
server_sock: TCPServer.new('0.0.0.0', 445),
gss_provider: RubySMB::Gss::Provider::Multi.new([kerberos, RubySMB::Gss::Provider::NTLM.new])
)And from the DC: The handler refuses the logon, so Afterwards the SPN was unregistered and the DNS record removed. Full AP-REQ decodeBoth the ticket and the authenticator are encrypted to keys we don't hold, which is exactly why the provider doesn't try to read them. NTLM is unaffectedSame server, same client, but Reproducible in both directions: with For contrast, a stock NTLM-only server never gets offered Kerberos at all, because the client is never given the option: The thing the lab caughtMy documentation described the token shape wrongly, and I'd never have found it from specs, because the specs only ever fed the parser SPNEGO tokens I'd built myself. Real clients send a GSS-API InitialContextToken (RFC 2743 §3.1), where the token id sits after the mechanism OID rather than at the front, and the framing around it isn't valid ASN.1 at all:
The provider itself was fine, it was already handing the token up untouched, which is the part that matters for forwarding. But the comment above it would have sent the first person to use this straight into a wall. Fixed in f65afd3, plus a That's also where the bare token limitation in the description comes from: same framing, and we reject it rather than mis-parse it. Suite is green at 12376 after the extra coverage. |
Opened as a draft while the scope is confirmed, see rapid7/metasploit-framework#21709.
Description
A server could only ever offer a client one authentication mechanism, NTLM. The SPNEGO NegTokenInit it sends during NEGOTIATE was built inside
Gss::Provider::NTLM::Authenticator#process(nil), with a mechTypes list hardcoded to a singleOID_NTLMSSP. A comment there noted the limitation:# this is only NTLMSSP (as opposed to SPNEGO + NTLMSSP)Since the token was owned by the NTLM provider, no other mechanism had a way to contribute to it, and
Serverholds exactly one provider, so two mechanisms could not coexist. A client is therefore never given anything to negotiate.This adds Kerberos as an offerable mechanism, in three steps:
Provider::Base#mech_typesreplaces the hardcoded list, and the NegTokenInit construction moves toGss.gss_neg_token_init(mech_types)so it is no longer owned by one provider. NTLM declaresOID_NTLMSSPand emits a byte identical token.Provider::Multiholds an ordered list of providers, advertises all of their mechanisms, and routes each request to whichever one understands the mechanism the client selected. A NegTokenInit names the mechanism, so routing is decided there; a NegTokenResp carries no mechanism OID and continues the exchange already under way.Provider::Kerberosadvertises the Kerberos mechanisms and hands the mechanism token to a handler.Why the Kerberos provider does not decode the token
An AP-REQ is encrypted to the service the client believes it is talking to, so a server without that service's key cannot read it.
Provider::Kerberostherefore does not try. It surfaces the token exactly as the client sent it and lets the handler decide how to reply.That keeps Kerberos message parsing out of this library, so no new dependency is introduced, and a caller that forwards the token elsewhere does not alter the ticket it contains.
This is deliberately not full Kerberos acceptance: there is no keytab, no ticket decryption and no PAC validation. The provider refactor would support that later, but it is a separate piece of work.
SMB1, SMB2 and SMB3
Routing happens in the authenticator rather than at the call sites. Every request already funnels through
ServerClient#process_gss, which bothdo_negotiate_smb1anddo_negotiate_smb2and both session setup paths call, so no version specific code needed to change.Backwards compatibility
Existing servers are unaffected, and this is asserted rather than assumed:
Multiproduces a byte identical advertisement and an identical authentication result to using that provider directly.Provider::Kerberosrefuses the attempt rather than silently accepting it, since nothing here can validate a ticket.Testing
12334 before this change, so 42 added and none broken.
New coverage in
spec/lib/ruby_smb/gss/provider/multi_spec.rbandspec/lib/ruby_smb/gss/provider/kerberos_spec.rb, including a complete NTLM exchange throughMultiproducing the same status, identity and session key as the NTLM provider on its own, mechanism routing and refusal of unsupported mechanisms, refusal of a continuation before any mechanism has been selected, and a byte identical round trip of a mechanism token so a forwarded ticket stays valid.Known limitation
A Kerberos token sent bare, rather than wrapped in SPNEGO, is not accepted. The GSS-API framing around such a token is not valid ASN.1, so
OpenSSL::ASN1.decodecannot read it and the request is refused. Windows wraps its mechanism token in SPNEGO for SMB, which is what the lab testing below exercised, but RFC 2743 does permit a bare token and another client could send one. Worth handling if this is wanted.