Skip to content

Go 主机安全面试:Windows NTFS 备用数据流(ADS)载荷隐藏与检测 ​

NTFS 的备用数据流(Alternate Data Stream,简称 ADS)是一个被严重低估的攻击面。

它是 NTFS 的原生特性:一个文件除了默认的无名数据流,还可以挂任意多个具名流。语法简单到令人不安——echo hello > test:stream 就在 test 文件上创建了一个叫 stream 的流。文件本身看起来大小为 0,文本编辑器打开是空的,dir 也不会列出它。

这个特性对攻击者非常友好:

  • 载荷可以藏在正常文件里。一个 1 MB 的图片可以额外挂着一个 5 MB 的 PE 文件,不改文件名、不改大小、不改哈希(主数据流不变)。
  • 复制工具默认不一定带走它。robocopy 的 /copy 标志里 X 表示"跳过备用数据流",而默认的 /copy:DAT 不包含 X——也就是说不同工具、不同参数组合下,流会"人间蒸发",给溯源和取证制造大量盲区。
  • 系统自己就在用它。浏览器下载文件时会挂一个 Zone.Identifier 流来标记"来自互联网",这是 Mark of the Web 的落地方式。也就是说,ADS 本身不是恶意信号,ADS 与 Mark of the Web 的组合才是。

这类面试题真正考三件事:ADS 的底层语义到底是什么;攻击者怎么用它藏载荷、怎么用它规避检测;EDR 怎么在海量文件里既不漏报也不刷屏。第三条最难,因为系统自己每天都在产生 ADS。

本文 API 语义、流名格式与流类型清单以 Microsoft Learn 的 File Streams (Local File Systems)、FindFirstStreamW/FindNextStreamW、WIN32_FIND_STREAM_DATA 和 [MS-FSCC] 为准;Zone.Identifier 的流内容格式取自 [MS-FSCC] 5.6.1;Sysmon 事件 ID 15 的语义取自 Sysinternals Sysmon 官方文档。

岗位场景 ​

text
攻击者通过 Web RCE 拿到主机上的一个低权限写权限
  -> 在 Web 根目录的正常文件上挂载具名流承载载荷
     (cmd.exe: echo <payload> > index.jsp:shell)
  -> 载荷不在主数据流里,文件大小、哈希、静态扫描全部"干净"
  -> 后续通过 cmd /c "type index.jsp:shell" 取回内容执行
  -> Go Agent 需要回答四个问题:
     1) 这个文件/目录上有没有具名流(不是只看主数据流)
     2) 具名流是谁写的、什么时候写的、和主数据流是什么关系
     3) 流的类型是系统基线($DATA 之外的 NTFS 属性流)
        还是攻击者自建的具名 $DATA 流
     4) 流内容是 Zone.Identifier 这类白名单,
        还是 PE / 脚本 / 压缩包这类可执行载荷

注意第 3 问。NTFS 内部有十几种流类型($ATTRIBUTE_LIST、$BITMAP、$INDEX_ALLOCATION、$REPARSE_POINT……),它们都是系统属性,不是数据。如果 Agent 不做类型区分,枚举一个普通目录就会报出一堆"异常流"。只有"具名 + $DATA 类型"的流才可能是用户数据——这是区分噪声和信号的第一道闸门。

高频面试题 ​

1. ADS 的底层语义是什么?一个文件能挂多少个流? ​

简洁答案: ADS 是 NTFS 的原生特性,每个文件除默认的无名数据流(unnamed data stream)外,可以关联多个具名流。流名语法为 filename:streamname:streamtype,其中流类型是 NTFS 内部的 attribute type code,以 $ 开头。

关键知识点:

  • 完整限定名形如 "myfile.dat:stream1:$DATA"。默认数据流是无名的,要完整指定它得写 "myfile.dat::$DATA"。
  • 官方文档明确:$DATA 本身是一个合法的流名。所以名为 bar 的流的全名是 "myfile.dat:bar:$DATA",而一个叫 $DATA 的流的全名是 "myfile.dat:$DATA:$DATA"。
  • 每个流都有独立的分配大小、实际大小和有效数据长度(VDL),也各自维护压缩、加密和稀疏状态。只要任一流曾是稀疏的,FindFirstFile/GetFileAttributesEx 返回的 FILE_ATTRIBUTE_SPARSE_FILE 就会被置上。
  • 一个关键细节:流本身没有文件时间戳。 官方文档原话是 "There are no file times associated with a stream",文件时间在任何流被更新时一起更新。这意味着 ADS 落地不会留下自己独立的创建/修改时间,取证时不能靠流的时间戳定位,只能靠 USN 日志、文件主时间戳的突变或审计日志。
  • 流与文件的权限/共享模式是逐流的:对文件请求删除访问时,系统会检查该文件上所有已打开流的删除访问;某个流没以 FILE_SHARE_DELETE 打开,整个文件就删不掉。

