Skip to content

Add native Kerberos relay stack and ESC8 (AD CS Web Enrollment) target (CVE-2026-20929) - #21709

Draft
Pushpenderrathore wants to merge 13 commits into
rapid7:masterfrom
Pushpenderrathore:feature/kerberos-relay-esc8
Draft

Add native Kerberos relay stack and ESC8 (AD CS Web Enrollment) target (CVE-2026-20929)#21709
Pushpenderrathore wants to merge 13 commits into
rapid7:masterfrom
Pushpenderrathore:feature/kerberos-relay-esc8

Conversation

@Pushpenderrathore

Copy link
Copy Markdown
Contributor

Part of #21693.

First of a stacked series adding native Kerberos authentication relay to Metasploit. This PR contributes the Kerberos relay stack and the AD CS Web Enrollment (ESC8) target; the DHCPv6 and rogue-RA coercion modules follow in separate PRs.

What this adds

Metasploit's relay stack is NTLM-only today. This mirrors the NTLM namespace under relay/kerberos and smb/relay/kerberos:

  • An SMB server captures the AP-REQ from the SMB2 SessionSetup.
  • GSS code extracts it from the SPNEGO blob as opaque DER and rebuilds it into a fresh GSS-SPNEGO token for the target (forward, do not decode; the AP-REQ is encrypted to the target service).
  • A one-shot relay handler falls through to NTLM for non-Kerberos tokens.
  • A create_client target factory feeds an HTTP target client that replays the AP-REQ over Authorization: Negotiate.
  • modules/auxiliary/server/relay/esc8_kerberos.rb drives the existing HTTP::WebEnrollment mixin over the relayed, already-authenticated connection to enroll a certificate as the coerced principal.

Unlike NTLM, an AP-REQ is a single self-contained message with no challenge/response, so the relay is one-shot with no per-identity target selection.

Background

CVE-2026-20929 (GHSA-cjjj-mhw7-f4xr; Cymulate, "Kerberos relay via DNS CNAME abuse") is catalogued as improper access control in Windows HTTP.sys (CWE-284, CVSS 7.5). The January 2026 patch added channel binding to HTTP.sys but does not cover targets that do not enforce it, such as plain-HTTP ESC8 /certsrv, which has no TLS channel to bind and is the primary target here.

Validation

Lab-validated: capturing a real SMB2 AP-REQ, round-tripping it byte-identical through the GSS code, rebuilding it into a Negotiate header, and driving AD CS Web Enrollment to issue a certificate for a coerced machine account with no knowledge of its credentials.

Pending live validation: the exact SMB2 SessionSetup response returned to the coerced client, and the full coerce-to-certificate chain (which additionally needs a two-host CA != KDC lab).

Notes

  • Opened as a draft: first of a stacked series, and pending a rebase onto current master.
  • Specs for the relay stack (relay/kerberos + smb/relay/kerberos) pass; msftidy clean on the module.

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

Foundation for native Kerberos relay support. Introduces the
Msf::Exploit::Remote::Relay::Kerberos namespace mirroring the NTLM relay
stack, with an ApReqExtractor mixin that pulls a captured AP-REQ out of a
client's GSS-API token (SPNEGO NegTokenInit or bare GSS Kerberos) as
opaque DER, ready to forward to a relay target unchanged.

The AP-REQ is carried, never interpreted: the client identity lives in
its encrypted ticket/authenticator which only the real target decrypts,
and ApReq#decode is not implemented. RelayResult struct added for the
target-reply contract. Covered by rspec (round-trip plus NTLM/malformed
rejection).
Rename the AP-REQ extractor mixin to GssApReq to reflect that it now
handles both directions of the relay: extract_ap_req captures a client's
AP-REQ (relay server side), and the new build_spnego_ap_req re-wraps that
captured AP-REQ into a fresh GSS-SPNEGO blob to send to the real service
(relay target side).

build_spnego_ap_req is the inverse of extract_ap_req and takes raw AP-REQ
DER rather than an ApReq model object, since the relay only holds the
captured bytes. Round-trip specs assert extract -> build -> extract yields
the original AP-REQ unchanged.
Adds Relay::Kerberos::Target::HTTP::Client, which replays a captured
AP-REQ to a real HTTP service (e.g. AD CS Web Enrollment for ESC8) over a
SPNEGO Negotiate exchange. Unlike NTLM there is no challenge/response
round-trip: the AP-REQ is a complete credential sent in a single request,
and on success the connection is left open for the calling module to
issue authenticated follow-up requests.

