news 2026/9/13 17:39:51

gVisor usermem 包详解:Sentry 如何安全访问应用虚拟内存

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
gVisor usermem 包详解:Sentry 如何安全访问应用虚拟内存

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/LoadUint32addr处的 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/DropFirst64TakeFirst/TakeFirst64——每完成一段拷贝后用这些方法推进游标;
  • 批量拷贝CopyOut/CopyIn/ZeroOut(分别委托给CopyOutVec/CopyInVec/ZeroOutVec,在区间序列上循环执行单区间拷贝,短拷贝返回nil错误);
  • 流式拷贝CopyOutFrom/CopyInTo(直接委托IO的同名方法);
  • io 适配器Reader(ctx)Writer(ctx)返回 IOSequenceReadWriter,它同时实现io.Readerio.Writertcpip.PayloaderLen()返回剩余字节数)。读尽后返回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包(那会形成环),这些模块无法直接引用TaskMemoryManager,只能由上层把 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.MemoryManagerIO的主要实现。其核心方法位于 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) }
  1. CheckIORange:把[addr, addr+len)校验/裁剪到应用地址范围内,越界直接EFAULT——这正是 iovec 导入时makeIovec(task_usermem.go#L232-L247)依赖的同一道防线;
  2. AddressSpace I/O 通路asCopyOut):当平台支持且数据量小于copyMapMinBytes时,直接通过平台层mm.as.CopyOut触及应用内存。若触发platform.SegmentationFault,调用handleASIOFault处理故障页后重试;若平台返回AddressSpaceIOUnavailable,则回退到内部映射通路。从源码结构看,这条通路是性能关键路径——它让哨兵进程(sentry)在部分平台上无需先经内部映射缓冲就能完成小拷贝;
  3. 内部映射通路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的逻辑(CopyInVecCopyStringInIOSequence的游标推进等)都可以脱离真实地址空间在单测中运行——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.ContextTaskIOOpts三者封装进去,从而让生成的代码以统一接口在"当前 Task 自己的 goroutine"与"跨 goroutine 持有引用"两种场景下都能正确完成拷贝。

七、小结:一张依赖关系图

把 README 的骨架与源码证据合起来,usermem包在 gVisor 中的位置可以概括为:

  1. usermem.IO是边界契约CopyIn/CopyOut处理单区间,CopyInTo/CopyOutFrom处理多区间流式 I/O,原子 32 位操作服务 futex/信号量类同步,IOOpts.IgnorePermissions区分"普通系统调用"与"调试/特权访问"两类调用者;
  2. mm.MemoryManager是主实现CheckIORange统一防越界,AddressSpace I/O 与内部映射双通路兼顾性能与可移植性,错误统一折算为EFAULT
  3. kernel.Task提供三种入口:Task goroutine 上的Copy*便捷方法(遵守应用保护)、MemoryManager直连(可忽略应用保护)、SingleIOSequence/IovecsIOSequence(向kernel之下的模块传递 I/O 参数);
  4. IOSequence是可传递的游标:上层导入一次,下层用DropFirst推进、用CopyOutFrom/Reader消费,其语义刻意对齐 Linuximport_single_range()/import_iovec()

对希望扩展 gVisor 的开发者而言,一条实用准则是:写新的系统调用时,优先走模式一的Task.Copy*;实现ptrace类接口时给IOOptsIgnorePermissions;而一旦你的代码落在pkg/sentry/fspkg/tcpip等下层包里,就应该像sys_read_write.go那样,只接受usermem.IOSequence参数并调用其CopyOutFrom/CopyInTo——这样既符合包的依赖层次,也复用了包内已经与 Linux 行为对齐的全部边界检查。

【免费下载链接】gvisorApplication Kernel for Containers项目地址: https://gitcode.com/GitHub_Trending/gv/gvisor

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 17:39:45

嵌入式系统核心知识压缩:2小时直击时钟、GPIO、中断与RTOS

1. 这不是“速成”&#xff0c;是嵌入式系统知识骨架的紧急加固 “2小时期末速成”——看到这个标题&#xff0c;我第一反应不是点开&#xff0c;而是放下手头正在调试的STM32F407开发板&#xff0c;泡了杯浓茶。干了十多年嵌入式教学、企业级固件开发和研究生复试指导&#xf…

作者头像 李华
网站建设 2026/9/13 17:36:33

车规级CAN-LIN网关OTA刷写协同设计

1. 项目概述&#xff1a;为什么一个车规级网关的刷写升级&#xff0c;必须同时吃透CAN和LIN两套协议&#xff1f;“CAN-LIN网关刷写升级方案&#xff1a;从CAN诊断到LIN从机OTA的完整技术实现”——这个标题里藏着整车电子电气架构演进中最硬核的一环。我干汽车电子底层开发十年…

作者头像 李华