[安全]: 淺談文件上傳之客戶端安全問題


漏洞只能減少, 無法根除,
本文只初步介紹常見的攻擊手段及客戶端的基本防禦

攻擊手段及原理

  • 上傳文件是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>

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章