Windows 命令行转义杂谈

on under windows
12 minute read

背景

2019 年五月的 Microsoft Build 大会,微软宣布了 Windows Terminal,并在 Github 上开源:https://github.com/microsoft/terminal。我作为技术爱好者,肯定要尝鲜一番。

使用截图:

在使用的过程中,多标签,亚克力的窗口背景和 Colour Emojis 都让我非常满意,而且使用 Direct2D 绘制的 emoji,看起来要比 Mintty 使用 GDI+PNG 的 emoji 方案好的多(PNG 的 emoji 无论是放大和缩小都更容易失真,Mintty 并没有 Segoe UI 字体的 emoji 风格,其他风格我并不太喜欢)。不过我在使用的过程中发现了一个 BUG,即 Bug Report: The conhost command line is not properly escaped #1090 ,后来我又提交了一个 PR 修复了此问题,在研究其他软件源码时,我发现这个问题并不是个例,因此有必要对此问题进行一次讨论,便有了此文。

Windows Terminal 的命令行转义错误

在 Windows Termianl 开源之处,我便安装了 Windows Termianl,并且每个周末都可能会拉取最新代码构建 Windows Termianl。但在自定义 profile.json 时,我发现 Windows Termianl 启动某个 profiles 会与预期不一致,相关的配置如下:

        {
            "acrylicOpacity": 0.75,
            "closeOnExit": false,
            "colorScheme": "Campbell",
            "commandline": "\"C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe\" -NoExit -Command \"$Host.UI.RawUI.WindowTitle=\\\"Windows Pwsh 💙 (7 Preview)\\\"\"",
            "cursorColor": "#FFFFFF",
            "cursorShape": "bar",
            "fontFace": "Consolas",
            "fontSize": 12,
            "guid": "{08a0be98-ff68-4e3a-a054-0fbd3969d3bb}",
            "historySize": 9001,
            "icon": "ms-appdata:///roaming/pwsh-32.png",
            "name": "Windows Pwsh \ud83d\udc99 (7 Preview)",
            "padding": "0, 0, 0, 0",
            "snapOnInput": true,
            "startingDirectory": "C:\\Users\\CharlieInc",
            "useAcrylic": true
        }

其中的 commandline 是启动 Shell 的命令,这里的目的是启动 PowerShell 7 Preview 并将其标题设置成 Emoji, 这里 \ud83d\udc99 实际上是 U+1F499 即 ‘💙’,你可以使用 echo -e "\U0001F499" 输出。但 Windows Terminal 中,报告了如下错误:

查看任务管理器命令行得到如下命令:

我们可以看到 Pwsh 进程的命令行与 JSON 配置中的已经不一致了:

:: want
"C:\Program Files\PowerShell\7-preview\pwsh.exe" -NoExit -Command "$Host.UI.RawUI.WindowTitle=\"Windows Pwsh 💙 (7 Preview)\""
:: got
"C:\Program Files\PowerShell\7-preview\pwsh.exe" -NoExit -Command $Host.UI.RawUI.WindowTitle="Windows Pwsh 💙 (7 Preview)"

按照 Windows 命令行解析规范 Parsing C++ Command-Line Arguments ,pwsh 在解析命令行时, 获得到的命令行会编成如下数组:

ARGV0: C:\Program Files\PowerShell\7-preview\pwsh.exe
ARGV1: -NoExit
ARGV2: -Command
ARGV3: $Host.UI.RawUI.WindowTitle=Windows Pwsh 💙 (7 Preview)

但实际上我们预期的命令行应该是:

ARGV0: C:\Program Files\PowerShell\7-preview\pwsh.exe
ARGV1: -NoExit
ARGV2: -Command 
ARGV3: $Host.UI.RawUI.WindowTitle="Windows Pwsh 💙 (7 Preview)"

这样一来在 Pwsh 解析命令行后设置 $Host.UI.RawUI.WindowTitle 肯定就会出错,那么问题是怎么产生的?

我们查看进程树,发现 Pwsh 的父进程是 OpenConsole,也就是图中的 conhost.exe, 即 Windows 控制台窗口主机的开源版本。

通过分析 OpenConsole 的源码,我发现问题出现在合成子进程命令行出错了:

[[nodiscard]]
HRESULT ConsoleArguments::_GetClientCommandline(_Inout_ std::vector<std::wstring>& args, const size_t index, const bool skipFirst)
{
    auto start = args.begin()+index;

    // Erase the first token.
    //  Used to get rid of the explicit commandline token "--"
    if (skipFirst)
    {
        // Make sure that the arg we're deleting is "--"
        FAIL_FAST_IF(!(CLIENT_COMMANDLINE_ARG == start->c_str()));
        args.erase(start);
    }

    _clientCommandline = L"";
    size_t j = 0;
    for (j = index; j < args.size(); j++)
    {
        _clientCommandline += args[j];
        if (j+1 < args.size())
        {
            _clientCommandline += L" ";
        }
    }
    args.erase(args.begin()+index, args.begin()+j);

    return S_OK;
}

在这段代码中,_clientCommandline 仅仅只是简单相加,没有做任何的转义,一旦某一个参数中需要转义却未转义,则会导致子进程命令行与预期不相符,于是我便打开了 Issue: Bug Report: The conhost command line is not properly escaped #1090 ,由于我在实现 Privexec/wsudo,已经有过此类经验,还在此 Issue 中添加了一段代码告知如何转义,但最初 Windows Terminal 的意见是直接使用 ConPTY 替换 Conhost,也就是省去了使用 Conhost 作为 ConPTY,好吧,这有点绕,目前 Windows Terminal 依然使用 Conhost 创建 ConPTY, 然后,WindowsTerminal.exe 通过三条管道连接 Conhost.exe 分别是输入输出和信号管道。创建 Conhost-ConPTY 的代码如下:

__declspec(noinline) inline HRESULT CreateConPty(const std::wstring& cmdline,
                                                 const unsigned short w,
                                                 const unsigned short h,
                                                 HANDLE* const hInput,
                                                 HANDLE* const hOutput,
                                                 HANDLE* const hSignal,
                                                 PROCESS_INFORMATION* const piPty)
{
    // Create some anon pipes so we can pass handles down and into the console.
    // IMPORTANT NOTE:
    // We're creating the pipe here with un-inheritable handles, then marking
    //      the conhost sides of the pipes as inheritable. We do this because if
    //      the entire pipe is marked as inheritable, when we pass the handles
    //      to CreateProcess, at some point the entire pipe object is copied to
    //      the conhost process, which includes the terminal side of the pipes
    //      (_inPipe and _outPipe). This means that if we die, there's still
    //      outstanding handles to our side of the pipes, and those handles are
    //      in conhost, despite conhost being unable to reference those handles
    //      and close them.
    // CRITICAL: Close our side of the handles. Otherwise you'll get the same
    //      problem if you close conhost, but not us (the terminal).
    HANDLE outPipeConhostSide;
    HANDLE inPipeConhostSide;
    HANDLE signalPipeConhostSide;

    SECURITY_ATTRIBUTES sa;
    sa = { 0 };
    sa.nLength = sizeof(sa);
    sa.bInheritHandle = FALSE;
    sa.lpSecurityDescriptor = nullptr;

    CreatePipe(&inPipeConhostSide, hInput, &sa, 0);
    CreatePipe(hOutput, &outPipeConhostSide, &sa, 0);

    // Mark inheritable for signal handle when creating. It'll have the same value on the other side.
    sa.bInheritHandle = TRUE;
    CreatePipe(&signalPipeConhostSide, hSignal, &sa, 0);

    SetHandleInformation(inPipeConhostSide, HANDLE_FLAG_INHERIT, 1);
    SetHandleInformation(outPipeConhostSide, HANDLE_FLAG_INHERIT, 1);

    std::wstring conhostCmdline = L"conhost.exe";
    conhostCmdline += L" --headless";
    std::wstringstream ss;
    if (w != 0 && h != 0)
    {
        ss << L" --width " << (unsigned long)w;
        ss << L" --height " << (unsigned long)h;
    }

    ss << L" --signal 0x" << std::hex << HandleToUlong(signalPipeConhostSide);
    conhostCmdline += ss.str();
    conhostCmdline += L" -- ";
    conhostCmdline += cmdline;

    STARTUPINFO si = { 0 };
    si.cb = sizeof(STARTUPINFOW);
    si.hStdInput = inPipeConhostSide;
    si.hStdOutput = outPipeConhostSide;
    si.hStdError = outPipeConhostSide;
    si.dwFlags |= STARTF_USESTDHANDLES;

    std::unique_ptr<wchar_t[]> mutableCommandline = std::make_unique<wchar_t[]>(conhostCmdline.length() + 1);
    if (mutableCommandline == nullptr)
    {
        return E_OUTOFMEMORY;
    }
    HRESULT hr = StringCchCopy(mutableCommandline.get(), conhostCmdline.length() + 1, conhostCmdline.c_str());
    if (!SUCCEEDED(hr))
    {
        return hr;
    }

    bool fSuccess = !!CreateProcessW(
        nullptr,
        mutableCommandline.get(),
        nullptr, // lpProcessAttributes
        nullptr, // lpThreadAttributes
        true, // bInheritHandles
        0, // dwCreationFlags
        nullptr, // lpEnvironment
        nullptr, // lpCurrentDirectory
        &si, // lpStartupInfo
        piPty // lpProcessInformation
    );

    CloseHandle(inPipeConhostSide);
    CloseHandle(outPipeConhostSide);
    CloseHandle(signalPipeConhostSide);

    return fSuccess ? S_OK : HRESULT_FROM_WIN32(GetLastError());
}

