[安全]: 浅谈文件上传之客户端安全问题


漏洞只能减少, 无法根除,
本文只初步介绍常见的攻击手段及客户端的基本防御

攻击手段及原理

  • 上传文件是WebShell时,攻击者可通过这些网页后门执行命令并控制服务器;

  • 上传文件是其他恶意脚本时,攻击者可直接执行脚本进行攻击;

  • 上传文件是恶意图片时,图片中可能包含了脚本,加载或者点击这些图片时脚本会悄无声息的执行;

  • 上传文件是伪装成正常后缀的恶意脚本时,攻击者可借助本地文件包含漏洞(Local File Include)执行该文件。如将bad.php文件改名为bad.doc上传到服务器,再通过PHP的include,include_once,require,require_once等函数包含执行。

客户端问题(非第三方工具 NC Fidder等上传工具)

  • 文件上传检查不严, 没有进行文件格式检查
    • 例如: .php .Php .pHp等
  • 文件名没有检查
    • 例如: xxx.php%00.jpg, (%00为十六进制的0x00字符, 对于服务器来说,因为%00字符截断的关系,最终上传的文件变成了xxx.php)
  • 修改文件名功能是带了后缀( 先传输.jpg后, 改文件名是把文件后缀更换为 .php)

抵御方法

  • 检查文件名后缀(注意大小写, 可先统一转换小写或是大写)
  • 重构文件名称(防止 xxx.php%00.jpg这种类型)
  • 若是图片, 使用resize函数, 压缩方式更改其大小, 这样就算是脚本, 里面的代码也会被破坏导致无法使用
  • 不可修改文件名后缀

具体代码实例

以市面上常见的框架及UI组件库

Vue

element-ui upload组件

<!--
 * @Descripttion: 上传组件
 * @version: 1.0.0
 * @Author: 仲灏
 * @Date: 2019-11-21 10:15:15
 * @LastEditors: 仲灏
 * @LastEditTime: 2019-12-09 14:13:13
 -->
<template>
  <el-upload
    ref="upload"
    :action="config.action"
    :headers="config.headers"
    :multiple="multiple"
    :data="file"
    :name="config.name"
    :http-request="handleHttpRequest"
    :show-file-list="showFileList"
    :drag="drag"
    :accept="accept"
    :list-type="listType"
    :file-list="fileList"
    :disabled="disabled"
    :limit="limit"
    :on-preview="handlePreview"
    :on-remove="handleRemove"
    :on-success="handleSuccess"
    :on-error="handleError"
    :on-progress="handleProgress"
    :on-change="handleChange"
    :before-upload="handleBeforeUpload"
    :before-remove="handleBeforeRemove"
    :on-exceed="handleExceed"
  >
    <i v-if="listType === 'picture-card' && !drag" class="el-icon-plus" />
    <div v-if="(listType === 'picture' && !drag) || (!drag && listType === 'text')">
      <el-button size="small" type="primary">点击上传</el-button>
      <div slot="tip" class="el-upload__tip">只能上传jpg/png文件,且不超过500kb</div>
    </div>

    <template v-if="drag">
      <div>
        <i class="el-icon-upload" />
        <div class="el-upload__text">
          将文件拖到此处,或
          <em>点击上传</em>
        </div>
        <div slot="tip" class="el-upload__tip">只能上传jpg/png文件,且不超过500kb</div>
      </div>
    </template>
    <el-dialog append-to-body :visible.sync="previewDialogVisible">
      <img width="100%" :src="dialogImageUrl" alt>
    </el-dialog>
  </el-upload>
</template>
<script>
import Cookies from 'js-cookie'
export default {
  name: 'ZUpload',
  props: {
    value: {
      type: Array,
      default: () => {
        return []
      }
    },
    label: {
      type: String,
      default: ''
    },
    tag: {
      type: String,
      default: ''
    },
    icon: {
      type: String,
      default: ''
    },
    prop: {
      type: String,
      default: ''
    },
    span: {
      type: Number,
      default: 24
    },
    readonly: {
      type: Boolean,
      default: false
    },
    size: {
      type: String,
      default: ''
    },
    fileSize: {
      type: Number,
      default: 5
    },
    disabled: {
      type: Boolean,
      default: false
    },
    accept: {
      type: String,
      default: ''
    },
    listType: {
      type: String,
      default: ''
    },
    drag: {
      type: Boolean,
      default: false
    },
    showFileList: {
      type: Boolean,
      default: true
    },
    multiple: {
      type: Boolean,
      default: true
    },
    limit: {
      type: Number,
      default: 10
    },
    quality: {
      // 图片质量
      type: Number,
      default: 0.1
    }
  },
  data() {
    return {
      dialogImageUrl: '',
      previewDialogVisible: false,
      file: {
        name: new Date().getTime()
      },
      fileList: [],
      config: {
        headers: {
          Authorization: Cookies.get('Token')
        },
        action: '********************',
        name: 'resource_url'
      },
      compressFile: ''
    }
  },
  mounted() {
    this.fileList = this.value
  },
  methods: {
    handlePreview(file) {
      console.log('点击文件列表中已上传的文件时的钩子')
      this.dialogImageUrl = file.url
      this.previewDialogVisible = true
    },
    handleDownload(file) {
      console.log('点击文件列表下载文件时的钩子')
      if (file.response && file.response.record) {
        window.open(file.response.record.resource_url)
      }
    },

    handleBeforeRemove(file, fileList) {
      console.log(`文件列表移除之前的钩子`)
      // return this.$confirm(`确定移除 ${file.name}?`)
      return true
    },

    handleRemove(file, fileList) {
      console.log('文件列表移除文件时的钩子')
      if (file.response) {
        const {
          response: {
            record: { id }
          }
        } = file
        this.$api('resource', 'delete', id).then(res => {
          // console.log(res)
          this.fileList = this.fileList.filter(v => v !== file)
        })
      }
      this.$emit('input', fileList)
    },

    handleBeforeUpload(file) {
      console.log(`上传文件之前的钩子`)

      const typeIsSatisfy = this.accept.split(',').includes(file.type)

      const fileType = file.name.substring(file.name.lastIndexOf('.') + 1)
      // const typeIsSatisfy = this.accept.split(',').includes(fileType)

      let sizeIsSatisfy = null
      if (Array.isArray(this.fileSize)) {
        sizeIsSatisfy =
          file.size > this.fileSize[0] && file.size < this.fileSize[1]
      } else {
        sizeIsSatisfy = file.size < this.fileSize * 1024 * 1024
      }

      if (!typeIsSatisfy) {
        this.$message.error(
          `当前限制文件格式为 ${this.accept},本次选择了文件格式为 ${fileType}`
        )
      }

      if (!sizeIsSatisfy) {
        this.$message.error(
          `当前限制小于 ${this.fileSize} M文件,本次选择了 ${(
            file.size /
            1024 /
            1024
          ).toFixed(2)} M大小的文件`
        )
      }

      return typeIsSatisfy && sizeIsSatisfy
    },

    handleProgress(event, file, filelist) {
      console.log(`文件开始上传时的钩子`)
    },
    // 自定义的上传实现函数
    handleHttpRequest(req) {
      console.log('触发上传行为函数方法')
      if (['image/jpeg', 'image/png', 'image/bmp', 'image/jpg'].includes(req.file.type)) {
        // 压缩图片
        this.compress(req.file, file => {
          const fileData = new FormData()
          fileData.append('resource_url', file)
          fileData.append('name', this.file.name)
          req.onProgress({
            percent: 0
          })
          req.onProgress({
            percent: 90
          })
          this.$api('resource', 'post', '', fileData)
            .then(res => {
              req.onProgress({
                percent: 100
              })
              req.onSuccess(res)
            })
            .catch(err => {
              req.onError(err)
            })
        })
      } else {
        const fileData = new FormData()
        fileData.append('resource_url', req.file)
        fileData.append('name', this.file.name)
        req.onProgress({
          percent: 0
        })
        req.onProgress({
          percent: 90
        })
        this.$api('resource', 'post', '', fileData)
          .then(res => {
            req.onProgress({
              percent: 100
            })
            req.onSuccess(res)
          })
          .catch(err => {
            req.onError(err)
          })
      }
    },
    handleSuccess(response, file, fileList) {
      console.log('文件上传成功时的钩子')
      console.log(response)
      if (response.code === 200) {
        this.fileList = fileList
        const fileArr = []
        fileList.forEach(v => {
          if (v.response) {
            let item = {}
            item = {
              name: v.response.record.id,
              url: v.response.record.resource_url
            }
            fileArr.push(item)
          } else {
            fileArr.push({ name: v.name, url: v.url })
          }
        })
        this.$emit('input', JSON.parse(JSON.stringify(fileArr)))
      }
    },

    // eslint-disable-next-line handle-callback-err
    handleError(err, file, filelist) {
      console.log(`文件上传失败时的钩子`)
      this.$message.error(err)
    },

    handleChange(file, fileList) {
      console.log(`文件状态改变时的钩子`)
    },

    handleExceed(files, fileList) {
      this.$message.warning(
        `当前限制选择 ${this.limit} 个文件,本次选择了 ${
          files.length
        } 个文件,共选择了 ${files.length + fileList.length} 个文件`
      )
    },

    /** 图片压缩,默认同比例压缩
     *  @param {Object} fileObj
     *  图片对象
     *  回调函数有一个参数,base64的字符串数据
     */
    compress(fileObj, callback) {
      const _this = this
      try {
        const image = new Image()
        image.src = URL.createObjectURL(fileObj)
        image.onload = function() {
          const that = this // 默认按比例压缩
          let w = that.width
          let h = that.height
          const scale = w / h
          w = fileObj.width || w
          h = fileObj.height || w / scale
          let quality = _this.quality // 默认图片质量为0.7 // 生成canvas
          const canvas = document.createElement('canvas')
          const ctx = canvas.getContext('2d') // 创建属性节点
          const anw = document.createAttribute('width')
          anw.nodeValue = w
          const anh = document.createAttribute('height')
          anh.nodeValue = h
          canvas.setAttributeNode(anw)
          canvas.setAttributeNode(anh)
          ctx.drawImage(that, 0, 0, w, h) // 图像质量
          if (fileObj.quality && fileObj.quality <= 1 && fileObj.quality > 0) {
            quality = fileObj.quality
          } // quality值越小,所绘制出的图像越模糊
          const base64 = canvas.toDataURL('image/jpeg', quality) // 压缩完成执行回调

          const file = _this.dataURLtoFile(base64)
          const blob = _this.base64ToBlob(base64)
          callback(file, blob, base64)
        }
      } catch (error) {
        console.log('压缩失败', error)
      }
    },
    // 将base64转换成file对象
    dataURLtoFile(dataurl, filename = this.file.name) {
      const arr = dataurl.split(',')
      const mime = arr[0].match(/:(.*?);/)[1]
      const suffix = mime.split('/')[1]
      const bstr = atob(arr[1])
      let n = bstr.length
      const u8arr = new Uint8Array(n)
      while (n--) {
        u8arr[n] = bstr.charCodeAt(n)
      }
      return new File([u8arr], `${filename}.${suffix}`, { type: mime })
    },
    base64ToBlob(urlData) {
      const bytes = window.atob(urlData.split(',')[1]) // 去掉url的头,并转换为byte
      // 处理异常,将ascii码小于0的转换为大于0
      const ab = new ArrayBuffer(bytes.length)
      const ia = new Uint8Array(ab)
      for (let i = 0; i < bytes.length; i++) {
        ia[i] = bytes.charCodeAt(i)
      }
      return new Blob([ab], { type: 'image/png' })
    }
  }
}
</script>

组件的使用

<!--
 * @Descripttion:
 * @version:
 * @Author: 仲灏
 * @Date: 2019-12-05 13:41:14
 * @LastEditors: 仲灏
 * @LastEditTime: 2019-12-09 14:15:49
 -->
<template>
  <ZUpload
    v-model="content"
    :readonly="props.readonly"
    :size="props.size"
    :file-size="props.fileSize"
    :disabled="props.disabled"
    :accept="props.accept"
    :list-type="props.listType"
    :drag="props.drag"
    :show-file-list="props.showFileList"
    :multiple="props.multiple"
    :limit="props.limit"
    :quality="props.quality"
  />
</template>

<script>
import ZUpload from '@/components/ZUpload'

export default {
  name: 'Test',
  components: { ZUpload },
  data() {
    return {
      content: [],
      props: {
        label: '文件上传 upload',
        tag: 'z-upload',
        icon: 'iconfont icon-wenjianshangchuan',
        prop: 'elupload',
        span: 24,
        class: '',
        value: [],
        rules: [{ required: true, message: '请上传文件', trigger: 'blur' }],
        readonly: false,
        size: 'small',
        fileSize: 5,
        disabled: false,
        accept: 'image/jpeg,image/gif,image/png,image/bmp,image/jpg',
        listType: 'picture-card',
        drag: false,
        showFileList: true,
        multiple: true,
        limit: 10,
        quality: 1
      }
    }
  }
}
</script>

在这里插入图片描述

posted @ 2019-12-06 21:11  仲灏  阅读(341)  评论(0编辑  收藏  举报