Go 落地思路: 事件模型里 ADS 不能当成"文件的附属属性",它必须是一等实体——有自己的 stream_name、stream_size、stream_type,以及独立于主数据流的哈希。归一化时建议把 path + stream_name 合成一个稳定实体 ID,否则同一文件上的两个流在实体图里会撞成一个节点,攻击链关联就断了。

2. 怎么枚举一个文件的所有流?FindFirstStreamW 的语义细节有哪些? ​

简洁答案: 用 FindFirstStreamW + FindNextStreamW 枚举,FindClose 关闭搜索句柄。两者只枚举 ::$DATA 类型的流。

关键知识点:

  • FindFirstStreamW(lpFileName, InfoLevel, lpFindStreamData, dwFlags):InfoLevel 目前只有 FindStreamInfoStandard(0),dwFlags 保留,必须传 0。
  • 返回结构是 WIN32_FIND_STREAM_DATA:
c
typedef struct _WIN32_FIND_STREAM_DATA {
    LARGE_INTEGER StreamSize;
    WCHAR cStreamName[MAX_PATH + 36];
} WIN32_FIND_STREAM_DATA, *PWIN32_FIND_STREAM_DATA;

流名字符串的格式是 ": streamname :$ streamtype "。

  • 枚举顺序与内容有个反直觉点:对文件来说,第一个流永远是默认的无名数据流 "::$DATA";目录默认没有 $DATA 流、也不能有无名数据流,但可以在创建之后被加上具名数据流。
  • 错误语义要分清:
    • 找不到任何流 → 失败,GetLastError 返回 ERROR_HANDLE_EOF(38)。这不是错误,是正常结束。
    • 文件系统不支持流 → ERROR_INVALID_PARAMETER(87)。
    • 枚举循环中 FindNextStreamW 返回 false 后,必须检查 GetLastError 是不是 ERROR_HANDLE_EOF,否则会丢掉真正的错误(比如权限不足)。
  • WIN32_FIND_STREAM_DATA.cStreamName 是定长 MAX_PATH + 36 的 UTF-16 数组,Go 里用 [syscall.MAX_PATH + 36]uint16 布局要严格对齐:LARGE_INTEGER 占 8 字节且要求 8 字节对齐,后面紧跟数组,结构体总大小是 8 + 2*(260+36) = 600 字节,不能加任何对齐填充,否则 StreamSize 偏移就错了。
  • FindFirstStreamW 最低支持客户端是 Windows Vista,服务端是 Windows Server 2003。SMB 3.0 支持枚举流,但列出的流总长不超过 64 KB。

Go 落地思路: 枚举函数必须把 ERROR_HANDLE_EOF 当成正常终止,并且把其他 Win32 错误原样上抛(syscall.Errno),否则上层无法区分"文件没有流"和"没权限看流"——这两者在告警语义上完全不同。

3. Zone.Identifier 是什么?为什么它既是攻击面也是白名单? ​

简洁答案: Zone.Identifier 是浏览器(以及 Mark of the Web 机制)在下载文件时挂上的具名 $DATA 流,记录该文件来自哪个 URL 安全区域。

关键知识点:

  • [MS-FSCC] 5.6.1 明确:Windows Internet Explorer 使用流名 Zone.Identifier 存储 URL 安全区域信息,完全限定形式是 sample.txt:Zone.Identifier:$DATA。
  • 流内容是一个纯文本流,形如:
text
[ZoneTransfer]
ZoneId=3

实际实现中还有 HostUrl 等字段,以现场抓到的流内容为准。

  • ZoneId 对应 URLZONE 枚举:0 = LOCAL_MACHINE(本地机器)、1 = INTRANET(内网)、2 = TRUSTED(受信任)、3 = INTERNET(互联网)、4 = UNTRUSTED(不受信任)。ZoneId=3 表示来自互联网。
  • 这个流是执行策略的输入。RemoteSigned 策略下,从互联网下载的脚本会因为带这个流而被阻止执行。PowerShell 的 Unblock-File 做的唯一一件事,就是移除 Zone.Identifier 流——官方文档原话就是 "removes the Zone.Identifier alternate data stream"。
  • 所以 Zone.Identifier 既是白名单(几乎所有下载文件都有它)又是检测线索:如果一个流叫 Zone.Identifier 但 ZoneId 不是 3,或者文件声称来自受信区域却在可疑路径下运行,语义就矛盾了。
  • 更重要的检测点:Zone.Identifier 被删除这个动作本身。攻击者落地载荷后删掉这个流,意图就是绕过 SmartScreen / 执行策略。Sysinternals 的 Sysmon 为此专门提供了 Event ID 15: FileCreateStreamHash——官方描述是"当日志到具名文件流被创建时",会同时记录流所属文件的哈希和具名流内容的哈希,并明确指出这个事件的设立动机就是"恶意软件变体会通过浏览器下载投放可执行文件或配置,而浏览器会附加 Zone.Identifier 这个 mark of the web 流"。

Go 落地思路: Zone.Identifier 要拆成两层看:流名(白名单基线之一)和流内容(ZoneId 取值是可判定的数据)。内容解析要做成纯函数 ParseZoneIdentifier([]byte) (zoneID int, ok bool),因为这是唯一一处规则能直接消费的内容,而不是靠流名猜。同时要采集"删除 Zone.Identifier 流"这个动作——Remove-Item -Stream / Unblock-File 都是现成的检测入口。

4. GetFileInformationByHandleEx 的 FileStreamInfo 和 FindFirstStreamW 有什么区别? ​

简洁答案: FindFirstStreamW 按文件路径枚举流;GetFileInformationByHandleEx 配合 FileStreamInfo 类别按已打开的句柄返回流信息。

关键知识点:

  • FILE_INFO_BY_HANDLE_CLASS 枚举里 FileStreamInfo 的说明是 "File stream information for the specified file should be retrieved",返回 FILE_STREAM_INFO 结构。
  • 两种方式的关键差异:
    • FindFirstStreamW 只枚举 $DATA 类型的流,且对目录与文件的首次返回语义不同(见第 2 题)。
    • FileStreamInfo 是按句柄查询,能拿到该句柄所关联的全部流信息,包括非 $DATA 的属性流。
  • 句柄方式还有一个工程优势:复用已经打开的句柄,避免"路径 TOCTOU"——枚举用的路径和实际读取用的路径如果不是一个对象,中间可能被换掉。主机安全 Agent 采集文件属性时,这个差异很重要。
  • FileStreamInfo 走 GetFileInformationByHandleEx,返回结构大小随流数量变化,需要按"缓冲区不够就翻倍重试"的模式处理。

Go 落地思路: 采集层建议两条路并行:路径级用 FindFirstStreamW 做低成本全盘扫描(能拿到流名和大小),句柄级用 FileStreamInfo 在关键文件上做精确复核(拿到含非 $DATA 流的完整视图)。但不要一开始就上 FileStreamInfo 全盘跑——它是按句柄的,成本和实现复杂度都高得多。

5. 攻击者具体怎么用 ADS 藏载荷?常见的几种手法是什么? ​

简洁答案: 把载荷写进具名 $DATA 流,文件名和主数据流保持"正常",需要时再取回。

关键知识点:

  • 最朴素的手法:cmd /c "echo <base64> > C:\wwwroot\index.jsp:shell"。主数据流还是一个普通的 index.jsp,dir 看不到 shell。
  • 取回方式:type 命令不接受流语法——Sysinternals Streams 官方文档特别指出这一点,必须用 more < test:stream(Sysinternals Streams 文档原话:"the type command doesn't accept stream syntax so you have to use more")。PowerShell 侧可以用 Get-Content -Stream / Get-Item -Stream。
  • 执行方式:把流内容落地成 exe 再执行(more < file:payload > payload.exe),或者用 PowerShell 直接读流内容执行(Get-Content file:payload -Raw | Invoke-Expression)。后者不需要中间文件。
  • Windows 组件作为"读流器":certutil -decode、PowerShell 的 -Stream 参数都是现成的读流工具,不必自己写代码。
  • 规避点:很多静态扫描与文件完整性基线只看主数据流。载荷在流里,主数据流的 SHA256 一点没变——所以只对主数据流做哈希基线的 EDR 完全看不见。
  • 另一个坑:Unblock-File(或 Remove-Item -Stream Zone.Identifier)删掉 Mark of the Web 后再执行,绕过 RemoteSigned 执行策略。这是 Zone.Identifier 流的滥用方向,不是它的正常用途。

Go 落地思路: 检测规则不要试图匹配"流名叫什么",要匹配行为组合:具名 $DATA 流 + 流内容有可执行特征(MZ/PE 头、脚本 shebang、base64 块) + 流所在路径是数据目录(Web 根目录、用户目录、临时目录)。单看任何一项都不够,组合起来信噪比才够用。

6. 怎么降噪?系统自己产生的 ADS 怎么区分? ​

