零乱 随风安全 2023-12-04 13:00 发表于北京
声明
由于传播、利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,文章作者不为此承担任何责任。 目录 前言 项目研究 fuzzing-templates ParamSpider 代码实现 总体设计 爬虫函数 工具函数 路径问题 爬虫结果问题 HTTP协议头问题 随机UA头 保存路径 扫描函数 主函数 小彩蛋 总结 继续一个彩蛋 前言 项目研究 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的。 [color=rgba(0, 0, 0, 0.9)]查看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:
- - '<!DOCTYPE {{rletter}} [ <!ENTITY {{rletter}} SYSTEM "file:///c:/windows/win.ini"> ]><x>&{{rletter}};</x>'
- - '<!DOCTYPE {{rletter}} [ <!ENTITY {{rletter}} SYSTEM "file:////etc/passwd"> ]><x>&{{rletter}};</x>'
- fuzzing:
- - part: query
- keys-regex:
- - "(.*?)xml(.*?)"
- fuzz:
- - "{{xxe}}"
- - part: query
- values:
- - "(<!DOCTYPE|<?xml|%3C!DOCTYPE|%3C%3Fxml)(.*?)>"
- 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,一个是结果判断。 [color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]HTTP标签: [color=rgba(0, 0, 0, 0.9)]HTTP请求部分 · 方法:GET · 路径:使用变量{{BaseURL}}作为目标URL的基础路径,即传入的带有参数的URL [color=rgba(0, 0, 0, 0.9)]负载 · XXE:定义了两个XXE攻击的负载。这些负载使用了DOCTYPE声明来定义一个实体,该实体尝试读取系统文件(如win.ini或/etc/passwd)。 [color=rgba(0, 0, 0, 0.9)]fuzzing · part:指定模糊测试应用在HTTP请求的查询部分(query)。 · keys-regex:定义了用于匹配包含"xml"的查询参数的正则表达式。 · values:提供了正则表达式,用于匹配可能的XML声明或DOCTYPE声明。 · fuzz:使用上面定义的xxe负载进行模糊测试。 [color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]matchers标签: · stop-at-first-match:为真,表示一旦匹配成功就停止。 · matchers-condition:或,意味着满足任一匹配器就视为成功。 · type:使用正则表达式(regex)和单词(word)两种类型的匹配器。 · part:指定在响应体(body)中进行匹配。 · words:提供了用于检测XXE漏洞的正则表达式和单词列表,如检查是否包含系统文件内容(例如/etc/passwd文件的内容)。 [color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]那么在分析完这个模板内容之后就十分清晰了,与一般的nuclei模板不同的地方就是增加了这个fuzzing标签部分,这部分可以粗略的理解为定义了哪些参数或使用正则匹配哪些内容,使用定义的fuzzing payload进行测试,其他的与一般的模板并无不同,其他的fuzzing-templates也是相同的原理。 [color=rgba(0, 0, 0, 0.9)]简单测试下这个模板: nuclei.exe -t fuzzing\ -u http://127.0.0.1/test?id=1 -v
可以看到跟我们分析的基本一致,替换URL中的参数,进行发包FUZZING。 [color=rgba(0, 0, 0, 0.9)]那么我们分析完之后,阶段性总结一下,现在发现的问题: [color=rgba(0, 0, 0, 0.9)]1.要使用fuzzing-templates需要提供的URL必须是带有参数的,我们要想点子获取带参数的URL; [color=rgba(0, 0, 0, 0.9)]2.模板本身目前全部仅支持GET请求;
[color=rgba(0, 0, 0, 0.9)]问题2目前来说我并没有解决的能力,毕竟这已经是官方自己的模板文件了,看来要实现POST的fuzzing还是要使用ffuf、burp这一类的工具啊。但此时问题1还是有解决办法的,我们是从NucleiFuzzer这个项目调过来的,工具中存在一个ParamSpider的,那么我们就顺势开始这个工具的分析吧。 ParamSpider[color=rgba(0, 0, 0, 0.9)]先说结论,这是一个比较简陋的爬虫小工具,甚至于它并不能称为爬虫工具。 [color=rgba(0, 0, 0, 0.9)]项目结构如下:
直接丢给GPT完成的分析,那么主要的功能部分就是core部分,我们将core部分除init外的3个主要文件进行分析。 [color=rgba(0, 0, 0, 0.9)]按照一般的处理流程: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的结果,其余均是通过主函数的命令行参数进行控制。具体的执行逻辑如下: [color=rgba(0, 0, 0, 0.9)]1.使用正则表达式提取 URL: o set(re.findall(...))用于获取所有匹配项的唯一集合(移除重复项)。 [color=rgba(0, 0, 0, 0.9)]2.处理每个匹配的URL: o 找到第一个和第二个等号(=)的位置,这些位置标识了参数值的开始。 o 遍历每个提取的URL。 [color=rgba(0, 0, 0, 0.9)]3.应用黑名单过滤: o 如果black_list 非空,将使用它来构建一个正则表达式,并检查每个 URL 是否包含黑名单中的任何词。如果 URL 包含黑名单中的词,则跳过该 URL。 [color=rgba(0, 0, 0, 0.9)]4.参数替换: o 如果 level设置为 'high',则对第二个参数值也进行替换。 [color=rgba(0, 0, 0, 0.9)]5.返回结果: o 返回处理后的URL 列表,确保唯一性(移除重复项)。 [color=rgba(0, 0, 0, 0.9)]看完这两个函数之后基本就理解了它整个脚本工具的作用了,请求地址,然后通过正则提取出带有参数的URL,主函数中存在黑名单、等级和占位符等功能,除了黑名单外,实际可有可无。 [color=rgba(0, 0, 0, 0.9)] 值得一提的是主函数中提供了—subs参数提供了针对于子域名的查询,作者采用的方法是访问https://web.archive.org/,通过互联网档案馆的查询功能,查询*.domain,来获取子域名,属于是一个我没见过的思路了。
那么分析到这里,一个想法就出来了,这不是不如我使用rad或者crawlergo进行爬虫获取URL,然后通过判断URL是否存在=?,提取出带有参数的URL,而后调用Nuclei进行扫描呢?
代码实现[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]Nuclei本身不是一款被动扫描工具,但是这都不是问题,你不能被动扫描,我把爬虫结果保存下来然后主动扫描不就行了。 [color=rgba(0, 0, 0, 0.9)]而后我又想到了另一个问题,把带有参数的URL使用fuzzing-templates进行fuzzing了,那不带有参数的呢?是不是我直接用常用的poc进行一个基础扫描呢? 总体设计[color=rgba(0, 0, 0, 0.9)] 1. 设置三个参数-u,-f,-m,代表单个url扫描,读取文件获取url进行扫描,m用来控制模式 2. 功能函数包括爬虫,添加http头和三个模式的扫描函数,分为fuzz、common和all 3. 创建parma目录存爬取到的参数URL,创建common目录存取不带有参数的URL,创建result存取扫描结果 [color=rgba(0, 0, 0, 0.9)]流程图如下:
简单、明了、没有难度。但是实际上我在写的过程中碰到了各种各样的问题,也做了各种各样的完善和考虑,我在这里分享出来,希望各位大佬指点。 爬虫函数[color=rgba(0, 0, 0, 0.9)] 1. 构造crawlergo命令并执行 2. 获取爬虫结果 3. 对结果进行进一步过滤并保存到文件中 [color=rgba(0, 0, 0, 0.9)]针对爬虫的处理参考crawlergo自身项目的readme:
可以看到项目本身提供了一个示例的python调用demo,我们按照这样直接操作即可。 [color=rgba(0, 0, 0, 0.9)]即执行完命令后,获取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
复制代码
针对输出方面,由于爬虫的输出实在是太多了,输出到控制台我觉得很影响观感,所以直接没有输出,当然大家可以自己进行调整。
工具函数[color=rgba(0, 0, 0, 0.9)]在写的过程中发现了几点需求问题: 1. 我的脚本中主要依靠命令行调用外部工具,同时还要指定nuclei的模板路径; 2. 针对网站的爬虫,并不一定存在带有参数的URL; 3. 从文件读取的URL可能存在纯domain或者IPORT类型,需要手动添加http://或者https://头4. 作为一名安服选手,所有的http发包请求都应该添加随机生成UA头。 5. 针对爬取到的url和结果,需要先判断文件夹是否存在,不存在就创建 [color=rgba(0, 0, 0, 0.9)]我将这些的解决统一都写在这一章节中。 路径问题[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]路径采用读取配置文件的方式解决,定义配置文件config.json,内容如下:
- {
- "httpx_path": "",
- "crawlergo_path": "",
- "nuclei_path": "",
- "fuzzing_template_path": "",
- "common_template_path": "",
- "chrome_path": ""
- }
复制代码
按照自己的工具路径位置进行填写,同时需要避免中文目录,配置文件中的httpx并没有使用,至于为什么我会在后面说明。 [color=rgba(0, 0, 0, 0.9)]脚本中使用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']
复制代码
爬虫结果问题[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]爬虫扫描不一定有结果,在运行函数中添加判断,如果存在再去调用相应的扫描函数,不存在就输出提示
- 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协议头问题[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]读取传入的内容添加判断即可
- 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
复制代码[color=rgba(0, 0, 0, 0.9)]
随机UA头[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]采用fake_useragent获取相应的UA头 [color=rgba(0, 0, 0, 0.9)] - from fake_useragent import UserAgent
- ua = UserAgent()
- def get_random_headers():
- headers = {'User-Agent': ua.random}
- return headers
复制代码
保存路径[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]创建判断函数,而后传入进行判断即可
- 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/'])
复制代码
扫描函数[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]针对不同的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的扫描结果我选择了输出,不然这个不输出内容,感觉脚本一直就是处于卡死的状态。 [color=rgba(0, 0, 0, 0.9)]同时在这里我也回答一下为什么没有httpx的问题,按照我最开始的设想,爬取到的URL,我先使用httpx进行验活处理,提取出200 301 403 401这些返回码,但是很不幸的是subprocess.Popen,在windows环境下,对于管道符的处理存在问题,下面是我本来想执行的命令:
type url_parma.txt | httpx -silent -mc 200,301,302,403|nuclei ...[color=rgba(0, 0, 0, 0.9)]此时发现第一个问题,我的url_parma.txt的格式是./parma/url_parma.txt,提示语法命令不正确: [color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]
这都是小事,我把/换成\呗
但是在后续调试过程中,我发现了,管道符没有起作用,它不给后面的命令了,我如果直接使用windows的cmd直接运行这个命令是没问题的,但是使用subprocess.Popen,进行一次输出调试,直接输出内容了,并没有往后面执行,因此我选择直接放弃,httpx的任务就交给域名收集之后大家手动完成吧。 [color=rgba(0, 0, 0, 0.9)]我的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}")
复制代码
主函数[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]主函数主要存在一个问题——不优雅,为什么不优雅?因为我的判断实在太多了,每个扫描函数需要的参数不一样,首先判断需要哪些参数从爬虫的返回里面去取,还要判断文件内容是否存在,如果爬虫没有结果就会导致后面的内容报错。 [color=rgba(0, 0, 0, 0.9)]那么这一部分主要依靠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}")
复制代码
其余部分就不进行展示,其实基本也已经全部展示完了,大家还可以自己添加多任务功能什么的。
小彩蛋[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]想着写都写了,直接给输出提示字符写个颜色,一开始是这么写的:
- class bcolors:
- FAIL = '\033[91m'
- OK = '\033[92m'
- INFO = '\033[94m'
- ENDC = '\033[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}")
复制代码
总结[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]在这篇文章中,我从一个项目的分析作为开始,尝试进行自己的实现与改造。项目是非常简单的项目,思路也是经常碰到的思路,分享自己的问题,也是为了让大家少踩坑,同时存在不足的地方也欢迎大家补充。
[color=rgba(0, 0, 0, 0.9)]
继续一个彩蛋[color=rgba(0, 0, 0, 0.9)] [color=rgba(0, 0, 0, 0.9)]当我想着整理自己的common模板时,我突然想到了攻防中最重要的fastjson和shiro反序列化,当然我也在官方的模板中发现了有大佬提交了。 [color=rgba(0, 0, 0, 0.9)]但是我在调试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框架,然后用其他自动化工具再扫描会不会更好呢?
|