Post

Microsoft Windows NCSI Cross-Context Proxy Authentication Coercion - ZDI-26-708 - Part 2

Microsoft Windows NCSI Cross-Context Proxy Authentication Coercion - ZDI-26-708 - Part 2

Summary

Part 1 ended with a question: how does a proxy configured by an interactive user arrive inside ncsi.dll when the service thread is not impersonating that user?

In this part we will walk through the internals of NCSI to understand how a proxy configured by another user finally lands in netprofm, and how that value is later used by the WinHTTP request.

The answer was not a direct read from the attacker’s HKCU, and it was not the WNF payload I spent a few days chasing. NCSI subscribes to this Windows Event Log channel:

1
Microsoft-Windows-WinINet-Config/ProxyConfigChanged

It requests Event ID 5600 and renders four values from each event:

1
2
3
4
fAutoDetect
pwszAutoConfigUrl
pwszProxy
pwszProxyBypass

Those values are copied into NCSI’s internal user-proxy state. Later, the NETWORK SERVICE thread uses that state when it performs a proxy-aware connectivity probe. The event tells NCSI what the proxy configuration is, but the fields selected by NCSI do not bind it to the user who supplied it.

The event and WNF have separate roles in this path:

MechanismRole in the observed flow
WinINet Event ID 5600Carries the four proxy fields into netprofm; NCSI stores them and sets IsUserProxyConfigured to 1.
WNF proxy-discovered notificationCalls NcsiProxyDetectedMonitor::WnfCallback, which queues the active probe that will consume the stored proxy state.

The WNF payload did not contain the proxy string. At first glance, I thought the WNF payload would contain the proxy information, but it was not the case finally. By the time its callback schedules the probe, the Event Log handler has already populated NCSI’s configuration. The later NcsiHttpGet call sees IsUserProxyConfigured == 1, selects probe type 4 or 5, and applies the stored proxy to its WinHTTP session.

Recap of the call trace

1
2
3
4
5
6
7
8
9
10
11
12
13
netprofm
  -> ncsi.dll
    -> NcsiActiveProbe
      -> NcsiActiveProbeWorker
        -> NcsiHttpGet
          -> HttpGetRequest::HttpGetRequest
          -> WinHttpOpen
          -> WinHttpSetOption
          -> WinHttpGetProxyForUrl / GetManualProxies
          -> WinHttpConnect
          -> WinHttpOpenRequest
          -> WinHttpSendRequest
          -> WinHttpReceiveResponse

The complete trace shows every WinHttpSetOption call on this path (Following the complete path in WinDbg and IDA section). The security-sensitive one uses WINHTTP_OPTION_DISABLE_PROXY_AUTH_SCHEMES, and its failure message says Setting disable leaking credential option failed. However, NCSI passes 0x100 (WINHTTP_PROXY_DISABLE_AUTH_LOCAL_SERVICE), not 0x04 (WINHTTP_PROXY_DISABLE_SCHEME_NTLM).

The WNF detour

WNF, Windows Notification Facility, looked like the obvious place to start. A state name exists for exactly this sort of notification:

Breaking on NcsiProxyDetectedMonitor::WnfCallback also showed that the callback schedules fresh active probes. Everything seemed to fit.

But, the proxy string could be found in the memory of the svchost.exe process hosting NCSI just when the WNF notification callback was received. So, it was clear that the proxy information was coming before the WNF callback was processed.:

In some cases, the race could be win and the WNF callback arrives before the Event 5600 and the proxy information not being available in memory, but the WNF payload was empty.

For a while I assumed WNF was carrying the proxy string in its payload. It was a decent theory and also completely wrong.

The WNF callback is relevant to probe scheduling and proxy-detected state, but I could not trace the user-controlled string back to that payload. Finding a value in a service process tells us where it ended up, not how it got there. That sounds obvious now; it was less obvious after hours staring at hardware breakpoints.

