gVisor usermem 包详解:Sentry 如何安全访问应用虚拟内存
【免费下载链接】gvisorApplication Kernel for Containers项目地址: https://gitcode.com/GitHub_Trending/gv/gvisor
在 gVisor 中,Sentry(沙箱内核)运行在 Go 运行时之上,而被沙箱化的应用程序拥有自己独立的虚拟地址空间。两者之间的每一次数据交换——read/write的缓冲区拷贝、openat的路径字符串、pselect6的 fd 集合、ptrace的内存窥视——都必须穿过一道受控的边界。pkg/usermem包正是为这道边界定义的抽象:它为 Sentry 访问应用内存提供原语,核心是代表虚拟地址空间 I/O 能力的IO接口,以及代表"一组连续地址区间按序操作"的IOSequence(类比 Linux 内核的struct iov_iter)。读完本文,你将掌握 Sentry 访问用户内存的三种标准模式、IO接口的完整方法语义,以及MemoryManager作为主实现的实际执行路径(含 AddressSpace I/O 与内部映射两条通路),并能看懂 iovec 导入、字符串拷贝等辅助函数的 Linux 兼容性语义。
一、包定位与两大核心类型
pkg/usermem/README.md 对包职责的概括只有两句话:定义 Sentry 访问应用内存的原语,并指出两个主要类型:
IO接口:代表一个虚拟地址空间,并提供针对该地址空间的 I/O 方法。它是最底层的原语,主要实现是 mm.MemoryManager。IOSequence:代表IO中一组"各自连续"的地址区间的集合,按序(sequentially)操作,类似 Linux 的struct iov_iter。
包内的其他文件分别为:
- pkg/usermem/usermem.go:
IO接口、IOSequence、各种Copy*辅助函数; - pkg/usermem/bytes_io.go:基于字节切片的
IO实现,用于测试; - pkg/usermem/marshal.go:把
IO适配为marshal.CopyContext,供代码生成的 marshal 框架使用。
理解这个分层的关键在于:usermem包本身不持有任何内存状态,它只是接口与组合逻辑;真正"拥有"应用地址空间的是每个 Task 的mm.MemoryManager(定义于 pkg/sentry/mm)。usermem位于依赖层次的更底层,因此文件系统、网络等更下层模块不能反过来依赖kernel包,只能通过上层传入的IOSequence参数来读写应用内存——这正是 README 中第三类使用模式的基础。
二、IO 接口:单地址拷贝与原子操作
IO 接口定义了以下方法,每个方法都带有明确的锁序前提(调用者不得持有mm.MemoryManager.mappingMu及其锁序中后续的任何锁),这对避免死锁至关重要:
| 方法 | 语义 | 返回值 |
|---|---|---|
CopyOut(ctx, addr, src, opts) | 把len(src)字节从 Go 侧写入addr处的应用内存 | 已拷贝字节数,短拷贝时返回非 nil 错误 |
CopyIn(ctx, addr, dst, opts) | 把addr处的应用内存读入dst | 同上 |
ZeroOut(ctx, addr, toZero, opts) | 从addr开始将toZero字节清零 | 已清零字节数 |
CopyOutFrom(ctx, ars, src, opts) | 从safemem.Reader读入到一组地址区间ars | 已拷贝字节数;src.ReadToBlocks至多被调用一次 |
CopyInTo(ctx, ars, dst, opts) | 把一组地址区间ars的内容写入safemem.Writer | 已拷贝字节数;dst.WriteFromBlocks至多被调用一次 |
SwapUint32/CompareAndSwapUint32/LoadUint32 | 对addr处的 32 位值做原子交换/比较交换/加载(要求 4 字节对齐) | 旧值 |
源码中的 TODO(usermem.go#L93-L97)说明了CopyOutFrom/CopyInTo要求底层至多调用一次ReadToBlocks/WriteFromBlocks的代价:实现方必须把safemem.Blocks汇聚为单个 slice 再传入,从而产生额外分配;作者计划添加CopyOutFromIter/CopyInToIter放宽这一限制。
所有方法共享 IOOpts:
type IOOpts struct { // If IgnorePermissions is true, application-defined memory protections set // by mmap(2) or mprotect(2) will be ignored. (Memory protections required // by the target of the mapping are never ignored.) IgnorePermissions bool }这是理解 README 第二、三类使用模式的分水岭:IgnorePermissions控制是否忽略应用通过mmap(2)/mprotect(2)设置的内存保护——例如PTRACE_POKEDATA这类调试操作需要无视应用自身的读写保护;但注释同时强调,映射目标本身要求的安全属性永远不会被忽略。
2.1 IOReadWriter:给地址空间套上 io.Reader/Writer
IOReadWriter把一个固定的IO+ 起始Addr包装成io.ReadWriter,每次Read/Write后自动推进内部地址。注意其语义细节:地址空间没有"文件尾"的概念,Read只有在底层CopyIn返回io.EOF时才返回io.EOF;访问未映射或不可读内存应返回EFAULT。另外,当地址推进发生回绕(wraparound)时,代码会把地址置为^0并强制返回EFAULT,从结构上杜绝地址回绕漏洞。
三、IOSequence:iov_iter 在 gVisor 中的对应物
IOSequence 是一个轻量结构体:
// IOSequence holds arguments to IO methods. type IOSequence struct { IO IO Addrs hostarch.AddrRangeSeq Opts IOOpts }它把"在哪个地址空间(IO)、操作哪些区间(Addrs,一个hostarch.AddrRangeSeq有序区间序列)、以什么权限(Opts)"三要素打包成可向下传递的 I/O 参数。其上提供的操作包括:
- 区间裁剪:
NumBytes()、DropFirst/DropFirst64、TakeFirst/TakeFirst64——每完成一段拷贝后用这些方法推进游标; - 批量拷贝:
CopyOut/CopyIn/ZeroOut(分别委托给CopyOutVec/CopyInVec/ZeroOutVec,在区间序列上循环执行单区间拷贝,短拷贝返回nil错误); - 流式拷贝:
CopyOutFrom/CopyInTo(直接委托IO的同名方法); - io 适配器:
Reader(ctx)与Writer(ctx)返回 IOSequenceReadWriter,它同时实现io.Reader、io.Writer和tcpip.Payloader(Len()返回剩余字节数)。读尽后返回io.EOF,写越界返回ErrEndOfIOSequence。
一个值得注意的实现细节是NumBytes()的注释(usermem.go#L417-L437):即使Addrs非空,若其中包含零长度区间,NumBytes()也可能返回 0。作者指出许多调用方用NumBytes() == 0做零长度 I/O 短路,这与 Linux 的access_ok()行为存在微妙差异——长期方案是把ErrWouldBlock等检查移入reader.ReadToBlocks内部。这类注释是阅读 gVisor 源码理解其 Linux 兼容性取舍的好样本。
3.1 辅助函数:字符串、向量与解析
usermem包还提供了一批面向系统调用实现的便捷函数,全部构建在IO之上:
- CopyStringIn:从应用内存拷入 NUL 结尾字符串(不含尾 NUL),超过
maxlen时截断并返回ENAMETOOLONG。实现上有两处优化值得注意:初始缓冲最多 256 字节(copyStringMaxInitBufLen),按 64 字节增量(copyStringIncrement)读取;且每次读取会被缩短到不跨越页边界(addr.RoundDown() != end.RoundDown()时回退到页边界),避免不必要地缺页——注释明确写道"faulting in a page unnecessarily is expensive"。 - CopyOutVec / CopyInVec / ZeroOutVec:在区间序列上顺序拷贝/清零,上限为
ars.NumBytes()与数据长度中的较小者。 - CopyInt32StringsInVec:从应用内存解析空格分隔的十进制 int32 序列,其注释逐条列出了与 Linux 内核
kernel/sysctl.c:proc_dointvec(write=1)的行为一致性(溢出/非法字符返回EINVAL、末尾空白计入读取字节数等),以及刻意不同的两点(不隐式限制为PageSize-1;空区间返回EINVAL)。这是 gVisor 在源码注释中显式对齐 Linux 内核行为的典型做法。 - CopyObjectOut / CopyObjectIn:通过反射对定长结构做编解码后拷贝,注释警告性能敏感路径应手动编码并直接调用
uio.CopyOut/uio.CopyIn。
四、三大使用模式(README 核心内容)
README 的"Major usage patterns"部分给出了 Sentry 访问应用内存的三条正交路径,选择哪一条取决于:你在依赖层次中的位置、是否运行在目标 Task 的 goroutine 上、是否需要遵守应用的内存保护。
4.1 模式一:Task 的 Copy* 包装器(最常见路径)
适用场景:上下文位于kernel包层级及以上(例如 pkg/sentry/syscalls/linux 中大多数系统调用实现),需要遵守应用内存保护、并且运行在该 Task 的 goroutine 上。
此时使用 kernel/task_usermem.go 中定义的kernel.Task.Copy*包装器:
// CopyInBytes is a legacy wrapper for t.MemoryManager().CopyIn. // // Preconditions: The caller must be running on the task goroutine. func (t *Task) CopyInBytes(addr hostarch.Addr, dst []byte) (int, error) { return t.MemoryManager().CopyIn(t, addr, dst, usermem.IOOpts{}) }同文件还提供:
- CopyInString:
maxlen上限内的 NUL 结尾字符串拷贝,底层就是usermem.CopyStringIn; - CopyInVector:拷贝
execve那样的 NULL 结尾字符串数组(argv/envp),通过maxElemSize/maxTotalSize双重限额防止失控分配; - CopyInIovecs / CopyInIovecsAsSlice:把用户态的
struct iovec数组导入为hostarch.AddrRangeSeq。其实现 copyInIovecs 的注释明确对齐了 Linuxlib/iov_iter.c:import_iovec() => fs/read_write.c:rw_copy_check_uvector()的语义:单个区间长度超过ssize_t范围返回EINVAL,区间末端溢出返回EFAULT,包含应用地址范围之外的地址返回EFAULT,总长度被静默截断到MAX_RW_COUNT; - CopyContext:返回
marshal.CopyContext,其内部通过getMemoryManager()锁住t.mu并调用tmm.IncUsers()递增引用计数(任务已退出则返回ESRCH,地址空间已回收则返回EFAULT),defer tmm.DecUsers(ctx)释放。这条路径使得拷贝可以在非 Task goroutine 上安全进行——IncUsers/DecUsers机制阻止了地址空间在拷贝中途被销毁。
4.2 模式二:直接拿 MemoryManager,无视应用权限
适用场景:上下文在kernel包层级及以上,但模式一的约束不成立——典型如PTRACE_POKEDATA这类必须忽略应用内存保护的调试接口。此时通过kernel.Task.MemoryManager取得该 Task 的mm.MemoryManager,直接调用其IO方法:
mm := t.MemoryManager() mm.CopyIn(ctx, addr, dst, usermem.IOOpts{IgnorePermissions: true})IgnorePermissions: true会让 MemoryManager.CopyIn 跳过应用级保护检查(withInternalMappings将其传入权限校验路径),但内核侧(映射目标要求)的安全属性仍然生效。
4.3 模式三:下层模块只接收 IOSequence
适用场景:上下文位于kernel包层级以下(例如文件系统 I/O、TCP/IP 栈)。由于 Go 包依赖不允许下层反向依赖kernel包(那会形成环),这些模块无法直接引用Task或MemoryManager,只能由上层把 I/O 参数以IOSequence的形式传下来。
README 指出的便捷构造函数有两个,都在 kernel/task_usermem.go 中:
// SingleIOSequence 对应 Linux 的 lib/iov_iter.c:import_single_range()。 func (t *Task) SingleIOSequence(addr hostarch.Addr, length int, opts usermem.IOOpts) (usermem.IOSequence, error) // IovecsIOSequence 对应 Linux 的 lib/iov_iter.c:import_iovec()。 func (t *Task) IovecsIOSequence(addr hostarch.Addr, iovcnt int, opts usermem.IOOpts) (usermem.IOSequence, error)两者的实现要点:
- SingleIOSequence 先按
linux.MAX_RW_COUNT静默截断长度(对应 Linuxrw_verify_area()的截断行为),再调用mm.CheckIORange校验,失败返回EFAULT,成功则组装{IO: t.MemoryManager(), Addrs: ..., Opts: opts}返回; - IovecsIOSequence 校验
iovcnt在[0, linux.UIO_MAXIOV]范围内(越界EINVAL),再经CopyInIovecs导入 iovec 数组。注意其注释强调:opts作用于返回的IOSequence(即后续数据拷贝),而不是作用于读取 iovec 数组本身。
真实调用链可以完整走一遍:sys_read_write.go 中sys_read首先dst, err := t.SingleIOSequence(addr, si, usermem.IOOpts{}),随后dst被传入文件系统Read;文件系统实现位于kernel之下,只看到usermem.IOSequence,通过dst.CopyOutFrom(ctx, reader)完成"文件页 → 应用缓冲区"的拷贝。sys_aio.go 中io_getevents的事件缓冲区导入同样调用t.SingleIOSequence(...)。这条"上层导入、下层消费"的链路与 README 的三条模式一一对应。
五、主实现深挖:mm.MemoryManager 的两条拷贝通路
README 指出mm.MemoryManager是IO的主要实现。其核心方法位于 pkg/sentry/mm/io.go,以 CopyOut 为例,执行路径清晰分三步:
func (mm *MemoryManager) CopyOut(ctx context.Context, addr hostarch.Addr, src []byte, opts usermem.IOOpts) (int, error) { ar, ok := mm.CheckIORange(addr, int64(len(src))) if !ok { return 0, linuxerr.EFAULT } if len(src) == 0 { return 0, nil } // Do AddressSpace IO if applicable. if mm.asioEnabled(opts) && len(src) < copyMapMinBytes { return mm.asCopyOut(ctx, ar, src, opts) } // Go through internal mappings. return mm.imCopyOut(ctx, ar, src, opts) }CheckIORange:把[addr, addr+len)校验/裁剪到应用地址范围内,越界直接EFAULT——这正是 iovec 导入时makeIovec(task_usermem.go#L232-L247)依赖的同一道防线;- AddressSpace I/O 通路(
asCopyOut):当平台支持且数据量小于copyMapMinBytes时,直接通过平台层mm.as.CopyOut触及应用内存。若触发platform.SegmentationFault,调用handleASIOFault处理故障页后重试;若平台返回AddressSpaceIOUnavailable,则回退到内部映射通路。从源码结构看,这条通路是性能关键路径——它让哨兵进程(sentry)在部分平台上无需先经内部映射缓冲就能完成小拷贝; - 内部映射通路(
imCopyOut):通过mm.withInternalMappings(ctx, ar, hostarch.Write, opts.IgnorePermissions, ...)把目标区间映射为 Sentry 可见的安全内存,再用safemem.CopySeq完成实际字节搬运。注意第三个实参正是IOOpts.IgnorePermissions——模式二的"无视应用保护"语义在此落地。
所有底层 I/O 错误最终由 translateIOError 统一翻译成 Linux 语义的EFAULT(可选记录调试日志),这保证了无论走哪条通路,向上返回的错误形态一致,上层系统调用只需处理EFAULT/EINVAL等标准 errno。
CopyIn/ZeroOut(io.go#L166-L239)与向量化的CopyOutFrom/CopyInTo(io.go#L271, L318)遵循完全相同的三分支结构,只是方向与数据来源换成safemem.Reader/Writer。
六、测试实现与 marshal 集成
BytesIO 是IO接口的第二个实现:把"地址"解释为字节切片偏移,越界读写返回EFAULT,用clear()实现ZeroOut。它使得任何只依赖usermem.IO的逻辑(CopyInVec、CopyStringIn、IOSequence的游标推进等)都可以脱离真实地址空间在单测中运行——pkg/usermem/usermem_test.go 即以此方式覆盖这些路径。
marshal.go 中的IOCopyContext则把任意IO适配为 gVisor 的marshal.CopyContext接口(CopyScratchBuffer/CopyInBytes/CopyOutBytes)。gVisor 中大量面向用户态数据的读写由代码生成器(go_marshal)生成,Task.CopyContext(task_usermem.go#L302-L310)在运行时把context.Context、Task和IOOpts三者封装进去,从而让生成的代码以统一接口在"当前 Task 自己的 goroutine"与"跨 goroutine 持有引用"两种场景下都能正确完成拷贝。
七、小结:一张依赖关系图
把 README 的骨架与源码证据合起来,usermem包在 gVisor 中的位置可以概括为:
usermem.IO是边界契约:CopyIn/CopyOut处理单区间,CopyInTo/CopyOutFrom处理多区间流式 I/O,原子 32 位操作服务 futex/信号量类同步,IOOpts.IgnorePermissions区分"普通系统调用"与"调试/特权访问"两类调用者;mm.MemoryManager是主实现:CheckIORange统一防越界,AddressSpace I/O 与内部映射双通路兼顾性能与可移植性,错误统一折算为EFAULT;kernel.Task提供三种入口:Task goroutine 上的Copy*便捷方法(遵守应用保护)、MemoryManager直连(可忽略应用保护)、SingleIOSequence/IovecsIOSequence(向kernel之下的模块传递 I/O 参数);IOSequence是可传递的游标:上层导入一次,下层用DropFirst推进、用CopyOutFrom/Reader消费,其语义刻意对齐 Linuximport_single_range()/import_iovec()。
对希望扩展 gVisor 的开发者而言,一条实用准则是:写新的系统调用时,优先走模式一的Task.Copy*;实现ptrace类接口时给IOOpts设IgnorePermissions;而一旦你的代码落在pkg/sentry/fs、pkg/tcpip等下层包里,就应该像sys_read_write.go那样,只接受usermem.IOSequence参数并调用其CopyOutFrom/CopyInTo——这样既符合包的依赖层次,也复用了包内已经与 Linux 行为对齐的全部边界检查。
【免费下载链接】gvisorApplication Kernel for Containers项目地址: https://gitcode.com/GitHub_Trending/gv/gvisor
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考