fix(credssp): complete the SPNEGO mechListMIC exchange in CredSspServer#688
Conversation
When NTLM runs inside SPNEGO (ClientMode::Negotiate / ServerMode::Negotiate - the pairing RDP requires), the client always includes an NTLM MIC, which makes the SPNEGO mechListMIC exchange mandatory per [MS-SPNG]. The client therefore defers pubKeyAuth until it has received and verified the server's final SPNEGO token (accept-completed + mechListMIC). CredSspServer did not implement that leg: when the acceptor completed on the AUTHENTICATE message it dropped its own final SPNEGO token (ts_request.nego_tokens = None) and demanded pubKeyAuth in that same TSRequest, failing the handshake with "CredSSP server expected an encrypted public key" - so CredSspServer could never authenticate sspi's own CredSspClient in Negotiate-NTLM mode (Devolutions#687). Fix: when the security context completes but the incoming TSRequest carries no pubKeyAuth and the acceptor produced a final token, send that token as ReplyNeeded and accept pubKeyAuth on the following leg - matching the client's behavior and what Windows RDP servers do. Pairs where pubKeyAuth arrives together with the last nego token (bare NTLM, Kerberos) keep the existing single-leg path. The new credssp_negotiate_ntlm test pins the four-leg handshake (NEGOTIATE, AUTHENTICATE+mechListMIC, pubKeyAuth, authInfo); the existing credssp_ntlm and credssp_kerberos tests cover the unchanged paths. Closes Devolutions#687
|
One observation from reading the CI config while preparing this PR: the Happy to add |
The tests/sspi/client_server module is gated on all(network_client, __test-data), but no test-matrix row enabled __test-data - so the client<->server pairing tests (credssp_ntlm, credssp_kerberos, and the credssp_negotiate_ntlm regression test added in this PR) never ran in CI. That gap is how Devolutions#687 shipped unnoticed. The suite is hermetic (KdcMock / NetworkClientMock, no real network), verified locally with the exact win-row feature combo (network_client,dns_resolver,scard,tsssp,__test-data) and the shared minimal combo (network_client,__test-data): all green.
|
Went ahead and added it in f8959ca (kept as a separate commit so it's easy to drop if you'd rather scope this PR to the fix). Verified locally with the win-row combo |
7858cd5 to
f8959ca
Compare
Pavlo Myroniuk (TheBestTvarynka)
left a comment
There was a problem hiding this comment.
Hi,
Thank you for the fix! 💟 And sorry for the late review. I was on a vacation last week 😅
I understand how we allowed this to happen. We have tests for CredSSP + bare NTLM and CredSSP + SPNEGO + Kerberos. But we did not have CredSSP + SPNEGO + NTLM.
since Windows RDP servers reject bare NTLM inside CredSSP
I don't think it's true. As far as I know, the mstsc (default Windows RDP client) always sends bare NTLM over CredSSP, and it has always worked well.
I left a comment about your approach.
| } | ||
|
|
||
| #[test] | ||
| fn credssp_negotiate_ntlm() { |
There was a problem hiding this comment.
praise: good test 💟
There was a problem hiding this comment.
This change should be in a separate PR. We try not to mix CI and library code changes
| /// Set when the security context completed but the final SPNEGO token (accept-completed + | ||
| /// `mechListMIC`) still had to be sent to the client: the client cannot send `pubKeyAuth` | ||
| /// until it has verified that token, so `pubKeyAuth` arrives on the *next* leg. | ||
| awaiting_pub_key_auth: bool, |
There was a problem hiding this comment.
Honestly, I do not like this field 😅 It seems like a workaround due to the bad state machine.
I explored the code more deeply and thought about it for some time.
The problem is in the server-side CredSSP state machine. It expects the encrypted public key immediately after the internal protocol (NTLM) completes authorization. And that is wrong. It worked in the past, before the big SPNEGO refactoring.
Instead of adding a new bool flag, I recommend improving the state machine. I already developed a fix and will create a PR today.
Thank you for showing up to this problem!
|
closed in favor of #689 |
|
Yeah, this is a much better fix. The flag I added was really just a workaround, your state machine change actually fixes it. Thanks for taking the time to dig into it. I'll send the |
Closes #687.
Problem
CredSspServercannot authenticate sspi's ownCredSspClientwhen both sides run NTLM inside SPNEGO (ClientMode::Negotiate/ServerMode::Negotiate— the pairing RDP requires, since Windows RDP servers reject bare NTLM inside CredSSP). The handshake fails on the AUTHENTICATE leg with:Because the client always includes an NTLM MIC, the SPNEGO
mechListMICexchange is mandatory (MS-SPNG 3.3.5.5 / RFC 4178 §5), so the client deferspubKeyAuthuntil it has received and verified the server's final SPNEGO token. The server side, however, dropped that final token (ts_request.nego_tokens = None) at the moment its acceptor completed, and demandedpubKeyAuthin the same TSRequest — one leg earlier than its own client sends it.Fix
In
CredSspServer'sNegoTokenstate, when the acceptor completes (Ok/CompleteNeeded) but the incoming TSRequest carries nopubKeyAuthand the acceptor produced a final output token:ServerState::ReplyNeededcarrying that final SPNEGO token (accept-completed +mechListMIC) instead of dropping it, andpubKeyAuthon the following leg (awaiting_pub_key_authflag; calling the acceptor again on that leg would hitNegotiateState::Ok → OutOfSequence).This matches the client's behavior and what Windows RDP servers do on the wire. Pairings where
pubKeyAutharrives together with the last nego token (bare NTLM, Kerberos) take the unchanged single-leg path — the new branch only activates whenpubKeyAuthis absent and a final token exists. ThepubKeyAuthdecrypt/encrypt sequence is extracted intoexchange_pub_key_authto avoid duplicating it across the two legs.Resulting wire flow (Negotiate-NTLM)
Tests
credssp_negotiate_ntlmclient↔server test pins the four-client-leg handshake — it fails with the pre-fix error on master and passes with this change. (The suite previously paired onlyNtlm↔NtlmandNegotiate↔Negotiate-Kerberos, so this path was never exercised.)credssp_ntlm(3 legs, unchanged) andcredssp_kerberos(single-legpubKeyAuth, unchanged) still pass —cargo test --test sspi --features "network_client,__test-data": 19 passed;cargo test -p sspi --lib: 230 passed.Found while building an RDP client that drives
CredSspClientin Negotiate mode against real Windows hosts (which complete this exact client flow) and attempting to stand up an sspi-based loopback CredSSP server for integration tests.CI
f8959caadds__test-datato the three root-manifest test-matrix rows, so theclient_serversuite (gated onall(network_client, __test-data)) actually runs in CI from now on — it previously never did, which is how #687 shipped unnoticed. The suite is hermetic (KdcMock/NetworkClientMock); verified locally with the exact win-row feature combo and the shared minimal combo, all green.