解决什么问题: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 打印,不是手填):

项实测值
JDKjava 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 条语料:谁漏了,漏在哪

语料分五组:正常路径(测误杀)、穿越、编码/畸形、链接、名字黑名单。判定为放行的样本会真的去读文件,用读到的内容判逃逸,不看返回值。

24 条攻击语料在四层防御里的落点(实测)

语料表:11 条最能说明问题的样本

#输入词法+黑名单基线基线读到四层沙箱
1data/note.txtALLOWinside-ok-alphaALLOW
5DATA/NOTE.TXTALLOWinside-ok-alphaALLOW
6../../outside/creds/key.txtDENY L1—DENY L1
11%2e%2e%2f...key.txtALLOWNoSuchFileExceptionDENY L0
13data/note.txt\0.exeDENY L0—DENY L0 NULL_BYTE
17\\?\C:\Windows\win.iniDENY L0—DENY L0
20data/note.txt:hiddenDENY L0—DENY L0 ADS
22link/key.txt(junction 指根外)ALLOWLEAKDENY L2 CANONICAL_ESCAPE
23pub.key(硬链接指根外)ALLOWLEAKALLOW → LEAK
24selflink/note.txt(根内 junction)ALLOWinside-ok-alphaDENY L3 REPARSE
27SECRET~1/key.txt(8.3 短名绕黑名单)ALLOWLEAKDENY 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.isSymbolicLinkfalsefalse
Files.readSymbolicLinkNotLinkException: Reparse point is not a symbolic linkNotLinkException: 此文件或目录不是一个重分析点。
Files.getAttribute("dos:reparsePoint")IllegalArgumentException: 'reparsePoint' not recognized同左
Files.getAttribute("dos:directory")IllegalArgumentException: 'directory' not recognized同左
toRealPath()解析到目标目录(越界)路径本身(不变,仍显示在根内)
Files.isSameFile(它, 目标)truetrue
普通权限可否创建能能

第一,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:窗口能量出来,但归不了零

一次 read_file 调用的 TOCTOU 时间轴(实测)

三种实现,三种结局

窗口本身只占「校验完成 → 句柄建立」这一段,但三种用法给出的结局完全不同:

  1. 把 check 的判定结果缓存(性能优化里太常见了):窗口从微秒被拉长成 TTL。实测里,我先在 link -> public 时缓存判定,再把 link 改指到 creds,然后按缓存的「放行」结论去读调用方给的原始路径——读到 OUTSIDE-SECRET-DO-NOT-READ-1f9c,全程零次重新校验。这不是抢概率,是必漏。顺带一个反直觉分支:如果缓存的是「判定 + 规范路径」,则读回来的是 inside-public-ok,没越界,但内容已经不是 link 此刻指向的东西了——安全问题变成了一致性问题。
  2. 每次读取前重新 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 设置重解析点会快一个量级以上,所以真实比值比这更不利。
  3. 真抢一次:后台线程不间断换链,前台正常调用 4 秒,共 956 次调用,DENY 280 次,IO 异常(链正在被换掉)519 次,读到根外内容 1 次(0.1046%)。窗口确实能抢到——但代价是 54% 的调用直接失败。

于是有了个反直觉的结论:抢窗攻击在这条路径上不划算,牺牲可用性换一次读取成功,不如直接改内容或走硬链接。TOCTOU 在文件沙箱里更像成本放大器,而不是主攻击面;但在「检查一次、长期缓存」的实现里,它是必漏项。

六、代价:四层把 3.9 µs 变成了 4.4 ms

安全不能不谈开销。基准口径:同一 JVM、单线程、预热 2 轮、采样 9 轮取中位数,内循环次数按操作轻重分别取 400(含 syscall)和 20000(纯内存)。

操作中位 µs/次minmax折算 次/秒
naiveLexical(resolve+normalize+startsWith)8.2417.6949.834121,338
Files.exists(leaf)141.465131.975201.2447,069
canonicalize(一次 toRealPath,文件已存在)1,915.81,623.02,361.1522
canonicalize(尾巴不存在,退到父目录)938.8802.61,319.11,065
check 全通过(L0→L3 原始实现)4,498.23,415.95,324.0222
check 在 L1 拦停(穿越)16.66811.93219.25159,995
check 在 L2 拦停(junction)1,279.9986.61,542.9781
check 在 L3 拦停(根内 junction)1,670.51,202.61,755.2599
check 结果缓存命中0.3430.0880.4902,916,430
check 全通过(fastLinkScan 优化后)1,764.21,609.52,498.6567

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 条语料自测一遍。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