Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Privexec 的内幕一标准输出原理与彩色输出实现 #10173

Open
guevara opened this issue Oct 25, 2023 · 0 comments
Open

Privexec 的内幕一标准输出原理与彩色输出实现 #10173

guevara opened this issue Oct 25, 2023 · 0 comments

Comments

@guevara
Copy link
Owner

guevara commented Oct 25, 2023

Privexec 的内幕(一)标准输出原理与彩色输出实现



https://ift.tt/RxCJlcm






前言

Privexec 是笔者借鉴远景好友 MouriNaruto 的 NSudo 而开发的一个提权或者降权执行进程的工具。其中 wsudo 是 Privexec 的命令行版本。

在 wsudo 中,笔者使用了 Privexec.Console 提供彩色输出,截图如下:

wsudo

wsudo3

本文将讲述标准输出是如何输出到控制台的,以及怎样在 Windows 中实现同时支持标准控制台和 MSYS2 Cygwin 终端模拟器以及 VT 模式的控制台彩色输出。

关于标准输出

大部分编程语言的入门从 Helloworld 开始,也就是将文本 Helloworld 输出到标准输出。在 C++ 中使用 std::cout ,在 C 中使用 printf 以及在 C# 中使用 Console.Write。进程启动时,操作系统或者父进程会设置好进程的标准输出1。默认情况下,标准输出设备是 控制台 console 或者是 终端 tty 当然在启动进程前,可以将标准输出重定向管道 (Pipe/Named Pipe, Pipe/FIFO)文件 而在 Unix like 系统中,还可以将输出重定向到 socket 等其他 Unix 文件。在 Windows 上,如果要将 IO 重定向到 socket 需要使用 WSASocket 创建 socket,且不要使用 WSA_FLAG_OVERLAPPED 标志。

输出的设备或者文件存在多样性,对于 CRT 而言,标准输出的实现就要兼顾这些设备,通常来说,操作系统会提供 WriteFile write 这样的 API 或者系统调用支持输出,一般来说,printf 这样的函数也是使用这样的 API 或者系统调用实现。这些函数的输出优先考虑的是本机默认编码,比如 Unix 上,一般都是 UTF-8,对于兼容性大户 Windows 来说,虽然内部编码都是 UTF-16 但是输出到文件时,任然优先选择的是本机代码页,比如在简体中文系统中是,代码页也就是 936。

Printf 的心路历程

在以前我曾经思考过 printf 是如何实现的,很多开发者在开始也有同样的疑惑,在知乎上,就有人提问:printf()等系统库函数是如何实现的? ,在这个问题下,有很多人回复了,有兴趣的用户可以看一下;

在 Unix 的 CRT 中,printf 的调用历程在这篇文章中有详细介绍:Where the printf() Rubber Meets the Road

在 Windows 10 中,新增了 Universal CRT (UCRT) CRT Library FeaturesIntroducing the Universal CRT,与之前的 Visual C++ CRT 有了很大的不同,全部代码使用 C++11 重构,不用疑惑,正是使用 C++11 实现 C Runtime。笔者对 printf 的分析也是基于 ucrt 。

Visual C++ 会将 CRT/C++ STL 源码一同发布(没有构建文件),在 Visual Studio 的安装目录下的 VC\crt\src ,而 UCRT 源码则在 %ProgramFiles(x86)%Windows Kits\10\Source\$BuildVersion\ucrt

在 UCRT 中 printf 是个内联函数,调用了 _vfprintf_l_vfprintf_l 也是内联 的,它调用了 __stdio_common_vfprintf。在 ucrt 源码路径 stdio\output.cpp__stdio_common_vfprintf 调用了模板函数 common_vfprintf ,而 common_vfprintf 则在内部调用了模板类 output_processor ,output_processor 使用了输出适配器模板类 stream_output_adapter

template <typename Character> class stream_output_adapter : public output_adapter_common<Character, stream_output_adapter<Character>> { public: typedef __acrt_stdio_char_traits<Character> char_traits; stream_output_adapter(FILE* const public_stream) throw() : _stream{public_stream} { } bool validate() const throw() { _VALIDATE_RETURN(_stream.valid(), EINVAL, false); return char_traits::validate_stream_is_ansi_if_required(_stream.public_stream()); } bool write_character_without_count_update(Character const c) const throw() { if (_stream.is_string_backed() && _stream->_base == nullptr) { return true; } return char_traits::puttc_nolock(c, _stream.public_stream()) != char_traits::eof; } void write_string( Character const* const string, int const length, int* const count_written, __crt_deferred_errno_cache& status ) const throw() { if (_stream.is_string_backed() && _stream->_base == nullptr) { *count_written += length; return; } write_string_impl(string, length, count_written, status); } private: __crt_stdio_stream _stream; }; 

File stream 的写入流程是 write_strings -> write_string_impl -> write_character -> write_character_without_count_update ,然后是 char_traits::puttc_nolock

#define _CORECRT_GENERATE_FORWARDER(prefix, callconv, name, callee_name) \ __pragma(warning(push)) \ __pragma(warning(disable: 4100)) /* unreferenced formal parameter */ \ template <typename... Params> \ prefix auto callconv name(Params&&... args) throw() -> decltype(callee_name(args...)) \ { \ _BEGIN_SECURE_CRT_DEPRECATION_DISABLE \ return callee_name(args...); \ _END_SECURE_CRT_DEPRECATION_DISABLE \ } \ __pragma(warning(pop)) 

char_traits::puttch_nolock 实际上是通过 __acrt_stdio_char_traits __crt_char_traits 定义的静态成员函数,,printf 对应的是 _fputc_nolock

_fputc_nolockfputc 类似,实际上 fputc 也会调用它,在 _fputc_nolock 中调用了 __acrt_stdio_flush_and_write_narrow_nolock,在源码 stdio/_flsbuf.cpp 中, __acrt_stdio_flush_and_write_narrow_nolock 又调用了 common_flush_and_write_nolock<char>

往下一步走会调用 write_buffer_nolock -> _write ->_write_nolock(lowio/write.cpp)

然后根据不同设备和字符类型,_write_nolock 会调用:

  • Console ANSI write_double_translated_ansi_nolock
  • Console UTF16 write_double_translated_unicode_nolock
  • File ANSI write_text_ansi_nolock
  • File UTF16 write_text_utf16le_nolock
  • File UTF8 write_text_utf8_nolock
  • File Binary write_binary_nolock

最后终究要调用 WriteFile,对于不需要缓冲区的文件读写为什么不直接使用 WriteFile

值得一提的是,在 Windows 中,如果使用 FILE 读写文件,尽量使用 rb wb 之类的标志,显示的指定文件类型是 binary, 否则自动添加 CR 就不好了。

通过源码,我们还知道 UTF-16 或者 UTF-8 一般还是需要转换成 Ansi 才能输出到控制台。

UCRT 还提供了 _cputs _cprintf _cputws _cwprintf 这样的函数,这些函数处理流程类似但要简单的多,output_processor 的输出适配器是 console_output_adapter,无论是字符类型是 wchar_t 还是 char 最终都会调用 _putwch_nolock__dcrt_write_console_w,最后使用 WriteConsoleW 写入到控制台。

_cwprintfwprintf 相比,输出 Unicode 字符要容易的多,不过,在使用标准输出的时候,你不能假定程序一定拥有控制台。

WriteConsole 内部原理

虽然在 Windows/ReactOS 中,CRT 写入到标准输出的使用了 WriteFile ,WriteFile 是如何写到控制台的?

ReactOS 源码中,WriteFile 将检查 hFile 其值是否为 STD_INPUT_HANDLE ,STD_OUTPUT_HANDLE ,STD_ERROR_HANDLE 如果是就从 PEB 中获得对应的控制台句柄,否则使用句柄 hFile 原本的值,然后就判断是否是控制台句柄,如果是控制台,则调用 WriteConsoleA,对于其他类型文件会直接调用 NtWriteFile

STD_*_HANDLE 转换为 Windows 内核对象:

HANDLE TranslateStdHandle(IN HANDLE hHandle) { PRTL_USER_PROCESS_PARAMETERS Ppb = NtCurrentPeb()->ProcessParameters; switch ((ULONG)hHandle) { case STD_INPUT_HANDLE: return Ppb->StandardInput; case STD_OUTPUT_HANDLE: return Ppb->StandardOutput; case STD_ERROR_HANDLE: return Ppb->StandardError; } return hHandle; } 

判断是否是控制台文件:

#define IsConsoleHandle(h) \ (((ULONG_PTR)(h) & 0x10000003) == 0x3) 

或者

BOOL IsConsoleHandle(HANDLE hHandle) { DWORD dwMode; /* Check whether the handle may be that of a console... */ if ((GetFileType(hHandle) & ~FILE_TYPE_REMOTE) != FILE_TYPE_CHAR) return FALSE; /* * It may be. Perform another test... The idea comes from the * MSDN description of the WriteConsole API: * * "WriteConsole fails if it is used with a standard handle * that is redirected to a file. If an application processes * multilingual output that can be redirected, determine whether * the output handle is a console handle (one method is to call * the GetConsoleMode function and check whether it succeeds). * If the handle is a console handle, call WriteConsole. If the * handle is not a console handle, the output is redirected and * you should call WriteFile to perform the I/O." */ return GetConsoleMode(hHandle, &dwMode); } 

有兴趣的可以参阅 ReactOS WriteFile 源码: WriteFile to Console

对于控制台句柄,CloseHandle,ReadFile,CreateFile ,以及 WriteFile 都要单独的调用对象的控制台 API。比如说,如果文件名是 CON, CONOUT$, CONIN$, \\.\CON 时就会使用 OpenConsoleW 打开控制台。然后返回控制台的句柄。

WriteConsoleA 又是如何写入到图形界面呢?在 Windows Technet 有两幅图分别介绍了 Vista 以前的控制台结构和 Windows 7 的控制台架构 Windows 7 / Windows Server 2008 R2: Console Host

在 Windows 7 以前,WriteConsole 通过 LPC 与 CSRSS(Client Server Runtime Process) 通信:

Windows

由于CSRSS 以 Local System 权限运行,这样的逻辑容易导致 Shatter attack,于是在 Windows 7 中出现了新的 Console Host

Windows7OrLater

在这种架构中,WriteConsole LPC 调用将控制台消息发送到了一个 Conhost 宿主进程,这个进程是在 CreateProcess 中自动创建的。

在 ReactOS 中,依然使用的是 CsrCaptureMessageBuffer 将数据发送到 CSRSS。源码在这里: WriteConsole

ReactOS 文档:ReactOS

WriteFile 输出到控制台时,实际调用的是 WriteConsoleA,在前面我们还知道使用 wprintf 时,CRT 会将文本内容转换成 Ansi(Codepage) 然后再使用 WriteFile 写入到控制台窗口。

我们知道,绘制字符的时候,ANSI 文本最终将会转换成 UTF-16LE 文本,然后经 DrawTextExW 或者其他函数绘制出来,如果使用了 wprintf 这样的函数,势必会经过两次转换 UTF16->CodePage->UTF16,在 Unicode 中存在的字符不一定存在于代码页中,这就导致文本在转换编码的时候发生丢失,输出到控制台时,可能是乱码或者干脆截断了。所以在 Windows 控制台中, Unicode 编码,特别是 emoji 还是不要妄想通过 wprintf 输出。

在 Windows 中,内码是 Unicode,而控制台也支持使用 WriteConsoleW 这样的 API 输出文本,如果我们直接使用 WriteConsoleW 就可以避免出现字符无法呈现或者乱码的问题了。如果控制台的图形对各种字体字符支持更好,这个 API 也就能够输出彩色字符或者更多的 Emoji。遗憾的是,目前 Console 字体渲染的改进任然在计划中,暂时不支持 Emoji 和各种特殊字体。

控制台彩色输出

讲了这么长一段,该讲如何实现彩色输出了,首先,我们要知道 Windows 控制台 API 是支持颜色输出的,不过,这些 API 仅支持 16 色输出。 在 .Net Core corefx 有如下一个枚举定义了控制台基本的颜色:

 [Serializable] public enum ConsoleColor { Black = 0, DarkBlue = 1, DarkGreen = 2, DarkCyan = 3, DarkRed = 4, DarkMagenta = 5, DarkYellow = 6, Gray = 7, DarkGray = 8, Blue = 9, Green = 10, Cyan = 11, Red = 12, Magenta = 13, Yellow = 14, White = 15 } 

在 C++ 中,如果在 WriteConsoleW 调用之前使用了 SetConsoleTextAttribute 设置输出格式,那么就能输出带有上述颜色的文本内容了。在 Privexec.Console 中,使用控制台 API 输出颜色代码如下:

int WriteConhost(int color, const wchar_t *data, size_t len) { CONSOLE_SCREEN_BUFFER_INFO csbi; auto hConsole = GetStdHandle(STD_OUTPUT_HANDLE); GetConsoleScreenBufferInfo(hConsole, &csbi); WORD oldColor = csbi.wAttributes; WORD color_ = static_cast<WORD>(color); WORD newColor; if (color > console::fc::White) { newColor = (oldColor & 0x0F) | color_; } else { newColor = (oldColor & 0xF0) | color_; } SetConsoleTextAttribute(hConsole, newColor); DWORD dwWrite; WriteConsoleW(hConsole, data, (DWORD)len, &dwWrite, nullptr); SetConsoleTextAttribute(hConsole, oldColor); return static_cast<int>(dwWrite); } 

在这里,我们选择的是 STD_OUTPUT_HANDLE,使用 &0x0F 或者 &0xF0 的目的是不修改原有的背景色或者前景色,第二次调用 SetConsoleTextAttribute 的目的是恢复控制台原有的颜色。

终端模拟器彩色输出

在 Windows 上,还有 Cygwin 和 MSYS2 MSYS 这样的模拟 Unix 的环境。wsudo 对其支持也非常有必要。 这些环境启动进程往往是通过管道通信,这个时候,我们可以判断是否是终端还是控制台。

bool IsWindowsConhost(HANDLE hConsole, bool &isvt) { if (GetFileType(hConsole) != FILE_TYPE_CHAR) { return false; } DWORD mode; if (!GetConsoleMode(hConsole, &mode)) { return false; } if ((mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0) { isvt = true; } return true; } 

如果使用 GetFileType(hConsole) 得到的文件类型不是 FILE_TYPE_CHAR 我们就可以确定不是控制台,并且如果不支持 GetConsoleMode 函数,也要视其不是控制台。

核心代码如下:

namespace console { std::string wchar2utf8(const wchar_t *buf, size_t len) { std::string str; auto N = WideCharToMultiByte(CP_UTF8, 0, buf, (int)len, nullptr, 0, nullptr, nullptr); str.resize(N); WideCharToMultiByte(CP_UTF8, 0, buf, (int)len, &str[0], N, nullptr, nullptr); return str; } struct TerminalsColorTable { int index; bool blod; }; namespace vt { namespace fg { enum Color { Black = 30, Red = 31, Green = 32, Yellow = 33, Blue = 34, Magenta = 35, Cyan = 36, Gray = 37, Reset = 39 }; } namespace bg { enum Color { Black = 40, Red = 41, Green = 42, Yellow = 43, Blue = 44, Magenta = 45, Cyan = 46, Gray = 47, Reset = 49 }; } } bool TerminalsConvertColor(int color, TerminalsColorTable &co) { std::unordered_map<int, TerminalsColorTable> tables = { {console::fc::Black, {vt::fg::Black, false}}, {console::fc::DarkBlue, {vt::fg::Blue, false}}, {console::fc::DarkGreen, {vt::fg::Green, false}}, {console::fc::DarkCyan, {vt::fg::Cyan, false}}, {console::fc::DarkRed, {vt::fg::Red, false}}, {console::fc::DarkMagenta, {vt::fg::Magenta, false}}, {console::fc::DarkYellow, {vt::fg::Yellow, false}}, {console::fc::DarkGray, {vt::fg::Gray, false}}, {console::fc::Blue, {vt::fg::Blue, true}}, {console::fc::Green, {vt::fg::Green, true}}, {console::fc::Cyan, {vt::fg::Cyan, true}}, {console::fc::Red, {vt::fg::Red, true}}, {console::fc::Magenta, {vt::fg::Magenta, true}}, {console::fc::Yellow, {vt::fg::Yellow, true}}, {console::fc::White, {vt::fg::Gray, true}}, {console::bc::Black, {vt::bg::Black, false}}, {console::bc::Blue, {vt::bg::Blue, false}}, {console::bc::Green, {vt::bg::Green, false}}, {console::bc::Cyan, {vt::bg::Cyan, false}}, {console::bc::Red, {vt::bg::Red, false}}, {console::bc::Magenta, {vt::bg::Magenta, false}}, {console::bc::Yellow, {vt::bg::Yellow, false}}, {console::bc::DarkGray, {vt::bg::Gray, false}}, {console::bc::LightBlue, {vt::bg::Blue, true}}, {console::bc::LightGreen, {vt::bg::Green, true}}, {console::bc::LightCyan, {vt::bg::Cyan, true}}, {console::bc::LightRed, {vt::fg::Red, true}}, {console::bc::LightMagenta, {vt::bg::Magenta, true}}, {console::bc::LightYellow, {vt::bg::Yellow, true}}, {console::bc::LightWhite, {vt::bg::Gray, true}}, }; auto iter = tables.find(color); if (iter == tables.end()) { return false; } co = iter->second; return true; } int WriteTerminals(int color, const wchar_t *data, size_t len) { TerminalsColorTable co; auto str = wchar2utf8(data, len); if (!TerminalsConvertColor(color, co)) { return static_cast<int>(fwrite(str.data(), 1, str.size(), stdout)); } if (co.blod) { fprintf(stdout, "\33[1;%dm", co.index); } else { fprintf(stdout, "\33[%dm", co.index); } auto l = fwrite(str.data(), 1, str.size(), stdout); fwrite("\33[0m", 1, sizeof("\33[0m") - 1, stdout); return static_cast<int>(l); } 

这些终端环境基本上将文本视为 UTF8 编码,为了让文字正常显示,我们需要将其转换为 UTF8。经过测试,中文都能正常显示。

VT 模式颜色输出

在 Windows 10 中,新增了 Windows Subsystem for Linux ,可以通过 Bash 命令启动终端运行 Linux 程序,Windows 控制台还增加了 VT 模式 Console Virtual Terminal Sequences,并且支持24-Bit 颜色:24-bit Color in the Windows Console!,这意味着,可以像 Linux 一样在 printf 中添加转义字符控制颜色输出。 在 Github 中也有 Issues 讨论: support 256 color 笔者在开发时发现 WriteConsoleW 也支持 VT 模式,对于也添加了代码支持 VT 模式:

int WriteConsoleInternal(const wchar_t *buffer, size_t len) { DWORD dwWrite = 0; auto hConsole = GetStdHandle(STD_OUTPUT_HANDLE); if (WriteConsoleW(hConsole, buffer, (DWORD)len, &dwWrite, nullptr)) { return static_cast<int>(dwWrite); } return 0; } int WriteVTConsole(int color, const wchar_t *data, size_t len) { TerminalsColorTable co; if (!TerminalsConvertColor(color, co)) { return WriteConsoleInternal(data, len); } std::wstring buf(L"\x1b["); if (co.blod) { buf.append(L"1;").append(std::to_wstring(co.index)).push_back(L'm'); } else { buf.append(std::to_wstring(co.index)).push_back(L'm'); } WriteConsoleInternal(buf.data(), (DWORD)buf.size()); auto N = WriteConsoleInternal(data, (DWORD)len); WriteConsoleInternal(L"\x1b[0m", (sizeof("\x1b[0m") - 1)); return static_cast<int>(N); } template <typename... Args> int PrintConsole(const wchar_t *format, Args... args) { std::wstring buffer; size_t size = StringPrint(nullptr, 0, format, args...); buffer.resize(size); size = StringPrint(&buffer[0], buffer.size() + 1, format, args...); return WriteConsoleInternal(buffer.data(), size); } 

由于 VT 模式支持 256 色,这里还增加了 PrintConsole 模板函数,支持用户自定义输出多一些色彩。

输出函数自动选择

在不考虑 freopen 这样的重新设置标准输出输出的情况下,Privexec.Console 使用如下代码支持自动选择不同的输出函数

class ConsoleInternal { public: ConsoleInternal() { HANDLE hConsole = GetStdHandle(STD_OUTPUT_HANDLE); if (hConsole == INVALID_HANDLE_VALUE) { impl = WriteFiles; return; } if (GetFileType(hConsole) == FILE_TYPE_DISK) { impl = WriteFiles; return; } bool isvt = false; if (IsWindowsConhost(hConsole, isvt)) { if (isvt) { impl = WriteVTConsole; return; } impl = WriteConhost; return; } impl = WriteTerminals; } int WriteRealize(int color, const wchar_t *data, size_t len) { return this->impl(color, data, len); } private: int (*impl)(int color, const wchar_t *data, size_t len); }; int WriteInternal(int color, const wchar_t *buf, size_t len) { static ConsoleInternal provider; return provider.WriteRealize(color, buf, len); } 

使用 console::Print 不用担心乱码和颜色问题,在这几个主流的环境中都能正常显示(包括 ConEmu)。Print 使用的完全是 wchar_t。

其他

很欣慰的是 Windows 控制台团队在 Windows 10 开发之处就在不断改进控制台: 比如 Console Improvements in the Windows 10 Technical Preview

还有计划中的 Emoji 支持: Add emoji support to Windows Console

以及基于 DirectWrite 改进控制台字体渲染的计划: UTF-8 rendering woes

当然 ConEmu 也有计划使用 DirectWrite 改进其渲染。

不过遗憾的是,Mintty 的开发者并不认为有使用 DirectWrite 改进渲染的必要。

基于 Rust 的跨平台 GPU 终端 Alacritty - A cross-platform, GPU-accelerated terminal emulator 也计划在 1.0 时对 Windows 提供支持,字体渲染也有 DirectWrite 的身影。

Privexec.Console 官方并不会支持 Windows 10 以前的版本,毕竟作者精力有限。

备注

  1. 父进程未显式设置标准输入输出和标准错误时,子进程会继承父进程的值,在 Windows 中,GUI 程序的标准输入输出和 Unix 下重定向到 /dev/null 类似,但启动的 CUI 子进程默认下依然有控制台窗口

I feedback.

Let me know what you think of this article on twitter @sinopre!







via Charlie's Rethinking

October 25, 2023 at 05:23PM
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant