├── requirements.txt ├── tutorial ├── example │ ├── seeds │ │ ├── test-4 │ │ ├── test-3 │ │ ├── test-10 │ │ ├── test-12 │ │ ├── test-13 │ │ ├── test-14 │ │ ├── test-15 │ │ ├── test-16 │ │ ├── test-21 │ │ └── test-24 │ ├── fuzztest │ └── seeds-cov │ │ ├── test-3.cov │ │ ├── test-4.cov │ │ ├── test-10.cov │ │ ├── test-12.cov │ │ ├── test-13.cov │ │ ├── test-14.cov │ │ ├── test-15.cov │ │ ├── test-16.cov │ │ ├── test-21.cov │ │ ├── test-24.cov │ │ ├── test-10-drcov3.cov │ │ └── test-10.modcov ├── Makefile ├── fuzztest.c └── README.md ├── .gitignore ├── pictures ├── bncov_demo.png ├── demo_overview.gif ├── Coverage-Report.png ├── Relative-Rarity.png ├── Coverage-watching.gif ├── Frontier-Highlight.png └── Heartbleed-Rare-block.png ├── LICENSE ├── scripts ├── basic_frontier.py ├── summarize_coverage.py ├── coverage_watcher.py ├── demo.py ├── difference_analysis.py ├── block_min.py ├── timeline_coverage.py ├── compare_coverage.py └── find_uncovered_calls.py ├── download_dynamorio.py ├── README.md ├── plugin.json ├── dr_block_coverage.py ├── parse.py ├── coverage.py └── __init__.py /requirements.txt: -------------------------------------------------------------------------------- 1 | msgpack 2 | -------------------------------------------------------------------------------- /tutorial/example/seeds/test-4: -------------------------------------------------------------------------------- 1 | k' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *DS_Store* 2 | *.pyc 3 | venv 4 | *.covdb 5 | extras/ 6 | -------------------------------------------------------------------------------- /pictures/bncov_demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/pictures/bncov_demo.png -------------------------------------------------------------------------------- /pictures/demo_overview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/pictures/demo_overview.gif -------------------------------------------------------------------------------- /tutorial/example/fuzztest: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/fuzztest -------------------------------------------------------------------------------- /pictures/Coverage-Report.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/pictures/Coverage-Report.png -------------------------------------------------------------------------------- /pictures/Relative-Rarity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/pictures/Relative-Rarity.png -------------------------------------------------------------------------------- /tutorial/example/seeds/test-3: -------------------------------------------------------------------------------- 1 | AAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAOAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AA -------------------------------------------------------------------------------- /pictures/Coverage-watching.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/pictures/Coverage-watching.gif -------------------------------------------------------------------------------- /pictures/Frontier-Highlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/pictures/Frontier-Highlight.png -------------------------------------------------------------------------------- /tutorial/example/seeds/test-10: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds/test-10 -------------------------------------------------------------------------------- /tutorial/example/seeds/test-12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds/test-12 -------------------------------------------------------------------------------- /tutorial/example/seeds/test-13: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds/test-13 -------------------------------------------------------------------------------- /tutorial/example/seeds/test-14: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds/test-14 -------------------------------------------------------------------------------- /tutorial/example/seeds/test-15: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds/test-15 -------------------------------------------------------------------------------- /tutorial/example/seeds/test-16: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds/test-16 -------------------------------------------------------------------------------- /tutorial/example/seeds/test-21: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds/test-21 -------------------------------------------------------------------------------- /tutorial/example/seeds/test-24: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds/test-24 -------------------------------------------------------------------------------- /pictures/Heartbleed-Rare-block.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/pictures/Heartbleed-Rare-block.png -------------------------------------------------------------------------------- /tutorial/Makefile: -------------------------------------------------------------------------------- 1 | default: 2 | clang -Os -gline-tables-only fuzztest.c -o fuzztest 3 | 4 | clean: 5 | rm -f fuzztest 6 | -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-3.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-3.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-4.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-4.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-10.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-10.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-12.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-12.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-13.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-13.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-14.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-14.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-15.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-15.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-16.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-16.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-21.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-21.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-24.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-24.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-10-drcov3.cov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ForAllSecure/bncov/HEAD/tutorial/example/seeds-cov/test-10-drcov3.cov -------------------------------------------------------------------------------- /tutorial/example/seeds-cov/test-10.modcov: -------------------------------------------------------------------------------- 1 | ; this is a manually-constructed modcov file for demonstration 2 | ; these lines show how comments can be inserted 3 | fuzztest+600 4 | fuzztest+580 5 | fuzztest+5f8 6 | fuzztest+689 7 | fuzztest+50a 8 | fuzztest+678 9 | fuzztest+68f 10 | fuzztest+590 11 | fuzztest+510 12 | fuzztest+78f 13 | fuzztest+814 14 | fuzztest+695 15 | fuzztest+717 16 | fuzztest+69c 17 | fuzztest+7a0 18 | fuzztest+6a3 19 | fuzztest+72c 20 | fuzztest+638 21 | fuzztest+540 22 | fuzztest+640 23 | fuzztest+6c6 24 | fuzztest+649 25 | fuzztest+74c 26 | fuzztest+6ce 27 | fuzztest+5d0 28 | fuzztest+7d6 29 | fuzztest+6df 30 | fuzztest+7e0 31 | fuzztest+560 32 | fuzztest+670 33 | fuzztest+570 34 | fuzztest+7f6 35 | fuzztest+4f8 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 ForAllSecure, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /scripts/basic_frontier.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | from binaryninja import * 7 | # this line allows these scripts to be run portably on python2/3 8 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 9 | import bncov 10 | 11 | # Basic demo script: print the number of frontier blocks by function 12 | 13 | if __name__ == "__main__": 14 | USAGE = "%s " % sys.argv[0] 15 | if len(sys.argv) != 3: 16 | print(USAGE) 17 | exit() 18 | 19 | target_filename = sys.argv[1] 20 | covdir = sys.argv[2] 21 | 22 | bv = bncov.make_bv(target_filename, quiet=False) 23 | covdb = bncov.make_covdb(bv, covdir, quiet=False) 24 | 25 | frontier = covdb.get_frontier() 26 | print("[*] %d total frontier blocks found" % len(frontier)) 27 | function_mapping = covdb.get_functions_from_blocks(frontier, by_name=True) 28 | for function_name, frontier_blocks in function_mapping.items(): 29 | print(" %d frontier blocks in %s" % (len(frontier_blocks), function_name)) 30 | 31 | -------------------------------------------------------------------------------- /tutorial/fuzztest.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | void fuzz_test(char *buf, int len) { 8 | 9 | char* stop_at = buf + len - 8; 10 | 11 | for(char* p = buf; p < stop_at; p++) { 12 | if (p[0] == 'F') 13 | if (p[1] == 'u') 14 | if (p[2] == 'z') 15 | if (p[3] == 'z') 16 | if (p[4] == 'T') 17 | if (p[5] == 'e') 18 | if (p[6] == 's') 19 | if (p[7] == 't') { 20 | printf("Aborting\n"); 21 | abort(); 22 | } 23 | } 24 | } 25 | 26 | int main(int argc, char **argv) { 27 | 28 | char buf[100] = {0}; 29 | 30 | if (argc != 2) { 31 | printf("USAGE: %s \n", argv[0]); 32 | return 1; 33 | } 34 | 35 | int fd = open(argv[1], 0); 36 | if (fd == -1) { 37 | printf("USAGE: %s INPUT_FILE\n", argv[0]); 38 | return -1; 39 | } 40 | 41 | ssize_t bytes_read = read(fd, buf, sizeof(buf)-1); 42 | close(fd); 43 | 44 | if (bytes_read >= 0) { 45 | fuzz_test(buf, strlen(buf)); 46 | } 47 | 48 | return 0; 49 | } 50 | -------------------------------------------------------------------------------- /scripts/summarize_coverage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | 6 | from binaryninja import * 7 | # this line allows these scripts to be run portably on python2/3 8 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 9 | import bncov 10 | 11 | USAGE = "%s " % sys.argv[0] 12 | USAGE += "\n Print a summary of functions and blocks covered" 13 | 14 | 15 | def print_coverage_summary(bv, covdb): 16 | overall_coverage_stats = covdb.get_overall_function_coverage() 17 | number_of_functions, total_blocks_covered, total_blocks = overall_coverage_stats 18 | 19 | # Summarize coverage for all functions with nonzero coverage 20 | function_summaries = {} 21 | for function_obj in covdb.bv.functions: 22 | stats = covdb.function_stats[function_obj.start] 23 | if stats.blocks_covered == 0: 24 | continue 25 | demangled_name = function_obj.symbol.short_name 26 | summary = "%.1f%% (%d/%d blocks)" % (stats.coverage_percent, stats.blocks_covered, stats.blocks_total) 27 | function_summaries[demangled_name] = summary 28 | 29 | print("Coverage Summary for %s:" % target_filename) 30 | 31 | for name, summary in sorted(function_summaries.items()): 32 | print(" %s %s" % (name, summary)) 33 | 34 | total_coverage = float(total_blocks_covered) / float(total_blocks) * 100 35 | print("Overall %d/%d Functions covered, %.1f%% block coverage (%d blocks)" % 36 | (len(function_summaries), len(bv.functions), total_coverage, total_blocks_covered)) 37 | 38 | 39 | if __name__ == "__main__": 40 | if len(sys.argv) != 3: 41 | print(USAGE) 42 | exit() 43 | 44 | target_filename = sys.argv[1] 45 | covdir = sys.argv[2] 46 | 47 | bv = bncov.make_bv(target_filename, quiet=False) 48 | covdb = bncov.make_covdb(bv, covdir, quiet=False) 49 | print_coverage_summary(bv, covdb) 50 | -------------------------------------------------------------------------------- /download_dynamorio.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | 4 | import platform 5 | import os 6 | import urllib.request 7 | 8 | 9 | if __name__ == '__main__': 10 | cur_platform = platform.system() 11 | if cur_platform == 'Linux': 12 | download_url = 'https://github.com/DynamoRIO/dynamorio/releases/download/release_8.0.0-1/DynamoRIO-Linux-8.0.0-1.tar.gz' 13 | elif cur_platform == 'Windows': 14 | download_url = 'https://github.com/DynamoRIO/dynamorio/releases/download/release_8.0.0-1/DynamoRIO-Windows-8.0.0-1.zip' 15 | else: 16 | print(f'[!] Sorry, Dynamorio is not supported for "{cur_platform}"') 17 | exit(0) 18 | 19 | download_location = os.path.basename(download_url) 20 | if download_url.endswith('.zip'): 21 | extract_cmd = f'unzip {download_location}' 22 | extracted_dir = download_location[:download_location.rfind('.zip')] 23 | elif download_url.endswith('.tar.gz'): 24 | extract_cmd = f'tar xf {download_location}' 25 | extracted_dir = download_location[:download_location.rfind('.tar.gz')] 26 | 27 | if os.path.exists(extracted_dir): 28 | print(f'[!] Looks like DynamoRIO is already installed at "{extracted_dir}"') 29 | exit(0) 30 | 31 | if os.path.exists(download_location): 32 | print(f'[*] Found expected download file "{download_location}", skipping download') 33 | else: 34 | print(f'[*] Downloading DynamoRIO package from {download_url}...') 35 | urllib.request.urlretrieve(download_url, download_location) 36 | file_size = os.path.getsize(download_location) 37 | print(f'[+] Downloaded package to {download_location} ({file_size} bytes)') 38 | 39 | print(f'[*] Extracting with "{extract_cmd}"') 40 | retval = os.system(extract_cmd) 41 | print(f'[*] Returncode: {retval}') 42 | 43 | if not os.path.exists(extracted_dir): 44 | print(f'[!] ERROR: expected extracted dir "{extracted_dir}"') 45 | exit(0) 46 | else: 47 | os.unlink(download_location) 48 | print(f'[+] DynamoRIO extracted to "{extracted_dir}"') 49 | -------------------------------------------------------------------------------- /scripts/coverage_watcher.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import time 5 | import os 6 | 7 | from binaryninja import * 8 | # this line allows these scripts to be run portably on python2/3 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 10 | import bncov 11 | 12 | 13 | def get_duration(): 14 | duration = time.time() - script_start 15 | return "%02d:%02d:%02d" % (duration // 3600, (duration // 60) % 60, duration % 60) 16 | 17 | 18 | def time_print(s): 19 | print("[%s] %s" % (get_duration(), s)) 20 | 21 | 22 | def watch_coverage(covdb): 23 | number_of_functions, total_blocks_covered, total_blocks = covdb.get_overall_function_coverage() 24 | time_print("Coverage baseline has %d blocks covered in %d functions" % (total_blocks_covered, number_of_functions)) 25 | 26 | poll_interval = 1 27 | try: 28 | while True: 29 | coverage_files = os.listdir(coverage_dir) 30 | for filename in coverage_files: 31 | coverage_filepath = os.path.join(coverage_dir, filename) 32 | if coverage_filepath not in covdb.trace_dict: 33 | coverage_before = set() 34 | coverage_before.update(covdb.total_coverage) 35 | coverage_from_file = covdb.add_file(coverage_filepath) 36 | new_coverage = coverage_from_file - coverage_before 37 | time_print("New coverage file found: %s, %d new blocks covered" % (filename, len(new_coverage))) 38 | function_mapping = covdb.get_functions_from_blocks(new_coverage) 39 | for function_name in function_mapping: 40 | for block in function_mapping[function_name]: 41 | time_print(" New block 0x%x in %s" % (block, function_name)) 42 | time.sleep(poll_interval) 43 | except KeyboardInterrupt: 44 | time_print("Caught CTRL+C, exiting") 45 | 46 | 47 | if __name__ == "__main__": 48 | if len(sys.argv) != 3: 49 | print("USAGE: %s " % sys.argv[0]) 50 | exit() 51 | 52 | target_filename = sys.argv[1] 53 | coverage_dir = sys.argv[2] 54 | bv = bncov.make_bv(target_filename, quiet=False) 55 | covdb = bncov.make_covdb(bv, coverage_dir, quiet=False) 56 | 57 | script_start = time.time() 58 | watch_coverage(covdb) 59 | -------------------------------------------------------------------------------- /scripts/demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import os 5 | 6 | from binaryninja import * 7 | # this line allows these scripts to be run portably on python2/3 8 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 9 | import bncov.coverage as coverage 10 | 11 | if __name__ == "__main__": 12 | if len(sys.argv) != 3: 13 | print("USAGE: %s " % sys.argv[0]) 14 | exit() 15 | filename = sys.argv[1] 16 | coverage_dir = sys.argv[2] 17 | 18 | bv = BinaryViewType.get_view_of_file(filename) 19 | bv.update_analysis_and_wait() 20 | print("[*] Loaded %s" % filename) 21 | 22 | covdb = coverage.CoverageDB(bv) 23 | covdb.add_directory(coverage_dir) 24 | 25 | print(covdb.module_name) 26 | print("Module base: 0x%x" % covdb.module_base) 27 | print("Files (%d): %s" % (len(covdb.coverage_files), len(covdb.trace_dict.keys()))) 28 | print("Blocks: %d %d" % (len(covdb.block_dict), len(covdb.total_coverage))) 29 | 30 | frontier = covdb.get_frontier() 31 | print("Frontier: %s" % repr(frontier)) 32 | print("Rare blocks: %s" % repr(covdb.get_rare_blocks())) 33 | 34 | rare_block = covdb.get_rare_blocks()[0] 35 | print("Traces for rare block: %s" % repr(covdb.get_traces_from_block(rare_block))) 36 | print("Function coverage: %s" % repr(covdb.get_overall_function_coverage())) 37 | 38 | num_addrs = 7 39 | print("First %d num traces by addr:" % num_addrs) 40 | for i in range(num_addrs): 41 | addr = list(covdb.block_dict.keys())[i] 42 | print(" %d 0x%x %d" % (i, addr, len(covdb.get_traces_from_block(addr)))) 43 | 44 | rare_traces = covdb.get_traces_from_block(rare_block) 45 | print("Get_traces_from_block - rare_block: %s" % repr(rare_traces)) 46 | print("Get_traces_with_rare_blocks(): %s" % repr(covdb.get_traces_with_rare_blocks())) 47 | 48 | rare_trace = rare_traces[0] 49 | print("Get_trace_uniq_blocks - rare_trace: %s" % repr(covdb.get_trace_uniq_blocks(rare_trace))) 50 | print("Get_trace_blocks - rare_trace: %s" % repr(covdb.get_trace_blocks(rare_trace))) 51 | print("Get_functions_from_trace - rare_trace: %s" % repr(covdb.get_functions_from_trace(rare_trace, by_name=True))) 52 | print("Get_functions_from_blocks - rare_blocks: %s" % 53 | repr(covdb.get_functions_from_blocks(covdb.get_rare_blocks(), by_name=True))) 54 | print("Get_trace_uniq_functions - rare_trace: %s" % repr(covdb.get_trace_uniq_functions(rare_trace, by_name=True))) 55 | 56 | rare_func_start = list(covdb.get_functions_with_rare_blocks())[0] 57 | rare_func = bv.get_function_at(rare_func_start).name 58 | print("get_functions_with_rare_blocks(): %s" % repr(covdb.get_functions_with_rare_blocks(by_name=True))) 59 | print("Get_functions_from_blocks - frontier: %s" % repr(covdb.get_functions_from_blocks(frontier, by_name=True))) 60 | 61 | function_name = rare_func 62 | function_start = rare_func_start 63 | print("Get_traces_from_function_name - %s: %s" % (function_name, repr(covdb.get_traces_from_function_name(function_name)))) 64 | print("Get_traces_from_function - 0x%x: %s" % (function_start, repr(covdb.get_traces_from_function(function_start)))) 65 | print("Get_overall_function_coverage: %s" % repr(covdb.get_overall_function_coverage())) 66 | 67 | key, value = list(covdb.function_stats.items())[0] 68 | print("First func_stats: %s : %s" % (key, value)) 69 | 70 | -------------------------------------------------------------------------------- /scripts/difference_analysis.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import time 5 | import os 6 | 7 | from binaryninja import * 8 | # this line allows these scripts to be run portably on python2/3 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 10 | import bncov.coverage as coverage 11 | 12 | 13 | def get_coverage_object_name(dirname): 14 | output_filename = os.path.basename(dirname + ".covdb") 15 | return output_filename 16 | 17 | 18 | def get_coverage_db(dirname, bv): 19 | covdb_name = get_coverage_object_name(dirname) 20 | start = time.time() 21 | if os.path.exists(covdb_name): 22 | sys.stdout.write("[*] Loading coverage from object file %s..." % covdb_name) 23 | sys.stdout.flush() 24 | covdb = coverage.CoverageDB(bv, covdb_name) 25 | duration = time.time() - start 26 | num_files = len(covdb.coverage_files) 27 | print(" finished (%d files) in %.02f seconds" % (num_files, duration)) 28 | else: 29 | sys.stdout.write("[*] Creating coverage db from directory %s..." % dirname) 30 | sys.stdout.flush() 31 | covdb = coverage.CoverageDB(bv) 32 | covdb.add_directory(dirname) 33 | duration = time.time() - start 34 | num_files = len(os.listdir(dirname)) 35 | print(" finished (%d files) in %.02f seconds" % (num_files, duration)) 36 | # Including for example, disabling to reduce surprise 37 | # try: 38 | # import msgpack # dependency for save_to_file 39 | # sys.stdout.write("[*] Saving coverage object to file '%s'..." % covdb_name) 40 | # sys.stdout.flush() 41 | # start = time.time() 42 | # covdb.save_to_file(covdb_name) 43 | # duration = time.time() - start 44 | # print(" finished in %.02f seconds" % duration) 45 | # except ImportError: 46 | # pass 47 | return covdb 48 | 49 | 50 | if __name__ == "__main__": 51 | if len(sys.argv) < 4: 52 | print("USAGE: %s ..." % sys.argv[0]) 53 | exit() 54 | 55 | target_filename = sys.argv[1] 56 | coverage_dirs = sys.argv[2:] 57 | 58 | print("=== LOADING DATA ===") 59 | sys.stdout.write("[*] Loading Binary Ninja view of %s..." % target_filename) 60 | sys.stdout.flush() 61 | start = time.time() 62 | bv = BinaryViewType.get_view_of_file(target_filename) 63 | bv.update_analysis_and_wait() 64 | duration = time.time() - start 65 | print("finished in %.02f seconds" % duration) 66 | 67 | covdbs = [get_coverage_db(dirname, bv) for dirname in coverage_dirs] 68 | 69 | prev_covdb = None 70 | print("=== ANALYSIS ===") 71 | for i, covdb in enumerate(covdbs): 72 | if i == 0: 73 | prev_covdb = covdb 74 | print('[*] "%s" is the base, containing %d blocks' % (coverage_dirs[i], len(covdb.total_coverage))) 75 | continue 76 | new_coverage = covdb.total_coverage - prev_covdb.total_coverage 77 | prev_covdb = covdb 78 | num_new_coverage = len(new_coverage) 79 | print('[*] "%s" contains %d new blocks' % (coverage_dirs[i], num_new_coverage)) 80 | covdb.collect_function_coverage() 81 | if num_new_coverage > 0: 82 | f2a = covdb.get_functions_from_blocks(new_coverage, by_name=True) 83 | for function, blocks in f2a.items(): 84 | print(" %s: %s" % (function, str(["0x%x" % addr for addr in blocks]))) 85 | 86 | -------------------------------------------------------------------------------- /scripts/block_min.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import os 5 | import time 6 | import shutil 7 | 8 | from binaryninja import * 9 | # this line allows these scripts to be run portably on python2/3 10 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 11 | import bncov 12 | 13 | USAGE = "USAGE: %s [coverage_directory] [output_dir] " % sys.argv[0] 14 | USAGE += "\n Calculate minimal set of files that cover all blocks" 15 | # if coverage_directory not specified, assume -cov 16 | # if output_dir not specified, use -bmin 17 | 18 | if __name__ == "__main__": 19 | if len(sys.argv) not in [3, 4, 5]: 20 | print(USAGE) 21 | exit(1) 22 | 23 | target_filename = sys.argv[1] 24 | seed_dir = os.path.normpath(sys.argv[2]) 25 | coverage_dir = seed_dir + "-cov" 26 | output_dir = seed_dir + "-bmin" 27 | if len(sys.argv) >= 4: 28 | coverage_dir = os.path.normpath(sys.argv[3]) 29 | if len(sys.argv) == 5: 30 | output_dir = os.path.normpath(sys.argv[4]) 31 | 32 | script_start = time.time() 33 | bv = bncov.make_bv(target_filename) 34 | covdb = bncov.make_covdb(bv, coverage_dir) 35 | 36 | seed_paths = [os.path.join(seed_dir, filename) for filename in os.listdir(seed_dir)] 37 | seed_sizes = {seed_path: os.path.getsize(seed_path) for seed_path in seed_paths} 38 | coverage_to_seed = {} 39 | seed_to_coverage = {} 40 | for trace_path in covdb.trace_dict.keys(): 41 | trace_name = os.path.basename(trace_path) 42 | if trace_name.endswith('.cov') is False: 43 | print("[!] Trace file %s doesn't the right extension (.cov), bailing..." % trace_path) 44 | exit(1) 45 | seed_name = trace_name[:-4] 46 | seed_path = os.path.join(seed_dir, seed_name) 47 | if not os.path.exists(seed_path): 48 | print("[!] Couldn't find matching seed path (%s) for trace %s, bailing..." % (seed_path, trace_path)) 49 | exit(1) 50 | coverage_to_seed[trace_path] = seed_path 51 | seed_to_coverage[seed_path] = trace_path 52 | 53 | sys.stdout.write("[M] Starting block minset calculation...") 54 | sys.stdout.flush() 55 | minset_start = time.time() 56 | block_minset = set() 57 | minset_files = [] 58 | while True: 59 | blocks_remaining = covdb.total_coverage - block_minset 60 | if len(blocks_remaining) == 0: 61 | break 62 | next_block = blocks_remaining.pop() 63 | containing_traces = covdb.get_traces_from_block(next_block) 64 | # map traces to seed files' sizes 65 | containing_traces_by_size = sorted(containing_traces, 66 | key=lambda trace_name: seed_sizes[coverage_to_seed[trace_name]]) 67 | # pick the smallest file by size 68 | matching_trace = containing_traces_by_size[0] 69 | minset_files.append(coverage_to_seed[matching_trace]) 70 | block_minset.update(covdb.trace_dict[matching_trace]) 71 | minset_duration = time.time() - minset_start 72 | print(" finished in %.2f seconds" % minset_duration) 73 | 74 | if not os.path.exists(output_dir): 75 | os.mkdir(output_dir) 76 | for seed_path in minset_files: 77 | output_path = os.path.join(output_dir, os.path.basename(seed_path)) 78 | shutil.copy(seed_path, output_path) 79 | # print("[DBG] %s: %d" % (seed_path, seed_sizes[seed_path])) 80 | print('[+] Finished, minset contains %d files, saved to "%s" ' % (len(minset_files), output_dir)) 81 | -------------------------------------------------------------------------------- /scripts/timeline_coverage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import os 5 | import time 6 | 7 | from binaryninja import * 8 | # this line allows these scripts to be run portably on python2/3 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 10 | import bncov 11 | 12 | USAGE = "USAGE: %s [log_name]" % sys.argv[0] 13 | USAGE += "\n Break down timeline of when coverage increased based on modification time of seeds" 14 | 15 | 16 | def format_duration(seconds): 17 | return "%02d:%02d:%02d" % (seconds // 3600, (seconds // 60) % 60, seconds % 60) 18 | 19 | 20 | def get_timestamp(path): 21 | """Return a timestamp to give a sense of time between seeds. 22 | If you want to maintain timestamps via other means (db, flat file with times, etc), 23 | just swap out this function for your implementation.""" 24 | return os.path.getmtime(path) 25 | 26 | 27 | def get_coverage_timeline(covdb, seed_dir, cov_dir): 28 | if not os.path.exists(seed_dir): 29 | print("[!] Seed dir `%s` doesn't exist" % seed_dir) 30 | exit(1) 31 | seeds = os.listdir(seed_dir) 32 | seed_paths = [os.path.join(seed_dir, seed_name) for seed_name in seeds] 33 | seed_times = {path: get_timestamp(path) for path in seed_paths} 34 | 35 | # Assume the bncov naming convention 36 | seed_to_coverage = {} 37 | for seed_path in seed_paths: 38 | seed_name = os.path.basename(seed_path) 39 | coverage_path = os.path.join(cov_dir, seed_name) + ".cov" 40 | if coverage_path not in covdb.trace_dict: 41 | print("[!] Didn't find matching trace (expected %s) for seed \"%s\", skipping" % (coverage_path, seed_path)) 42 | seed_times.pop(seed_path) 43 | else: 44 | seed_to_coverage[seed_path] = coverage_path 45 | 46 | sorted_seeds = sorted(seed_times.items(), key=lambda kv: kv[1]) 47 | running_coverage = set() 48 | initial_time = sorted_seeds[0][1] 49 | datapoints = [] # list of (seconds_elapsed, total_blocks) 50 | for seed_path, mod_time in sorted_seeds: 51 | # print("[DBG] %s: %s" % (seed_path, time.asctime(time.localtime(mod_time)))) 52 | seed_name = os.path.basename(seed_path) 53 | seed_coverage = covdb.trace_dict[seed_to_coverage[seed_path]] 54 | new_coverage = seed_coverage - running_coverage 55 | # print('[DBG] %s: %d total, %d new' % (seed_name, len(seed_coverage), len(new_coverage))) 56 | if len(new_coverage) > 0: 57 | seconds_elapsed = mod_time - initial_time 58 | num_new_blocks = len(new_coverage) 59 | running_coverage.update(new_coverage) 60 | num_total_blocks = len(running_coverage) 61 | print("[T+%s] %d new blocks from %s (%d)" % 62 | (format_duration(seconds_elapsed), num_new_blocks, seed_name, num_total_blocks)) 63 | datapoints.append((int(seconds_elapsed)+1, num_total_blocks)) 64 | return datapoints 65 | 66 | 67 | if __name__ == "__main__": 68 | if len(sys.argv) not in [4, 5]: 69 | print(USAGE) 70 | exit(1) 71 | target_filename, seed_dir, cov_dir = sys.argv[1:4] 72 | log_name = None 73 | if len(sys.argv) == 5: 74 | log_name = sys.argv[4] 75 | bv = bncov.make_bv(target_filename, quiet=False) 76 | covdb = bncov.make_covdb(bv, cov_dir, quiet=False) 77 | 78 | datapoints = get_coverage_timeline(covdb, seed_dir, cov_dir) 79 | 80 | if log_name is not None: 81 | with open(log_name, 'w') as f: 82 | f.write(repr(datapoints)) 83 | print('[+] Wrote %d datapoints to %s' % (len(datapoints), log_name)) 84 | -------------------------------------------------------------------------------- /scripts/compare_coverage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import time 5 | import os 6 | 7 | from binaryninja import * 8 | # this line allows these scripts to be run portably on python2/3 9 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 10 | import bncov.coverage as coverage 11 | 12 | # This script compares block coverage, given a target and two directories of coverage files 13 | USAGE = "%s " % sys.argv[0] 14 | 15 | 16 | # It's MUCH faster to save/load CoverageDBs, helpful for multiple analyses over coverage sets 17 | def get_coverage_db(dirname, bv): 18 | # Allow specification of covdb files directly 19 | if dirname.endswith(".covdb"): 20 | covdb_name = dirname 21 | else: 22 | covdb_name = os.path.basename(dirname + ".covdb") 23 | 24 | start = time.time() 25 | if os.path.exists(covdb_name): 26 | sys.stdout.write('[L] Loading coverage from object file "%s"...' % covdb_name) 27 | sys.stdout.flush() 28 | covdb = coverage.CoverageDB(bv, covdb_name) 29 | duration = time.time() - start 30 | num_files = len(covdb.coverage_files) 31 | print(" finished (%d files) in %.02f seconds" % (num_files, duration)) 32 | elif os.path.isdir(dirname): 33 | sys.stdout.write('[C] Creating coverage db from directory "%s"...' % dirname) 34 | sys.stdout.flush() 35 | covdb = coverage.CoverageDB(bv) 36 | covdb.add_directory(dirname) 37 | duration = time.time() - start 38 | num_files = len(os.listdir(dirname)) 39 | print(" finished (%d files) in %.02f seconds" % (num_files, duration)) 40 | elif os.path.isfile(dirname): 41 | sys.stdout.write('[C] Creating coverage db from file "%s"...' % dirname) 42 | sys.stdout.flush() 43 | covdb = coverage.CoverageDB(bv) 44 | covdb.add_file(dirname) 45 | duration = time.time() - start 46 | print(" finished in %.02f seconds" % duration) 47 | else: 48 | raise Exception('[!] File "%s" not a file or directory?' % dirname) 49 | 50 | return covdb 51 | 52 | 53 | def print_function_blocks(covdb, block_set): 54 | function_mapping = covdb.get_functions_from_blocks(block_set, by_name=True) 55 | for function_name, blocks in function_mapping.items(): 56 | function_obj = [f for f in bv.functions if f.name == function_name][0] 57 | pretty_name = function_obj.symbol.short_name 58 | print(" %s: %s" % (pretty_name, ["0x%x" % b for b in blocks])) 59 | 60 | 61 | def compare_covdbs(covdb1, covdb2): 62 | print("=== COMPARISON ===") 63 | print("[*] %s and %s have %d blocks in common" % 64 | (covdir1, covdir2, len(covdb1.total_coverage & covdb2.total_coverage))) 65 | 66 | only_1 = covdb1.total_coverage - covdb2.total_coverage 67 | print("[1] %d Blocks only in %s:" % (len(only_1), covdir1)) 68 | print_function_blocks(covdb1, only_1) 69 | 70 | only_2 = covdb2.total_coverage - covdb1.total_coverage 71 | print("[2] %d Blocks only in %s:" % (len(only_2), covdir2)) 72 | print_function_blocks(covdb2, only_2) 73 | 74 | 75 | if __name__ == "__main__": 76 | if len(sys.argv) != 4: 77 | print(USAGE) 78 | exit() 79 | 80 | # Loading .bndb's also works, and is faster 81 | target_filename = sys.argv[1] 82 | covdir1 = sys.argv[2] 83 | covdir2 = sys.argv[3] 84 | 85 | if not os.path.exists(target_filename): 86 | print("[!] Couldn't find target file \"%s\"..." % target_filename) 87 | print(" Check that target_filename is correct") 88 | exit(1) 89 | for covdir in [covdir1, covdir2]: 90 | if not os.path.exists(covdir): 91 | print("[!] Couldn't find coverage directory \"%s\"..." % covdir) 92 | print(" Check that covdirs specified are correct") 93 | exit(1) 94 | 95 | print("=== LOADING DATA ===") 96 | sys.stdout.write("[B] Loading Binary Ninja view of %s..." % target_filename) 97 | sys.stdout.flush() 98 | start = time.time() 99 | bv = BinaryViewType.get_view_of_file(target_filename) 100 | bv.update_analysis_and_wait() 101 | print("finished in %.02f seconds" % (time.time() - start)) 102 | 103 | covdb1 = get_coverage_db(covdir1, bv) 104 | covdb2 = get_coverage_db(covdir2, bv) 105 | compare_covdbs(covdb1, covdb2) 106 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bncov - Scriptable Binary Ninja plugin for coverage analysis and visualization 2 | 3 | bncov provides a scriptable interface for bringing together coverage 4 | information with Binary Ninja's static analysis and visualization. Beyond 5 | visualization, the abstractions in bncov allow for programmatic reasoning 6 | about coverage. It was designed for interactive GUI use as well as for 7 | factoring into larger analysis tasks and standalone scripts. 8 | 9 | ![Demo Overview](/pictures/demo_overview.gif) 10 | 11 | This plugin is provided as a way to give back to the community, 12 | and is not part of the Mayhem product. If you're interested in Mayhem, the 13 | combined symbolic execution and fuzzing system, check us out at 14 | [forallsecure.com](http://forallsecure.com). 15 | 16 | ## CHANGELOG 17 | 18 | Feb 2023: drcov format version 3 now supported. 19 | Oct 2021: Some changes in the API: 20 | 21 | - Added a `ctx` object that is keyed off the Binary View and helpers 22 | `bncov.get_ctx` and `bncov.get_covdb` to support multibinary use case in the 23 | UI, so now you can interactively use bncov across tabs! 24 | - Changed old helpers `bncov.get_bv`/`bncov.get_covdb` to 25 | `bncov.make_bv`/`bncov.make_covdb` for making a Binary View from a target file 26 | and covdb from a binary and a coverage directory, respectively 27 | - All function-related covdb member functions now default to keying off of 28 | function start addresses rather than names since function starts are unique 29 | and more usable for many applications. Extra optional args or helper functions 30 | implement the existing behavior. 31 | - Abandoning Python2 suport 32 | - Added `download_dynamorio.py` for the sloths 33 | - Minor quality-of-life fixes 34 | 35 | ## Installation 36 | 37 | The easiest way is to install via the Binary Ninja plugin manager! 38 | The only difference when installing via plugin manager is that wherever 39 | you see `import bncov`, you'll do `import ForAllSecure_bncov as bncov`. 40 | 41 | Alternatively: 42 | 43 | - Clone or copy this directory into your binja plugins folder. 44 | ([More detailed instructions here](https://docs.binary.ninja/guide/plugins/index.html#using-plugins)) 45 | - (Optional) pip install msgpack if you want to enable loading/saving 46 | coverage database files. 47 | 48 | ## Usage 49 | 50 | Check out the [tutorial](/tutorial/) for a complete walkthrough or how to get 51 | started right away using data that's already included in this repo! 52 | 53 | First collect coverage information in DynamoRIO's drcov format 54 | ([example script](/dr_block_coverage.py)). 55 | 56 | To use in Binary Ninja GUI: 57 | 58 | 1. Open the target binary, then import coverage files using one of 59 | the commands in `bncov/Coverage Data/Import *` 60 | either from the Tools menu or from the context (right-click) menu. 61 | 2. Explore the coverage visualization and explore additional analyses from 62 | the right-click menu or with the built-in interpreter and `import bncov` 63 | followed by `covdb = bncov.get_covdb(bv)`. 64 | 65 | Scripting: 66 | 67 | 1. Ensure bncov's parent directory is in your module search path 68 | OR add it to sys.path at the top of your script like this: 69 | `sys.path.append(os.path.split(os.path.normpath('/path/to/bncov'))[0])` 70 | 2. `import bncov` and write scripts with the CoverageDB class in 71 | `coverage.py`, check out the `scripts` folder for examples. 72 | 73 | ## Screenshots 74 | 75 | Import a coverage directory containing trace files to see blocks colored in 76 | heat map fashion: blocks covered by most traces (blue) or by few traces 77 | (red). Additional context commands (right-click menu) include frontier 78 | highlighting and a per-function block coverage report. 79 | 80 | * Watch a directory to have new coverage results get automatically highlighted 81 | when new coverage files appear 82 | 83 | ![Watch Coverage Directory](/pictures/Coverage-watching.gif) 84 | 85 | * See at a glance which blocks are only covered by one or a few traces 86 | (redder=rarer, bluer=more common) 87 | 88 | ![See Relative Rarity](/pictures/Relative-Rarity.png) 89 | 90 | * Quickly discover rare functionality visually or with scripting 91 | 92 | ![Highlight Rare Blocks](/pictures/Heartbleed-Rare-block.png) 93 | 94 | * Identify which blocks have outgoing edges not covered in the traces 95 | 96 | ![Highlight Frontier Blocks](/pictures/Frontier-Highlight.png) 97 | 98 | * See coverage reports on functions of interest or what functionality may not 99 | be hit, or write your own analyses for headless scripting. 100 | 101 | ![Block Coverage Report](/pictures/Coverage-Report.png) 102 | 103 | ## Notes 104 | 105 | Currently the plugin only deals with block coverage and ingests files in the 106 | drcov format or "module+offset" format. Included in the repo is 107 | `dr_block_coverage.py` which can be used for generating coverage files, just 108 | specify your DynamoRIO install location with an environment variable (or 109 | modify the script) and it can process a directory of inputs. DynamoRIO binary 110 | packages can be found 111 | [here](https://github.com/DynamoRIO/dynamorio/wiki/Downloads) or you can use the 112 | included `download_dynamorio.py` script. See the 113 | [tutorial](/tutorial/) for a complete walkthrough. 114 | 115 | Please file any feature requests/bugs as issues on GitHub, we welcome any input 116 | or feedback. 117 | 118 | ## Scripting 119 | 120 | bncov was designed so users can interact directly with the data structures 121 | the plugin uses. See the `scripts/` directory for more ideas. 122 | 123 | * Helpful CoverageDB members: 124 | * trace_dict (maps filenames to set of basic block start addresses) 125 | * block_dict (maps basic block start addresses to files containing it) 126 | * total_coverage (set of start addresses of the basic blocks covered) 127 | 128 | * Helpful CoverageDB functions: 129 | * get_traces_from_block(addr) - get files that cover the basic block 130 | starting at addr. 131 | * get_rare_blocks(threshold) - get blocks covered by <= 'threshold' traces 132 | * get_frontier() - get blocks that have outgoing edges that aren't covered 133 | * get_functions_from_blocks(blocks, by_name=False) - return dict mapping 134 | function starts/names to blocks they contain 135 | * get_traces_from_function(function_start) - return set of traces that have 136 | coverage in the specified function 137 | * get_traces_from_function_name(function_name, demangle=False) - return set 138 | of traces that have coverage in the specified function 139 | 140 | * You can use Binary Ninja's python console and built-in python set operations with 141 | bncov.highlight_set() to do custom highlights in the Binary Ninja UI. 142 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginmetadataversion": 2, 3 | "name": "bncov", 4 | "author": "Mark Griffin", 5 | "type": [ 6 | "helper" 7 | ], 8 | "api": [ 9 | "python3" 10 | ], 11 | "description": "Scriptable code coverage analysis and visualization plugin", 12 | "longdescription":"# bncov - Scriptable Binary Ninja plugin for coverage analysis and visualization\n\nbncov provides a scriptable interface for bringing together coverage\ninformation with Binary Ninja's static analysis and visualization. Beyond\nvisualization, the abstractions in bncov allow for programmatic reasoning\nabout coverage. It was designed for interactive GUI use as well as for\nfactoring into larger analysis tasks and standalone scripts.\n\n![Demo Overview](https://github.com/ForAllSecure/bncov/raw/master/pictures/bncov_demo.png)\n\nThis plugin is provided as a way to give back to the community,\nand is not part of the Mayhem product. If you're interested in Mayhem, the\ncombined symbolic execution and fuzzing system, check us out at\n[forallsecure.com](http://forallsecure.com).\n\n## CHANGELOG\nFeb 2023: drcov format version 3 now supported.\nOct 2021: Some changes in the API:\n\n- Added a `ctx` object that is keyed off the Binary View and helpers\n `bncov.get_ctx` and `bncov.get_covdb` to support multibinary use case in the\n UI, so now you can interactively use bncov across tabs!\n- Changed old helpers `bncov.get_bv`/`bncov.get_covdb` to\n `bncov.make_bv`/`bncov.make_covdb` for making a Binary View from a target file\n and covdb from a binary and a coverage directory, respectively\n- All function-related covdb member functions now default to keying off of\n function start addresses rather than names since function starts are unique\n and more usable for many applications. Extra optional args or helper functions\n implement the existing behavior.\n- Abandoning Python2 suport\n- Added `download_dynamorio.py` for the sloths\n- Minor quality-of-life fixes\n\n## Installation\n\nThe easiest way is to install via the Binary Ninja plugin manager!\nThe only difference when installing via plugin manager is that wherever\nyou see `import bncov`, you may have to do `import ForAllSecure_bncov as bncov`.\n\nAlternatively:\n\n - Clone or copy this directory into your binja plugins folder.\n([More detailed instructions here](https://docs.binary.ninja/guide/plugins/index.html#using-plugins))\n - (Optional) pip install msgpack if you want to enable loading/saving\ncoverage database files.\n\n## Usage\n\nCheck out the [tutorial](/tutorial/) for a complete walkthrough or how to get\nstarted right away using data that's already included in this repo!\n\nFirst collect coverage information in DynamoRIO's drcov format\n([example script](/dr_block_coverage.py)).\n\nTo use in Binary Ninja GUI:\n\n1. Open the target binary, then import coverage files using one of\nthe commands in `bncov/Coverage Data/Import *`\neither from the Tools menu or from the context (right-click) menu.\n2. Explore the coverage visualization and explore additional analyses from\nthe right-click menu or with the built-in interpreter and `import bncov`\nfollowed by `covdb = bncov.get_covdb(bv)`.\n\nScripting:\n\n1. Ensure bncov's parent directory is in your module search path\nOR add it to sys.path at the top of your script like this:\n`sys.path.append(os.path.split(os.path.normpath('/path/to/bncov'))[0])`\n2. `import bncov` and write scripts with the CoverageDB class in\n`coverage.py`, check out the `scripts` folder for examples.\n\n## Screenshots\n\nImport a coverage directory containing trace files to see blocks colored in\nheat map fashion: blocks covered by most traces (blue) or by few traces\n(red). Additional context commands (right-click menu) include frontier\nhighlighting and a per-function block coverage report.\n\n* Watch a directory to have new coverage results get automatically highlighted\nwhen new coverage files appear\n* See at a glance which blocks are only covered by one or a few traces\n(redder=rarer, bluer=more common)\n* Quickly discover rare functionality visually or with scripting\n* Identify which blocks have outgoing edges not covered in the traces\n* See coverage reports on functions of interest or what functionality may not\nbe hit, or write your own analyses for headless scripting.\n\n![Block Coverage Report](https://github.com/ForAllSecure/bncov/raw/master/pictures/Coverage-Report.png)\n\n## Notes\n\nCurrently the plugin only deals with block coverage and ingests files in the\ndrcov format or module+offset format. Included in the repo is\n`dr_block_coverage.py` which can be used for generating coverage files, just\nspecify your DynamoRIO install location with an environment variable (or\nmodify the script) and it can process a directory of inputs. DynamoRIO binary\npackages can be found\n[here](https://github.com/DynamoRIO/dynamorio/wiki/Downloads) or you can use the\nincluded `download_dynamorio.py` script. See the\n[tutorial](/tutorial/) for a complete walkthrough.\n\nPlease file any feature requests/bugs as issues on GitHub, we welcome any input\nor feedback.\n\n## Scripting\n\nbncov was designed so users can interact directly with the data structures\nthe plugin uses. See the `scripts/` directory for more ideas.\n\n* Helpful CoverageDB members:\n * trace_dict (maps filenames to set of basic block start addresses)\n * block_dict (maps basic block start addresses to files containing it)\n * total_coverage (set of start addresses of the basic blocks covered)\n\n* Helpful CoverageDB functions:\n * get_traces_from_block(addr) - get files that cover the basic block\n starting at addr.\n * get_rare_blocks(threshold) - get blocks covered by <= 'threshold' traces\n * get_frontier() - get blocks that have outgoing edges that aren't covered\n * get_functions_from_blocks(blocks, by_name=False) - return dict mapping\n function starts/names to blocks they contain\n * get_traces_from_function(function_start) - return set of traces that have\n coverage in the specified function\n * get_traces_from_function_name(function_name, demangle=False) - return set\n of traces that have coverage in the specified function\n\n* You can use Binary Ninja's python console and built-in python set operations with\nbncov.highlight_set() to do custom highlights in the Binary Ninja UI.\n", 13 | "license": { 14 | "name": "MIT", 15 | "text": "Copyright 2023 ForAllSecure, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE." 16 | }, 17 | "platforms": [ 18 | "Darwin", 19 | "Windows", 20 | "Linux" 21 | ], 22 | "installinstructions": { 23 | "Darwin": "No installation necessary, but you may need tools to collect block coverage information.\n\nSee the [Github page](https://github.com/ForAllSecure/bncov) for usage instructions and a [tutorial](https://github.com/ForAllSecure/bncov/tree/master/tutorial).", 24 | "Windows": "No installation necessary, but you may want to download [DynamoRIO](https://github.com/DynamoRIO/dynamorio/wiki/Downloads) or other tools to collect block coverage information.\n\nSee the [Github page](https://github.com/ForAllSecure/bncov) for usage instructions and a [tutorial](https://github.com/ForAllSecure/bncov/tree/master/tutorial).", 25 | "Linux": "No installation necessary, but you may want to download [DynamoRIO](https://github.com/DynamoRIO/dynamorio/wiki/Downloads) or other tools to collect block coverage information.\n\nSee the [Github page](https://github.com/ForAllSecure/bncov) for usage instructions and a [tutorial](https://github.com/ForAllSecure/bncov/tree/master/tutorial)." 26 | }, 27 | "version": "1.4.2", 28 | "minimumbinaryninjaversion": 1470 29 | } 30 | -------------------------------------------------------------------------------- /tutorial/README.md: -------------------------------------------------------------------------------- 1 | # bncov Usage Tutorial 2 | 3 | In this example we'll compile the target program, fuzz it, and watch 4 | coverage roll in with Binary Ninja. This is the simplest example and 5 | won't coverage much in the way of scripting (which you should definitely 6 | explore later in the `scripts` directory). 7 | 8 | If you're so excited to try out bncov that you can't wait any longer, we've 9 | also included the sample binary with seeds and coverage traces in the 10 | [example folder](example/), just jump down to where we start 11 | [using the data](#visualizing-coverage). 12 | 13 | ## Compiling the Target 14 | 15 | The first thing we have to do is compile our target, which is a simple 16 | C program designed to illustrate how a coverage-guided fuzzer like 17 | AFL can discover new blocks pretty quickly. Let's start with a 18 | shell in the current `tutorial` directory. 19 | 20 | Normal compilation of the target: 21 | 22 | ```bash 23 | gcc fuzztest.c -o fuzztest 24 | ``` 25 | 26 | In order to use AFL for our example, we also need to compile an 27 | instrumented version of our target by using afl-gcc instead. 28 | [Download here](http://lcamtuf.coredump.cx/afl/releases/afl-latest.tgz) 29 | if you don't already have it installed. 30 | 31 | Instrumented compilation of the target: 32 | 33 | ```bash 34 | afl-gcc fuzztest.c -o fuzztest-instrumented 35 | ``` 36 | 37 | ## Running the Target 38 | 39 | We can run the target and confirm that it works: 40 | 41 | ```bash 42 | echo "Does nothing" > seed.txt && ./fuzztest seed.txt 43 | echo "FuzzTest" > crash.txt && ./fuzztest crash.txt 44 | ``` 45 | 46 | Fuzzing the target isn't much more work, especially if you're 47 | familiar with AFL: 48 | 49 | ```bash 50 | mkdir seeds && echo "AAAAAAAA" > seeds/original.txt 51 | afl-fuzz -i seeds -o output -- ./fuzztest-instrumented @@ 52 | ``` 53 | 54 | That should eventually find its way all the way down to the crash, 55 | but wouldn't it be great if we could watch the fuzzer make progress 56 | in real-time? You can leave the fuzzer running, or stop it and remove 57 | the `output` directory and restart it later. 58 | 59 | ## Getting Coverage 60 | 61 | If you haven't downloaded DynamoRIO, now would be a great time! Find your 62 | [DynamoRIO binary package download Link](https://github.com/DynamoRIO/dynamorio/wiki/Downloads) 63 | or use the included `download_dynamorio.py` 64 | 65 | In a new terminal, you'll need to tell the coverage script where to 66 | find the DynamoRIO binaries, which you can do by changing a variable in 67 | the script or by setting an environment variable like so: 68 | 69 | ```bash 70 | DYNAMORIO=/mnt/hgfs/vmshare/dr ../dr_block_coverage.py output/queue/ output/coverage --continuously_monitor -- ./fuzztest @@ 71 | ``` 72 | 73 | You should change the command above so that DYNAMORIO points to the directory 74 | you extracted DynamoRIO to. The other parts of the command specify where 75 | to look for seeds that the fuzzer generates, where to write the coverage 76 | files to (if omitted, the script bases it off the seed directory name), and an 77 | optional switch saying that you want the script to continuously poll for new 78 | inputs as the fuzzer runs. 79 | 80 | The last thing to note is that we used the *NON-instrumented* version of 81 | the binary. You should be able to use either, but in general I recommend 82 | using the non-instrumented version for all analysis tasks since the 83 | disassembly is a little cleaner to look at. 84 | 85 | You should see that the script is collecting coverage information and storing 86 | coverage files in the coverage output directory and naming them after the seed 87 | that was run. These coverage files are referred to as a "trace" or "trace 88 | file" throughout this codebase. Now that we have these traces, let's 89 | visualize them! 90 | 91 | ## Visualizing Coverage 92 | 93 | If somehow you've gotten this far and not installed the plugin in Binary Ninja, 94 | go ahead and do that now ([Read more about that here](https://docs.binary.ninja/guide/plugins/index.html#using-plugins)). 95 | 96 | Then open up Binary Ninja and open the non-instrumented target we made earlier. 97 | You should be able to right-click and see near 98 | the bottom of the context menu a `bncov` submenu, where you'll want to select 99 | `bncov -> Coverage Data -> Import Directory and Watch` because we want to both 100 | import all of the existing coverage files as well as monitor for new coverage 101 | files that appear (which will happen as long as both the fuzzer and the drcov 102 | script are running). Navigate to the coverage output directory we specified 103 | earlier (`output/coverage` if you've been following along). 104 | 105 | You'll want to navigate to the function of interest, which you can quickly do 106 | by hitting `g` and typing `main` to jump to the function by name. At this point, 107 | depending on how long the fuzzer has been running and how fast/lucky it is, 108 | you'll either see most of or some portion of the function of interest covered, 109 | which is indicated by blocks being colored blue, red, or some color in-between. 110 | 111 | If you don't see that, double check any error messages that may have appeared 112 | in the log. Common mistakes include picking the wrong directory (we want the 113 | coverage file directory, not the seed directory), having the wrong binary open 114 | in binary ninja (double check the coverage script invocation), or the coverage 115 | directory could be empty if the fuzzer or coverage script had an error, so make 116 | sure those are up and running correctly. 117 | 118 | ## Coverage Rarity 119 | 120 | The coloring indicates the relative rarity of coverage, where a pure blue block 121 | indicates that every coverage file (or trace) covered that block (which is to say 122 | that during execution, the seed corresponding to that trace caused the program 123 | to execute that specific basic block of code). A pure red block would mean that 124 | only one trace covered that block, and purple coloring means that some but not 125 | all traces cover it. 126 | 127 | If we look at the target function and see a gradual descent from blue to red, 128 | it makes sense intuitively based on how AFL works and how this code is structured 129 | that the further into the nested if-statements we go, the rarer coverage is 130 | (since AFL primarily only saves seeds it thinks have new/better edge coverage). 131 | 132 | ## Scripting 133 | 134 | While it's interesting to watch the fuzzer discover new blocks in real-time 135 | (if you haven't that done yet, you can delete the whole `output` directory 136 | we made, restart the fuzzer and the coverage script, and either restart 137 | Binary Ninja or do `bncov -> Coverage Data -> Reset Coverage State`, 138 | followed by `bncov -> Coverage Data -> Import Directory and Watch` again to 139 | watch it happen), the other advantage is that we can now ask interesting 140 | questions about the coverage represented by the seeds generated by the fuzzer, 141 | and using Binary Ninja we can reason about the program we are analyzing. 142 | 143 | For example, we can ask the coverage plugin what blocks were only covered 144 | by ten or fewer traces, and what functions those blocks are in by going 145 | to the python console and typing: 146 | 147 | ```python 148 | import bncov 149 | rare_blocks = bncov.covdb.get_rare_blocks(10) 150 | for block in rare_blocks: 151 | print("0x%x %s" % (block, bv.get_functions_containing(block)[0].name)) 152 | ``` 153 | 154 | Or you can identify which traces are hitting blocks of interest (like the 155 | one right above the call to `abort()` in the function `main`) by clicking 156 | the address of interest and typing in the python console: 157 | 158 | ```python 159 | bncov.covdb.get_traces_from_block(here) 160 | ``` 161 | 162 | Using this information, you can use python's built-in set operations 163 | to reason about the difference in coverage between traces like this: 164 | 165 | ```python 166 | hit = '/Users/user/vmshare/bncov/tutorial/output/coverage/id&%000040,src&%000034,op&%havoc,rep&%4.cov' 167 | miss = '/Users/user/vmshare/bncov/tutorial/output/coverage/id&%000029,src&%000021,op&%havoc,rep&%16.cov' 168 | bncov.covdb.trace_dict[hit] - bncov.covdb.trace_dict[miss] 169 | # output: set([2336L, 2366L, 2351L]) 170 | ``` 171 | 172 | If you wanted to minimize the number of seeds and coverage files to 173 | only those with unique block coverage, that too is straightforward: 174 | 175 | ```python 176 | uniq_traces = {} 177 | for cur_trace, cur_coverage in bncov.covdb.trace_dict.items(): 178 | cur_coverage = set(cur_coverage) 179 | if cur_coverage not in uniq_traces.values(): 180 | uniq_traces[cur_trace] = cur_coverage 181 | #print("%d unique among %d" % (len(uniq_traces), len(bncov.covdb.trace_dict))) 182 | ``` 183 | 184 | The bncov plugin can be used to write arbitrary analysis scripts, and it can 185 | also be used without the GUI if your Binary Ninja license allows for headless 186 | scripting. You can also make your own reports within Binary Ninja like the 187 | built-in `bncov -> Reports -> Generate Coverage Report` demonstrates. 188 | Happy hunting! 189 | -------------------------------------------------------------------------------- /dr_block_coverage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from __future__ import print_function 4 | import os 5 | import subprocess 6 | import sys 7 | import time 8 | import glob 9 | import multiprocessing 10 | 11 | # wraps calling drcov on a bunch of files and renaming the outputs 12 | 13 | USAGE = "USAGE: %s [output directory] [optional_args] -- " % sys.argv[0] 14 | USAGE += "\n Optional script arguments:" 15 | USAGE += "\n --workers=N Use N worker processes" 16 | USAGE += "\n --continuously_monitor Process new seeds as they appear" 17 | USAGE += "\n --debug Print stdout and stderr of target" 18 | USAGE += "\n --stdin Target uses stdin for input" 19 | 20 | # Path to DynamoRIO root 21 | path_to_dynamorio = os.getenv("DYNAMORIO", "DynamoRIO-Linux-8.0.0-1") 22 | if not os.path.exists(path_to_dynamorio): 23 | matches = glob.glob('DynamoRIO-*-*') 24 | for match in matches: 25 | if os.path.isdir(match) and 'bin32' in os.listdir(match): 26 | path_to_dynamorio = match 27 | break 28 | if not os.path.exists(path_to_dynamorio): 29 | print(f"[!] DynamoRIO not found at '{path_to_dynamorio}'\n" + 30 | " Please run the ./download_dynamorio.py script.\n" + 31 | f" Or update the default path in this script ({sys.argv[0]}),\n" + 32 | " Or set the env var DYNAMORIO to point to your DynamoRIO dir.") 33 | exit() 34 | 35 | use_stdin = False 36 | 37 | def wrap_get_block_coverage(path): 38 | try: 39 | name = os.path.basename(path) 40 | output_path = os.path.join(output_dir, name + ".cov") 41 | if get_block_coverage(path, output_path, command): 42 | return 1 43 | else: 44 | print("[!] Error occurred on seed: %s" % path) 45 | except KeyboardInterrupt: 46 | pool.terminate() 47 | 48 | 49 | def get_block_coverage(input_path, output_path, command): 50 | if use_stdin: 51 | fuzz_input = open(input_path, 'rb') 52 | else: 53 | fuzz_input = subprocess.PIPE 54 | command = command.replace("@@", input_path) 55 | drcov_process = subprocess.Popen(command.split(), 56 | stdin=fuzz_input, 57 | stdout=subprocess.PIPE, 58 | stderr=subprocess.PIPE, 59 | shell=False) 60 | if use_stdin: 61 | fuzz_input.close() 62 | output, err = drcov_process.communicate() 63 | if debug: 64 | print("[DBG] stdout: `%s`" % output) 65 | print("[DBG] stderr: `%s`" % err) 66 | print("[DBG] returncode: %s" % repr(drcov_process.poll())) 67 | 68 | # handle the output file that is produced 69 | matching = glob.glob("drcov*%05d*.log" % drcov_process.pid) 70 | if len(matching) == 0: 71 | print("[!] drcov didn't output a file named after pid %d" % drcov_process.pid) 72 | print(" command invoked: %s" % command) 73 | print(" stderr: %s" % err) 74 | return False 75 | if len(matching) > 1: 76 | print("[!] More than one drcov file matched pid %d" % drcov_process.pid) 77 | return False 78 | matching = matching[0] 79 | # handle drcov failures 80 | return_code = drcov_process.poll() 81 | # drcov errors ask users to report errors 82 | if return_code != 0 and b"report" in err.lower() and b"dynamorio" in err.lower(): 83 | print("[!] drcov process %d indicated failure return code (%d)" % (drcov_process.pid, return_code)) 84 | print(" command invoked: %s" % command) 85 | print(" stdout: %s" % output) 86 | print(" stderr: %s" % err) 87 | os.unlink(matching) 88 | return False 89 | else: 90 | os.rename(matching, output_path) 91 | return True 92 | 93 | 94 | def is64bit(binary_path): 95 | with open(binary_path, "rb") as f: 96 | data = f.read(32) 97 | e_machine = data[18] 98 | if isinstance(e_machine, str): # python2/3 compatibility, handle e_machine as string or value 99 | e_machine = ord(e_machine) 100 | if e_machine == 0x03: # x86 101 | return False 102 | elif e_machine == 0x3e: # x64 103 | return True 104 | raise Exception("[!] Unexpected e_machine value in 64-bit check: %s (e_machine: 0x%x)" % (binary_path, e_machine)) 105 | 106 | 107 | if __name__ == "__main__": 108 | if len(sys.argv) < 4 or "--" not in sys.argv: 109 | print(USAGE) 110 | exit() 111 | 112 | script_options = sys.argv[1:sys.argv.index("--")] 113 | target_invocation = " ".join(sys.argv[sys.argv.index("--")+1:]) 114 | 115 | # parse switches 116 | num_workers = 4 117 | continuously_monitor = False 118 | debug = False 119 | remaining_args= [] 120 | for i, option in enumerate(script_options): 121 | if option.startswith("--workers="): 122 | num_workers = int(option.split('=')[1]) 123 | worker_index = i 124 | elif option == "--continuously_monitor": 125 | continuously_monitor = True 126 | print("[*] Will continuously monitor seed directory") 127 | elif option == "--debug": 128 | debug = True 129 | elif option == '--stdin': 130 | use_stdin = True 131 | elif option.startswith('--'): 132 | print("[!] Unrecognized option: %s" % option) 133 | print(USAGE) 134 | exit() 135 | else: 136 | remaining_args.append(option) 137 | print("[*] Using %d worker processes" % num_workers) 138 | 139 | # if no output dir provided, just name it based on seed dir 140 | to_process = remaining_args[0] 141 | if len(remaining_args) == 1: 142 | to_process = os.path.normpath(to_process) 143 | output_dir = to_process + "-cov" 144 | else: 145 | output_dir = remaining_args[1] 146 | 147 | if not os.path.exists(to_process): 148 | print("[!] Seed directory %s does not exist, quitting." % to_process) 149 | exit() 150 | 151 | print("[*] Using output directory %s" % output_dir) 152 | if not os.path.exists(output_dir): 153 | os.makedirs(output_dir) 154 | 155 | if not use_stdin and "@@" not in target_invocation: 156 | print('[!] "@@" not found in target invocation and no --stdin flag; quitting.') 157 | exit(-2) 158 | print("[*] Non-instrumented invocation: %s" % target_invocation) 159 | target_binary = target_invocation.split()[0] 160 | print("[*] Presumed target executable: %s" % target_binary) 161 | 162 | bitness = 32 163 | if is64bit(target_binary): 164 | bitness = 64 165 | path_to_drrun = "%s/bin%d/drrun" % (path_to_dynamorio, bitness) 166 | if not os.path.exists(path_to_drrun): 167 | print("[!] drrun not found at expected path: %s" % path_to_drrun) 168 | exit() 169 | command = "%s -t drcov -- %s" % (path_to_drrun, target_invocation) 170 | 171 | prev_skipped_files = -1 172 | pool = None 173 | # break out of loop by checking continuously_monitor at end 174 | # or CTRL+C at any time 175 | try: 176 | while True: 177 | # gather inputs 178 | if os.path.isdir(to_process): 179 | files_to_process = [os.path.join(to_process, name) for name in os.listdir(to_process)] 180 | else: 181 | files_to_process = [to_process] 182 | num_existing_files = len(os.listdir(output_dir)) 183 | 184 | files_to_remove = [] 185 | skipped_files = 0 186 | for filepath in files_to_process: 187 | # silently remove afl-style state file 188 | if os.path.basename(filepath) == ".state": 189 | # print("[!] Removing \".state\" from list of seeds, rename the file to include it") 190 | files_to_remove.append(filepath) 191 | continue 192 | # silently remove tmp files 193 | if os.path.basename(filepath).startswith(".fuse_hidden"): 194 | files_to_remove.append(filepath) 195 | continue 196 | # skip input files with existing coverage file 197 | output_path = os.path.join(output_dir, os.path.basename(filepath) + ".cov") 198 | if os.path.exists(output_path): 199 | files_to_remove.append(filepath) 200 | skipped_files += 1 201 | files_to_process = [f for f in files_to_process if f not in files_to_remove] 202 | # for continuous monitoring, only announce skipping when number of files changes 203 | if skipped_files != prev_skipped_files: 204 | print("[*] Skipping %d files with existing coverage" % skipped_files) 205 | prev_skipped_files = skipped_files 206 | num_files = len(files_to_process) 207 | if num_files != 0: 208 | print("[*] %d files to process:" % num_files) 209 | pool = multiprocessing.Pool(num_workers) 210 | return_stream = pool.imap(wrap_get_block_coverage, files_to_process) 211 | for i, path in enumerate(files_to_process): 212 | if return_stream.next(): 213 | # If this doesn't work, use sys.stdout.write("\b" * prev_output_len) 214 | sys.stdout.write("\r[%d/%d] Coverage collected for %s" 215 | % (i+1, num_files, path)) 216 | sys.stdout.flush() 217 | sys.stdout.write("\n") 218 | pool.close() 219 | pool = None 220 | 221 | if continuously_monitor: 222 | time.sleep(2) 223 | else: 224 | break 225 | except KeyboardInterrupt: 226 | print("[!] Caught CTRL+C") 227 | if pool: 228 | pool.terminate() 229 | 230 | print("[*] Done, check for coverage files in %s" % output_dir) 231 | -------------------------------------------------------------------------------- /parse.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import re 4 | from struct import unpack 5 | from os.path import basename 6 | 7 | # Handle parsing files into sets of addresses, each describing the start of a basic block 8 | # Can be invoked as a standalone script for debugging purposes 9 | 10 | 11 | def detect_format(filename): 12 | """Return the name of the format based on the start of the file.""" 13 | enough_bytes = 0x1000 14 | with open(filename, 'rb') as f: 15 | data = f.read(enough_bytes) 16 | if isinstance(data, bytes): 17 | data = data.decode(errors='replace') 18 | 19 | if data.startswith('DRCOV VERSION:'): 20 | return 'drcov' 21 | if '+' in data: 22 | # Check for module+offset, skipping any comment lines at start 23 | for line in data.split('\n'): 24 | if line.strip().startswith(';'): 25 | continue 26 | pieces = line.split('+') 27 | if len(pieces) == 2: 28 | try: 29 | hex_int = int(pieces[1], 16) 30 | return 'module+offset' 31 | except ValueError: 32 | pass 33 | raise Exception('[!] File "%s" doesn\'t appear to be drcov or module+offset format' % filename) 34 | 35 | 36 | def parse_coverage_file(filename, module_name, module_base, module_blocks, debug=False): 37 | """Return a set of addresses of covered blocks in the specified module""" 38 | file_format = detect_format(filename) 39 | if file_format == 'drcov': 40 | blocks = parse_drcov_file(filename, module_name, module_base, module_blocks) 41 | elif file_format == 'module+offset': 42 | blocks = parse_mod_offset_file(filename, module_name, module_base, module_blocks) 43 | return blocks 44 | 45 | 46 | def parse_mod_offset_file(filename, module_name, module_base, module_blocks, debug=False): 47 | """Return blocks from a file with "module_name+hex_offset" format.""" 48 | blocks = set() 49 | modules_seen = set() 50 | # We do a case-insensitive module name comparison to match Windows behavior 51 | module_name = module_name.lower() 52 | with open(filename, 'r') as f: 53 | for line in f.readlines(): 54 | if line.strip().startswith(';'): 55 | continue 56 | pieces = line.split('+') 57 | if len(pieces) != 2: 58 | continue 59 | name, offset = pieces 60 | name = name.lower() 61 | if debug: 62 | if module_name != name and name not in modules_seen: 63 | print('[DBG] module mismatch, expected (%s), encountered (%s)' % (module_name, name)) 64 | modules_seen.add(name) 65 | block_offset = int(offset, 16) 66 | block_addr = module_base + block_offset 67 | if block_addr in module_blocks: 68 | blocks.add(block_addr) 69 | elif debug: 70 | print('[!] DBG: address 0x%x not in module_blocks!' % block_addr) 71 | return blocks 72 | 73 | 74 | def parse_drcov_header(header, module_name, filename, debug): 75 | module_name = module_name.lower() 76 | module_table_start = False 77 | module_ids = [] 78 | for i, line in enumerate(header.split("\n")): 79 | # Encountering the basic block table indicates end of the module table 80 | if line.startswith("BB Table"): 81 | break 82 | # The first entry in the module table starts with "0", potentially after leading spaces 83 | if line.strip().startswith("0"): 84 | module_table_start = True 85 | if module_table_start: 86 | columns = line.split(",") 87 | if debug: 88 | print("[DBG] Module table entry: %s" % line.strip()) 89 | for col in columns[1:]: 90 | if module_name != "" and module_name in basename(col).lower(): 91 | module_ids.append(int(columns[0])) 92 | if debug: 93 | print("[DBG] Target module found (%d): %s" % (int(columns[0]), line.strip())) 94 | if not module_table_start: 95 | raise Exception('[!] No module table found in "%s"' % filename) 96 | if not module_ids and not debug: 97 | raise Exception("[!] Didn't find expected target '%s' in the module table in %s" % 98 | (module_name, filename)) 99 | 100 | return module_ids 101 | 102 | 103 | def parse_drcov_binary_blocks(block_data, filename, module_ids, module_base, module_blocks, debug): 104 | blocks = set() 105 | block_data_len = len(block_data) 106 | blocks_seen = 0 107 | 108 | remainder = block_data_len % 8 109 | if remainder != 0: 110 | print("[!] Warning: %d trailing bytes left over in %s" % (remainder, filename)) 111 | block_data = block_data[:-remainder] 112 | if debug: 113 | module_dict = {} 114 | 115 | for i in range(0, block_data_len, 8): 116 | block_offset = unpack("I", block_data[i:i + 4])[0] 117 | block_size = unpack("H", block_data[i + 4:i + 6])[0] 118 | block_module_id = unpack("H", block_data[i + 6:i + 8])[0] 119 | block_addr = module_base + block_offset 120 | blocks_seen += 1 121 | if debug: 122 | print("%d: 0x%08x 0x%x" % (block_module_id, block_offset, block_size)) 123 | module_dict[block_module_id] = module_dict.get(block_module_id, 0) + 1 124 | if block_module_id in module_ids: 125 | cur_addr = block_addr 126 | # traces can contain "blocks" that split and span blocks 127 | # so we need a fairly comprehensive check to get it right 128 | while cur_addr < block_addr + block_size: 129 | if cur_addr in module_blocks: 130 | blocks.add(cur_addr) 131 | cur_addr += module_blocks[cur_addr] 132 | else: 133 | cur_addr += 1 134 | if debug: 135 | print('[DBG] Block count per-module:') 136 | for module_number, blocks_hit in sorted(module_dict.items()): 137 | print(' %d: %d' % (module_number, blocks_hit)) 138 | return blocks, blocks_seen 139 | 140 | 141 | def parse_drcov_ascii_blocks(block_data, filename, module_ids, module_base, module_blocks, debug): 142 | blocks = set() 143 | blocks_seen = 0 144 | int_base = 0 # 0 not set, 10 or 16 145 | if debug: 146 | module_dict = {} 147 | 148 | for line in block_data.split(b"\n"): 149 | # example: 'module[ 4]: 0x0000000000001090, 8' 150 | left_bracket_index = line.find(b'[') 151 | right_bracket_index = line.find(b']') 152 | # skip bad/blank lines 153 | if left_bracket_index == -1 or right_bracket_index == -1: 154 | continue 155 | block_module_id = int(line[left_bracket_index+1: right_bracket_index]) 156 | block_offset, block_size = line[right_bracket_index+2:].split(b',') 157 | 158 | if int_base: 159 | block_offset = int(block_offset, int_base) 160 | else: 161 | if b'x' in block_offset: 162 | int_base = 16 163 | else: 164 | int_base = 10 165 | block_offset = int(block_offset, int_base) 166 | 167 | block_size = int(block_size) 168 | block_addr = module_base + block_offset 169 | blocks_seen += 1 170 | if debug: 171 | print("%d: 0x%08x 0x%x" % (block_module_id, block_offset, block_size)) 172 | module_dict[block_module_id] = module_dict.get(block_module_id, 0) + 1 173 | if block_module_id in module_ids: 174 | cur_addr = block_addr 175 | while cur_addr < block_addr + block_size: 176 | if cur_addr in module_blocks: 177 | blocks.add(cur_addr) 178 | cur_addr += module_blocks[cur_addr] 179 | else: 180 | cur_addr += 1 181 | if debug: 182 | print('[DBG] Block count per-module:') 183 | for module_number, blocks_hit in sorted(module_dict.items()): 184 | print(' %d: %d' % (module_number, blocks_hit)) 185 | return blocks, blocks_seen 186 | 187 | 188 | def parse_drcov_file(filename, module_name, module_base, module_blocks, debug=False): 189 | """Return set of blocks in module covered (block definitions provided in module_blocks)""" 190 | with open(filename, 'rb') as f: 191 | data = f.read() 192 | 193 | # Sanity checks for expected contents 194 | drcov_match = re.match(b"DRCOV VERSION: ([0-9])", data) 195 | if drcov_match is None: 196 | raise Exception("[!] File \"%s\" does not appear to be a drcov format file, " % filename + 197 | "it doesn't start with the expected signature: 'DRCOV VERSION: 2'") 198 | 199 | # Most recent Dynamorio release as of this writing: 9.0.1 200 | supported_versions = [2, 3] 201 | drcov_version = int(drcov_match.groups()[0]) 202 | if drcov_version not in supported_versions: 203 | raise Exception("[!] The drcov version (%d) of \"%s\" is not yet supported (please file a GitHub issue)" % 204 | (drcov_version, filename)) 205 | 206 | header_end_pattern = b"BB Table: " 207 | header_end_location = data.find(header_end_pattern) 208 | if header_end_location == -1: 209 | raise Exception("[!] File \"%s\" does not appear to be a drcov format file, " % filename + 210 | "it doesn't contain a header for the basic block table'") 211 | header_end_location = data.find(b"\n", header_end_location) + 1 # +1 to skip the newline 212 | 213 | # Check for ascii vs binary drcov version (binary is the default) 214 | binary_file = True 215 | ascii_block_header = b"module id, start, size:" 216 | 217 | block_header_candidate = data[header_end_location:header_end_location + len(ascii_block_header)] 218 | if block_header_candidate == ascii_block_header: 219 | binary_file = False 220 | # Skip the ascii block header line ("module id, start, size:\n") 221 | header_end_location = data.find(b"\n", header_end_location) + 1 # +1 to skip the newline 222 | 223 | # Parse the header 224 | header = data[:header_end_location].decode() 225 | block_data = data[header_end_location:] 226 | module_ids = parse_drcov_header(header, module_name, filename, debug) 227 | 228 | # Parse the block data itself 229 | if binary_file: 230 | parse_blocks = parse_drcov_binary_blocks 231 | else: 232 | parse_blocks = parse_drcov_ascii_blocks 233 | if debug: 234 | print("[DBG] Detected drcov %s format" % ("binary" if binary_file else "ascii")) 235 | print("[DBG] Basic block dump (module_id, block_offset, block_size)") 236 | blocks, blocks_seen = parse_blocks(block_data, filename, module_ids, module_base, module_blocks, debug) 237 | 238 | if debug: 239 | if not module_ids: 240 | print("[*] %d blocks parsed, no module id specified" % blocks_seen) 241 | else: 242 | num_blocks_found = len(blocks) 243 | print("[*] %d blocks parsed; module_ids %s" % 244 | (blocks_seen, module_ids)) 245 | return blocks 246 | 247 | 248 | if __name__ == "__main__": 249 | import sys 250 | import time 251 | if len(sys.argv) == 1: 252 | print("STANDALONE USAGE: %s [module_name]" % sys.argv[0]) 253 | exit() 254 | target = sys.argv[1] 255 | module_name = "" 256 | if len(sys.argv) >= 3: 257 | module_name = sys.argv[2] 258 | start = time.time() 259 | parse_coverage_file(sys.argv[1], module_name, 0, [], debug=True) 260 | duration = time.time() - start 261 | print('[*] Completed parsing in %.2f seconds' % duration) 262 | -------------------------------------------------------------------------------- /scripts/find_uncovered_calls.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import os 5 | import time 6 | import shutil 7 | import subprocess 8 | 9 | from binaryninja import * 10 | # this line allows these scripts to be run portably on python2/3 11 | sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) 12 | import bncov 13 | 14 | # By default, path_to_addr2line == "", so try to find it in path using built-ins 15 | if sys.version_info[0] == 3 and sys.version_info[1] >= 3: 16 | which_func = shutil.which 17 | else: 18 | try: 19 | from distutils import spawn 20 | which_func = spawn.find_executable 21 | except AttributeError: 22 | def which_func(x): return None 23 | 24 | # if addr2line isn't in your path, you can put the path to the binary here 25 | path_to_addr2line = "" 26 | # this scripts does a string replacement over the original source path (e.g. source_path.replace(old, new) ) 27 | source_path_old = "" 28 | source_path_new = "" 29 | 30 | USAGE = "USAGE: %s " % sys.argv[0] 31 | USAGE += "\n\nThis script requires four things:" \ 32 | "\n 1. Target binary with debug symbols (compile with -g)" \ 33 | "\n 2. The *exact* source files used to build the target" \ 34 | "\n 3. A directory of coverage files (drcov format)" \ 35 | "\n 4. The addr2line binary (in your path or specified in the script)" \ 36 | "\nIf the source files are in a different place than when the target was compiled," \ 37 | "\nyou'll have to modify the source_path_[old|new] variables in the script to translate paths" 38 | 39 | 40 | def get_uncovered_descendants(covdb, root_block, max_depth=1): 41 | """Return set of all uncovered blocks up to max_depth edges away. 42 | 43 | max_depth=0 does nothing, max_depth=1 gets immediate uncovered children. 44 | return value is a set of BN BasicBlocks 45 | """ 46 | blocks_checked = {} 47 | child_blocks = set() 48 | blocks_remaining = {root_block: 0} 49 | 50 | while len(blocks_remaining) > 0: 51 | cur_block, depth = blocks_remaining.popitem() 52 | blocks_checked[cur_block] = depth 53 | next_depth = depth + 1 54 | # skip this block if children are beyond max_depth 55 | if next_depth > max_depth: 56 | continue 57 | 58 | for edge in cur_block.outgoing_edges: 59 | child_block = edge.target 60 | if child_block.start in covdb.total_coverage: 61 | continue 62 | child_blocks.add(child_block) 63 | 64 | if child_block not in blocks_checked: 65 | # only add/update when next_depth is better than current entry 66 | if child_block in blocks_remaining and blocks_remaining[child_block] <= next_depth: 67 | pass 68 | else: 69 | blocks_remaining[child_block] = next_depth 70 | else: # child block in blocks_checked 71 | # if it was checked at a higher depth, we need to reconsider it 72 | if blocks_checked[child_block] > next_depth: 73 | blocks_checked.pop(child_block) 74 | blocks_remaining[child_block] = next_depth 75 | 76 | return child_blocks 77 | 78 | 79 | def get_uncovered_calls(covdb, max_distance=2): 80 | """Return a dictionary of addresses of uncovered calls mapped to their disassembly. 81 | 82 | max_distance specifies the number of edges away from the frontier to check. 83 | max_distance=None considers all uncovered blocks in functions with partial coverage. 84 | """ 85 | function_coverage_stats = covdb.collect_function_coverage() 86 | 87 | bv = covdb.bv 88 | uncovered_calls = {} 89 | # if no max distance is specified, just search all uncovered blocks 90 | if max_distance is None: 91 | for func in bv.functions: 92 | if function_coverage_stats[func.start].blocks_covered == 0: 93 | continue 94 | for block in func: 95 | if block.start in covdb.total_coverage: 96 | continue 97 | block_offset = 0 98 | for instruction in block: 99 | token_list, inst_size = instruction 100 | inst_addr = block.start + block_offset 101 | if func.is_call_instruction(inst_addr): 102 | for disassembly_line in block.get_disassembly_text(): 103 | if disassembly_line.address == inst_addr: 104 | uncovered_calls[inst_addr] = str(disassembly_line) 105 | break 106 | block_offset += inst_size 107 | else: # default: check for calls that are within max_distance edges of the frontier 108 | blocks_checked = set() 109 | for block_start in covdb.get_frontier(): 110 | 111 | cur_block = bv.get_basic_blocks_starting_at(block_start)[0] 112 | uncovered_child_blocks = get_uncovered_descendants(covdb, cur_block, max_distance) 113 | for block in uncovered_child_blocks: 114 | if block in blocks_checked: 115 | continue 116 | func = block.function 117 | block_offset = 0 118 | for instruction in block: 119 | token_list, inst_size = instruction 120 | inst_addr = block.start + block_offset 121 | if func.is_call_instruction(inst_addr): 122 | for disassembly_line in block.get_disassembly_text(): 123 | if disassembly_line.address == inst_addr: 124 | uncovered_calls[inst_addr] = str(disassembly_line) 125 | break 126 | block_offset += inst_size 127 | blocks_checked.update(uncovered_child_blocks) 128 | 129 | return uncovered_calls 130 | 131 | 132 | def get_source_line(target_binary_path, address): 133 | """Returns corresponding "filepath:line" for address, or None on failure. 134 | 135 | Filepath portion may be "" or "??". 136 | Line portion may have trailing information such as "168 (discriminator 1)", or may be "?". 137 | """ 138 | # Run addr2line to get source line info 139 | cmd = "%s -e %s -a 0x%x" % (path_to_addr2line, target_binary_path, address) 140 | p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE) 141 | output, error = p.communicate() 142 | if not isinstance(output, str): 143 | output = output.decode() 144 | error = error.decode() 145 | if error: 146 | print("[!] stderr from addr2line: \"%s\"" % error) 147 | return None 148 | source_line_info = output.strip().split('\n')[-1] 149 | return source_line_info 150 | 151 | 152 | warnings_given = {} # warn only first time on failures 153 | source_hits = 0 # for warning that the line table is probably absent 154 | def print_source_line(target_binary_path, address, num_context_lines=5): 155 | """Print source line for address if possible, prints warning on failure. 156 | 157 | Uses addr2line under the hood to do offset-to-source mapping. 158 | Returns True on success, False on failure.""" 159 | global warnings_given, source_hits 160 | 161 | source_line_info = get_source_line(target_binary_path, address) 162 | if source_line_info is None: 163 | return False 164 | original_filepath, target_line_number = source_line_info.split(':') 165 | if original_filepath == "" or "??" in original_filepath: 166 | print("[*] No source mapping (\"%s\") for address 0x%x" % (source_line_info, address)) 167 | return False 168 | source_hits += 1 169 | if os.sep not in original_filepath: # observed on library files with no path 170 | if original_filepath not in warnings_given: 171 | print('[*] Note: Original source file appears built-in (\"%s\")' % original_filepath) 172 | warnings_given[original_filepath] = True 173 | return False 174 | if target_line_number == "?": 175 | print("[*] No source line mapping (\"%s\") for address 0x%x" % (source_line_info, address)) 176 | return False 177 | print('Source line: %s' % source_line_info) 178 | 179 | # fix up filepath, which may have ".." naturally, or "~" if we use it in the source path replacement 180 | filepath = os.path.abspath(original_filepath) 181 | if source_path_old and source_path_new: 182 | filepath = filepath.replace(source_path_old, source_path_new) 183 | filepath = os.path.expanduser(filepath) 184 | if not os.path.exists(filepath): 185 | 186 | if original_filepath not in warnings_given or \ 187 | warnings_given[original_filepath] != (source_path_old, source_path_new): 188 | print('[!] Warning: original source file "%s" not found' % original_filepath) 189 | if source_path_old and source_path_new: 190 | print(' After path translation, looked for it at: "%s"' % filepath) 191 | else: 192 | print(' You can specify a path replacement in the script with source_path_[new|old]') 193 | warnings_given[original_filepath] = (source_path_old, source_path_new) 194 | return False 195 | 196 | # Fix up source line if needed 197 | try: 198 | target_line_number = int(target_line_number) 199 | except ValueError: 200 | # drop discriminator info for line numbers that include it, like: "800 (discriminator 4)" 201 | if ' ' in target_line_number: 202 | target_line_number = int(target_line_number.split(' ')[0]) 203 | else: 204 | print('[!] Unexpected non-integer line number: %s' % target_line_number) 205 | return False 206 | # Print lines from source file 207 | # print("[DBG] Found source file at %s" % filepath) 208 | with open(filepath, 'r') as f: 209 | lines = f.readlines() 210 | start = max(0, target_line_number - num_context_lines - 1) # avoid negative index value 211 | stop = min(len(lines), target_line_number + num_context_lines) 212 | max_num_len = len(str(stop)) 213 | for i, line in enumerate(lines[start:stop]): 214 | cur_line_number = start + i + 1 # plus 1 for zero-indexing 215 | if cur_line_number == target_line_number: 216 | prefix = "-->" 217 | else: 218 | prefix = " " 219 | sys.stdout.write("%s%s: %s" % (prefix, str(cur_line_number).rjust(max_num_len), line)) 220 | return True 221 | 222 | 223 | if __name__ == "__main__": 224 | if len(sys.argv) != 3: 225 | print(USAGE) 226 | exit() 227 | 228 | if path_to_addr2line == "": 229 | path_to_addr2line = which_func("addr2line") 230 | if path_to_addr2line is None: 231 | print("ERROR: addr2line not found in path and not hardcoded, cannot resolve addresses to lines without it.") 232 | print(" Please put addr2line in your path or update the path_to_addr2line variable in this script.") 233 | exit(1) 234 | 235 | target_filename = sys.argv[1] 236 | covdir = sys.argv[2] 237 | 238 | bv = bncov.make_bv(target_filename, quiet=False) 239 | original_filepath = bv.file.original_filename 240 | if not os.path.exists(original_filepath): 241 | print("ERROR: Original file %s not found (often due to a .bndb with a stale path)" % original_filepath) 242 | print(" This script requires the original target and that it has debug symbols.") 243 | exit(1) 244 | 245 | covdb = bncov.make_covdb(bv, covdir, quiet=False) 246 | 247 | uncovered_calls = get_uncovered_calls(covdb) 248 | any_source_found = False 249 | for i, item in enumerate(uncovered_calls.items()): 250 | address, disassembly = item 251 | function_name = bv.get_functions_containing(address)[0].symbol.short_name 252 | print('\n[%d] %s: 0x%x: "%s"' % (i, function_name, address, disassembly)) 253 | if print_source_line(original_filepath, address): 254 | any_source_found = True 255 | 256 | if source_hits == 0: 257 | print("WARNING: No source paths were resolved, double check that the target has debug information") 258 | elif any_source_found is False: 259 | print("WARNING: No address-to-line mappings succeeded, check the source paths given for the calls.") 260 | if source_path_old == "" and source_path_new == "": 261 | print(" If the target was not built on this machine," 262 | " set the variables `source_path_old` and `source_path_new` to map source paths to local files") 263 | else: 264 | print(" Check that your source path translation is correct (forgetting trailing slashes is common)") 265 | print(" source_path_old: \"%s\"" % source_path_old) 266 | print(" source_path_new: \"%s\"" % source_path_new) 267 | -------------------------------------------------------------------------------- /coverage.py: -------------------------------------------------------------------------------- 1 | """ 2 | coverage.py - defines CoverageDB, which encapsulates coverage data and basic methods for loading/presenting that data 3 | """ 4 | 5 | from re import match 6 | from typing import Dict, List, Set 7 | 8 | import os 9 | from . import parse 10 | from collections import namedtuple 11 | 12 | try: 13 | import msgpack 14 | file_backing_disabled = False 15 | except ImportError: 16 | file_backing_disabled = True 17 | # print("[!] bncov: without msgpack module, CoverageDB save/load to file is disabled") 18 | 19 | FuncCovStats = namedtuple("FuncCovStats", "coverage_percent blocks_covered blocks_total complexity") 20 | 21 | 22 | class CoverageDB(object): 23 | 24 | def __init__(self, bv, filename=None): 25 | self.bv = bv 26 | self.module_name = os.path.basename(bv.file.original_filename) 27 | self.module_base = bv.start 28 | if filename: 29 | self.load_from_file(filename) 30 | else: 31 | # map basic blocks in module to their size, used for disambiguating dynamic block coverage 32 | self.module_blocks = {bb.start: bb.length for bb in bv.basic_blocks} 33 | self.block_dict = {} # map address of start of basic block to list of traces that contain it 34 | self.total_coverage = set() # overall coverage set of addresses 35 | self.coverage_files = [] # list of trace names (filepaths) 36 | self.trace_dict = {} # map filename to the set of addrs of basic blocks hit 37 | self.function_stats = {} # deferred - populated by self.collect_function_coverage() 38 | self.frontier = set() # deferred - populated by self.get_frontier() 39 | self.filename = "" # the path to file this covdb is loaded from/saved to ("" otherwise) 40 | 41 | # Save/Load covdb functions 42 | def save_to_file(self, filename): 43 | """Save only the bare minimum needed to reconstruct this CoverageDB. 44 | 45 | This serializes the data to a single file and cab reduce the disk footprint of 46 | block coverage significantly (depending on overlap and number of files).""" 47 | if file_backing_disabled: 48 | raise Exception("[!] Can't save/load coverage db files without msgpack. Try `pip install msgpack`") 49 | save_dict = dict() 50 | save_dict["version"] = 1 # serialized covdb version 51 | save_dict["module_name"] = self.module_name 52 | save_dict["module_base"] = self.module_base 53 | save_dict["coverage_files"] = self.coverage_files 54 | # save tighter version of block dict {int: int} vice {int: str} 55 | block_dict_to_save = {} 56 | file_index_map = {filepath: self.coverage_files.index(filepath) for filepath in self.coverage_files} 57 | for block, trace_list in self.block_dict.items(): 58 | trace_id_list = [file_index_map[name] for name in trace_list] 59 | block_dict_to_save[block] = trace_id_list 60 | save_dict["block_dict"] = block_dict_to_save 61 | # write packed version to file 62 | with open(filename, "wb") as f: 63 | msgpack.dump(save_dict, f) 64 | self.filename = filename 65 | 66 | def load_from_file(self, filename): 67 | """Reconstruct a CoverageDB using the current BinaryView and a CoverageDB saved to disk using .save_to_file()""" 68 | if file_backing_disabled: 69 | raise Exception("[!] Can't save/load coverage db files without msgpack. Try `pip install msgpack`") 70 | self.filename = filename 71 | with open(filename, "rb") as f: 72 | loaded_dict = msgpack.load(f, raw=False, strict_map_key=False) 73 | if "version" not in loaded_dict: 74 | self._old_load_from_file(loaded_dict) 75 | # Do sanity checks 76 | loaded_version = int(loaded_dict["version"]) 77 | if loaded_version != 1: 78 | raise Exception("[!] Unsupported version number: %d" % loaded_version) 79 | 80 | loaded_module_name = loaded_dict["module_name"] 81 | if loaded_module_name != self.module_name: 82 | raise Exception("[!] ERROR: Module name from covdb (%s) doesn't match BinaryView (%s)" % 83 | (loaded_module_name, self.module_name)) 84 | 85 | loaded_module_base = loaded_dict["module_base"] 86 | if loaded_module_base != self.module_base: 87 | raise Exception("[!] ERROR: Module base from covdb (0x%x) doesn't match BinaryView (0x%x)" % 88 | (loaded_module_base, self.module_base)) 89 | 90 | # Parse the saved members 91 | coverage_files = loaded_dict["coverage_files"] 92 | self.coverage_files = coverage_files 93 | 94 | block_dict = dict() 95 | loaded_block_dict = loaded_dict["block_dict"] 96 | file_index_map = {self.coverage_files.index(filepath): filepath for filepath in self.coverage_files} 97 | for block, trace_id_list in loaded_block_dict.items(): 98 | trace_list = [file_index_map[i] for i in trace_id_list] 99 | block_dict[block] = trace_list 100 | self.block_dict = block_dict 101 | 102 | # Regen other members from saved members 103 | bv = self.bv 104 | self.module_blocks = {bb.start: bb.length for bb in bv.basic_blocks} 105 | trace_dict = {} 106 | for block, trace_list in block_dict.items(): 107 | for name in trace_list: 108 | trace_dict.setdefault(name, set()).add(block) 109 | self.trace_dict = trace_dict 110 | self.total_coverage = set(block_dict.keys()) 111 | 112 | # Other members are blank/empty 113 | self.function_stats = {} 114 | self.frontier = set() 115 | 116 | def _old_load_from_file(self, loaded_object_dict): 117 | """Backwards compatibility for when version numbers weren't saved""" 118 | self.module_name = loaded_object_dict["module_name"] 119 | self.module_base = loaded_object_dict["module_base"] 120 | self.module_blocks = loaded_object_dict["module_blocks"] 121 | self.trace_dict = {k: set(v) for k, v in loaded_object_dict["trace_dict"].items()} 122 | self.block_dict = loaded_object_dict["block_dict"] 123 | self.function_stats = loaded_object_dict["function_stats"] 124 | self.coverage_files = loaded_object_dict["coverage_files"] 125 | self.total_coverage = set(loaded_object_dict["total_coverage"]) 126 | self.frontier = set(loaded_object_dict["frontier"]) 127 | 128 | # Coverage import functions 129 | def add_file(self, filepath): 130 | """Add a new coverage file""" 131 | if os.path.getsize(filepath) == 0: 132 | print('[!] Warning: Coverage file "%s" is empty, skipping...' % filepath) 133 | return set() 134 | coverage = parse.parse_coverage_file(filepath, self.module_name, self.module_base, self.module_blocks) 135 | if len(coverage) <= 10: 136 | print("[!] Warning: Coverage file %s returned very few coverage addresses (%d)" 137 | % (filepath, len(coverage))) 138 | for addr in coverage: 139 | self.block_dict.setdefault(addr, []).append(filepath) 140 | self.coverage_files.append(filepath) 141 | self.trace_dict[filepath] = coverage 142 | self.total_coverage |= coverage 143 | return coverage 144 | 145 | def add_directory(self, dirpath): 146 | """Add directory of coverage files""" 147 | for filename in os.listdir(dirpath): 148 | self.add_file(os.path.join(dirpath, filename)) 149 | 150 | def add_raw_coverage(self, name, coverage): 151 | """Add raw coverage under a name""" 152 | for addr in coverage: 153 | if not self.bv.get_basic_blocks_at(addr): 154 | raise Exception('[!] Attempted to import a block addr (0x%x) that doesn\'t match a basic block' % addr) 155 | for addr in coverage: 156 | self.block_dict.setdefault(addr, []).append(name) 157 | self.coverage_files.append(name) 158 | self.trace_dict[name] = coverage 159 | self.total_coverage |= coverage 160 | return coverage 161 | 162 | # Analysis functions 163 | def get_traces_from_block(self, addr): 164 | """Return traces that cover the block that contains addr""" 165 | addr = self.bv.get_basic_blocks_at(addr)[0].start 166 | return [name for name, trace in self.trace_dict.items() if addr in trace] 167 | 168 | def get_rare_blocks(self, threshold=1): 169 | """Return a list of blocks that are covered by <= threshold traces""" 170 | rare_blocks = [] 171 | for block in self.total_coverage: 172 | count = 0 173 | for _, trace in self.trace_dict.items(): 174 | if block in trace: 175 | count += 1 176 | if count > threshold: 177 | break 178 | if count <= threshold: 179 | rare_blocks.append(block) 180 | return rare_blocks 181 | 182 | def get_block_rarity_dict(self): 183 | """Return a mapping of blocks to the # of traces that cover it""" 184 | return {block: len(self.get_traces_from_block(block)) for block in self.total_coverage} 185 | 186 | def get_functions_from_blocks(self, blocks, by_name=False) -> Dict[int, List[int]]: 187 | """Returns a dictionary mapping functions to basic block addrs""" 188 | functions = {} 189 | for addr in blocks: 190 | matching_functions = self.bv.get_functions_containing(addr) 191 | if not matching_functions: 192 | print("[!] No functions found containing block start 0x%x" % addr) 193 | else: 194 | for cur_func in matching_functions: 195 | if by_name: 196 | functions.setdefault(cur_func.symbol.short_name, []).append(addr) 197 | else: 198 | functions.setdefault(cur_func.start, []).append(addr) 199 | return functions 200 | 201 | def get_trace_blocks(self, trace_name): 202 | """Get the set of basic blocks a trace covers""" 203 | return self.trace_dict[trace_name] 204 | 205 | def get_functions_from_trace(self, trace_name, by_name=False): 206 | """Get the list of functions a trace covers""" 207 | return list(self.get_functions_from_blocks(self.trace_dict[trace_name], by_name).keys()) 208 | 209 | def get_trace_uniq_blocks(self, trace_name): 210 | """Get the set of basic blocks that are only seen in the specified trace""" 211 | return self.trace_dict[trace_name] & set(self.get_rare_blocks()) 212 | 213 | def get_trace_uniq_functions(self, trace_name, by_name=False): 214 | """Get a list of functions containing basic blocks that are only seen in the specified trace""" 215 | return list(self.get_functions_from_blocks(self.get_trace_uniq_blocks(trace_name), by_name).keys()) 216 | 217 | def get_functions_with_rare_blocks(self, by_name=False): 218 | """Get a list of function names that contain basic blocks only covered by one trace""" 219 | return list(self.get_functions_from_blocks(self.get_rare_blocks(), by_name).keys()) 220 | 221 | def get_traces_with_rare_blocks(self): 222 | """Get the set of traces that have blocks that are unique to them""" 223 | traces = set() 224 | for block in self.get_rare_blocks(): 225 | traces.update(self.get_traces_from_block(block)) 226 | return traces 227 | 228 | def get_traces_from_function_name(self, function_name, demangle=False): 229 | """Return a set of traces that cover the function specified by function_name""" 230 | if demangle: 231 | matching_functions = [f for f in self.bv.functions if f.symbol.short_name == function_name] 232 | else: 233 | matching_functions = [f for f in self.bv.functions if f.name == function_name] 234 | if len(matching_functions) == 0: 235 | print("[!] No functions match %s" % function_name) 236 | return set() 237 | if len(matching_functions) > 1: 238 | raise Exception("[!] Warning, multiple functions matched name: %s" % function_name) 239 | matching_function = matching_functions[0] 240 | traces = set() 241 | for block in matching_function.basic_blocks: 242 | traces.update(self.get_traces_from_block(block.start)) 243 | return traces 244 | 245 | def get_traces_from_function(self, function_start: int): 246 | """Return a set of traces that cover the function specified by function_name""" 247 | matching_function = self.bv.get_function_at(function_start) 248 | if matching_function is None: 249 | print("[!] No function starts at 0x%x" % function_start) 250 | return set() 251 | traces = set() 252 | for block in matching_function.basic_blocks: 253 | traces.update(self.get_traces_from_block(block.start)) 254 | return traces 255 | 256 | def get_n_rarest_blocks(self, n): 257 | blocks_by_rarity = sorted(list(self.block_dict.keys()), key=lambda x: len(self.block_dict[x])) 258 | return blocks_by_rarity[:n] 259 | 260 | def all_edges_covered(self, addr): 261 | """Return True if all outgoing edge targets are covered, False otherwise""" 262 | blocks = self.bv.get_basic_blocks_at(addr) 263 | for block in blocks: 264 | if len(block.outgoing_edges) == 1: 265 | # there could be cases where we don't cover the next block, 266 | # ignoring for now 267 | return True 268 | for edge in block.outgoing_edges: 269 | if edge.target.start not in self.total_coverage: 270 | return False 271 | return True 272 | 273 | def get_frontier(self): 274 | """Return a set of addrs of blocks that have an uncovered outgoing edge target""" 275 | frontier_set = set() 276 | for addr in self.total_coverage: 277 | if not self.all_edges_covered(addr): 278 | frontier_set.add(addr) 279 | self.frontier = frontier_set 280 | return frontier_set 281 | 282 | # Statistic report functions 283 | def collect_function_coverage(self): 284 | """Collect stats on block coverage within functions (which is by default deferred)""" 285 | for func in self.bv: 286 | func_blocks = len(func.basic_blocks) 287 | blocks_covered = 0 288 | for block in func.basic_blocks: 289 | if block.start in self.total_coverage: 290 | blocks_covered += 1 291 | coverage_percent = (blocks_covered / float(func_blocks)) * 100 292 | complexity = self.get_cyclomatic_complexity(func.start) 293 | cur_stats = FuncCovStats(coverage_percent, blocks_covered, func_blocks, complexity) 294 | self.function_stats[func.start] = cur_stats 295 | return self.function_stats 296 | 297 | def get_overall_function_coverage(self): 298 | """Returns (number_of_functions, total_blocks_covered, total_blocks)""" 299 | if self.function_stats == {}: 300 | self.collect_function_coverage() 301 | blocks_covered = 0 302 | blocks_total = 0 303 | for _, stats in self.function_stats.items(): 304 | blocks_covered += stats.blocks_covered 305 | blocks_total += stats.blocks_total 306 | return len(self.function_stats), blocks_covered, blocks_total 307 | 308 | def find_orphan_blocks(self): 309 | """Find blocks that are covered in a function whose start isn't covered. 310 | 311 | Good for finding problems with the block coverage collecting/parsing. 312 | 313 | Will be unreliable on targets that have functions with multiple entrypoints 314 | or that do certain kinds of function thunking. 315 | """ 316 | orphan_blocks = set() 317 | for func_start, blocks in self.get_functions_from_blocks(self.total_coverage).items(): 318 | for containing_func in self.bv.get_functions_containing(blocks[0]): 319 | if containing_func.start == func_start: 320 | if containing_func.start not in blocks: 321 | print('[!] WARNING: Function "%s" has coverage, but not the start (0x%x)' % 322 | (containing_func.name, containing_func.start)) 323 | orphan_blocks.update(blocks) 324 | return orphan_blocks 325 | 326 | def find_stop_blocks(self, addr_list=None): 327 | """Find covered blocks that have successors, but none of them are covered. 328 | 329 | This usually indicates a crash, a non-returning jump/call, or some other oddity 330 | (such as a coverage problem). 331 | 332 | Suggested use is on a crashing testcase's block set, you should see one block 333 | for each function in the backtrace, something like: 334 | bncov.covdb.get_functions_from_blocks(bncov.covdb.find_stop_blocks()) 335 | """ 336 | if addr_list is None: 337 | addr_list = self.total_coverage 338 | 339 | stop_blocks = set() 340 | for block_addr in addr_list: 341 | containing_blocks = self.bv.get_basic_blocks_starting_at(block_addr) 342 | for basic_block in containing_blocks: 343 | # see if any outgoing edges were taken 344 | if len(basic_block.outgoing_edges) > 0: 345 | outgoing_seen = False 346 | for edge in basic_block.outgoing_edges: 347 | successor_addr = edge.target.start 348 | if successor_addr in self.total_coverage: 349 | outgoing_seen = True 350 | break 351 | if outgoing_seen is False: 352 | stop_blocks.add(block_addr) 353 | return stop_blocks 354 | 355 | def get_cyclomatic_complexity(self, function_start_addr): 356 | func = self.bv.get_function_at(function_start_addr) 357 | 358 | if func is None: 359 | return None 360 | 361 | num_blocks = len(func.basic_blocks) 362 | num_edges = sum(len(bb.outgoing_edges) for bb in func.basic_blocks) 363 | 364 | return num_edges - num_blocks + 2 365 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import division, absolute_import 2 | 3 | from binaryninja import * 4 | 5 | import os 6 | import sys 7 | from dataclasses import dataclass 8 | from typing import Optional 9 | from time import time, sleep 10 | from html import escape as html_escape 11 | from webbrowser import open_new_tab as open_new_browser_tab 12 | 13 | from .coverage import CoverageDB 14 | 15 | # shim for backwards-compatible log 16 | import binaryninja 17 | if hasattr(binaryninja.log, 'log_debug'): 18 | log_debug = log.log_debug 19 | log_info = log.log_info 20 | log_warn = log.log_warn 21 | log_error = log.log_error 22 | 23 | # __init__.py is only for Binary Ninja UI-related tasks 24 | 25 | PLUGIN_NAME = "bncov" 26 | 27 | USAGE_HINT = """[*] In the python shell, do `import bncov` to use 28 | [*] bncov.get_covdb(bv) gets the covdb object for the given Binary View 29 | covdb houses the the coverage-related functions (see coverage.py for more): 30 | covdb.get_traces_from_block(addr) - get files that cover block starting at addr 31 | Tip: click somewhere, then do bncov.covdb.get_traces_from_block(here) 32 | covdb.get_rare_blocks(threshold) - get blocks covered by <= threshold traces 33 | covdb.get_frontier(bv) - get blocks that have outgoing edges that aren't covered 34 | [*] Helpful covdb members: 35 | covdb.trace_dict (maps filenames to set of block start addrs) 36 | covdb.block_dict (maps block start addrs to files containing it) 37 | covdb.total_coverage (set of addresses of starts of bbs covered) 38 | [*] If you pip install msgpack, you can save/load the covdb (WARNING: files can be large) 39 | [*] Useful UI-related bncov functions (more are in the Highlights submenu) 40 | bncov.highlight_set(addr_set, color=None) - 41 | Highlight blocks by set of basic block start addrs, optional color override 42 | bncov.highlight_trace(bv, filepath, color_name="") - 43 | Highlight one trace file, optionally with a human-readable color_name 44 | bncov.restore_default_highlights(bv) - Reverts covered blocks to heatmap highlights. 45 | [*] Built-in python set operations and highlight_set() allow for custom highlights. 46 | You can also import coverage.py for coverage analysis in headless scripts. 47 | Please report any bugs via the git repo.""" 48 | 49 | 50 | @dataclass 51 | class Ctx: 52 | covdb: CoverageDB 53 | watcher: Optional[BackgroundTaskThread] 54 | 55 | 56 | # Helpers for scripts 57 | def make_bv(target_filename, quiet=True): 58 | """Return a BinaryView of target_filename""" 59 | if not os.path.exists(target_filename): 60 | print("[!] Couldn't find target file \"%s\"..." % target_filename) 61 | return None 62 | if not quiet: 63 | sys.stdout.write("[B] Loading Binary Ninja view of \"%s\"... " % target_filename) 64 | sys.stdout.flush() 65 | start = time() 66 | bv = BinaryViewType.get_view_of_file(target_filename) 67 | bv.update_analysis_and_wait() 68 | if not quiet: 69 | print("finished in %.02f seconds" % (time() - start)) 70 | return bv 71 | 72 | 73 | def make_covdb(bv: BinaryView, coverage_directory, quiet=True): 74 | """Return a CoverageDB based on bv and directory""" 75 | if not os.path.exists(coverage_directory): 76 | print("[!] Couldn't find coverage directory \"%s\"..." % coverage_directory) 77 | return None 78 | if not quiet: 79 | sys.stdout.write("[C] Creating coverage db from directory \"%s\"..." % coverage_directory) 80 | sys.stdout.flush() 81 | start = time() 82 | covdb = CoverageDB(bv) 83 | covdb.add_directory(coverage_directory) 84 | if not quiet: 85 | duration = time() - start 86 | num_files = len(os.listdir(coverage_directory)) 87 | print(" finished (%d files) in %.02f seconds" % (num_files, duration)) 88 | return covdb 89 | 90 | 91 | def save_bndb(bv: BinaryView, bndb_name=None): 92 | """Save current BinaryView to .bndb""" 93 | if bndb_name is None: 94 | bndb_name = os.path.basename(bv.file.filename) # filename may be a .bndb already 95 | if not bndb_name.endswith('.bndb'): 96 | bndb_name += ".bndb" 97 | bv.create_database(bndb_name) 98 | 99 | 100 | usage_shown = False 101 | def get_ctx(bv: BinaryView) -> Ctx: 102 | global usage_shown 103 | ctx = bv.session_data.get(PLUGIN_NAME) 104 | 105 | if ctx is None: 106 | covdb = CoverageDB(bv) 107 | ctx = Ctx(covdb, None) 108 | bv.session_data[PLUGIN_NAME] = ctx 109 | if not usage_shown: 110 | log_info(USAGE_HINT) 111 | usage_shown = True 112 | 113 | return ctx 114 | 115 | 116 | def get_covdb(bv: BinaryView) -> CoverageDB: 117 | return get_ctx(bv).covdb 118 | 119 | 120 | def close_covdb(bv: BinaryView): 121 | cancel_watch(bv) 122 | bv.session_data.pop(PLUGIN_NAME) 123 | 124 | 125 | # UI warning function 126 | def no_coverage_warn(bv: BinaryView): 127 | """If no coverage imported, pops a warning box and returns True""" 128 | ctx = get_ctx(bv) 129 | if len(ctx.covdb.coverage_files) == 0: 130 | show_message_box("Need to Import Traces First", 131 | "Can't perform this action yet, no traces have been imported for this Binary View", 132 | MessageBoxButtonSet.OKButtonSet, 133 | MessageBoxIcon.ErrorIcon) 134 | return True 135 | return False 136 | 137 | 138 | # UI interaction functions: 139 | def get_heatmap_color(hit_count, max_count): 140 | """Return HighlightColor between Blue and Red based on hit_count/max_count. 141 | 142 | If max_count is 1 or 0, uses red.""" 143 | heatmap_colors = [[0, 0, 255], [255, 0, 0]] # blue to red 144 | rgb = [] 145 | hit_count -= 1 # 0 hits wouldn't be highlighted at all 146 | max_count -= 1 # adjust max to reflect lack of hitcount == 0 147 | if max_count <= 0: 148 | rgb = heatmap_colors[1] 149 | else: 150 | for i in range(len("rgb")): 151 | common = heatmap_colors[0][i] 152 | uncommon = heatmap_colors[1][i] 153 | step = (common - uncommon) / max_count 154 | rgb.append(int(uncommon + step * hit_count)) 155 | color = HighlightColor(red=rgb[0], green=rgb[1], blue=rgb[2]) 156 | return color 157 | 158 | 159 | def highlight_block(block, count=0, color=None): 160 | """Highlight a block with heatmap default or a specified HighlightColor""" 161 | ctx = get_ctx(block.view) 162 | if color is None: 163 | if ctx.covdb is not None: 164 | max_count = len(ctx.covdb.trace_dict) 165 | else: 166 | max_count = 0 167 | color = get_heatmap_color(count, max_count) 168 | block.set_user_highlight(color) 169 | 170 | 171 | # This is the basic building block for visualization 172 | def highlight_set(bv: BinaryView, addr_set, color=None, start_only=True): 173 | """Take a set of addresses and highlight the blocks starting at (or containing if start_only=False) those addresses. 174 | 175 | You can use this manually, but you'll have to clear your own highlights. 176 | bncov.highlight_set(bv, addrs, color=bncov.colors['blue']) 177 | If you're using this manually just to highlight the blocks containing 178 | a group of addresses and aren't worry about overlapping blocks, use start_only=False. 179 | """ 180 | if start_only: 181 | get_blocks = bv.get_basic_blocks_starting_at 182 | else: 183 | get_blocks = bv.get_basic_blocks_at 184 | for addr in addr_set: 185 | blocks = get_blocks(addr) 186 | if len(blocks) >= 1: 187 | ctx = get_ctx(bv) 188 | for block in blocks: 189 | if addr in ctx.covdb.block_dict: 190 | count = len(ctx.covdb.block_dict[addr]) 191 | else: 192 | count = 0 193 | highlight_block(block, count, color) 194 | else: 195 | if get_blocks == bv.get_basic_blocks_starting_at: 196 | containing_blocks = bv.get_basic_blocks_at(addr) 197 | if containing_blocks: 198 | log_warn("[!] No blocks start at 0x%x, but %d blocks contain it:" % 199 | (addr, len(containing_blocks))) 200 | for i, block in enumerate(containing_blocks): 201 | log_info("%d: 0x%x - 0x%x in %s" % (i, block.start, block.end, block.function.name)) 202 | else: 203 | log_warn("[!] No blocks contain address 0x%x; check the address is inside a function." % addr) 204 | else: # get_blocks is bv.get_basic_blocks_at 205 | log_warn("[!] No blocks contain address 0x%x; check the address is inside a function." % addr) 206 | 207 | 208 | def clear_highlights(bv: BinaryView, addr_set): 209 | """Clear all highlights from the set of blocks containing the addrs in addr_set""" 210 | for addr in addr_set: 211 | blocks = bv.get_basic_blocks_at(addr) 212 | for block in blocks: 213 | block.set_user_highlight(HighlightStandardColor.NoHighlightColor) 214 | 215 | 216 | colors = {"black": HighlightStandardColor.BlackHighlightColor, 217 | "blue": HighlightStandardColor.BlueHighlightColor, 218 | "cyan": HighlightStandardColor.CyanHighlightColor, 219 | "green": HighlightStandardColor.GreenHighlightColor, 220 | "magenta": HighlightStandardColor.MagentaHighlightColor, 221 | "orange": HighlightStandardColor.OrangeHighlightColor, 222 | "red": HighlightStandardColor.RedHighlightColor, 223 | "white": HighlightStandardColor.WhiteHighlightColor, 224 | "yellow": HighlightStandardColor.YellowHighlightColor} 225 | 226 | 227 | # Good for interactive highlighting, undo with restore_default_highlights() 228 | def highlight_trace(bv: BinaryView, filepath, color_name=""): 229 | """Highlight blocks from a given trace with human-readable color_name""" 230 | ctx = get_ctx(bv) 231 | if filepath not in ctx.covdb.coverage_files: 232 | log_error("[!] %s is not in the coverage DB" % filepath) 233 | return 234 | blocks = ctx.covdb.trace_dict[filepath] 235 | if color_name == "": 236 | color = HighlightStandardColor.OrangeHighlightColor 237 | elif color_name.lower() in colors: 238 | color = colors[color_name] 239 | else: 240 | log_warn("[!] %s isn't a HighlightStandardColor, using my favorite color instead" % color_name) 241 | color = colors["red"] 242 | highlight_set(bv, blocks, color) 243 | log_info("[*] Highlighted %d basic blocks in trace %s" % (len(blocks), filepath)) 244 | 245 | 246 | def tour_set(bv: BinaryView, addresses, duration=None, delay=None): 247 | """Go on a whirlwind tour of a set of addresses""" 248 | default_duration = 20 # seconds 249 | num_addresses = len(addresses) 250 | # overriding duration is probably safer 251 | if duration is None: 252 | duration = default_duration 253 | # but why not 254 | if delay is None: 255 | delay = duration / num_addresses 256 | else: 257 | delay = float(delay) 258 | log_debug("[*] %d addresses to tour, delay: %.2f, tour time: %.2f" % 259 | (num_addresses, delay, delay*num_addresses)) 260 | for addr in addresses: 261 | bv.navigate(bv.view, addr) 262 | sleep(delay) 263 | 264 | 265 | # NOTE: this call will block until it finishes 266 | def highlight_dir(bv: BinaryView, covdir=None, color=None): 267 | ctx = get_ctx(bv) 268 | if covdir is None: 269 | covdir = get_directory_name_input("Coverage File Directory") 270 | ctx.covdb.add_directory(covdir) 271 | highlight_set(bv, ctx.covdb.total_coverage) 272 | log_info("Highlighted basic blocks for %d files from %s" % (len(os.listdir(covdir)), covdir)) 273 | 274 | 275 | def restore_default_highlights(bv: BinaryView): 276 | """Resets coverage highlighting to the default heatmap""" 277 | ctx = get_ctx(bv) 278 | highlight_set(bv, ctx.covdb.total_coverage) 279 | log_info("Default highlight colors restored") 280 | 281 | 282 | # Import helpers: 283 | def cancel_watch(bv: BinaryView): 284 | """If continuous monitoring was used, cancel it""" 285 | ctx = get_ctx(bv) 286 | if ctx.watcher is not None: 287 | ctx.watcher.finish() 288 | ctx.watcher = None 289 | 290 | 291 | class BackgroundHighlighter(BackgroundTaskThread): 292 | def __init__(self, bv: BinaryView, coverage_dir, watch=False): 293 | super(BackgroundHighlighter, self).__init__("Starting import...", can_cancel=True) 294 | self.progress = "Initializing..." 295 | self.bv = bv 296 | self.coverage_dir = coverage_dir 297 | self.watch = watch 298 | self.start_time = time() 299 | self.files_processed = [] 300 | 301 | def watch_dir_forever(self): 302 | self.progress = "Will continue monitoring %s" % self.coverage_dir 303 | # Immediately (re)color blocks in new traces, but also recolor all blocks when idle for some time 304 | # in order to show changes in relative rarity for blocks not touched by new traces 305 | idle = -1 # idle -1 means no new coverage seen 306 | idle_threshold = 5 307 | ctx = get_ctx(self.bv) 308 | while True: 309 | dir_files = os.listdir(self.coverage_dir) 310 | new_files = [name for name in dir_files if name not in self.files_processed] 311 | new_coverage = set() 312 | for new_file in new_files: 313 | new_coverage |= ctx.covdb.add_file(os.path.join(self.coverage_dir, new_file)) 314 | log_debug("[DBG] Added new coverage from file %s @ %d" % (new_file, int(time()))) 315 | self.files_processed.append(new_file) 316 | num_new_coverage = len(new_coverage) 317 | if num_new_coverage > 0: 318 | highlight_set(self.bv, new_coverage) 319 | idle = 0 320 | log_debug("[DBG] Updated highlights for %d blocks" % num_new_coverage) 321 | else: 322 | if idle >= 0: 323 | idle += 1 324 | if idle > idle_threshold: 325 | highlight_set(self.bv, ctx.covdb.total_coverage) 326 | idle = -1 327 | sleep(1) 328 | if ctx.watcher is None: 329 | break 330 | if self.cancelled: 331 | break 332 | 333 | def run(self): 334 | try: 335 | ctx = get_ctx(self.bv) 336 | log_info("[*] Loading coverage files from %s" % self.coverage_dir) 337 | dirlist = os.listdir(self.coverage_dir) 338 | num_files = len(dirlist) 339 | files_processed = 0 340 | for filename in dirlist: 341 | filepath = os.path.join(self.coverage_dir, filename) 342 | if os.path.getsize(filepath) == 0: 343 | log_warn('Coverage file %s is empty, skipping...' % filepath) 344 | continue 345 | blocks = ctx.covdb.add_file(filepath) 346 | if len(blocks) == 0: 347 | log_warn('Coverage file %s yielded zero coverage information' % filepath) 348 | self.progress = "%d / %d files processed" % (files_processed, num_files) 349 | files_processed += 1 350 | self.files_processed.append(filename) 351 | if self.cancelled: 352 | break 353 | highlight_set(self.bv, ctx.covdb.total_coverage) 354 | log_info("[*] Highlighted basic blocks for %d files from %s" % (len(dirlist), self.coverage_dir)) 355 | log_info("[*] Parsing/highlighting took %.2f seconds" % (time() - self.start_time)) 356 | if self.watch: 357 | self.watch_dir_forever() 358 | finally: 359 | self.progress = "" 360 | 361 | 362 | # PluginCommand - Coverage import functions 363 | def import_file(bv: BinaryView, filepath=None, color=None): 364 | """Import a single coverage file""" 365 | ctx = get_ctx(bv) 366 | if filepath is None: 367 | filepath = get_open_filename_input("Coverage File") 368 | if filepath is None: 369 | return 370 | if os.path.getsize(filepath) == 0: 371 | log_warn('Coverage file %s is empty!' % filepath) 372 | return 373 | blocks = ctx.covdb.add_file(filepath) 374 | if len(blocks) == 0: 375 | log_warn('Coverage file %s yielded 0 coverage blocks' % filepath) 376 | else: 377 | highlight_set(bv, blocks, color) 378 | log_info("[*] Highlighted %d basic blocks for file %s" % (len(blocks), filepath)) 379 | 380 | 381 | def background_import_dir(bv: BinaryView, watch=False): 382 | """Import a directory containing coverage files""" 383 | ctx = get_ctx(bv) 384 | coverage_dir = get_directory_name_input("Coverage File Directory") 385 | if coverage_dir is None: 386 | return 387 | ctx.watcher = BackgroundHighlighter(bv, coverage_dir, watch) 388 | ctx.watcher.start() 389 | 390 | 391 | def background_import_dir_and_watch(bv: BinaryView): 392 | """Import a directory, and then watch for new files and import them""" 393 | background_import_dir(bv, watch=True) 394 | 395 | 396 | def import_saved_covdb(bv: BinaryView, filepath=None): 397 | """Import a previously-generated and saved .covdb (fast but requires msgpack)""" 398 | try: 399 | import msgpack 400 | except ImportError: 401 | log_error("[!] Can't import saved covdb files without msgpack installed") 402 | return 403 | ctx = get_ctx(bv) 404 | if filepath is None: 405 | filepath = get_open_filename_input("Saved CoverageDB") 406 | if filepath is None: 407 | return 408 | start_time = time() 409 | ctx.covdb.load_from_file(filepath) 410 | highlight_set(bv, ctx.covdb.total_coverage) 411 | log_info("[*] Highlighted %d blocks from %s (containing %d files) in %.2f seconds" % 412 | (len(ctx.covdb.total_coverage), filepath, len(ctx.covdb.coverage_files), time() - start_time)) 413 | 414 | 415 | def clear_coverage(bv: BinaryView): 416 | """Deletes coverage objects and removes coverage highlighting""" 417 | ctx = get_ctx(bv) 418 | if len(ctx.covdb.coverage_files) > 0: 419 | remove_highlights(bv) 420 | close_covdb(bv) 421 | log_info("[*] Coverage information cleared") 422 | 423 | 424 | # PluginCommands - Highlight functions, only valid once coverage is imported 425 | def remove_highlights(bv: BinaryView): 426 | """Removes highlighting from all covered blocks""" 427 | if no_coverage_warn(bv): 428 | return 429 | ctx = get_ctx(bv) 430 | clear_highlights(bv, ctx.covdb.total_coverage) 431 | log_info("Highlights cleared.") 432 | 433 | 434 | def highlight_frontier(bv: BinaryView): 435 | """Highlights blocks with uncovered outgoing edge targets a delightful green""" 436 | if no_coverage_warn(bv): 437 | return 438 | ctx = get_ctx(bv) 439 | frontier_set = ctx.covdb.get_frontier() 440 | frontier_color = HighlightStandardColor.GreenHighlightColor 441 | highlight_set(bv, frontier_set, frontier_color) 442 | log_info("[*] Highlighted %d frontier blocks" % (len(frontier_set))) 443 | for block in frontier_set: 444 | log_info(" 0x%x" % block) 445 | 446 | 447 | def highlight_rare_blocks(bv: BinaryView, threshold=1): 448 | """Highlights blocks covered by < threshold traces a whitish red""" 449 | if no_coverage_warn(bv): 450 | return 451 | ctx = get_ctx(bv) 452 | rare_blocks = ctx.covdb.get_rare_blocks(threshold) 453 | rare_color = HighlightStandardColor.RedHighlightColor 454 | highlight_set(bv, rare_blocks, rare_color) 455 | log_info("[*] Found %d rare blocks (threshold: %d)" % 456 | (len(rare_blocks), threshold)) 457 | for block in rare_blocks: 458 | log_info(" 0x%x" % block) 459 | 460 | 461 | # PluginCommand - Report 462 | # Included this to show the potential usefulness of in-GUI reports 463 | def show_coverage_report(bv: BinaryView, save_output=False, filter_func=None, report_name=None): 464 | """Open a tab with a report of coverage statistics for each function. 465 | 466 | Optionally accept a filter function that gets the function start and stats 467 | and returns True if it should be included in the report, False otherwise.""" 468 | 469 | if no_coverage_warn(bv): 470 | return 471 | covdb = get_covdb(bv) 472 | covdb.get_overall_function_coverage() 473 | 474 | # Build report overview stats with the optional filter callback 475 | blocks_covered = 0 476 | blocks_total = 0 477 | addr_to_name_dict = {} 478 | for function_addr, stats in covdb.function_stats.items(): 479 | if filter_func is None or filter_func(function_addr, stats): 480 | demangled_name = bv.get_function_at(function_addr).symbol.short_name 481 | addr_to_name_dict[function_addr] = demangled_name 482 | blocks_covered += stats.blocks_covered 483 | blocks_total += stats.blocks_total 484 | num_functions = len(addr_to_name_dict) 485 | if num_functions == 0 and filter_func is not None: 486 | log_error('All functions filtered!') 487 | return 488 | 489 | if report_name is None: 490 | report_name = 'Coverage Report' 491 | title = "%s for %s" % (report_name, covdb.module_name) 492 | 493 | num_functions_unfiltered = len(covdb.function_stats) 494 | if num_functions == num_functions_unfiltered: 495 | report_header = "%d Functions, %d blocks covered of %d total" % \ 496 | (num_functions, blocks_covered, blocks_total) 497 | else: 498 | report_header = "%d / %d Functions shown, %d / %d blocks covered" % \ 499 | (num_functions, num_functions_unfiltered, blocks_covered, blocks_total) 500 | 501 | report_plaintext = "%s\n" % report_header 502 | report_html = "