Finding Event ID 5600

After some time without finding payload data in the WNF callback, I uploaded ncsi.dll to ChatGPT, gave it the research context and asked which other mechanisms NCSI could use to obtain the proxy data. It found traces of event-subscription capabilities in the DLL and suggested capturing the relevant providers.

The useful turn came from capturing the NCSI, WinHTTP, and WinINet configuration providers together:

1
2
3
4
logman create trace ProxyTrace -o C:\TTD_Traces\ProxyTrace.etl -ow -ets
logman update trace ProxyTrace -p Microsoft-Windows-NCSI 0xffffffffffffffff 0xff -ets
logman update trace ProxyTrace -p Microsoft-Windows-WinHttp 0xffffffffffffffff 0xff -ets
logman update trace ProxyTrace -p Microsoft-Windows-WinINet-Config 0xffffffffffffffff 0xff -ets

A proxy change produced Event ID 5600 from Microsoft-Windows-WinINet-Config. The event contained the new proxy values, while Event Viewer displayed its user as N/A:

The Details view was more useful than the generic description. It showed the four values carried by this event, including the literal 192.168.1.43:80 proxy used in the test:

Event ID 5600 details showing the four WinINet proxy fields

A terminology note before going further: the trace above is ETW, but ncsi.dll consumes the ProxyConfigChanged channel through the Windows Event Log API (wevtapi.dll). In other words, the provider helped me find the event; the vulnerable consumer uses EvtSubscribe and EvtRender. Calling the whole thing “the ETW path” is convenient, but slightly imprecise.

Static analysis of ncsi.dll (by Chaty :D) then exposed all the pieces in one place. The binary contains the channel name:

It also contains the query selecting Event ID 5600:

The subscription is created with EvtSubscribe:

Finally, NcsiWinINetProxySettingMonitor::ProcessEvent builds an EvtCreateRenderContext for four XPath selectors:

1
2
3
4
Event/EventData/Data[@Name="fAutoDetect"]
Event/EventData/Data[@Name="pwszAutoConfigUrl"]
Event/EventData/Data[@Name="pwszProxy"]
Event/EventData/Data[@Name="pwszProxyBypass"]

Then I moved back into the debugger to identify how the proxy data reached the NCSI active-probe function.

At runtime, ProcessEvent calls EvtRender twice: first to obtain the required buffer size and then again to retrieve the EVT_VARIANT values.

This finally explained why WinHttpGetIEProxyConfigForCurrentUser did not need to return the interactive user’s proxy while netprofm was running as NETWORK SERVICE. NCSI already had a copy. It had received the values from the event subscription and stored them as user-proxy metadata.

Following the complete path in WinDbg and IDA

The following part shows the whole process, from the receiving of the proxy data via Evt to the HTTP call in the NCSI Active Probe. This was the part that finally joined all the observations.

Breakpoints and debugging method

I used IDA first to mark the two main paths: the Windows Event Log callback and NcsiHttpGet. Then I attached WinDbg to the svchost.exe instance hosting netprofm and used breakpoints around the following functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ncsi!NcsiWinINetProxySettingMonitor::ProcessEvent
ncsi!NcsiConfigData::HandleWinInetProxyConfigForCurrentUser
ncsi!ActiveProbeManager::TryNcsiQueueActiveInternetProbe
ncsi!NcsiActiveProbe
ncsi!NcsiActiveProbeWorker
ncsi!NcsiHttpGet

wevtapi!EvtRender
WINHTTP!WinHttpOpen
WINHTTP!WinHttpSetOption
WINHTTP!WinHttpGetProxyForUrl
WINHTTP!WinHttpConnect
WINHTTP!WinHttpOpenRequest
WINHTTP!WinHttpSendRequest
WINHTTP!WinHttpReceiveResponse
WINHTTP!WinHttpQueryOption

For strings, a normal breakpoint was not enough because the same proxy appeared in several allocations.

Using !token -n at the event handler, the probe worker and the send path effectively revealed none of the relevant threads were impersonating the interactive user.

Stage 1: listener initialization

IDA showed a global g_ncsiWinInetProxySettingsMonitor being created during ncsi.dll initialization. Its setup prepares an EtwListener object with the channel and query already shown above:

1
2
Channel: Microsoft-Windows-WinINet-Config/ProxyConfigChanged
Query:   Event/System/EventID=5600

Despite the internal class name EtwListener, the final subscription uses the Windows Event Log API. EtwListener::Initialize stores the monitor as its callback context and calls EvtSubscribe, passing EtwListener::EventCallback as the notification callback. Semantically, the initialization looked like this:

1
2
3
4
5
listener.Initialize(
    &g_ncsiWinInetProxySettingsMonitor,
    L"Microsoft-Windows-WinINet-Config/ProxyConfigChanged",
    L"Event/System/EventID=5600"
);

The exact C++ prototype is reconstructed, but the channel, query, context and callback are visible in the disassembly. From that point onward, changing a user’s WinINet proxy causes the callback to run inside the netprofm process.

Stage 2: rendering Event ID 5600

The runtime call stack at the second EvtRender call was especially useful:

1
2
3
4
5
6
wevtapi!EvtRender
ncsi!NcsiWinINetProxySettingMonitor::ProcessEvent+0x249
ncsi!EtwListener::EventCallback+0x80
wevtapi!RemotePushSubscription::InvokeCallbackForEvent
wevtapi!RemotePushSubscription::ProcessResult
ntdll!TppWorkerThread

The debugger capture also fixes an important detail: this was an Event Log subscription callback running on a worker thread, not a registry read performed by the later probe thread.

WinDbg call stack for the second EvtRender call

ProcessEvent follows the usual two-call Event Log pattern. The first EvtRender call has no output buffer and obtains the required size. After GetLastError reports that a larger buffer is needed, NCSI allocates it from the process heap and calls EvtRender again.

The render context fixes the order of the returned EVT_VARIANT array:

1
2
3
4
index 0 -> fAutoDetect
index 1 -> pwszAutoConfigUrl
index 2 -> pwszProxy
index 3 -> pwszProxyBypass

At the second call I followed the pointer for index 2 with du and obtained the proxy configured by the unprivileged user:

1
192.168.1.43:80

The rendered proxy value followed in WinDbg with the du command

This is the first point where I observed the attacker-controlled string inside netprofm. There was no registry read under the attacker’s SID on this thread. The value came straight from the event payload rendered by wevtapi.dll.

Stage 3: from the event buffer to NCSI configuration

Stepping out of ProcessEvent led to NcsiConfigData::HandleWinInetProxyConfigForCurrentUser. A breakpoint at +0x7e showed the structure received by this method and, through its string member, the same 192.168.1.43:80 value.

WinDbg at HandleWinInetProxyConfigForCurrentUser with the proxy string in the input structure

The handler normalizes the four rendered values into NCSI’s internal proxy representation. The later NCSI_MANUAL_PROXIES structure starts with a type and is followed by the proxy data:

1
2
type 1 -> static/named proxy
type 2 -> AutoConfigURL / PAC

IDA also showed a module-level byte referenced by IsUserProxyConfigured and initialized by NcsiConfigData::NcsiConfigData. While stepping through the handler, that byte changed from 0 to 1. This flag is important later: NcsiHttpGet checks it before entering the code that retrieves NCSI’s stored user proxy.

The update is easy to miss in the graph view. The setnz result is exchanged with the module byte, after which execution continues with the proxy string member:

IDA disassembly IsUserProxyConfigured byte IDA disassembly updating the IsUserProxyConfigured byte

Here is an example in WinDbg of this option being checked when inside a proxy-aware Active Probe.

WinDbg checking the IsUserProxyConfigured byte

This is the end of the configuration-storage half of the path. ProcessEvent does not send the HTTP request and the event callback does not need to remain on the same thread. It leaves behind two things that the later probe needs: the normalized proxy data and a true IsUserProxyConfigured flag.

There is another copy of the same information in _NcsiUserProxyChangeMetadata. A hardware breakpoint caught it in this asynchronous call stack:

1
2
3
4
5
ucrtbase!memcpy
ncsi!std::any_cast<_NcsiUserProxyChangeMetadata>
ncsi!NlmManager::PublishDiagnosticsNotificationToNlm::<lambda>::operator()
ncsi!WcmCommon::ThreadPoolWorkQueue::WorkCallback
ntdll!TppWorkerThread

That stack explains how the proxy change is also published to Network List Manager diagnostics. It is not, by itself, the call stack that sends the HTTP request. This distinction matters because it was the reason the earlier WNF-payload theory looked more convincing than it really was.

Stage 4: scheduling a proxy-aware active probe

At this point, the data path and the trigger path join. The ordering observed in the debugger was:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
WinINet Event ID 5600
  -> EvtRender returns the four proxy fields
  -> HandleWinInetProxyConfigForCurrentUser stores them
  -> IsUserProxyConfigured = 1

WNF proxy-discovered notification
  -> NcsiProxyDetectedMonitor::WnfCallback
  -> TryNcsiQueueActiveInternetProbe
  -> timer starts NcsiActiveProbe

NcsiHttpGet
  -> sees IsUserProxyConfigured == 1
  -> selects user-proxy probe type 4 or 5
  -> resolves or copies the stored proxy
  -> applies it to the service-owned WinHTTP session

In other words, Event ID 5600 is the source of the configuration, while WNF is the immediate trigger for the active probe that uses it. The WNF callback stack did not contain the proxy string because it did not need to. What it did contain was the scheduling path:

1
2
3
4
5
ncsi!NcsiProxyDetectedMonitor::WnfCallback
ncsi!ActiveProbeManager::TryNcsiQueueActiveInternetProbe
ncsi!ActiveProbeManager::PerFamilyInterfaceProbeInfo::ProcessProbeQueueRequest
ncsi!ActiveProbeManager::PerFamilyInterfaceProbeInfo::AdjustTimer
ntdll!TpSetTimer

WinDbg call stack from the WNF callback into the active-probe scheduler

The timer later reaches NcsiActiveProbe, NcsiActiveProbeWorker and finally NcsiHttpGet. In the debugging session I saw the ordinary probe modes first, followed by modes 4 and 5 once IsUserProxyConfigured was true:

1
2
3
4
5
1 = GlobalWithProxy
2 = PerInterfaceWithProxy
3 = PerInterfaceWithoutProxy
4 = PerInterfaceWithUserProxy
5 = GlobalWithUserProxy

Only types 4 and 5 enter the user-proxy block. This was confirmed both by the enum-to-string helper and by the unsigned range check in NcsiHttpGet:

1
bool userProxyProbe = ((unsigned)(activeProbeType - 4) <= 1);

This also clarifies the role of WinHTTP option 181 (0xB5) (UNDOCUMENTED). Reversing winhttp showed that this option reaches INTERNET_SESSION_HANDLE_OBJECT::AggregateAllProxyConfigurations() and is used with probe types 1 and 2, not the type 4/5 branch involved here. It was interesting during reversing, but it was not how the user proxy entered this request.

IDA graph showing the option 181 path to AggregateAllProxyConfigurations

Stage 5: creating and preparing the WinHTTP session

The stack at WinHttpOpen connected the probe manager with the network request:

1
2
3
4
5
6
WINHTTP!WinHttpOpen
ncsi!NcsiHttpGet+0x236
ncsi!NcsiActiveProbeWorker+0x6f6
ncsi!NcsiActiveProbe
ncsi!NcsiActiveProbeCallback
ntdll!TppWorkerThread

The same range check controls the dwAccessType passed to WinHttpOpen:

1
2
3
DWORD accessType = userProxyProbe
    ? WINHTTP_ACCESS_TYPE_NO_PROXY       // 1
    : WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY; // 4

It looks backwards at first. For a user-proxy probe, NCSI opens a NO_PROXY session because it is going to resolve and apply the proxy itself. For other modes it allows WinHTTP automatic proxy selection.

After WinHttpOpen, NCSI registers HttpGetRequestStatusCallback and sets option 45 (WINHTTP_OPTION_CONTEXT_VALUE) so asynchronous WinHTTP notifications can be associated with its HttpGetRequest object. It then calls SetOptionsOnWinhttpSessionHandle.

The complete WinHttpSetOption inventory

I logged each NCSI call to WinHttpSetOption from session creation through WinHttpSendRequest. The following table is the complete inventory for the captured user-proxy path. It covers the calls made by ncsi.dll; it does not attempt to list options that WinHTTP might set internally.

StageHandleOptionObserved input or purpose
Associate callbacksSession45 / 0x2D — WINHTTP_OPTION_CONTEXT_VALUEPointer used to associate status callbacks with the HttpGetRequest object.
Prepare sessionSession112 / 0x70Reached SetDnsInterfaceAffinity in the tested WinHTTP build.
Prepare sessionSession193 / 0xC1 — WINHTTP_OPTION_DISABLE_PROXY_AUTH_SCHEMESFour-byte mask with value 0x100; authentication-sensitive call analysed below.
Prepare sessionSession170 / 0xAA — WINHTTP_OPTION_RESOLVER_CACHE_CONFIGResolver-cache configuration.
Prepare sessionSession73 / 0x49 — WINHTTP_OPTION_MAX_CONNS_PER_SERVERPer-server connection limit.
Enter user-proxy branchSession137 / 0x89 — WINHTTP_OPTION_PROXY_DISABLE_SERVICE_CALLSPrevents the separate service-assisted autoproxy path.
Apply stored user proxySession38 / 0x26 — WINHTTP_OPTION_PROXYWINHTTP_PROXY_INFO containing the named proxy rendered from Event ID 5600.
Prepare requestRequest88 / 0x58 — WINHTTP_OPTION_REDIRECT_POLICYRedirect policy for the connectivity probe.
Prepare requestRequest110 / 0x6E — WINHTTP_OPTION_UNSAFE_HEADER_PARSINGHeader-parsing behaviour.
Prepare requestRequest109 / 0x6D — WINHTTP_OPTION_PEERDIST_EXTENSION_STATEPeer-distribution extension state.
Prepare requestRequest63 / 0x3F — WINHTTP_OPTION_DISABLE_FEATUREValue 0x8, disabling keep-alive.

Option 137 is relevant to proxy resolution: reversing WinHttpSetOptionInternal showed it reaching INTERNET_SESSION_HANDLE_OBJECT::DisableAutoProxyServiceCalls. Option 38 is where the stored proxy becomes active on the session. Option 193 is the call that matters for proxy authentication.

Why option 193 does not disable NTLM

The failure branch beside option 193 logs Setting disable leaking credential option failed, which initially made it look as though NCSI explicitly disabled NTLM and WinHTTP ignored the setting:

IDA view of the disable-leaking-credential failure string beside the WinHttpSetOption call

However, WINHTTP_OPTION_DISABLE_PROXY_AUTH_SCHEMES takes a bitmask. The option number alone does not say which scheme is disabled:

ValueMeaning
0x01WINHTTP_PROXY_DISABLE_SCHEME_BASIC
0x02WINHTTP_PROXY_DISABLE_SCHEME_DIGEST
0x04WINHTTP_PROXY_DISABLE_SCHEME_NTLM
0x08WINHTTP_PROXY_DISABLE_SCHEME_KERBEROS
0x10WINHTTP_PROXY_DISABLE_SCHEME_NEGOTIATE
0x100WINHTTP_PROXY_DISABLE_AUTH_LOCAL_SERVICE

Microsoft’s WinHTTP option documentation describes 0x100 as the special local-service flag: for a loopback or local proxy address, it forces use of the local machine account. Unlike the other values in the table, it does not disable an authentication scheme. So, this preventing leaking credentials refers to local leaks to local proxies.

Microsoft documentation distinguishing the proxy authentication scheme flags from the local-service flag

WinDbg confirmed all three relevant arguments at the call site: dwOption was 193, the buffer length was four bytes, and the DWORD at the buffer address was 0x100:

WinDbg showing option 193, a four-byte buffer, and the value 0x100

For this threat model, the call would need to include 0x04 to disable NTLM proxy authentication. It does not. The accurate conclusion is therefore that NCSI failed to set the NTLM-disable bit on the observed path—not that WinHTTP ignored such a bit. The API call also succeeded, so its failure-only log message says nothing about whether the selected mask prevents the authentication behaviour under investigation.

Stage 6: resolving and applying the user’s proxy

With IsUserProxyConfigured set, NCSI first builds a WINHTTP_AUTOPROXY_OPTIONS structure and calls WinHttpGetProxyForUrl for the probe URL. The structure observed in WinDbg contained:

1
2
3
4
5
dwFlags              = WINHTTP_AUTOPROXY_AUTO_DETECT
dwAutoDetectFlags    = WINHTTP_AUTO_DETECT_TYPE_DHCP |
                       WINHTTP_AUTO_DETECT_TYPE_DNS_A
lpszAutoConfigUrl    = NULL
fAutoLogonIfChallenged = FALSE

In the captured run, automatic detection failed with error 12180 (ERROR_WINHTTP_AUTODETECTION_FAILED). Actually, it failed always even with WPAD configured and running in the network, but I do not know why.

The control flow then reached NcsiConfigData::GetManualProxies.

The failed call left 12180 in the thread’s last-error value, which made the fallback much easier to identify while stepping:

WinDbg showing error 12180 after WinHTTP automatic proxy detection failed

GetManualProxies also calls WinHttpGetIEProxyConfigForCurrentUser, but under the NETWORK SERVICE process token it did not retrieve the interactive user’s proxy (its HKCU does not contain any proxy configured).

The routine additionally checks NCSI’s manual-proxy sources through LoadManualProxies, which looks for specific proxy configuration for this service at HKLM\SYSTEM\CurrentControlSet\Services\NlaSvc\Parameters\Internet\ManualProxies.

The important point is that the user value does not need to come from that current-user API: HandleWinInetProxyConfigForCurrentUser has already placed the Event ID 5600 data into NCSI’s configuration state.

The next branch depends on the returned NCSI_MANUAL_PROXIES type:

  • For type 2, NCSI treats the string as an explicit PAC URL and evaluates it with WinHttpGetProxyForUrl.
  • For type 1, NCSI duplicates the static proxy string with wil::make_unique_string_nothrow and builds a WINHTTP_PROXY_INFO structure.

For the static proxy used in this test, WinDbg showed:

1
2
3
dwAccessType    = WINHTTP_ACCESS_TYPE_NAMED_PROXY // 3
lpszProxy       = L"192.168.1.43:80"
lpszProxyBypass = <configured bypass or NULL>

NCSI then applies this structure with:

1
2
3
4
5
6
WinHttpSetOption(
    hSession,
    WINHTTP_OPTION_PROXY, // 38 / 0x26
    &proxyInfo,
    sizeof(WINHTTP_PROXY_INFO)
);

At runtime the option was 38, the structure was 24 bytes on this x64 build, and its named-proxy member pointed to the same 192.168.1.43:80 string:

WinDbg showing WINHTTP_OPTION_PROXY applied with the user's proxy string

This is the exact point where the string received in Event ID 5600 becomes the proxy used by the service-owned WinHTTP session.

Stage 7: constructing, sending and checking the probe

After the proxy is selected, the remaining flow is a conventional WinHTTP request. WinHttpConnect receives the NCSI probe host, and WinHttpOpenRequest creates a GET request for /connecttest.txt. A NULL verb means GET; the flags observed in this call included WINHTTP_FLAG_ESCAPE_PERCENT (0x4).

The WinHttpConnect breakpoint showed the destination passed by NCSI as ipv6.msftconnecttest.com:

WinDbg at WinHttpConnect showing the NCSI probe host

SetOptionsOnRequestHandle then applies request-specific behaviour. The options seen in WinDbg were:

1
2
3
4
5
88  -> WINHTTP_OPTION_REDIRECT_POLICY
110 -> WINHTTP_OPTION_UNSAFE_HEADER_PARSING
109 -> WINHTTP_OPTION_PEERDIST_EXTENSION_STATE
63  -> WINHTTP_OPTION_DISABLE_FEATURE, value 0x8
       (WINHTTP_DISABLE_KEEP_ALIVE)

NCSI also sets the request timeouts before calling WinHttpSendRequest and WinHttpReceiveResponse. If the proxy answers with 407 Proxy Authentication Required, WinHTTP handles the authentication exchange underneath these calls and resends as required by the selected scheme.

WinDbg stopped at the NCSI call to WinHttpSendRequest

After the response, NCSI records what actually happened rather than trusting the requested mode. GetWinHttpProxyInfo queries WINHTTP_OPTION_PROXY (38) for user-proxy probes. The other probe path can query the internal selected-proxy information through option 182 (0xB6) (UNDOCUMENTED). GetWinHttpConnectionInfo also queries option 93 to retrieve the source address, destination address and port of the completed request.

From your WinHttpQueryOptionInternal disassembly, option 182 (0xB6) is the case that queries “selected proxy config info” for the handle.

What the code does (0xB6 / 182)

  1. The option dispatcher arithmetic eventually routes dwOption = 0xB6 to loc_7FF937E14650. You can see the final jump target is loc_7FF937E14650 after the sequence of sub / jz checks.

    disassembly.WinHttpQueryOptionI…

  2. At loc_7FF937E14650, it enforces a required output buffer size of 0x38 bytes.

    • If your *lpdwBufferLength is smaller, it writes 0x38 to *lpdwBufferLength and returns 0x7A (ERROR_INSUFFICIENT_BUFFER).
  3. If the buffer is big enough, it calls:

    • HTTP_REQUEST_HANDLE_OBJECT::SafeGetUsrReq

    • then HTTP_USER_REQUEST::QuerySelectedProxyConfigInfo, passing your output buffer as a _WINHTTP_SELECTED_PROXY_CONFIG_INFO*.

So, option 182 = “get the selected proxy configuration for this request/session handle”, returned as a _WINHTTP_SELECTED_PROXY_CONFIG_INFO structure (size 0x38 in this build). That’s distinct from WINHTTP_OPTION_PROXY, which returns a WINHTTP_PROXY_INFO (the proxy currently set on the handle).

In this run the option 38 query returned access type 3 (WINHTTP_ACCESS_TYPE_NAMED_PROXY) and, once again, 192.168.1.43. This is a useful confirmation after the response; it is not another source for the value.

WinDbg showing the effective named proxy returned by WinHttpQueryOption

Finally, the result returns to NcsiActiveProbeWorker, which uses the HTTP status and response data to update NCSI’s connectivity state. The WinHTTP handles are closed and any proxy strings allocated by WinHTTP are released with GlobalFree.

The last token check was the same as at the start of the path:

1
2
Thread is not impersonating. Using process token...
User: S-1-5-20 (NT AUTHORITY\NETWORK SERVICE)

So the proxy string begins in an event caused by one user’s HKCU configuration, but the request finishes on a thread using the netprofm process token. That is the entire cross-context transition.

Where the boundary is lost

The complete path observed in the tested build is:

1
2
3
4
5
6
7
8
9
10
11
1. An unprivileged user changes WinINet proxy settings in HKCU.
2. Microsoft-Windows-WinINet-Config emits Event ID 5600.
3. ncsi.dll receives it through its ProxyConfigChanged subscription.
4. NcsiWinINetProxySettingMonitor::ProcessEvent renders four proxy fields.
5. NCSI stores the values and sets IsUserProxyConfigured to 1.
6. The WNF callback queues a new active Internet probe.
7. The queued probe reaches NcsiHttpGet and sees that the flag is set.
8. Active probe type 4 or 5 enters the user-proxy branch.
9. NCSI resolves the PAC/WPAD data or copies the stored static proxy.
10. The result is applied to a WinHTTP handle with WINHTTP_OPTION_PROXY.
11. The request is sent by a thread using the netprofm process token.

The trust error sits between steps 4 and 5. The event fields selected by NCSI carry the network configuration, but no trustworthy user or session identity that follows the values into the later probe. WNF supplies the trigger at step 6, not the data. By step 11, the data and the security context no longer belong to the same principal.

What the authentication capture shows

Using impacket-ntlmrelayx HTTP server (which returns 407 Proxy Authentication Required and advertised NTLM), showed the requests arriving from the Windows test VM.

Proxy helper code returning HTTP 407 with the Proxy-Authenticate header

It recorded the /wpad.dat retrieval in this example because auto proxy was configured in this run instead of direct proxy, the absolute NCSI probe URL and, after the authentication messages were exchanged, the Connection ... controlled transition into ntlmrelayx’s relay handling:

Kali Linux ntlmrelayx receiving the NCSI proxy connection and handling its authentication exchange

WinHTTP responded with Type 1 and Type 3 NTLM messages and performed the authentication process.

On a domain-joined host, NETWORK SERVICE normally represents the computer account when authenticating to a remote resource. The 0x100 option is also explicitly about use of the local machine account. So, machine-account relay is environment-dependent impact rather than a universal chain.

For a real world example relaying to ADCS see the blog post at ITRESIT Labs:

Fix direction

There are several places where Microsoft can break the chain:

  • Do not use per-user proxy data for a service-context NCSI probe.
  • If NCSI must consume user proxy configuration, keep the originating user/session attached to the data and run the request in that context.
  • Do not transparently try to authenticate with machine credentials during NCSI proxy authentication.
  • Treat proxy-change events as notifications only. Re-read the configuration from an appropriately impersonated user context before using it.
  • Restrict service-owned probes to administrator-controlled, machine-wide proxy configuration.

Simply speaking: a proxy supplied by a user must not cause Windows to authenticate as a different principal.

Another thing I failed

Here is another point where I failed during the research, showing that this is not a plain sailing.

My first analysis identified the 0x100 value passed to WINHTTP_OPTION_DISABLE_PROXY_AUTH_SCHEMES as an NTLM-disable flag. The NTLM flag is 0x4; 0x100 is WINHTTP_PROXY_DISABLE_AUTH_LOCAL_SERVICE. WinHTTP was not shown ignoring an NTLM-disable setting.

There were two useful wrong turns in this research. WNF really does notify NCSI that a proxy has been discovered, but it was not carrying the proxy string I was following. And option 193 really is related to credential-leak prevention, but the value passed to it did not mean what I initially thought.

The root cause only became visible after following the value end to end: from the user’s HKCU setting, into WinINet Event ID 5600, through EvtSubscribe and EvtRender, into NCSI’s user-proxy metadata, and finally out through a WinHTTP request owned by netprofm.

This post is licensed under CC BY 4.0 by the author.