Moves RelayResult under the Target namespace to satisfy Zeitwerk (a
target.rb file must define Target); mirrors the NTLM target layout.
Verified by rspec (network mocked: Negotiate header contents and
success/failure status mapping) and the zeitwerk_compliance spec.
Adds Relay::Kerberos::RelayHandler, the Kerberos counterpart to the NTLM
server client's relay_ntlmssp. Given an incoming client GSS token it
dispatches on mechanism (Kerberos vs NTLM), extracts the AP-REQ, relays
it to the target client, and fires the module's on_relay_success /
on_relay_failure and on_relay_end callbacks.

The flow is one-shot: a captured AP-REQ is a complete credential, so there
is no challenge/response and no per-identity target selection. The AP-REQ
is cryptographically bound to the SPN the attacker coerced, so it can only
be relayed to the matching service. Non-Kerberos tokens return nil so a
shared relay server falls through to its NTLM path.

Protocol-agnostic (the RubySMB/HTTP server plumbing lives in the including
class); verified by rspec with the target and callbacks mocked.
Adds Target.create_client, the single dispatch point mapping a relay
target's protocol to its per-protocol client (HTTP today). Mirrors the
NTLM server client's create_relay_client and gives the relay server one
call to build a target, with a clear extension point for future protocols
(e.g. LDAP). Verified by rspec.
Adds SMB::Relay::Kerberos::ServerClient, the Kerberos counterpart to the
NTLM SMB relay server client. A coerced host authenticates over SMB; its
SMB2 SessionSetup carries a SPNEGO-wrapped Kerberos AP-REQ. The one-shot
flow (no NTLM-style challenge) captures that AP-REQ, dispatches on
kerberos_ap_req?, selects the SPN-matching target, builds its client via
Target.create_client, and relays through relay_kerberos.

The relay decision (target selection + relay) is split into
relay_captured_ap_req and unit-tested; the SMB status mapping is tested
too. The exact SMB2 SessionSetup response shape is noted as pending live
validation against a coerced client. Validated on real lab data: the
capture->extract->rebuild->Negotiate->CA(200) forward path was confirmed
end-to-end against the live AD CS server.
Adds SMB::Relay::Kerberos::Server, the Kerberos counterpart to the NTLM
SMB relay server. Accepts incoming SMB connections from coerced hosts and
services each with a Kerberos ServerClient on its own thread, completing
the capture side of the relay. Mirrors the NTLM server's dialect set,
accept loop, and shutdown; closed?/close behaviour is unit-tested.
Wire the Kerberos relay stack (CVE-2026-20929) through to AD CS ESC8
certificate enrollment:

- Make Relay::Kerberos::Target::HTTP::Client drivable as an HTTP client
  (request_raw/request_cgi/send_recv delegators) so WebEnrollment can
  reuse the Kerberos-authenticated connection after a successful relay.
- Add SMB::Relay::Kerberos::RelayServer, a reusable module-level mixin
  mirroring SMB::RelayServer that runs the Kerberos SMB relay server and
  keeps the relay server decoupled from the target action.
- Add auxiliary/server/relay/esc8_kerberos, which relays a captured
  AP-REQ to AD CS Web Enrollment and requests a certificate. Identity is
  supplied via RELAY_IDENTITY since the AP-REQ carries it encrypted.
The SessionSetup answer skipped three things the NTLM relay server client
does, all of which the coerced client depends on.

Sessions are now registered. A zero session id mints a new id and stores
a RubySMB::Server::Session in the session table, so RubySMB can resolve
the session for any follow-up request; previously the generated id was
returned to the client but recorded nowhere. A non-zero id that this
server never issued is now answered with STATUS_USER_SESSION_DELETED
rather than a normal SessionSetup response.

Credits are now granted. RubySMB does not add them for us, so a response
carrying none leaves the client with no allowance to send anything
further and the exchange stalls. One credit is granted up front and 32 on
success, matching the NTLM path.