简洁答案: 建三层白名单:默认数据流(无名)永远放行;NTFS 系统属性流类型($ATTRIBUTE_LIST、$BITMAP、$INDEX_ALLOCATION、$OBJECT_ID、$REPARSE_POINT 等)放行;已知具名数据流(Zone.Identifier、EFS 相关的 $EFS、TxF 的 $TXF_DATA)放行。剩下的"具名 + $DATA"才是候选。

关键知识点:

  • NTFS 的流类型分两类:用户可见的属性类型($DATA、$EA、$EA_INFORMATION、$OBJECT_ID、$REPARSE_POINT、$LOGGED_UTILITY_STREAM)和系统内部类型($ATTRIBUTE_LIST、$BITMAP、$FILE_NAME、$INDEX_ALLOCATION、$INDEX_ROOT)。后者描述的是目录索引和属性布局,属于文件系统实现细节。
  • $LOGGED_UTILITY_STREAM 是 EFS 和 TxF 用的:EFS 是 ":$EFS:$LOGGED_UTILITY_STREAM",TxF 是 ":$TXF_DATA:$LOGGED_UTILITY_STREAM"。这两个具名流是正常业务产物,必须进白名单。
  • 目录的 $INDEX_ALLOCATION 默认流名叫 $I30——文档明确 "DirName::$INDEX_ALLOCATION"、"DirName:$I30:$INDEX_ALLOCATION" 和 "DirName:$INDEX_ALLOCATION:$INDEX_ALLOCATION" 三种写法等价。枚举目录时一定会遇到它,不认识就会误报。
  • 白名单要可配置。不同客户的安装器、备份软件、企业软件会写自己的具名流,硬编码白名单一定会漏。

Go 落地思路: 白名单应该是"类型表 + 名称表"两张结构化配置,而不是一个 if 链。判定顺序很重要:先看是不是无名默认流 → 再查具名白名单 → 再查类型白名单 → 最后才判可疑。顺序反了会把 Zone.Identifier(类型是 $DATA)误判成可疑——因为 $DATA 也在类型白名单里,但"具名 $DATA"恰恰是可疑条件。

7. 复制、备份、迁移会怎么处理 ADS?对检测和取证意味着什么? ​

简洁答案: 取决于工具和参数。robocopy 的 /copy 标志里 X = "Skip alt data streams",默认 /copy:DAT 不含 X;/copyall = /copy:DATSOU,同样不含 X;而 /dcopy 的 E = Extended attribute、X = Skip alt data streams。Windows 自己的 CopyFile 系列 API 则不承诺复制 ADS。

关键知识点:

  • 官方 robocopy 文档给出的 /copy 有效标志是 D(数据)、A(属性)、T(时间戳)、X(跳过备用数据流)、S(NTFS ACL)、O(所有者)、U(审核)。默认 DAT。
  • 这个标志语义是反直觉的:X 是"跳过",所以不加 X 的组合会带上流。而 /copyall(=/copy:DATSOU)恰好也不含 X——也就是说 robocopy /copyall 会连 ADS 一起搬走。
  • CopyFileW/CopyFileEx 的文档只谈数据、属性、ACL 和符号链接,完全没提 ADS——不承诺复制流。所以同一个文件走两条不同的迁移路径,可能一份有 ADS 一份没有。
  • 网络重定向器场景有额外约束:官方文档指出,"如果被复制的文件带有数据流且使用了网络重定向器,客户端必须同时具备读权限和读属性权限才能复制"。
  • 对检测的影响:ADS 的存在与否是路径和操作相关的,不是文件固有属性。同一台机器上 Web 目录的 index.jsp 有 :shell 流,备份到 NAS 之后可能就没有了。这会让"以备份系统对比差异"的思路失效。
  • 对取证的影响:不能只看现网文件,必须结合审计日志、USN 日志、内存残留来还原。

Go 落地思路: Agent 上报 ADS 事件时必须带上采集方式(扫描发现 / 实时事件 / 审计日志),否则客户对比"备份里的文件"和"现网告警"会得出"告警是假的"的错误结论。这是很典型的客户投诉。

8. Sysmon 的 FileCreateStreamHash 事件该怎么用?它解决了什么采集盲区? ​

简洁答案: 它补上了 Sysmon 原本看不见具名流创建的盲区——常规的 FileCreate(事件 ID 11)只记录文件创建/覆盖,具名流是"另一个维度"的文件写入。

