Go中的SSRF攻防战

作者 : 开心源码 本文共6120个字,预计阅读时间需要16分钟 发布时间: 2022-05-13 共221人阅读

来自公#众#号:新世界杂货铺

写在最前面

“年年岁岁花类似,岁岁年年人不同”,没有什么是永恒的,很多东西都将成为过去式。比方,我以前在文章中自称“笔者”,细细想来这个称呼还是有肯定的距离感,经过一番深思熟虑后,我打算将文章中的自称改为“老许”。

关于自称,老许就不扯太远了,下面还是回到本篇的宗旨。

什么是SSRF

SSRF英文全拼为Server Side Request Forgery,翻译为服务端请求伪造。攻击者在未能获得服务器权限时,利用服务器漏洞以服务器的身份发送一条构造好的请求给服务器所在内网。关于内网资源的访问控制,想必大家心里都有数。

image

上面这个说法假如不好懂,那老许就直接举一个实际例子。现在很多写作平台都支持通过URL的方式上传图片,假如服务器对URL校验不严格,此时就为恶意攻击者提供了访问内网资源的可能。

“千里之堤,溃于蚁穴”,任何可能造成风险的漏洞我们程序员都不应忽视,而且这类漏洞很有可能会成为别人绩效的垫脚石。为了不成为垫脚石,下面老许就和各位读者一起看一下SSRF的攻防回合。

回合一:千变万化的内网地址

为什么用“千变万化”这个词?老许先不答复,请各位读者耐心往下看。下面,老许用182.61.200.7(www.baidu.com的一个IP地址)这个IP和各位读者一起复习一下IPv4的不同表示方式。

image.png

注意⚠️:点分混合制中,以点分割地每一部分均可以写作不同的进制(仅限于十、八和十六进制)。

上面仅是IPv4的不同体现方式,IPv6的地址也有三种不同表示方式。而这三种体现方式又可以有不同的写法。下面以IPv6中的回环地址0:0:0:0:0:0:0:1为例。

image.png

注意⚠️:冒分十六进制表示法中每个X的前导0是可以省略的,那么我可以部分省略,部分不省略,从而将一个IPv6地址写出不同的体现形式。0位压缩表示法和内嵌IPv4地址表示法同理也可以将一个IPv6地址写出不同的体现形式。

讲了这么多,老许已经无法统计一个IP可以有多少种不同的写法,麻烦数学好的算一下。

内网IP你以为到这儿就完了嘛?当然不!不知道各位读者有没有听过xip.io这个域名。xip可以帮你做自己设置的DNS解析,并且可以解析到任意IP地址(包括内网)。

image

我们通过xip提供的域名解析,还可以将内网IP通过域名的方式进行访问。

关于内网IP的访问到这儿仍将继续!搞过Basic验证的应该都知道,可以通过http://user:passwd@hostname/进行资源访问。假如攻击者换一种写法或者许可以绕过部分不够严谨的逻辑,如下所示。

image

关于内网地址,老许掏空了所有的知识储备总结出上述内容,因而老许说一句千变万化的内网地址不过分吧!

此时此刻,老许只想问一句,当恶意攻击者用这些不同体现形式的内网地址进行图片上传时,你怎样将其识别出来并拒绝访问。不会真的有大佬用正则表达式完成上述过滤吧,假如有请留言告诉我让小弟学习一下。

花样百出的内网地址我们已经基本理解,那么现在的问题是怎样将其转为一个我们可以进行判断的IP。总结上面的内网地址可分为三类:一、本身就是IP地址,仅体现形式不统一;二、一个指向内网IP的域名;三、一个包含Basic验证信息和内网IP的地址。根据这三类特征,在发起请求之前按照如下步骤可以识别内网地址并拒绝访问。

  1. 解析出地址中的HostName。
  2. 发起DNS解析,取得IP。
  3. 判断IP能否是内网地址。

上述步骤中关于内网地址的判断,请不要忽略IPv6的回环地址和IPv6的唯一本地地址。下面是老许判断IP能否为内网IP的逻辑。

// IsLocalIP 判断能否是内网ipfunc IsLocalIP(ip net.IP) bool {    if ip == nil {        return false    }    // 判断能否是回环地址, ipv4时是127.0.0.1;ipv6时是::1    if ip.IsLoopback() {        return true    }    // 判断ipv4能否是内网    if ip4 := ip.To4(); ip4 != nil {        return ip4[0] == 10 || // 10.0.0.0/8            (ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) || // 172.16.0.0/12            (ip4[0] == 192 && ip4[1] == 168) // 192.168.0.0/16    }    // 判断ipv6能否是内网    if ip16 := ip.To16(); ip16 != nil {        // 参考 https://tools.ietf.org/html/rfc4193#section-3        // 参考 https://en.wikipedia.org/wiki/Private_network#Private_IPv6_addresses        // 判断ipv6唯一本地地址        return 0xfd == ip16[0]    }    // 不是ip直接返回false    return false}

下图为按照上述步骤检测请求能否是内网请求的结果。

image

小结:URL形式多样,可以使用DNS解析获取规范的IP,从而判断能否是内网资源。

回合二:URL跳转

假如恶意攻击者仅通过IP的不同写法进行攻击,那我们自然可以高枕无忧,然而这场矛与盾的较量才刚刚开局。

我们回顾一下回合一的防御策略,检测请求能否是内网资源是在正式发起请求之前,假如攻击者在请求过程中通过URL跳转进行内网资源访问则完全可以绕过回合一中的防御策略。具体攻击流程如下。

image

如图所示,通过URL跳转攻击者可取得内网资源。在详情如何防御URL跳转攻击之前,老许和各位读者先一起复习一下HTTP重定向状态码——3xx。

根据维基百科的资料,3xx重定向码范围从300到308共9个。老许特意瞧了一眼go的源码,发现官方的http.Client发出的请求仅支持如下几个重定向码。

301:请求的资源已永久移动到新位置;该响应可缓存;重定向请求肯定是GET请求。

302:要求用户端执行临时重定向;只有在Cache-Control或者Expires中进行指定的情况下,这个响应才是可缓存的;重定向请求肯定是GET请求。

303:当POST(或者PUT / DELETE)请求的响应在另一个URI能被找到时可用此code,这个code存在主要是为了允许由脚本激活的POST请求输出重定向到一个新的资源;303响应禁止被缓存;重定向请求肯定是GET请求。

307:临时重定向;不可更改请求方法,假如原请求是POST,则重定向请求也是POST。

308:永久重定向;不可更改请求方法,假如原请求是POST,则重定向请求也是POST。

3xx状态码复习就到这里,我们继续SSRF的攻防回合探讨。既然服务端的URL跳转可能带来风险,那我们只需禁用URL跳转就完全可以规避此类风险。然而我们并不能这么做,这个做法在规避风险的同时也极有可能误伤正常的请求。那究竟该如何防范此类攻击手段呢?

看过老许“Go中的HTTP请求之——HTTP1.1请求流程分析”这篇文章的读者应该知道,对于重定向有业务需求时,可以自己设置http.Client的CheckRedirect。下面我们先看一下CheckRedirect的定义。

CheckRedirect func(req *Request, via []*Request) error

这里特别说明一下,req是即将发出的请求且请求中包含前一次请求的响应,via是已经发出的请求。在知晓这些条件后,防御URL跳转攻击就变得十分容易了。

  1. 根据前一次请求的响应直接拒绝307308的跳转(此类跳转可以是POST请求,风险极高)。
  2. 解析出请求的IP,并判断能否是内网IP。

根据上述步骤,可如下定义http.Client