%s

\n" % report_header 503 | column_titles = ['Start Address', 'Function Name', 'Coverage Percent', 'Blocks Covered / Total', 'Complexity'] 504 | report_html += ("\n\n%s\n\n" % \ 505 | ''.join('' % title for title in column_titles)) 506 | 507 | max_name_length = max([len(name) for name in addr_to_name_dict.values()]) 508 | for function_addr, stats in sorted(covdb.function_stats.items(), key=lambda x: (x[1].coverage_percent, x[1].blocks_covered), reverse=True): 509 | # skip filtered functions 510 | if function_addr not in addr_to_name_dict: 511 | continue 512 | 513 | name = addr_to_name_dict[function_addr] 514 | pad = " " * (max_name_length - len(name)) 515 | 516 | report_plaintext += " 0x%08x %s%s : %.2f%%\t( %-3d / %3d blocks)\n" % \ 517 | (function_addr, name, pad, stats.coverage_percent, stats.blocks_covered, stats.blocks_total) 518 | 519 | # build the html table row one item at a time, then combine them 520 | function_link = '0x%08x' % (function_addr, function_addr) 521 | function_name = html_escape(name) 522 | coverage_percent = '%.2f%%' % stats.coverage_percent 523 | blocks_covered = '%d / %d blocks' % (stats.blocks_covered, stats.blocks_total) 524 | row_data = [function_link, function_name, coverage_percent, blocks_covered, str(stats.complexity)] 525 | table_row = '' + ''.join('' % item for item in row_data) + '' 526 | report_html += table_row 527 | 528 | report_html += "
%s
%s
\n" 529 | 530 | embedded_css = '''\n''' 563 | # Optional, if it doesn't load, then the table is pre-sorted 564 | js_sort = '' 565 | report_html = '\n\n%s\n%s\n\n\n%s\n\n' % \ 566 | (embedded_css, js_sort, report_html) 567 | 568 | # Save report if it's too large to display or if user asks 569 | choices = ["Cancel Report", "Save Report to File", "Save Report and Open in Browser"] 570 | choice = 0 # "something unexpected" choice 571 | save_file, save_and_open = 1, 2 # user choices 572 | if len(report_html) > 1307673: # if Qt eats even one little wafer more, it bursts 573 | choice = interaction.get_choice_input( 574 | "Qt can't display a report this large. Select an action.", 575 | "Generated report too large", 576 | choices) 577 | if choice in [save_file, save_and_open]: 578 | save_output = True 579 | else: 580 | bv.show_html_report(title, report_html, plaintext=report_plaintext) 581 | 582 | target_dir, target_filename = os.path.split(bv.file.filename) 583 | html_file = os.path.join(target_dir, 'coverage-report-%s.html' % target_filename) 584 | if save_output: 585 | with open(html_file, 'w') as f: 586 | f.write(report_html) 587 | log_info("[*] Saved HTML report to %s" % html_file) 588 | if choice == save_file: 589 | interaction.show_message_box("Report Saved", 590 | "Saved HTML report to: %s" % html_file, 591 | enums.MessageBoxButtonSet.OKButtonSet, 592 | enums.MessageBoxIcon.InformationIcon) 593 | if choice == save_and_open: 594 | open_new_browser_tab("file://" + html_file) 595 | 596 | 597 | def show_high_complexity_report(bv, min_complexity=20, save_output=False): 598 | """Show a report of just high-complexity functions""" 599 | 600 | def complexity_filter(cur_func_start, cur_func_stats): 601 | if cur_func_stats.complexity >= min_complexity: 602 | return True 603 | else: 604 | return False 605 | 606 | show_coverage_report(bv, save_output, complexity_filter, 'High Complexity Coverage Report') 607 | 608 | 609 | def show_nontrivial_report(bv, save_output=False): 610 | """Demonstrate a coverage report filtered using BN's analysis""" 611 | 612 | def triviality_filter(cur_func_start, cur_func_stats): 613 | cur_function = bv.get_function_at(cur_func_start) 614 | 615 | trivial_block_count = 4 616 | trivial_instruction_count = 16 617 | blocks_seen = 0 618 | instructions_seen = 0 619 | for block in cur_function.basic_blocks: 620 | blocks_seen += 1 621 | instructions_seen += block.instruction_count 622 | if blocks_seen > trivial_block_count: 623 | return True 624 | if instructions_seen > trivial_instruction_count: 625 | return True 626 | return False 627 | 628 | show_coverage_report(bv, save_output, triviality_filter, 'Nontrivial Coverage Report') 629 | 630 | 631 | # Register plugin commands 632 | PluginCommand.register("bncov\\Coverage Data\\Import Directory", 633 | "Import basic block coverage from files in directory", 634 | background_import_dir) 635 | PluginCommand.register("bncov\\Coverage Data\\Import Directory and Watch", 636 | "Import basic block coverage from directory and watch for new coverage", 637 | background_import_dir_and_watch) 638 | PluginCommand.register("bncov\\Coverage Data\\Import File", 639 | "Import basic blocks by coverage", 640 | import_file) 641 | try: 642 | import msgpack 643 | PluginCommand.register("bncov\\Coverage Data\\Import Saved CoverageDB", 644 | "Import saved coverage database", 645 | import_saved_covdb) 646 | except ImportError: 647 | pass 648 | 649 | PluginCommand.register("bncov\\Coverage Data\\Reset Coverage State", 650 | "Clear the current coverage state", 651 | clear_coverage) 652 | 653 | # These are only valid once coverage data exists 654 | PluginCommand.register("bncov\\Highlighting\\Remove Highlights", 655 | "Remove basic block highlights", 656 | remove_highlights) 657 | PluginCommand.register("bncov\\Highlighting\\Highlight Rare Blocks", 658 | "Highlight only the rarest of blocks", 659 | highlight_rare_blocks) 660 | PluginCommand.register("bncov\\Highlighting\\Highlight Coverage Frontier", 661 | "Highlight blocks that didn't get fully covered", 662 | highlight_frontier) 663 | PluginCommand.register("bncov\\Highlighting\\Restore Default Highlights", 664 | "Highlight coverage", 665 | restore_default_highlights) 666 | 667 | PluginCommand.register("bncov\\Reports\\Show Coverage Report", 668 | "Show a report of function coverage", 669 | show_coverage_report) 670 | PluginCommand.register("bncov\\Reports\\Show High-Complexity Function Report", 671 | "Show a report of high-complexity function coverage", 672 | show_high_complexity_report) 673 | PluginCommand.register("bncov\\Reports\\Show Non-Trivial Function Report", 674 | "Show a report of non-trivial function coverage", 675 | show_nontrivial_report) 676 | --------------------------------------------------------------------------------