diff --git a/README.md b/README.md index 857a270..449cd2c 100644 --- a/README.md +++ b/README.md @@ -76,13 +76,22 @@ fvol serve -f memory.raw # prints a local URL with an access token

- - The web UI's overview of a Windows 11 image: evidence details, a process tree with lifelines, suggested first plugins and triage hints. + + The web UI's workspace for a Windows image: the file overview printed line by line, the plugin list with three malware plugins ticked and their options open, two runs with the per-plugin row counts, and triage hints.

-Plugin palette, million-row tables, process tree, hex viewer, run comparison, exports. Localhost -only, token-protected. [docs/web-ui.md](docs/web-ui.md) +

+ + + The results of a Windows Triage run: a tab per plugin with its row count, and the netscan table with its connections and listening sockets. + +

+ +Pick plugins or a preset, run them together, and read the results a tab per plugin: million-row +tables, one filter across all plugins, process trees, exports, every `fvol` option and your own +triage rules. Everything is saved in `~/.fvol` and comes back when the dump is reopened. +Localhost only, token-protected. [docs/web-ui.md](docs/web-ui.md) ## Supported images @@ -159,8 +168,7 @@ originals. [docs/architecture.md](docs/architecture.md#performance-techniques) ## License -Volatility Software License 1.0, as a port of Volatility 3 ([LICENSE.txt](LICENSE.txt)). The web UI -embeds JetBrains Mono (SIL OFL 1.1). +Volatility Software License 1.0, as a port of Volatility 3 ([LICENSE.txt](LICENSE.txt)). fastvol is built on the work of the Volatility Foundation and the volatility3 contributors. It is an independent project, not affiliated with or endorsed by the Volatility Foundation. diff --git a/bench/web/perf.py b/bench/web/perf.py index ad26a63..8b1e402 100644 --- a/bench/web/perf.py +++ b/bench/web/perf.py @@ -75,8 +75,8 @@ def ms(xs): print("== 42M-row plugin: windows.memmap (no --pid)") t = time.perf_counter() -r = req("POST", "/api/runs", {"plugin": "windows.memmap.Memmap"}) -rid = r["id"] +big = req("POST", "/api/batches", {"name": "memmap", "entries": [{"plugin": "windows.memmap.Memmap"}]}) +rid = big["runs"][0] first = None while True: s = req("GET", f"/api/runs/{rid}") @@ -115,22 +115,34 @@ def ms(xs): print("== browser (headless chromium)") from cdp import Chrome # noqa: E402 +small = req("POST", "/api/batches", {"name": "pslist", "entries": [{"plugin": "windows.pslist.PsList"}]}) +req("GET", f"/api/runs/{small['runs'][0]}/stream") +TABLE = "document.querySelector('#rs-body .vt')" + + +def open_run(bid): + """Open a run's results from the Runs panel and wait for its first rows.""" + c.eval(f"document.querySelector('.rn-head[data-key=\"b{bid}\"]').click()") + c.wait("document.querySelector('#rs-body .vt-row:not(.loading)')", 30) + c.pump(0.5) + + c = Chrome(1440, 900) try: c.goto(f"http://127.0.0.1:{PORT}/#token={TOKEN}", 1.5) - c.wait("document.querySelectorAll('.tnode').length > 0", 30) + c.wait("document.querySelector('.qs') || document.querySelector('.wb-panel')", 30) nav = c.eval("JSON.stringify(performance.getEntriesByType('navigation').map(e => [e.domContentLoadedEventEnd, e.loadEventEnd]))") print(f"app shell: DOMContentLoaded / load at {nav} ms after navigation start (after the login redirect)") - c.eval(f"location.hash = 'run/{rid}'") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 30) - c.pump(0.5) + c.key("Escape") + c.wait("document.querySelector('.rn-head')", 10) + open_run(big["id"]) res = c.eval("""(async () => { - const vt = document.querySelector('#panes > .pane:not([hidden]) .vt')._vt; + const vt = %s._vt; const sc = vt.scroll; const costs = []; const H = sc.scrollHeight; for (let i = 0; i < 120; i++) { - sc.scrollTop = (H * ((i * 37) % 120)) / 120; + sc.scrollTop = (H * ((i * 37) %% 120)) / 120; const t0 = performance.now(); vt.render(); document.body.getBoundingClientRect(); @@ -145,29 +157,30 @@ def ms(xs): const warm = performance.now() - t1; costs.sort((a, b) => a - b); return { median: costs[60], p95: costs[114], max: costs[119], warm, rows: vt.total }; - })()""") + })()""" % TABLE) print(f"scroll-jump render over {res['rows']:,} rows (JS + layout per frame): median {res['median']:.2f} ms, p95 {res['p95']:.2f} ms, max {res['max']:.2f} ms") res = c.eval("""(async () => { - const pane = document.querySelector('#panes > .pane:not([hidden])'); - const vt = pane.querySelector('.vt')._vt; - const q = pane.querySelector('input.q'); + const vt = %s._vt; + const q = document.querySelector('.rs-q'); const t0 = performance.now(); q.value = '0x7ff6'; q.dispatchEvent(new Event('input')); while (!(vt.view && vt.rowAt(0))) await new Promise(r => setTimeout(r, 5)); vt.render(); - return { ms: performance.now() - t0, rows: vt.total, view: vt.viewMs }; - })()""") + const out = { ms: performance.now() - t0, rows: vt.total, view: vt.viewMs }; + q.value = ''; + q.dispatchEvent(new Event('input')); + return out; + })()""" % TABLE) print(f"type a filter -> filtered rows on screen (incl. 180 ms debounce): {res['ms']:.0f} ms ({res['rows']:,} matching rows, server view {res['view']} ms)") - c.eval(f"location.hash = 'plugin/windows.pslist.PsList'") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 30) + open_run(small["id"]) res = c.eval("""(() => { - const vt = document.querySelector('#panes > .pane:not([hidden]) .vt')._vt; + const vt = %s._vt; const costs = []; for (let i = 0; i < 50; i++) { const t0 = performance.now(); vt.moveTo(i); void vt.scroll.offsetHeight; costs.push(performance.now() - t0); } costs.sort((a, b) => a - b); return { median: costs[25], max: costs[49] }; - })()""") + })()""" % TABLE) print(f"keyboard row move in a table (render + layout): median {res['median']:.2f} ms, max {res['max']:.2f} ms") if c.errors: print("JS errors:", c.errors) diff --git a/bench/web/serve.sh b/bench/web/serve.sh index f6f1103..3e92bf3 100755 --- a/bench/web/serve.sh +++ b/bench/web/serve.sh @@ -26,6 +26,10 @@ case $cmd in start) port=$3; img=$4; shift 4 stop + # a HOME of its own: test servers save their analyses there, never in the real ~/.fvol + # (the caches stay shared: XDG_CACHE_HOME points at the real ~/.cache) + mkdir -p "$dir/home" + XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" HOME="$dir/home" \ nohup "$ROOT/bench/scripts/limit.sh" -m 6G "$BIN" serve -f "$img" --port "$port" \ --token "testtoken-$name-0123456789" -o "$dir/out" "$@" > "$dir/serve.log" 2>&1 & for _ in $(seq 1 6000); do # limit.sh may wait for a free slot diff --git a/bench/web/shots.py b/bench/web/shots.py index 586c062..e2e79de 100644 --- a/bench/web/shots.py +++ b/bench/web/shots.py @@ -2,7 +2,9 @@ bench/scripts/limit.sh -m 4G python3 bench/web/shots.py [--port 18765] [--name win] [steps...] -Needs a server started with bench/web/serve.sh (token testtoken-NAME-0123456789).""" +Needs a server started with bench/web/serve.sh (token testtoken-NAME-0123456789). The `readme` +step writes the README's pictures (docs/assets/screenshots/web-ui-*.png); `--scrub DIR=NEW` +replaces a local path in the page before each picture, e.g. --scrub /home/me/images=/cases.""" import argparse import json @@ -18,6 +20,7 @@ ap.add_argument("--name", default="win") ap.add_argument("--out", default=os.path.join(DATA, "testdata/scratch/webui/shots")) ap.add_argument("--size", default="1440x900") +ap.add_argument("--scrub", action="append", default=[], metavar="DIR=NEW", help="show NEW instead of DIR in the pictures") ap.add_argument("steps", nargs="*") a = ap.parse_args() os.makedirs(a.out, exist_ok=True) @@ -30,6 +33,7 @@ def shot(name): + c.eval("document.getElementById('toasts').replaceChildren()") p = os.path.join(a.out, f"{a.name}-{name}.png") c.shot(p) print("shot", p) @@ -45,223 +49,192 @@ def deco(fn): STEPS = [] -def login(hash_=""): +def login(): c.goto(f"{BASE}/?r={time.time()}#token={TOKEN}", 1.0) - if hash_: - c.eval(f"location.hash = {json.dumps(hash_.lstrip('#'))}") - c.wait("document.querySelector('.tab')", 20) + c.wait("document.querySelector('.qs') || document.querySelector('.wb-panel')", 20) + c.pump(0.4) -def ready(): - c.wait("document.querySelectorAll('.tnode').length > 0 || document.querySelector('.tree-empty')", 30) - c.pump(0.4) +def workspace(): + """Past the Quick Start, results closed, nothing ticked.""" + if c.eval("!!document.querySelector('.qs')"): + c.key("Escape") + if c.eval("document.body.classList.contains('results-open')"): + c.key("Escape") + c.eval("document.getElementById('pl-tab-select').click()") + c.wait("document.querySelector('.wb-plugins .pl-row')", 20) + c.pump(0.3) + + +def theme(t): + c.eval(f"localStorage.setItem('fastvol.theme', '{t}'); document.documentElement.setAttribute('data-theme', '{t}')") + c.pump(0.3) -@step("overview") -def overview(): +def scrub(): + """Local paths (and notifications) out of a picture (the page shows real paths).""" + c.eval("document.getElementById('toasts').replaceChildren()") + pairs = json.dumps([x.split("=", 1) for x in a.scrub]) + c.eval(f"""(() => {{ const map = {pairs}; + const fix = s => {{ for (const [f, t] of map) s = s.split(f).join(t); return s; }}; + const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + let n; while ((n = w.nextNode())) {{ const v = fix(n.nodeValue); if (v !== n.nodeValue) n.nodeValue = v; }} + for (const e of document.querySelectorAll('[title]')) e.title = fix(e.title); }})()""") + + +def start_run(name): + """Run the built-in triage preset for the image's OS (else the first one); returns its name.""" + c.eval("document.getElementById('pl-tab-presets').click()") + c.wait("document.querySelector('.ps-row')", 10) + pick = "([...document.querySelectorAll('.ps-row')].find(r => /Triage/.test(r.textContent)) || document.querySelector('.ps-row'))" + preset = c.eval(f"{pick}.querySelector('.ps-name').textContent") + c.eval(f"{pick}.click()") + c.wait("!document.getElementById('pl-run').hidden", 10) + c.eval("document.getElementById('pl-run').click()") + c.wait("document.body.classList.contains('results-open')", 20) + # every plugin of the run finished + c.wait("document.querySelectorAll('.rs-tab').length > 0 && ![...document.querySelectorAll('.rs-tab .rs-dot')].some(d => d.classList.contains('running') || d.classList.contains('queued'))", 300) + c.pump(0.6) + return preset + + +@step("quickstart") +def quickstart(): login() - ready() - c.pump(0.8) - shot("01-overview") - - -@step("process") -def process(): - # the process with the most children that is still running: usually explorer / services - pid = c.eval("import('/assets/core.js').then(m => { const ps = (m.store.procs||[]).filter(p => p.exit === null && p.name); ps.sort((a,b) => b.kids.length - a.kids.length); const e = ps.find(p => /explorer|systemd|launchd|bash|sshd/i.test(p.name)) || ps[0]; return e ? e.pid : null; })") - c.eval(f"location.hash = 'proc/{pid}'") - c.wait("document.querySelector('.pv-head h2')", 10) - c.wait("document.querySelector('.subpanes .vt-row:not(.loading)') || document.querySelector('.subpanes .errcard') || document.querySelector('.subpanes .vt-empty:not([hidden])')", 30) - c.pump(1.0) - shot("02-process") - - -@step("network") -def network(): - pid = c.eval("import('/assets/core.js').then(m => { const p = (m.store.procs||[]).find(p => /svchost|explorer|sshd|systemd-resolve/i.test(p.name) && p.exit === null); return p ? p.pid : 4; })") - c.eval(f"location.hash = 'proc/{pid}'") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .subtab')", 10) - ok = c.eval("(() => { const b = [...document.querySelectorAll('#panes > .pane:not([hidden]) .subtab')].find(b => /Network|Sockets/.test(b.textContent) && !b.disabled); if (b) b.click(); return !!b; })()") - if ok: - c.wait("document.querySelector('#panes > .pane:not([hidden]) .subpanes > .pane:not([hidden]) .vt-row:not(.loading)') || document.querySelector('#panes > .pane:not([hidden]) .subpanes > .pane:not([hidden]) .vt-empty:not([hidden])')", 60) + shot("01-quickstart") + c.key("2") + c.wait("document.querySelector('.qs-file')", 10) + c.pump(0.4) + shot("02-quickstart-open") + c.key("3") c.pump(0.8) - shot("02b-process-network") + shot("03-quickstart-previous") -@step("palette") -def palette(): - c.key("k", mods=2) - c.wait("document.querySelector('.palette')", 5) - c.type("mal") - c.pump(0.4) - shot("03-palette") - c.key("Tab") - c.pump(0.3) - shot("03b-palette-form") - c.key("Escape") - c.pump(0.2) +@step("workspace") +def workspace_step(): + login() + workspace() + shot("04-workspace") -@step("results") -def results(): - c.eval("import('/assets/core.js').then(m => { location.hash = 'plugin/' + m.store.session.os + '.pslist.PsList'; })") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 20) +@step("select") +def select(): + workspace() + c.eval("document.getElementById('pl-tab-select').click()") + c.eval("(q => { q.value = 'ps'; q.dispatchEvent(new Event('input')); })(document.querySelector('#plugins input[type=search]'))") c.pump(0.3) - # sort by CreateTime descending via keyboard: focus table, move to column, press s twice - c.eval("document.querySelector('#panes > .pane:not([hidden]) .vt').focus()") - for _ in range(8): - c.key("ArrowRight") - c.key("s") - c.pump(0.2) - c.key("s") - c.pump(0.5) - c.key("ArrowDown") - c.key("ArrowDown") - c.key("Enter") - c.pump(0.5) - shot("04-results-drawer") + c.eval("document.querySelectorAll('.pl-row')[0].click()") + c.eval("document.querySelectorAll('.pl-row')[1].click()") + c.wait("!document.getElementById('pl-run').hidden", 5) + c.pump(0.3) + shot("05-select") + c.eval("(q => { q.value = ''; q.dispatchEvent(new Event('input')); })(document.querySelector('#plugins input[type=search]'))") -@step("filescan") -def filescan(): - c.eval("location.hash = 'plugin/windows.filescan.FileScan'") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 60) - c.pump(0.5) - c.key("/") - c.type("\\users\\") - c.pump(0.8) - c.eval("document.querySelector('#panes > .pane:not([hidden]) .vt').focus()") - c.key("f") +@step("presets") +def presets(): + workspace() + c.eval("document.getElementById('pl-tab-presets').click()") + c.wait("document.querySelector('.ps-row')", 10) c.pump(0.3) - shot("05-filescan-filter") - - -@step("hex") -def hexview(): - off = c.eval("import('/assets/core.js').then(m => { const p = (m.store.procs||[]).find(p => typeof p.offset === 'string'); return p ? p.offset : '0x0'; })") - c.eval(f"location.hash = 'hex/kernel/{off}'") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .hx-row b')", 10) - c.pump(1.0) - c.eval("document.querySelector('#panes > .pane:not([hidden]) .hx-grid').focus()") - for k in ["ArrowRight"] * 8: - c.key(k) - c.key("d") - c.pump(1.0) - shot("06-hex") - - -@step("compare") -def compare(): - ids = c.eval("import('/assets/core.js').then(m => [...m.store.runs.values()].filter(r => r.status === 'done').map(r => [r.id, r.plugin]))") - by = {p: i for i, p in ids} - a_id = by.get("windows.pslist.PsList") - run = c.eval("import('/assets/core.js').then(m => m.runPlugin('windows.psscan.PsScan', {}, {reuse: true}).then(r => r.id))") - c.eval(f"import('/assets/core.js').then(m => m.whenDone({run}))") - c.eval(f"location.hash = 'compare/{a_id}/{run}'") - c.wait("document.querySelectorAll('.cmp-side .vt-row:not(.loading)').length > 2", 20) - c.pump(0.8) - shot("07-compare") + shot("06-presets") -@step("timeline") -def timeline(): - c.eval("import('/assets/core.js').then(m => { location.hash = 'plugin/' + m.store.session.os + '.pslist.PsList'; })") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 20) - c.eval("[...document.querySelectorAll('#panes > .pane:not([hidden]) .rv-bar button')].find(b => b.textContent === 'Timeline').click()") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .hist svg')", 10) +@step("results") +def results(): + workspace() + start_run("results") + shot("07-results") + # a tree (pstree, or the first plugin with nested rows) + tree = c.eval("[...document.querySelectorAll('.rs-tab')].findIndex(t => /pstree/.test(t.textContent))") + if tree >= 0: + c.eval(f"document.querySelectorAll('.rs-tab')[{tree}].click()") + c.wait("document.querySelector('.vt-row:not(.loading)')", 20) + c.pump(0.6) + shot("08-results-tree") + c.eval("(q => { q.value = 'svchost'; q.dispatchEvent(new Event('input')); })(document.querySelector('.rs-q'))") + c.pump(1.2) + shot("09-results-filter") + c.eval("(q => { q.value = ''; q.dispatchEvent(new Event('input')); })(document.querySelector('.rs-q'))") c.pump(0.5) - shot("08-timeline") -@step("timeliner") -def timeliner(): - c.eval("location.hash = 'plugin/timeliner.Timeliner'") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .hist svg rect')", 120) - c.pump(0.8) - shot("15-timeliner") - # brush the busiest stretch: drag across the middle fifth of the chart - # brush around the busiest bar: the burst an analyst would zoom into - box = c.eval("""(() => { const svg = document.querySelector('#panes > .pane:not([hidden]) .hist svg'); const r = svg.getBoundingClientRect(); - let best = null; for (const b of svg.querySelectorAll('rect.b')) { const q = b.getBoundingClientRect(); if (!best || q.height > best.height) best = q; } - return [r.left, r.top, r.width, r.height, best.left + best.width / 2]; })()""") - y = box[1] + box[3] / 2 - x0, x1 = max(box[0] + 1, box[4] - 12), box[4] + 12 # may run past the edge: the brush clamps - c.send("Input.dispatchMouseEvent", {"type": "mousePressed", "x": x0, "y": y, "button": "left", "clickCount": 1}) - for k in range(1, 11): - c.send("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x0 + (x1 - x0) * k / 10, "y": y, "button": "left"}) - c.send("Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x1, "y": y, "button": "left", "clickCount": 1}) - c.pump(1.5) - c.send("Input.dispatchMouseEvent", {"type": "mouseMoved", "x": box[0] + box[2] * 0.5, "y": box[1] + box[3] - 4}) - c.pump(0.5) - shot("16-timeliner-range") +@step("options") +def options(): + workspace() + c.eval("document.getElementById('wb-options').click()") + c.wait("document.querySelectorAll('.op-row').length > 10", 10) + c.pump(0.3) + shot("10-options") + c.key("Escape") @step("light") def light(): - c.eval("localStorage.setItem('fastvol.theme', 'light')") - login() - ready() - c.pump(0.8) - shot("09-overview-light") - pid = c.eval("import('/assets/core.js').then(m => { const ps = (m.store.procs||[]).filter(p => p.exit === null && p.name); ps.sort((a,b) => b.kids.length - a.kids.length); return ps[0] ? ps[0].pid : null; })") - c.eval(f"location.hash = 'proc/{pid}'") - c.wait("document.querySelector('.subpanes .vt-row:not(.loading)') || document.querySelector('.subpanes .errcard')", 30) - c.pump(0.8) - shot("10-process-light") - c.eval("localStorage.setItem('fastvol.theme', 'dark')") + workspace() + theme("light") + shot("11-workspace-light") + theme("dark") @step("laptop") def laptop(): - c.resize(1280, 720) - login() - ready() - c.eval("import('/assets/core.js').then(m => { location.hash = 'plugin/' + m.store.session.os + '.pslist.PsList'; })") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .vt-row:not(.loading)')", 20) - c.pump(0.6) - shot("11-laptop-1280") + workspace() + c.resize(1280, 800) + c.pump(0.5) + shot("12-laptop-1280") + c.eval("document.getElementById('wb-ovbtn').click()") + c.pump(0.4) + shot("13-laptop-overview") + c.eval("document.getElementById('wb-ovbtn').click()") c.resize(W, H) - - -@step("error") -def error(): - other = c.eval("import('/assets/core.js').then(m => m.store.session.os === 'windows' ? 'linux.pslist.PsList' : 'windows.info.Info')") - c.eval(f"location.hash = 'plugin/{other}'") - c.wait("document.querySelector('#panes > .pane:not([hidden]) .errcard')", 20) c.pump(0.4) - shot("13-error") -@step("nosession") -def nosession(): - login() - c.wait("document.querySelector('.evcard') || document.querySelector('.openbox')", 10) - c.pump(1.0) - shot("14-session") - - -@step("openimage") -def openimage(): - c.eval("document.getElementById('evidence').click()") - c.wait("document.querySelector('.dialog .fslist button')", 10) - c.eval("(() => { const i = document.querySelector('.dialog input'); i.value = %s; i.dispatchEvent(new Event('input')); })()" - % json.dumps(DATA + "/testdata/images/")) - c.pump(1.0) - shot("17-open-image") - c.key("Escape") - - -@step("help") -def helpstep(): - c.key("?") - c.pump(0.3) - shot("12-help") - c.key("Escape") +@step("readme") +def readme(): + """The README's pictures: 1440x780, workspace with plugins ticked and a run open, results.""" + c.resize(1440, 780) + for t in ("dark", "light"): + theme(t) + c.eval("for (const k of Object.keys(localStorage)) if (k.startsWith('fastvol.cols:') || k === 'fastvol.layout') localStorage.removeItem(k)") + login() + workspace() + if not c.eval("document.querySelector('.rn-head')"): + start_run("readme") + workspace() + head = "document.querySelector('.rn-head')" + if not c.eval(f"{head}.parentElement.classList.contains('open')"): + c.eval(f"{head}.querySelector('.rn-caret').click()") + c.eval("document.getElementById('pl-tab-select').click()") + c.eval("(q => { q.value = 'malware'; q.dispatchEvent(new Event('input')); })(document.querySelector('#plugins input[type=search]'))") + c.pump(0.3) + c.eval("[...document.querySelectorAll('.pl-row')].filter(r => /malfind|hollowprocesses|ldrmodules/.test(r.title)).forEach(r => r.click())") + c.eval("document.querySelector('.pl-list').scrollTop = 0; document.activeElement.blur()") + c.pump(0.6) + scrub() + c.shot(os.path.join(a.out, f"web-ui-workspace-{t}.png")) + c.eval(f"{head}.click()") + c.pump(0.2) + if not c.eval("document.body.classList.contains('results-open')"): + c.eval(f"{head}.click()") + c.wait("document.body.classList.contains('results-open')", 10) + # the tab with the most rows makes the fullest table + c.eval("(ts => ts.sort((x, y) => (+y.querySelector('.rs-badge').textContent.replace(/,/g, '') || 0) - (+x.querySelector('.rs-badge').textContent.replace(/,/g, '') || 0))[0].click())([...document.querySelectorAll('.rs-tab')])") + c.pump(1.5) + c.eval("document.activeElement.blur()") + scrub() + c.shot(os.path.join(a.out, f"web-ui-results-{t}.png")) + c.eval("(q => { q.value = ''; q.dispatchEvent(new Event('input')); })(document.querySelector('#plugins input[type=search]'))") + c.key("Escape") + print("readme pictures", t) + theme("dark") + c.resize(W, H) try: login() - ready() for name, fn in STEPS: if a.steps and name not in a.steps: continue diff --git a/docs/assets/screenshots/web-ui-memory.png b/docs/assets/screenshots/web-ui-memory.png deleted file mode 100644 index 4438118..0000000 Binary files a/docs/assets/screenshots/web-ui-memory.png and /dev/null differ diff --git a/docs/assets/screenshots/web-ui-overview-dark.png b/docs/assets/screenshots/web-ui-overview-dark.png deleted file mode 100644 index 6aab556..0000000 Binary files a/docs/assets/screenshots/web-ui-overview-dark.png and /dev/null differ diff --git a/docs/assets/screenshots/web-ui-overview-light.png b/docs/assets/screenshots/web-ui-overview-light.png deleted file mode 100644 index 650be29..0000000 Binary files a/docs/assets/screenshots/web-ui-overview-light.png and /dev/null differ diff --git a/docs/assets/screenshots/web-ui-process.png b/docs/assets/screenshots/web-ui-process.png deleted file mode 100644 index 2cec76c..0000000 Binary files a/docs/assets/screenshots/web-ui-process.png and /dev/null differ diff --git a/docs/assets/screenshots/web-ui-results-dark.png b/docs/assets/screenshots/web-ui-results-dark.png new file mode 100644 index 0000000..e614755 Binary files /dev/null and b/docs/assets/screenshots/web-ui-results-dark.png differ diff --git a/docs/assets/screenshots/web-ui-results-light.png b/docs/assets/screenshots/web-ui-results-light.png new file mode 100644 index 0000000..910a329 Binary files /dev/null and b/docs/assets/screenshots/web-ui-results-light.png differ diff --git a/docs/assets/screenshots/web-ui-workspace-dark.png b/docs/assets/screenshots/web-ui-workspace-dark.png new file mode 100644 index 0000000..ff9be39 Binary files /dev/null and b/docs/assets/screenshots/web-ui-workspace-dark.png differ diff --git a/docs/assets/screenshots/web-ui-workspace-light.png b/docs/assets/screenshots/web-ui-workspace-light.png new file mode 100644 index 0000000..2645f6a Binary files /dev/null and b/docs/assets/screenshots/web-ui-workspace-light.png differ diff --git a/docs/web-ui.md b/docs/web-ui.md index 656eb14..072ad3d 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -1,94 +1,105 @@ # The web UI: `fvol serve` -`fvol serve` runs a small web server inside the `fvol` binary and serves an analysis workspace for -one memory image. It is part of fastvol only; python volatility3 has no equivalent command. +`fvol serve` runs a small web server inside the `fvol` binary and serves an analysis workspace in +the browser: open a memory dump, run sets of plugins, read their results and keep all of it for +next time. It is part of fastvol only; python volatility3 has no equivalent command. This page has two parts. The [tutorial](#tutorial-a-first-session) walks through a first -session. The [reference](#reference) lists the options, keyboard shortcuts, HTTP API and the -security model. +session. The [reference](#reference) lists the options, the saved files, the keyboard +shortcuts, the HTTP API and the security model. Applies to fastvol 0.1.0. ## Tutorial: a first session -In this tutorial you open a Windows image in the browser, look at its processes, inspect one -process, read memory at an address and export a result. You need a built `fvol` binary, a Windows -memory image and a browser on the same machine. Linux and macOS images work the same way once you -add their symbol directory with `-s`. +In this tutorial you open a Windows image, run a preset, read the results, save your own preset +and come back to the analysis later. You need a built `fvol` binary, a memory image and a browser +on the same machine. Linux and macOS images work the same way once their symbol directory is set +in **Options** (or given with `-s`). ### 1. Start the server ```bash -fvol serve -f +fvol serve ``` ```text fastvol web UI · Volatility 3 Framework 2.28.2 - image /cases/memory-dirty.raw (5.0 GiB) + image (none yet: open one from the UI) output /cases/vol-serve-output open http://127.0.0.1:8765/#token=1c5fa1176bd21e349985b1160bfc6ac6 Anyone with this URL can read the image. Press Ctrl+C to stop. ``` -The server starts analysing the image right away: it maps the file, finds the kernel and loads -its symbols in the background. - -### 2. Open the workspace - Copy the `open` URL into your browser. The page stores the token and removes it from the address bar. -The overview tab shows what fastvol found: the operating system, kernel base, DTB, the symbol file -and the layers of the image. Next to it are quick actions for the plugins most analysts run first. -On the left is the process tree. +### 2. Open a memory image + +The page starts with the **Quick Start**: + +- **Continue** takes the image `fvol serve -f` was given. +- **New analysis** browses the server's folders. Memory images are tagged *image*; double-click + one, or press **Open File…** to pick it in your desktop's own file dialog. +- **Previous** lists the dumps you analysed before (see step 7). -### 3. Explore the process tree +Press `Esc` to go straight to the workspace. -Each process in the tree has a lifeline on the time axis of the capture, from its creation to its -exit or to the capture time. Processes that look out of place carry a hint, such as an unexpected -parent, a second instance of a process that is normally unique, or a process started shortly -before the capture. +### 3. Read the workspace -Press `T` and type `svchost` to filter the tree. Use the arrow keys to move and press `Enter` on -a process to open its process view. +The workspace has four panels, each resizable by dragging the lines between them +(double-click a line to reset it): -### 4. Look at one process +- **File Overview** on the left prints what fastvol found about the image, line by line: path, + size, operating system, capture time, symbols, and the kernel facts of `windows.info`. +- **Plugins** in the middle is where plugins are chosen. +- **Runs** at the top right lists the runs of this image. +- **Triage Hints** at the bottom right flags processes worth a look: unexpected parents, + duplicated singletons, processes started shortly before capture. **Load Rules…** adds your + own checks from a JSON file ([rules](#triage-rules)). -The process view shows the process's parent, children, lifetime and command line, and has a tab -for each plugin that can be limited to one PID: handles, DLLs, memory regions, environment, -network, threads, injected code and more. Open a tab to run its plugin for this process only. +In a narrow window the File Overview hides first; the **File Overview** button in the top bar +shows it again. -In any table, move with the arrow keys, press `Enter` for the details of a row, `S` to sort by -the current column and `F` to filter it. Filters accept text, `=exact`, `!not`, comparisons such -as `>0x10` and `/regex/`. +### 4. Run a preset -### 5. Run any plugin +Click **Presets** in the Plugins header and choose **Windows Triage**. Its plugins are ticked +under **Select**, where you can untick some or tick more. A ticked plugin with options unfolds +them below it: checkboxes for flags such as `--dump`, boxes for values such as `--pid`. -Press `Ctrl+K` to open the plugin palette, type `netscan` and choose `windows.netscan.NetScan`. -Plugins with options show a form first; integer fields accept python syntax such as `0x1000`, and -PID fields offer the process list. +Press **Run**. All the ticked plugins start together as one run, named after the preset, a few +at a time. The run appears under **Runs** with its progress, and its results replace the File +Overview and Plugins panels. -The run appears in the history rail. Rows stream in while the plugin runs, and a large result -stays fast because the browser holds only the rows on screen. +### 5. Read the results -### 6. Read memory at an address +The results panel, *Windows Triage: Results*, has a tab per plugin with its status and row count. +Step through them with `]` and `[`, or `Ctrl+→` and `Ctrl+←`. -In a result with addresses, put the cursor on an address column and press `H`. The memory viewer -opens at that address. It shows a hex dump with a data inspector. Press `Enter` on a pointer to -follow it, `Backspace` to go back and `D` to disassemble from the cursor. +- **Filter rows** filters the rows of every plugin of the run: type `svchost` once, then step + through the tabs. Each run keeps its own filter. +- **Filters** adds a filter box per column: `text`, `=exact`, `!not`, `>0x10`, `/regex/`. +- Click a column header to sort, drag it sideways to move the column, drag its right edge to + resize it and double-click that edge to fit the column to its values. +- Plugins that nest processes, such as `pstree`, draw their tree in the first column. +- **Export** downloads the rows shown as CSV, TSV, JSON, JSON Lines or Markdown, or as + **fvol output**: exactly what `fvol` prints for the plugin. -### 7. Export a result +Press `Esc` or **×** to go back to the File Overview and Plugins. Click a run under **Runs** to +open its results again; click it once more to fold it. -Use the export menu of a result tab to download the visible columns of the current view as CSV, -TSV, JSON, JSON Lines or Markdown. The `fvol -r ...` entries download what the command line prints -for the same plugin and options instead. Files that a plugin wrote, such as dumped executables, -are listed with the run and can be downloaded one by one or as a zip. +### 6. Save a preset -Stop the server with `Ctrl+C`. The results of this session are gone, but the files plugins wrote -stay in `vol-serve-output/`, and the fastvol caches make the next session on the same image start -immediately. +Under **Select**, tick the plugins you want and press **Save Preset**. Name it; it is saved as +`~/.fvol/presets/.json` and listed under **Presets** for every image from then on. -For more, read the [reference](#reference) below, and [usage.md](usage.md) for symbol setup. +### 7. Come back later + +Stop the server with `Ctrl+C`. Everything you did is already saved: the runs, their results and +filters, the options and the rules. Start `fvol serve` again, choose **Previous** in the Quick +Start and pick the dump. The fastvol caches make the image open immediately, and the results +come back without running any plugin again. To delete an analysis, press **Delete** next to it +in **Previous**. ## Reference @@ -102,11 +113,11 @@ fvol serve [-h] [-f FILE] [--host HOST] [--port PORT] [-s SYMBOL_DIRS] [-o OUTPU | Option | Default | Meaning | | ------------------------ | -------------------- | ------------------------------------------------------------------------ | -| `-f, --file FILE` | none | Image to open. Without it, open one from the UI. | +| `-f, --file FILE` | none | Image to open. Without it, open one from the Quick Start. | | `--host HOST` | `127.0.0.1` | IP address to listen on. `localhost` means `127.0.0.1`. | | `--port PORT` | 8765 | Port. Without the option, the first free port from 8765 to 8784 is used, then any free port. `0` means any free port. | -| `-s, --symbol-dirs DIRS` | none | Semicolon-separated symbol directories, as for `fvol`. | -| `-o, --output-dir DIR` | `./vol-serve-output` | Root of the per-run output directories. | +| `-s, --symbol-dirs DIRS` | none | Semicolon-separated symbol directories, as for `fvol`. Like `--offline`, `-u` and `--cache-path`, it wins over the options saved with an analysis. | +| `-o, --output-dir DIR` | `./vol-serve-output` | Root of the per-run output directories. Given, it wins over an output folder saved with an analysis. | | `--offline` | off | Never download symbols. | | `-u, --remote-isf-url URL` | none | Remote symbol file list, as for `fvol`. | | `--cache-path PATH` | python's default | python volatility3 cache path, as for `fvol`. | @@ -116,44 +127,102 @@ fvol serve [-h] [-f FILE] [--host HOST] [--port PORT] [-s SYMBOL_DIRS] [-o OUTPU | `--parallel N` | 3 | Plugins that may run at the same time, between 1 and 64. | | `--max-memory SIZE` | `3G` | Memory for stored result rows across all runs, such as `2G` or `512M`. Rows past the budget are counted but not kept; exports through the `fvol` renderer stay complete. | +### Options + +**Options** in the top bar sets the global options of `fvol`. Tick an option to use it; each does +what it does on the command line, applied where the web UI has the same step. The options are +saved with the analysis. + +| Options | Applied | +| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `-s`, `--offline`, `-u`, `--cache-path`, `--single-location`, `--stackers`, `--single-swap-locations`, `-v`, `-o` | When the image is opened. A change reopens the image; its runs stay. | +| `--clear-cache` | Once, when the image reopens. It empties `~/.cache/fastvol` only, never `~/.fvol`. | +| `-c`, `-e` | To each plugin started afterwards: values for options the plugin leaves unset. `-e automagic.*` keys act when the image is opened. | +| `--write-config`, `--save-config NAME` | Each run writes its configuration, as `fvol` writes it, into its output folder (`config.json`, or `NAME`). | +| `-l FILE` | A line per run started, finished or failed. `FILE` must end in `.log`. | +| `--parallelism off` | One plugin at a time instead of `--parallel N`. | +| `-r`, `--filters`, `--hide-columns` | The **fvol output** export. | +| `-q`, `-p` | No effect: the web UI has no console progress, and fastvol does not load python plugins. | + +### Saved files + +```text +~/.fvol/-metadata.json one analysis: the dump's path, size and modification time, + the options, the rules file, every run and its plugins +~/.fvol///.jsonl the rows of the n-th plugin of a run +~/.fvol/presets/.json your presets, for every image +``` + +`dump_id` is the key fastvol's cache (`~/.cache/fastvol`) uses for an image: a hash of its +canonical path, size and modification time. A moved or changed dump is therefore a new +analysis; **Previous** marks the old one *missing* or *changed*. A metadata file this fastvol +cannot read (damaged, or written by another version) is listed as *unreadable* and left as it is: +the dump opens without its analysis, and nothing is saved over the file until it is deleted. +Opening another dump saves the current one and closes its runs. The dump itself is never +copied. **Delete** next to an analysis in **Previous** removes its metadata and result rows (for +the dump that is open, its runs too). Results that did not fit the memory budget (`--max-memory`) are not saved; their run +lists them, and running the plugin again gets them back. + +A preset file: + +```json +{"name": "Ransomware sweep", "os": "windows", "created": 1790000000, + "plugins": [{"plugin": "windows.pslist.PsList", "args": {"pid": "4 628"}}, + {"plugin": "windows.malware.malfind.Malfind", "args": {"dump": true}}]} +``` + +Presets can be copied between machines and edited by hand; a file that cannot be read is listed +with the reason under **Presets**. + +### Triage rules + +**Load Rules…** reads a JSON file of checks against the process list. A rule flags the processes +for which every field of its `match` matches: + +```json +{"name": "Office and script abuse", + "rules": [{"title": "Office app spawning a shell", "severity": "high", + "description": "Macros often launch cmd or PowerShell.", + "match": {"parent": "/^(winword|excel|outlook)\\.exe$/i", + "name": "/^(cmd|powershell|wscript)\\.exe$/i"}}]} +``` + +| Field | Value | +| ----- | ----- | +| `name`, `parent`, `path`, `cmdline` | text: `"text"` (contains), `"=exact"`, `"!not"`, `"/regex/flags"` | +| `pid`, `ppid`, `threads`, `handles`, `session` | numbers: `">10"`, `"<=0x40"`, `"=4"` | +| `age` | seconds between the process start and the capture, such as `"<600"` | +| `exited`, `wow64` | `true` or `false` | + +`severity` is `high`, `medium` (the default) or `low`. `path` and `cmdline` are known on Windows +once `pstree` ran in the background. The rules file is saved with the analysis. + ### Output files Every run of a plugin that writes files gets its own directory below the output root, named -`run--`. Exports through the `fvol` renderer use `export--`. Downloads are -served only from these directories. +`run--`; a run's configuration file (`--write-config`) goes there too. Exports +through the `fvol` renderer use `export--`. Downloads are served only from these +directories. ### Keyboard shortcuts -Press `?` in the UI for this list. - | Where | Keys | Action | | -------------- | ---------------------------- | -------------------------------------------------------- | -| Anywhere | `Ctrl+K` | Run a plugin | -| | `/` | Filter the current table | -| | `T` | Filter the process tree | -| | `O` | Overview | -| | `M` | Memory viewer at an address | -| | `Alt+1` to `Alt+9` | Go to tab N | -| | `[` and `]` | Previous and next tab | -| | `Alt+W` | Close the tab | -| | `Shift+T` | Light or dark theme | -| | `?` | Shortcut help | +| Quick Start | `1` to `3` | Choose | +| | `Esc` | Go to the workspace | +| Anywhere | `Shift+T` | Light or dark theme | +| Plugins | `↑`, `↓`, `Space`, `Enter` | Move, tick | +| Runs | `Enter` | Open the run's results (again: fold it) | +| | `Space`, `←`, `→` | Fold or unfold | +| | `F2` | Rename | +| Results | `]`, `[`, `Ctrl+→`, `Ctrl+←` | Next and previous plugin | +| | `/` | Filter rows | +| | `Esc` | Close the results | | Result table | arrows, `PgUp`, `PgDn` | Move; `Ctrl+Home` and `Ctrl+End` go to the first and last row | -| | `Enter` | Row details | | | `C`, `Shift+C` | Copy the cell, or the row as TSV | | | `S`, `Shift+S` | Sort by the column; again to reverse; with Shift, add a sort key | -| | `F` | Column filter: `text`, `=exact`, `!not`, `>0x10`, `<=5`, `/regex/`, `-` for empty | -| | `H` | Open the address under the cursor in the memory viewer | -| | `P` | Open the row's process | -| Process tree | arrows | Move, collapse, expand | -| | `Enter` | Open the process view | -| | `*` | Expand everything | -| | `a` to `z` | Jump to a process by name | -| Memory viewer | `G` | Go to an address | -| | `Enter` | Follow the pointer under the cursor | -| | `Backspace` | Back | -| | `D` | Disassemble from the cursor | -| | `Shift+Left`, `Shift+Right` | Select a range; `C` copies it as hex | +| | `F` | Filter the column | +| Splitters | arrows | Resize | ### HTTP API @@ -163,35 +232,47 @@ The API exists for the UI and for scripts. Every call under `/api/` needs the to | Method and path | Purpose | | ------------------------------------ | ------------------------------------------------------------------ | | `GET /api/session` | Current image, analysis state, OS, and the facts of the overview | -| `POST /api/session` | Open another image: `{"file": "", "symbol_dirs": [""]}` | +| `POST /api/session` | Open another image: `{"file": "", "symbol_dirs": [""]}` (`symbol_dirs` optional, set like `-s` in Options); its saved analysis comes back | | `GET /api/plugins` | Every plugin with its options | -| `GET /api/runs` | All runs | -| `POST /api/runs` | Start a run: `{"plugin": "", "args": {"