但由于 Switch from ConhostConnection to the official ConPTY API #1131 依赖 winconpty.c,但 winconpty.c 迟迟没有开源,因此,此问题一直没有得到解决,后来随着 Windows Terminal 的公开预览,此问题也不断由用户报告。于是我提交了一个 Fix The conhost command line is not properly escaped, 试图修复这个问题,经过一个星期的等待,PR 终于被合并,目前使用最新源码构建的 Windows Terminal 已经能够按照预期工作,我的 Windows Terminal 配置文件也已经更新:https://gist.github.com/fcharlie/7530d36175bc5249f1ae92be536238cd

这个问题在 conhost 中都能出现,可能并不是个例,那么应该很多软件都会出现命令行正确转义。

Windows UCRT 的进程启动函数

在 Windows 中,通常编写 C 程序启动进程除了使用 CreateProcess API 之外,还可以使用 _spawn* _exec* 之类的函数,但在 Windows 1903(10.0.18362.0)UCRT 的源码中,都是调用 common_spawnvp 实现,而命令行合成的代码是通过如下函数实现:

// "C:\Program Files (x86)\Windows Kits\10\Source\10.0.18362.0\ucrt\exec\cenvarg.cpp" +24

// Converts a main()-style argv arguments vector into a command line.  On success,
// returns a pointer to the newly constructed arguments block; the caller is
// responsible for freeing the string.  On failure, returns null and sets errno.
template <typename Character>
static errno_t __cdecl construct_command_line(
    Character const* const* const argv,
    Character**             const command_line_result
    ) throw()
{
    typedef __crt_char_traits<Character> traits;

    *command_line_result = nullptr;

    // Compute the number of bytes required to store the arguments in argv in a
    // command line string (including spaces between arguments and a terminator):
    size_t const command_line_count = [&]
    {
        size_t n = 0;
        for (Character const* const* it = argv; *it; n += traits::tcslen(*it++) + 1) { }

        // If there were no arguments, return 1 so that we can return an empty
        // string:
        return __max(n, 1);
    }();

    __crt_unique_heap_ptr<Character> command_line(_calloc_crt_t(Character, command_line_count));
    if (!command_line)
    {
        __acrt_errno_map_os_error(ERROR_NOT_ENOUGH_MEMORY);
        return errno = ENOMEM;
    }

    Character const* const* source_it = argv;
    Character*              result_it = command_line.get();

    // If there are no arguments, just return the empty string:
    if (*source_it == '\0')
    {
        *command_line_result = command_line.detach();
        return 0;
    }

    // Copy the arguments, separated by spaces:
    while (*source_it != '\0')
    {
        _ERRCHECK(traits::tcscpy_s(result_it, command_line_count - (result_it - command_line.get()), *source_it));
        result_it += traits::tcslen(*source_it);
        *result_it++ = ' ';
        ++source_it;
    }

    // Replace the last space with a terminator:
    result_it[-1] = '\0';

    *command_line_result = command_line.detach();
    return 0;
}

实际上这个 UCRT 出现此问题,修复的可能性要小于 Windows Terminal,毕竟没啥渠道。这和 SSH 的命令行转义一样难修。还有一些其他软件也存在这个问题,比如 reproc

说不完的转义

转义问题不仅仅是命令行需要注意的,在查询数据库,解析网络请求,方方面面都需要注意到转义问题,一些安全漏洞也是有缺乏转义导致的。写代码虽易,写好代码难。

这个问题在 Windows 中发生频率较高的原因之一是在 Windows 进程 PEB 中,命令行与 POSIX 的命令行保存格式差异,Windows 命令行参数是 CommandLine 形式,而 POSIX 以 Linux 为例是 Argv。内存中如下:

const wchar_t *WinCommandLine=L"\"C:\\Program Files\\PowerShell\\7-preview\\pwsh.exe\" -NoExit -Command \"$Host.UI.RawUI.WindowTitle=\\\"Windows Pwsh 💙 (7 Preview)\\\"\"\0";

const char *argvblock="pwsh\0-NoExit\0-Command\0\"$Host.UI.RawUI.WindowTitle=\"Windows Pwsh 💙 (7 Preview)\"\0";
//char *const argv[]={0x0100,...};

因此,以 Argv 的这种命令行形式在使用系统调用真正启动进程时,Windows 仍需要一次转换,这就容易带来问题,这也是命令行转义问题在 Windows 平台太比较多的原因。

最后

Windows Terminal 开源还是非常不错的,参与到其中改进在 Windows 系统的开发体验感觉很不错,而其中的 OpenConsole 源码也让我更加深入了解了 Windows Console 的一些原理。

OpenConsole 目前可以开启 注册表选项 HKCU\Console\UseDx=DWORD(1) 切换到使用 DirectWrite 渲染字体,但我目前(2019-07-20)测试虽然使用 DirectWrite 渲染,但 Emoji 无法显示,这个问题我将进一步跟踪。

控制台团队还在 Windows Terminal 源码中添加了一个 Console 控件,这样一来,第三方程序中集成控制台窗口将更加容易。Visual Studio WhackWhackTerminal 的作者 Daniel Griffen 还增加了一些 PR, 实现 WPF 控制台控件,这可以预见,可能将新的控制台集成到 Visual Studio。

下面是一些控制台的文档和关键代码: