运维笔记:从消失的301记录一窥WEB指纹扫描器的请求设计

本期主题其实是困扰了我蛮久的一个问题。Cpanel上的原始访问日志(Raw Access Log)一般是按照SSL(443端口)和非SSL(80端口)来分割的。一直以来,我注意到,即便是开启强制跳转HTTPS,有大量非SSL流量在服务器返回301之后并没有跳转到HTTPS。我当时查了cpanel和Apache社区,还有各类exchange论坛,但我并没有得到答案。

一、缘起

举个例子,下图的这些流量,就“神秘消失”了:

这些流量很明显是URL probing

这些流量去哪了?按正常流程来讲,客户端和服务器对301处理应该是:

客户端收到301重定向响应后,会解析响应头部字段,发现Location字段指示了新的URL。于是,客户端会根据新的URL发起一个新的请求。

但我观察非SSL日志时,我发现这些神秘消失的流量和其他正常到达HTTPS的流量有不小的差异:

1、来自服务器的轮询任务、baidu等搜索引擎的爬虫和浏览器的流量能正确重定向至HTTPS(百度爬虫以后有时间有机会可以展开讲讲,有很多有意思的发现)。

2、来自扫描器,一眼能看出是URL刺探的流量往往不能到达HTTPS,只有部分能够重定向到HTTPS。

一句话,就是恶意流量有古怪!

二、折腾

遇到问题,我的第一反应都是情景复现,看下能不能还原当时的各类环境。因为天天跟各种HTTP状态码打交道,每条请求后面是人是鬼我大概都有个数。在404记录中,可以看到大量对某些不存在的静态文件的请求,这些静态文件可能是某个图标、某个样式表、某个xml、某个文本文档,等等。对脚本小子我来说,这画面太熟悉了,这不就是渗透第一步,信息收集吗。这些静态文件其实是某个插件、CMS的指纹(finger print),攻击者是在扫描你的网站,企图窥探网站的技术栈看能不能匹配上已知的漏洞。

在我的经验中,很多库在发送请求时都能提供精细的控制,Python或js设置一下参数就能做到。但我知道,这次与其自己写个脚本来测试一下,不如直接单刀直入,直接去关于web scanner的各类社区看一看。结果实在是令我失望,Burp、nikto里关于重定向的问题,90%以上围绕的是,“怎么打开/关闭重定向开关?”,之类关于工具使用,HOW的问题。我想知道的是WHY,为什么大部分扫描器不会自动、主动遵循重定向。

看来,去使用者社区是找不到答案的,那只能溯源追本,去理解WEB扫描器的设计了。好在不少扫描器是开源的,我这次就从NMAP的http-enum脚本入手。为什么选择NMAP,因为它是信息收集阶段最负盛名的利器,生态非常完整,可供学习的资料最多。http-enum,顾名思义HTTP枚举,是NMAP中用来扫描URL,探寻敏感文件和潜在漏洞的脚本。

我注意到脚本说明里有一句话:By default, only pages that return 200 OK or 401 Authentication Required are displayed. 即是说不添加displayall参数的话,只有返回200或401状态码的结果才会被展示。先来看看脚本代码逻辑吧!

脚本的逻辑很明确,就是拼接URL、模拟请求、解析请求。
一开始先从http-fingerprints.lua文件中读取敏感路径,再拼接命令行输入的主机构成完整的URL。看一下http-fingerprints.lua中的代码片段:

table.insert(fingerprints, {
    category = 'general',
    probes = {
      {
        path = '/reg_1.htm',
        method = 'HEAD'
      }
    },
    matches = {
      {
        match = '',
        output = 'Polycom IP phone'
      }
    }
  });

这个指纹文件,按照不同类目(操作系统、CMS、设备、数据库)将指纹路径分门别类。每条路径都有对应的请求方法,这一栏为空的话默认是GET,其他的GET或HEAD各一半。matches中的字段match,是这个路径成功利用后,会包含的字符串,output就是请求结果和match匹配后,用于打印输出结果的说明。

脚本迭代完所有路径后,把请求结果存储在results_nopipeline中,接下来会迭代results_nopipeline,看请求结果是否包含http-fingerprints.lua中match字段对应值。在迭代results_nopipeline时,先设定一个名为good的flag,如果成功匹配,那么goog为true,可以打印输出。

脚本大概的逻辑差不多是这样。

既然我想了解的是重定向,那毫无疑问从HTTP状态码入手,先看一下这个脚本是如何处理各种状态码的。但是脚本中一直到results_nopipeline这一步,都没有进行任何状态码的筛选。也就是说,不管状态码是什么,所有的请求结果都保存进了results_nopipeline。那么进一步筛选的关键就在于迭代results_nopipeline的过程中了。先来看代码片段:

if(result) then
        local path = basepath .. probe['path']
        local good = true
        local output = nil
        -- Unless this check said to ignore 404 messages, check if we got a valid page back using a known 404 message.
        if(fingerprint.ignore_404 ~= true and not(http.page_exists(result, result_404, known_404, path, displayall))) then
          good = false
        else
          -- Loop through our matches table and see if anything matches our result
          for _, match in ipairs(fingerprint.matches) do
            if(match.match) then
              local result, matches = http.response_contains(result, match.match)
              if(result) then
                output = match.output
                good = true
                for k, value in ipairs(matches) do
                  output = string.gsub(output, '\\' .. k, matches[k])
                end
              end
            else
              output = match.output
            end

找到了,if(fingerprint.ignore_404 ~= true and not(http.page_exists(result, result_404, known_404, path, displayall))) ,ignore_404默认为false,那么关键的地方就是它调用的http.page_exists了。好家伙,不得不进一步看nselib的核心模块http了。

page_exists这个函数接受5个参数data, result_404, known_404, page, displayall(返回值、identify_404的值,路径、display参数),将状态码分成两类来处理,一类是200,一类是不是200。因为我想看的是3xx状态码的对应处理,所以直接后者:

else
      -- If the result isn't a 200, check if it's a 404 or returns the same code as a 404 returned
      if(data.status ~= 404 and data.status ~= result_404) then
        -- If this check succeeded, then the page isn't a standard 404 -- it could be a redirect, authentication request, etc. Unless the user
        -- asks for everything (with a script argument), only display 401 Authentication Required here.
        stdnse.debug1("HTTP: Page didn't match the 404 response (%s) (%s)", get_status_string(data), page)

        if(data.status == 401) then -- "Authentication Required"
          return true
        elseif(displayall) then
          return true
        end

        return false
      else
        -- Page was a 404, or looked like a 404
        return false
      end
    end

因为只有page_exists的返回值为false时,http-enum脚本的判断才会成立。在这个判断中,如果状态码既不是200,也不是404,那么会进一步检查是否为401,为401则返回值为true(这也就对应了前面提到的,默认情况下只有200和401会被展示);接下来检查是否传入displayall参数,有displayall参数,则会额外展示其他错误状态码(400、403、503、500等),返回值也将为true;其余情况(3xx,或是没有指定displayall参数)通通返回false。

一通看下来,你就明白了,对于web扫描器来说,有价值的首先是200和404,因为它们的目的在于快速定位资源,这个路径存在还是不存在是最重要的。在某些情况下(比如这里提到的displayall)错误状态码也有一定意义,能为渗透测试提供一定的线索,但也存在误报率的可能。至于3xx,跟前边两位比起来,意义就不是特别大了。

PS 我感觉自己这次有点钻牛角尖了,算了,who cares!下次的主题我都想好啦,立个flag,想进一步讲讲false positive和identify_404。大家节日快乐!

发布者

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注