QUIC 存在 UDP 反射 DDoS 攻击漏洞吗?

今年年初,360信息安全部发布了一篇关于利用 UDP 反射 DDoS 的分析报告:Memcache UDP反射放大攻击技术分析。报告一出,引起了业界的普遍关注。根据文中所述,光是Qrator Labs 在 medium.com 上 批露的一次DDoS攻击看,其攻击流量峰值达到 480Gbps。而360信息安全团队本身也监测和确认有更大的攻击已经实际发生,只是未被公开报道。

而就在这个这个事件纰漏没多久,我把博客升级为支持基于 UDP 的 QUIC 协议来改善小站的访问体验:本站开启支持 QUIC 的方法与配置。本着小站没几人访问的蜜汁自信,当时也没太纠结 QUIC 是否也存在 UDP 反射漏洞。前几天,看到著名博主,阮一峰同学网站被 DDoS 攻击,心里咯噔一下:出来混迟早是要还的,还是填坑为安吧。

什么是 UDP 反射 DDoS 攻击

简单讲,就是攻击者利用IP网络不做真实源地址检查的“设计缺陷“,向提供基于 UDP 服务的服务器发送伪造源地址(一般为被攻击者的主机IP)的 UDP 报文请求,使得这些 UDP 报文的响应数据都会发送给被攻击者主机,这种攻击我们称之为 UDP 反射 DDoS 攻击。

之所以要通过被利用的服务器反射流量到被攻击的服务器,是因为被利用的服务器一般存在流量放大效应。即一个伪造IP的 UDP 请求发送到到被利用服务器后,被利用服务器会发送比请求更多的数据到被攻击者服务器。

被利用服务器输出流量与输入流量的比值我们称之为放大系数。这个系数与被利用服务器所提供的 UDP 服务有关。之前提到的利用 Memcache 漏洞的 DRDoS 攻击,可以获得稳定的 60000 倍放大系数。而我们日常使用的 DNS 则可以轻松的获得 50 倍的放大系数。

由放大系数反推,我们可以知道,如果一个 UDP 服务被利用以后,放大系数小于等于1的话,则不存在利用价值,因为这个时候,只从带宽流量方面考虑的话,还不如直接利用攻击主机对被攻击服务器进行攻击效率高。

QUIC 存在 UDP 反射攻击漏洞吗

按照蛤乎惯例,照顾猴急的同学,先给结论:可以。

QUIC 主要通过以下机制来解决该问题:

  1. 对于首次发起建立 QUIC 连接的客户端,服务端要求其初始化的 hello 数据包必须完全填充。这个包在 IPv4 下一般是 1370 字节,在 IPv6 下是 1350 字节。在 QUIC 协议中,服务器和客户端数据交互的基本单位是就是 UDP 数据包,而一个全填充的数据包已经达到了数据包大小的上限,因此服务器的响应数据包一定是小于等于这个 hello 数据包的。显然,放大系数小于等于1. 因此,新连接的建立没有反射利用的价值。
  2. 建立 QUIC 连接后,客户端发出的数据包就不会全填充了。这个时候,如果被 UDP 反射利用,放大系数是大于1的。因此, QUIC 引入了源地址token (source address token):在成功建立 QUIC 连接后,服务器会用发放一个源地址token给客户端,并要求客户端在后续的数据包中带上这个token;服务器只对源地址token有效的数据包进行处理。源地址token中一般包含客户端的源地址和服务器的时间。因此这是一个客户端证明其IP所有权的凭证。
  3. 由于源地址token可能会被网络中的攻击者嗅探收集,因此 QUIC 设计了一套源地址token的过期和刷新机制。另一方面,每次客户端发送的数据包如果都带上源地址token的话,不仅客户端流量大,服务器验证token也是额外的开销,使得协议延迟变高。因此 QUIC 协议允许客户端按照一个动态策略选择是否在数据包中夹带源地址token:服务器端收集和统计源地址的数据包,当统计到源地址数据包交互响应异常的数量超过阈值时,要求该源地址的客户端必须夹带源地址token, 对于无法提供合法源地址的token的请求进行 reject 处理。

扩展阅读

微信小程序文件上传二三事

这段时间陆陆续续上了好几个微信小程序,功能上都会用到文件上传功能(头像上传、证件照上传等)。在APP上传文件到云端的正确姿势中,我们介绍了我们认为安全的上传流程:

即将密钥保存在服务器,客户端每次向服务器申请一个一次性的signature,然后使用该signature作为凭证来上传文件。一般情况下,向阿里云OSS上传内容,又拍云作为灾备。

随着大家安全意识的增强,这种上传流程几乎已经成为标准姿势。但是,把这个流程在应用到微信小程序却有很多细节需要调整。这里把踩过的坑记录一下,希望能让有需要的同学少走弯路。

微信小程序无法直接读取文件内容进行上传

在我们第一版的上传流程方案中,我们的cds 签名发放服务只实现了阿里云 PutObject 接口的signature发放. PutObject 上传是直接将需要上传的内容以二进流的方式 PUT 到云储存。

但是,微信小程序提供的文件上传API wx.uploadFile 要求文件通过 filePath 提供:

另一方面,微信小程序的 JS API 当前还比较封闭,无法根据 filePath 读取到文件内容,因此也无法通过 wx.request 直接发起网络请求的方式来实现文件上传。

考虑到 wx.uploadFile 本质上是一个 multipart/form-data 网络请求的封装,因此我们只需要实现一个与之对应的签名发放方式接口。阿里云OSS对应的上传接口是 PostObject, 又拍云对应的是其 FORM API. 以阿里云OSS为例,cds 服务生成signature 代码如下:

func GetDefaultOSSPolicyBase64Str(bucket, key string) string {
    policy := map[string]interface{}{
        "expiration": time.Now().AddDate(3, 0, 0).Format("2006-01-02T15:04:05.999Z"),
        "conditions": []interface{}{
            map[string]string{
                "bucket": bucket,
            },
            []string{"starts-with", "$key", key},
        },
    }
    data, _ := json.Marshal(&policy)
    return base64.StdEncoding.EncodeToString(data)
}

func GetOSSPostSignature(secret string, policyBase64 string) string {
    h := hmac.New(sha1.New, []byte(secret))
    io.WriteString(h, policyBase64)
    return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

小程序端代码如下:

//使用说明
/**
 * 1、引入该文件:const uploadFile = require('../../common/uploadAliyun.js');
 * 2、调用如下:
 * uploadImg: function () {
        const params = {
            _success: this._success
        }
        uploadFile.chooseImg(params);
    },
    _success: function(imgUrl){
        this.setData({
            cover_url: imgUrl,
        })
    },
*/

const uploadFile = {
    _fail: function(desc) {
        wx.showToast({
            icon: "none",
            title: desc
        })
    },
    _success: function() {},
    chooseImg: function(sendData) {
        //先存储传递过来的回调函数
        this._success = sendData._success;
        var that = this;
        wx.chooseImage({
            count: 1,
            sizeType: ['original', 'compressed'],
            sourceType: ["album", "carmera"],
            success: function (res) {
                that.getSign(res.tempFilePaths[0]);
            },
            fail: function (err) {
                wx.showToast({
                    icon: "none",
                    title: "选择图片失败" + err
                })
            }
        })
    },
    //获取阿里上传图片签名
    getSign: function (path) {
        var that = this;
        wx.request({
            url: 'https://somewhere/v2/cds/apply_upload_signature',
            method: 'POST',
            data: {
                "content_type": "image/jpeg",
                "signature_type": "oss_post",
                "business": "xiaochengxu",
                "file_ext": '.jpeg',
                "count": '1'
            },
            success: function (res) {
                let getData = res.data.data[0];
                that.startUpload(getData, path);
            },
            fail: function (err) {
                that._fail("获取签名失败" + JSON.stringify(err))
            }
        })
    },
    //拿到签名后开始上传
    startUpload: function (getData, path) {
        var that = this;
        this.uploadAliYun({
            filePath: path,
            dir: 'wxImg/',
            access_key_id: getData.oss_ext_param.access_key_id,
            policy_base64: getData.oss_ext_param.policy_base64,
            signature: getData.signature,
            upload_url: getData.upload_url,
            object_key: getData.oss_ext_param.object_key,
            content_url: getData.content_url.origin 
        })
    },
    uploadAliYun: function(params) {
        var that = this;
        // if (!params.filePath || params.filePath.length < 9) {
        if (!params.filePath) {
            wx.showModal({
                title: '图片错误',
                content: '请重试',
                showCancel: false,
            })
            return;
        }
        const aliyunFileKey = params.dir + params.filePath.replace('wxfile://', '');

        const aliyunServerURL = params.upload_url;
        const accessid = params.access_key_id;
        const policyBase64 = params.policy_base64;
        const signature = params.signature;
        wx.uploadFile({
            url: aliyunServerURL,
            filePath: params.filePath,
            name: 'file',
            formData: {
                'key': params.object_key,
                'policy': policyBase64,
                'OSSAccessKeyId': accessid,
                'Signature': signature
            },
            success: function (res) {
                if (res.statusCode != 204) {
                    that._fail("上传图片失败");
                    return;
                }
                that._success(params.content_url);
            },
            fail: function (err) {
                that._fail(JSON.stringify(err));
            },
        })
    }
}


module.exports = uploadFile;

使用阿里云OSS域名上传失败

解决签名问题后,发现使用阿里云OSS提供的上传域名无法上传成功,在微信后台尝试添加合法域名的时候,惊奇的发现阿里云OSS的域名直接被微信小程序封禁了:

显然是两个神仙在打架,作为草民只能见招拆招。解决办法就是在阿里云OSS -> bucket -> 域名管理 绑定用户域名:

此外,由于微信小程序已经升级为uploadFile的链接必须是https, 因此还需要在绑定用户域名后设置 证书托管

他山之石,可以攻玉

既然微信能够封禁用阿里云OSS的上传域名,那么微信也可以封禁你自定义的域名。根据以往经验(对天发誓,我们不是有意为之,我们也是受害者……),微信封禁域名一般都是一锅端,即发现一个子域名存在违规内容,那么整个域名都会被封禁。因此,一方面要从技术角度对上传的内容及时检查是否合规(如黄图扫描),另一方面提前做好域名规划,将业务接口域名与自定义的文件上传域名分开,这样即使上传域名被一锅端了,不至于是业务完全不可用。

100行代码实现基于 QUIC 的 http 代理

本站开启支持 QUIC 的方法与配置后,主观感觉从国内访问快了很多。看了一下Chrome的timing, 大部分建立连接都能够做到0-RTT:

既然这样,顺手实现一个基于QUIC的http代理,把平时查资料时使用的网络也顺带加速一下。(对了,前两天看到Google发布了Outline, 看来这项运动从来都不缺少运动员哪……)

http 代理原理

http 代理处理http和https请求的方式有所不同。对于http请求:

  1. 浏览器与代理服务器建立TCP连接后,将http请求发送给代理服务器。
  2. 代理服务器将http请求发送给目标服务器。
  3. 代理服务器获取到相应结果以后,将结果发送给浏览器。

这里有一个细节需要注意,浏览器向代理服务器发送的http请求URI与直接访问有所不同。

浏览器直接访问 GET http://www.yahoo.com 的http请求格式为:

GET / HTTP/1.1
User-Agent: Quic-Proxy
...

而向代理服务器发送的http请求格式为:

GET http://www.yahoo.com HTTP/1.1
User-Agent: Quic-Proxy
...

也就是浏览器想代理服务器发送的http请求URI中包含了scheme和host,目的是为了让代理服务器知道这个代理请求要访问的目标服务器地址。

对于https请求,一般是通过CONNECT建立隧道:

  1. 浏览器向代理服务器建立TCP连接,发送CONNECT请求。
  2. 代理服务器根据CONNECT请求中包含的host信息,向目标服务器建立TCP连接,然后向浏览器返回200连接成功的响应。
  3. 这时代理服务器同时维持着连接浏览器和目标服务器的TCP连接。
  4. 从浏览器的角度看,相当于建立了一条直连目标服务器的TCP隧道。然后直接在该隧道上进行TLS握手,发送http请求即可实现访问目标服务器的目的。

QUIC Proxy的设计与实现

QUIC Proxy 部署结构图

QUIC Proxy的部署结构与上面http代理原理稍微有所不同。主要区别是增加了qpclient。主要原因是应用程序与代理服务器支架的请求是明文传输(http请求代理是全明文,https请求代理时的CONNECT头会泄露目标服务器信息)。我们是要隐私的人(虽然小扎可能并不care),因此,在应用程序与qpserver之间加了一个qpclient,之间使用QUIC作为传输层。

实现

QUIC Proxy使用Go实现,猴急的同学可以直接到github看源码:Quic Proxy, a http/https proxy using QUIC as transport layer.

代码比较简单,基于标准库的http.Server根据http代理的原理进行了一点http请求的修改。然后,因为qpclientqpserver之间使用QUIC作为transport,而QUIC上的每一个connection都是可以多路复用(multiplexing)的,因此,对于qpserver需要自己实现一个传入http.Server的listener:

type QuicListener struct {
    quic.Listener
    chAcceptConn chan *AcceptConn
}

type AcceptConn struct {
    conn net.Conn
    err  error
}

func NewQuicListener(l quic.Listener) *QuicListener {
    ql := &QuicListener{
        Listener:     l,
        chAcceptConn: make(chan *AcceptConn, 1),
    }
    go ql.doAccept()
    return ql
}

func (ql *QuicListener) doAccept() {
    for {
        sess, err := ql.Listener.Accept()
        if err != nil {
            log.Error("accept session failed:%v", err)
            continue
        }
        log.Info("accept a session")

        go func(sess quic.Session) {
            for {
                stream, err := sess.AcceptStream()
                if err != nil {
                    log.Error("accept stream failed:%v", err)
                    sess.Close(err)
                    return
                }
                log.Info("accept stream %v", stream.StreamID())
                ql.chAcceptConn <- &AcceptConn{
                    conn: &QuicStream{sess: sess, Stream: stream},
                    err:  nil,
                }
            }
        }(sess)
    }
}

func (ql *QuicListener) Accept() (net.Conn, error) {
    ac := <-ql.chAcceptConn
    return ac.conn, ac.err
}

同样的,qpclientqpserver建立连接也需要考虑到多路复用的问题,实现实现一个基于QUIC的dialer:

type QuicStream struct {
    sess quic.Session
    quic.Stream
}

func (qs *QuicStream) LocalAddr() net.Addr {
    return qs.sess.LocalAddr()
}

func (qs *QuicStream) RemoteAddr() net.Addr {
    return qs.sess.RemoteAddr()
}

type QuicDialer struct {
    skipCertVerify bool
    sess           quic.Session
    sync.Mutex
}

func NewQuicDialer(skipCertVerify bool) *QuicDialer {
    return &QuicDialer{
        skipCertVerify: skipCertVerify,
    }
}

func (qd *QuicDialer) Dial(network, addr string) (net.Conn, error) {
    qd.Lock()
    defer qd.Unlock()

    if qd.sess == nil {
        sess, err := quic.DialAddr(addr, &tls.Config{InsecureSkipVerify: qd.skipCertVerify}, nil)
        if err != nil {
            log.Error("dial session failed:%v", err)
            return nil, err
        }
        qd.sess = sess
    }

    stream, err := qd.sess.OpenStreamSync()
    if err != nil {
        log.Info("[1/2] open stream from session no success:%v, try to open new session", err)
        qd.sess.Close(err)
        sess, err := quic.DialAddr(addr, &tls.Config{InsecureSkipVerify: true}, nil)
        if err != nil {
            log.Error("[2/2] dial new session failed:%v", err)
            return nil, err
        }
        qd.sess = sess

        stream, err = qd.sess.OpenStreamSync()
        if err != nil {
            log.Error("[2/2] open stream from new session failed:%v", err)
            return nil, err
        }
        log.Info("[2/2] open stream from new session OK")
    }

    log.Info("addr:%s, stream_id:%v", addr, stream.StreamID())
    return &QuicStream{sess: qd.sess, Stream: stream}, nil
}

好吧,我承认实现代码似乎在200行左右……但是,我们实现了一个client和一个server, 平均下来基本控制在100行左右,对吧……(?逃……)

部署

:需要golang版本 >= 1.9

1. 在远程服务器上安装 qpserver

go get -u github.com/liudanking/quic-proxy/qpserver

2. 启动qpserver:

qpserver -v -l :3443 -cert YOUR_CERT_FILA_PATH -key YOUR_KEY_FILE_PATH

3. 在本地安装 qpclient

go get -u github.com/liudanking/quic-proxy/qpclient

4. 启动 qpclient:

qpclient -v -k -proxy http://YOUR_REMOTE_SERVER:3443 -l 127.0.0.1:18080

5. 设置应用程序代理:

以 Chrome with SwitchyOmega 为例:

Enjoy!