3

Yak基础插件案例——CDN检测

 2 years ago
source link: https://www.freebuf.com/sectool/322038.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

一、关于CDN

01 介绍

要谈CDN,就得先从CDN以及CDN的配置先说起。

内容分发网络(英语:Content Delivery Network或Content Distribution Network,缩写:CDN)是指一种透过互联网互相连接的电脑网络系统,利用最靠近每位用户的服务器,更快、更可靠地将音乐、图片、视频、应用程序及其他文件发送给用户,来提供高性能、可扩展性及低成本的网络内容传递给用户。——来自Wikipedia

02 优点

CDN的总承载量取决于其网络节点的数量决定,通常会比单一骨干最大的带宽还要大。这使得CDN可以承载的用户数量比起传统的单一服务器多。假设现在把一台有100Gbps处理能力的服务器放在只有10Gbps带宽的数据中心,那么这时候带宽就成为了架构上了瓶颈。但是如果放到十个有10Gbps的地点,整个系统的处理能力就可以达到10*10Gbps。同时,将服务器放到不同地点还有其他好处,例如:异地备援、隐藏真实IP等。

03 配置

配置CDN一般有两种方式:

第一种

CDN厂商提供一个域名,给自己需要接入CDN的域名添加一个cname,指向CDN厂商提供的域名。这种域名一般会有个随机前缀。

第二种

把需要接入CDN的域名的NS记录指向CDN厂商的DNS服务器IP。

二、检测CDN

知道了配置原理后,我们可以很容易得到几种检测的思路。

01 CNAME指纹

配置中说的第一种方法是去配置一个CNAME记录为CDN厂商提供的域名,这个域名通常会有一个前缀,后缀一般都是固定的。找了两个加载js库的CDN加速服务,看一下他们的CNAME记录。

v2-034d405a5da6b07ddcfcb6174f2e2678_720w.jpgv2-ccc4926feb7c6203c2b9e3f2262fd828_720w.jpg

可以看到不同的CDN厂商的域名一般会有个共同点,就是带有cdn/dns等字眼(不一定),这就相当于CDN厂商的一个指纹。我们只需要维护一个常见CDN厂商的CNAME指纹字典,可以去里面查目标的CNAME记录是否为某个CDN厂商的域名。但是这种方式的缺点十分明显,就是维护指纹的成本很高,如果某个CDN厂商加了新的域名,那就需要重新添加指纹了。

02 IP段

我们都知道CDN厂商一般会有很多个节点,而这些节点一般是是在一些IP段里面。所以我们可以维护一个常见CDN厂商的IP段列表。

v2-f97f2f23682d74c7680c4166cbc1ea95_720w.jpg

https://github.com/al0ne/Vxscan/blob/master/lib/iscdn.py

如果目标域名的A记录在IP段列表中,那么我们可以暂时认为其接入了CDN。但是这样做也是明显有跟上面一样的缺点的,CDN厂商添加节点的行为会使得我们的脚本出现误报/漏报。

03 ASN号

ASN介绍

自治系统或自治域(英文:Autonomous system, AS)是指在互联网中,一个或多个实体管辖下的所有IP网络和路由器的组合,它们对互联网执行共同的路由策略。——来自Wikipedia

我们可以整理出常见CDN厂商的ASN号列表,如果目标域名A记录IP的ASN号在列表中,那么我们也可以暂时认为其接入了CDN。

v2-6a3b478545b34feec63f7cdfdc09f80e_720w.jpg

但是还是有明显的误报/漏报问题,因为谁也不知道会不会突然冒出一个新的CDN厂商被我们遇到,或者某些家大业大的大厂自己实现了CDN的接入,没有使用CDN厂商的服务。

04 多地区ping

上面介绍的三种方式的优点就是速度快,需要检测的域名非常多时优势很大。基本就是DNS查一下然后进行各种类型指纹的匹配就行,但缺点就是误报/漏报率高。想要降低误报/漏报率可以将以上的方式做一下结合,例如精灵师傅的OneForAll子域名工具就结合了上面提到的方式。但是再怎么样也只是能将误报/漏报率降到最低,达不到零误报/漏报。

前面介绍到CDN会根据地区返回不同的IP。那么我们可以准备很多个地区的服务器,同时对该域名执行ping操作。看看返回的IP是否相同,达到判断是否接入CDN的目的。

但是这样成本非常高,好在网上有现成的服务可以使用。例如:站长之家多地区ping

v2-b76db5c5c38007d32cab29a6d039e0dc_720w.jpg

但是在官网使用该服务一次只会请求一部分监测点进行ping操作,效率不高。所以我打算使用yaklang利用其探测点,重新写一个并发的版本。

三、插件编写

其实这个插件本质上是一个“爬虫”,我们需要去分析一下站长之家的请求流程。

大概流程如下:首先会发送一个POST请求到https://ping.chinaz.com/,获取监测点的guid,以及enkeycheckType等参数,作为后续请求的参数。

v2-6453b796f52c6f076eeee0d8e59f1d6c_720w.jpg

接着带着上一步获取到的参数发送一个POST请求到https://ping.chinaz.com/iframe.ashx?t=ping

v2-1de7d545b338a1b77863df49d12a3441_720w.jpg

如果成功则返回格式如下

{state:1,msg:'',result:{ip:'36.152.44.96',ipaddress:'中国江苏南京 移动',responsetime:'13毫秒',ttl:'51',bytes:'32'}}

反之则返回格式如下

{state:0,msg:''}

将其中需要的信息提取出来就行。

01 编写获取初始化参数的函数

经过测试,其实后续的回调接口只需要获取enkeycheckType以及监测点的guid,所以我们发送对应的POST请求后使用正则表达式提取出需要的参数即可。(在写完插件的第二天V1师傅加了个Xpath库,可以更容易提取出参数了,所以这里最优解是用Xpath库。)

// 字典转url query格式,带urlencode
dict2UrlQueryWithUrlEncode := func(d) {
    s := make([]string)
    for k, v := range d {
        s = append(s, sprintf("%v=%v", k, codec.EscapeQueryUrl(v)))
    }
    return str.Join(s, "&")
}

// 获取初始化配置
getInitConfig := func() {
    datas := dict2UrlQueryWithUrlEncode({
        "host": "example.com",
        "linetype": "电信,多线,联通,移动,其他",
    })
    // 请求接口获取配置
    res, err := http.Request("POST", "https://ping.chinaz.com/", http.body(datas), http.header("Content-Type", "application/x-www-form-urlencoded"))
    die(err)
    resBody := string(http.GetAllBody(res))

    // <div id="0e519c9d-dab8-480c-a372-c72480dd133a" class="row listw tc clearfix" linetype="1" state="0" trycount="0">
            // <div class="col-2" name="city" serveruroup="0" data-company="[网锐]微端BGP200M/1200/月,www.wridc.com/hd.html">江苏宿迁[电信]</div>
    // 获取监测点UUID
    r1, _ := re.Compile(`<div id="([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})" class="row listw tc clearfix"[\s\S]+?<div class="col-2" name="city"[\s\S]+?>(\S+)</div>`)
    result := r1.FindAllStringSubmatch(resBody, -1)
    pingServerInfoList := make([]map[string]string)
    for _, i := range result {
        pingServerInfoList = append(pingServerInfoList, {
            "name": i[2],
            "guid": i[1],
        })
    }

    // <input type="hidden" id="enkey" value="OT5JUx9bX5INGvYSBT087i8pZeO7y9et" />
    // 获取enkey参数
    r2, _ := re.Compile(`<input type="hidden" id="enkey" value="(\S+)" />`)
    enkey := r2.FindStringSubmatch(resBody)
    if len(enkey) == 0 {
        die("get enkey failure")
    }
    enkey := enkey[1]

    // <input type="hidden" id="checktype" value="0" />
    // 获取checkType参数
    r3, _ := re.Compile(`<input type="hidden" id="checktype" value="(\d+?)" />`)
    checkType := r3.FindStringSubmatch(resBody)
    if len(checkType) == 0 {
        die("get checkType failure")
    }
    checkType := checkType[1]

    return pingServerInfoList, enkey, checkType
}

02 编写执行监测点ping操作函数