client := &http.Client{    CheckRedirect: func(req *http.Request, via []*http.Request) error {        // 跳转超过10次,也拒绝继续跳转        if len(via) >= 10 {            return fmt.Errorf("redirect too much")        }        statusCode := req.Response.StatusCode        if statusCode == 307 || statusCode == 308 {            // 拒绝跳转访问            return fmt.Errorf("unsupport redirect method")        }        // 判断ip        ips, err := net.LookupIP(req.URL.Host)        if err != nil {            return err        }        for _, ip := range ips {            if IsLocalIP(ip) {                return fmt.Errorf("have local ip")            }            fmt.Printf("%s -> %s is localip?: %v\n", req.URL, ip.String(), IsLocalIP(ip))        }        return nil    },}

如上自己设置CheckRedirect可以防范URL跳转攻击,但此方式会进行屡次DNS解析,效率不佳。后文会结合其余攻击方式详情更加有效率的防御措施。

小结:通过自己设置http.ClientCheckRedirect可以防范URL跳转攻击。

回合三:DNS Rebinding

众所周知,发起一次HTTP请求需要先请求DNS服务获取域名对应的IP地址。假如攻击者有可控的DNS服务,即可以通过DNS重绑定绕过前面的防御策略进行攻击。

具体流程如下图所示。

image

验证资源是能否合法时,服务器进行了第一次DNS解析,取得了一个非内网的IP且TTL为0。对解析的IP进行判断,发现非内网IP可以后续请求。因为攻击者的DNS Server将TTL设置为0,所以正式发起请求时需要再次进行DNS解析。此时DNS Server返回内网地址,因为已经进入请求资源阶段再无防御措施,所以攻击者可取得内网资源。

额外提一嘴,老许特意看了Go中DNS解析的部分源码,发现Go并没有对DNS的结果作缓存,所以即便TTL不为0也存在DNS重绑定的风险。

在发起请求的过程中有DNS解析才让攻击者有机可乘。假如我们能对该过程进行控制,即可以避免DNS重绑定的风险。对HTTP请求控制可以通过自己设置http.Transport来实现,而自己设置http.Transport也有两个方案。

方案一

dialer := &net.Dialer{}transport := http.DefaultTransport.(*http.Transport).Clone()transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {    host, port, err := net.SplitHostPort(addr)    // 解析host和 端口    if err != nil {        return nil, err    }    // dns解析域名    ips, err := net.LookupIP(host)    if err != nil {        return nil, err    }    // 对所有的ip串行发起请求    for _, ip := range ips {        fmt.Printf("%v -> %v is localip?: %v\n", addr, ip.String(), IsLocalIP(ip))        if IsLocalIP(ip) {            continue        }        // 非内网IP可继续访问        // 拼接地址        addr := net.JoinHostPort(ip.String(), port)        // 此时的addr仅包含IP和端口信息        con, err := dialer.DialContext(ctx, network, addr)        if err == nil {            return con, nil        }        fmt.Println(err)    }    return nil, fmt.Errorf("connect failed")}// 使用此client请求,可避免DNS重绑定风险client := &http.Client{    Transport: transport,}

transport.DialContext的作用是创立未加密的TCP连接,我们通过自己设置此函数可规避DNS重绑定风险。另外特别说明一下,假如传递给dialer.DialContext方法的地址是常规IP格式则可使用net包中的parseIPZone函数直接解析成功,否则会继续发起DNS解析请求。

方案二

dialer := &net.Dialer{}dialer.Control = func(network, address string, c syscall.RawConn) error {    // address 已经是ip:port的格式    host, _, err := net.SplitHostPort(address)    if err != nil {        return err    }    fmt.Printf("%v is localip?: %v\n", address, IsLocalIP(net.ParseIP(host)))    return nil}transport := http.DefaultTransport.(*http.Transport).Clone()// 使用官方库的实现创立TCP连接transport.DialContext = dialer.DialContext// 使用此client请求,可避免DNS重绑定风险client := &http.Client{    Transport: transport,}

dialer.Control在创立网络连接之后实际拨号之前调用,且仅在go版本大于等于1.11时可用,其具体调用位置在sock_posix.go中的(*netFD).dial方法里。

image

上述两个防御方案不仅仅可以防范DNS重绑定攻击,也同样可以防范其余攻击方式。事实上,老许更加推荐方案二,简直一劳永逸!

小结

  1. 攻击者可以通过自己的DNS服务进行DNS重绑定攻击。
  2. 通过自己设置http.Transport可以防范DNS重绑定攻击。

个人经验

1、不要下发详细的错误信息!不要下发详细的错误信息!不要下发详细的错误信息!

假如是为了开发调试,请将错误信息打进日志文件里。强调这一点不仅仅是为了防范SSRF攻击,更是为了避免敏感信息泄漏。例如,DB操作失败后直接将error信息下发,而这个error信息很有可能包含SQL语句。

再额外多说一嘴,老许的公司对打进日志文件的某些信息还要求脱敏,可谓是十分严格了。

2、限制请求端口。

在结束之前特别说明一下,SSRF漏洞并不只针对HTTP协议。本篇只探讨HTTP协议是由于go中通过http.Client发起请求时会检测协议类型,某P*P语言这方面检测就会弱很多。尽管http.Client会检测协议类型,但是攻击者依然可以通过漏洞不断更换端口进行内网端口探测。

最后,衷心希望本文能够对各位读者有肯定的帮助。

  1. 写本文时, 笔者所用go版本为: go1.15.2
  2. 文章中所用完整例子: Isites/go-coder/blob/master/ssrf/main.go
说明
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » Go中的SSRF攻防战

发表回复