诗盗·大禹

《诗盗·大禹》:治水为命顺逆同,浩然莫测鬼神工。三过涂山生悟空,定海神针串鱼龙。

注解

改自霹雳角色“渊渟无迹静涛君”的诗号:

知水为命顺逆同,
浩然莫测深浅中。
无波沧海掩汹涌,
渊渟不动现鱼龙。

指针判空

引子

C 和 C++ 都有一些很基本的语句出现不同派系的写法,比如 * 靠左还是靠右,抑或居中?这种还是排版问题,并不影响有效字符数,但下面这个就直接影响有效字符数了!

《对于选择恐惧症患者来说,指针判空究竟要怎么写才不纠结?》

问题

指针判空有两大类写法:

  • if (p) {}

  • if (nullptr != p) {}

后者被挺多人推荐的,比如林锐的《高质量C++/C编程指南》。

高质量C++/C编程指南

搜一下,能发现不少网友都挺纠结。这么基础的问题,如果不交代清楚,就是给 C++ 黑很好的攻击理由。

故事

稣刚大学毕业时,是使用 C,并不屑 C++ 的,当时看了不少代码,就是直接 if (p)if (!p),所以也坚持这种写法,并认为这样写比较短,对手指好,比较养生。

后来 C++17 出现,稣就坚定地改用 C++,于是也更多遵守 C++ 类型安全的原则,开始认为 if (p)if (!p) 这种写法隐含类型转换,不太好。记得 2017 年以前,其实就看过 Google C++ Style Guide,里面也曾经建议写成 if (nullptr != p)if (nullptr == p)

现在,再去看 Google C++ Style Guide,已经没有这样的建议。

解决

C++ Core Guidelines 中有一条:ES.87: Don’t add redundant == or != to conditions,它的理由是:

Reason Doing so avoids verbosity and eliminates some opportunities for mistakes. Helps make style consistent and conventional.

if、while、for 的条件语句本身就是在选择 true 或 false,对于指针来说,会自动与 nullptr 比较。通常来说,if (p) 可以读作 “if p is valid”。

接着,还有一个例子:

1
2
3
if (auto pc = dynamic_cast<Circle>(ps)) { ... } // execute if ps points to a kind of Circle, good

if (auto pc = dynamic_cast<Circle>(ps); pc != nullptr) { ... } // not recommended

同样是推荐更简短的写法。但这个例子却有人提出疑问:推荐的写法不会被怀疑是 == 少写了一个 = 吗?

还真不会!这个语句其实不是经典 C++ 的语法,而是 C++17 的 init-statement 语法,这里的 auto 是类型,说明 = 不可能是 == 错误地少写成 =。

对于部分 C 程序员和“经典 C++”程序员会把赋值语句写在 if 条件里的做法,建议是改为分开写。

1
2
3
4
5
6
7
8
// p is defined before

if (p = func()) { ... } // bad

p = func();
if (p) { ... } // good

// p is used after

稣会在新工程里坚持使用 C++ Core Guidelines 的建议,但也不反对另一种写法,只要不混合使用。允许有不同派系,但最好别精神分裂。

BoringSSL? LibreSSL? OpenSSL!

问题

  • Windows 自带 OpenSSH 用的是 LibreSSL。要不要也用一下?甚至,直接动态链接到系统自带的 LibreSSL 的 dll,减少 exe 体积!

  • Chrome 用的是 BoringSSL,要不要……折腾一下?

尝试

稣使用 vcpkg,安装几个库,还不是手到擒来?

结果……连装都不让装!这是 LibreSSL 的:

1
2
3
4
5
Building libressl:x64-windows...
CMake Warning at ports/libressl/portfile.cmake:2 (message):
Can't build libressl if openssl is installed. Please remove openssl, and
try install libressl again if you need it. Build will continue since
libressl is a subset of openssl

这是 BoringSSL 的:

1
2
3
4
5
Building boringssl:x64-windows...
CMake Error at ports/boringssl/portfile.cmake:2 (message):
Can't build BoringSSL if OpenSSL is installed. Please remove OpenSSL, and
try to install BoringSSL again if you need it. Build will continue since
BoringSSL is a drop-in replacement for OpenSSL

在忍痛 vcpkg remove openssl 后,陷入一阵思考——稣主要通过 Boost.Asio 使用 SSL,所以问题转换为:Boost.Asio 对这几个 SSL 库支持得如何?其中,OpenSSL 是使用多年没有任何问题的,只需要调研其它两个!

先试试 LibreSSL,毕竟是 OpenBSD 的,而且微软也用它。拿 ClipboardSync 代码编译,发现顺利通过!但是运行时抛了异常,说不支持 TLS 1.3……遂查阅官网,说是从 3.2.0 就支持!那就是 Boost.Asio 不对了,果断给它提 issue

接着尝试 BoringSSL,毕竟是 Google 的,号称重视安全,而且有 Chrome 这个大型流行软件做背书!然而很打脸的是:它居然不支持 SM3!稣当年特地选择用国密标准里的 SM3 做 Hash 算法,就是因为爱国!不支持国密这点岂能忍?立刻 vcpkg remove boringssl

结论

Boost.Asio 和稣联合推荐 OpenSSL 为唯一好用又爱国的 SSL 库。

Boost【9】Boost.Beast

介绍

Boost.Beast 是一个 HTTP/WebSocket 库。本文只讨论 WebSocket。

相关经验

UMU 先是用过 WebSocket++(websocketpp),又用过 libwebsockets,再用的 Boost.Beast,之后就一直使用 Boost.Beast。

  • 2018 年,参与 EOS 开发时,它是用 WebSocket++ 的,跟着学习了一阵子。

  • 2020 年,在金山云时,内部版云游戏用 libwebsockets,跟着学习了一阵子。

  • 做开源版云游戏——鎏光云游戏引擎时,特地学习并使用 Boost.Beast。因为公司其实是要求和内部版有一些差异的,正好之前一直用 Boost.Asio,对它的熟悉可以快速套用在 Boost.Beast,于是果断切到 Boost.Beast。

什么场景应该使用 WebSocket?

如果您原本使用裸 TCP,您应该知道 TCP 的流式传输,导致您需要自己分包,即界定一个“消息”的边界。鎏光本来设计为同时支持裸 TCP 和 WebSocket 的,所以还保留着处理分包的代码:

https://github.com/UMU618/liuguang/blob/97f558275571b9be14893e4b55703b4b65cdbda5/src/cge/cge/game_session.cpp#L232

这对 WebSocket 其实并不需要,它的发送和接受已经是都是“消息”,带着长度的。所以如果您原来用 Asio 写 C/S 程序,把它们改为 Beast,是很容易的,而且代码量会缩减不少。

对于工具性的 C/S 程序,建议下次直接用 Beast 写更省事。

另一种适用场景是需要支持浏览器,即同时支持 C/S 和 B/S 模型。

Boost.Beast 经验

  1. text 和 binary 模式需要区分清楚,如果用于发送音视频,显然应该使用 binary 模式。

  2. stream 的默认接收长度是 16MiB,如果不够可以改长点,0 表示最大的 std::uint64_t。

参考:boost/beast/websocket/stream.hpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/** Set the maximum incoming message size option.

Sets the largest permissible incoming message size. Message
frame fields indicating a size that would bring the total
message size over this limit will cause a protocol failure.

The default setting is 16 megabytes. A value of zero indicates
a limit of the maximum value of a `std::uint64_t`.

@par Example
Setting the maximum read message size.
@code
ws.read_message_max(65536);
@endcode

@param amount The limit on the size of incoming messages.
*/
void
read_message_max(std::size_t amount);

/// Returns the maximum incoming message size setting.
std::size_t
read_message_max() const;
  1. 默认开了 deflate 压缩。UMU 在开发 ClipboardSync 时,曾经考虑给剪切板数据压缩,对比了 lz4 和 zstd,很是犹豫,后来发现 Beast 默认开了 deflate 压缩,于是放弃自己用 lz4 或 zstd 压缩。

自动添加 IP 黑名单到防火墙

问题

前文《防爆破远程桌面密码》提到可以用 Powershell 实现自动加 IP 黑名单到防火墙,这个坑还是得填,毕竟爆破依然在持续……

分析

核心点:

  • IP 黑名单会持续新增,过去已经加入防火墙的名单也需要保存。所以应该把新增 IP 和防火墙已有 IP 求并集。

  • 如果名单没变,不应该覆盖防火墙规则。

代码

见 Github 仓库:

https://github.com/UMU618/scripts/blob/master/pwsh/add-ip-blacklist-to-firewall.ps1

防爆破远程桌面密码

问题

面向公网开了个远程桌面端口,无论用多少号端口,都会被爆破!

虽然稣的密码很安全,几乎爆破不了,但会在系统日志里留下大量记录,实在很不雅观!

分析

改端口是没用的,因为只有 65535 个,只要机器 IP 被发现,扫描端口很快就能完成。

封 IP 是有用的,虽然爆破者(攻击者)有很多 IP,但一定是有限的,有多少封多少!

解决

  1. 从系统日志里分析爆破者 IP

打算使用 PowerShell 7 来编写脚本,首先学习 Get-WinEvent 命令:

1
Get-Help Get-WinEvent -Online

日志的过滤条件可以用“事件查看器”来协助生成:

WinEvent UI
WinEvent XML

当然,以上全部 XML 是 -FilterXml 的参数,比较长,可以用 -FilterXPath 来简化,只需要中间一部分。

条件还可以再加上 LogonType,以缩小范围,其中 3 表示“网络登录”:

1
Get-WinEvent -LogName 'Security' -FilterXPath '*[System[EventID=4625] and EventData[Data[@Name="LogonType"]=3]]' -MaxEvents 1 | Format-List Message

以下是完整代码,它会打印出 IP,和这个 IP 的登录失败次数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ips = @{}
Get-WinEvent -LogName Security -FilterXPath '*[System[band(Keywords,4503599627370496)] and EventData[Data[@Name="LogonType"]=3]]' | %{
$xml = [xml]$_.toXml()
foreach ($data in $xml.Event.EventData.Data) {
if ($data.Name -eq 'IpAddress') {
$ip = $data.'#text'
if ($ips.ContainsKey($ip)) {
++$ips[$ip]
} else {
$ips.Add($ip, 1)
'Fisrt find ' + $ip + ' on ' + $_.TimeCreated
}
}
}
}
Write-Output 'All IPs:', $ips
  1. 把爆破者 IP 加入防火墙,阻止它们

目前收集到这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
112.184.96.197
118.45.92.239
122.38.193.166
138.199.21.245
14.116.196.99
14.49.207.162
141.98.11.119
141.98.11.58
147.78.47.57
148.113.4.245
176.111.174.173
176.111.174.174
179.60.147.13
182.180.92.224
185.161.248.145
188.250.64.50
222.191.242.228
45.143.201.62
58.221.4.54
58.33.52.84
62.122.184.88
67.159.237.58
77.90.185.132
78.128.114.18
89.248.163.94
89.248.163.95
91.191.209.202
91.240.118.187
91.240.118.29

打开 wf.msc,新建一个阻止型的防火墙策略,然后加入到“作用域”的“远程 IP 地址”里。

Firewall

加防火墙也可以用 PowerShell 搞定,这次先偷个懒,下次再说吧!

函数名首字母用大写还是小写?

问题

不同编码规范对函数名的命名格式有不同要求,但主流有以下几类:

哪种适合 Windows 开发呢?

分析

1. PascalCase 的坑

PascalCase 偶尔会遇到和 Win32 API 宏冲突的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <Windows.h>

#include <iostream>

namespace umutech {

int CreateWindow() noexcept {
MessageBox(nullptr, L"For disassembling", L"CreateWindow", MB_OK);
return 0;
}

} // namespace umutech

int main() {
umutech::CreateWindow();
}

以上代码无法编译,因为 SDK 头文件里有这样的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define CreateWindowA(lpClassName, lpWindowName, dwStyle, x, y,\
nWidth, nHeight, hWndParent, hMenu, hInstance, lpParam)\
CreateWindowExA(0L, lpClassName, lpWindowName, dwStyle, x, y,\
nWidth, nHeight, hWndParent, hMenu, hInstance, lpParam)
#define CreateWindowW(lpClassName, lpWindowName, dwStyle, x, y,\
nWidth, nHeight, hWndParent, hMenu, hInstance, lpParam)\
CreateWindowExW(0L, lpClassName, lpWindowName, dwStyle, x, y,\
nWidth, nHeight, hWndParent, hMenu, hInstance, lpParam)
#ifdef UNICODE
#define CreateWindow CreateWindowW
#else
#define CreateWindow CreateWindowA
#endif // !UNICODE

导致以下编译错误:

1
2
3
4
5
6
1>function_name.cpp(7,5): warning C4003: not enough arguments for function-like macro invocation 'CreateWindowW'
1>function_name.cpp(7,5): error C2059: syntax error: ','
1>function_name.cpp(7,29): error C2143: syntax error: missing ';' before '{'
1>function_name.cpp(7,29): error C2447: '{': missing function header (old-style formal list?)
1>function_name.cpp(15,12): warning C4003: not enough arguments for function-like macro invocation 'CreateWindowW'
1>function_name.cpp(15,12): error C2059: syntax error: ','

改成下面这样,才能编译:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <Windows.h>

#include <iostream>

namespace umutech {

int CreateWindowEx() noexcept {
MessageBox(nullptr, L"For disassembling", L"CreateWindowEx", MB_OK);
return 0;
}

} // namespace umutech

int main() {
umutech::CreateWindowEx();
}

以上代码虽然编译通过,但实际上 CreateWindowEx 还是个宏。用 IDA 逆向编译后的 exe,并加载 pdb 后,可以看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
; int __cdecl main(int argc, const char **argv, const char **envp)
main proc near
sub rsp, 28h

; int __fastcall umutech::CreateWindowExW()
umutech__CreateWindowExW: ; uType
xor r9d, r9d
lea r8, Caption ; "CreateWindowEx"
lea rdx, Text ; "For disassembling"
xor ecx, ecx ; hWnd
call cs:__imp_MessageBoxW
xor eax, eax
add rsp, 28h
retn
main endp

虽然没啥危害,但 C++ 20 程序员不喜欢宏!

2. camelCase 是不是更好?

如果函数名使用 camelCase,则没有机会与 Win32 API 的宏定义冲突。

另一个好处是在做 API Hooking 时,命名可以更短。比如 Hook ShowWindow,那么替代函数可以就叫 showWindow,而用 PascalCase,则可能需要叫 MyShowWindow。

那么是不是把 Google C++ Style 的函数名由 PascalCase 改为 camelCase 就完美了?

更好,并不是完美……camelCase 也有个小问题——只有一个单词时,无法区分是 camelCase,还是 snake_case。比如 size,是函数(camelCase),还是临时变量(snake_case)?

总结

UMU 建议,如果已经在使用 Google C++ Style,应该避免函数名与 Win32 API 一样。如果正在从头制定一套 Coding Style,则可以考虑函数用 camelCase。

解决运行 bcdedit 后 BitLocker 提示输入恢复密钥

问题

因为种种原因……需要调试本机内核,用 bcdedit 开启调试模式:

1
bcdedit -debug ON

结果重启后,BitLocker 提示输入恢复密钥!

分析

首先,要强调——调试本机内核,本身就是一个很危险的操作!建议还是用虚拟机调试。

其次,您一定已经备份了 BitLocker 的恢复密钥。只是它不一定在身边,比如说放在家里,人在公司,一来一回需要很长时间,所以得想办法节省时间。

最后,不要被 BitLocker 吓倒!即使,您没有备份恢复密钥,也还有救!在这个界面按 ESC,再点“跳过此驱动器”,后面是可以进入“控制台”的,只需要在“控制台”里撤销操作即可!

解决

尝试关闭调试模式:

1
bcdedit -debug OFF

先别急着重启,因为这么敲——无效!不信您可以重启后再运行 bcdedit,会发现这个 debug 选项还是 Yes。

看来在 WindowsRE 环境下,直接运行 bcdedit,并不能修改 C 盘里的启动选项。您需要加上个 ID,一般为 {default}。

1
bcdedit -debug {default} OFF

先别急着重启,因为这么敲——还是无效!原本没有 debug 这个值,现在多了一个,数据是 No 而已,因为默认值就是 No,看似没有改变“调试模式”,但其实 BCD 数据库是变了的。正确的做法是删除这个 debug 值:

1
bcdedit -deletevalue {default} debug

重启后不再要求输入恢复密钥。这时可以在 BitLocker 的控制面板里先暂停保护,然后再操作 BCD,即可。

为什么应该使用 C++ 20?

实战项目

  1. 鎏光云游戏引擎

  2. 金山云 LiveNet

  3. ClipboardSync

  4. PowerEconomizer

其中,鎏光一开始是用 C++ 17 的,在 MSVC 的 std::format 可用时,第一时间切换到 C++ 20。而 LiveNet 主要运行于 Linux,开发时 gcc 还不支持 std::format,使用 fmt::format 代替,但一直用 cxxstd=20 编译。

ClipboardSync 和 PowerEconomizer 使用了 C++ 20 modules。

现在还在开发的云桌面产品也是使用 C++ 20,但没有用 modules。

理由

根据 jetbrains 的统计,2022 年时,C++ 20 的使用率是 23%,已经超过经典 C++ 的 8%。在游戏开发领域,C++ 20 的使用率为 25%,甚至已经超过 C++ 14 的 24%。

以下列举能够很容易想到的一些好处:

  1. 好用、安全的新类:std::format、std::span、std::jthread、原子(Atomic)智能指针

  2. designated-initializers 安全初始化,防止因为调整结构体而顺序不对

  3. modules 加速编译

  4. 更多标签 [[likely]], [[unlikely]], [[nodiscard(reason)]]

  5. 可以 using enum

  6. 对模板形式的 Lambda 有更好支持

  7. 范围 for 循环支持初始化

  8. 三路比较运算符 <=>

  9. Boost 的 awaitables 协程(BOOST_ASIO_HAS_CO_AWAIT)需要 C++ 20

  10. ranges 库

阵痛

  1. 曾经遇到 clang-format 对 modules 支持不好的问题,后来升级 clang-format 解决。但目前还不建议在大型项目里使用 modules。

  2. 有些隐式转换无法编译,尤其在编译驱动代码时,容易遇到连 WDK 里的头文件都无法编译。这是因为 C++ 20 比 C++ 17 都严格。建议内核态驱动使用 C++ 17;用户态驱动可以 C++ 20。

无银第八哥【2】修改 API 返回结果

需求

想修改 API 的返回结果。举个例子,想把前文提到的 Windows Release 信息改为 “0618”。

winver

分析

从技术角度看,需求就是在 RegQueryValueExW 返回时,改 lpData 指向的内存。

1
2
3
4
5
6
7
8
LSTATUS RegQueryValueExW(
[in] HKEY hKey,
[in, optional] LPCWSTR lpValueName,
LPDWORD lpReserved,
[out, optional] LPDWORD lpType,
[out, optional] LPBYTE lpData,
[in, out, optional] LPDWORD lpcbData
);

lpData 为第 5 个参数,根据《x64 软件约定》,它位于栈。具体来说,断点时,rip 处于 call 指令执行完毕的时间点,这时调用方的返回地址已经被压入栈里,所以此时的栈顶为返回地址。按照内存地址递增方向,返回地址后面就是 6 个参数。

实现

  1. 按照前文方法,断到 DisplayVersion 的读取:
1
2
3
DisplayVersion
KERNELBASE!RegQueryValueExW:
00007ffc`2bd79ff0 48895c2410 mov qword ptr [rsp+10h],rbx ss:00000073`b6cfe618=00007ffc2e6731dc
  1. dq rsp L7 显示返回地址和 6 个参数:
1
2
3
4
5
0:000> dq rsp L7
000000d7`e975e7e8 00007ffc`2e53e6bd 00007ffc`2d600000
000000d7`e975e7f8 00007ffc`2e6731dc 00000268`e4a4a6a0
000000d7`e975e808 00000000`0000003c 00000268`e4a96d10
000000d7`e975e818 000000d7`e975e820

其中,00000268`e4a96d10 对应 lpData,000000d7`e975e820 对于 lpcbData。

  1. 可选地,查看 lpcbData 指向的值,发现它是 0x100:
1
2
0:000> dd 000000d7`e975e820 L1
000000d7`e975e820 00000100
  1. 输入 gu 运行到函数返回,再查看 lpData 的内容:
1
2
3
4
5
0:000> gu
shcore!_SHRegQueryValueW+0x8d:
00007ffc`2e53e6bd 0f1f440000 nop dword ptr [rax+rax]
0:000> du 00000268`e4a96d10
00000268`e4a96d10 "22H2"
  1. 用 eu 指令修改 lpData 的内容:
1
eu 00000268`e4a96d10 "0618"
  1. 输入 g,再继续运行,即可大功告成。

埋前文的坑

  1. 前文“实现”节的第 5 步,bm KERNELBASE!RegQueryValueExW 断不下来?

您可能是在 Windows 10 下实践才遇到这个问题。可以用 x KERNELBASE!RegQueryValueExW* 看看是不是有多个。一般来说,即使有多个,无银第八哥也能都断,用 bl 可以看到多个都被加入。万一多个函数的类型不一样,可能就只加了一个 void 类型的,可以直接用地址指定另外类型的(没有被添加的)。

Windows 11 是这样:

1
2
3
4
5
6
0:000> x KERNELBASE!RegQueryValueExW*
00007ffc`2bd79ff0 KERNELBASE!RegQueryValueExW (void)
00007ffc`2beb2090 KERNELBASE!RegQueryValueExW (void)
0:000> bl
1 e Disable Clear 00007ffc`2bd79ff0 0001 (0001) 0:**** KERNELBASE!RegQueryValueExW "$$<C:\\devel\\windbg\\RegQueryValueExW.txt"
2 e Disable Clear 00007ffc`2beb2090 0001 (0001) 0:**** KERNELBASE!RegQueryValueExW "$$<C:\\devel\\windbg\\RegQueryValueExW.txt"

Windows 10 可能是这样:

1
2
3
0:000> x kernelbase!RegQueryValueExW*
00007ff8`2a400a20 KERNELBASE!RegQueryValueExW (void)
00007ff8`2a303700 KERNELBASE!RegQueryValueExW (RegQueryValueExW)
  1. KERNELBASE!RegQueryValueExW 返回后的指令怪怪的?
1
2
3
0:000> gu
shcore!_SHRegQueryValueW+0x8d:
00007ffc`2e53e6bd 0f1f440000 nop dword ptr [rax+rax]

前一条的 call qword ptr [shcore!_imp_RegQueryValueExW (00007ffc2e5e5c90)]` 指令是 7 字节的,如果为了对齐也不应该补 5 字节呀。

1
2
3
4
5
6
7
8
9
10
0:000> u 00007ffc`2e53e6b6
shcore!_SHRegQueryValueW+0x86:
00007ffc`2e53e6b6 48ff15d3750a00 call qword ptr [shcore!_imp_RegQueryValueExW (00007ffc`2e5e5c90)]
00007ffc`2e53e6bd 0f1f440000 nop dword ptr [rax+rax]
00007ffc`2e53e6c2 8bd8 mov ebx,eax
00007ffc`2e53e6c4 85c0 test eax,eax
00007ffc`2e53e6c6 755a jne shcore!_SHRegQueryValueW+0xf2 (00007ffc`2e53e722)
00007ffc`2e53e6c8 8b55b8 mov edx,dword ptr [rbp-48h]
00007ffc`2e53e6cb 83fa01 cmp edx,1
00007ffc`2e53e6ce 0f85b7000000 jne shcore!_SHRegQueryValueW+0x15b (00007ffc`2e53e78b)

看!后面的指令是从 00007ffc`2e53e6c2 开始的,也没有对齐。所以,如果不是为了对齐,那就可能是为了方便调试时在函数返回时加 int 3 了。如果有新的答案,将在本系列后续文章分享。