优化实战 第 17 期 - 文件类型的终极校验,堪称完美

1,791 阅读4分钟

如果把任意的垃圾文件后缀名修改成允许上传的类型,不仅会造成程序崩溃,还有可能会浪费不必要的资源

这种恶意上传的操作,不仅带来安全隐患,还会造成不必要的性能开销

filetype.jpg

前置知识点

  • Blob 对象

    Blob(Binary Large Object)二进制类型的大对象,表示一个不可变、原始数据的类文件对象。可以通过 slice() 方法将它们分割成为非常小的数据块

    File 接口基于 Blob 实现,继承了其功能并将其扩展,使其支持用户系统上的文件

  • FileReader 对象

    主要用于将文件内容读入 内存,通过一系列 异步接口 读取本地文件内容并输出结果

    readAsArrayBuffer() 按字节读取文件内容,结果用 ArrayBuffer 对象表示

    readAsText() 按字节读取文件内容,结果用字符串表示

    readAsDataURL() 读取文件内容,结果用 data:URL 格式的 Base64 字符串表示

  • ArrayBuffer 对象

    表示通用的、固定长度的原始二进制数据缓冲区,它是一个 二进制字节数组

  • Uint8Array 数组类型

    表示一个 8 位无符号整型数组,创建时内容被初始化为 0。创建完后,可以以 对象的方式或使用数组下标索引的方式 引用数组中的元素

前置插件配置

  • 安装 VSCode 插件

    按下 Ctrl + P 激活打开窗口,输入 ext install slevesque.vscode-hexdump 回车后将会自动安装该插件

  • 使用插件查看文件的十六进制内容(以 jpg 文件为例)

    jpg_hex.png

常规类型校验

  • 使用 HTML 属性

    <input type="file" accept=".mp4, .mov">
    <input type="file" accept=".png, .jpg, .jpeg, .gif">
    

    缺点:可通过浏览器控制台删除 accept 属性,绕过限制

  • 使用文件 MIME 类型

    const validFileType = Object.assign(Object.create(null), {
      'video': ['video/mp4', 'video/quicktime'],
      'image': ['image/png', 'image/jpeg', 'image/gif'],
    })
    const validator = (type, files) => files.map(file => ({ filename: file.name, valid: validFileType[type].includes(file.type) })).filter(f => !f.valid)
    

    缺点:可通过本地修改文件扩展名,绕过限制

安全性类型校验

  • 核心思想

    使用 slice() 方法截取文件头部数据,将其转换为 十六进制 字符串,然后进行逻辑判断

  • 配置文件类型映射策略

    const typeMapping = new Map([
      ['mp4', { num: 4, hexs: ['00 00 00 18', '00 00 00 20', '00 00 00 1C'] }],
      ['mov', { num: 8, hexs: ['00 00 00 14 66 74 79 70'] }],
      ['jpg', { num: 3, hexs: ['FF D8 FF'] }],
      ['png', { num: 8, hexs: ['89 50 4E 47 0D 0A 1A 0A'] }],
      ['gif', { num: 4, hexs: ['47 49 46 38'] }],
      ['pdf', { num: 7, hexs: ['25 50 44 46 2D 31 2E'] }],
      ['zip', { num: 6, hexs: ['50 4B 03 04 14 00'] }],
      ['excel', { num: 4, hexs: ['50 4B 03 04'] }],
      ['image', { num: 8, hexs: ['FF D8 FF', '89 50 4E 47 0D 0A 1A 0A', '47 49 46 38'] }],
      ['video', { num: 8, hexs: ['00 00 00 18', '00 00 00 20', '00 00 00 1C', '00 00 00 14 66 74 79 70'] }]
    ])
    

    使用 num 来配置提取文件头部数据的范围,提取起始处的索引从 0 开始

    使用 hexs 来配置提取范围对应的十六进制字符串,用于策略逻辑判断

  • 根据文件类型读取映射策略

    const validFileType = async (type, file) => {
      const { num, hexs } = typeMapping.get(type)
      const str = await blobToString(file.slice(0, num))
      if (type === 'image' || type === 'video') {
        return hexs.some(hex => str.startsWith(hex))
      } else {
        return str === hexs.join()
      }
    }
    
  • 文件的二进制数据转为十六进制字符串

    /**
    * @param {Blob} blob 二进制文件数据
    * @return {String} 16进制字符串
    */
    const blobToString = async blob => {
      const buffer = await blob.arrayBuffer()
      return [...new Uint8Array(buffer)].map(n => n.toString(16).toUpperCase().padStart(2, '0')).join(' ')
    }
    
  • 文件类型校验

    /**
    * @param {String} type 文件类型
    * @param {Array} files 文件对象
    * @return {Array} 文件类型校验不通过的文件列表
    */
    const validator = async (type, files) => (await Promise.all(files.map(async file => ({ filename: file.name, valid: await validFileType(type, file) })))).filter(f => !f.valid)
    

    经测试,即便通过本地修改文件扩展名,文件也会被校验限制

  • 总结

    很多攻击方式都是把可执行文件的后缀名进行修改,然后上传到服务器进行攻击,可见文件类型校验的重要性

浏览器兼容性

  • 在 Safari 浏览器下报错

    TypeError: blob.arrayBuffer is not a function. (In 'blob.arrayBuffer()', 'blob.arrayBuffer' is undefined)

  • 通过 外观模式 解决兼容性

    function arrayBuffer(blob) {
      return new Promise(resolve => {
        const reader = new FileReader()
        reader.readAsArrayBuffer(blob)
        reader.onload = event => resolve(event.target.result)
      })
    }
    const blobToString = async blob => {
      let buffer = null
      try {
        buffer = await blob.arrayBuffer()
      } catch {
        buffer = await arrayBuffer(blob)
      }
      return [...new Uint8Array(buffer)].map(n => n.toString(16).toUpperCase().padStart(2, '0')).join(' ')
    }
    

    使用 FileReaderreadAsArrayBuffer 方法异步读取 Blob 对象为 ArrayBuffer 数据对象,在错误捕获中进行兼容处理

    一起学习,加群交流看 沸点