关于golang:Go中的SSRF攻防战

52次阅读

共计 6046 个字符,预计需要花费 16 分钟才能阅读完成。

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

写在最后面

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

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

什么是 SSRF

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

下面这个说法如果不好懂,那老许就间接举一个理论例子。当初很多写作平台都反对通过 URL 的形式上传图片,如果服务器对 URL 校验不严格,此时就为歹意攻击者提供了拜访内网资源的可能。

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

回合一:变幻无穷的内网地址

为什么用“变幻无穷”这个词?老许先不答复,请各位读者急躁往下看。上面,老许用182.61.200.7(www.baidu.com 的一个 IP 地址)这个 IP 和各位读者一起温习一下 IPv4 的不同示意形式。

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

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

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

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

内网 IP 你认为到这儿就完了嘛?当然不!不晓得各位读者有没有听过 xip.io 这个域名。xip能够帮你做自定义的 DNS 解析,并且能够解析到任意 IP 地址(包含内网)。

咱们通过 xip 提供的域名解析,还能够将内网 IP 通过域名的形式进行拜访。

对于内网 IP 的拜访到这儿仍将持续!搞过 Basic 验证的应该都晓得,能够通过 http://user:passwd@hostname/ 进行资源拜访。如果攻击者换一种写法或者能够绕过局部不够谨严的逻辑,如下所示。

对于内网地址,老许掏空了所有的常识储备总结出上述内容,因而老许说一句变幻无穷的内网地址不过分吧!

此时此刻,老许只想问一句,当歹意攻击者用这些不同表现形式的内网地址进行图片上传时,你怎么将其辨认进去并回绝拜访。不会真的有大佬用正则表达式实现上述过滤吧,如果有请留言通知我让小弟学习一下。

花样百出的内网地址咱们曾经根本理解,那么当初的问题是怎么将其转为一个咱们能够进行判断的 IP。总结下面的内网地址可分为三类:一、自身就是 IP 地址,仅表现形式不对立;二、一个指向内网 IP 的域名;三、一个蕴含 Basic 验证信息和内网 IP 的地址。依据这三类特色,在发动申请之前依照如下步骤能够辨认内网地址并回绝拜访。

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

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

// IsLocalIP 判断是否是内网 ip
func 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
}

下图为依照上述步骤检测申请是否是内网申请的后果。

小结:URL 形式多样,能够应用 DNS 解析获取标准的 IP,从而判断是否是内网资源。

回合二:URL 跳转

如果歹意攻击者仅通过 IP 的不同写法进行攻打,那咱们天然能够居安思危,然而这场矛与盾的较量才刚刚开局。

咱们回顾一下回合一的进攻策略,检测申请是否是内网资源是在正式发动申请之前,如果攻击者在申请过程中通过 URL 跳转进行内网资源拜访则齐全能够绕过回合一中的进攻策略。具体攻打流程如下。

如图所示,通过 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 重绑定绕过后面的进攻策略进行攻打。

具体流程如下图所示。

验证资源是是否非法时,服务器进行了第一次 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 办法里。

上述两个进攻计划不仅仅能够防备 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. 文章中所用残缺例子:https://github.com/Isites/go-…

正文完
 0