解决什么问题:Agent 的
read_file类工具只做normalize + startsWith,在 Windows 上会被 junction、8.3 短名、硬链接从沙箱里带出去。本文给出一个纯 JDK 17 的四层文件沙箱、29 条真实攻击语料,以及「哪一层拦住了什么、还剩哪一条拦不住」的实测答案。适合谁读:在做 Agent 工具网关、MCP server、代码执行沙箱的 Java 后端工程师,以及需要给模型权限画边界的人。能收获什么:一套能直接编译运行的沙箱实现(javac/java全跑通)、Windows 路径语义的四个具体坑与对应 API 真相、TOCTOU 窗口的实测微秒数、四层防御的每调用开销,以及一条「Java 层做不到、必须交给 OS」的硬边界。
近一周的公开报道里,Agent 的权限边界被反复提起:Codex CLI 被曝自 0.157.0 起默认拉起常驻守护进程,issue 冲上 Hacker News 热榜;Anthropic 披露内部评测中的自主 Agent 多次越界,随后切断了评测环境的实时联网;微软则把 Windows 上面向 Agent 的隔离执行容器 MXC 推向正式可用。这三件事指向同一个判断:给 Agent 的能力加护栏,正在从「加分项」变成「上线前置条件」。而 Java 后端最常见的护栏,就是那个看起来最简单、也最容易写错的东西——文件路径沙箱。
我自己写了个沙箱,造了 29 条语料,在 JDK 17.0.20 / Windows 11 上真跑了一遍。结论不太好听:常见的写法漏 3 条,四层防御漏 1 条,而那 1 条不是「再加一层」能补的。
一、实验台:一个能编译运行的四层沙箱
先说清楚口径。以下所有数字都来自本机执行,环境实采如下(由System.getProperty与FileStore打印,不是手填):
| 项 | 实测值 |
|---|---|
| JDK | java version 17.0.20 2026-07-21 LTS(build 17.0.20+7-LTS-191) |
| 编译器 | javac 17.0.20 |
| 操作系统 | os.name = Windows 11,os.version = 10.0,os.arch = amd64 |
| 卷 | NTFS,支持的属性视图[owner, dos, acl, basic, user] |
| 沙箱根 | ...\demo17\run\ws |
| 敏感目标 | ...\demo17\run\outside\creds\key.txt,内容标记OUTSIDE-SECRET-DO-NOT-READ-1f9c |
沙箱分四层,每层只干一件事:
public Decision check(String raw) { // ---- L0 语法层:字符与命名空间形态,先于任何文件系统调用 ---- if (raw.indexOf('\0') >= 0) return Decision.deny(Layer.L0_SYNTAX, "NULL_BYTE", "含 NUL"); String low = raw.toLowerCase(Locale.ROOT); if (low.contains("%2e") || low.contains("%2f") || low.contains("%5c") || low.contains("%25")) return Decision.deny(Layer.L0_SYNTAX, "ENCODED_TRAVERSAL", "疑似 URL/双重编码穿越"); if (raw.startsWith("\\\\")) { if (raw.startsWith("\\\\.\\") || raw.startsWith("\\\\?\\")) return Decision.deny(Layer.L0_SYNTAX, "DEVICE_OR_EXTENDED_NS", "Win32 设备/扩展命名空间"); return Decision.deny(Layer.L0_SYNTAX, "UNC", "UNC 路径"); } if (raw.indexOf('*') >= 0 || raw.indexOf('?') >= 0) return Decision.deny(Layer.L0_SYNTAX, "WILDCARD", "含通配符,可能被 shell 展开"); Path candidate = (isAbsolute(raw) ? Paths.get(raw) : rootGiven.resolve(raw)).normalize(); // ---- L1 词法层:归一化之后必须仍在根前缀内 ---- if (!candidate.startsWith(rootGiven.normalize())) return Decision.deny(Layer.L1_LEXICAL, "PREFIX_MISS", "归一后越出根:" + candidate); // L2 / L3 见下文 }L2 是整套防御的重心:别看字符串,问内核。关键在于toRealPath()会跟随 junction、解析 8.3 短名、吃掉尾点,而normalize()只会天真地删..。
/** 对最深「已存在」祖先做 toRealPath,尾部不存在的名字原样拼回。 */ static Path canonicalize(Path candidate) throws IOException { List<String> tail = new ArrayList<>(); Path p = candidate; while (p != null && !Files.exists(p, LinkOption.NOFOLLOW_LINKS)) { Path name = p.getFileName(); if (name != null) tail.add(0, name.toString()); p = p.getParent(); } if (p == null) throw new IOException("no existing ancestor"); Path real = p.toRealPath(); // 跟随链接,解析短名/尾点/大小写 for (String s : tail) real = real.resolve(s); return real; }拿到real之后再做一次前缀比较,并拿真实路径去撞名字黑名单——顺序很重要,先撞字符串黑名单会被短名绕过(下面有实测)。L3 则处理「链指进根内」这种 L2 放行的情况。
二、29 条语料:谁漏了,漏在哪
语料分五组:正常路径(测误杀)、穿越、编码/畸形、链接、名字黑名单。判定为放行的样本会真的去读文件,用读到的内容判逃逸,不看返回值。
语料表:11 条最能说明问题的样本
| # | 输入 | 词法+黑名单基线 | 基线读到 | 四层沙箱 |
|---|---|---|---|---|
| 1 | data/note.txt | ALLOW | inside-ok-alpha | ALLOW |
| 5 | DATA/NOTE.TXT | ALLOW | inside-ok-alpha | ALLOW |
| 6 | ../../outside/creds/key.txt | DENY L1 | — | DENY L1 |
| 11 | %2e%2e%2f...key.txt | ALLOW | NoSuchFileException | DENY L0 |
| 13 | data/note.txt\0.exe | DENY L0 | — | DENY L0 NULL_BYTE |
| 17 | \\?\C:\Windows\win.ini | DENY L0 | — | DENY L0 |
| 20 | data/note.txt:hidden | DENY L0 | — | DENY L0 ADS |
| 22 | link/key.txt(junction 指根外) | ALLOW | LEAK | DENY L2 CANONICAL_ESCAPE |
| 23 | pub.key(硬链接指根外) | ALLOW | LEAK | ALLOW →LEAK |
| 24 | selflink/note.txt(根内 junction) | ALLOW | inside-ok-alpha | DENY L3 REPARSE |
| 27 | SECRET~1/key.txt(8.3 短名绕黑名单) | ALLOW | LEAK | DENY L2 NAME_BLOCK_ON_CANONICAL |
汇总数字:攻击样本 24 条,基线放行 9 条、真实逃逸 3 条;四层沙箱放行 4 条、真实逃逸 1 条。正常路径 5 条,两种实现都是零误杀。各层落点:L0 拦 8 条、L1 拦 6 条、L2 拦 5 条、L3 拦 1 条、放行 4 条。
被放行的 4 条里,有 3 条其实读不出东西(..;/、全角../、以及创建失败的符号链接),它们在沙箱眼里就是「根内一个不存在的文件」,最终NoSuchFileException。这里要区分两个常被混为一谈的指标:放行不等于逃逸。安全设备上「放行数」是噪声,「真读到根外字节」才是事故。
三、Windows 路径语义的四个真相
三种「链」里,普通权限能造出来的正好是杀伤力最大的两种:mklink /J(junction)和mklink /H(硬链接)都成功了,而创建真符号链接被系统直接拒——cmd返回「客户端没有所需的特权」,Files.exists(sym.txt, NOFOLLOW)为 false。所以下表只列我实测到的两列,符号链接那一列我没有未测先写。
| API / 手段 | junction(ws\link→根外) | 硬链接(ws\pub.key→根外文件) |
|---|---|---|
Files.isSymbolicLink | false | false |
Files.readSymbolicLink | NotLinkException: Reparse point is not a symbolic link | NotLinkException: 此文件或目录不是一个重分析点。 |
Files.getAttribute("dos:reparsePoint") | IllegalArgumentException: 'reparsePoint' not recognized | 同左 |
Files.getAttribute("dos:directory") | IllegalArgumentException: 'directory' not recognized | 同左 |
toRealPath() | 解析到目标目录(越界) | 路径本身(不变,仍显示在根内) |
Files.isSameFile(它, 目标) | true | true |
| 普通权限可否创建 | 能 | 能 |
第一,isSymbolicLink对 junction 返回 false,想靠它挡链接等于没挡。第二,我原本以为dos:reparsePoint能拿到重解析点标志,实测IllegalArgumentException——这台 JDK 17.0.20 的DosFileAttributeView只认readonly(我顺手验了dos:readonly = false是可用的),别照抄脑子里的 API 表。第三,真正能用的信号有两个:readSymbolicLink抛出的异常文案里带 "Reparse point"(这句是 JDK 硬编码的英文,和系统本地化的「不是一个重分析点」可区分),以及toRealPath()与normalize()的字符串比较。
static String linkKind(Path p) { if (Files.isSymbolicLink(p)) return "symlink"; try { Files.readSymbolicLink(p); return "symlink"; } catch (java.nio.file.NotLinkException e) { if (String.valueOf(e.getMessage()).contains("Reparse point")) return "junction(重解析点,非符号链接)"; } catch (IOException | RuntimeException e) { return null; } try { Path real = p.toRealPath(), abs = p.toAbsolutePath().normalize(); if (!real.toString().equalsIgnoreCase(abs.toString())) return "real!=lexical"; } catch (IOException ignore) { return null; } return null; }还有一个坑值得单独点名:L3 必须扫描「调用方给的那条路径」,不能扫 L2 解析后的真实路径。我第一版写的就是firstReparseOnChain(real),结果selflink/note.txt永远扫不到——toRealPath已经把selflink这一段吃掉了,链只存在于原始candidate里。表现就是 L3 拦停数恒为 0,看着「四层齐全」,其实第三层是个摆设。
顺带一条容易被忽略的边界:Path.startsWith在 Windows 上是大小写不敏感的(WindowsPath.compareTo语义),所以DATA/NOTE.TXT这类变体不需要自己toLowerCase;反过来,如果你在 Linux 容器里跑同一套代码,大小写敏感,白名单语义会跟着变。同一份沙箱代码,换个 OS 就不是同一个安全边界。
四、硬链接:四层全过,Java 层无解
ws\pub.key是指向outside\creds\key.txt的硬链接。四层沙箱的判定是 ALLOW,而且读得到内容:
四层沙箱判定 = ALLOW 经 ws\pub.key 读到 = OUTSIDE-SECRET-DO-NOT-READ-1f9c 经 outside\... 读到 = OUTSIDE-SECRET-DO-NOT-READ-1f9c isSameFile(pub.key, target) = true原因很干净:硬链接不是链接,它是同一个文件记录上的第二个名字。它的规范路径确确实实在根内,所以 L2 没理由拒绝;它不是重解析点,L3 也看不见。更糟的是,「打开后再复检一次」也没用——readChecked走完 L0→L3 两次,两次都通过。
唯一能在路径维度识破它的是Files.isSameFile,但它要求你已经知道可疑目标;把整个磁盘的文件都比对一遍显然不现实。所以这里必须承认 Java 层的尽头:权限属于文件对象,不属于路径字符串。我用纯 JDK 的AclFileAttributeView给真实文件加了一条 DENY(DENY插在原 ACL 最前面,测完还原):
AclFileAttributeView view = Files.getFileAttributeView(target, AclFileAttributeView.class, LinkOption.NOFOLLOW_LINKS); List<AclEntry> original = new ArrayList<>(view.getAcl()); AclEntry deny = AclEntry.newBuilder() .setType(AclEntryType.DENY) .setPrincipal(Files.getOwner(target)) .setPermissions(EnumSet.of(AclEntryPermission.READ_DATA)) .build(); List<AclEntry> next = new ArrayList<>(); next.add(deny); next.addAll(original); view.setAcl(next); // 之后无论走 pub.key 还是 creds\key.txt,读都被内核拒实际运行结果(ACL 随后被还原,还原后硬链接重新可读 31 字节):
principal = DESKTOP-RAQBN1J\Admin,原 ACL 条数 = 3 已写入 DENY ACE:DESKTOP-RAQBN1J\Admin:READ_DATA:DENY 读硬链接被拒 -> java.nio.file.AccessDeniedException: ...\run\ws\pub.key 读原路径也被拒 -> AccessDeniedException一条 ACE 顶掉 Java 层十层。工程含义很直接:沙箱的正解是「Agent 用独立账号跑 + 根外目录不给读权限」,Java 里的这四层只是纵深防御的第二道,不是第一道。
五、TOCTOU:窗口能量出来,但归不了零
三种实现,三种结局
窗口本身只占「校验完成 → 句柄建立」这一段,但三种用法给出的结局完全不同:
- 把
check的判定结果缓存(性能优化里太常见了):窗口从微秒被拉长成 TTL。实测里,我先在link -> public时缓存判定,再把link改指到creds,然后按缓存的「放行」结论去读调用方给的原始路径——读到OUTSIDE-SECRET-DO-NOT-READ-1f9c,全程零次重新校验。这不是抢概率,是必漏。顺带一个反直觉分支:如果缓存的是「判定 + 规范路径」,则读回来的是inside-public-ok,没越界,但内容已经不是link此刻指向的东西了——安全问题变成了一致性问题。 - 每次读取前重新
check:窗口只剩「校验完成 → 句柄建立」这一段。3000 个样本实测中位147.6 µs,p95 253.7 µs,p99 375.6 µs,max 1450.2 µs;而攻击者换一次 junction(rmdir /q+mklink /J)中位456.7 ms。按到达时刻随机粗算,单次命中概率约0.00032。这里必须给个折扣:换链走的是cmd.exe,两次进程启动占了大头,直接调 Win32 设置重解析点会快一个量级以上,所以真实比值比这更不利。 - 真抢一次:后台线程不间断换链,前台正常调用 4 秒,共956次调用,DENY 280 次,IO 异常(链正在被换掉)519 次,读到根外内容 1 次(0.1046%)。窗口确实能抢到——但代价是 54% 的调用直接失败。
于是有了个反直觉的结论:抢窗攻击在这条路径上不划算,牺牲可用性换一次读取成功,不如直接改内容或走硬链接。TOCTOU 在文件沙箱里更像成本放大器,而不是主攻击面;但在「检查一次、长期缓存」的实现里,它是必漏项。
六、代价:四层把 3.9 µs 变成了 4.4 ms
安全不能不谈开销。基准口径:同一 JVM、单线程、预热 2 轮、采样 9 轮取中位数,内循环次数按操作轻重分别取 400(含 syscall)和 20000(纯内存)。
| 操作 | 中位 µs/次 | min | max | 折算 次/秒 |
|---|---|---|---|---|
naiveLexical(resolve+normalize+startsWith) | 8.241 | 7.694 | 9.834 | 121,338 |
Files.exists(leaf) | 141.465 | 131.975 | 201.244 | 7,069 |
canonicalize(一次 toRealPath,文件已存在) | 1,915.8 | 1,623.0 | 2,361.1 | 522 |
canonicalize(尾巴不存在,退到父目录) | 938.8 | 802.6 | 1,319.1 | 1,065 |
check全通过(L0→L3 原始实现) | 4,498.2 | 3,415.9 | 5,324.0 | 222 |
check在 L1 拦停(穿越) | 16.668 | 11.932 | 19.251 | 59,995 |
check在 L2 拦停(junction) | 1,279.9 | 986.6 | 1,542.9 | 781 |
check在 L3 拦停(根内 junction) | 1,670.5 | 1,202.6 | 1,755.2 | 599 |
check结果缓存命中 | 0.343 | 0.088 | 0.490 | 2,916,430 |
check全通过(fastLinkScan 优化后) | 1,764.2 | 1,609.5 | 2,498.6 | 567 |
4.5 毫秒里的大头是异常控制流
四层比词法基线贵546 倍(8.2 µs → 4.5 ms),每秒 1000 次工具调用就要吃掉 4.5 秒 CPU。这不是「四层不能要」,而是我的实现先要修:4.5 ms 里的大头是L3 逐段调用readSymbolicLink,每一次非链路径都要抛一个NotLinkException——用异常做控制流,在 Windows 上就是毫秒级的。把 L3 改成「先比candidate与real的字符串,相同就直接跳过扫描」,实测4,498 µs → 1,764 µs,省 61%,且 29 条语料的判定一条没变。
剩下的量级仍然贵(一次toRealPath就要 1.9 ms)。另外提醒一句抖动:同一段naiveLexical代码,两次独立运行的中位数分别是 3.9 µs 和 8.2 µs——Windows 文件系统调用的方差就这样,别拿单次运行做容量规划。所以落地建议按这个顺序(下面这段是配置形态示意,不是本文的实测对象):
agent-file-tool: sandbox: root: D:/agent-ws/task-23817 # 每个任务一个独立根,避免任务间互读 resolve-symlinks: reject # L3:链一律拒,代价是误杀根内合法 junction canonicalize-per-call: true # 禁止缓存 check 结果(第五节证明缓存即必漏) os-isolation: dedicated-account # 正解:独立账号 + 根外不给 READ_DATA budget: { max-read-bytes: 1048576, timeout-ms: 2000 }resolve-symlinks: reject要单独提醒一句:它是安全换可用性的取舍。我的语料里selflink/note.txt读的是根内合法文件,严格模式下被误杀;而 monorepo、node_modules、迁移过来的老仓库里,根内 junction 非常常见。默认 reject 之前,先数一下自己仓库里有多少条路径会因此变成不可用。
七、适用边界与风险提示
- 只覆盖读路径。写、删、重命名、创建链接、解压(Zip Slip)是另一套语义:写操作还多了「目录尚不存在」的分支,
toRealPath会失败,本文的canonicalize只能退到「最近已存在祖先」,判定精度会掉一档。 - 不含内容级风险。路径合规不代表内容无害:符号链接没越界,但文件里可能是密钥;Prompt 注入也不走文件系统。命令注入同理——把路径拼进 shell 字符串,
data/x.txt & echo INJECTED就能多跑一条命令;必须用ProcessBuilder的 argv 形式传参。 - 不含并发与配额。没做软配额、fd 上限、超时强杀,这些属于运行时沙箱的活。
- 平台相关。所有结论是 JDK 17.0.20 + Windows 11 + NTFS 的实测;换 Linux 或 exFAT,
isSymbolicLink、大小写敏感、ADS、短名这些行为会一起变,语料得重跑。 - 本文不解决「Agent 已经拿到合法内容后怎么办」,也不试图给出一条可以宣布「安全」的层数;能给的只有「哪一层拦住了多少条、还剩哪一条要靠别人」。
- AI 辅助说明:选题与代码由我确定并在本机全量执行,文中数字均为真实运行输出,未使用他人文章素材。
八、怎么复现,以及怎么判定成功
cd demo17 javac -encoding UTF-8 -d out Sandbox.java Setup.java Corpus.java OsAcl.java Bench.java Toctou.java Probe.java java -Dfile.encoding=UTF-8 -cp out Setup # 建实验台,观察 mklink 三条命令的输出 java -Dfile.encoding=UTF-8 -cp out Corpus # 29 条语料表 + 各层落点 java -Dfile.encoding=UTF-8 -cp out Toctou # 缓存逃逸 + 窗口微秒数 + 4 秒抢窗 java -Dfile.encoding=UTF-8 -cp out OsAcl # 三种链的 API 真相 + DENY ACE 生效/还原 java -Dfile.encoding=UTF-8 -cp out Probe # 逐 API 探 junction(本文 API 表的来源)判定标准(不是「能跑就行」):①Corpus汇总里真实逃逸:基线 3 条 / 沙箱 1 条,且那 1 条必须是pub.key;②Probe里 junction 的isSymbolicLink=false、readSymbolicLink抛带 "Reparse point" 的NotLinkException;③OsAcl必须看到AccessDeniedException且最后一条是 ACL 还原成功;④Toctou走缓存那条要打印出根外内容,不缓存那条要DENY L2。四条里任何一条对不上,先怀疑实验台被改过(比如mklink没权限、目录被清理),不要先怀疑结论。
九、结论
把三件事分开,思路会清楚很多:字符串层(L0/L1)能拦住所有「看起来像攻击」的输入,但对「词法合法、语义越界」无能为力;内核层(L2/L3)能拦住 junction 和短名,但对硬链接无能为力;真正能兜住硬链接的只有 OS 层的 ACL 与账号隔离。29 条语料最后剩下的那 1 条,恰好落在「Java 写不出防御」的位置上——这不是坏消息,它告诉你沙箱该在哪一层收口。
如果你的 Agent 工具现在只有normalize + startsWith,那按本文语料,它对link/key.txt、pub.key、SECRET~1/key.txt三条一律放行且真的能读到内容;把Probe.java拷进你的项目跑一遍,你会知道自己线上依赖的是哪一层。
最后想问两个具体问题,欢迎在评论区聊:你的 Agent 文件工具跑在哪个用户下,它现在能不能读C:\Users\<你>\.ssh?以及你有没有缓存过路径校验结果?收藏这篇当作 checklist,下次加新工具时按 29 条语料自测一遍。