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