由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,文章作者不为此承担任何责任。
目录
一次网上冲浪的过程中发现了一个项目https://github.com/0xKayala/NucleiFuzzer,抱着看看的心态开始了一段不归路,从有想法,到最后的勉强实现,花费了我整整一天的时间,期间十分的痛苦(现在要写这篇文章就更痛苦了)。整篇文章的思路,我会按照我自己实现的整个流程来,期间可能会有点乱,希望大家理解。
项目研究
NucleiFuzzer 是一款自动化工具,结合了ParamSpider和Nuclei ,以增强Web应用程序安全测试。它使用ParamSpider来识别潜在的入口点,并使用Nuclei的模板来扫描漏洞。NucleiFuzzer简化了这个过程,使安全专业人员和Web开发人员更容易有效地检测和解决安全风险。下载NucleiFuzzer以保护您的Web应用程序免受漏洞和攻击。
以上是项目本身的介绍,通过介绍不然发现,这个项目是一个类似于爬虫+漏扫联动类的项目,很像我们在攻防打点过程中经常使用的爬虫+Xray,这里它使用的Nuclei和自写的简单爬虫工具Paramspider,由于本人对于nuclei的使用较少,理解也很一般,我印象的nuclei就是扫poc的,对于常规的漏洞趋近于不支持,但是它竟然能用来fuzzing嘛?于是引起了我的兴趣,开始研究。
在项目readme中,我们可以看到用到的工具和模板,那么有经验的安服仔在这里基本就能看出来了,它这个项目的本质就是一个工具的联动脚本。
工具一个是它自己写的爬虫,一个是Nuclei,暂时不看,先看看用到的模板吧!
fuzzing-templates
跳转来到项目,发现它这是一个fork的项目,原项目是nuclei团队自己的!原来nuclei自己提供了fuzzing的模板,怪我对它的了解太少。
直接进入到原项目,先看readme:
所以我们要使用这个template需要输入的URL是带有参数的,那么是不是可以认为NucleiFuzzer这个项目中,作者自写的爬虫工具就是为了得到带有参数的URL呢?我们暂时先不管,先点击一个具体的模板分析分析,到底是如何做到fuzzing的。
查看xxe/fuzz-xxe.yaml内容如下:
id: fuzz-xxe
info:
name: XXE Fuzzing
author: pwnhxl
severity: medium
reference:
- https://github.com/andresriancho/w3af/blob/master/w3af/plugins/audit/xxe.py
tags: dast,xxe
variables:
rletter: "{{rand_base(6,'abc')}}"
http:
- method: GET
path:
- "{{BaseURL}}"
payloads:
xxe:
- '<x>&{{rletter}};</x>'
- '<x>&{{rletter}};</x>'
fuzzing:
- part: query
keys-regex:
- "(.*?)xml(.*?)"
fuzz:
- "{{xxe}}"
- part: query
values:
- "( "
fuzz:
- "{{xxe}}"
stop-at-first-match: true
matchers-condition: or
matchers:
- type: regex
name: linux
part: body
regex:
- 'root:.*?:[0-9]*:[0-9]*:'
- type: word
name: windows
part: body
words:
- 'for 16-bit app support'
只需要看http和matchers部分即可,一个是payload,一个是结果判断。
HTTP标签:
HTTP请求部分
-
方法:GET
-
路径:使用变量{{BaseURL}}作为目标URL的基础路径,即传入的带有参数的URL
负载
-
XXE:定义了两个XXE攻击的负载。这些负载使用了DOCTYPE声明来定义一个实体,该实体尝试读取系统文件(如win.ini或/etc/passwd)。
fuzzing
-
part:指定模糊测试应用在HTTP请求的查询部分(query)。
-
keys-regex:定义了用于匹配包含"xml"的查询参数的正则表达式。
-
values:提供了正则表达式,用于匹配可能的XML声明或DOCTYPE声明。
-
fuzz:使用上面定义的xxe负载进行模糊测试。
matchers标签:
-
stop-at-first-match:为真,表示一旦匹配成功就停止。
-
matchers-condition:或,意味着满足任一匹配器就视为成功。
-
type:使用正则表达式(regex)和单词(word)两种类型的匹配器。
-
part:指定在响应体(body)中进行匹配。
-
words:提供了用于检测XXE漏洞的正则表达式和单词列表,如检查是否包含系统文件内容(例如/etc/passwd文件的内容)。
那么在分析完这个模板内容之后就十分清晰了,与一般的nuclei模板不同的地方就是增加了这个fuzzing标签部分,这部分可以粗略的理解为定义了哪些参数或使用正则匹配哪些内容,使用定义的fuzzing payload进行测试,其他的与一般的模板并无不同,其他的fuzzing-templates也是相同的原理。
简单测试下这个模板:
nuclei.exe -t fuzzing -u http://127.0.0.1/test?id=1 -v
可以看到跟我们分析的基本一致,替换URL中的参数,进行发包FUZZING。
那么我们分析完之后,阶段性总结一下,现在发现的问题:
1.要使用fuzzing-templates需要提供的URL必须是带有参数的,我们要想点子获取带参数的URL;
2.模板本身目前全部仅支持GET请求;
问题2目前来说我并没有解决的能力,毕竟这已经是官方自己的模板文件了,看来要实现POST的fuzzing还是要使用ffuf、burp这一类的工具啊。但此时问题1还是有解决办法的,我们是从NucleiFuzzer这个项目调过来的,工具中存在一个ParamSpider的,那么我们就顺势开始这个工具的分析吧。
ParamSpider
先说结论,这是一个比较简陋的爬虫小工具,甚至于它并不能称为爬虫工具。
项目结构如下:
直接丢给GPT完成的分析,那么主要的功能部分就是core部分,我们将core部分除init外的3个主要文件进行分析。
按照一般的处理流程:requester(请求)——>extractor(提取)——>save_it(保存),保存函数不进行分析,尝试对请求和提取进行分析。
-
requester:
上面是定义UA头,并随机选择,下面是对URL发起请求,获取response的内容,试用raise_for_status()处理错误的返回状态码,如404或500,最后是一系列的异常处理操作。得出结论这就是个纯粹的request,可以说是没有进行其他任何操作。
-
extractor:
传入四个参数:response, level, black_list, placeholder,分别代表要从中提取 URL 的字符串、提取级别,控制提取的详细程度、一个包含不应包含在最终 URL 中的扩展名或词的列表、用于替换 URL 中参数值的占位符,其中response就是requester的结果,其余均是通过主函数的命令行参数进行控制。具体的执行逻辑如下:
1.使用正则表达式提取 URL:
-
set(re.findall(...)) 用于获取所有匹配项的唯一集合(移除重复项)。
-
函数使用正则表达式 r'.*?://.*?.*=[^$]' 来匹配含有查询参数的 URL。这个表达式匹配形式为 http://someurl.com?param=value 的字符串。
2.处理每个匹配的 URL:
-
找到第一个和第二个等号(=)的位置,这些位置标识了参数值的开始。
-
遍历每个提取的 URL。
3.应用黑名单过滤:
-
如果 black_list 非空,将使用它来构建一个正则表达式,并检查每个 URL 是否包含黑名单中的任何词。如果 URL 包含黑名单中的词,则跳过该 URL。
4.参数替换:
-
如果 level 设置为 'high',则对第二个参数值也进行替换。
-
用 placeholder 替换 URL 中的参数值。例如,如果 placeholder 是 XXX,则 URL http://example.com?page=1 会变成 http://example.com?page=XXX。
5.返回结果:
-
返回处理后的 URL 列表,确保唯一性(移除重复项)。
看完这两个函数之后基本就理解了它整个脚本工具的作用了,请求地址,然后通过正则提取出带有参数的URL,主函数中存在黑名单、等级和占位符等功能,除了黑名单外,实际可有可无。
值得一提的是主函数中提供了—subs参数提供了针对于子域名的查询,作者采用的方法是访问https://web.archive.org/,通过互联网档案馆的查询功能,查询*.domain,来获取子域名,属于是一个我没见过的思路了。
那么分析到这里,一个想法就出来了,这不是不如我使用rad
或者crawlergo
进行爬虫获取URL,然后通过判断URL是否存在=?
,提取出带有参数的URL,而后调用Nuclei
进行扫描呢?
代码实现
目前我个人使用的较多联动脚本是https://github.com/timwhitez/crawlergo_x_XRAY,基本逻辑就是使用crawlergo进行爬虫,工具本身存在--push-to-proxy参数,此时将代理设置为xray被动监听的地址,就实现了爬虫+被动扫描的联动。
Nuclei本身不是一款被动扫描工具,但是这都不是问题,你不能被动扫描,我把爬虫结果保存下来然后主动扫描不就行了。
而后我又想到了另一个问题,把带有参数的URL使用fuzzing-templates进行fuzzing了,那不带有参数的呢?是不是我直接用常用的poc进行一个基础扫描呢?
总体设计
-
设置三个参数-u,-f,-m,代表单个url扫描,读取文件获取url进行扫描,m用来控制模式
-
功能函数包括爬虫,添加http头和三个模式的扫描函数,分为fuzz、common和all
-
创建parma目录存爬取到的参数URL,创建common目录存取不带有参数的URL,创建result存取扫描结果
流程图如下:
简单、明了、没有难度。但是实际上我在写的过程中碰到了各种各样的问题,也做了各种各样的完善和考虑,我在这里分享出来,希望各位大佬指点。
爬虫函数
-
构造crawlergo命令并执行
-
获取爬虫结果
-
对结果进行进一步过滤并保存到文件中
针对爬虫的处理参考crawlergo自身项目的readme:
可以看到项目本身提供了一个示例的python调用demo,我们按照这样直接操作即可。
即执行完命令后,获取req_list的结果,而后获取req_list[’url’]的结果,针对url进行判断,是否存在?=,而后按照结果进行保存,保存的文件的格式采用url_parma.txt和url_common.txt用来区分目标和爬取到的url是否带有参数,由于文件名中不能存在://等字符,均直接替换为下划线:
def crawl_url(url):
"""爬虫函数"""
print(f"{bcolors.OK}----开始爬虫----{bcolors.ENDC}")
# 构造文件名
param_filename = './parma/' + url.replace('http://', '').replace('https://', '').replace('/', '_').replace(':', '_') + "_parma.txt"
common_filename = './common/' + url.replace('http://', '').replace('https://', '').replace('/', '_').replace(':', '_') + "_common.txt"
# 构造crawlergo命令并执行
cmd = [crawlergo_path, "-c", chrome_path,"-t", "5","-f","smart","--fuzz-path","--custom-headers",json.dumps(get_random_headers()), "--output-mode", "json" , url]
rsp = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, error = rsp.communicate()
try:
# 处理爬虫结果
result = simplejson.loads(output.split("--[Mission Complete]--")[1])
except:
print(f"{bcolors.FAIL}爬虫失败{bcolors.ENDC}")
return None, None
req_list = result["req_list"]
print(f"{bcolors.OK}----爬虫结束,开始处理爬取结果----{bcolors.ENDC}")
if req_list:
for req in req_list:
url = req['url']
if '=' in url and '?' in url:
with open(param_filename, 'a') as f:
f.write(url + 'n')
else:
with open(common_filename, 'a') as f:
f.write(url + 'n')
return param_filename, common_filename
针对输出方面,由于爬虫的输出实在是太多了,输出到控制台我觉得很影响观感,所以直接没有输出,当然大家可以自己进行调整。
工具函数
在写的过程中发现了几点需求问题:
-
我的脚本中主要依靠命令行调用外部工具,同时还要指定nuclei的模板路径;
-
针对网站的爬虫,并不一定存在带有参数的URL;
-
从文件读取的URL可能存在纯domain或者IP:PORT类型,需要手动添加http://或者https://头
-
作为一名安服选手,所有的http发包请求都应该添加随机生成UA头。
-
针对爬取到的url和结果,需要先判断文件夹是否存在,不存在就创建
我将这些的解决统一都写在这一章节中。
路径问题
路径采用读取配置文件的方式解决,定义配置文件config.json,内容如下:
{
"httpx_path": "",
"crawlergo_path": "",
"nuclei_path": "",
"fuzzing_template_path": "",
"common_template_path": "",
"chrome_path": ""
}
按照自己的工具路径位置进行填写,同时需要避免中文目录,配置文件中的httpx并没有使用,至于为什么我会在后面说明。
脚本中使用json.load的方式获取相关路径内容:
def get_config():
with open('config.json', 'r') as f:
config = json.load(f)
return config
config = get_config()
httpx_path = config['httpx_path']
crawlergo_path = config['crawlergo_path']
nuclei_path = config['nuclei_path']
fuzzing_template_path = config['fuzzing_template_path']
common_template_path = config['common_template_path']
chrome_path = config['chrome_path']
爬虫结果问题
爬虫扫描不一定有结果,在运行函数中添加判断,如果存在再去调用相应的扫描函数,不存在就输出提示
if os.path.exists(param_filename) and process_func in [process_url_fuzz, process_url_all]:
process_func(url, *needed_params)
elif os.path.exists(common_filename) and process_func in [process_url_common, process_url_all]:
process_func(url, *needed_params)
else:
print(f"{bcolors.FAIL}无法以{args.mode}处理{url},因为没有足够的数据{bcolors.ENDHTTC}")
HTTP协议头问题
读取传入的内容添加判断即可
def getAllrequest(target_url):
"""添加http头"""
# url检测,传入的url是否为完整的域名,若仅为IP+port需要添加协议头
isHTTPS = True # 将是否为https首先标志为True
if ("https" not in target_url) & ("http" not in target_url):
try:
url = "https://" + target_url
requests.packages.urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) # 忽略ssl验证警告
requests.get(url=url, verify=False, timeout=3) # 设置忽略ssl证书安全性警告,设置3s的延迟保证程序健壮性
except Exception as e:
isHTTPS = False
finally:
if isHTTPS:
target_url = "https://" + target_url
else:
target_url = "http://" + target_url
return target_url
随机UA头
采用fake_useragent获取相应的UA头
from fake_useragent import UserAgent
ua = UserAgent()
def get_random_headers():
headers = {'User-Agent': ua.random}
return headers
保存路径
创建判断函数,而后传入进行判断即可
def ensure_directories_exist(directories):
"""Ensure that the given directories exist, create them if not."""
for directory in directories:
if not os.path.exists(directory):
os.makedirs(directory)
# 主函数中
# Check if directories exist, create if not
ensure_directories_exist(['./parma/', './common/', './result/'])
扫描函数
针对不同的mode模式调用不同的扫描函数,具体内容应该基本一致,唯一需要注意的就是保存nuclei扫描结果的报告命名方式,这里我同样采用url_common.json或者url_fuzz.json的方式进行保存
def process_url_common(url, common_filename):
"""common模式扫描"""
# 定义输出文件名
output_common_filename = url.replace('http://', '').replace('https://', '').replace('/', '_').replace(':', '_') + "_common.json"
print(f'{bcolors.OK}----开始扫描{common_filename}----{bcolors.ENDC}')
cmd_httpx_nuclei = f"{nuclei_path} -t {common_template_path} -l {common_filename} -o ./result/{output_common_filename}"
print(f"{bcolors.OKBLUE}{cmd_httpx_nuclei}{bcolors.ENDC}")
try:
rsp = subprocess.Popen(cmd_httpx_nuclei, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = rsp.communicate()
print(stdout)
print(stderr)
print(f"{bcolors.OK}----{url},common扫描结束----{bcolors.ENDC}")
except FileNotFoundError as e:
print(f"{bcolors.FAIL}Error: {e}{bcolors.ENDC}")
nuclei的扫描结果我选择了输出,不然这个不输出内容,感觉脚本一直就是处于卡死的状态。
同时在这里我也回答一下为什么没有httpx的问题,按照我最开始的设想,爬取到的URL,我先使用httpx进行验活处理,提取出200 301 403 401这些返回码,但是很不幸的是subprocess.Popen,在windows环境下,对于管道符的处理存在问题,下面是我本来想执行的命令:
type url_parma.txt | httpx -silent -mc 200,301,302,403|nuclei ...
此时发现第一个问题,我的url_parma.txt的格式是./parma/url_parma.txt
,提示语法命令不正确:
这都是小事,我把/换成呗
但是在后续调试过程中,我发现了,管道符没有起作用,它不给后面的命令了,我如果直接使用windows的cmd直接运行这个命令是没问题的,但是使用subprocess.Popen,进行一次输出调试,直接输出内容了,并没有往后面执行,因此我选择直接放弃,httpx的任务就交给域名收集之后大家手动完成吧。
我的all模式扫描函数,选择的方式是直接调用fuzz和common两个扫描函数,因此在判断爬虫结果的处理上还是存在问题,我索性在这个函数中重新进行了一次判断:
def process_url_all(url, param_filename, common_filename):
"""all模式扫描"""
if os.path.exists(param_filename):
process_url_fuzz(url, param_filename)
else:
print(f"{bcolors.FAIL}{param_filename} does not exist{bcolors.ENDC}")
if os.path.exists(common_filename):
process_url_common(url, common_filename)
else:
print(f"{bcolors.FAIL}{common_filename} does not exist{bcolors.ENDC}")
主函数
主函数主要存在一个问题——不优雅,为什么不优雅?因为我的判断实在太多了,每个扫描函数需要的参数不一样,首先判断需要哪些参数从爬虫的返回里面去取,还要判断文件内容是否存在,如果爬虫没有结果就会导致后面的内容报错。
那么这一部分主要依靠github copilot的建议完成,定义一个字典:
process_funcs = {
'fuzz': (process_url_fuzz, [0]),
'common': (process_url_common, [1]),
'all': (process_url_all, [0, 1])
}
process_func, param_indices = process_funcs[args.mode]
needed_params = [all_params[i] for i in param_indices]
if os.path.exists(param_filename) and process_func in [process_url_fuzz, process_url_all]:
process_func(url, *needed_params)
elif os.path.exists(common_filename) and process_func in [process_url_common, process_url_all]:
process_func(url, *needed_params)
else:
print(f"{bcolors.FAIL}无法以{args.mode}处理{url},因为没有足够的数据{bcolors.ENDC}")
其余部分就不进行展示,其实基本也已经全部展示完了,大家还可以自己添加多任务功能什么的。
小彩蛋
想着写都写了,直接给输出提示字符写个颜色,一开始是这么写的:
class bcolors:
FAIL = ' 33[91m'
OK = ' 33[92m'
INFO = ' 33[94m'
ENDC = ' 33[0m'
print(f"{bcolors.OK}----开始爬虫----{bcolors.ENDC}")
发现用这样的方式windows的cmd显示不出来
那么我就继续问AI吧(没有AI我什么都不是啊),解决方案是使用python的colorama库
from colorama import init, Fore, Back, Style
class bcolors:
HEADER = Fore.CYAN
OKBLUE = Fore.BLUE
OK = Fore.GREEN
WARNING = Fore.YELLOW
FAIL = Fore.RED
ENDC = Fore.RESET
BOLD = Style.BRIGHT
UNDERLINE = Style.DIM
print(f"{bcolors.OK}----开始爬虫----{bcolors.ENDC}")
总结
在这篇文章中,我从一个项目的分析作为开始,尝试进行自己的实现与改造。项目是非常简单的项目,思路也是经常碰到的思路,分享自己的问题,也是为了让大家少踩坑,同时存在不足的地方也欢迎大家补充。
继续一个彩蛋
当我想着整理自己的common模板时,我突然想到了攻防中最重要的fastjson和shiro反序列化,当然我也在官方的模板中发现了有大佬提交了。
但是我在调试shiro反序列化的模板时发现了问题,就是扫不出来啊,我首先用fofa提取了1W个ruoyi,它一个没扫出来,然后我受不了了,开了一个vulhub,它竟然也没扫出来,那这样我就觉得不对了。
模板的内容如上,采用的payload就是shiro_encrypted_keys.txt这个文档中的内容,那么我们看看文档中的内容:
payload字典每一行实际上是key:payload的内容,我们知道shiro的加密方式是aes然后base64,我们随便取一行用相同的方式解密就能得到加密的原材料,就是原始的payload,然后用你自己的key加密,这样就能扩展这个字典了(都不能用,先想着怎么加字典了)。然后我们发现,模板中是直接读取这里面的内容,也就是说我们发送的payload是rememberMe=key:payload,这明显不对吧,直接删除key:,重新扫一下:
好了,扫出来了,但是实际上如果真正要用的话,是不是只用shiro-detect.yaml,判断是否存在shiro框架,然后用其他自动化工具再扫描会不会更好呢?
点击下方名片进入公众号,欢迎关注!
往期推荐
原文始发于微信公众号(随风安全):Nuclei Fuzzer 实战指南:自动化 Web 应用安全测试的优化与实践
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论