[安全]: 浅谈文件上传之客户端安全问题
漏洞只能减少, 无法根除,
本文只初步介绍常见的攻击手段及客户端的基本防御
攻击手段及原理
-
上传文件是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>