文章
理解边界,验证假设。编辑准则 ↗
Pwn4 实验室
首页/内存与二进制/复制区域可能重叠时,先证明区间关系再选择接口
Pwn4 实验室 / 文章

复制区域可能重叠时,先证明区间关系再选择接口

在同一缓冲区里移动内容,与把数据复制到独立目标并非相同前提。本文通过有限的本地数组示意,说明怎样描述源区间、目标区间与长度,为什么一次正常结果不能证明 memcpy 适用,以及选择 memmove 后还必须验证哪些边界和数据含义。

Pwn4 实验室 编辑部

名称不同的两个指针,可能访问同一块空间

假设一个本地练习需要移除缓冲区开头的一段内容,于是把后面的数据复制到前面。代码中的源变量与目标变量名称不同,却都指向同一数组的不同位置。此时不能仅凭变量不同,就认为两片空间独立。审查复制操作要看实际访问区间,而不是看参数名称。把起点与长度标在数组图上,往往能够直接看出原来未被明确考虑的关系。

GNU C 库说明,memcpy 的源与目标不能重叠;可能重叠时,应使用能够处理这种情况的 memmove。但选择接口只解决其中一个前提,并不替应用验证可访问长度、目标容量或最终文本表示。分析时应当逐层处理:先确认数据在哪里,再确认需要移动多少,最后判断接口是否满足区间关系。把所有问题简化成替换一个函数名,容易漏掉仍然存在的边界错误。

用下标图描述区间,比猜测复制方向更可靠

对于一个已经明确有效的本地数组,可以用下标表示源起点、目标起点与复制数量。先画出操作以前的内容,再标注哪些位置将被读取、哪些位置将被写入。只要两个区间覆盖了共同位置,就必须考虑重叠条件。图示不需要依赖真实内存地址,也不需要制造越界访问,使用很小的固定数组即可说明关系。这样能让读者理解为什么同一个数组内的两个入口并不独立。

应当特别区分相邻与交叠。一个区间的结束位置正好是另一个区间的起点,与实际共享若干元素不同。讨论时明确采用怎样的区间表示,能够避免“结束位置算不算在里面”的歧义。例如用起点和数量描述,再在图中只标出真正访问的元素。不要从自然语言中模糊的“前半段”“后半段”直接推导调用参数,实际操作需要能够核对的范围。

先证明范围有效,再谈重叠

源区间必须包含所需数据,目标区间也必须拥有足够空间。即使两个区间确实不重叠,只要长度超出任一有效范围,复制就仍不满足前提。反过来,能够处理重叠的接口也不会凭空扩大目标容量。审查时应分别列出这两项条件,避免看到 memmove 就放弃其余检查。接口提供的某项保障,不应被误读成对调用者所有输入的全面验证。

长度来源也需要与当前起点配套。如果源入口已经偏移到数组中部,不能继续把整块数组的总容量作为可读数量。若目标从中间开始写,剩余可用空间也与整块容量不同。一个简单的局部数据结构可以同时保留总容量与当前有效数量,但调用位置必须选对含义。测试应包含合法子区间,确认程序处理的是约定范围,而非只在起点恰好位于数组开头时成立。

为什么不能以一次 memcpy 结果作为依据

违反接口前提的程序,即使某次运行产生看似正确内容,也不能据此证明用法被支持。不同构建、库实现或数据规模下可能出现不同表现。调试目标不应是找出当前实现恰好采用的复制次序,然后围绕它编写依赖。应当回到公开接口约定,选择能够覆盖真实区间关系的方式。一次正常输出只是观察,不能把不被承诺的行为变成稳定契约。

因此不建议通过反复运行不合约调用来判断“这台机器是否安全”。更有价值的本地实验,是在明确有效的区间上使用符合前提的接口,并验证结果是否符合应用目的。若需要解释旧代码为什么不可靠,可以用数组示意与接口文档说明缺失的前提,无需依赖实际损坏来证明。学习过程应当训练对条件的推理,而不是训练从偶然表现中寻找通用保证。

memmove 保留的是源区间原本的内容

GNU 文档说明,memmove 在区域重叠时仍按源区间原有内容完成复制。这让应用可以表达合法的数组内部移动,而不必自己依赖特定复制方向。但调用者仍需要决定目标操作意味着什么。例如把后半部分前移以后,当前有效长度是否减少,末尾多余内容如何解释,都属于应用状态。完成字节移动与完成数据结构更新是两个不同步骤。

一个移除前缀的练习,可以先写出预期剩余内容与新的有效数量,再核对移动后的对应位置。不能因为数组末尾仍保留旧字节,就直接把全部容量当成新内容输出;也不能认为 memmove 会自动添加文本终止条件。若数据是字符串,应单独确认表示依然成立;若是显式长度的二进制区域,应使用更新后的长度。数据类型与协议决定后续规则,复制接口不会替它们作出选择。

在同一缓冲中插入内容需要先计算新状态

向中间插入数据时,程序可能先腾出空间,再放入新内容。这里需要验证目标是否有容纳新总量的空间,以及移动后哪些数据应保持不变。不要先执行操作,再根据结果试着修正长度。可以在操作前把原有效数量、插入位置、插入数量和新有效数量列在一起,检查关系是否成立。有限的本地数组例子足以说明这一过程,不必使用真实大文件或业务缓存。

如果插入来源本身也位于同一缓冲区,问题会比单次移动更复杂:第一步可能改变第二步原本想读取的内容。此时不仅要检查每次调用的区间,还要推演整个操作序列中的数据来源。一个合约的单次复制,不代表多个步骤组合后仍表达预期。可以先设计清楚的中间状态,必要时采用明确的临时表示,再针对最终语义验证,而不是不断调换调用顺序试到看似正确为止。

不要用指针外观代替归属信息

当两个入口来自不同函数或不同对象字段时,是否可能指向同一底层区域,需要沿来源追踪。代码审查应询问接口是否允许调用者传入重叠区域,而不只是检查当前一个调用点。若函数承诺支持任意合法子区间,就应采用满足这一承诺的实现;若明确要求独立区域,应将前提写入接口说明并在可行范围中验证。模糊的要求会让调用者与实现者形成不同假设。

对于跨模块接口,最好同时说明源内容是否可被修改、目标写入多少、是否允许重叠以及返回结果含义。不能把这些政策全藏在内部实现细节里,再要求调用者自行推断。接口以后可能被新的调用位置复用,原先只在一个地方成立的独立性未必继续成立。将区间关系作为正式契约,能让维护者在扩展用途前知道需要重新证明哪些条件。

字节数量与元素数量不能混用

memcpy 与 memmove 的数量以字节表达,而某些数组处理逻辑采用元素数量。调用边界必须清楚完成这项转换,不能因为字符数组例子中数值恰好相同,就把同样写法搬到其他元素类型。计算也应遵守目标类型与允许范围,不能先让数量变得无意义,再交给复制接口。这里的核心是保持单位清楚,而不是背诵一个脱离对象类型的通用长度表达式。

测试可以使用不止一种内容布局,但每个样本都必须处于明确有效的范围。检查移动结果时按元素或字节的实际需求比较,并确认没有把填充或未使用空间当成业务内容。若只是要移动原始数据,就用显式长度表达;若涉及对象语义,则需要先确认这种操作适用于该类型与程序规则。复制内存不等于自动完成任意对象的正确复制,不能由简单数组练习推导无限广泛的结论。

编译诊断与运行工具只能提供部分线索

编译器可能提示某些可疑的长度或重叠用法,但没有警告不表示所有调用关系已经被证明。尤其在运行时才决定起点和数量的接口中,编译器未必掌握完整前提。遇到诊断时应逐条回到真实对象与范围,而不是直接关闭;没有诊断时则继续进行契约审查。工具能帮助定位问题,却不能替应用定义它希望支持哪些输入组合。

运行检查也应被视为具体样本下的观察。测试没有发现异常,不等于所有区间都已覆盖,更不意味着违反前提的调用得到认可。可以把测试矩阵组织成独立区域、合法前移、合法后移以及边界拒绝等类别,每类说明预期。拒绝用例应在进入不适用的内存操作前由程序验证路线处理。这样测试提供的是应用行为的证据,而不是用系统是否报错来猜测接口规则。

操作顺序应当保留数据来源的解释

审查一连串移动时,可以给每个阶段画一幅很小的内容图。图中注明当前有效区域与下一步准备读取的区域,避免把操作前的预期状态套到操作后。若一个源入口是根据旧布局计算出来的,还要确认它在布局改变以后是否仍表示所需内容。

这种阶段图尤其适合比较删除、插入与替换三种操作。它们可能使用相同复制接口,却有不同的新长度和保留范围。把操作目的写在图旁,能够让审查围绕真正的数据变化进行,而不是只争论哪一种调用形式看起来更简洁。

将修复落到状态与回归条件上

如果原问题来自重叠,就记录源和目标为何可能共享区域,以及新实现怎样满足这一条件。若同时发现长度错误,也应分别修复并解释,避免把两种原因混成一条“换函数即可”的经验。修改后检查有效数量、终止条件和后续消费者是否同步使用新状态。字节移动正确,却让下一层仍按旧长度读取,仍然没有完成应用需要的修复。

最后保留一个小而具体的回归样本,让未来维护者看到区间关系为何重要。样本应当能够从操作前内容推导预期结果,并说明哪些前提已经检查。不要仅留下一个偶然输出正确的截图。复制操作的可靠性来自对象范围、单位、区间关系与状态更新共同成立;理解这些条件,才能在代码重构或接口复用以后继续判断实现是否符合原来的数据契约。

延伸阅读

讨论

参与讨论

评论审核后公开,邮箱地址不会公开。

继续阅读