技术文章

在 HotXLS 中安全重放不可信的 EMF 与 WMF

Excel 工作簿可以携带 EMF 与 WMF 图片,而传统的绘制方式是把字节流交给操作系统的图元文件播放器。这个决策值得正面审视:图元文件是面向图形 API 的序列化命令流,回放它等于让一个通过邮件到来的文件去驱动显卡驱动。HotXLS 走另一条路。XLSDecodeVectorScene 自己解析图元文件,校验头部、每个记录大小、声明的记录总数以及文件结束记录的精确位置,断然拒绝转义记录,返回一个由原始绘图命令组成的 TXLSVectorScene,Canvas 与 SVG 后端用自己的代码重放它。任何环节都不涉及驱动回放

HotXLS 用 XLSDecodeVectorScene 把不可信的 EMF 与 WMF 工作表字节解析成 TXLSVectorScene 命令列表,而不是 GDI 图元文件回放
HotXLS 自己解析图元文件,返回供 Canvas 与 SVG 重放的原始命令;传统路线则在图形栈上执行字节流

这笔交易是用覆盖换围堵。一个以矩形为中心的命令白名单无法重现设计者能造出的每个图元文件,所以场景会报告它无法表示的绘图记录数量,由调用方决定如何处置。对一个渲染着非自己创建文档的服务进程,这个交易方向是对的

为什么图元文件回放不适合不可信输入?

因为这个格式不是图片,是程序。EMF 记录流操纵设备上下文状态栈,从句柄表分配并选择对象,还能携带载荷直达设备驱动的转义记录。回放它会走上平台图形栈中那些按"图元文件来自同一台机器上协作应用"假设写就的路径。当输入是一个电子表格附件时,这个假设不复存在,而电子表格库内部再小心也无济于事,因为做解析的不是这个库

这与约束容器层的推理相同。工作簿是一个 ZIP 归档,HotXLS 校验其中央目录而不是信任声明的偏移,ZIP 中央目录结尾校验一文有所叙述。图元文件载荷是同一问题的下一层

解码器在绘制任何东西之前检查什么

校验是结构性的,而且前置发生,因为一个边画边校验的解析器早已对未经验证的数据采取了行动。头部必须严格匹配,而不是貌似匹配。每个记录声明的大小必须落在剩余缓冲区内,且大到足以容纳自己的固定字段。头部声明的记录数必须与实际存在的记录一致。文件结束记录必须恰好在流结束处,而不是在附近——这封死了把第二个载荷藏在合法图片之后的尾随垃圾把戏

结构之外,解码器在语义上失败即关闭。转义记录被拒绝,不是跳过。解码器未建模的改状态记录会使解码失败而不是被忽略,因为忽略一个状态变更意味着后续每个绘图命令都运行在文件并未请求的状态里,产出一幅以无人能预测的方式错误着的图片。支持命令集之外的绘图记录则是另一回事:它们被计数并跳过,因为缺一个形状是可见、可报告的缺口,而不是无声的损坏

XLSDecodeVectorScene 前置检查头部、记录大小、总数与 EOF 位置,然后拒绝转义记录并计数不支持的绘图记录
结构检查前置运行,失败即关闭的语义拒绝转义记录,不支持的绘图记录仅被计数并跳过

预算是格式契约的一部分

矢量格式有它们自己的解压炸弹版本。几 KB 的记录可以声明数亿个点的折线,或一张声明尺寸相乘达数 TB 的图片。因此边界必须是显式常量,而不是这台机器碰巧扛得住多少

// 来自 lxVectorScene:解码预算,明示而非隐含
XL_VECTOR_MAX_RECORDS           = 1000000;
XL_VECTOR_MAX_HANDLES           = 4096;
XL_VECTOR_MAX_DC_DEPTH          = 32;
XL_VECTOR_MAX_COMMANDS          = 100000;
XL_VECTOR_MAX_POINTS_PER_RECORD = 100000;
XL_VECTOR_MAX_TOTAL_POINTS      = 2000000;
XL_VECTOR_MAX_TEXT_CHARS        = 4096;
XL_VECTOR_MAX_TOTAL_TEXT_CHARS  = 1000000;
XL_VECTOR_MAX_IMAGE_SIDE        = 8192;
XL_VECTOR_MAX_IMAGE_PIXELS      = 32 * 1024 * 1024;
XL_VECTOR_MAX_IMAGE_BYTES       = 64 * 1024 * 1024;
XL_VECTOR_MAX_COORD             = 1000000000;

其中两个值得说明。设备上下文深度上限 32 的存在是因为 SaveDCRestoreDC 记录会嵌套,不平衡的流可以永远压栈;32 对真实图元文件相当宽裕,执行也便宜。坐标上限的存在是因为坐标会进变换,接近整数范围极限的值会产出无穷或回绕的变换结果,此后下游每个包围盒计算都是胡话。解析时钳制坐标,比为几何的每个消费者设防好推理得多

HotXLS lxVectorScene 的解码预算常量:记录、句柄、DC 深度、命令、点数、文本、图片尺寸与坐标钳制
每个限制都是解析期强制执行的具名常量;DC 深度上限与坐标钳制最值得关注

使用场景对象

解码器交还一个归你所有的对象、一个命令数、一个标称尺寸,以及它选择不予表示的绘图记录数

uses
  lxVectorScene;

var
  Scene: TXLSVectorScene;
  Error: WideString;
  I: Integer;
begin
  // Data 持有从工作簿取出的原始图片载荷
  if not XLSDecodeVectorScene(Data, xlsvfEmf, Scene, Error) then
  begin
    // 被拒绝:头部、边界、总数、EOF 位置或某个预算
    LogReject('metafile rejected: ' + Error);
    Exit;
  end;
  try
    if Scene.SkippedDrawRecords > 0 then
      LogWarning(Format('%d drawing records outside the safe subset',
        [Scene.SkippedDrawRecords]));
    for I := 0 to Scene.Count - 1 do
      case Scene.Commands[I].Kind of
        xlsvcRectangle: DrawRect(Scene.Commands[I]);
        xlsvcEllipse:   DrawEllipse(Scene.Commands[I]);
        xlsvcPolyline,
        xlsvcPolygon,
        xlsvcBezier:    DrawPath(Scene.Commands[I]);
        xlsvcText:      DrawText(Scene.Commands[I]);
        xlsvcImage:     DrawImage(Scene.Commands[I]);
      end;
  finally
    Scene.Free;
  end;
end;

命令记录携带后端需要的一切,而不携带任何需要设备的东西:画笔有无、颜色、宽度与样式;画刷有无与颜色;几何;文本则包括字符串、字体名、字号、样式与对齐。正因如此,同一个场景既能被屏幕画布渲染器使用,也能被 SVG 写出器使用,矢量路径在预览与导出之间也不会分叉。工作表内容的屏幕渲染总述见自定义 VCL 网格渲染一文

拒绝一张图片不会损坏工作簿

这个设计的一个重要性质:被拒绝的解码只影响渲染。原始载荷留在模型里,因此打开再保存的工作簿会逐字节携带其图元文件图片出去,无论安全解码器画不画得出来。既有的有界栅格路径也继续作为回退可用。换言之,严格解析器把关的是被执行的东西,不是被保存的东西——正是这个区分,让一项出于安全动机的改动得以发布而不变成数据丢失改动

绘图对象处理的总览,包括对象模型中历经往返而不变的部分,见图表、图片与绘图一文

这对服务器部署意味着什么

如果你在服务里渲染用户上传的工作簿,现在的实际立场是可以辩护的:图元文件图片由你能审计的代码解析,受你能读懂的常量约束,绝不交给图形驱动。诚实的警告是覆盖。绘图工具产出的复杂图元文件会撞上跳过记录计数器,应对之道是把计数器浮出水面,而不是悄悄放宽白名单。部分渲染且明说的图片是一场支持对话;渲染错误且一声不吭的图片是一份来自客户的 bug 报告

HotXLS 在 Delphi 与 C++Builder 中原生处理 XLS、XLSX、ODS 与 CSV,无需安装 Excel,同样的有界解析哲学贯穿其容器、公式与绘图层。格式与安全细节列在 HotXLS Delphi spreadsheet component 产品页