这里需要注意的点是,目标返回的是一个JSONP格式的数据,往JSONP回调函数里面丢的是一个js的对象,不是一个标准的JSON格式,所以需要对其进行一些字符串操作,使其能被转为一个map[string]var格式的数据。且因为我们需要启动goroutine进行并发操作,所以我是使用了一个Channel来在多个goroutine中安全地操作数据。

// 字典转url query格式,带urlencode
dict2UrlQueryWithUrlEncode := func(d) {
    s := make([]string)
    for k, v := range d {
        s = append(s, sprintf("%v=%v", k, codec.EscapeQueryUrl(v)))
    }
    return str.Join(s, "&")
}
// 转为合法json,并反序列化
convertLegalJson := func(s) {
    // 去掉括号
    s = str.ReplaceAll(s, "({", "{")
    s = str.ReplaceAll(s, "})", "}")
    
    // 加引号,改单引号
    s = str.ReplaceAll(s, `state:`, `"state":`)
	s = str.ReplaceAll(s, `msg:`, `"msg":`)
	s = str.ReplaceAll(s, `result:`, `"result":`)
	s = str.ReplaceAll(s, `ip:`, `"ip":`)
	s = str.ReplaceAll(s, `ipaddress:`, `"ipaddress":`)
	s = str.ReplaceAll(s, `responsetime:`, `"responsetime":`)
	s = str.ReplaceAll(s, `ttl:`, `"ttl":`)
	s = str.ReplaceAll(s, `bytes:`, `"bytes":`)
	s = str.ReplaceAll(s, `'`, `"`)

    // 反序列化
    d, err := json.New(s)
    die(err)
    return d.Value()
}
// 让监测点开始ping操作
ping := func(serverInfo, enkey, checkType, target, results) {
    datas := dict2UrlQueryWithUrlEncode({
        "guid": serverInfo["guid"],
        "host": target,
        "ishost": "0",
        "isipv6": "0",
        "encode": enkey,
        "checktype": checkType,
    })
    res, err := http.Request("POST", "https://ping.chinaz.com/iframe.ashx?t=ping", http.body(datas), http.header("Content-Type", "application/x-www-form-urlencoded"), http.timeout(20))
    // 请求失败也返回ping失败的结果
    if err != nil {
        results <- {"state": 0, "msg": ""}
        return
    }
    resBody := string(http.GetAllBody(res))
    // 失败 ({state:0,msg:''})
    // 成功 ({state:1,msg:'',result:{ip:'110.242.68.4',ipaddress:'中国河北保定顺平县 联通',responsetime:'20毫秒',ttl:'52',bytes:'32'}})

    // 处理结果
    pingInfo := convertLegalJson(resBody)
    pingInfo["cityname"] = serverInfo["name"]
    if pingInfo["state"] == float64(1) {
        if str.Contains(pingInfo["result"]["responsetime"], "超时") {
            pingInfo["result"]["responsetime"] = "超时"
        }
        if str.Contains(pingInfo["result"]["ttl"], "超时") {
            pingInfo["result"]["ttl"] = "超时"
        }
        if pingInfo["result"]["bytes"] == "" {
            pingInfo["result"]["bytes"] = "-"
        }
        pingInfo["result"]["ipaddress"] = str.Join(str.Fields(pingInfo["result"]["ipaddress"]), " ")
    }
    results <- pingInfo
}

03 编写监测逻辑与图形化输出

第一步

初始化与yakit的连接,并解析外部参数(即需要检测的域名)。因为使用了str.ParseStringToHosts(),所以target参数可以使用,进行分割以支持多个目标。

yakit.AutoInitYakit()

// 解析参数
targets := cli.String("target", cli.setRequired(true))
targetList := str.ParseStringToHosts(targets)

调用func getInitConfig()拿到所有需要的参数

pingServerInfoList, enkey, checkType := getInitConfig()
serverNum := len(pingServerInfoList)
printf("初始化成功,共获取到%v个监测点\n", serverNum)

遍历targetList拿到每一个target,初始化一个当前target的表格,并声明一个用于收集结果的Channel。再遍历探测点信息pingServerInfoList,启动goroutine并发执行func ping()

for targetIndex, target := range targetList {
    yakit.EnableTable(target, ["监测点", "响应IP", "IP归属地", "响应时间", "TTL", "数据包大小"])
    results := make(chan var)

    // https://www.yaklang.io/docs/newforyak/concurrent
    submitTask := func(param...) {
        go ping(param...)
    }
    for _, serverInfo := range pingServerInfoList {
        submitTask(serverInfo, enkey, checkType, target, results)
    }
}

这里还有一个关于在循环中goroutine启动时定义域的一个坑点。需要使用一个trick去化解。即我们在循环中不直接启动goroutine,而是在循环中调用一个同步函数,在该函数中再开启goroutine执行异步任务。具体可以看官网这个链接:https://www.yaklang.io/docs/newforyak/concurrent

从通道中拿到结果,将结果做一系列处理(如统计返回IP数量、进度条、表格等)和最重要的CDN判断后用yakit库进行输出。

for targetIndex, target := range targetList {
    yakit.EnableTable(target, ["监测点", "响应IP", "IP归属地", "响应时间", "TTL", "数据包大小"])
    results := make(chan var)

    // https://www.yaklang.io/docs/newforyak/concurrent
    submitTask := func(param...) {
        go ping(param...)
    }
    for _, serverInfo := range pingServerInfoList {
        submitTask(serverInfo, enkey, checkType, target, results)
    }
    // println(<- results)
    ips := make(map[string]int)
    for i := 0; i < serverNum; i++ {
        data := <- results
     // dump(data)

        // 总进度条
        // yakit.SetProgress(( float64(i+1)*(float64(targetIndex+1)/float64(len(targetList))) )/float64(serverNum))
        yakit.SetProgress( float64(i+1)*float64(targetIndex+1) / (float64(len(targetList)) * float64(serverNum)) )
        // 子任务进度条
        yakit.SetProgressEx(target, float64(i+1)/float64(serverNum))

        // 统计结果、处理表格输出
        if data["state"] == float64(1) {
            if data["result"]["ip"] != "" {
                if ips[data["result"]["ip"]] == undefined {
                    ips[data["result"]["ip"]] = 1
                }else {
                    ips[data["result"]["ip"]]++
                }
                // 成功的探测点输出表格
                tableData := make(map[string]var)
                tableData["监测点"] = data["cityname"]
                tableData["响应IP"] = data["result"]["ip"]
                tableData["IP归属地"] = data["result"]["ipaddress"]
                tableData["响应时间"] = data["result"]["responsetime"]
                tableData["TTL"] = data["result"]["ttl"]
                tableData["数据包大小"] = data["result"]["bytes"]
                yakit.Output(yakit.TableData(target, tableData))
            }
        }
        // printf("\r正在执行ping操作,当前:%v/%v个,成功:%v/%v个,失败:%v/%v个,总进度:%.2f%%", i+1, serverNum, success, serverNum, failure, serverNum, 100*(float64(i+1)/float64(serverNum)))
    }
    println()
    if len(ips) > 1 {
        yakit.StatusCard(sprintf("%v:IS CDN", target), "是", target)
        yakit.StatusCard(sprintf("%v:IP Number", target), len(ips), target)
        // println("监测点返回不同IP,可能存在CDN")
    }else {
        for ip := range ips {
            yakit.StatusCard(sprintf("%v:IS CDN", target), "否", target)
            yakit.StatusCard(sprintf("%v:IP Address", target), ip, target)
            // printf("所有监测点返回IP一致,为%v\n", ip)
        }
    }
    // break
}

最终插件效果

v2-7d51d68248fdb30e16f8b2772b8e9c91_720w.jpg

(其实还可以再给每个域名都分配一个goroutine,让每个目标间的监测也是异步进行的。但是考虑到可能会对接口产生较大的压力,所以就没这样做了。

04 插件地址

如果大家对这个CDN判断插件感兴趣的话,可以直接在插件仓库中导入米斯特的第三方yakit-store插件库。地址为:https://github.com/Acmesec/yakit-store

更新后点击头部的刷新按钮

v2-aefc49dcf0f27b427056f1ce800b784d_720w.jpg

就可以在插件仓库或者基础安全工具中看到插件啦!

v2-9c45a896f8c9baa9bf6bd532233f8491_720w.jpg


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK