Skip to content

Add shared Kerberos GSS and SPNEGO token handling - #21717

Open
eve0805 wants to merge 1 commit into
rapid7:masterfrom
eve0805:feature/kerberos-gss-token
Open

Add shared Kerberos GSS and SPNEGO token handling#21717
eve0805 wants to merge 1 commit into
rapid7:masterfrom
eve0805:feature/kerberos-gss-token

Conversation

@eve0805

@eve0805 eve0805 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

This introduces Rex::Proto::Gss::KerberosToken as a shared low-level implementation for handling Kerberos tokens carried by GSS-API and SPNEGO.

The new component supports:

  • parsing bare GSS-Kerberos tokens;
  • the standard Kerberos V5 and Microsoft Kerberos mechanism OIDs;
  • parsing SPNEGO NegTokenInit and NegTokenResp metadata;
  • extracting AP-REQ messages as byte-identical opaque DER;
  • strict extract_ap_req, non-raising try_extract_ap_req, and kerberos_ap_req? APIs;
  • building bare GSS-Kerberos AP-REQ tokens;
  • building SPNEGO NegTokenInit tokens containing a Kerberos AP-REQ;
  • identifying AP-REQ, AP-REP, KRB-ERROR, and unknown GSS token IDs without decoding the Kerberos payload.

The existing Kerberos client AP-REQ encoding helpers now delegate to this shared implementation.

Error handling

Malformed or unsupported tokens are normalized into Rex::Proto::Gss::KerberosToken::ParseError.

The parser explicitly rejects:

  • unsupported mechanism OIDs;
  • incomplete GSS token identifiers;
  • empty AP-REQ payloads;
  • empty SPNEGO mechanism tokens;
  • empty SPNEGO mechanism lists.

This also fixes unwrap_pseudo_asn1 leaking a TypeError when a token does not contain a top-level mechanism OID. It now raises an ASN.1 parsing error that callers can handle consistently.

Motivation

Kerberos tracing and the Kerberos relay implementation in #21709 both need the same low-level GSS/SPNEGO operations:

  • identifying Kerberos mechanism tokens;
  • extracting an opaque AP-REQ;
  • rebuilding the AP-REQ into a GSS/SPNEGO token;
  • handling malformed or non-Kerberos input safely.

Placing these operations under Rex::Proto::Gss avoids coupling protocol parsing to either tracing or relay-specific behavior.

The relay-specific GssApReq mixin from #21709 can delegate to this component in a follow-up change. This PR intentionally does not modify the relay implementation itself.

Implementation notes

The AP-REQ payload is deliberately treated as opaque DER. It is never decoded or modified, which allows a captured AP-REQ to be extracted and rebuilt without changing the encrypted ticket or authenticator.

For NegTokenInit, the first offered mechanism is exposed as preferred_mech rather than selected_mech, because the acceptor has not selected a mechanism at that stage.

Testing

The test coverage includes:

  • bare GSS-Kerberos parsing;
  • standard and Microsoft Kerberos mechanism OIDs;
  • SPNEGO NegTokenInit and NegTokenResp parsing;
  • AP-REQ extraction from bare GSS and SPNEGO tokens;
  • byte-identical AP-REQ round trips;
  • AP-REP and unknown token identification;
  • malformed ASN.1 and unsupported mechanism handling;
  • empty payload and mechanism-list validation;
  • non-raising extraction and Kerberos AP-REQ predicates;
  • existing Kerberos client and service authenticator regression tests;
  • Zeitwerk compliance.

Validation results:

  • 61 examples, 0 failures
  • RuboCop: 7 files inspected, no offenses

@github-actions

Copy link
Copy Markdown

Thanks for your pull request! As part of our landing process, we manually verify that all modules work as expected.

We've added the additional-testing-required label to indicate that additional testing is required before this pull request can be merged.
For maintainers, this means visiting here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a shared low-level Rex::Proto::Gss::KerberosToken implementation for parsing/building Kerberos mechanism tokens carried by GSS-API and SPNEGO, and updates existing Kerberos client helpers to delegate to it while normalizing ASN.1 parsing failures.

Changes:

  • Introduces Rex::Proto::Gss::KerberosToken with APIs for parsing GSS-Kerberos / SPNEGO NegTokenInit/Resp metadata and extracting/rebuilding opaque AP-REQ payloads.
  • Normalizes unwrap_pseudo_asn1 failures into an OpenSSL::ASN1::ASN1Error when no top-level mechanism OID exists (avoiding leaking TypeError).
  • Refactors Kerberos client AP-REQ GSS/SPNEGO encoding helpers and token ID constants to use the shared token implementation.

Impact Analysis:

  • Blast radius: medium — impacts callers of Rex::Proto::Gss::Asn1#unwrap_pseudo_asn1 and all consumers of Kerberos client GSS/SPNEGO AP-REQ encoding; downstream relay/tracing consumers are Unknown from diff alone.
  • Data and contract effects: error behavior changes from potential TypeError leakage to consistent ASN.1/ParseError failure modes; new parsing/building APIs are introduced but existing public entrypoints are retained via delegation.
  • Rollback and test focus: rollback is straightforward (new class + delegation), but validate SPNEGO parsing edge cases (empty mech list / empty mech token), and confirm Kerberos client authentication flows still produce byte-identical AP-REQ wrappers.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
spec/lib/rex/proto/gss/kerberos_token_spec.rb Adds RSpec coverage for GSS/SPNEGO parsing, AP-REQ extraction, and builders.
spec/lib/rex/proto/gss/asn1_spec.rb Adds regression coverage for unwrap_pseudo_asn1 raising ASN.1 errors instead of leaking TypeError.
lib/rex/proto/gss/kerberos_token.rb New shared parser/builder for Kerberos tokens inside GSS-API and SPNEGO.
lib/rex/proto/gss/asn1.rb Updates pseudo-ASN.1 unwrap to raise a consistent ASN.1 error when no mechanism OID exists.
lib/rex/proto/gss.rb Ensures required dependencies are loaded for OID constants.
lib/msf/core/exploit/remote/kerberos/client/ap_request.rb Delegates Kerberos AP-REQ GSS/SPNEGO wrapping to KerberosToken.
lib/msf/core/exploit/remote/kerberos/client.rb Reuses shared Kerberos token ID constants from KerberosToken.
Comments suppressed due to low confidence (1)

lib/rex/proto/gss/kerberos_token.rb:137

  • Important: Problem: extract_ap_req only checks mech_token for nil, so a present-but-empty SPNEGO mechanism token falls through into parse and yields a generic parse failure rather than the explicit "empty SPNEGO mechanism token" rejection described in the PR. Impact: callers lose a clear, normalized error cause for a common malformed-input case. Fix: add an explicit empty-string check and raise ParseError with a specific message.
                             mech_token = spnego[:mech_token]
                             raise ParseError, 'SPNEGO NegTokenInit does not contain a mechanism token' if mech_token.nil?

Comment thread lib/rex/proto/gss/kerberos_token.rb
@jheysel-r7 jheysel-r7 added the rn-enhancement release notes enhancement label Jul 29, 2026
@jheysel-r7 jheysel-r7 moved this from Todo to In Progress in Metasploit Kanban Jul 30, 2026
@jenkins-eks-metasploit

Copy link
Copy Markdown

Additional test pipeline started ⌛
Note: build results only accessible to maintainers.

@jenkins-eks-metasploit

Copy link
Copy Markdown

Pipeline results available

Slice summary:

No test slices found.

Note: build results only accessible to maintainers.

@eve0805
eve0805 force-pushed the feature/kerberos-gss-token branch from d2adc3b to 1f2d8c6 Compare August 11, 2026 02:18
@Pushpenderrathore

Pushpenderrathore commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Hi @eve0805, this is a really nice consolidation. Since #21709 is meant to delegate its AP-REQ handling to this, I took Rex::Proto::Gss::KerberosToken to the lab and ran it against a real AP-REQ, side by side with the relay's current GssApReq, to confirm the two are interchangeable before I wire the delegation.

I captured a genuine service ticket the way the relay would: a Server 2022 DC in kerberos.issue, a decoy CIFS/smbrelay.kerberos.issue SPN with DNS pointing the name at an SMB server that offers Kerberos, and a SYSTEM scheduled task on the DC to trigger the connection as the machine account. That yields a 3231 byte AP-REQ inside a 3248 byte bare GSS-Kerberos token (mech 1.2.840.113554.1.2.2, starts 6e820c9b, APPLICATION 14).

Feeding that real token through both implementations, everything lines up byte for byte, and each one reads what the other builds:

Cross-check results (12/12)
REAL captured data: bare token 3248B, AP-REQ 3231B, starts 6e820c9b

1) extract the AP-REQ from the real token
  KerberosToken.extract_ap_req  == captured AP-REQ           PASS
  GssApReq.extract_ap_req       == captured AP-REQ           PASS
  the two implementations agree byte-for-byte                PASS
  KerberosToken.kerberos_ap_req? true                        PASS
  GssApReq.kerberos_ap_req?      true                        PASS

2) rebuild the AP-REQ into a SPNEGO token, cross-read it with the other impl
  GssApReq reads what KerberosToken built                    PASS
  KerberosToken reads what GssApReq built                    PASS

3) SPNEGO NegTokenInit path on the real token
  KerberosToken.extract_ap_req from NegTokenInit             PASS
  GssApReq.extract_ap_req from NegTokenInit                  PASS

4) the unwrap_pseudo_asn1 TypeError case: a NegTokenResp (2nd SessionSetup leg)
  KerberosToken.try_extract_ap_req -> nil, no TypeError      PASS
  GssApReq.try_extract_ap_req -> nil, no TypeError           PASS

Your spec/lib/rex/proto/gss/kerberos_token_spec.rb also came back green here: 34 examples, 0 failures.

Two things worth calling out from this:

The unwrap_pseudo_asn1 fix is the right call. The relay hit that exact TypeError on the second SessionSetup leg during live testing and I had patched it at the caller in 2446062cca. Fixing it at the source where it belongs is better, and once this lands I can drop that workaround.

One heads-up for the delegation, on my side rather than yours: the relay dispatch (kerberos_ap_req?, server_client.rb, relay_handler.rb) rescues only ArgumentError and relies on a non-Kerberos token returning false so it falls through to NTLM. Your strict extract_ap_req raises ParseError < StandardError, so the adapter in #21709 will use try_extract_ap_req / kerberos_ap_req? (which return nil/false, verified above) rather than the raising path, or map ParseError to ArgumentError. Nothing to change here, just noting how the relay will consume it.

Cross-check harness

Run from the framework root with bundle exec ruby, after dropping this branch's kerberos_token.rb and asn1.rb into place. raw_mech_token.bin is the bare GSS-Kerberos token captured from the client and captured_ap_req.bin is the AP-REQ inside it.

$LOAD_PATH.unshift(File.expand_path('lib', Dir.pwd))
require 'openssl'
require 'rasn1'
module Rex; module Proto; module Gss; end; end; end
require 'rex/proto/gss'

# my #21709 relay mixin
load File.expand_path('lib/msf/core/exploit/remote/relay/kerberos/gss_ap_req.rb', Dir.pwd)
klass = Class.new { include Msf::Exploit::Remote::Relay::Kerberos::GssApReq }
g = klass.new
KT = Rex::Proto::Gss::KerberosToken

bare  = File.binread('raw_mech_token.bin')    # real bare GSS-Kerberos InitialContextToken
apreq = File.binread('captured_ap_req.bin')   # the AP-REQ inside it

def show(label, ok); puts format('  %-58s %s', label, ok ? 'PASS' : '*** FAIL ***'); end

puts "REAL captured data: bare token #{bare.bytesize}B, AP-REQ #{apreq.bytesize}B, starts #{apreq[0, 4].unpack1('H*')}"
puts
puts '1) extract the AP-REQ from the real token'
kt_x = KT.extract_ap_req(bare)
g_x  = g.extract_ap_req(bare)
show('KerberosToken.extract_ap_req  == captured AP-REQ', kt_x == apreq)
show('GssApReq.extract_ap_req       == captured AP-REQ', g_x == apreq)
show('the two implementations agree byte-for-byte',      kt_x == g_x)
show('KerberosToken.kerberos_ap_req? true',              KT.kerberos_ap_req?(bare) == true)
show('GssApReq.kerberos_ap_req?      true',              g.kerberos_ap_req?(bare) == true)

puts
puts '2) rebuild the AP-REQ into a SPNEGO token, cross-read it with the other impl'
kt_blob = KT.build_spnego_ap_req(apreq)
g_blob  = g.build_spnego_ap_req(apreq)
show('GssApReq reads what KerberosToken built', g.extract_ap_req(kt_blob) == apreq)
show('KerberosToken reads what GssApReq built', KT.extract_ap_req(g_blob) == apreq)

puts
puts '3) SPNEGO NegTokenInit path on the real token'
init_blob = KT.build_spnego_init(bare)
show('KerberosToken.extract_ap_req from NegTokenInit', KT.extract_ap_req(init_blob) == apreq)
show('GssApReq.extract_ap_req from NegTokenInit',      g.extract_ap_req(init_blob) == apreq)

puts
puts '4) the unwrap_pseudo_asn1 TypeError case: a NegTokenResp (2nd SessionSetup leg)'
negresp = OpenSSL::ASN1::ASN1Data.new([
  OpenSSL::ASN1::Sequence.new([
    OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::OctetString.new('continuation')], 2, :CONTEXT_SPECIFIC)
  ])
], 1, :CONTEXT_SPECIFIC).to_der
show('KerberosToken.try_extract_ap_req -> nil, no TypeError', KT.try_extract_ap_req(negresp).nil?)
show('GssApReq.try_extract_ap_req -> nil, no TypeError',      g.try_extract_ap_req(negresp).nil?)

One note on the two red checks, so they are not mistaken for this change. They are two separate jobs, and each one is red because of a single flaky meterpreter acceptance example with the session dropping mid-run, not the GSS parsing:

mettle  macos-15-intel:  46 examples, 1 failure
  rspec ./spec/acceptance/meterpreter_spec.rb[1:4:2:1:3:1]  # osx/x64/meterpreter_reverse_tcp, post/test/cmd_exec
php 7.4 macos-15-intel:  91 examples, 1 failure
  rspec ./spec/acceptance/meterpreter_spec.rb[1:2:1:1:5:1]  # php/meterpreter_reverse_tcp (osx), post/test/file

[*] 127.0.0.1 - Meterpreter session closed.  Reason: Died

Both suites pass on their non-macOS runners (mettle on ubuntu-latest; php 7.4 on ubuntu-latest and windows-2022), and every Verify rspec leg (the ones that exercise this code) is green, so it is the runner rather than the change. A maintainer re-run should clear it.

Happy to fold the delegation into #21709 once this lands. Thanks for pulling this out into a shared spot.

@jenkins-eks-metasploit

Copy link
Copy Markdown

Additional test pipeline started ⌛
Note: build results only accessible to maintainers.

@jenkins-eks-metasploit

Copy link
Copy Markdown

Pipeline results available

Slice summary:

  • Test slice 1 - 🟢
  • Test slice 2 - 🟢
  • Test slice 3 - 🟢

Note: build results only accessible to maintainers.

@jheysel-r7 jheysel-r7 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @eve0805, this looks great, thanks for the PR. I've stepped through smb authentication using kerberos and ensured this is working as expected and maintains backwards compatibility.

msf auxiliary(scanner/smb/smb_login) > run 
[*] 172.16.199.200:445    - Starting SMB login bruteforce
[*] 172.16.199.200:445    - Loaded a credential from ticket file: /Users/jheysel/.msf4/loot/20260811151219_default_172.16.199.200_mit.kerberos.cca_943325.bin
[+] 172.16.199.200:445    - Success: 'kerberos.issue\Administrator:' Administrator
[*] 172.16.199.200:445    - Scanned 1 of 1 hosts (100% complete)
[*] 172.16.199.200:445    - Bruteforce completed, 1 credential was successful.
[*] 172.16.199.200:445    - You can open an SMB session with these credentials and CreateSession set to true
[*] Auxiliary module execution completed
msf auxiliary(scanner/smb/smb_login) > 

Rspecs are also all passing on my end as well as the jenkins pro tests

➜  metasploit-framework git:(1f2d8c6e4f6) ✗ bundle exec rspec spec/lib/rex/proto/gss/
Overriding user environment variable 'OPENSSL_CONF' to enable legacy functions.
Run options:
  include {:focus=>true}
  exclude {:acceptance=>true}

All examples were filtered out; ignoring {:focus=>true}

Randomized with seed 3527
Rex::Proto::Gss::KerberosToken ...........................
Rex::Proto::Gss::ChannelBinding ............
Rex::Proto::Gss::Asn1 ...
Rex::Proto::Gss::SpnegoNegTokenInit .
Rex::Proto::Gss::SpnegoNegTokenTarg .
Acceptance::DatastoreFormatting .......
Rex::Proto::Gss::Kerberos::MessageEncryptor .......

Top 10 slowest examples (0.28817 seconds, 17.8% of total time):
  Rex::Proto::Gss::Kerberos::MessageEncryptor When we are the initiator Is reversible
    0.05943 seconds ./spec/lib/rex/proto/gss/kerberos/message_encryptor_spec.rb:52
  Rex::Proto::Gss::Kerberos::MessageEncryptor When we are the acceptor Decrypts a real value
    0.04074 seconds ./spec/lib/rex/proto/gss/kerberos/message_encryptor_spec.rb:72
  Rex::Proto::Gss::Kerberos::MessageEncryptor When we are the acceptor without using subkey The header is correct
    0.0383 seconds ./spec/lib/rex/proto/gss/kerberos/message_encryptor_spec.rb:89
  Rex::Proto::Gss::KerberosToken.parse rejects a non-Kerberos mechanism OID
    0.03293 seconds ./spec/lib/rex/proto/gss/kerberos_token_spec.rb:62
  Rex::Proto::Gss::KerberosToken.parse_spnego_init preserves the initiator mechanism preference order
    0.02884 seconds ./spec/lib/rex/proto/gss/kerberos_token_spec.rb:105
  Rex::Proto::Gss::ChannelBinding.create #digest_algorithm should be SHA256
    0.01884 seconds ./spec/lib/rex/proto/gss/channel_binding_spec.rb:79
  Rex::Proto::Gss::KerberosToken.parse_spnego_init rejects an empty mechanism list
    0.01745 seconds ./spec/lib/rex/proto/gss/kerberos_token_spec.rb:118
  Rex::Proto::Gss::KerberosToken.parse_spnego_response normalizes malformed input into ParseError
    0.01731 seconds ./spec/lib/rex/proto/gss/kerberos_token_spec.rb:143
  Rex::Proto::Gss::KerberosToken.extract_ap_req rejects an empty AP-REQ payload
    0.01721 seconds ./spec/lib/rex/proto/gss/kerberos_token_spec.rb:194
  Rex::Proto::Gss::KerberosToken.kerberos_ap_req? distinguishes Kerberos AP-REQ tokens from other input
    0.0171 seconds ./spec/lib/rex/proto/gss/kerberos_token_spec.rb:219

Top 7 slowest example groups:
  Rex::Proto::Gss::SpnegoNegTokenInit
    0.0539 seconds average (0.0539 seconds / 1 example) ./spec/lib/rex/proto/gss/spnego_neg_token_init_spec.rb:4
  Rex::Proto::Gss::SpnegoNegTokenTarg
    0.04359 seconds average (0.04359 seconds / 1 example) ./spec/lib/rex/proto/gss/spnego_neg_token_targ_spec.rb:4
  Rex::Proto::Gss::KerberosToken
    0.03412 seconds average (0.92118 seconds / 27 examples) ./spec/lib/rex/proto/gss/kerberos_token_spec.rb:6
  Rex::Proto::Gss::Kerberos::MessageEncryptor
    0.03243 seconds average (0.227 seconds / 7 examples) ./spec/lib/rex/proto/gss/kerberos/message_encryptor_spec.rb:5
  Rex::Proto::Gss::Asn1
    0.02711 seconds average (0.08134 seconds / 3 examples) ./spec/lib/rex/proto/gss/asn1_spec.rb:6
  Rex::Proto::Gss::ChannelBinding
    0.02062 seconds average (0.24739 seconds / 12 examples) ./spec/lib/rex/proto/gss/channel_binding_spec.rb:5
  Acceptance::DatastoreFormatting
    0.00497 seconds average (0.03482 seconds / 7 examples) ./spec/support/acceptance/datastore_formatting_spec.rb:6

Finished in 1.62 seconds (files took 16.45 seconds to load)
58 examples, 0 failures

Randomized with seed 3527
Coverage report generated for RSpec to /Users/jheysel/rapid7/metasploit-framework/coverage.
Line Coverage: 21.42% (3003 / 14019)

@jheysel-r7 jheysel-r7 moved this from In Progress to What about Second Review? in Metasploit Kanban Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: What about Second Review?

Development

Successfully merging this pull request may close these issues.

4 participants