文章
理解边界,验证假设。编辑准则 ↗
Pwn4 实验室
首页/内存与二进制/格式化输出被截断以后:用返回值恢复需要的长度信息
Pwn4 实验室 / 文章

格式化输出被截断以后:用返回值恢复需要的长度信息

一个缓冲区里已经出现可读文字,不表示格式化结果完整。本文说明怎样理解 snprintf 的空间参数与返回值,怎样区分错误、完整输出和截断,以及为什么重新分配以后仍需检查第二次操作,并为日志、路径和机器字段选择不同的截断政策。

Pwn4 实验室 编辑部

有结尾标记,也可能只有半份结果

假设本地程序把任务名与状态组合成一段文字,窗口中能够正常显示开头,维护者便认为格式化完成。稍后发现关键尾部不见了。这里需要区分字符串表示是否成立与结果是否完整:一个可以被当作字符串读取的缓冲区,仍可能只保存本次应有内容的一部分。测试如果只检查开头,就无法发现这种情况。先写清完整结果应包含什么,再使用接口返回信息判断,才能让格式化真正满足需求。

snprintf 的空间参数限制本次输出可使用的容量,并将终止零字符计入这一限制;返回值则描述在空间足够时本应生成的内容数量,不含终止字符。GNU 文档明确指出,当相应非负返回值达到或超过所给空间时,结果未被完整保存。不要把返回值简单当作当前缓冲区实际写入的长度。它提供的信息正好可以用于识别缺失,但前提是程序按约定解释,而不是仅调用函数后继续使用内容。

先审查空间参数的来源

调用位置需要证明目标确有对应空间。传给接口的上限不是一个希望值,不能写成超过实际容量的数字。若目标是某块数组中间的位置,应当使用当前位置剩余可用空间,而非整块数组最初的总容量。审查时把起点、容量和已使用数量放在一起核对,能够发现一些看起来有界、实际上边界仍然不成立的调用。函数名中含有限制,并不代表调用者可以省略容量推理。

如果空间来自动态分配,分配是否成功与容量计算是否有效也属于前提。程序不能在没有取得可用对象时继续格式化,也不能把一个已经失去意义的计算结果当作真实大小。对于有限练习,先设置合理的应用上限,并让失败路线可观察,比盲目尝试很大的空间更容易验证。测试不应把资源耗尽当作正常覆盖手段,而应围绕明确边界检查调用政策。

还要说明数量的单位。普通字符缓冲的容量与另一些宽字符接口的容量规则不能凭名称相近就互换。GNU 文档专门指出,相关宽字符格式化接口在空间不足时的返回约定也与 snprintf 不同。若项目同时使用多套接口,应分别阅读说明,避免抽取一个过度通用的辅助函数,把本来不同的语义压成相同判断。适度重复清楚的规则,比复用一个含糊规则更可靠。

错误、截断与完整结果需要不同路线

首先检查返回结果是否表示输出错误,再在有效的非负范围内判断完整性。不能先把负值转成无符号数量,再试图用它安排分配或追加;这种顺序会丢失原来的错误含义。接口返回类型与容量类型不同,比较和转换都应当建立在先行验证上。阅读代码时应沿实际顺序确认,而不只看最终条件中似乎同时出现了所有变量。

如果结果完整,应用可以按约定交付目标;如果发生截断,则需要决定是拒绝、重新生成还是明确允许有限显示。这三种政策不应混用。一个给人看的短摘要可能允许截短,但一个将被当作完整标识或路径的字段,通常不能未经判断就使用半份结果。具体规则由用途决定,应写在调用接口附近,让以后修改格式字符串的人知道为何必须检查完整性。

错误发生时,也不应该直接拿缓冲区里的某段内容冒充完整结果。程序可能需要保留诊断或交付失败状态,但调用者必须知道本次没有取得被承诺的数据。测试可以使用能够稳定触发应用拒绝路线的有限输入,核对错误分类与后续行为。重要的是验证失败不会继续走成功路径,而不是强行在所有环境中制造相同底层错误,并用一次偶然表现替代接口契约。

根据需要的长度重新生成,也要检查整个过程

若选择扩大空间,返回的信息可以帮助规划下一次所需容量,但仍需为终止字符和应用上限作出正确处理。不能只把一个数值加一就认为所有前提已经成立。需要验证数值能否用于容量表示,分配是否成功,以及下一次调用是否真正取得完整结果。对小型本地工具,可以把这些步骤写成清楚的单一路线,避免错误状态被多层封装悄悄忽略。

第二次格式化也不是无条件成功。两次操作之间使用的数据或设置如果发生变化,原先测得的需要长度未必继续成立。测试与设计应说明输入在这段期间是否稳定。如果能够保持稳定,就明确这一前提;如果无法保证,就需要有界的重试或其他适合用途的策略,而不是无限循环扩大空间。关键在于每一步都验证自己实际取得的结果,而非只相信第一次推算。

使用先测长度再生成的方案时,同样要理解接口在零容量条件下的约定,而不是把一个没有分配空间的目标随意当作普通写入对象。GNU 文档描述了零空间时不写入的情况。是否采用这种方式取决于设计,但应把测量与写入分成明确步骤,避免让测量产生业务效果。记录中也应区分它们各自的返回结果,让读者知道哪一个值用于规划,哪一次调用负责生成最终内容。

逐段追加时,剩余空间需要随状态更新

有些练习不是一次生成全部文本,而是依次追加几段。此时需要维护已经实际保存的内容与剩余空间,不能把某次本应生成的长度直接当成实际增长量。尤其在截断以后,继续按完整长度推进目标位置,可能让后续推理不再对应真实对象。应当先处理当前段是否完整,再决定是否允许进入下一段。状态一旦失去依据,后续所有有界调用也会跟着使用错误前提。

可以为追加过程建立简单的不变量:当前写入位置处于目标范围内,已确认内容满足表示要求,剩余容量与起点一致。每一步结束都检查它们是否仍成立,而不是只在全部完成后查看结果。对测试而言,选择一段恰好接近边界的输入,能验证追加路线是否正确处理空间不足。不要只用所有内容都很短的样本,因为它们可能从未进入需要维护边界的分支。

如果某段失败,后续政策也要清楚。是丢弃整份结果、保留明确标记的部分显示,还是返回已经完成的有限结构,应当由接口约定决定。不能让调用者从缓冲区里碰巧留下什么去推断成功程度。将数据完整性与失败状态一起交付,能避免上层界面把部分文字当作完整路径或机器字段使用,也使错误排查不必再猜测是哪一段被静默省略。

截断对不同内容有不同后果

一条诊断日志缺少末尾,可能失去定位原因的关键字段;一个文件路径缺少末尾,则可能不再表示预期目标;一份机器数据缺少字段,会破坏接收方的解释。不能因为它们都以字符数组存放,就采取相同容错政策。审查时先问这段结果将交给谁、由谁判断有效,再决定是否允许截断。输出用途是选择处理方式的依据,不是一个与技术无关的附加说明。

对于允许简短显示的内容,也要让使用者能够理解其完整性状态。可以选择明确的显示政策,而不是静默掩盖丢失。但显示政策本身不能被混入机器协议,尤其不应在另一程序期待固定字段时添加未说明的标记。界面摘要与原始数据最好保留清楚的边界,让一个为了阅读方便的限制,不会反过来改变业务字段的含义。这样既能控制界面空间,也不会让短显示污染实际数据。

多字节文本还会增加另一个检查层次:按字节容量得到的截短部分,不应被自动认定为适合当前编码的完整可显示文本。具体处理应遵循应用采用的编码与界面规则,不能仅因为末尾有零字符就声称文本完全有效。测试可以选择真实需要支持的有限字符样本,检查完整输出与显示政策分别成立。不要用纯英文短词的一次成功,代替对实际内容范围的验证。

将格式模板与外部文本分开

格式化接口中的模板具有自己的语法,普通输入内容则属于被呈现的数据。接口设计应让二者各司其职,不要因为外部文本看起来像一句提示,就把它直接当作程序格式模板。审查时标出模板从哪里来、哪些参数是数据,可以减少对可变内容的错误解释。

同样,参数类型需要与模板中相应转换匹配。空间限制只控制一项条件,不会替应用纠正类型关系。涉及模板调整的代码评审,应同时核对参数与返回值处理,而不是只检查最终显示是否看起来美观。

为边界准备有目的的测试组

一组基本测试可以围绕足够空间、恰好容纳完整结果与终止字符、少一点空间以及应用明确拒绝的过长输入展开。每个样本先写预期完整性,再检查返回结果和实际内容。边界数量应从约定推导,而非试到某个值碰巧出现差异。测试运行在专用有限对象中,不应故意向不存在的空间写入,也不必把崩溃当成边界检查是否存在的证明。

对追加函数,再加入前面已有内容和剩余空间不足的情形;对重分配方案,检查失败路线不会交付旧的半份结果;对机器字段,检查消费者不会接受被截断的表示。测试项应当对应实际设计,不必为没有承诺的功能建立虚假覆盖。完整的测试记录会说明每条结果验证了哪个条件,也会保留暂时无法验证的限制,而不是仅输出一个笼统的通过数量。

格式字符串修改以后,要重新检查最长合理内容和已有容量政策。一次看似只是增加提示文字的改动,可能改变原先边界测试的预期。把相关测试放在格式化封装旁边,能让维护者知道文本与空间约定之间存在联系。修复最终应当让完整性成为接口可以解释的结果,而不是让下一层在看到一段可读开头以后自行猜测后面是否还缺少内容。

延伸阅读

讨论

参与讨论

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

继续阅读