Nmap源码分析,identify_404系列代码

关于我最近对Nmap源码的兴趣,还得从前置文章说起。在Nmap的http-enum脚本内,我注意到脚本的主要作者Ron Bowes(同时也是脚本引擎http库的作者),对404状态码进行了大量附加说明。另外,脚本中不少判断都依赖于http库identify_404的返回值。在好奇心驱使下,我进入http库细细观摩了整个identify_404的实现逻辑和运用,并被数位前辈细心的观察和实践所打动。

一、不断观察,不断改进

在identify_404的函数注释里,可以看到这个功能的最初构思。原作者Ron Bowes提到,他通过观察服务器与客户端的交互过程,发现并不是所有服务器在处理不存在的请求资源时,都能正确传回404状态码,某些服务器对于以下三种类型的不存在的请求路径,会有不同的响应:

1、全小写字母的路径

2、大小写混用的路径

3、位于不存在的目录里的路径

于是Ron根据以上三种情况,设计了三种不同的请求路径,并对这三个路径发起GET请求,测试服务器的响应,并将此作为依据,判断下一步行动。

不过这一初始构想始于2009年,距今已经15年之久,Ron的观察虽然已经极尽细致,但难免有些许遗漏。2015年,另一位Nmap脚本作者Tom Seller在测试脚本时,发现某些交换机会把对根目录/的请求重定向到其他页面(404劫持,以前运营商的老基操了)。他随即联想到identify_404,原先代码并没有特别设定禁用重定向。要知道,一般的HTTP客户端,如果没有特别指定,都是默认遵守重定向的。那么问题就来了,虽然identify_404针对不同状态码进行了分流处理,但如果一个请求,首先返回302,遵循重定向后,状态码变成了200,那处理逻辑就变成了状态码200的逻辑,而不是3XX的逻辑,这可能会影响到函数的准确性。禁用重定向,identify_404的又一个功能性改动就这么来了。

二、代码逻辑

在整个http库中,涉及到identify_404的地方,包括清洗、加工、储存和调用,一共有can_use_head,clean_404,cache_404_response,page_exists和identify_404本身这五个函数。

除了identify_404是处理404逻辑的主函数,clean_404,cache_404_response这两个函数服务于identify_404,clean_404顾名思义,用于清理请求的返回值,cache_404_response同理,用来储存返回值,page_exists和can_use_head则是调用identify_404的处理结果,用于下一步判断。

现在,看一下源码,梳理逻辑:

-- 一些常量,预设好的返回值,以及一个被归类为bad_responses的数组(3XX/4XX/5XX)用于循环,和禁用重定向的参数
local bad_responses = { 301, 302, 400, 401, 403, 499, 501, 503 }
local identify_404_get_opts = {redirect_ok=false}
local identify_404_cache_404 = {true, 404}
local identify_404_cache_unknown = {false,
  "Two known 404 pages returned valid and different pages; unable to identify valid response."
}
local identify_404_cache_unknown_folder = {false,
  "Two known 404 pages returned valid and different pages; unable to identify valid response (happened when checking a folder)."
}
local identify_404_cache_200 = {true, 200}
---Try requesting a non-existent file to determine how the server responds to
-- unknown pages ("404 pages")
--
-- @param host The host object.
-- @param port The port to which we are establishing the connection.
-- @return status Did we succeed?
-- @return result If status is false, result is an error message. Otherwise,
--                it's the code to expect (typically, but not necessarily,
--                '404').
-- @return body Body is a hash of the cleaned-up body that can be used when
--              detecting a 404 page that doesn't return a 404 error code.
function identify_404(host, port)
  if type(host) == "table" and host.registry and host.registry.http_404 then
    local portnum = port
    if type(port) == "table" then
      portnum = port.number
    end
    local result = host.registry.http_404[portnum]
    if result then
      return table.unpack(result)
    end
  end
  local data

  -- 针对第一节提到的三种情境,设计了三种不同的请求路径
  local URL_404_1 = '/nmaplowercheck' .. os.time(os.date('*t'))
  local URL_404_2 = '/NmapUpperCheck' .. os.time(os.date('*t'))
  local URL_404_3 = '/Nmap/folder/check' .. os.time(os.date('*t'))

  data = get(host, port, URL_404_1, identify_404_get_opts)
  if(data == nil) then
    stdnse.debug1("HTTP: Failed while testing for 404 status code")
    -- do not cache; maybe it will work next time?
    return false, "Failed while testing for 404 error message"
  end

  -- 根据第一次请求的状态码来分流处理,分为404,200,bad_response,和其他
  if(data.status and data.status == 404) then
    stdnse.debug1("HTTP: Host returns proper 404 result.")
    return cache_404_response(host, port, identify_404_cache_404)
  end

  if(data.status and data.status == 200) then
    stdnse.debug1("HTTP: Host returns 200 instead of 404.")

    -- Clean up the body (for example, remove the URI). This makes it easier to validate later
    if(data.body) then
      -- Obtain a couple more 404 pages to test different conditions
      local data2 = get(host, port, URL_404_2)
      local data3 = get(host, port, URL_404_3)
      if(data2 == nil or data3 == nil) then
        stdnse.debug1("HTTP: Failed while testing for extra 404 error messages")
        -- do not cache; maybe it will work next time?
        return false, "Failed while testing for extra 404 error messages"
      end

      -- Check if the return code became something other than 200.
      -- Status code: -1 represents unknown.
      -- If the status is nil or the string "unknown" we switch to -1.
      if(data2.status ~= 200) then
        if(type(data2.status) ~= "number") then
          data2.status = -1
        end
        stdnse.debug1("HTTP: HTTP 404 status changed for second request (became %d).", data2.status)
        return cache_404_response(host, port, {false,
            string.format("HTTP 404 status changed for second request (became %d).", data2.status)
          })
      end

      -- Check if the return code became something other than 200
      if(data3.status ~= 200) then
        if(type(data3.status) ~= "number") then
          data3.status = -1
        end
        stdnse.debug1("HTTP: HTTP 404 status changed for third request (became %d).", data3.status)
        return cache_404_response(host, port, {false,
            string.format("HTTP 404 status changed for third request (became %d).", data3.status)
          })
      end

      -- Check if the returned bodies (once cleaned up) matches the first returned body
      local clean_body  = clean_404(data.body)
      local clean_body2 = clean_404(data2.body)
      local clean_body3 = clean_404(data3.body)
      if(clean_body ~= clean_body2) then
        stdnse.debug1("HTTP: Two known 404 pages returned valid and different pages; unable to identify valid response.")
        stdnse.debug1("HTTP: If you investigate the server and it's possible to clean up the pages, please post to nmap-dev mailing list.")
        return cache_404_response(host, port, identify_404_cache_unknown)
      end

      if(clean_body ~= clean_body3) then
        stdnse.debug1("HTTP: Two known 404 pages returned valid and different pages; unable to identify valid response (happened when checking a folder).")
        stdnse.debug1("HTTP: If you investigate the server and it's possible to clean up the pages, please post to nmap-dev mailing list.")
        return cache_404_response(host, port, identify_404_cache_unknown_folder)
      end

      cache_404_response(host, port, {true, 200, clean_body})
      return true, 200, clean_body
    end

    stdnse.debug1("HTTP: The 200 response didn't contain a body.")
    return cache_404_response(host, port, identify_404_cache_200)
  end

  -- Loop through any expected error codes
  for _,code in pairs(bad_responses) do
    if(data.status and data.status == code) then
      stdnse.debug1("HTTP: Host returns %s instead of 404 File Not Found.", get_status_string(data))
      return cache_404_response(host, port, {true, code})
    end
  end

  stdnse.debug1("Unexpected response returned for 404 check: %s", get_status_string(data))

  return cache_404_response(host, port, {true, data.status})
end

一开始,先在函数外定义了几个table(数组),用于存储一些既定的返回值和可迭代对象(bad_responses,3XX/4XX/5XX状态码)。

