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
@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 - 🔴
  • Test slice 4 - 🟢

Note: build results only accessible to maintainers.

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