V8 引擎的六道 CLR 拦截线:JS 为什么读不到 System.IO 文件

发布时间:2026/8/15 18:20:07
V8 引擎的六道 CLR 拦截线:JS 为什么读不到 System.IO 文件 # V8 引擎的六道 CLR 拦截线JS 为什么读不到 System.IO 文件 把 V8 脚本丢到生产环境没几天告警里就开始出现“用户脚本尝试读取服务器 appsettings.json”的日志。第一反应是去看网络层——但网络没问题问题在更上游。Microi 吾码 AI 引擎允许用户在 V8 里写 JS 直接调 C# 对象那么问题来了**用户写 new System.IO.File().ReadAllText(...)V8 到底会不会乖乖照做** 答案是不做。V8 不是靠 Network ACL 拦也不是靠 jint.Native 关键字过滤而是靠 Jint 的 TypeResolver.MemberFilter 在每一次 CLR 成员访问时过六道开关。本文用 2026-05-01 上线的安全加固源码 Microi.Server/Microi.net/V8Engine/V8Engine.csSHA-256 e5ee7684ae0463e6e2fb11df4c01961463e597b70ce9bd7e8e745c8e6271e60d第 68–138 行做实证告诉你这六道线到底在拦什么、为什么 System.Type.GetType(System.IO.File) 这种借道也会被拦、漏掉哪一种代码就还有逃逸可能。 ![V8 引擎六道 CLR 拦截线示意图AI 概念图](https://static.itdos.com/itdos/promotion/2026/08/14/v8-sandbox-clr-six-gates/images/202608/cover-1786728833.png) **摘要** V8 引擎把 CLR 类型按命名空间前缀 完全名匹配建立黑白名单再用 Jint 的 TypeResolver.MemberFilter 在每个 .Member 访问点做即时拦截这比禁用关键字更稳因为它连 Type.GetType、Activator.CreateInstance 这类反射入口都一并封禁但仍以成员级为粒度用户 V8 代码仍可使用 V8.Http、V8.HDFS 等受控扩展。 ## ① 直觉方案为什么失效关键词黑名单拦不住反射 最容易想到的方案是“禁词”——脚本里只要出现 System.IO 就拒绝。但任何用过 .NET 反射的人都知道把字符串交给 System.Type.GetType() 之后原始脚本里根本看不到 System.IO 这五个字符 javascript // 关键词黑名单根本看不到这五个字符 var t System.Type.GetType(System.IO.File); var bytes t.ReadAllText(D:/Work/microi.net.all/Microi.Server/appsettings.json); return bytes; 关键词拦截器对这种写法完全透明但“读文件”这个动作照样发生。真正能挡的是**在 Jint 解析 .Member 这一帧**就拒绝把 System.IO.File 这个 CLR 类型实体返回给 JS 引擎——而不是去检查源码文本里写了什么词。 ## ② 六道线在 V8Engine.cs 的哪个位置 打开 Microi.Server/Microi.net/V8Engine/V8Engine.cs 静态字段区2026-05-01 加固一次性落下了 6 段关键代码分别覆盖 4 个命名空间前缀维度 1 个完全名维度 1 个解析时即时过滤 csharp // 行 68–92按命名空间前缀拦截维度 1–4 private static readonly string[] _blockedNamespacePrefixes new[] { System.IO., System.Diagnostics.Process, System.Diagnostics.EventLog, System.Diagnostics.PerformanceCounter, System.Reflection., System.Net.Sockets., System.Net.NetworkInformation., System.Runtime.InteropServices., System.Runtime.Loader., Microsoft.Win32., System.Data., Microsoft.Data., }; // 行 94–106完全名精确匹配维度 5 private static readonly HashSet _blockedExactTypes new HashSet(StringComparer.Ordinal) { System.AppDomain, System.Activator, System.Type, System.RuntimeType, System.RuntimeTypeHandle, System.Environment, System.GC, }; 第 6 道不是数据是行为行 109–138 实现的 IsBlockedMember(MemberInfo) 是即时拦截函数行 232–235 把它绑到 Jint 的 TypeResolver.MemberFilter csharp // 行 232–235Jint 解析每个 CLR 成员都会调用的过滤器维度 6解析时拦截 private static readonly TypeResolver _safeTypeResolver new TypeResolver { MemberFilter m !IsBlockedMember(m) }; 任何 JS 表达式 foo.Bar、JS 代码 new X()、甚至 Type.GetType(...) 的内部分解最终都要把目标 MemberInfoType、PropertyInfo、MethodInfo 等抛给 MemberFilter 过一道命中黑名单的返回 falseJint 直接抛 JavaScriptException 而不是真的把这个 CLR 对象交给脚本。 ## ③ 4 道前缀线它们拦的是家族不是单个类型 System.IO. 这一个前缀就一锅端掉 File、FileInfo、Directory、Path、StreamReader 全部 50 个类型System.Reflection. 干掉 Assembly、MethodInfo、FieldInfo 和 Activator.CreateInstance 走的入口。看似粗放但和 C# 类型系统是兼容的只要命名空间下任何一个类型有越权可能整个命名空间就不该让 JS 拿到。 | 前缀 | 公开例子 | 越权面 | | --- | --- | --- | | System.IO. | File.ReadAllText、Directory.Delete | 任意文件系统读写 | | System.Diagnostics.Process | Process.Start(cmd.exe) | 启动子进程 / RCE | | System.Reflection. | Assembly.Load(恶意dll)、MethodInfo.Invoke | 动态加载 / 反射调用 | | System.Net.Sockets. | TcpClient.Connect | 绕过 V8.Http 的网络出口 | | Microsoft.Win32. | Registry.LocalMachine.OpenSubKey | 注册表读写 | | System.Data.、Microsoft.Data. | SqlConnection | 数据库直连绕过 V8.Db | 注意和“完全名”那一行相比前缀线只对**以小数点结尾**的形式生效。System.Diagnostics无小数点不会被前缀线命中——但这没关系System.Diagnostics 命名空间下唯一需要拦的几个类型Process、EventLog、PerformanceCounter已经单独列成完全名命中。 ## ④ 第 5 道线8 个完全名为什么是按精确名匹配 HashSet 用 StringComparer.Ordinal按字节比对不区分大小写大小写做 8 项精确拦截。看起来只防 8 个类很弱但它防的是 4 个最经典的反射逃逸入口 - System.Type —— Type.GetType(System.IO.File) 这条命令是反射链的根必须禁注释里也写了防止 Type.GetType 绕过。 - System.RuntimeType / System.RuntimeTypeHandle —— typeof(File).GetType() 实际返回的是 CLR 内部的 RuntimeType单禁 Type 会被绕过。 - System.Activator —— Activator.CreateInstance(Type.GetType(...)) 是动态实例化的万能钥匙。 - System.AppDomain —— AppDomain.CreateDomain 加载任意程序集跨 AppDomain 取对象。 - System.Environment —— 含 Exit、FailFast、SetEnvironmentVariable可以关掉进程、改环境变量。 - System.GC —— GC.Collect()、GC.WaitForPendingFinalizers() 干扰 GC 节奏破坏内存统计。 这条线用 StringComparer.Ordinal 而不是 OrdinalIgnoreCase 是有意为之System.Type 在大小写不敏感的哈希集合里会和 system.type 同样命中反射链是大小写敏感的语言名字必须字字对应。 ## ⑤ 第 6 道线解析时即时过滤才是真正的哨兵 前 5 道都是数据第 6 道是**行为**。它的工作流程是Jint 每解析一个 JS 表达式碰到 foo.Bar、碰到 new X()、碰到 Type.GetType(...) 内部展开的 MethodInfo.Invoke 时都会从 .NET 反射库拉一个 MemberInfo 实例丢给 _safeTypeResolver.MemberFilter由它决定这个成员是否能让 JS 看见 csharp // 行 109–127MemberFilter 的实现 private static bool IsBlockedMember(MemberInfo member) { if (member null) return false; var t member.DeclaringType ?? member.ReflectedType; if (t null) return false; while (t.IsNested t.DeclaringType ! null) t t.DeclaringType; var fullName t.FullName; if (string.IsNullOrEmpty(fullName)) return false; if (_blockedExactTypes.Contains(fullName)) return true; for (int i 0; i _blockedNamespacePrefixes.Length; i) { if (fullName.StartsWith(_blockedNamespacePrefixes[i], StringComparison.Ordinal)) { return true; } } return false; } 注意 DeclaringType ?? ReflectedType当 JS 拿到的是 File.ReadAllText 的 MethodInfo 时它的 DeclaringType 是 FileSystem.IO.FilefullName 是 System.IO.File命中 System.IO. 前缀被拒绝。**这就是为什么 Type.GetType 反射出来的对象也跑不掉**——反射拿到的还是同一个 Type 实例过滤器在 Jint 解析 .Member 那一刻还是会被调用。 Jint 官方文档对 TypeResolver 的建议就是“跨 Engine 复用”。这里也复用了——_safeTypeResolver 是 private static readonly所有 V8 实例共享一份黑名单缓存。这意味着 12 个并发闸门、1024 个脚本缓存槽位背后过滤器本身不会成为热路径瓶颈。 ## ⑥ 用户用得着的V8.* 扩展对象不走黑名单 被拦的是“用户自己 new 出来的 CLR 类型”。V8 引擎启动时行 756 的 V8ExtensionRegistry.InjectAll(engine) 会注入一组受控扩展例如 V8.Http、V8.HDFS、V8.FormEngine、V8.Db、V8.Cache 等。它们的来源是 V8ExtensionRegistry 静态注册的不通过 TypeResolver 解析 JS 端的 new而是直接把 C# 委托挂到 V8.Http 这个 Engine.SetValue 的键下。所以下面的代码是合法的 javascript // 受控扩展对象黑名单不拦因为它们不是走 CLR TypeResolver 解析 var resp V8.Http.Get({ Url: https://api.itdos.com, Timeout: 30 }); return resp; 但同样的 HTTP 调用如果换成直接 new 一个 HttpClient javascript // 走 TypeResolver立刻被前缀 System.Net. 命中拦截 var hc new System.Net.Http.HttpClient(); return hc; ## ⑦ 这套设计的三条边界 第一**只能挡 CLR 类型反射**。JS 自己动态拼装的对象、字符串处理、JSON.parse 出来的对象完全不在拦截范围内——这部分由 MaxStatements、LimitRecursion、LimitMemory 等资源预算兜底参看 2026-08-03 V8 2GB 内存限制那篇不靠黑名单。 第二**不能拦 Assembly.Load**。System.Reflection.Assembly 在 System.Reflection. 前缀里但**反射调用链是另一条路径**——脚本如果能拿到一个不通过 TypeResolver 的反射对象例如通过宿主对象意外暴露的委托过滤就会失效。所以黑名单之外还必须配租户最小权限脚本里调 V8.Db.FromSql 时租户不存在的库根本连不上。 第三**业务需要的能力必须走受控扩展**。V8.Http 不只是代替 HttpClient那么简单它强制走 Limitfalse、Previewfalse、超时、代理、重试审计V8.HDFS 把上传路径只指向公有 HDFS不开放任意目录。把危险但必需的能力放进 V8.* 而不是开放 CLR 类型——这才是 2026-05-01 加固最关键的一条设计原则。 ![V8 拦截线与受控扩展的边界](https://static.itdos.com/itdos/promotion/2026/08/14/v8-sandbox-clr-six-gates/images/202608/concept-boundary-1786728832.png) ## ⑧ 这套设计在源码上怎么被串起来 把上面六道线从源码角度串一遍脚本被丢进 JintJint 在词法分析阶段产出 ASTAST 在解释器层每碰到一个 .Member 节点或 new 节点就会通过 _safeTypeResolver 解析底层 CLR MemberInfo由 MemberFilter 做最终判定。这个过程比检查源码文本晚一拍但比 Assembly.Load 早一拍——所有用户能直接 new 出来的 CLR 类型在 MemberInfo 阶段全部走同一道闸。 这意味着三件事 - 反射链不会绕过Type.GetType(...) 内部展开到 MethodInfo.Invoke 时invoke 这一帧的 MemberInfo 仍然受过滤。 - 第三方包Newtonsoft.Json、Dapper 等只要走 TypeResolver 解析 MethodInfo同样被拦。 - 性能成本集中在每个 .Member 节点的反射查表但 _blockedExactTypes 是 HashSet O(1)、前缀扫描是 12 个 StartsWith整体成本远低于一次 Assembly.Load。 ## ⑨ 一句话总结 V8 引擎不是靠禁词挡危险调用而是把 .NET 反射拉过来的 MemberInfo 按命名空间前缀和完全名做 6 维黑白名单再让 Jint 在每一次 .Member 解析时跑同一个 MemberFilter反射逃逸、动态加载、进程外发都不在脚本手里但业务必需的 HTTP / HDFS / FormEngine 仍由 V8.* 受控扩展提供。 ![V8 拦截链闭环示意图AI 概念图](https://static.itdos.com/itdos/promotion/2026/08/14/v8-sandbox-clr-six-gates/images/202608/concept-closed-loop-1786728832.png) 本文为原创。封面与配图为 AI 概念图源码事实绑定 Microi.Server/Microi.net/V8Engine/V8Engine.cs SHA-256 e5ee7684ae0463e6e2fb11df4c01961463e597b70ce9bd7e8e745c8e6271e60d 行 68 到 138、232 到 235、756Microi.Server/Microi.net/V8Engine/MicroiV8MemoryConstraint.cs SHA-256 5a9d0be91587df2f0b6cd4cab8081cbc1fbd8dc781fed19f4d5d96977317f38a 仅作交叉引用本机 Microi吾码AI 端到端验证需要在 Microi.net.Api 暴露 v8-sandbox-demo 接口引擎后回写本文不写已验证以外的量化数字。