On success the session is marked valid. session.key is deliberately left
unset and signing is not requested: the AP-REQ is relayed as opaque DER
and only the real target service can decrypt it, so we never learn the
Kerberos session key and could not sign as the victim. That is harmless
because the relay is one-shot, but it does mean the session must not be
marked as requiring signing.

Adds seven specs covering session registration, id reuse, rejection of an
unknown id, the credit grant, the state transition, and the absence of a
session key.
@Pushpenderrathore
Pushpenderrathore force-pushed the feature/kerberos-relay-esc8 branch from 00e1d07 to c9715ff Compare July 26, 2026 07:25
@Pushpenderrathore

Copy link
Copy Markdown
Contributor Author

@jheysel-r7 hit something in lab testing I could use your steer on.

Set up a decoy SPN and pointed a real DC at the relay over SMB. It never sends an AP-REQ. Every attempt comes in as NTLM:

NTLM authentication request overridden to succeed for \

Took me a while to work out why. RubySMB doesn't have the Kerberos OID at all:

$ grep -rn "113554|48018|OID_KERBEROS" ruby_smb-3.3.21/lib/
(no matches)

gss_type1/gss_type2 only ever offer OID_NTLMSSP, so the client is never given Kerberos as an option and picks NTLM. Which means try_extract_ap_req always returns nil and we fall straight through to the NTLM path. The capture code itself is fine, it just never sees an AP-REQ.

That makes this look like a ruby_smb change rather than something to fix here. Teaching the provider to advertise the Kerberos mech would also let the existing NTLM relay server negotiate it. The alternative is building the negotiate blob myself in the module, but that's duplicating gem internals and I'd rather not go there without asking.

So before I open anything against ruby_smb: was the server side only offering NTLM deliberately, or has nobody needed Kerberos there yet? And if the gem is the right place, do you want that PR and the version bump paired with this one?

On the plus side the live test did confirm the session handling works, and it shook out a crash along the way: a SPNEGO NegTokenResp raises TypeError out of unwrap_pseudo_asn1 and safe_unwrap wasn't catching it, so every connection was dying on the second SessionSetup. Fixed in 69d94e2.

@Pushpenderrathore

Copy link
Copy Markdown
Contributor Author

@jheysel-r7 follow-up after more lab time on this. Two findings, and I still want your steer on the second.

First one, a crash. I set up a decoy SPN, pointed a real DC at the relay over SMB, and every connection died on the second SessionSetup with nil can't be coerced into Integer. A SPNEGO NegTokenResp raises TypeError out of unwrap_pseudo_asn1 (no top-level mech OID, so it never sets the start offset, then it does token.length - nil), and safe_unwrap was only rescuing ASN1Error. Windows sends a NegTokenResp as the second leg of a SessionSetup every time, so the relay never actually got through a full exchange. Fixed in 69d94e2 by rescuing TypeError too, so it falls through to NTLM cleanly instead of dying. Good side effect of the same run: the session bookkeeping holds up, the second dispatch comes in carrying the registered session like it should.

The second one is what I want your read on. Once it stopped crashing, the DC just authenticated with NTLM, never sent an AP-REQ:

NTLM authentication request overridden to succeed for \

Took me a while to work out why. RubySMB doesn't have the Kerberos OID anywhere:

$ grep -rn "113554|48018|OID_KERBEROS" ruby_smb-3.3.21/lib/
(no matches)

gss_type1/gss_type2 only ever offer OID_NTLMSSP, so the client is never given Kerberos as an option and picks NTLM. Which means try_extract_ap_req returns nil and we fall straight through to the NTLM path. The capture code is fine, it just never sees an AP-REQ, so as it stands the module can't do the Kerberos relay against a real client.

That looks like a ruby_smb change to me rather than something to fix in the module: have the GSS provider advertise the Kerberos mech so a client can select it, which would also let the existing NTLM relay server negotiate Kerberos. The alternative is building the negotiate blob myself in the module, but that's duplicating gem internals and I'd rather not without asking.

So two questions before I open anything against ruby_smb: was the server side only offering NTLM on purpose, or has nobody needed Kerberos there yet? And if the gem is the right place, do you want the ruby_smb PR and the version bump handled as a pair with this one?

