bigywater.tech
Java · 355

SecureDirectoryStream:减少路径竞态

目录在检查之后被换成符号链接,是文件系统操作最经典的竞争。这篇用确定性的改名 + 符号链接替换,对比按路径操作与 SecureDirectoryStream 的结果,并看这个 API 在 macOS 上的可用性。

只有 JDK 27
在这台 macOS 上 Files.newDirectoryStream 返回 SecureDirectoryStream 的 JDK(17 与 21 不返回)
outside 被删
JDK 21 上检查再使用、toRealPath、已打开的 DirectoryStream 三种做法在目录被换成符号链接后的共同结果
../ 与绝对路径
SecureDirectoryStream.deleteFile 对这两种名字都能删到流目录之外

哪个 JDK 返回 SecureDirectoryStream

实测 · 本机执行输出
  [JDK 17]
    JDK 17.0.20.1;FileSystemProvider = sun.nio.fs.MacOSXFileSystemProvider
        newDirectoryStream(dir)            → sun.nio.fs.UnixDirectoryStream;SecureDirectoryStream? false
        newDirectoryStream(dir, "*.txt")   → sun.nio.fs.UnixDirectoryStream;SecureDirectoryStream? false
        newDirectoryStream(dir, Filter)    → sun.nio.fs.UnixDirectoryStream;SecureDirectoryStream? false
    JDK 内部类 sun.nio.fs.UnixSecureDirectoryStream 在这个 JDK 里存在
  [JDK 21]
    JDK 21.0.12.1;FileSystemProvider = sun.nio.fs.MacOSXFileSystemProvider
        newDirectoryStream(dir)            → sun.nio.fs.UnixDirectoryStream;SecureDirectoryStream? false
        newDirectoryStream(dir, "*.txt")   → sun.nio.fs.UnixDirectoryStream;SecureDirectoryStream? false
        newDirectoryStream(dir, Filter)    → sun.nio.fs.UnixDirectoryStream;SecureDirectoryStream? false
    JDK 内部类 sun.nio.fs.UnixSecureDirectoryStream 在这个 JDK 里存在
  [JDK 27]
    JDK 27;FileSystemProvider = sun.nio.fs.MacOSXFileSystemProvider
        newDirectoryStream(dir)            → sun.nio.fs.UnixSecureDirectoryStream;SecureDirectoryStream? true
        newDirectoryStream(dir, "*.txt")   → sun.nio.fs.UnixSecureDirectoryStream;SecureDirectoryStream? true
        newDirectoryStream(dir, Filter)    → sun.nio.fs.UnixSecureDirectoryStream;SecureDirectoryStream? true
    JDK 内部类 sun.nio.fs.UnixSecureDirectoryStream 在这个 JDK 里存在

API:7 个方法

实测 · 本机执行输出
  [api]
    SecureDirectoryStream 声明的方法(7 个):
        deleteDirectory(Object)
        deleteFile(Object)
        getFileAttributeView(Class)
        getFileAttributeView(Object, Class, LinkOption)
        move(Object, SecureDirectoryStream, Object)
        newByteChannel(Object, Set, FileAttribute)
        newDirectoryStream(Object, LinkOption)
    它继承自 [interface java.nio.file.DirectoryStream];所以仍然有 iterator() 与 close()

按路径操作为什么不安全

实测 · 本机执行输出
  [JDK 21]
    准备:data/{keep.txt, sub/inner.txt};outside/{secret.txt, inner.txt}(都是不该被删的)。
    场景 1(检查再使用,TOCTOU):先检查 data 不是符号链接:true;检查之后、使用之前,攻击者把 data 改名为 data.moved,并把 data 换成指向 outside 的符号链接
      Files.delete(data/keep.txt) → 成功;outside/keep.txt 还在? false;data.moved/keep.txt 还在? true
    场景 2:提前 toRealPath() = data(把路径固定下来)
      攻击者同样换掉 data 后,Files.delete(real/keep.txt) → 成功;outside/keep.txt 还在? false(路径字符串没变,仍会经过新的符号链接)
    场景 3:先打开 DirectoryStream(data),再换掉 data。迭代得到的条目(Path 带完整前缀):[data/keep.txt, data/sub];这些条目是原目录里的(keep.txt、sub),没有 secret.txt 与 outside 的内容
      对条目调用 Files.delete(data/keep.txt) → 成功;data.moved/keep.txt 还在? true;outside/keep.txt 还在? false(条目是路径,删除时按路径重新解析)
    data 目录恢复。outside 里:[inner.txt, secret.txt];data 里:[keep.txt, sub]

walkFileTree 里的竞争

实测 · 本机执行输出
  [JDK 21]
        pre  data
        pre  data/sub
          (攻击者此刻把 data/sub 换成指向 outside 的符号链接)
        file data/sub/inner.txt
          → 删除 data/sub/inner.txt
        post data/sub
        file data/keep.txt
        post data
    遍历之后 outside 里:[secret.txt](原来有 inner.txt 与 secret.txt);sub.moved 里:[inner.txt]
  [JDK 27]
        pre  data
        pre  data/sub
          (攻击者此刻把 data/sub 换成指向 outside 的符号链接)
        file data/sub/inner.txt
          → 删除 data/sub/inner.txt
        post data/sub
        file data/keep.txt
        post data
    遍历之后 outside 里:[secret.txt](原来有 inner.txt 与 secret.txt);sub.moved 里:[inner.txt]

