├── .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 | 
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('%s' % 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('| %s' % item for item in row_data) + ' | 
'
526 |         report_html += table_row
527 | 
528 |     report_html += "
\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 = ''''
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 = '''        '
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