GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞

ZIP解压漏洞

  • 漏洞场景

  • 解压出的标准化路径文件在解压目标目录之外

  • 解压的文件消耗过多的系统资源

  • 攻击影响

  • 对于第1种情况:攻击者可以从zip文件中往用户可访问的任何目录写入任意的数据;

  • 对于第2种情况:当资源使用远远大于输入数据所使用的资源时,就可能产生拒绝服务。
    Zip算法有非常高的压缩比。例如,一个由字符a和字符b交替出现的行构成的文件,压缩比可以达到200:1。使用针对目标压缩算法的输入数据,或者使用更多的输入数据(不针对目标压缩算法的),或者使用其他的压缩方法,甚至可以达到更高的压缩比。
    由于Zip算法有极高的压缩率,即使在解压如ZIPGIFgzip编码HTTP的小文件时,也可能会导致过度的资源消耗,导致zip炸弹(zip bomb)。

  • 防范措施

  • 任何被提取条目的目标路径不在程序预期目录之内时(必须先对文件名进行标准化),要么拒绝将其提取出来,要么将其提取到一个安全的位置。

  • Zip文件中任何被提取条目,若解压之后的文件大小超过一定的限制时,必须拒绝将其解压。具体大小限制由平台的处理性能来决定。

今天实验部分只演示了跨目录解压需要压缩包的条目足够多

第二种情况的解压单个条目需要的资源足够大,不好弄,懒得试。

添加代码

views部分

views 文件夹里新建一个File,命名为ZipController.tpl ,添加如下代码(即在body标签里添加两个表单,各放一个input 表示要上传的压缩包):

<div class="postform">
    <p> ZIP炸弹 </p>
    <form enctype="multipart/form-data" action="http://127.0.0.1:8080/problems/ZipBomb" method="post">
        <input type="file" name="uploadname" />
        <input type="submit">
    </form>
    <br><br><br><br>
    <p> ZIP炸弹防范 </p>
    <form enctype="multipart/form-data" action="http://127.0.0.1:8080/problems/SafeZipBomb" method="post">
        <input type="file" name="uploadname" />
        <input type="submit">
    </form>
</div>

controllers部分

controllers 文件夹里新建一个go文件,命名为ZipController.go ,添加如下代码(老惯例,仍然是声明了两个对比的控制器,并分别重写了GetPost函数):

package controllers

import (
	"fmt"
		"log"
		"github.com/astaxie/beego"
	"archive/zip"
	"path/filepath"
	"os"
	"io"
	"errors"
)

// ZIP炸弹
type ZipController struct {
	beego.Controller
}

func (c *ZipController) Get() {
	c.TplName = "ZipController.tpl"
}

/**
*解压压缩包操作
* @zipfile 要解压的压缩包,必须是严格的zip类型,其他类型修改后缀强转为zip也不行!!!
* @destpath 解压到的目的路径
*/
func unzipfile(zipfile, destpath string) bool {
	// 打开压缩包
	rc, err := zip.OpenReader(zipfile)
	// fmt.Println(zipfile)
	if err != nil {
		fmt.Println("Open the zipfile error.")
		return false
	}
	defer rc.Close()

	// 遍历压缩包内的目录和文件
	for _, file := range rc.File {
		// 输出当前要解压的文件
		fmt.Printf("Contents of %s:\n", file.Name)
		// file此时可能是文件可能是目录,统一Open打开看看会不会报错。
		irc, err := file.Open()
		if err != nil {
			fmt.Println("open file which in zip archive error.")
			break
		}
		defer irc.Close()

		// 拼装指定的解压路径
		var targetpath = filepath.Join(destpath, file.Name)

		// 如果是目录则创建;如果是文件则把文件复制到指定的目的地
		if file.FileInfo().IsDir() {
			os.MkdirAll(targetpath, file.Mode())/** 【错误】直接将文件路径传给MkdirAll(),缺乏事先的标准化**/
		} else {
			// 新建一个文件
			f, err := os.OpenFile(targetpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
			if err != nil {
				fmt.Println("open the dest file error.")
				break
			}
			defer f.Close()

			// 从压缩流中把内容复制到目的文件中,从打开的irc到新建的f
			wt, err := io.Copy(f, irc) /** 【错误】未检查解压文件消耗情况  **/
			if err != nil {
				fmt.Println("copy file content error.")
				break
			}
			// 输出字节数
			fmt.Println("wt:", wt)
		}
	}
	return true
}

// 上传压缩包的post请求处理
func (c *ZipController) Post() {
	c.TplName = "ZipController.tpl"
	if c.Ctx.Request.MultipartForm.File["uploadname"] == nil {
		fmt.Println("哈哈,我很健壮")
		return
	}
	// 还是像上传文件一样上传压缩包,然后上传成功后立即解压至当前目录,
	// 获取控制器数据流里的压缩包,不限制压缩包类型
	f, h, err := c.GetFile("uploadname")
	if err != nil {
		log.Fatal("getfile err ", err)
	} else {
		// 保存位置在 static/upload, 没有文件夹要先创建,不然压缩包保存失败
		pathSrc := "static/upload/" + h.Filename
		// 正则匹配,\表转义
		pattern := `\\static\\upload\\`
		// 验证压缩包是否在安全路径下,防一手跨目录上传
		if !validate(pathSrc, pattern) {
			fmt.Println("file not in security directory.")
			return
		}
		// 肯定是安全目录,所以先保存压缩包,再解压
		c.SaveToFile("uploadname", pathSrc)
		// 如果解压失败把压缩包删了。
		if !unzipfile(pathSrc, "static/upload/") {
			os.Remove(pathSrc)
		}
	}
	defer f.Close()
}

// ZIP炸弹防范
type SafeZipController struct {
	beego.Controller
}

func (c *SafeZipController) Get() {
	c.TplName = "ZipController.tpl"
}

const TOO_MANY_FILE int = 1024
// max size of unzipped data, 100MB
const TOOBIG = 0x6400000
const BUFSIZE = 1024

/**
*解压压缩包操作
* @zipfile 要解压的压缩包
* @destpath 解压到的目的路径
在每解压一个条目之前都对目标文件路径进行校验,若校验不通过则跳过该条目,继续后面的解压,
除了校验文件路径之外,还会检查每一个条目的大小,如果条目太大(例如是100M)则会跳过该条目;
最后代码会计算压缩包中的总条目数量(例如超过1024个)则解压失败。
*/
func safeunzipfile(zipfile, destpath string) bool {
	// 打开压缩包
	rc, err := zip.OpenReader(zipfile)
	if err != nil {
		fmt.Println("Open the zipfile error.")
		return false
	}
	defer rc.Close()

	/**【修改】解压文件的数量超过1024限制 **/
	if len(rc.File) > TOO_MANY_FILE {
		fmt.Println("Too many file will be unzip.")
		return false
	}
	// 这一步只是对压缩包的压缩目录进行标准化,事实是可能压缩包里的文件本身也带有../的前缀,等下拼接的时候也要标准化
	destpath = filepath.Clean(destpath) /**【修改】将目的路径标准化 **/

	// 遍历压缩包内的目录和文件
	for _, file := range rc.File {
		// 输出当前要解压的文件
		fmt.Printf("Contents of %s:\n", file.Name)
		// file此时可能是文件可能是目录,统一Open打开看看会不会报错。
		irc, err := file.Open()
		if err != nil {
			fmt.Println("open file which in zip archive error.")
			continue
		}
		defer irc.Close()

		// 拼装指定的解压路径
		var targetpath = filepath.Join(destpath, file.Name)
		// 拼接后再进行一次校验,确保是解压至安全目录下
		// 正则匹配,\表转义
		pattern := `\\static\\upload\\`
		// 验证压缩包是否在安全路径下,防一手跨目录上传
		if !validate(targetpath, pattern) {
			fmt.Println("unzip file not in security directory.")
			continue
		}

		// 如果是目录则创建;如果是文件则把文件复制到指定的目的地
		if file.FileInfo().IsDir() {
			os.MkdirAll(targetpath, file.Mode())
		} else {
			f, err := os.OpenFile(targetpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
			if err != nil {
				fmt.Println("open the dest file error.")
				continue
			}
			defer f.Close()

			// 从压缩流中把内容复制到目的文件中
			wt, err := copyBuffer(f, irc) /**【修改】检查解压文件消耗情况 **/
			if err != nil {
				fmt.Println("copy file content error.")
				break
			}
			fmt.Println("wt:", wt)
		}
	}
	return true
}

func copyBuffer(dst io.Writer, src io.Reader) (written int64, err error) {
	buf := make([]byte, BUFSIZE)
	for {
		nr, er := src.Read(buf)

		if nr > 0 {
			//判断文件大小是否超出限制
			if written > TOOBIG {
				err = errors.New("The file too big!")
				break
			}
			nw, ew := dst.Write(buf[0:nr])
			if nw > 0 {
				written += int64(nw)
			}
			if ew != nil {
				err = ew
				break
			}
			if nr != nw {
				err = io.ErrShortWrite
				break
			}
		}
		// 文件读完,返回
		if er == io.EOF {
			break
		}
		// 文件读出错,返回
		if er != nil {
			err = er
			break
		}
	}
	return written, err
}

func (c *SafeZipController) Post() {
	c.TplName = "ZipController.tpl"
	if c.Ctx.Request.MultipartForm.File["uploadname"] == nil {
		fmt.Println("哈哈,我很健壮")
		return
	}
	// 还是像上传文件一样上传压缩包,然后上传成功后立即解压至当前目录,
	// 获取控制器数据流里的压缩包,不限制压缩包类型
	f, h, err := c.GetFile("uploadname")
	if err != nil {
		log.Fatal("getfile err ", err)
	} else {
		// 保存位置在 static/upload, 没有文件夹要先创建,不然压缩包保存失败
		pathSrc := "static/upload/" + h.Filename
		// 正则匹配,\表转义
		pattern := `\\static\\upload\\`
		// 验证压缩包是否在安全路径下,防一手跨目录上传
		if !validate(pathSrc, pattern) {
			fmt.Println("file not in security directory.")
			return
		}
		// 肯定是安全目录,所以先保存压缩包,再解压
		c.SaveToFile("uploadname", pathSrc)
		// 如果解压失败把压缩包删了。
		if !safeunzipfile(pathSrc, "static/upload/") {
			os.Remove(pathSrc)
		}
	}
	defer f.Close()
}

routers部分

routers/router.go 文件添加如下代码(即为上述两个控制器注册路由):

// ZIP炸弹问题
beego.Router("/problems/ZipBomb", &controllers.ZipController{})
beego.Router("/problems/SafeZipBomb", &controllers.SafeZipController{})

这样,无论url是访问/problems/ZipBomb 还是/problems/SafeZipBomb,两种Get请求都能正确渲染ZipController.tpl这个页面,然后当从表单发送Post请求时,一个表单会发送至ZipControllerPost函数响应并处理,而另一个表单会发送至SafeZipControllerPost函数响应并处理。

进行实验

在浏览器中输入http://127.0.0.1:8080/problems/ZipBomb
这里写图片描述

正常情况

我这里准备了一个压缩包如图
这里写图片描述

在“ZIP炸弹”的表单里选择该压缩包并提交上传:
这里写图片描述

后台显示如下:
这里写图片描述

可以看到压缩包被成功上传并且解压。

跨目录解压

使用编辑器(如010 Editor)打开压缩包,搜索“HelloWorld”字样
这里写图片描述

在搜索到的两处地方,都要将“HelloWorld.exe”修改为“../loWorld.exe”,即将前面三个字符“Hel”修改为“../
这里写图片描述

保存后,打开压缩包一看,出现了,传说中的“../”:
这里写图片描述

在“ZIP炸弹”的表单里选择该压缩包并提交上传:
这里写图片描述

后台显示如下:
这里写图片描述

可以看到,由于“HelloWorld.exe”被修改为“../loWorld.exe”,因而被解压到上级目录去了(“../”代表上级目录)。

跨目录解压防范

在“ZIP炸弹防范”的表单里选择该压缩包并提交上传:
这里写图片描述

后台显示如下:
这里写图片描述

后台在解压“../loWorld.exe” 的时候,检测到该被解压的文件不在安全目录下,因而拒绝将其解压出来,可以看到upload目录内和目录外均没有“loWorld.exe”。

原因分析

表单的本意设计是可以选择一个本机内的压缩包,将其上传至服务器的\static\upload 目录,并且将其解压至当前目录下。

然而在ZipControllerPost函数中,未对每一个被解压的文件名做校验,直接将文件解压路径传递给os.MkdirAll()函数,同时也未检查解压文件的数目、资源消耗情况,这可能会导致程序运行到本地资源被耗尽。

// 拼装指定的解压路径
var targetpath = filepath.Join(destpath, file.Name)

// 如果是目录则创建;如果是文件则把文件复制到指定的目的地
if file.FileInfo().IsDir() {
	os.MkdirAll(targetpath, file.Mode())/** 【错误】直接将文件路径传给MkdirAll(),缺乏事先的标准化**/
}
...
// 从压缩流中把内容复制到目的文件中,从打开的irc到新建的f
wt, err := io.Copy(f, irc) /** 【错误】未检查解压文件消耗情况  **/
if err != nil {
	fmt.Println("copy file content error.")
	break
}

虽然Windows系统并不允许正常文件名含有../的字符,但是先将文件放进压缩包中,再通过修改压缩包字节的方式修改包内文件名,仍然可以使得文件名含有../的字符。

所以当“HelloWorld.exe”被修改为“../loWorld.exe”后,解压的目录随之变成了static/upload/../loWorld.exe ,也就是“static/loWorld.exe

于是同上一节的跨目录上传一样,产生了压缩包的跨目录压缩漏洞,通过这个漏洞,攻击者同样可以将文件上传到任意目录。

推荐防范措施:
1) 在每解压一个条目之前都对文件路径进行校验,若校验不通过则跳过该条目,继续后面的解压;

// 拼装指定的解压路径
var targetpath = filepath.Join(destpath, file.Name)
// 拼接后再进行一次校验,确保是解压至安全目录下
// 正则匹配,\表转义
pattern := `\\static\\upload\\`
// 验证压缩包是否在安全路径下,防一手跨目录上传
if !validate(targetpath, pattern) {
	fmt.Println("unzip file not in security directory.")
	continue
}

校验的方式即为上一节的正则匹配,只要验证解压路径的子串是否含有安全目录即可

2) 检查每一个条目的大小,如果条目太大(例如是100M)则会跳过该条目

//判断文件大小是否超出限制
if written > TOOBIG {
	err = errors.New("The file too big!")
	break
}

3) 提前计算压缩包中的总条目数量,如果数量过大(例如超过1024个)则拒绝解压。

const TOO_MANY_FILE int = 1024
...
/**【修改】解压文件的数量超过1024限制 **/
if len(rc.File) > TOO_MANY_FILE {
	fmt.Println("Too many file will be unzip.")
	return false
}

比如这里,如果压缩包里的文件数目超过1024,则会出现如下输出并拒绝解压:
这里写图片描述

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值