函数内,在完成一些初始化设定后,就开始了真正的表演。Ron按自己的观察设计了3种不存在的请求路径,并对第一个路径发起请求。根据路径1(用data表示)的状态码,用if语句进行分支选择:

-- 1 如果data请求失败(为nil),不保存结果,return false
-- 2 如果data状态码为404,return true (预设返回值identify_404_cache_404)
-- 3 如果data状态码为200
    -- 3.1 如果data存在响应数据,对路径2和路径3发起请求(data2和data3)
        -- 3.1.1 如果data2或data3请求失败,不保存结果,return false
        -- 3.1.2 如果data2的状态码和data不同,return false
        -- 3.1.3 如果data3的状态码和data不同,return false
        -- 
        -- 以上是纯比较状态码的异同,但如果3次请求状态码皆为200,那么会对响应数据做进一步的清洗和对比
        -- 调用clean_404对3次请求的响应数据(用body表示)进行清理
        -- 3.1.4 如果data2.body和data.body不同,return false (预设返回值identify_404_cache_unknown)
        -- 3.1.5 如果data3.body和data.body不同,return false (预设返回值identify_404_cache_unknown_folder)
        -- 3.1.6 如果3次状态码皆为200,并且body也相同,return true,并额外返回data的clean_body(至此,已经穷举完存在响应数据的所有情况,如果对3种不同的不存在路径状态码一样,而且响应数据也一样,那么可能是某种自定义的页面)
    -- 3.2 如果data不存在响应数据,return true (预设返回值identify_404_cache_200)
-- 4 如果data状态码为bad_responses中任一,return true
-- 5 如果data状态码不属于上述任一情况,return true

通过这样地毯式的梳理,基本穷尽了各种状态码的可能性。

三、200状态码可能的原因

不难看出,identify_404非常仔细和深入地分析了在200状态码下的各种场景,光是对比状态码还不够,还要抽丝剥茧地对响应数据进行对比。可见404页面返回200状态码,给客户端带来了不小的困扰。

看到了现象,就想了解它的本质和产生的原因。

如果回到Ron最初的构想,考虑它的时空背景是2009年,那时候还是互联网从科网泡沫中恢复,逐渐过渡到兴盛的时期。早在2010年之前,有相当部分的框架和程序员,会对不存在的资源返回404错误页面,却将状态码设置为200。现在还能在互联网上找到一些零几年的文章和讨论串,比如这篇,提到谷歌search console曾对返回200的404页面发出过警告:We’ve detected that your 404 (file not found) error page returns a status of 200 (OK) in the header.’。webmaster论坛的版主也提示过不存在的资源返回200状态码可能会影响收录,返回404状态码才是最佳实践。

那么Tom提出的功能性改动,禁用重定向的时空背景是2015年。Tom提到是观察某些交换机发现的重定向。我一下子就想到了2017年以前,各大运营商臭名昭著的网页劫持,这类劫持往往用302跳转进行缓存劫持;而具体到2015年,则曝出了小米路由器劫持404页面的事件,还引发了大规模讨论。现在运营商劫持和路由器劫持已经不太常见,忘记是哪一年销声匿迹的。

四、一点展望,关于信息隐藏

说到最后,我在网上查前辈们关于identify_404的记述时,在medium上看见一篇名为Evading Detection while using nmap的文章,还有个别博客,都提到虽然Nmap可以通过指定User-Agent等方式掩盖信息,但identify_404所构造的3条检测路径,却结结实实地出卖了你:

local URL_404_1 = '/nmaplowercheck' .. os.time(os.date('*t'))
local URL_404_2 = '/NmapUpperCheck' .. os.time(os.date('*t'))
local URL_404_3 = '/Nmap/folder/check' .. os.time(os.date('*t'))

每一个路径都带有字符串“nmap”,要是系统管理员上点心,一溯源,不就知道是nmap扫描了吗。

其实这三个检测路径,混淆一下,进行多次编码,无法直接看出nmap字符串或许是更好的方法。

发布者

发表回复

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