用 SecureDirectoryStream 抵御替换

实测 · 本机执行输出
  [JDK 27]
    准备:data/{keep.txt, sub/inner.txt};outside/{secret.txt, inner.txt}。
    流打开之后,data 被改名为 data.moved,data 换成指向 outside 的符号链接(data 现在是符号链接? true)
    通过流 sds.deleteFile("keep.txt") → 成功;data.moved/keep.txt 还在? false;outside/keep.txt 还在? true
    通过流打开子目录 sub,条目 [/private/var/folders/km/b69gr9cs3dv8qsm8rjjb4qqc0000gn/T/sds15862479477539469773/data/sub/inner.txt];删除 inner.txt → 成功;data.moved/sub 里剩 [];outside 里剩 [inner.txt, keep.txt, secret.txt]
    按路径对比:Files.delete(data/keep.txt) 会走到 outside(上一节已验证)——这里 outside/keep.txt 仍在说明流没有被误导

操作、符号链接与「名字不是沙箱」

实测 · 本机执行输出
  [JDK 27]
    newByteChannel(new.txt, CREATE_NEW, WRITE) 之后 data/new.txt 内容:via-stream
    再次 CREATE_NEW → FileAlreadyExistsException: new.txt
    通过流的 PosixFileAttributeView 改权限后:rw-------;readAttributes().size() = 10
    deleteDirectory(非空目录 sub) → DirectoryNotEmptyException: null
    deleteFile(目录 sub) → FileSystemException: sub: Operation not permitted
    newDirectoryStream(sub) 得到 UnixSecureDirectoryStream,条目 [/private/var/folders/km/b69gr9cs3dv8qsm8rjjb4qqc0000gn/T/sds14902985670484253058/data/sub/inner.txt]
    清空后 deleteDirectory(sub) 成功,sub 存在? false
    sds.move(new.txt → other 流里的 moved.txt):data 里还有 new.txt? false;other/moved.txt 内容 via-stream
        deleteFile("a/b"         ) → NoSuchFileException: a/b
        deleteFile("../keep.txt" ) → NoSuchFileException: ../keep.txt
        deleteFile("."           ) → FileSystemException: .: Operation not permitted
    deleteFile("../outside/x1.txt")(相对于流的目录,指向兄弟目录)→ 成功;outside/x1.txt 还在? false
    deleteFile(绝对路径 outside/x2.txt)(不在流的目录里)→ 成功;outside/x2.txt 还在? false
    deleteFile(绝对路径 data/abs-target.txt,在流的目录里)→ 成功;文件还在? false
    newDirectoryStream(lnk)(默认,跟随)条目:[/private/var/folders/km/b69gr9cs3dv8qsm8rjjb4qqc0000gn/T/sds14902985670484253058/data/lnk/inner.txt, /private/var/folders/km/b69gr9cs3dv8qsm8rjjb4qqc0000gn/T/sds14902985670484253058/data/lnk/secret.txt]
    newDirectoryStream(lnk, NOFOLLOW_LINKS) → FileSystemLoopException: lnk: Too many levels of symbolic links or unable to access attributes of symbolic link
    读 filelnk 属性:默认 isSymbolicLink = false;NOFOLLOW_LINKS 的 isSymbolicLink = true
    deleteFile(filelnk) 删的是链接本身:outside/secret.txt 还在? true;filelnk 还在? false
    deleteFile(lnk)(目录符号链接)删的是链接本身:outside 还在? true;lnk 还在? false

迭代器与关闭后的状态

实测 · 本机执行输出
  [state]
    第二次调用 iterator() → IllegalStateException: Iterator already obtained
    遍历完得到 [keep.txt, sub];再 hasNext() = false
    耗尽后 next() → NoSuchElementException
    关闭后 it.hasNext() = false
    重复 close() 不报错
    关闭后(未读过)的 hasNext() = false
    关闭后 next() → NoSuchElementException
    流打开后、迭代前删除 keep.txt:迭代结果 [sub](弱一致:这次没有出现 keep.txt;文档说可能反映也可能不反映之后的变化)
    对普通文件 newDirectoryStream → NotDirectoryException
    对不存在的路径 → NoSuchFileException

分步演示

SecureDirectoryStream
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。macOS 27 arm64(MacOSXFileSystemProvider),JDK 17.0.20.1、21.0.12.1、27;改名与符号链接替换由程序在固定的时刻执行(确定性模拟),不是真实的并发攻击,也没有测量竞争窗口的大小
    • 没有验证:Linux 上 JDK 17 / 21 是否返回 SecureDirectoryStream(没有 Linux 环境)、JDK 27 在 macOS 上启用它的原因(没有读 JDK 源码或发行说明)、Files.walkFileTree 内部是否使用 SecureDirectoryStream、Windows 的行为、SecureDirectoryStream 在并发使用与大量目录项下的性能
    京ICP备2026067502号-1京公网安备11010502063487号