CodeQL C 库演进:System.Text.StringBuilder 数据流与污点建模深度解析

原创2026-10-07 20:08:14433 阅读
文章标签:静态分析SAST应用安全漏洞扫描代码质量

CodeQL C# 库演进:System.Text.StringBuilder 数据流与污点建模深度解析

导读

本文围绕 CodeQL C# 库在 2021 年 4 月发布的一项核心变更——StringBuilder 数据流建模改进——展开。该变更通过重构 System.Text.StringBuilder 的 flow summaries,让 taint tracking 类安全查询能够识别更多经由 Append、AppendLine、AppendFormat 等方法传播的污点,尤其是在插值字符串与 StringBuilder 实例互相传递的场景下。读完本文,你将理解 CodeQL 外部模型(extensible model)中 summaries 的声明格式与语义,掌握 StringBuilder 全家族方法(Append、AppendLine、AppendFormat、AppendJoin 等)的建模明细,并能够对照仓库中的模型文件与测试用例验证污点流是否如预期传播。

一、变更概述:一次"小 changelog、大影响"的建模重构

原始变更记录(csharp/old-change-notes/2021-04-26-string-builder-summaries.md)全文仅有两条:

lgtm,codescanning
* Data-flow modelling of `StringBuilder` objects has been improved.

虽然 changelog 措辞简练,但它对应的是仓库中一套完整的实现:模型定义文件 csharp/ql/lib/ext/System.Text.model.yml 为 StringBuilder 的数十个重载逐一声明了数据流/污点传播规则,配套库代码 csharp/ql/lib/semmle/code/csharp/frameworks/system/Text.qll 提供了类型识别基础,而测试文件 csharp/ql/test/library-tests/dataflow/global/GlobalDataFlowStringBuilder.cs 则完整验证了各传播场景。

同一变更在 CodeQL 标准库的发布说明(csharp/ql/lib/change-notes/released/0.8.6.md)中有更明确的表述:

The dataflow models for the System.Text.StringBuilder class have been reworked. New summaries have been added for Append and AppendLine. With the changes, we expect queries that use taint tracking to find more results when interpolated strings or StringBuilder instances are passed to Append or AppendLine.

从中可以归纳本次变更的三个关键结论:

  1. 建模对象:System.Text.StringBuilder 类,覆盖其核心可变字符串构造方法;
  2. 新增内容:Append 与 AppendLine 的 summaries(AppendFormat、AppendJoin 等同样被系统性覆盖);
  3. 预期收益:使用 taint tracking 的查询(如跨站脚本、SQL 注入、日志注入等)在污点经由 Append/AppendLine 传入 StringBuilder 并最终通过 ToString() 流出时,将检出更多真实告警。

二、建模基石:如何在库中识别 StringBuilder 类

在 CodeQL C# 库中,System.Text.StringBuilder 并非以硬编码字符串方式散落各处,而是由库类型层级统一封装。查看 csharp/ql/lib/semmle/code/csharp/frameworks/system/Text.qll:

/** The `System.Text.StringBuilder` class. */
class SystemTextStringBuilderClass extends SystemTextClass {
  SystemTextStringBuilderClass() { this.hasName("StringBuilder") }
}

这段代码说明:

  • SystemTextStringBuilderClass 继承自 SystemTextClass(System.Text 命名空间下的库类基类);
  • 它通过 hasName("StringBuilder") 按类型名匹配,再结合命名空间约束,精确锁定 System.Text.StringBuilder;
  • 所有依赖 StringBuilder 的查询、数据流步骤与 summaries 都可以基于该抽象类编写,避免直接拼字符串。

这正是 CodeQL 库"以库类为中心组织建模"的典型做法:模型文件负责声明"方法级"的传播规则,库类负责提供"类型级"的识别能力,两者配合构成完整的数据流知识。

三、summaries 机制:读懂 System.Text.model.yml 的字段语义

CodeQL 的外部模型(extensible model)允许以 YAML 形式声明类型/方法的 flow summary,无需改动 QL 库本体。StringBuilder 的全部建模都集中在 csharp/ql/lib/ext/System.Text.model.yml。每条记录是一个 10 元组,形如:

- ["System.Text", "StringBuilder", False, "Append", "(System.String)", "", "Argument[0]", "Argument[this]", "taint", "manual"]

各字段含义如下(以 Append(string) 为例):

字段 示例值 含义
命名空间 System.Text 类型所在命名空间
类型名 StringBuilder 建模目标类型
是否静态 False True 表示静态方法,False 表示实例方法(此处 Append 为实例方法)
方法名 Append 目标方法
方法签名 (System.String) 重载参数类型,用于精确区分不同重载
扩展位 "" 预留字段(本文件中为空)
输入 Argument[0] 污点/数据流入参位置
输出 Argument[this] 污点/数据流汇聚位置(this 指接收者实例)
传播类型 taint taint 表示污点传播(保留污点标记),value 表示值流(精确数据流)
来源 manual 该条目的来源标注(人工编写)

理解"输入 → 输出"的语义是掌握该文件的关键:

  • 对于 Append(string),声明 Argument[0] -> Argument[this](taint):参数 0 中的污点会进入接收者 StringBuilder 对象内部;
  • 配套地,几乎所有 Append 重载都声明 Argument[this] -> ReturnValue(value):因为 Append 返回 StringBuilder 本身(链式调用支持),接收者的内容状态会随之流向返回值;
  • 正是"参数进 this + this 进返回值"的组合,使得 var sb2 = new StringBuilder(); sb2.Append(x); sb2.ToString(); 这类写法中的污点能够一路传播到 ToString() 的返回值。

四、Append 家族:最细粒度的污点入口建模

Append 是 StringBuilder 最核心的追加方法,仓库为其覆盖了从基本类型到新式 ReadOnlySpan/插值处理器的完整重载集(System.Text.model.yml)。按传播语义可归为两类:

1. 标量类型(value 流):this -> ReturnValue 包括 Boolean、Byte、Char、Decimal、Double、Int16/32/64、SByte、Single、UInt16/32/64 等重载,以及 Char*、ReadOnlyMemory<Char>、ReadOnlySpan<Char> 等内存视图重载。这类参数本身不含污点,建模价值在于正确传递链式调用的对象状态。

2. 污点承载类型(taint 流):Argument[i] -> Argument[this] 这是本次改进的精华所在:

重载签名 传播规则
Append(System.String) Argument[0] -> Argument[this](taint)
Append(System.String, System.Int32, System.Int32) Argument[0] -> Argument[this](taint,子串追加)
Append(System.Text.StringBuilder) Argument[0] -> Argument[this](taint,StringBuilder 之间直接传播)
Append(System.Text.StringBuilder, System.Int32, System.Int32) Argument[0] -> Argument[this](taint)
Append(System.Char[]) / Append(System.Char[], System.Int32, System.Int32) Argument[0].Element -> Argument[this](taint,数组元素级传播)
Append(System.Object) Argument[0] -> Argument[this](taint,对象 ToString() 语义)

特别注意两个设计细节:

  • Argument[0].Element 语法:对 Char[] 重载使用 .Element 限定,表示从"数组元素"级别建立传播,这与 CodeQL 对容器元素的建模方式一致,能准确表达"把 char 数组内容倒进 StringBuilder"这一语义;
  • StringBuilder 互传:Append(StringBuilder) 重载同时存在 taint 与 value 两条规则,前者负责 sb1.Append(sb2) 时 sb2 内部污点流入 sb1,后者负责返回值链。这正是测试用例中 sb1.Append(sb); var sink1 = sb1.ToString(); 能被识别为污染的底层依据。

五、AppendLine 家族:换行追加同样不漏污点

AppendLine 在追加内容的同时附加换行符,是日志构建场景的高频方法,也是本次新增 summaries 的重点(System.Text.model.yml):

重载签名 传播规则
AppendLine() Argument[this] -> ReturnValue(value)
AppendLine(System.String) Argument[0] -> Argument[this](taint)+ Argument[this] -> ReturnValue(value)
AppendLine(AppendInterpolatedStringHandler) Argument[0] -> Argument[this](taint)+ Argument[this] -> ReturnValue(value)
AppendLine(IFormatProvider, AppendInterpolatedStringHandler) Argument[1] -> Argument[this](taint)

从建模可以看到,AppendLine(string) 与 Append(string) 保持完全一致的"参数进 this、this 进返回"的双向规则,确保无论开发者用哪种方式拼接日志,污点都能沿链传播至最终 ToString() 输出点。

六、格式化与拼接:AppendFormat / AppendJoin 的复杂场景覆盖

除 Append/AppendLine 外,模型文件还对 StringBuilder 的其他内容注入路径做了系统性覆盖(System.Text.model.yml):

AppendFormat(格式化追加):所有重载都遵循"格式串与参数分别传播"的策略:

  • AppendFormat(string, object):Argument[0](格式串)与 Argument[1](参数对象)均 taint -> Argument[this];
  • AppendFormat(string, object[]):除格式串外,还声明 Argument[1].Element -> Argument[this],即数组的每个元素都可能携带污点进入 StringBuilder;
  • 带 IFormatProvider 的重载:参数下标整体后移(如 Argument[1] 为格式串、Argument[2..] 为参数),建模同样逐一覆盖;
  • 新式 CompositeFormat、ReadOnlySpan<Object> 重载也在文件中有对应条目,体现了模型对新 .NET API 的持续跟进。

AppendJoin(连接追加):分隔符 + 元素集合的追加路径同样建模为 Argument[1].Element -> Argument[this](taint),保证集合元素中的污点能够通过 Join 进入字符串缓冲区。

这种"逐重载、逐参数、区分值流与污点"的建模粒度,正是 CodeQL 能在不运行目标程序的前提下精确模拟 .NET 字符串构建过程的原因。

七、插值字符串:AppendInterpolatedStringHandler 的污点贯通

本变更的核心收益之一是插值字符串场景。.NET 6+ 引入的 AppendInterpolatedStringHandler 与 Append(IFormatProvider, ...) 重载让 sb.Append($"a{s}b") 这类写法变得常见。模型文件为此声明了专门规则(System.Text.model.yml):

- ["System.Text", "StringBuilder", False, "Append", "(System.Text.StringBuilder+AppendInterpolatedStringHandler)", "", "Argument[0]", "Argument[this]", "taint", "manual"]
- ["System.Text", "StringBuilder", False, "Append", "(System.IFormatProvider,System.Text.StringBuilder+AppendInterpolatedStringHandler)", "", "Argument[1]", "Argument[this]", "taint", "manual"]

也就是说,无论插值内容是直接传入(Argument[0])还是带 provider 传入(Argument[1]),其内部承载的污点都会被识别为流入 StringBuilder 接收者。这解决了此前"污点进入插值处理器后被当作黑盒丢弃"的漏报问题。

八、测试验证:从源码看污点流如何被确认

变更的正确性由库测试背书。核心测试文件 csharp/ql/test/library-tests/dataflow/global/GlobalDataFlowStringBuilder.cs 构造了五个代表性场景:

static void AppendToStringBuilder(StringBuilder sb, string s) { sb.Append(s); }

static void AppendToStringBuilderInterpolated(StringBuilder sb, string s)
{
    sb.Append($"a{s}b");
}

void TestStringBuilderFlow()
{
    var sb = new StringBuilder();
    AppendToStringBuilder(sb, "taint source");
    var sink0 = sb.ToString();        // sink:参数经 Append 进入 sb,再由 ToString 流出
    Check(sink0);

    var sb1 = new StringBuilder();
    sb1.Append(sb);                    // StringBuilder 互传
    var sink1 = sb1.ToString();       // sink

    var sb2 = new StringBuilder();
    sb2.Append($"{sb}");              // 插值字符串携带 sb 的污点
    var sink2 = sb2.ToString();       // sink

    sb.Clear();                       // 清空后污点应被终止
    var nonSink = sb.ToString();      // 非 sink:Clear() 切断传播
    Check(nonSink);

    AppendToStringBuilderInterpolated(sb, "taint source");
    var sink3 = sb.ToString();        // sink:插值内容进入 Append
    Check(sink3);
}