I also tried to validate the coercion modules (the DHCPv6/RA DNS takeover) against the real victim while I was at it. Couldn't get a clean run, and it's worth being upfront about why. My Mac can't host the coercion since mDNSResponder squats on port 53 and it is SIP-protected, so I vendored the module onto the Kali attacker's stock Metasploit instead. The DNS half came up fine ("DNS server started, poisoning names under kerberos.issue"), but the DHCPv6 half kept throwing uninitialized constant Rex::Proto::DHCPv6. That is a vendoring artifact rather than a module bug: the new rex/proto/dhcpv6 tree and its msf_autoload.rb inflection don't wire up the same way when you graft them onto a stock install. On the branch itself the module loads and its specs pass fine. So a real coercion test needs the full branch checked out on the attacker box, not a partial graft, and I've parked that until the mech question is sorted, since there is no point driving a coerced client at a relay that can only take NTLM.

Full coerce -> relay -> cert is still pending regardless, and to mean much it needs a two-host lab with the CA separate from the KDC (a single host collapses the KDC into the target).

Parse the GSS blob once. do_session_setup_smb2 tested for Kerberos and
then relay_kerberos tested again and extracted, so a single SessionSetup
parsed the same blob up to four times. GssApReq gains try_extract_ap_req,
which yields the AP-REQ or nil in one pass; kerberos_ap_req? is now
defined in terms of it, and relay_kerberos takes the extracted AP-REQ
rather than the raw blob. Deciding whether a blob is Kerberos at all, and
falling through to NTLM when it is not, now belongs unambiguously to the
caller.

Fix the RHOSTS aliases. HttpClient re-registers RHOSTS after the relay
server mixin and drops its aliases, so SMBHOST and RELAY_TARGETS were
accepted at the prompt but never reached RHOSTS: setting either reported
success and left the module with no target. Re-registered at module level,
which is applied last. Verified that both aliases now set RHOSTS, and that
the relay-specific description is the one shown.

Keep the closing log line honest when the peer lookup fails. ip_address
was assigned inside the begin block but read after the rescue, so a
failure in getpeername left it interpolating nil.

Also drop @issued_certs, which was assigned and never read, correct a
doc reference to Kerberos::Target::RelayResult, and note that
relay_identity is always nil on the Kerberos path today and is honoured
only for a future target that can recover an identity.
Found in a live lab test against a Windows DC. The relay never survived a
real SMB2 SessionSetup exchange.

unwrap_pseudo_asn1 only assigns its start offset when it finds a
top-level mechanism OID. For a token that has none it leaves the offset
nil and then evaluates `token.length - nil`, raising TypeError rather
than an ASN1Error. safe_unwrap rescued only ASN1Error, so the TypeError
escaped through extract_ap_req and try_extract_ap_req and killed the
connection thread.

A SPNEGO NegTokenResp is exactly such a token, and a Windows client sends
one as the second leg of a SessionSetup. Every connection therefore died
mid-exchange with "nil can't be coerced into Integer" after the first
message. safe_unwrap now rescues TypeError as well, so a NegTokenResp is
reported as "not a Kerberos mechanism" and the caller falls through to
the NTLM path as intended.

Verified in the lab: the same exchange that previously died now completes,
the session reaches :valid and the following TREE_CONNECT succeeds.

Also pass the exception to elog as error: rather than as the message, so
the backtrace survives. Diagnosing this from the log was impossible
without it, since elog(exception) records only the message.
@jheysel-r7

Copy link
Copy Markdown
Contributor

Hey @Pushpenderrathore, thanks for raising all these concerns.

  1. You are absolutely correct that gss_type1 and related methods were hardcoded to only support NTLM. That's just how they were written, I believe no one has needed ruby_smb / kerberos support just yet. You're also correct that this would be a ruby_smb change. It would be a sizable amount of work to support kerberos auth in ruby_smb (having to account for SMB version 1 2 and 3) if it's doable we would want that work reusable in ruby_smb and not baked into the module.

  2. I would say focus on the ruby_smb changes for now as they're blocking this work/ the rest of the relay attack workflow. Once the ruby_smb PR is complete we can prioritize that, get it landed and then perform the gem version bump.

As for the DHCPv6/RA DNS takeover issue, thanks for being upfront - I think that will be a solvable issue. We should be able to test everything on the kali system eventually.

@Pushpenderrathore

Copy link
Copy Markdown
Contributor Author

Put it up as a draft so you can look at the actual code rather than my description of it: rapid7/ruby_smb#303.

Three commits, 650 lines with specs. Providers declare their own mechanisms, Provider::Multi lets a server hold several and routes on whichever the client picks, and Provider::Kerberos advertises Kerberos and hands the token up without decoding it.

Left as a draft until you've had a chance to weigh in on the scope question above. If capture and forward is the right depth it's ready as it stands, and if you'd rather it went all the way to real acceptance the refactor underneath still holds and I'd build on it rather than start again.

Both were found running the relay against a live domain controller, with
ruby_smb teaching the SMB server to advertise Kerberos so a real client
would actually send an AP-REQ. Neither could be reached before, because
nothing had ever got past the capture stage.

The AP-REQ relayed and AD CS accepted it, then the module raised
"undefined method `[]' for nil". HTTP::WebEnrollment#cert_issued? reads
@issued_certs on the first certificate request, and only the NTLM ESC8
module was initialising it.

With that fixed the enrollment reached the target and raised "undefined
method `conn'". The Kerberos HTTP relay client stands in for a
Rex::Proto::Http::Client when it is handed to #send_request_raw, and that
method reaches for the underlying socket after every response to trace the
peer certificate. Delegate #conn so it can.

With both fixed the chain completes: a coerced client's AP-REQ is relayed
to AD CS Web Enrollment and a client-auth certificate is issued for the
coerced principal, which PKINIT then exchanges for a TGT.
@Pushpenderrathore

Copy link
Copy Markdown
Contributor Author

Ran this against a live DC with rapid7/ruby_smb#303 in place, and the whole chain works: coerced client to relayed AP-REQ to an AD CS certificate to a TGT. It also shook out two bugs in here that nothing could have reached before, since the relay had never actually got past the capture stage.

Lab was Server 2022, kerberos.issue, AD CS Web Enrollment on the DC. Coercion was a decoy SPN plus a DNS record pointing the name at the attacker, then net use from the victim.

The interesting thing about combining the two is how little glue it needs. This PR intercepts the SessionSetup blob directly:

ap_req = try_extract_ap_req(request.buffer.to_binary_s)
return super if ap_req.nil?

so it never asks the GSS provider anything. All #303 has to do is widen the mechanism list in the NEGOTIATE response so the client is allowed to pick Kerberos. Once it can, the existing capture path here just works. The wiring is about ten lines in relay_server.rb, wrapping the existing provider:

gss_provider = ::RubySMB::Gss::Provider::Multi.new(
  [::RubySMB::Gss::Provider::Kerberos.new, gss_provider]
)

I have deliberately not committed that, since it cannot work against the released gem. It is the follow-up once #303 lands and the version is bumped.

The two bugs

Both only appear after a relay succeeds, which is why the specs never saw them.

The AP-REQ relayed, AD CS accepted it, and then undefined method '[]' for nil. HTTP::WebEnrollment#cert_issued? reads @issued_certs on the first certificate request, and only the NTLM esc8.rb was initialising it.

With that fixed it got as far as the enrollment request and raised undefined method 'conn'. This PR's HTTP relay client stands in for a Rex::Proto::Http::Client when it is passed to send_request_raw, and that method reaches for the underlying socket after every response to trace the peer certificate. It needed to delegate conn.

Both fixed in efbdada, specs still 60/60 and msftidy clean.

The full run, end to end
[*] SMB Kerberos relay server is running. Listening on 0.0.0.0:445
[*] Server started.
[*] New request from 192.168.64.3
[*] Negotiated dialect: SMB v2.0.2
[*] Dispatching request to do_session_setup_smb2 (session: nil)
[*] Relaying Kerberos AP-REQ to http://192.168.64.3:80/certsrv/
[+] Successfully relayed Kerberos AP-REQ to http://192.168.64.3:80/certsrv/
[*] Building a certificate signing request for user labuser - RSA key size: 2048 - digest algorithm: SHA256 - template: User
[*] Submitting the certificate signing request to the target...
[+] Certificate generated using template User for KERBEROS\labuser
[*] Attempting to download the certificate from /certsrv/certnew.cer?ReqID=27&
[*] Certificate Policies:
[*]   * msEFS
[*]   * emailProtection
[*]   * clientAuth
[*] Certificate UPN: labuser@kerberos.issue
[*] Certificate stored at: ~/.msf4/loot/20260802190104_default_192.168.64.3_windows.ad.cs_913372.pfx
[*] Relay tasks complete; waiting for next login attempt.
QUERY_ONLY, enumerating templates as the relayed identity

Before issuing anything, MODE QUERY_ONLY confirms the relayed connection is genuinely authenticated at AD CS rather than merely connected:

[+] Successfully relayed Kerberos AP-REQ to http://192.168.64.3:80/certsrv/
[*] Retrieving available template list, this may take a few minutes
[*] ***Templates with CT_FLAG_MACHINE_TYPE set like Machine and DomainController will not display as available, even if they are.***
[+] Available Certificates for KERBEROS\labuser: User, EFS, TraceTest
[*] Relay tasks complete; waiting for next login attempt.
The issued certificate
subject : /DC=issue/DC=kerberos/CN=Users/CN=labuser
issuer  : /DC=issue/DC=kerberos/CN=kerberos-DC1-CA
serial  : 624420865699601690703776914184735291594506267
valid   : 2026-08-02 13:21:00 UTC -> 2027-08-02 13:21:00 UTC
has key : yes (OpenSSL::PKey::RSA, 2048 bit)
EKU     : Microsoft Encrypted File System, E-mail Protection, TLS Web Client Authentication
SAN     : othername: UPN:labuser@kerberos.issue

Signed by the real CA, with a private key, clientAuth, and the coerced principal's UPN. No knowledge of the account's password at any point.

PKINIT with the relayed certificate

The payoff, using the issued certificate to get a TGT:

msf > use auxiliary/admin/kerberos/get_ticket
msf > set CERT_FILE ~/.msf4/loot/20260802190104_default_192.168.64.3_windows.ad.cs_913372.pfx
msf > set action GET_TGT
msf > run

[*] Running module against 192.168.64.3
[*] 192.168.64.3:88 - Getting TGT for labuser@kerberos.issue
[+] 192.168.64.3:88 - Received a valid TGT-Response
[*] 192.168.64.3:88 - TGT MIT Credential Cache ticket saved to ~/.msf4/loot/20260802210522_default_192.168.64.3_mit.kerberos.cca_772474.bin

The DC's clock had drifted about two hours mid-session and this failed with KRB_AP_ERR_SKEW until a w32tm /resync, which is a lab artefact rather than anything to do with the module.

Reproducing it
setspn -S cifs/relaytest.kerberos.issue DC1
Add-DnsServerResourceRecordA -Name relaytest -ZoneName kerberos.issue -IPv4Address <attacker>
use auxiliary/server/relay/esc8_kerberos
set SRVHOST 0.0.0.0
set SRVPORT 445
set RHOSTS <dc>
set RPORT 80
set TARGETURI /certsrv/
set RELAY_IDENTITY KERBEROS\labuser
set MODE AUTO
run

and from the victim:

klist purge
net use \\relaytest.kerberos.issue\ipc$ /user:kerberos.issue\labuser <password>

Kerberos only gets selected when explicit domain credentials are given. Without /user: Windows falls back to NTLM and the relay correctly passes it through to the NTLM path instead. Both directions were reproducible.

Afterwards the SPN was unregistered and the DNS record removed.

One caveat in the description I can now drop

The PR says full validation needs a two-host lab with the CA separate from the KDC. That turns out not to be true for this path. The concern was that poisoning the CA's name would cut the victim off from the KDC, but the coercion here introduces a new name rather than poisoning an existing one, so the victim keeps talking to the KDC normally. CA and KDC on the same host was fine.

Still outstanding is RELAY_IDENTITY having to be supplied by hand. The AP-REQ carries the principal encrypted to the target service, so there is nothing on the wire to read it from, and it is only used for template selection and labelling. Worth knowing that an operator has to know who they are coercing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants