PAYATHON 2026

如何处理 ntoskrnl 中 _snwprintf 的实现限制?

排障赵师傅

结论

可以在日志层创建一个临时 UNICODE_STRING,将 MaximumLength 设为 Length,再交给 %wZ。把这几行封装成辅助函数,通常改动最少:既不用逐个修改格式字符串,也不用复制字符串内容。

使用前必须确认,Buffer 指向的至少 Length 字节可以安全读取。修改描述符只能绕过长度字段不一致的问题,无法修复无效指针或错误的 Length。

如果日志不需要保留字符串中间的 L'\0',也可以使用 %.*ls。日志统一使用普通宽字符串格式时,将这种处理集中封装也很直接。

为什么会返回 -1

UNICODE_STRING 的两个长度字段都以字节为单位:

  • Length 表示有效内容的长度,不包含末尾的空字符。
  • MaximumLength 表示缓冲区容量,正常情况下应满足 Length <= MaximumLength。

题目中的描述符将 Length 设为 10,MaximumLength 却仍为 0,两个字段不一致。从给出的运行结果看,所调用的 NT 实现拒绝了这种输入,格式化在 %wZ 处中断;示例中的 CRT 实现则接受了它。

这里不能简单地把 -1 归因于输出缓冲区不足:Hello, world! 共 13 个宽字符,16 个元素的数组足以容纳结果和结束符。

复现代码实际调用的是 ntdll.dll 的导出函数。这个结果说明该环境下两种实现存在差异,但不能据此认定所有 Windows 版本的 ntoskrnl 都有相同的行为。

保留 %wZ:封装一个只供日志使用的描述符

辅助函数只读取源对象的 Length 和 Buffer,不读取可能未初始化的 MaximumLength:

static UNICODE_STRING
UnicodeStringForLog(PCUNICODE_STRING source)
{
    UNICODE_STRING view;

    view.Length = source->Length;
    view.MaximumLength = source->Length;
    view.Buffer = source->Buffer;

    return view;
}

确认 pString 指向的描述符可安全读取、字符串内容有效后,可以这样调用:

UNICODE_STRING logString = UnicodeStringForLog(pString);
WCHAR output[64] = { 0 };

int result = _snwprintf(
    output,
    RTL_NUMBER_OF(output),
    L"Hello, %wZ!",
    &logString
);

output[RTL_NUMBER_OF(output) - 1] = L'\0';

这段代码只创建了一个局部描述符,Buffer 仍指向原来的内容。它不会分配内存、复制字符或修改原始参数。

MaximumLength = Length 已经足够。UNICODE_STRING 不要求内容以 L'\0' 结尾;没有额外存储空间时,不应为了补上结束符而把容量写成 Length + sizeof(WCHAR)。

这个临时描述符仅用于当前这次只读格式化,其中的容量值不代表原缓冲区的真实容量,因此不适合传给会追加或修改字符串的函数。

改用 %.*ls:直接限制读取字符数

如果可以调整格式字符串,可以用 %.*ls 避开 MaximumLength:

int count = (int)(pString->Length / sizeof(WCHAR));
PCWSTR text = count != 0 ? pString->Buffer : L"";

WCHAR output[64] = { 0 };

int result = _snwprintf(
    output,
    RTL_NUMBER_OF(output),
    L"Hello, %.*ls!",
    count,
    text
);

output[RTL_NUMBER_OF(output) - 1] = L'\0';

使用时需要注意:

  • .* 对应的可变参数必须是 int,所以需要显式转换。
  • 精度表示宽字符数,必须把字节长度除以 sizeof(WCHAR)。
  • %.*ls 最多读取指定数量的宽字符,遇到 L'\0' 会提前停止,因此与按长度处理的 %wZ 不完全等价。

调用位置较多时,可以将这段处理封装到日志辅助函数中。如果用宏展开参数,要留意重复求值,避免传入带副作用的表达式。

在 hook 中使用时的注意事项

在题目描述的 LdrQueryImageFileExecutionOptions() 调用路径中,下游函数成功处理了未正确填写 MaximumLength 的描述符,只能说明该次调用没有因此失败。日志格式化函数仍可能检查这个字段,因此应在日志边界做适配,不要为了记录日志直接改写被拦截函数的入参。

实际使用时还需注意:

  • Length 应是 sizeof(WCHAR) 的整数倍;非空内容必须有有效的 Buffer。
  • 描述符和缓冲区都必须在当前上下文中可安全访问。涉及用户态地址、并发修改或延迟记录时,应按调用场景安全捕获数据。复制描述符不会延长原缓冲区的生命周期。
  • 检查 _snwprintf 的返回值。旧式 _snwprintf 在截断时不保证写入结束符。示例中强制将最后一个元素设为 L'\0',只是为了保护后续读取,不能把失败结果变成完整日志。

备注:内容仅供参考。