对照文件头注释"All (tainted) sinks are named sink... and all non-sinks are named nonSink...",测试语义非常清晰:

  1. sink0:证明 Append(string) 的 Argument[0] -> this 规则生效,污点经 ToString() 流出;
  2. sink1:证明 Append(StringBuilder) 的 StringBuilder 间传播规则生效;
  3. sink2:证明插值字符串 $"{sb}" 中的对象插值仍保留污点;
  4. nonSink:验证 Clear() 被正确建模为传播终止点——这说明模型并非无脑将 this 状态永久污染,而是尊重对象的"清空"语义,避免误报;
  5. sink3:证明 Append 内部使用插值处理器(handler 形式)时污点同样贯通。

对应的期望输出(GlobalDataFlowStringBuilder.cs 同目录下的 *.expected 文件,如 TaintTracking.expected)记录了全局 taint tracking 的精确结果,任何模型改动都必须保持这些期望一致,从而防止回归。此外,StringBuilder 相关建模还体现在 LibraryTypeDataFlow.cs 的 flow summary 测试中,感兴趣的读者可以沿着 csharp/ql/test/library-tests/dataflow/ 目录继续深入。

九、实际影响:哪些安全查询因此获益

StringBuilder 是 C# 代码中最常用的字符串构建工具,也是大量安全 sink 的前置路径。本次建模改进的直接受益者是所有基于 taint tracking 的安全查询,典型场景包括:

  • 代码注入:CodeInjection.cs 等查询需要追踪从不可信输入到 Compile/Eval 类 sink 的污点,当中间环节经过 sb.Append(userInput) 再 sb.ToString() 时,本次建模保证了路径不断裂;
  • 资源注入:ResourceInjection.cs 中经 StringBuilder 拼接路径/资源名的污点同样受益;
  • SQL/日志/HTML 注入:凡是将外部输入逐段 Append 进缓冲区再统一输出(写日志、拼 SQL、渲染 HTML)的代码模式,都会因 Append/AppendLine 的 taint 规则而检出更多结果。

换句话说,变更记录中"expect queries that use taint tracking to find more results"并非空话——Append、AppendLine、AppendFormat、AppendJoin 及插值处理器重载共同补全了 StringBuilder 的污点传播图谱,让安全查询在"分段构建字符串"这一常见编码风格下不再漏报。

十、延伸:StringBuilder 相关的既有查询

除数据流建模外,仓库中还沉淀了多个以 StringBuilder 为主题的独立查询,可作为理解该类常见误用的补充:

这些查询与数据流建模互为补充:前者关注 API 误用与性能,后者关注污点可达性,共同构成 CodeQL 对 C# 字符串构建生态的完整覆盖。

结语:如何验证与跟进

如果你正在使用 CodeQL 分析 C# 代码库,可以按以下路径自行验证本次建模效果:

  1. 确认模型存在:阅读 System.Text.model.yml,检索 AppendLine 与 Append 条目,确认你的目标 .NET 版本对应的重载已被覆盖;
  2. 运行测试套件:在 csharp/ql/test/library-tests/dataflow/global/ 下运行数据流库测试,观察 GlobalDataFlowStringBuilder.cs 的期望输出是否与当前模型一致;
  3. 编写最小验证查询:以"不可信输入 → sb.Append(...) → sb.ToString() → 安全 sink"为路径写一个临时 taint 查询,对照变更前后(或不同模型版本)的告警差异,即可直观感受本次建模改进带来的检出能力提升。

需要说明的是,上述模型与测试来自当前仓库快照,具体到某个 CodeQL 发行版的生效情况以该发行版打包的库版本为准;本文所引用的行号与文件路径均可在仓库中直接核对。

登录后查看全文
codeql