Add native Kerberos relay stack and ESC8 (AD CS Web Enrollment) target (CVE-2026-20929) - #21709
Add native Kerberos relay stack and ESC8 (AD CS Web Enrollment) target (CVE-2026-20929)#21709Pushpenderrathore wants to merge 13 commits into
Conversation
|
Thanks for your pull request! As part of our landing process, we manually verify that all modules work as expected. We've added the |
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.
00e1d07 to
c9715ff
Compare
|
@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: Took me a while to work out why. RubySMB doesn't have the Kerberos OID at all:
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 |
|
@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 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: Took me a while to work out why. RubySMB doesn't have the Kerberos OID anywhere:
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 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.
69d94e2 to
2446062
Compare
|
Hey @Pushpenderrathore, thanks for raising all these concerns.
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. |
|
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, 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.
|
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, 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 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 bugsBoth only appear after a relay succeeds, which is why the specs never saw them. The AP-REQ relayed, AD CS accepted it, and then With that fixed it got as far as the enrollment request and raised Both fixed in efbdada, specs still 60/60 and msftidy clean. The full run, end to endQUERY_ONLY, enumerating templates as the relayed identityBefore issuing anything, The issued certificateSigned by the real CA, with a private key, PKINIT with the relayed certificateThe payoff, using the issued certificate to get a TGT: The DC's clock had drifted about two hours mid-session and this failed with Reproducing itand from the victim: Kerberos only gets selected when explicit domain credentials are given. Without Afterwards the SPN was unregistered and the DNS record removed. One caveat in the description I can now dropThe 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 |
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/kerberosandsmb/relay/kerberos:create_clienttarget factory feeds an HTTP target client that replays the AP-REQ overAuthorization: Negotiate.modules/auxiliary/server/relay/esc8_kerberos.rbdrives the existingHTTP::WebEnrollmentmixin 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
relay/kerberos+smb/relay/kerberos) pass; msftidy clean on the module.