├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── bncov ├── LICENSE ├── __init__.py ├── coverage.py └── parse.py ├── headless_phantasm.py ├── images ├── phantasm_demo.gif └── phantasm_interactive.gif ├── phantasm ├── __init__.py ├── anim.css ├── anim.js ├── first_animate.py ├── plugin.py ├── timeline.py └── visualize.py ├── svg-pan-zoom ├── LICENSE ├── svg-pan-zoom.d.ts ├── svg-pan-zoom.js └── svg-pan-zoom.min.js └── test ├── cgc ├── queue-cov │ ├── id-000007.cov │ ├── id-000024.cov │ ├── id-000028.cov │ ├── id-000094.cov │ ├── id-000112.cov │ ├── id-000131.cov │ ├── id-000188.cov │ ├── id-000189.cov │ ├── id-000198.cov │ ├── id-000287.cov │ ├── id-000291.cov │ ├── id-000348.cov │ ├── id-000350.cov │ ├── id-000369.cov │ ├── id-000370.cov │ └── id-000449.cov ├── queue-with-timestamps.tgz ├── queue │ ├── id-000007 │ ├── id-000024 │ ├── id-000028 │ ├── id-000094 │ ├── id-000112 │ ├── id-000131 │ ├── id-000188 │ ├── id-000189 │ ├── id-000198 │ ├── id-000287 │ ├── id-000291 │ ├── id-000348 │ ├── id-000350 │ ├── id-000369 │ ├── id-000370 │ └── id-000449 └── rematch-crackaddr ├── phantasm-libjpegturbo-examine_app0.html ├── phantasm-libjpegturbo-get_interesting_appn.html ├── phantasm-libjpegturbo-get_sof.html └── phantasm-rematch_crackaddr-main.html /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | __pycache__ 3 | 4 | orig/ 5 | TODO 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 mechanicalnull, except for the contents of bncov/ and svg-pan-zoom/ (see the respective LICENSE files in each directory) 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Phantasm - Binary Ninja plugin for visualizing fuzzing coverage over time 2 | 3 | Phantasm builds an interactive visualization of how fuzzing increases coverage 4 | of a function over time. Using coverage abstraction from the plugin 5 | [bncov](https://github.com/ForAllSecure/bncov), and building on Vector35's 6 | [export SVG example](https://github.com/Vector35/binaryninja-api/blob/dev/python/examples/export_svg.py), 7 | Phantasm adds the aspect of exploring time via a single-page HTML visualization 8 | that users can use to explore how the addition of each seed over time changes 9 | the coverage of the function. 10 | 11 | ![Phantasm Demo](/images/phantasm_demo.gif) 12 | 13 | Users can pan and zoom around the graph in their browser, as well as either 14 | watch a loop or step through each seed as it is added. This is currently a proof 15 | of concept, please open an issue if you have more ideas. 16 | 17 | ## Install 18 | 19 | Just clone this repo to your plugins directory. 20 | 21 | ## Usage 22 | 23 | 1. Navigate in Binary Ninja to the function you want to visualize coverage over 24 | time for. 25 | 2. Use the context menu to select Phantasm -> Animate Current Function. 26 | 3. Fill in the dialog (description of key fields below). 27 | 4. Open the output file in a browser. Any errors will be written to the log in 28 | Binary Ninja 29 | 30 | - Corpus Directory: where the input files are stored (NOTE: the inputs files are 31 | only used for their timestamps, make sure you either use the original 32 | directory or have copied the files around in a manner that preserves 33 | timestamps, such as `cp -pr src dst`). 34 | - Coverage Directory: directory containing files in drcov or module+offset 35 | format. 36 | - Output File: where to save the HTML output 37 | 38 | For those that want to see results before trying it, the repository includes 39 | [several](/test/phantasm-libjpegturbo-examine_app0.html) 40 | [examples](/test/phantasm-rematch_crackaddr-main.html) 41 | that should display even on systems without Binary Ninja installed. 42 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from binaryninja import PluginCommand 2 | 3 | from .phantasm.plugin import make_visualization 4 | 5 | PluginCommand.register_for_function( 6 | "Phantasm\\Animate Current Function", 7 | "Generate visualization", 8 | make_visualization 9 | ) 10 | -------------------------------------------------------------------------------- /bncov/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 | -------------------------------------------------------------------------------- /bncov/__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 | -------------------------------------------------------------------------------- /bncov/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) 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 | -------------------------------------------------------------------------------- /bncov/parse.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from struct import unpack 4 | from os.path import basename 5 | 6 | # Handle parsing files into sets of addresses, each describing the start of a basic block 7 | # Can be invoked as a standalone script for debugging purposes 8 | 9 | 10 | def detect_format(filename): 11 | """Return the name of the format based on the start of the file.""" 12 | enough_bytes = 0x1000 13 | with open(filename, 'rb') as f: 14 | data = f.read(enough_bytes) 15 | if isinstance(data, bytes): 16 | data = data.decode(errors='replace') 17 | 18 | if data.startswith('DRCOV VERSION: 2'): 19 | return 'drcov' 20 | if '+' in data: 21 | # Check for module+offset, skipping any comment lines at start 22 | for line in data.split('\n'): 23 | if line.strip().startswith(';'): 24 | continue 25 | pieces = line.split('+') 26 | if len(pieces) == 2: 27 | try: 28 | hex_int = int(pieces[1], 16) 29 | return 'module+offset' 30 | except ValueError: 31 | pass 32 | raise Exception('[!] File "%s" doesn\'t appear to be drcov or module+offset format' % filename) 33 | 34 | 35 | def parse_coverage_file(filename, module_name, module_base, module_blocks, debug=False): 36 | """Return a set of addresses of covered blocks in the specified module""" 37 | file_format = detect_format(filename) 38 | if file_format == 'drcov': 39 | blocks = parse_drcov_file(filename, module_name, module_base, module_blocks) 40 | elif file_format == 'module+offset': 41 | blocks = parse_mod_offset_file(filename, module_name, module_base, module_blocks) 42 | return blocks 43 | 44 | 45 | def parse_mod_offset_file(filename, module_name, module_base, module_blocks, debug=False): 46 | """Return blocks from a file with "module_name+hex_offset" format.""" 47 | blocks = set() 48 | modules_seen = set() 49 | # We do a case-insensitive module name comparison to match Windows behavior 50 | module_name = module_name.lower() 51 | with open(filename, 'r') as f: 52 | for line in f.readlines(): 53 | if line.strip().startswith(';'): 54 | continue 55 | pieces = line.split('+') 56 | if len(pieces) != 2: 57 | continue 58 | name, offset = pieces 59 | name = name.lower() 60 | if debug: 61 | if module_name != name and name not in modules_seen: 62 | print('[DBG] module mismatch, expected (%s), encountered (%s)' % (module_name, name)) 63 | modules_seen.add(name) 64 | block_offset = int(offset, 16) 65 | block_addr = module_base + block_offset 66 | if block_addr in module_blocks: 67 | blocks.add(block_addr) 68 | elif debug: 69 | print('[!] DBG: address 0x%x not in module_blocks!' % block_addr) 70 | return blocks 71 | 72 | 73 | def parse_drcov_header(header, module_name, filename, debug): 74 | module_name = module_name.lower() 75 | module_table_start = False 76 | module_ids = [] 77 | for i, line in enumerate(header.split("\n")): 78 | # Encountering the basic block table indicates end of the module table 79 | if line.startswith("BB Table"): 80 | break 81 | # The first entry in the module table starts with "0", potentially after leading spaces 82 | if line.strip().startswith("0"): 83 | module_table_start = True 84 | if module_table_start: 85 | columns = line.split(",") 86 | if debug: 87 | print("[DBG] Module table entry: %s" % line.strip()) 88 | for col in columns[1:]: 89 | if module_name != "" and module_name in basename(col).lower(): 90 | module_ids.append(int(columns[0])) 91 | if debug: 92 | print("[DBG] Target module found (%d): %s" % (int(columns[0]), line.strip())) 93 | if not module_table_start: 94 | raise Exception('[!] No module table found in "%s"' % filename) 95 | if not module_ids and not debug: 96 | raise Exception("[!] Didn't find expected target '%s' in the module table in %s" % 97 | (module_name, filename)) 98 | 99 | return module_ids 100 | 101 | 102 | def parse_drcov_binary_blocks(block_data, filename, module_ids, module_base, module_blocks, debug): 103 | blocks = set() 104 | block_data_len = len(block_data) 105 | blocks_seen = 0 106 | 107 | remainder = block_data_len % 8 108 | if remainder != 0: 109 | print("[!] Warning: %d trailing bytes left over in %s" % (remainder, filename)) 110 | block_data = block_data[:-remainder] 111 | if debug: 112 | module_dict = {} 113 | 114 | for i in range(0, block_data_len, 8): 115 | block_offset = unpack("I", block_data[i:i + 4])[0] 116 | block_size = unpack("H", block_data[i + 4:i + 6])[0] 117 | block_module_id = unpack("H", block_data[i + 6:i + 8])[0] 118 | block_addr = module_base + block_offset 119 | blocks_seen += 1 120 | if debug: 121 | print("%d: 0x%08x 0x%x" % (block_module_id, block_offset, block_size)) 122 | module_dict[block_module_id] = module_dict.get(block_module_id, 0) + 1 123 | if block_module_id in module_ids: 124 | cur_addr = block_addr 125 | # traces can contain "blocks" that split and span blocks 126 | # so we need a fairly comprehensive check to get it right 127 | while cur_addr < block_addr + block_size: 128 | if cur_addr in module_blocks: 129 | blocks.add(cur_addr) 130 | cur_addr += module_blocks[cur_addr] 131 | else: 132 | cur_addr += 1 133 | if debug: 134 | print('[DBG] Block count per-module:') 135 | for module_number, blocks_hit in sorted(module_dict.items()): 136 | print(' %d: %d' % (module_number, blocks_hit)) 137 | return blocks, blocks_seen 138 | 139 | 140 | def parse_drcov_ascii_blocks(block_data, filename, module_ids, module_base, module_blocks, debug): 141 | blocks = set() 142 | blocks_seen = 0 143 | int_base = 0 # 0 not set, 10 or 16 144 | if debug: 145 | module_dict = {} 146 | 147 | for line in block_data.split(b"\n"): 148 | # example: 'module[ 4]: 0x0000000000001090, 8' 149 | left_bracket_index = line.find(b'[') 150 | right_bracket_index = line.find(b']') 151 | # skip bad/blank lines 152 | if left_bracket_index == -1 or right_bracket_index == -1: 153 | continue 154 | block_module_id = int(line[left_bracket_index+1: right_bracket_index]) 155 | block_offset, block_size = line[right_bracket_index+2:].split(b',') 156 | 157 | if int_base: 158 | block_offset = int(block_offset, int_base) 159 | else: 160 | if b'x' in block_offset: 161 | int_base = 16 162 | else: 163 | int_base = 10 164 | block_offset = int(block_offset, int_base) 165 | 166 | block_size = int(block_size) 167 | block_addr = module_base + block_offset 168 | blocks_seen += 1 169 | if debug: 170 | print("%d: 0x%08x 0x%x" % (block_module_id, block_offset, block_size)) 171 | module_dict[block_module_id] = module_dict.get(block_module_id, 0) + 1 172 | if block_module_id in module_ids: 173 | cur_addr = block_addr 174 | while cur_addr < block_addr + block_size: 175 | if cur_addr in module_blocks: 176 | blocks.add(cur_addr) 177 | cur_addr += module_blocks[cur_addr] 178 | else: 179 | cur_addr += 1 180 | if debug: 181 | print('[DBG] Block count per-module:') 182 | for module_number, blocks_hit in sorted(module_dict.items()): 183 | print(' %d: %d' % (module_number, blocks_hit)) 184 | return blocks, blocks_seen 185 | 186 | 187 | def parse_drcov_file(filename, module_name, module_base, module_blocks, debug=False): 188 | """Return set of blocks in module covered (block definitions provided in module_blocks)""" 189 | with open(filename, 'rb') as f: 190 | data = f.read() 191 | 192 | # Sanity checks for expected contents 193 | if not data.startswith(b"DRCOV VERSION: 2"): 194 | raise Exception("[!] File %s does not appear to be a drcov format file, " % filename + 195 | "it doesn't start with the expected signature: 'DRCOV VERSION: 2'") 196 | 197 | header_end_pattern = b"BB Table: " 198 | header_end_location = data.find(header_end_pattern) 199 | if header_end_location == -1: 200 | raise Exception("[!] File %s does not appear to be a drcov format file, " % filename + 201 | "it doesn't contain a header for the basic block table'") 202 | header_end_location = data.find(b"\n", header_end_location) + 1 # +1 to skip the newline 203 | 204 | # Check for ascii vs binary drcov version (binary is the default) 205 | binary_file = True 206 | ascii_block_header = b"module id, start, size:" 207 | 208 | block_header_candidate = data[header_end_location:header_end_location + len(ascii_block_header)] 209 | if block_header_candidate == ascii_block_header: 210 | binary_file = False 211 | # Skip the ascii block header line ("module id, start, size:\n") 212 | header_end_location = data.find(b"\n", header_end_location) + 1 # +1 to skip the newline 213 | 214 | # Parse the header 215 | header = data[:header_end_location].decode() 216 | block_data = data[header_end_location:] 217 | module_ids = parse_drcov_header(header, module_name, filename, debug) 218 | 219 | # Parse the block data itself 220 | if binary_file: 221 | parse_blocks = parse_drcov_binary_blocks 222 | else: 223 | parse_blocks = parse_drcov_ascii_blocks 224 | if debug: 225 | print("[DBG] Detected drcov %s format" % ("binary" if binary_file else "ascii")) 226 | print("[DBG] Basic block dump (module_id, block_offset, block_size)") 227 | blocks, blocks_seen = parse_blocks(block_data, filename, module_ids, module_base, module_blocks, debug) 228 | 229 | if debug: 230 | if not module_ids: 231 | print("[*] %d blocks parsed, no module id specified" % blocks_seen) 232 | else: 233 | num_blocks_found = len(blocks) 234 | print("[*] %d blocks parsed; module_ids %s" % 235 | (blocks_seen, module_ids)) 236 | return blocks 237 | 238 | 239 | if __name__ == "__main__": 240 | import sys 241 | import time 242 | if len(sys.argv) == 1: 243 | print("STANDALONE USAGE: %s [module_name]" % sys.argv[0]) 244 | exit() 245 | target = sys.argv[1] 246 | module_name = "" 247 | if len(sys.argv) >= 3: 248 | module_name = sys.argv[2] 249 | start = time.time() 250 | parse_coverage_file(sys.argv[1], module_name, 0, [], debug=True) 251 | duration = time.time() - start 252 | print('[*] Completed parsing in %.2f seconds' % duration) 253 | -------------------------------------------------------------------------------- /headless_phantasm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Demonstrating Headless use of Phantasm. 5 | 6 | Note that this requires a Binary Ninja license that supports headless operation. 7 | Otherwise just enjoy using the plugin via the GUI and builtin Python console. 8 | 9 | Example Usage (make sure you heed warnings about timestamps and save and restore 10 | timestamps via something like tar archives): 11 | python3 headless_phantasm.py test/cgc/rematch-crackaddr main test/cgc/queue{,-cov} 12 | ''' 13 | 14 | import sys 15 | import argparse 16 | import time 17 | from binaryninja import BinaryViewType 18 | 19 | 20 | from phantasm.plugin import graph_coverage 21 | 22 | 23 | if __name__ == "__main__": 24 | 25 | parser = argparse.ArgumentParser('headless_phantasm') 26 | parser.add_argument('target', help='Target binary or BNDB') 27 | parser.add_argument('function', help='Target function to graph') 28 | parser.add_argument('corpus_dir', help='Dir containing inputs with timestamps') 29 | parser.add_argument('coverage_dir', help='Dir containing coverage information for inputs') 30 | parser.add_argument('--output_file', help='Where to save the file (default: current dir, auto-named)') 31 | parser.add_argument('--show_opcodes', type=bool, default=True, 32 | help='Don\'t show opcodes in graph (default: shown)') 33 | parser.add_argument('--show_addrs', type=bool, default=True, 34 | help='Don\'t show addresses in graph (default: shown)') 35 | args = parser.parse_args() 36 | 37 | sys.stdout.write("[B] Loading Binary Ninja view of \"%s\"... " % args.target) 38 | sys.stdout.flush() 39 | start = time.time() 40 | bv = BinaryViewType.get_view_of_file(args.target) 41 | print("finished in %.02f seconds" % (time.time() - start)) 42 | 43 | print(f'[*] Invoking graph_coverage on {args.function}') 44 | output_file = graph_coverage( 45 | bv, 46 | args.function, 47 | args.corpus_dir, 48 | args.coverage_dir, 49 | args.output_file, 50 | args.show_opcodes 51 | ) 52 | -------------------------------------------------------------------------------- /images/phantasm_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mechanicalnull/phantasm/0be99c66b96051926ec83bc8fecafa25ad6f0f08/images/phantasm_demo.gif -------------------------------------------------------------------------------- /images/phantasm_interactive.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mechanicalnull/phantasm/0be99c66b96051926ec83bc8fecafa25ad6f0f08/images/phantasm_interactive.gif -------------------------------------------------------------------------------- /phantasm/__init__.py: -------------------------------------------------------------------------------- 1 | from .plugin import make_visualization 2 | -------------------------------------------------------------------------------- /phantasm/anim.css: -------------------------------------------------------------------------------- 1 | @import url(https://fonts.googleapis.com/css?family=Source+Code+Pro); 2 | body { 3 | background-color: rgb(42, 42, 42); 4 | color: rgb(220, 220, 220); 5 | font-family: "Source Code Pro", "Lucida Console", "Consolas", monospace; 6 | } 7 | a, a:visited { 8 | color: rgb(200, 200, 200); 9 | font-weight: bold; 10 | } 11 | svg { 12 | background-color: rgb(42, 42, 42); 13 | display: block; 14 | margin: 0 auto; 15 | width: inherit; 16 | height: 100%; 17 | } 18 | .basicblock { 19 | stroke: rgb(224, 224, 224); 20 | } 21 | .edge { 22 | fill: none; 23 | stroke-width: 1px; 24 | } 25 | .back_edge { 26 | fill: none; 27 | stroke-width: 2px; 28 | } 29 | .UnconditionalBranch, .IndirectBranch { 30 | stroke: rgb(128, 198, 233); 31 | color: rgb(128, 198, 233); 32 | } 33 | .FalseBranch { 34 | stroke: rgb(222, 143, 151); 35 | color: rgb(222, 143, 151); 36 | } 37 | .TrueBranch { 38 | stroke: rgb(162, 217, 175); 39 | color: rgb(162, 217, 175); 40 | } 41 | .arrow { 42 | stroke-width: 1; 43 | fill: currentColor; 44 | } 45 | text { 46 | font-family: "Source Code Pro", "Lucida Console", "Consolas", monospace; 47 | font-size: 9pt; 48 | fill: rgb(224, 224, 224); 49 | } 50 | .CodeSymbolToken { 51 | fill: rgb(128, 198, 223); 52 | } 53 | .DataSymbolToken { 54 | fill: rgb(142, 230, 237); 55 | } 56 | .TextToken, .InstructionToken, .BeginMemoryOperandToken, .EndMemoryOperandToken { 57 | fill: rgb(224, 224, 224); 58 | } 59 | .CodeRelativeAddressToken, .PossibleAddressToken, .IntegerToken, .AddressDisplayToken { 60 | fill: rgb(162, 217, 175); 61 | } 62 | .RegisterToken { 63 | fill: rgb(237, 223, 179); 64 | } 65 | .AnnotationToken { 66 | fill: rgb(218, 196, 209); 67 | } 68 | .IndirectImportToken, .ImportToken { 69 | fill: rgb(237, 189, 129); 70 | } 71 | .LocalVariableToken, .StackVariableToken { 72 | fill: rgb(193, 220, 199); 73 | } 74 | .OpcodeToken { 75 | fill: rgb(144, 144, 144); 76 | } 77 | .basicblock { 78 | fill: #4a4a4a; 79 | } 80 | #header { 81 | position: fixed; 82 | left: 0; 83 | top: 0; 84 | width: 100%; 85 | z-index: 100; 86 | background: rgb(32, 32, 32); 87 | border-bottom: 1px solid rgb(224, 224, 224); 88 | white-space: nowrap; 89 | } 90 | #slider { 91 | vertical-align: middle; 92 | } 93 | #content { 94 | position: absolute; 95 | top: 44px; 96 | bottom: 52px; 97 | width: 100%; 98 | } 99 | #footer { 100 | position: fixed; 101 | left: 0; 102 | bottom: 0; 103 | width: 100%; 104 | z-index: 100; 105 | background: rgb(32, 32, 32); 106 | border-top: 1px solid rgb(224, 224, 224); 107 | white-space: nowrap; 108 | text-align: center; 109 | } 110 | html, body { 111 | margin:0px; 112 | } 113 | 114 | /* button css from https://codepen.io/ben_jammin/pen/syaCq */ 115 | .button::-moz-focus-inner{ 116 | border: 0; 117 | padding: 0; 118 | } 119 | 120 | .button{ 121 | display: inline-block; 122 | *display: inline; 123 | zoom: 1; 124 | padding: 6px 20px; 125 | margin: 0; 126 | cursor: pointer; 127 | border: 1px solid #bbb; 128 | overflow: visible; 129 | font: bold 13px arial, helvetica, sans-serif; 130 | text-decoration: none; 131 | white-space: nowrap; 132 | color: #555; 133 | 134 | background-color: #ddd; 135 | background-image: -webkit-gradient(linear, left top, left bottom, from(rgba(255,255,255,1)), to(rgba(255,255,255,0))); 136 | background-image: -webkit-linear-gradient(top, rgba(255,255,255,1), rgba(255,255,255,0)); 137 | background-image: -moz-linear-gradient(top, rgba(255,255,255,1), rgba(255,255,255,0)); 138 | background-image: -ms-linear-gradient(top, rgba(255,255,255,1), rgba(255,255,255,0)); 139 | background-image: -o-linear-gradient(top, rgba(255,255,255,1), rgba(255,255,255,0)); 140 | background-image: linear-gradient(top, rgba(255,255,255,1), rgba(255,255,255,0)); 141 | 142 | -webkit-transition: background-color .2s ease-out; 143 | -moz-transition: background-color .2s ease-out; 144 | -ms-transition: background-color .2s ease-out; 145 | -o-transition: background-color .2s ease-out; 146 | transition: background-color .2s ease-out; 147 | background-clip: padding-box; 148 | -moz-border-radius: 3px; 149 | -webkit-border-radius: 3px; 150 | border-radius: 3px; 151 | -moz-box-shadow: 0 1px 0 rgba(0, 0, 0, .3), 0 2px 2px -1px rgba(0, 0, 0, .5), 0 1px 0 rgba(255, 255, 255, .3) inset; 152 | -webkit-box-shadow: 0 1px 0 rgba(0, 0, 0, .3), 0 2px 2px -1px rgba(0, 0, 0, .5), 0 1px 0 rgba(255, 255, 255, .3) inset; 153 | box-shadow: 0 1px 0 rgba(0, 0, 0, .3), 0 2px 2px -1px rgba(0, 0, 0, .5), 0 1px 0 rgba(255, 255, 255, .3) inset; 154 | text-shadow: 0 1px 0 rgba(255,255,255, .9); 155 | 156 | -webkit-touch-callout: none; 157 | -webkit-user-select: none; 158 | -khtml-user-select: none; 159 | -moz-user-select: none; 160 | -ms-user-select: none; 161 | user-select: none; 162 | } 163 | 164 | .button:hover{ 165 | background-color: #eee; 166 | color: #555; 167 | } 168 | 169 | .button:active{ 170 | background: #e9e9e9; 171 | position: relative; 172 | top: 1px; 173 | text-shadow: none; 174 | -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, .3) inset; 175 | -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, .3) inset; 176 | box-shadow: 0 1px 1px rgba(0, 0, 0, .3) inset; 177 | } 178 | 179 | /* Group buttons */ 180 | .button-group, 181 | .button-group li{ 182 | display: inline-block; 183 | *display: inline; 184 | zoom: 1; 185 | } 186 | 187 | .button-group{ 188 | font-size: 0; 189 | margin: 0; 190 | padding: 0; 191 | background: rgba(0, 0, 0, .1); 192 | border-bottom: 1px solid rgba(0, 0, 0, .1); 193 | padding: 7px; 194 | -moz-border-radius: 7px; 195 | -webkit-border-radius: 7px; 196 | border-radius: 7px; 197 | } 198 | 199 | .button-group li{ 200 | margin-right: -1px; 201 | } 202 | 203 | .button-group .button{ 204 | font-size: 13px; 205 | -moz-border-radius: 0; 206 | -webkit-border-radius: 0; 207 | border-radius: 0; 208 | } 209 | 210 | .button-group .button:active{ 211 | -moz-box-shadow: 0 0 1px rgba(0, 0, 0, .2) inset, 5px 0 5px -3px rgba(0, 0, 0, .2) inset, -5px 0 5px -3px rgba(0, 0, 0, .2) inset; 212 | -webkit-box-shadow: 0 0 1px rgba(0, 0, 0, .2) inset, 5px 0 5px -3px rgba(0, 0, 0, .2) inset, -5px 0 5px -3px rgba(0, 0, 0, .2) inset; 213 | box-shadow: 0 0 1px rgba(0, 0, 0, .2) inset, 5px 0 5px -3px rgba(0, 0, 0, .2) inset, -5px 0 5px -3px rgba(0, 0, 0, .2) inset; 214 | } 215 | 216 | .button-group li:first-child .button{ 217 | -moz-border-radius: 3px 0 0 3px; 218 | -webkit-border-radius: 3px 0 0 3px; 219 | border-radius: 3px 0 0 3px; 220 | } 221 | 222 | .button-group li:first-child .button:active{ 223 | -moz-box-shadow: 0 0 1px rgba(0, 0, 0, .2) inset, -5px 0 5px -3px rgba(0, 0, 0, .2) inset; 224 | -webkit-box-shadow: 0 0 1px rgba(0, 0, 0, .2) inset, -5px 0 5px -3px rgba(0, 0, 0, .2) inset; 225 | box-shadow: 0 0 1px rgba(0, 0, 0, .2) inset, -5px 0 5px -3px rgba(0, 0, 0, .2) inset; 226 | } 227 | 228 | .button-group li:last-child .button{ 229 | -moz-border-radius: 0 3px 3px 0; 230 | -webkit-border-radius: 0 3px 3px 0; 231 | border-radius: 0 3px 3px 0; 232 | } 233 | 234 | .button-group li:last-child .button:active{ 235 | -moz-box-shadow: 0 0 1px rgba(0, 0, 0, .2) inset, 5px 0 5px -3px rgba(0, 0, 0, .2) inset; 236 | -webkit-box-shadow: 0 0 1px rgba(0, 0, 0, .2) inset, 5px 0 5px -3px rgba(0, 0, 0, .2) inset; 237 | box-shadow: 0 0 1px rgba(0, 0, 0, .2) inset, 5px 0 5px -3px rgba(0, 0, 0, .2) inset; 238 | } 239 | -------------------------------------------------------------------------------- /phantasm/anim.js: -------------------------------------------------------------------------------- 1 | // seeds = [seed_object, ... ]; written in by the Python code above this 2 | // Globals 3 | var play_on = false; 4 | var do_loop = false; 5 | var do_highlight_cur = false; 6 | var frame_interval = 1000; 7 | var play_callback; 8 | 9 | var svg_obj; 10 | var play_button; 11 | var loop_button; 12 | var prev_button; 13 | var next_button; 14 | var slider_obj; 15 | var highlight_cur_button; 16 | var cur_frame_span; 17 | var max_frame_span; 18 | var cur_seed_span; 19 | var panZoomGraph; 20 | 21 | var num_colors = 3; // numbers of colors in gradient, including new and old 22 | var new_color = [255, 0, 0]; // newest blocks color 23 | var old_color = [0, 0, 255]; // old blocks color 24 | var cur_seed_color = [195, 155, 0]; // replaces new_color if highlighting current seed 25 | // golden: [195, 155, 0] // replaces new_color if highlighting current seed 26 | // dark cyan: [0, 139, 139] 27 | // sea foam: [60, 179, 113] 28 | var colors; 29 | 30 | 31 | // global init function once page is loaded 32 | function init_anim() { 33 | //console.log("init_anim"); 34 | 35 | // initialize page element handles 36 | svg_obj = document.getElementById('anim_graph'); 37 | play_button = document.getElementById('play_button'); 38 | loop_button = document.getElementById('loop_button'); 39 | prev_button = document.getElementById('prev_button'); 40 | next_button = document.getElementById('next_button'); 41 | slider_obj = document.getElementById('slider'); 42 | highlight_cur_button = document.getElementById('highlight_cur_button'); 43 | cur_frame_span = document.getElementById('cur_frame_span'); 44 | max_frame_span = document.getElementById('max_frame_span'); 45 | cur_seed_span = document.getElementById('cur_seed_span'); 46 | scalable_svg = document.getElementById('functiongraph')[0]; 47 | 48 | // Helpers to implement click'n'drag to pan for the SVG 49 | var panZoomGraph = svgPanZoom('#anim_graph', { 50 | minZoom: 0.25, 51 | maxZoom: 8, 52 | beforePan: limit_pan, 53 | }); 54 | 55 | // set up the slider and text values 56 | slider_obj.max = seeds.length - 1; 57 | slider_obj.min = 0; 58 | slider_obj.value = 0; 59 | cur_frame_span.innerHTML = 0; 60 | max_frame_span.innerHTML = seeds.length - 1; 61 | // 'change' and 'input' are different, 'input' is responsive 62 | slider_obj.addEventListener('input', handle_slider_change); 63 | 64 | // mix colors once 65 | colors = mix_colors(new_color, old_color, num_colors) 66 | 67 | // set button click handlers 68 | play_button.addEventListener('click', toggle_play); 69 | loop_button.addEventListener('click', toggle_loop); 70 | prev_button.addEventListener('click', click_prev); 71 | next_button.addEventListener('click', click_next); 72 | highlight_cur_button.addEventListener('click', click_highlight_cur); 73 | 74 | // show initial highlights and kick off the animation 75 | reset_anim(); 76 | toggle_play(); 77 | } 78 | 79 | 80 | // SVG pan and zoom using https://github.com/ariutta/svg-pan-zoom 81 | 82 | // limit_pan based on the demo in that repo 83 | function limit_pan(oldPan, newPan) { 84 | let sizes = this.getSizes(); 85 | 86 | let gutterWidth = sizes.viewBox.width / 3; 87 | let gutterHeight = sizes.viewBox.height / 3; 88 | 89 | let leftLimit = -((sizes.viewBox.x + sizes.viewBox.width) * sizes.realZoom) + gutterWidth; 90 | let rightLimit = sizes.width - gutterWidth - (sizes.viewBox.x * sizes.realZoom); 91 | let topLimit = -((sizes.viewBox.y + sizes.viewBox.height) * sizes.realZoom) + gutterHeight; 92 | let bottomLimit = sizes.height - gutterHeight - (sizes.viewBox.y * sizes.realZoom); 93 | 94 | customPan = {}; 95 | customPan.x = Math.max(leftLimit, Math.min(rightLimit, newPan.x)); 96 | customPan.y = Math.max(topLimit, Math.min(bottomLimit, newPan.y)); 97 | 98 | return customPan; 99 | } 100 | 101 | 102 | // Functions to handle interaction with controls 103 | 104 | function toggle_play() { 105 | //console.log(play_on); 106 | 107 | // regardless of start or stop we should cancel any previous callback 108 | if (play_callback) { 109 | clearTimeout(play_callback); 110 | play_callback = undefined; 111 | } 112 | 113 | play_on = !play_on; 114 | if (play_on) { 115 | play_button.style.background = "lightgreen"; 116 | play_callback = setTimeout(play_animation, frame_interval); 117 | } else { 118 | play_button.style.background = ""; 119 | } 120 | } 121 | 122 | function toggle_loop() { 123 | //console.log(do_loop); 124 | 125 | do_loop = !do_loop; 126 | if (do_loop) 127 | loop_button.style.background = "lightgreen"; 128 | else 129 | loop_button.style.background = ""; 130 | } 131 | 132 | function click_prev() { 133 | if (play_on) 134 | toggle_play(); 135 | 136 | let new_index = frame_index - 1; 137 | // allow prev to loop back around 138 | if (new_index == 0 && do_loop) 139 | new_index = seeds.length; 140 | 141 | if (new_index > 0) 142 | run_anim_up_to(new_index); 143 | } 144 | 145 | function click_next() { 146 | if (play_on) 147 | toggle_play(); 148 | 149 | advance_animation(); 150 | } 151 | 152 | function handle_slider_change() { 153 | let cur_value = parseInt(slider_obj.value, 10); 154 | //console.log(`handle_slider_change: ${cur_value}`) 155 | 156 | run_anim_up_to(cur_value + 1); 157 | 158 | if (play_on) 159 | toggle_play(); 160 | } 161 | 162 | function click_highlight_cur() { 163 | if (play_on) { 164 | toggle_play(); 165 | } 166 | 167 | do_highlight_cur = !do_highlight_cur; 168 | if (do_highlight_cur) { 169 | highlight_cur_button.style.background = "lightgreen"; 170 | } else { 171 | highlight_cur_button.style.background = ""; 172 | } 173 | 174 | // apply the new coloring 175 | run_anim_up_to(frame_index); 176 | } 177 | 178 | 179 | // animation functions 180 | 181 | // animation callback trigger 182 | function play_animation() { 183 | if (play_on) { 184 | //console.log(`play_animation ${Date()}`) 185 | advance_animation(); 186 | } 187 | // advance_animation can cancel play_on 188 | if (play_on) { 189 | play_callback = setTimeout(play_animation, frame_interval); 190 | } 191 | } 192 | 193 | var frame_index = 0; 194 | function reset_anim() { 195 | frame_index = 0; 196 | reset_all_blocks(); 197 | advance_animation(); 198 | } 199 | 200 | function advance_animation() { 201 | //console.log(`advance_animation: ${frame_index}`); 202 | // check if trying to advance past last frame 203 | if (frame_index >= seeds.length) { 204 | if (do_loop) { 205 | reset_anim(); 206 | } else { 207 | if (play_on) 208 | toggle_play(); 209 | } 210 | return; 211 | } 212 | 213 | slider_obj.value = frame_index; 214 | cur_frame_span.innerHTML = frame_index; 215 | cur_seed_span.innerHTML = `Time: ${seeds[frame_index].time}, Name: ${seeds[frame_index].name}`; 216 | highlight_last_n(frame_index); 217 | //highlight_index(frame_index); 218 | 219 | frame_index += 1; 220 | } 221 | 222 | // up to, meaning "index" is technically one more than is shown 223 | function run_anim_up_to(index) { 224 | if ((index < 0) || (index > seeds.length)) { 225 | return; 226 | } 227 | // FUTURE: implement better strategy here for large datasets 228 | //console.log(`running to ${index}`); 229 | reset_anim(); 230 | while (frame_index < index) { 231 | advance_animation(); 232 | } 233 | } 234 | 235 | // Highlight/Color functions 236 | 237 | function highlight_index(index) { 238 | // basic highlight, just one color 239 | let cur_seed = seeds[frame_index]; 240 | //console.log(cur_seed); 241 | 242 | 243 | for (block of cur_seed.blocks) { 244 | //console.log(block); 245 | highlight_block(block); 246 | } 247 | 248 | } 249 | 250 | function highlight_last_n(index) { 251 | // colors already initialized in init function 252 | 253 | for (let i = num_colors - 1; i >= 0; i--) { 254 | let cur_index = index - i; 255 | if (cur_index < 0) 256 | continue; 257 | 258 | let seed = seeds[cur_index]; 259 | let color = colors[i]; 260 | //console.log(`${i} ${seed} ${color}`); 261 | 262 | // handle highlight current seed, override new_color 263 | if (do_highlight_cur && color == new_color) { 264 | for (block of seed.blocks) { 265 | set_block_color(block, cur_seed_color); 266 | } 267 | } 268 | // otherwise normal colors 269 | else { 270 | for (block of seed.blocks) { 271 | //console.log(block); 272 | set_block_color(block, color); 273 | } 274 | } 275 | } 276 | 277 | if (do_highlight_cur) { 278 | for (block of seeds[frame_index].extras) { 279 | set_block_color(block, cur_seed_color); 280 | } 281 | } 282 | } 283 | 284 | // currently linear interpolation of RGB colors 285 | function mix_colors(start, end, steps) { 286 | if (steps == 1) return [start]; 287 | if (steps == 2) return [start, end]; 288 | 289 | let num_intermediates = steps - 2; 290 | 291 | let rgb_deltas = [ 292 | end[0] - start[0], 293 | end[1] - start[1], 294 | end[2] - start[2] 295 | ]; 296 | let rgb_increments = [ 297 | rgb_deltas[0] / (num_intermediates + 1), 298 | rgb_deltas[1] / (num_intermediates + 1), 299 | rgb_deltas[2] / (num_intermediates + 1) 300 | ] 301 | 302 | let color_steps = []; 303 | color_steps.push(start); 304 | for (let i = 1; i < (num_intermediates+1); i++) { 305 | cur_color = [ 306 | start[0] + rgb_increments[0] * i, 307 | start[1] + rgb_increments[1] * i, 308 | start[2] + rgb_increments[2] * i 309 | ]; 310 | color_steps.push(cur_color); 311 | } 312 | color_steps.push(end); 313 | 314 | return color_steps; 315 | } 316 | 317 | // Element color manipulation functions 318 | function set_block_color(block_id, rgb) { 319 | let block = svg_obj.getElementById(block_id); 320 | block.style.fill = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`; 321 | } 322 | 323 | function highlight_block(block_id) { 324 | set_block_color(block_id, [255, 0, 0]); 325 | } 326 | 327 | function reset_block_color(block_id) { 328 | let block = svg_obj.getElementById(block_id); 329 | block.style.fill = ''; // default CSS rule takes over 330 | } 331 | 332 | function reset_all_blocks() { 333 | let all_blocks = svg_obj.querySelectorAll(".basicblock"); 334 | 335 | for (const block of all_blocks) { 336 | reset_block_color(block.id); 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /phantasm/first_animate.py: -------------------------------------------------------------------------------- 1 | # from binaryninja import * 2 | import os 3 | import time 4 | import sys 5 | 6 | from pathlib import Path 7 | from urllib.request import pathname2url 8 | 9 | from binaryninja.interaction import get_save_filename_input, show_message_box, TextLineField, ChoiceField, SaveFileNameField, get_form_input 10 | from binaryninja.settings import Settings 11 | from binaryninja.enums import MessageBoxButtonSet, MessageBoxIcon, MessageBoxButtonResult, InstructionTextTokenType, BranchType, DisassemblyOption, FunctionGraphType 12 | from binaryninja.function import DisassemblySettings 13 | from binaryninja.plugin import PluginCommand 14 | 15 | colors = {'green': [162, 217, 175], 'red': [222, 143, 151], 'blue': [128, 198, 233], 'cyan': [142, 230, 237], 'lightCyan': [ 16 | 176, 221, 228], 'orange': [237, 189, 129], 'yellow': [237, 223, 179], 'magenta': [218, 196, 209], 'none': [74, 74, 74], 17 | 'disabled': [144, 144, 144]} 18 | 19 | escape_table = { 20 | "'": "'", 21 | ">": ">", 22 | "<": "<", 23 | '"': """, 24 | ' ': " " 25 | } 26 | 27 | 28 | def gray_to_red(block_start, frame_num, total_frames, duration): 29 | """New blocks pop up as red, stay red""" 30 | bb_id = f"bb-{block_start:x}" 31 | start_percent = frame_num / total_frames * 100 32 | next_percent = (frame_num + 1) / total_frames * 100 33 | # this animation transitions fill from gray to red between start frame and next frame 34 | animation_css = f""" 35 | @keyframes anim-{bb_id} {{ 36 | 0%, {start_percent:.4f}% {{ fill: #4a4a4a; }} 37 | {next_percent:.4f}%, 100% {{ fill: #ff0000; }} 38 | }} 39 | #{bb_id} {{ animation: {duration}s linear infinite anim-{bb_id}; }}""" 40 | return animation_css 41 | 42 | 43 | def gray_red_blue(block_start, frame_num, total_frames, duration): 44 | """New blocks pop red, stay red, then fade to blue""" 45 | bb_id = f"bb-{block_start:x}" 46 | delay_at_end = 1 # amount of time to freeze at end 47 | 48 | red_duration_seconds = 1 49 | red_duration_frame_percent = red_duration_seconds / (duration - delay_at_end) * 100 50 | blue_to_red_duration_seconds = 1 51 | blue_to_red_frame_percent = blue_to_red_duration_seconds / (duration - delay_at_end) * 100 52 | # artificially expand total_frames to account for delay at end 53 | total_frames *= duration / (duration - delay_at_end) 54 | 55 | gray_stop_percent = max(0, frame_num - 1) / total_frames * 100 56 | red_start_percent = frame_num / total_frames * 100 57 | red_stop_percent = red_start_percent + red_duration_frame_percent 58 | blue_start_percent = min(red_stop_percent + blue_to_red_frame_percent, 100) 59 | 60 | animation_css = f""" 61 | @keyframes anim-{bb_id} {{ 62 | 0%, {gray_stop_percent:.4f}% {{ fill: #4a4a4a; }} 63 | {red_start_percent:.4f}%, {red_stop_percent:.4f}% {{ fill: #ff0000; }} 64 | {blue_start_percent:.4f}%, 100% {{ fill: #0000ff; }} 65 | }} 66 | #{bb_id} {{ animation: {duration}s ease-in-out infinite anim-{bb_id}; }}""" 67 | return animation_css 68 | 69 | 70 | def get_animation_for_block( 71 | block_start: int, 72 | frame_num: int, 73 | total_frames: int, 74 | duration: int=5, 75 | ): 76 | """Generate CSS to pop a block from gray to red at the right frame 77 | 78 | block_start: int 79 | frame_num: int 80 | total_frames: int 81 | duration: int # seconds""" 82 | animation_function = gray_red_blue 83 | return animation_function(block_start, frame_num, total_frames, duration) 84 | 85 | 86 | def get_custom_css( 87 | timeline: "CoverageTimeline", 88 | function: "binaryninja.function.Function", 89 | frame_for_empty_update: bool=False, 90 | ): 91 | """Generate animation CSS to highlight the graph to show coverage over time""" 92 | 93 | animation_css = "/* start animation CSS */" 94 | func_blocks = set(block.start for block in function.basic_blocks) 95 | 96 | # show frames for empty updates to represent time elapsed 97 | # This isn't really time elapsed, just timestamps where 98 | # functions other than this one got updated 99 | if frame_for_empty_update: 100 | num_total_frames = len(timeline.sorted_timestamps) 101 | else: 102 | num_total_frames = 0 103 | for timestamp in timeline.sorted_timestamps: 104 | if any( 105 | func_blocks.intersection(cov_file.block_coverage) 106 | for cov_file in timeline.coverage_timeline[timestamp] 107 | ): 108 | num_total_frames += 1 109 | for i, cur_timestamp in enumerate(timeline.sorted_timestamps): 110 | new_blocks = set() 111 | for cur_coverage_file in timeline.coverage_timeline[cur_timestamp]: 112 | # Using the fact that the coverage_timeline should only include deltas here 113 | cur_func_blocks = func_blocks.intersection(cur_coverage_file.block_coverage) 114 | if cur_func_blocks: 115 | new_blocks.update(cur_func_blocks) 116 | for block_addr in new_blocks: 117 | cur_animation_css = get_animation_for_block(block_addr, i, num_total_frames) 118 | animation_css += cur_animation_css 119 | animation_css += "\n/* end animation CSS */" 120 | return animation_css 121 | 122 | 123 | def escape(toescape): 124 | # handle extended unicode 125 | toescape = toescape.encode('ascii', 'xmlcharrefreplace') 126 | # still escape the basics 127 | if sys.version_info[0] == 3: 128 | return ''.join(escape_table.get(chr(i), chr(i)) for i in toescape) 129 | else: 130 | return ''.join(escape_table.get(i, i) for i in toescape) 131 | 132 | 133 | def save_svg(bv, function, timeline, outputfile=None): 134 | sym = bv.get_symbol_at(function.start) 135 | if sym: 136 | offset = sym.name 137 | else: 138 | offset = "%x" % function.start 139 | path = Path(os.path.dirname(bv.file.filename)) 140 | origname = os.path.basename(bv.file.filename) 141 | svg_path = path / f'binaryninja-{origname}-{offset}-animated.html' 142 | if outputfile is None: 143 | outputfile = str(svg_path) 144 | showOpcodes = False 145 | showAddresses = False 146 | 147 | content = render_html(function, offset, "Graph", "Assembly", showOpcodes, showAddresses, origname, timeline) 148 | with open(outputfile, 'w') as f: 149 | f.write(content) 150 | print(f'[+] Wrote {len(content)} bytes to "{outputfile}"') 151 | 152 | 153 | def generate_css(function, timeline): 154 | default_css = ''' 155 | @import url(https://fonts.googleapis.com/css?family=Source+Code+Pro); 156 | body { 157 | background-color: rgb(42, 42, 42); 158 | color: rgb(220, 220, 220); 159 | font-family: "Source Code Pro", "Lucida Console", "Consolas", monospace; 160 | } 161 | a, a:visited { 162 | color: rgb(200, 200, 200); 163 | font-weight: bold; 164 | } 165 | svg { 166 | background-color: rgb(42, 42, 42); 167 | display: block; 168 | margin: 0 auto; 169 | } 170 | .basicblock { 171 | stroke: rgb(224, 224, 224); 172 | } 173 | .edge { 174 | fill: none; 175 | stroke-width: 1px; 176 | } 177 | .back_edge { 178 | fill: none; 179 | stroke-width: 2px; 180 | } 181 | .UnconditionalBranch, .IndirectBranch { 182 | stroke: rgb(128, 198, 233); 183 | color: rgb(128, 198, 233); 184 | } 185 | .FalseBranch { 186 | stroke: rgb(222, 143, 151); 187 | color: rgb(222, 143, 151); 188 | } 189 | .TrueBranch { 190 | stroke: rgb(162, 217, 175); 191 | color: rgb(162, 217, 175); 192 | } 193 | .arrow { 194 | stroke-width: 1; 195 | fill: currentColor; 196 | } 197 | text { 198 | font-family: "Source Code Pro", "Lucida Console", "Consolas", monospace; 199 | font-size: 9pt; 200 | fill: rgb(224, 224, 224); 201 | } 202 | .CodeSymbolToken { 203 | fill: rgb(128, 198, 223); 204 | } 205 | .DataSymbolToken { 206 | fill: rgb(142, 230, 237); 207 | } 208 | .TextToken, .InstructionToken, .BeginMemoryOperandToken, .EndMemoryOperandToken { 209 | fill: rgb(224, 224, 224); 210 | } 211 | .CodeRelativeAddressToken, .PossibleAddressToken, .IntegerToken, .AddressDisplayToken { 212 | fill: rgb(162, 217, 175); 213 | } 214 | .RegisterToken { 215 | fill: rgb(237, 223, 179); 216 | } 217 | .AnnotationToken { 218 | fill: rgb(218, 196, 209); 219 | } 220 | .IndirectImportToken, .ImportToken { 221 | fill: rgb(237, 189, 129); 222 | } 223 | .LocalVariableToken, .StackVariableToken { 224 | fill: rgb(193, 220, 199); 225 | } 226 | .OpcodeToken { 227 | fill: rgb(144, 144, 144); 228 | } 229 | .basicblock { 230 | fill: #4a4a4a; 231 | } 232 | ''' 233 | custom_css = get_custom_css(timeline, function) 234 | return f"{default_css}\n{custom_css}" 235 | 236 | 237 | def render_html(function, offset, mode, form, showOpcodes, showAddresses, origname, timeline): 238 | """Build an HTML document containing an animated SVG showing coverage over time""" 239 | 240 | css = generate_css(function, timeline) 241 | svg = render_svg(function, offset, mode, form, showOpcodes, showAddresses, origname) 242 | 243 | output = f''' 244 | 245 | 248 | 249 | 250 | {svg} 251 | ''' 252 | 253 | timestring = time.strftime("%c") 254 | func_description = f"showing {function.symbol.short_name}" 255 | output += f'

This CFG generated by Binary Ninja from {origname} on {timestring} {func_description}.

' 256 | output += '' 257 | return output 258 | 259 | 260 | def render_svg(function, offset, mode, form, showOpcodes, showAddresses, origname): 261 | """Build SVG XML for the given function""" 262 | settings = DisassemblySettings() 263 | if showOpcodes: 264 | settings.set_option(DisassemblyOption.ShowOpcode, True) 265 | if showAddresses: 266 | settings.set_option(DisassemblyOption.ShowAddress, True) 267 | graph_type = FunctionGraphType.NormalFunctionGraph 268 | graph = function.create_graph(graph_type=graph_type, settings=settings) 269 | graph.layout_and_wait() 270 | 271 | heightconst = 15 272 | ratio = 0.48 273 | widthconst = heightconst * ratio 274 | 275 | function_name = function.name # not guaranteed to be unique or pretty 276 | function_start = f'{function.start:x}' # unique, not pretty 277 | demangled_function_name = function.symbol.short_name # pretty, may not be unique 278 | 279 | output = ''' 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | '''.format(width=graph.width * widthconst + 20, height=graph.height * heightconst + 20) 295 | output += f''' 296 | Graph of {demangled_function_name} 297 | ''' 298 | 299 | edges = '' 300 | for i, block in enumerate(graph): 301 | 302 | # Calculate basic block location and coordinates 303 | x = ((block.x) * widthconst) 304 | y = ((block.y) * heightconst) 305 | width = ((block.width) * widthconst) 306 | height = ((block.height) * heightconst) 307 | 308 | # Get basic block start address 309 | bb_start = f'{block.basic_block.start:x}' 310 | bb_id = f'bb-{bb_start}' 311 | 312 | # Render block 313 | output += ' \n' 314 | output += f' Basic Block @ {bb_start}\n' 315 | # We're going to override block colors with .basicblock default and CSS animation, so we omit fill color for each block 316 | output += f' \n' 317 | 318 | # Render instructions, unfortunately tspans don't allow copying/pasting more 319 | # than one line at a time, need SVG 1.2 textarea tags for that it looks like 320 | output += ' \n'.format( 321 | x=x, y=y + (i + 1) * heightconst) 322 | for i, line in enumerate(block.lines): 323 | output += ' '.format( 324 | x=x + 6, y=y + 6 + (i + 0.7) * heightconst, address=hex(line.address)[:-1]) 325 | for token in line.tokens: 326 | output += '{text}'.format( 327 | text=escape(token.text), tokentype=InstructionTextTokenType(token.type).name) 328 | output += '\n' 329 | output += ' \n' 330 | output += ' \n' 331 | 332 | # Edges are rendered in a separate chunk so they have priority over the 333 | # basic blocks or else they'd render below them 334 | for edge in block.outgoing_edges: 335 | points = "" 336 | x, y = edge.points[0] 337 | points += str(x * widthconst) + "," + \ 338 | str(y * heightconst + 12) + " " 339 | for x, y in edge.points[1:-1]: 340 | points += str(x * widthconst) + "," + \ 341 | str(y * heightconst) + " " 342 | x, y = edge.points[-1] 343 | points += str(x * widthconst) + "," + \ 344 | str(y * heightconst + 0) + " " 345 | if edge.back_edge: 346 | edges += ' \n'.format( 347 | type=BranchType(edge.type).name, points=points) 348 | else: 349 | edges += ' \n'.format( 350 | type=BranchType(edge.type).name, points=points) 351 | 352 | output += ' ' + edges + '\n' 353 | output += ' \n' 354 | output += '' 355 | 356 | return output 357 | 358 | 359 | def animate_prompt(bv, function): 360 | # TODO: register a plugin hook 361 | # TODO: make sure this function signature matches the function plugin hook 362 | # TODO: prompt for a coverage file directory and coverage seed directory 363 | # TODO: make a coverage timeline, then pass into save_svg 364 | raise NotImplementedError 365 | -------------------------------------------------------------------------------- /phantasm/plugin.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | from time import time 4 | 5 | from binaryninja import log_info, log_error, log_debug 6 | from binaryninja import DirectoryNameField, ChoiceField, SaveFileNameField, get_form_input, show_html_report 7 | 8 | from bncov import CoverageDB 9 | from .timeline import CoverageTimeline 10 | from .visualize import generate_html 11 | 12 | 13 | def prompt_for_settings(bv, func): 14 | form_fields = [] 15 | form_fields.append(f'Function to Graph: {func.symbol.short_name}') 16 | 17 | corpus_dir_choice = DirectoryNameField("Corpus Directory (inputs)") 18 | form_fields.append(corpus_dir_choice) 19 | 20 | coverage_dir_choice = DirectoryNameField("Coverage Directory (*.cov)") 21 | form_fields.append(coverage_dir_choice) 22 | 23 | show_opcodes_field = ChoiceField("Show Opcodes", ["No", "Yes"]) 24 | form_fields.append(show_opcodes_field) 25 | 26 | show_addresses_field = ChoiceField("Show Addresses", ["No", "Yes"]) 27 | form_fields.append(show_addresses_field) 28 | 29 | orig_name = os.path.basename(bv.file.original_filename) 30 | default_outputfile = f'phantasm-{orig_name}-{func.symbol.short_name}.html' 31 | output_file_choice = SaveFileNameField("Output file", 'HTML files (*.html)', default_outputfile) 32 | form_fields.append(output_file_choice) 33 | 34 | if not get_form_input(form_fields, "Phantasm Generation") or output_file_choice.result is None: 35 | return None 36 | 37 | output_file = output_file_choice.result 38 | if output_file == '': 39 | output_file = default_outputfile 40 | log_info(f'Phantasm: No output filename supplied, using default {output_file}') 41 | 42 | settings = ( 43 | corpus_dir_choice.result, 44 | coverage_dir_choice.result, 45 | show_opcodes_field.result, 46 | show_addresses_field.result, 47 | output_file, 48 | ) 49 | return settings 50 | 51 | 52 | def make_visualization(bv, func): 53 | """For interactive use, promps user for settings, then renders viz to file""" 54 | 55 | settings = prompt_for_settings(bv, func) 56 | if settings is None: 57 | return 58 | corpus_dir, coverage_dir, show_opcodes, show_addresses, output_file = settings 59 | 60 | if not os.path.exists(corpus_dir): 61 | log_error(f'Corpus directory "{corpus_dir}" not found') 62 | return 63 | if not os.path.exists(coverage_dir): 64 | log_error(f'Coverage directory "{coverage_dir}" not found') 65 | return 66 | 67 | log_info(f"Phantasm: Graphing coverage for {func.symbol.short_name}") 68 | log_info(f" Corpus dir: {corpus_dir}") 69 | log_info(f" Coverage dir: {coverage_dir}") 70 | 71 | html_content = generate_coverage_over_time(bv, func, corpus_dir, coverage_dir, show_opcodes, show_addresses) 72 | if len(html_content) == 0: 73 | return '' 74 | 75 | output_path = os.path.abspath(output_file) 76 | with open(output_file, 'w') as f: 77 | f.write(html_content) 78 | log_info(f'Phantasm: Wrote visualization to "{output_path}"') 79 | 80 | return output_path 81 | 82 | 83 | def graph_coverage(bv, func, corpus_dir, coverage_dir, output_file=None, show_opcodes=True, show_addresses=True): 84 | """Renders visualization to a file; for headless use""" 85 | if isinstance(func, str): 86 | func = next(f for f in bv.functions if f.name == func) 87 | 88 | if output_file is None: 89 | auto_name = f'phantasm-{os.path.basename(bv.file.original_filename)}-{func.name}.html' 90 | output_file = os.path.join(os.getcwd(), auto_name) 91 | 92 | html_content = generate_coverage_over_time(bv, func, corpus_dir, coverage_dir, show_opcodes, show_addresses) 93 | if len(html_content) == 0: 94 | return '' 95 | 96 | with open(output_file, 'w') as f: 97 | f.write(html_content) 98 | print(f'Wrote {os.path.getsize(output_file)} bytes to "{output_file}"') 99 | 100 | return output_file 101 | 102 | 103 | def generate_coverage_over_time(bv, func, corpus_dir, coverage_dir, show_opcodes, show_addresses): 104 | """Calculate coverage timeline, then build the html visualization""" 105 | 106 | start_time = time() 107 | covdb = CoverageDB(bv) 108 | covdb.add_directory(coverage_dir) 109 | duration = time() - start_time 110 | log_debug(f'Phantasm: Coverage loaded in {duration:.2f} seconds') 111 | 112 | def map_coverage_to_corpus(coverage_path: Path) -> Path: 113 | coverage_name = coverage_path.name 114 | if coverage_name.endswith('.cov'): 115 | input_name = coverage_name[:-4] 116 | else: 117 | input_name = coverage_name 118 | corpus_dir_path = Path(corpus_dir) 119 | return corpus_dir_path.joinpath(input_name) 120 | 121 | start_time = time() 122 | cov_timeline = CoverageTimeline(bv, [covdb,]) 123 | cov_timeline.get_seed_from_coverage_file = map_coverage_to_corpus 124 | cov_timeline.process_timeline() 125 | 126 | unique_timestamps = len(cov_timeline.sorted_timestamps) 127 | if unique_timestamps <= 1: 128 | log_error(f'[!] Phantasm: Only detected {unique_timestamps} unique timestamps,' + 129 | ' which means coverage over time is not meaningful\n') 130 | log_error(' If you copied the corpus directory around, ensure you ' + 131 | 'preserve timestamps with `cp -a` or equivalent') 132 | return '' 133 | 134 | cov_timeline.print_total_coverage_delta(func) 135 | duration = time() - start_time 136 | log_debug(f'Phantasm: Timeline calculated in {duration:.2f} seconds') 137 | 138 | start_time = time() 139 | html_content = generate_html(bv, func, cov_timeline, show_opcodes, show_addresses) 140 | duration = time() - start_time 141 | log_debug(f'Phantasm: HTML generated in {duration:.2f} seconds') 142 | 143 | return html_content 144 | -------------------------------------------------------------------------------- /phantasm/timeline.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import time 6 | import argparse 7 | from pathlib import Path 8 | from typing import List, Dict, Callable, Any, Set 9 | 10 | from binaryninja import * 11 | from bncov import * 12 | 13 | 14 | # May want to override these defaults 15 | def default_get_seed_from_coverage_file(coverage_filepath: Path) -> Path: 16 | """Find a seed based on the coverage file's path. 17 | 18 | This example assumes a directory containing testcases is in the same parent 19 | directory as the coverage dir and that they are named the same except for 20 | a suffix of "-cov" for the coverage dir and a suffix of ".cov" for the 21 | coverage file (this is bncov's default naming scheme).""" 22 | 23 | coverage_dir = coverage_filepath.parent 24 | seed_dir = coverage_dir.parent / coverage_dir.name.replace("-cov", "") 25 | seed_path = seed_dir / coverage_filepath.stem 26 | return seed_path 27 | 28 | 29 | def default_get_metadata(coverage_filepath: Path) -> Dict[str, Any]: 30 | """Lookup/derive any metadata based on the seed and return it as a dict""" 31 | source = Path(coverage_filepath).parent 32 | # AFL structure: corpus dirs like "output/NAME/queue" 33 | if source.name.startswith("queue"): 34 | source = source.parent 35 | metadata = { 36 | "source": source.name, 37 | } 38 | return metadata 39 | 40 | 41 | class CoverageFile: 42 | def __init__( 43 | self, 44 | path: Path, 45 | block_coverage: Set[int], 46 | metadata: Dict[str, Any], 47 | timestamp: float, 48 | ): 49 | self.path = path 50 | self.block_coverage = block_coverage 51 | self.metadata = metadata 52 | self.timestamp = timestamp 53 | self.extra_blocks: Set[int] = set() 54 | 55 | 56 | class CoverageTimeline: 57 | """Houses all the relevant information about the progress of block coverage 58 | over time: timestamps and block coverage updates. 59 | 60 | This implementation uses a dictionary and tracks individual coverage files 61 | which is useful when doing analysis based on per-testcase metadata.""" 62 | 63 | def __init__(self, bv: binaryninja.BinaryView, covdbs: List[CoverageDB]): 64 | self.bv = bv 65 | self.covdbs = covdbs 66 | self.coverage_timeline: Dict[int, List[CoverageFile]] = {} 67 | self.block_times: Dict[int, int] = {} 68 | self.sorted_timestamps: List[int] = [] 69 | self.get_seed_from_coverage_file: Callable[[Path], Path] = default_get_seed_from_coverage_file 70 | self.get_metadata: Callable[[Path], Dict[str, Any]] = default_get_metadata 71 | 72 | def process_timeline(self): 73 | """Build up the coverage timeline as a dictionary of coverage deltas""" 74 | 75 | # get timestamps and metadata for all coverage so we can sort on it 76 | time_coverage_mapping: Dict[float, List[CoverageFile]] = {} 77 | for covdb in self.covdbs: 78 | for coverage_file, block_coverage in covdb.trace_dict.items(): 79 | if isinstance(coverage_file, bytes): 80 | coverage_file = coverage_file.decode('utf-8') 81 | coverage_filepath = Path(coverage_file) 82 | timestamp = self.get_timestamp(coverage_filepath) 83 | metadata = self.get_metadata(coverage_filepath) 84 | cur_coverage = CoverageFile( 85 | coverage_filepath, block_coverage, metadata, timestamp 86 | ) 87 | time_coverage_mapping.setdefault(timestamp, []).append(cur_coverage) 88 | sorted_coverage_files = sorted( 89 | time_coverage_mapping.items(), key=lambda kv: kv[0] 90 | ) 91 | 92 | # use the sorted list so we only store relevant deltas 93 | coverage_so_far: Set[int] = set() 94 | timestamp_zero = sorted_coverage_files[0][0] 95 | for timestamp, timestamp_coverage_list in sorted_coverage_files: 96 | for cur_coverage in timestamp_coverage_list: 97 | coverage_delta = cur_coverage.block_coverage - coverage_so_far 98 | extra_blocks = cur_coverage.block_coverage - coverage_delta 99 | if coverage_delta: 100 | # reuse instance, just reduce coverage to only the new blocks 101 | cur_coverage.block_coverage = coverage_delta 102 | cur_coverage.extra_blocks = extra_blocks 103 | # Not using sub-second accuracy; group by second 104 | time_delta = timestamp - timestamp_zero 105 | self.coverage_timeline.setdefault(int(time_delta), []).append( 106 | cur_coverage 107 | ) 108 | coverage_so_far.update(coverage_delta) 109 | for block_start in coverage_delta: 110 | self.block_times[block_start] = time_delta 111 | self.sorted_timestamps = sorted(self.coverage_timeline) 112 | 113 | def get_timestamp(self, coverage_filepath: Path) -> float: 114 | """Return a timestamp to establish a relative temporal order for testcases. 115 | 116 | This is a reasonable default, using timestamps from the testcases themselves. 117 | 118 | If you don't have the seeds with their original timestamps, you could do 119 | something like parse id numbers from AFL-style testcase names.""" 120 | 121 | timestamp = os.path.getmtime(self.get_seed_from_coverage_file(coverage_filepath)) 122 | return timestamp 123 | 124 | def print_coverage_over_time(self): 125 | """List the files and coverage added in time order""" 126 | for timestamp in self.sorted_timestamps: 127 | print(f"[*] At timestamp {timestamp}:") 128 | for coverage_file in self.coverage_timeline[timestamp]: 129 | filename = coverage_file.path.stem 130 | blocks_added = len(coverage_file.block_coverage) 131 | if len(self.covdbs) == 1: 132 | print(f' {blocks_added} new blocks from "{filename}"') 133 | else: 134 | source = coverage_file.metadata["source"] 135 | print(f' {source} added {blocks_added} blocks from "{filename}"') 136 | 137 | def show_function_steps(self): 138 | bv = self.bv 139 | func_steps = {} 140 | for timestamp in self.sorted_timestamps: 141 | for coverage_file in self.coverage_timeline[timestamp]: 142 | blocks_added = coverage_file.block_coverage 143 | funcs_seen = set() 144 | for block in blocks_added: 145 | funcs = bv.get_functions_containing(block) 146 | for f in funcs: 147 | name = f.name 148 | if name not in funcs_seen: 149 | funcs_seen.add(name) 150 | func_steps[name] = func_steps.get(name, 0) + 1 151 | for name, steps in sorted(func_steps.items(), key=lambda kv: kv[1], reverse=True): 152 | print(name, steps) 153 | 154 | def get_coverage_at_timestamp(self, timestamp: int): 155 | """Return blocks covered up to and including the time of the timestamp arg.""" 156 | coverage_so_far = set() 157 | for cur_timestamp in self.sorted_timestamps: 158 | if cur_timestamp > timestamp: 159 | break 160 | for cur_coveragefile in self.coverage_timeline[cur_timestamp]: 161 | coverage_so_far.update(cur_coveragefile.block_coverage) 162 | return coverage_so_far 163 | 164 | def print_total_coverage_delta(self, func=None): 165 | """Show difference between the initial coverage and coverage at the end""" 166 | first_timestamp = self.sorted_timestamps[0] 167 | initial_coverage = self.get_coverage_at_timestamp(first_timestamp) 168 | 169 | last_timestamp = self.sorted_timestamps[-1] 170 | final_coverage = self.get_coverage_at_timestamp(last_timestamp) 171 | 172 | if func is not None: 173 | func_blocks = [block.start for block in func.basic_blocks] 174 | initial_coverage = initial_coverage.intersection(func_blocks) 175 | final_coverage = final_coverage.intersection(func_blocks) 176 | print(f'Phantasm coverage delta for "{func.symbol.short_name}":') 177 | else: 178 | print('Phantasm coverage delta:') 179 | coverage_delta = final_coverage - initial_coverage 180 | 181 | print(f" First timestamp: {first_timestamp}, {len(initial_coverage)} blocks covered") 182 | print(f" Last timestamp: {last_timestamp}, {len(final_coverage)} blocks covered") 183 | print(f" Difference of {len(coverage_delta)} blocks") 184 | 185 | 186 | if __name__ == "__main__": 187 | parser = argparse.ArgumentParser() 188 | parser.add_argument("target", help="The target binary") 189 | parser.add_argument( 190 | "coverage_dirs", nargs="+", help="Directories containing coverage files" 191 | ) 192 | args = parser.parse_args() 193 | 194 | bv = make_bv(args.target, quiet=False) 195 | covdbs = [ 196 | make_covdb(bv, cur_dir) for cur_dir in args.coverage_dirs 197 | ] 198 | 199 | timeline = CoverageTimeline(bv, covdbs) 200 | print("[*] Processing timeline...", end="") 201 | timeline.process_timeline() 202 | timeline.print_coverage_over_time() 203 | #timeline.show_function_steps() 204 | -------------------------------------------------------------------------------- /phantasm/visualize.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Render an HTML visualization of coverage over time using SVG and Javascript. 3 | 4 | Based on the original idea from the export_svg example 5 | https://github.com/Vector35/binaryninja-api/blob/master/python/examples/export_svg.py 6 | ''' 7 | 8 | import sys 9 | from datetime import datetime 10 | 11 | from pathlib import Path 12 | 13 | from binaryninja import * 14 | 15 | 16 | escape_table = { 17 | "'": "'", 18 | ">": ">", 19 | "<": "<", 20 | '"': """, 21 | ' ': " " 22 | } 23 | 24 | 25 | def escape(toescape): 26 | # handle extended unicode 27 | toescape = toescape.encode('ascii', 'xmlcharrefreplace') 28 | # still escape the basics 29 | if sys.version_info[0] == 3: 30 | return ''.join(escape_table.get(chr(i), chr(i)) for i in toescape) 31 | else: 32 | return ''.join(escape_table.get(i, i) for i in toescape) 33 | 34 | 35 | def generate_css(function, timeline): 36 | 37 | css_path = Path(__file__).parent / 'anim.css' 38 | with open(css_path.as_posix(), 'r') as f: 39 | css_content = f.read() 40 | 41 | return css_content 42 | 43 | 44 | def generate_svg(name, graph): 45 | """Build SVG XML for the given function. 46 | 47 | NOTE: there's some unlabeled magic constants in here from the original 48 | example, I haven't yet taken time to experiment and name all of them. 49 | """ 50 | 51 | heightconst = 15 52 | ratio = 0.48 53 | widthconst = heightconst * ratio 54 | 55 | output = ''' 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | '''.format(width=graph.width * widthconst + 20, height=graph.height * heightconst + 20) 71 | 72 | output += f''' ''' 73 | edges = '' 74 | 75 | for i, block in enumerate(graph): 76 | # Calculate basic block location and coordinates 77 | x = ((block.x) * widthconst) 78 | y = ((block.y) * heightconst) 79 | width = ((block.width) * widthconst) 80 | height = ((block.height) * heightconst) 81 | 82 | # Render block 83 | bb_id = f'bb-{block.basic_block.start:x}' 84 | output += ' \n' 85 | # We're going to override block colors with .basicblock default and CSS animation, so we omit fill color for each block 86 | output += f' \n' 87 | 88 | output += ' \n'.format(x=x, y=y + (i + 1) * heightconst) 89 | for i, line in enumerate(block.lines): 90 | line_str = ' ' 91 | line_str = line_str.format(x=x + 6, y=y + 6 + (i + 0.7) * heightconst, address=hex(line.address)[:-1]) 92 | output += line_str 93 | for token in line.tokens: 94 | token_str = '{text}' 95 | token_str = token_str.format(text=escape(token.text), tokentype=InstructionTextTokenType(token.type).name) 96 | output += token_str 97 | output += '\n' 98 | output += ' \n' 99 | output += ' \n' 100 | 101 | # Edges are rendered in a separate chunk so they have priority over the 102 | # basic blocks or else they'd render below them 103 | for edge in block.outgoing_edges: 104 | points = "" 105 | x, y = edge.points[0] 106 | points += str(x * widthconst) + "," + \ 107 | str(y * heightconst + 12) + " " 108 | for x, y in edge.points[1:-1]: 109 | points += str(x * widthconst) + "," + \ 110 | str(y * heightconst) + " " 111 | x, y = edge.points[-1] 112 | points += str(x * widthconst) + "," + \ 113 | str(y * heightconst + 0) + " " 114 | if edge.back_edge: 115 | edges += ' \n'.format( 116 | type=BranchType(edge.type).name, points=points) 117 | else: 118 | edges += ' \n'.format( 119 | type=BranchType(edge.type).name, points=points) 120 | 121 | output += ' ' + edges + '\n' 122 | output += ' \n' 123 | output += '' 124 | 125 | return output 126 | 127 | 128 | def get_graph(func, show_opcodes, show_addresses): 129 | """Get a graph from the Binary Ninja Function object for rendering""" 130 | settings = DisassemblySettings() 131 | if show_opcodes: 132 | settings.set_option(DisassemblyOption.ShowOpcode, True) 133 | if show_addresses: 134 | settings.set_option(DisassemblyOption.ShowAddress, True) 135 | graph_type = FunctionGraphType.NormalFunctionGraph 136 | graph = func.create_graph(graph_type=graph_type, settings=settings) 137 | graph.layout_and_wait() 138 | 139 | return graph 140 | 141 | def generate_js(func, timeline): 142 | func_blocks = set(bb.start for bb in func.basic_blocks) 143 | timeline_array = [] 144 | array_index = 0 145 | for timestamp in timeline.sorted_timestamps: 146 | for coverage_file in timeline.coverage_timeline[timestamp]: 147 | seed_name = coverage_file.path.stem 148 | new_blocks = func_blocks.intersection(coverage_file.block_coverage) 149 | extra_blocks = func_blocks.intersection(coverage_file.extra_blocks) 150 | if len(new_blocks) == 0: 151 | continue 152 | blocks_added = '[' + ','.join(f'"bb-{block_addr:x}"' for block_addr in new_blocks) + ']' 153 | extra_block_str = '[' + ','.join(f'"bb-{block_addr:x}"' for block_addr in extra_blocks) + ']' 154 | js_seed_obj = ( 155 | f'{{ "name": "{seed_name}", "blocks": {blocks_added}, ' + 156 | f'"index": {array_index}, "time": {timestamp}, "extras": {extra_block_str} }}' 157 | ) 158 | timeline_array.append(js_seed_obj) 159 | array_index += 1 160 | 161 | js_str = 'let seeds = [' + ', '.join(timeline_array) + '];\n' 162 | 163 | anim_path = Path(__file__).parent / 'anim.js' 164 | with open(anim_path.as_posix()) as f: 165 | anim_str = f.read() 166 | js_str += anim_str 167 | 168 | return js_str 169 | 170 | 171 | def get_embedded_js(): 172 | js = '' 173 | cur_path = Path(__file__) 174 | 175 | svg_pan_zoom_path = cur_path.parent.parent / 'svg-pan-zoom' / 'svg-pan-zoom.min.js' 176 | with open(svg_pan_zoom_path.as_posix(), 'r') as f: 177 | svg_pan_zoom_js = f.read() 178 | js += svg_pan_zoom_js 179 | 180 | return js 181 | 182 | 183 | def generate_footer(func_name, filename): 184 | timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') 185 | description = f'Coverage of "{func_name}" from {filename} at {timestamp}.' 186 | 187 | phantasm_link = 'Phantasm by @mechanicalnull' 188 | bn_link = 'Binary Ninja' 189 | cred = f'Generated by {phantasm_link}, using {bn_link}.' 190 | 191 | footer = f'

{description} {cred}

' 192 | return footer 193 | 194 | 195 | def generate_html(bv, func, timeline, show_opcodes=False, show_addresses=False): 196 | """Build and save an HTML document showing coverage over time""" 197 | 198 | func_name = func.symbol.short_name 199 | 200 | css = generate_css(func, timeline) 201 | graph = get_graph(func, show_opcodes, show_addresses) 202 | svg = generate_svg(func_name, graph) 203 | embedded_js = get_embedded_js() 204 | js = generate_js(func, timeline) 205 | footer = generate_footer(func_name, os.path.basename(bv.file.original_filename)) 206 | 207 | content = f''' 208 | 209 | 210 | 211 | Phantasm {func_name} 212 | 215 | 218 | 221 | 222 | 223 | 236 |
237 | {svg} 238 |
239 | 242 | 243 | 244 | ''' 245 | return content 246 | -------------------------------------------------------------------------------- /svg-pan-zoom/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2009-2010 Andrea Leofreddi 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 18 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 21 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /svg-pan-zoom/svg-pan-zoom.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for svg-pan-zoom v3.5.0 2 | // Project: https://github.com/ariutta/svg-pan-zoom 3 | // Definitions by: César Vidril 4 | // Definitions: https://github.com/ariutta/svg-pan-zoom 5 | 6 | declare namespace SvgPanZoom { 7 | interface Options { 8 | /** 9 | * can be querySelector string or SVGElement (default enabled) 10 | * @type {string|HTMLElement|SVGElement} 11 | */ 12 | viewportSelector?: string|HTMLElement|SVGElement; 13 | /** 14 | * enable or disable panning (default enabled) 15 | * @type {boolean} 16 | */ 17 | panEnabled?: boolean; 18 | /** 19 | * insert icons to give user an option in addition to mouse events to control pan/zoom (default disabled) 20 | * @type {boolean} 21 | */ 22 | controlIconsEnabled?: boolean; 23 | /** 24 | * enable or disable zooming (default enabled) 25 | * @type {boolean} 26 | */ 27 | zoomEnabled?: boolean; 28 | /** 29 | * enable or disable zooming by double clicking (default enabled) 30 | * @type {boolean} 31 | */ 32 | dblClickZoomEnabled?: boolean; 33 | /** 34 | * enable or disable zooming by scrolling (default enabled) 35 | * @type {boolean} 36 | */ 37 | mouseWheelZoomEnabled?: boolean; 38 | /** 39 | * prevent mouse events to bubble up (default enabled) 40 | * @type {boolean} 41 | */ 42 | preventMouseEventsDefault?: boolean; 43 | zoomScaleSensitivity?: number; // Zoom sensitivity (Default 0.2) 44 | minZoom?: number; // Minimum Zoom level (Default 0.5) 45 | maxZoom?: number; // Maximum Zoom level (Default 10) 46 | fit?: boolean; // enable or disable viewport fit in SVG (default true) 47 | contain?: boolean; // (default true) 48 | center?: boolean; // enable or disable viewport centering in SVG (default true) 49 | refreshRate?: number | "auto"; // (default 'auto') 50 | beforeZoom?: (oldScale: number, newScale: number) => void | boolean; 51 | onZoom?: (newScale: number) => void; 52 | beforePan?: (oldPan: Point, newPan: Point) => void | boolean | PointModifier; 53 | onPan?: (newPan: Point) => void; 54 | onUpdatedCTM?: (newCTM: SVGMatrix) => void; 55 | customEventsHandler?: CustomEventHandler; // (default null) 56 | eventsListenerElement?: SVGElement; // (default null) 57 | } 58 | 59 | interface CustomEventHandler { 60 | init: (options: CustomEventOptions) => void; 61 | haltEventListeners: string[]; 62 | destroy: Function; 63 | } 64 | 65 | interface CustomEventOptions { 66 | svgElement: SVGSVGElement; 67 | instance: Instance; 68 | } 69 | 70 | interface Point { 71 | x: number; 72 | y: number; 73 | } 74 | 75 | interface PointModifier { 76 | x: number|boolean; 77 | y: number|boolean; 78 | } 79 | 80 | interface Sizes { 81 | width: number; 82 | height: number; 83 | realZoom: number; 84 | viewBox: { 85 | width: number; 86 | height: number; 87 | }; 88 | } 89 | 90 | interface Instance { 91 | /** 92 | * Creates a new SvgPanZoom instance with given element selector. 93 | * 94 | * @param {string|HTMLElement|SVGElement} svg selector of the tag on which it is to be applied. 95 | * @param {Object} options provides customization options at the initialization of the object. 96 | * @return {Instance} Current instance 97 | */ 98 | (svg: string|HTMLElement|SVGElement, options?: Options): Instance; 99 | 100 | /** 101 | * Enables Panning on svg element 102 | * @return {Instance} Current instance 103 | */ 104 | enablePan(): Instance; 105 | 106 | /** 107 | * Disables panning on svg element 108 | * @return {Instance} Current instance 109 | */ 110 | disablePan(): Instance; 111 | 112 | /** 113 | * Checks if Panning is enabled or not 114 | * @return {Boolean} true or false based on panning settings 115 | */ 116 | isPanEnabled(): boolean; 117 | 118 | setBeforePan(fn: (oldPoint: Point, newPoint: Point) => void | boolean | PointModifier): Instance; 119 | 120 | setOnPan(fn: (point: Point) => void): Instance; 121 | 122 | /** 123 | * Pan to a rendered position 124 | * 125 | * @param {Object} point {x: 0, y: 0} 126 | * @return {Instance} Current instance 127 | */ 128 | pan(point: Point): Instance; 129 | 130 | /** 131 | * Relatively pan the graph by a specified rendered position vector 132 | * 133 | * @param {Object} point {x: 0, y: 0} 134 | * @return {Instance} Current instance 135 | */ 136 | panBy(point: Point): Instance; 137 | 138 | /** 139 | * Get pan vector 140 | * 141 | * @return {Object} {x: 0, y: 0} 142 | * @return {Instance} Current instance 143 | */ 144 | getPan(): Point; 145 | 146 | resetPan(): Instance; 147 | 148 | enableZoom(): Instance; 149 | 150 | disableZoom(): Instance; 151 | 152 | isZoomEnabled(): boolean; 153 | 154 | enableControlIcons(): Instance; 155 | 156 | disableControlIcons(): Instance; 157 | 158 | isControlIconsEnabled(): boolean; 159 | 160 | enableDblClickZoom(): Instance; 161 | 162 | disableDblClickZoom(): Instance; 163 | 164 | isDblClickZoomEnabled(): boolean; 165 | 166 | enableMouseWheelZoom(): Instance; 167 | 168 | disableMouseWheelZoom(): Instance; 169 | 170 | isMouseWheelZoomEnabled(): boolean; 171 | 172 | setZoomScaleSensitivity(scale: number): Instance; 173 | 174 | setMinZoom(zoom: number): Instance; 175 | 176 | setMaxZoom(zoom: number): Instance; 177 | 178 | setBeforeZoom(fn: (oldScale: number, newScale: number) => void | boolean): Instance; 179 | 180 | setOnZoom(fn: (scale: number) => void): Instance; 181 | 182 | zoom(scale: number): void; 183 | 184 | zoomIn(): Instance; 185 | 186 | zoomOut(): Instance; 187 | 188 | zoomBy(scale: number): Instance; 189 | 190 | zoomAtPoint(scale: number, point: Point): Instance; 191 | 192 | zoomAtPointBy(scale: number, point: Point): Instance; 193 | 194 | resetZoom(): Instance; 195 | 196 | /** 197 | * Get zoom scale/level 198 | * 199 | * @return {float} zoom scale 200 | */ 201 | getZoom(): number; 202 | 203 | setOnUpdatedCTM(fn: (newCTM: SVGMatrix) => void): Instance; 204 | 205 | /** 206 | * Adjust viewport size (only) so it will fit in SVG 207 | * Does not center image 208 | * 209 | * @return {Instance} Current instance 210 | */ 211 | fit(): Instance; 212 | 213 | /** 214 | * Adjust viewport size (only) so it will contain in SVG 215 | * Does not center image 216 | * 217 | * @return {Instance} Current instance 218 | */ 219 | contain(): Instance; 220 | 221 | /** 222 | * Adjust viewport pan (only) so it will be centered in SVG 223 | * Does not zoom/fit image 224 | * 225 | * @return {Instance} Current instance 226 | */ 227 | center(): Instance; 228 | 229 | /** 230 | * Recalculates cached svg dimensions and controls position 231 | * 232 | * @return {Instance} Current instance 233 | */ 234 | resize(): Instance; 235 | 236 | /** 237 | * Get all calculate svg dimensions 238 | * 239 | * @return {Object} {width: 0, height: 0, realZoom: 0, viewBox: { width: 0, height: 0 }} 240 | */ 241 | getSizes(): Sizes; 242 | 243 | reset(): Instance; 244 | 245 | /** 246 | * Update content cached BorderBox 247 | * Use when viewport contents change 248 | * 249 | * @return {Instance} Current instance 250 | */ 251 | updateBBox(): Instance; 252 | 253 | destroy(): void; 254 | } 255 | } 256 | 257 | declare const svgPanZoom: SvgPanZoom.Instance; 258 | 259 | declare module "svg-pan-zoom" { 260 | export = svgPanZoom; 261 | } 262 | -------------------------------------------------------------------------------- /svg-pan-zoom/svg-pan-zoom.min.js: -------------------------------------------------------------------------------- 1 | // svg-pan-zoom v3.6.1 2 | // https://github.com/ariutta/svg-pan-zoom 3 | !function s(r,a,l){function u(e,t){if(!a[e]){if(!r[e]){var o="function"==typeof require&&require;if(!t&&o)return o(e,!0);if(h)return h(e,!0);var n=new Error("Cannot find module '"+e+"'");throw n.code="MODULE_NOT_FOUND",n}var i=a[e]={exports:{}};r[e][0].call(i.exports,function(t){return u(r[e][1][t]||t)},i,i.exports,s,r,a,l)}return a[e].exports}for(var h="function"==typeof require&&require,t=0;tthis.options.maxZoom*n.zoom&&(t=this.options.maxZoom*n.zoom/this.getZoom());var i=this.viewport.getCTM(),s=e.matrixTransform(i.inverse()),r=this.svg.createSVGMatrix().translate(s.x,s.y).scale(t).translate(-s.x,-s.y),a=i.multiply(r);a.a!==i.a&&this.viewport.setCTM(a)},i.prototype.zoom=function(t,e){this.zoomAtPoint(t,a.getSvgCenterPoint(this.svg,this.width,this.height),e)},i.prototype.publicZoom=function(t,e){e&&(t=this.computeFromRelativeZoom(t)),this.zoom(t,e)},i.prototype.publicZoomAtPoint=function(t,e,o){if(o&&(t=this.computeFromRelativeZoom(t)),"SVGPoint"!==r.getType(e)){if(!("x"in e&&"y"in e))throw new Error("Given point is invalid");e=a.createSVGPoint(this.svg,e.x,e.y)}this.zoomAtPoint(t,e,o)},i.prototype.getZoom=function(){return this.viewport.getZoom()},i.prototype.getRelativeZoom=function(){return this.viewport.getRelativeZoom()},i.prototype.computeFromRelativeZoom=function(t){return t*this.viewport.getOriginalState().zoom},i.prototype.resetZoom=function(){var t=this.viewport.getOriginalState();this.zoom(t.zoom,!0)},i.prototype.resetPan=function(){this.pan(this.viewport.getOriginalState())},i.prototype.reset=function(){this.resetZoom(),this.resetPan()},i.prototype.handleDblClick=function(t){var e;if((this.options.preventMouseEventsDefault&&(t.preventDefault?t.preventDefault():t.returnValue=!1),this.options.controlIconsEnabled)&&-1<(t.target.getAttribute("class")||"").indexOf("svg-pan-zoom-control"))return!1;e=t.shiftKey?1/(2*(1+this.options.zoomScaleSensitivity)):2*(1+this.options.zoomScaleSensitivity);var o=a.getEventPoint(t,this.svg).matrixTransform(this.svg.getScreenCTM().inverse());this.zoomAtPoint(e,o)},i.prototype.handleMouseDown=function(t,e){this.options.preventMouseEventsDefault&&(t.preventDefault?t.preventDefault():t.returnValue=!1),r.mouseAndTouchNormalize(t,this.svg),this.options.dblClickZoomEnabled&&r.isDblClick(t,e)?this.handleDblClick(t):(this.state="pan",this.firstEventCTM=this.viewport.getCTM(),this.stateOrigin=a.getEventPoint(t,this.svg).matrixTransform(this.firstEventCTM.inverse()))},i.prototype.handleMouseMove=function(t){if(this.options.preventMouseEventsDefault&&(t.preventDefault?t.preventDefault():t.returnValue=!1),"pan"===this.state&&this.options.panEnabled){var e=a.getEventPoint(t,this.svg).matrixTransform(this.firstEventCTM.inverse()),o=this.firstEventCTM.translate(e.x-this.stateOrigin.x,e.y-this.stateOrigin.y);this.viewport.setCTM(o)}},i.prototype.handleMouseUp=function(t){this.options.preventMouseEventsDefault&&(t.preventDefault?t.preventDefault():t.returnValue=!1),"pan"===this.state&&(this.state="none")},i.prototype.fit=function(){var t=this.viewport.getViewBox(),e=Math.min(this.width/t.width,this.height/t.height);this.zoom(e,!0)},i.prototype.contain=function(){var t=this.viewport.getViewBox(),e=Math.max(this.width/t.width,this.height/t.height);this.zoom(e,!0)},i.prototype.center=function(){var t=this.viewport.getViewBox(),e=.5*(this.width-(t.width+2*t.x)*this.getZoom()),o=.5*(this.height-(t.height+2*t.y)*this.getZoom());this.getPublicInstance().pan({x:e,y:o})},i.prototype.updateBBox=function(){this.viewport.simpleViewBoxCache()},i.prototype.pan=function(t){var e=this.viewport.getCTM();e.e=t.x,e.f=t.y,this.viewport.setCTM(e)},i.prototype.panBy=function(t){var e=this.viewport.getCTM();e.e+=t.x,e.f+=t.y,this.viewport.setCTM(e)},i.prototype.getPan=function(){var t=this.viewport.getState();return{x:t.x,y:t.y}},i.prototype.resize=function(){var t=a.getBoundingClientRectNormalized(this.svg);this.width=t.width,this.height=t.height;var e=this.viewport;e.options.width=this.width,e.options.height=this.height,e.processCTM(),this.options.controlIconsEnabled&&(this.getPublicInstance().disableControlIcons(),this.getPublicInstance().enableControlIcons())},i.prototype.destroy=function(){var e=this;for(var t in this.beforeZoom=null,this.onZoom=null,this.beforePan=null,this.onPan=null,(this.onUpdatedCTM=null)!=this.options.customEventsHandler&&this.options.customEventsHandler.destroy({svgElement:this.svg,eventsListenerElement:this.options.eventsListenerElement,instance:this.getPublicInstance()}),this.eventListeners)(this.options.eventsListenerElement||this.svg).removeEventListener(t,this.eventListeners[t],!this.options.preventMouseEventsDefault&&h);this.disableMouseWheelZoom(),this.getPublicInstance().disableControlIcons(),this.reset(),c=c.filter(function(t){return t.svg!==e.svg}),delete this.options,delete this.viewport,delete this.publicInstance,delete this.pi,this.getPublicInstance=function(){return null}},i.prototype.getPublicInstance=function(){var o=this;return this.publicInstance||(this.publicInstance=this.pi={enablePan:function(){return o.options.panEnabled=!0,o.pi},disablePan:function(){return o.options.panEnabled=!1,o.pi},isPanEnabled:function(){return!!o.options.panEnabled},pan:function(t){return o.pan(t),o.pi},panBy:function(t){return o.panBy(t),o.pi},getPan:function(){return o.getPan()},setBeforePan:function(t){return o.options.beforePan=null===t?null:r.proxy(t,o.publicInstance),o.pi},setOnPan:function(t){return o.options.onPan=null===t?null:r.proxy(t,o.publicInstance),o.pi},enableZoom:function(){return o.options.zoomEnabled=!0,o.pi},disableZoom:function(){return o.options.zoomEnabled=!1,o.pi},isZoomEnabled:function(){return!!o.options.zoomEnabled},enableControlIcons:function(){return o.options.controlIconsEnabled||(o.options.controlIconsEnabled=!0,s.enable(o)),o.pi},disableControlIcons:function(){return o.options.controlIconsEnabled&&(o.options.controlIconsEnabled=!1,s.disable(o)),o.pi},isControlIconsEnabled:function(){return!!o.options.controlIconsEnabled},enableDblClickZoom:function(){return o.options.dblClickZoomEnabled=!0,o.pi},disableDblClickZoom:function(){return o.options.dblClickZoomEnabled=!1,o.pi},isDblClickZoomEnabled:function(){return!!o.options.dblClickZoomEnabled},enableMouseWheelZoom:function(){return o.enableMouseWheelZoom(),o.pi},disableMouseWheelZoom:function(){return o.disableMouseWheelZoom(),o.pi},isMouseWheelZoomEnabled:function(){return!!o.options.mouseWheelZoomEnabled},setZoomScaleSensitivity:function(t){return o.options.zoomScaleSensitivity=t,o.pi},setMinZoom:function(t){return o.options.minZoom=t,o.pi},setMaxZoom:function(t){return o.options.maxZoom=t,o.pi},setBeforeZoom:function(t){return o.options.beforeZoom=null===t?null:r.proxy(t,o.publicInstance),o.pi},setOnZoom:function(t){return o.options.onZoom=null===t?null:r.proxy(t,o.publicInstance),o.pi},zoom:function(t){return o.publicZoom(t,!0),o.pi},zoomBy:function(t){return o.publicZoom(t,!1),o.pi},zoomAtPoint:function(t,e){return o.publicZoomAtPoint(t,e,!0),o.pi},zoomAtPointBy:function(t,e){return o.publicZoomAtPoint(t,e,!1),o.pi},zoomIn:function(){return this.zoomBy(1+o.options.zoomScaleSensitivity),o.pi},zoomOut:function(){return this.zoomBy(1/(1+o.options.zoomScaleSensitivity)),o.pi},getZoom:function(){return o.getRelativeZoom()},setOnUpdatedCTM:function(t){return o.options.onUpdatedCTM=null===t?null:r.proxy(t,o.publicInstance),o.pi},resetZoom:function(){return o.resetZoom(),o.pi},resetPan:function(){return o.resetPan(),o.pi},reset:function(){return o.reset(),o.pi},fit:function(){return o.fit(),o.pi},contain:function(){return o.contain(),o.pi},center:function(){return o.center(),o.pi},updateBBox:function(){return o.updateBBox(),o.pi},resize:function(){return o.resize(),o.pi},getSizes:function(){return{width:o.width,height:o.height,realZoom:o.getZoom(),viewBox:o.viewport.getViewBox()}},destroy:function(){return o.destroy(),o.pi}}),this.publicInstance};var c=[];e.exports=function(t,e){var o=r.getSvg(t);if(null===o)return null;for(var n=c.length-1;0<=n;n--)if(c[n].svg===o)return c[n].instance.getPublicInstance();return c.push({svg:o,instance:new i(o,e)}),c[c.length-1].instance.getPublicInstance()}},{"./control-icons":1,"./shadow-viewport":2,"./svg-utilities":5,"./uniwheel":6,"./utilities":7}],5:[function(t,e,o){var l=t("./utilities"),s="unknown";document.documentMode&&(s="ie"),e.exports={svgNS:"http://www.w3.org/2000/svg",xmlNS:"http://www.w3.org/XML/1998/namespace",xmlnsNS:"http://www.w3.org/2000/xmlns/",xlinkNS:"http://www.w3.org/1999/xlink",evNS:"http://www.w3.org/2001/xml-events",getBoundingClientRectNormalized:function(t){if(t.clientWidth&&t.clientHeight)return{width:t.clientWidth,height:t.clientHeight};if(t.getBoundingClientRect())return t.getBoundingClientRect();throw new Error("Cannot get BoundingClientRect for SVG.")},getOrCreateViewport:function(t,e){var o=null;if(!(o=l.isElement(e)?e:t.querySelector(e))){var n=Array.prototype.slice.call(t.childNodes||t.children).filter(function(t){return"defs"!==t.nodeName&&"#text"!==t.nodeName});1===n.length&&"g"===n[0].nodeName&&null===n[0].getAttribute("transform")&&(o=n[0])}if(!o){var i="viewport-"+(new Date).toISOString().replace(/\D/g,"");(o=document.createElementNS(this.svgNS,"g")).setAttribute("id",i);var s=t.childNodes||t.children;if(s&&0