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
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第1张图片

正常情况

我这里准备了一个压缩包如图
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第2张图片

在“ZIP炸弹”的表单里选择该压缩包并提交上传:
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第3张图片

后台显示如下:
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第4张图片

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

跨目录解压

使用编辑器(如010 Editor)打开压缩包,搜索“HelloWorld”字样
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第5张图片

在搜索到的两处地方,都要将“HelloWorld.exe”修改为“../loWorld.exe”,即将前面三个字符“Hel”修改为“../
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第6张图片

保存后,打开压缩包一看,出现了,传说中的“../”:
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第7张图片

在“ZIP炸弹”的表单里选择该压缩包并提交上传:
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第8张图片

后台显示如下:
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第9张图片

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

跨目录解压防范

在“ZIP炸弹防范”的表单里选择该压缩包并提交上传:
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第10张图片

后台显示如下:
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第11张图片

后台在解压“../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,则会出现如下输出并拒绝解压:
GO语言Beego框架之WEB安全小系统(6)ZIP解压漏洞_第12张图片

你可能感兴趣的:(GO)