关键知识点:

  • Sysmon 事件 ID 11(FileCreate)记录文件创建或覆盖,用于监控自启动位置、临时目录和下载目录——但它不区分流。
  • 事件 ID 15(FileCreateStreamHash)记录具名文件流被创建的时刻,同时给出两处哈希:文件所属流(即默认流)的哈希,以及具名流自身内容的哈希。
  • 官方文档给出的设立动机非常明确:恶意软件变体会通过浏览器下载投放可执行文件或配置,而浏览器会附加 Zone.Identifier 这个 mark of the web 流——这个事件就是为了覆盖这种情况。
  • 有了两个哈希,检测才能做跨流关联:默认流是 .jpg,具名流内容哈希却和一个已知恶意样本一致,这就是铁证。只报"某文件有具名流"是弱信号,"两个哈希的关系"才是强信号。
  • 注意 Sysmon 是可选组件,很多客户环境根本没装或者被精简过配置。Agent 不能把 Sysmon 当作唯一数据源。

Go 落地思路: Agent 自己的采集器要做到和 Sysmon 事件 ID 15 同等的语义:流创建时刻 + 流内容哈希 + 主数据流哈希,三者缺一不可。只有流名和大小,做不出关联。

9. 用 Go 怎么读一个流的内容?有哪些坑? ​

简洁答案: 用 CreateFile 打开 "路径:流名",注意分享模式和权限;另外要用 \\?\ 长路径前缀绕开 260 字符限制。

关键知识点:

  • 打开流就是把流名拼进路径:CreateFile(L"C:\\dir\\file.txt:Zone.Identifier", ...)。流名可以是任意合法文件名字符(包括空格),只有流类型限定符以 $ 开头。
  • CreateFile 的分享模式要放开 FILE_SHARE_READ | FILE_SHARE_WRITE——浏览器可能正在写这个流。
  • os.Open 在 Windows 上能打开 file:stream 形式的路径吗?不能直接依赖。Go 的 os 包在 Windows 上把路径转成 UTF-16 后调用 CreateFile,: 在路径里会被当作驱动器分隔符处理,行为不可控;filepath 的 VolumeName 逻辑也把冒号当卷名边界。直接用 syscall.CreateFile 拼完整路径最稳。
  • Go 标准库 syscall 里没有 FindFirstStreamW 的封装,golang.org/x/sys/windows 也没有。要么用 syscall.NewLazyDLL("kernel32.dll").NewProc("FindFirstStreamW") 自己绑,要么用 NtQueryInformationFile 的 FileStreamInformation 类(官方文档与 Sysinternals Streams 工具都提到过这条路径,但 NT API 属于半公开接口,稳定性不如 Win32 API)。
  • 读流内容时别一次性读全。攻击者可以挂一个 1 GB 的流。读前 64 KB 拿头部特征就够了,还省 I/O。

Go 落地思路: 采集层做一个 ReadStreamPrefix(path, stream string, n int64) ([]byte, error),上限写死(如 64 KB),返回前 N 字节供特征匹配。永远不要在 Agent 里读全流——那既是性能问题,也是被攻击者当放大器用的风险。

10. 客户现场怎么排障?"文件有 ADS"这件事怎么向客户解释? ​

简洁答案: 分三步:确认流是真实的(用官方工具枚举,不要只信 Agent);确认流属于哪一类(系统基线 / 浏览器 Zone.Identifier / 用户自建);确认写入者(审计日志、USN 日志、进程活动)。

关键知识点:

  • 枚举工具:Sysinternals Streams(v1.6)可以列出文件与目录上的具名流及大小,支持 -s 递归、-d 删除。官方文档同时说明:Windows 本身不带能列出这些流的工具,Streams 用的是一个未公开的原生函数获取流信息。PowerShell 侧 Get-Item -Path <file> -Stream * 也能列出。
  • 目录也要查。Streams 文档明确 "directories can also have alternate data streams",只扫文件会漏。
  • 解释话术:绝大多数客户看到"ADS"的第一反应是"这是攻击"。正确的表述是:"NTFS 的 ADS 是系统原生特性,浏览器下载文件时就会挂 Zone.Identifier 流作为来源标记,这是微软的 Mark of the Web 机制。我们检测的是在主数据流之外额外写入的具名 $DATA 流,以及流内容与可执行载荷的关联。"
  • 区分手段:Zone.Identifier 流有固定结构([ZoneTransfer] / ZoneId=N),内容短小;攻击者自建的流名五花八门,内容是载荷。结构 + 大小 + 内容三重区分比流名本身可靠得多。
  • 一个真实误区:有的客户会认为"文件大小没变所以肯定没藏东西"。要解释主数据流的大小和哈希都不受具名流影响——哈希不变恰恰是问题所在,不是安全证明。

Go 落地思路: 告警文案里必须带"主数据流哈希"和"具名流哈希"两个字段,并明确区分 benign-type / benign-name / suspicious 三档判定。客户能自己点开看到"这是 Zone.Identifier,不是载荷",比 Agent 自己解释有效得多。

11. 这类检测在产品上的定位是什么?值不值得投入? ​

简洁答案: 值得,但它的定位是高信噪比的补充检测面,不是主力。主力还是进程、命令行、网络连接这些行为面。

关键知识点:

  • 投入产出:FindFirstStreamW 枚举一次文件成本不高(一次系统调用 + 一次 FindNextStreamW 循环),适合挂在"文件创建/写入事件"的增量路径上,而不是全盘定期扫。全盘扫 ADS 的成本和普通文件遍历一样,但收益取决于目录选择——只扫 Web 根目录、用户目录、临时目录,收益最高。
  • 它抓不到什么:ADS 只是静态载荷的藏身地。攻击者不用 ADS(直接写文件)、或者用 ADS 但通过内存加载执行(不落地),这套检测就完全失效。所以它是"纵深防御的一层",不是"单点检测"。
  • 它的独特价值:大部分 EDR 的文件哈希基线只覆盖主数据流,ADS 恰好是这条基线的盲区。补上它,等于把"文件完整性"维度从一维变成二维。
  • 落地顺序建议:先做"枚举 + 分类 + 白名单"(只报可疑具名流,不做内容分析),确认误报率可控;再上"流内容哈希 + 与样本库/主数据流哈希关联";最后才考虑"流内容特征匹配(PE 头等)"。

Go 落地思路: Agent 侧拆成三层:枚举层(找流)、分类层(白名单 + 评分)、关联层(哈希 + 路径 + 进程上下文)。三层可以独立灰度上线——枚举层误报高就先只落本地日志,分类层收敛后再上报。这比"一次性做完再上线"稳得多。

Go 落地:四段可以直接用的代码 ​

4.1 流名解析(纯 Go,可跨平台单测) ​

FindFirstStreamW 返回的流名格式是 ": streamname :$ streamtype ",默认数据流是 "::$DATA"。解析的第一步就是把它拆成结构化字段。

go
package adsdemo

import "strings"

// StreamRef 表示一个 NTFS 备用数据流的引用。
type StreamRef struct {
	Raw  string // 原始流名,形如 ":Zone.Identifier:$DATA"
	Name string // 流名,默认数据流时为空
	Type string // 流类型,如 "$DATA"
}

// ParseStreamName 解析 FindFirstStreamW/FindNextStreamW 返回的流名。
// 官方格式为 ": streamname :$ streamtype",默认数据流为 "::$DATA"。
func ParseStreamName(raw string) (StreamRef, bool) {
	s := strings.TrimSpace(raw)
	if !strings.HasPrefix(s, ":") {
		return StreamRef{}, false
	}
	body := s[1:]
	if i := strings.LastIndex(body, ":$"); i > 0 {
		return StreamRef{Raw: s, Name: body[:i], Type: body[i+1:]}, true
	}
	return StreamRef{Raw: s, Type: body}, true
}

这里用 LastIndex 而不是 Index:官方文档特别提到 $DATA 本身是一个合法流名,"sample:$DATA:$DATA" 表示名为 $DATA 的流。取最后一次出现的 :$ 才能正确处理这种重名情况。

4.2 枚举具名流(Windows) ​

golang.org/x/sys/windows 没有 FindFirstStreamW 的封装,需要自己绑。这里同时把 ERROR_HANDLE_EOF(38)当作正常结束——这是最常见的错误来源,把"没有流"当成错误会污染整条采集链路。

go
//go:build windows

package adsdemo

import (
	"syscall"
	"unsafe"
)

var (
	modkernel32      = syscall.NewLazyDLL("kernel32.dll")
	procFindFirstStr = modkernel32.NewProc("FindFirstStreamW")
	procFindNextStr  = modkernel32.NewProc("FindNextStreamW")
	procFindClose    = modkernel32.NewProc("FindClose")
)

const errorHandleEOF = syscall.Errno(38)

// win32FindStreamData 对应 WIN32_FIND_STREAM_DATA:
// LARGE_INTEGER StreamSize; WCHAR cStreamName[MAX_PATH + 36];
type win32FindStreamData struct {
	StreamSize int64
	StreamName [syscall.MAX_PATH + 36]uint16
}

// ListStreams 枚举一个文件/目录下的所有 $DATA 类型流。
func ListStreams(path string) ([]StreamRef, int64, error) {
	p, err := syscall.UTF16PtrFromString(path)
	if err != nil {
		return nil, 0, err
	}
	var data win32FindStreamData
	h, _, callErr := procFindFirstStr.Call(uintptr(unsafe.Pointer(p)), 0,
		uintptr(unsafe.Pointer(&data)), 0)
	if h == uintptr(syscall.InvalidHandle) {
		if errno, ok := callErr.(syscall.Errno); ok && errno == errorHandleEOF {
			return nil, 0, nil
		}
		return nil, 0, callErr
	}
	defer procFindClose.Call(h)

	var refs []StreamRef
	for {
		ref, ok := ParseStreamName(syscall.UTF16ToString(data.StreamName[:]))
		if ok {
			refs = append(refs, ref)
		}
		r, _, nextErr := procFindNextStr.Call(h, uintptr(unsafe.Pointer(&data)))
		if r == 0 {
			if errno, ok := nextErr.(syscall.Errno); ok && errno == errorHandleEOF {
				break
			}
			return refs, 0, nextErr
		}
	}
	return refs, 0, nil
}

注意 win32FindStreamData 的字段布局:LARGE_INTEGER 是 8 字节且要求 8 字节对齐,后面紧跟 uint16 数组,结构体总大小 8 + 2*(260+36) = 600 字节,中间不能有任何对齐填充。Go 的结构体布局和 C 一致,所以这样写是正确的;但如果你在中间插入别的字段,偏移就错了。

4.3 读取流内容前缀(Windows) ​

go
//go:build windows

package adsdemo

import (
	"fmt"
	"syscall"
)

// ReadStream 读取指定备用数据流的内容,最多 limit 字节。
func ReadStream(path, streamName string, limit int64) ([]byte, error) {
	full := path + ":" + streamName
	p, err := syscall.UTF16PtrFromString(full)
	if err != nil {
		return nil, err
	}
	h, err := syscall.CreateFile(p, syscall.GENERIC_READ,
		syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE, nil,
		syscall.OPEN_EXISTING, syscall.FILE_ATTRIBUTE_NORMAL, 0)
	if err != nil {
		return nil, err
	}
	defer syscall.CloseHandle(h)
	buf := make([]byte, limit)
	n, err := syscall.Read(h, buf)
	if err != nil {
		return nil, fmt.Errorf("read stream %s: %w", full, err)
	}
	return buf[:n], nil
}

limit 必须由调用方给一个固定上限(例如 64 KB)。永远不要在 Agent 里读全流——攻击者可以挂一个超大流,那是性能问题,也是放大器风险。

4.4 降噪打分(纯函数,可单测) ​

go
package adsdemo

import "strings"

// 系统与浏览器会合法产生的流类型,作为白名单基线。
var benignStreamTypes = map[string]bool{
	"$ATTRIBUTE_LIST":        true,
	"$BITMAP":                true,
	"$EA":                    true,
	"$EA_INFORMATION":        true,
	"$FILE_NAME":             true,
	"$INDEX_ALLOCATION":      true,
	"$INDEX_ROOT":            true,
	"$LOGGED_UTILITY_STREAM": true,
	"$OBJECT_ID":             true,
	"$REPARSE_POINT":         true,
}

// 系统与浏览器会合法产生的具名流,作为白名单基线。
var benignStreamNames = map[string]bool{
	"Zone.Identifier": true,
	"$EFS":            true,
	"$TXF_DATA":       true,
}

// ClassifyStream 判断一个流是否属于已知良性基线。
func ClassifyStream(ref StreamRef) string {
	if ref.Name == "" {
		return "benign-default"
	}
	if benignStreamNames[ref.Name] {
		return "benign-name"
	}
	if ref.Type != "$DATA" && benignStreamTypes[ref.Type] {
		return "benign-type"
	}
	if strings.EqualFold(ref.Name, "Zone.Identifier") {
		return "benign-name"
	}
	return "suspicious"
}

// ScoreStream 对具名备用数据流打分,返回 0 表示放行。
func ScoreStream(ref StreamRef, size int64, contentPeek []byte) int {
	if ref.Name == "" {
		return 0 // 默认数据流
	}
	switch ClassifyStream(ref) {
	case "benign-type", "benign-name":
		return 0
	}
	score := 2
	if size >= 64*1024 {
		score++
	}
	if len(contentPeek) >= 2 && contentPeek[0] == 'M' && contentPeek[1] == 'Z' {
		score += 3 // PE 头,载荷藏在流里
	}
	if strings.HasSuffix(strings.ToLower(ref.Name), ".exe") ||
		strings.HasSuffix(strings.ToLower(ref.Name), ".dll") {
		score++
	}
	return score
}

判定顺序是关键:无名默认流 → 具名白名单 → 类型白名单 → 可疑。如果把类型判断放在具名判断之前,Zone.Identifier 会被 $DATA 类型白名单放行,但"具名 $DATA"恰恰是可疑条件——顺序反了会把攻击载荷一起放过去。

学习要点与知识点 ​

  • ADS 是 NTFS 原生特性,不是 Windows 的"后门"。微软文档在 File Streams 里花了整节讲它,PowerShell 的 Get-Content -Stream / Get-Item -Stream / Remove-Item -Stream 都是正式支持的 API。
  • 流名格式 ": streamname :$ streamtype ",默认数据流是 "::$DATA";$DATA 本身是合法流名,所以解析要用 LastIndex。
  • 流没有自己的时间戳,文件时间在任何流更新时一起更新。这是 ADS 取证最难的地方。
  • 流类型分两层:NTFS 系统属性流($ATTRIBUTE_LIST、$BITMAP、$INDEX_ALLOCATION 等)和用户可见的数据流($DATA)。只有"具名 $DATA"是攻击面。
  • 已知具名流基线:Zone.Identifier(浏览器下载标记,Mark of the Web 的落地方式)、$EFS(EFS)、$TXF_DATA(TxF)。枚举目录时一定会遇到 $I30。
  • FindFirstStreamW 的错误语义:找不到流返回 ERROR_HANDLE_EOF(38),不是错误;文件系统不支持返回 ERROR_INVALID_PARAMETER(87);循环里必须检查 GetLastError,否则丢真错误。
  • WIN32_FIND_STREAM_DATA 布局:LARGE_INTEGER + WCHAR[MAX_PATH+36],总 600 字节,无填充。
  • 复制语义不统一:robocopy 的 /copy:X 是"跳过 ADS",不含 X 的组合会带走流(/copyall = /copy:DATSOU 也不含 X);CopyFile 系列 API 的文档完全没提 ADS,不作任何承诺。
  • Sysmon 事件 ID 15(FileCreateStreamHash) 专门覆盖具名流创建,给出主数据流哈希 + 具名流哈希,是现成可借鉴的关联模型;但 Sysmon 是可选组件,不能当唯一数据源。
  • Zone.Identifier 的双面性:白名单基线,同时"删除该流"本身是检测点——攻击者删它是为了绕过 SmartScreen / RemoteSigned。
  • Go 侧的坑:os/filepath 在 Windows 上把 : 当卷名边界,别指望 os.Open("file:stream");用 syscall.CreateFile 拼完整路径;syscall 和 x/sys/windows 都没有 FindFirstStreamW 封装,需自己 NewLazyDLL。

小练习 / 复盘题 ​

  1. 枚举顺序题:对一个普通文件调用 FindFirstStreamW,第一个返回的流是什么?对目录呢?两者的差异说明了什么设计约束?
  2. 解析题:给定流名 ":$DATA"、":Zone.Identifier:$DATA"、":payload:$DATA"、":$I30:$INDEX_ALLOCATION",分别解析出 Name 和 Type。特别说明 ":$DATA" 的 Name 应该是什么。
  3. 重名题:官方文档说 "$DATA" 是合法流名。那么 ":$DATA:$DATA" 解析出的 Name 和 Type 分别是什么?你的解析函数用 Index 而不是 LastIndex 会出什么问题?
  4. 错误处理题:写一个遍历循环时,如果不检查 FindNextStreamW 之后的 GetLastError,会在什么情况下把错误当成"枚举结束"?对上层告警链路会造成什么后果?
  5. 白名单题:你的 ClassifyStream 判定顺序是"无名默认流 → 具名白名单 → 类型白名单 → 可疑"。如果把类型白名单提到具名白名单前面,哪一类流会被误判?举一个具体例子。
  6. 降噪题:列出三种"合法的具名 $DATA 流",并说明各自由哪个系统机制产生。
  7. 迁移语义题:某客户用 robocopy /copyall 做主机迁移,迁移后 Web 目录的告警消失了。请说明 /copyall 展开成什么标志、其中有没有 X,以及这个现象是"告警误报"还是"检测盲区"。
  8. 取证题:为什么 ADS 载荷的落地时间很难从文件系统元数据直接还原?请列出两条替代的时间来源。
  9. Go 实现题:在 win32FindStreamData 里插入一个 uint32 字段(比如放一个自定义标记)会发生什么?为什么?请写出正确的结构体大小。
  10. 工程题:设计一个 Agent 侧的"流内容哈希"方案,要求:不读全流、对抗超大流、支持 Zone.Identifier 这类短小流与 PE 这类大流的差异,并能与主数据流哈希做关联。说明你选用的哈希算法与截断策略。
  11. 降噪实战题:客户反馈"Agent 报某个目录有异常流"。请给出一个三步排障流程:第一步用什么确认流真实存在,第二步用什么区分系统基线与用户流,第三步用什么定位写入者。
  12. 产品题:为什么"只对主数据流做哈希基线"的 EDR 会漏掉 ADS 载荷?补上 ADS 维度之后,文件完整性检测的信噪比会受什么影响?你打算怎么控制?
最近更新