Lesson 18 / 22
Disk Imaging
Measuring the verifiability of write-blocked copying: on a fictional 4,096-slice source, a selective directory copy takes 4 of 15 evidence items, a logical copy takes 8, a partition block takes 13, and a full block copy takes all 15. Because the source hash can be recomputed in an offline copy, 15 of 15 items are verified; in a copy taken from a running system, 303 of 4,096 slices change, all three hashes come out different, and the number of verified items drops to 9.
Published Updated
In this lesson
- Shows what proves a copy is identical to its source through hash comparison
- Compares copying scope in terms of slices taken, duration, and evidence items missed
- Counts the slices that change and the items that cannot be verified in a copy taken from a running system
Contents
The previous lesson built the chain around the item and noted that three of the 28 items are disk images, each carrying a 5,400-second collection duration in the model. The chain’s first link, the collection step, passed there as a single box. This lesson opens that box and asks a single question: what proves the copy is identical to the source.
The question is not as narrow as it looks. A hash compares two values; for the comparison to mean anything, the second value has to be computable. If the source does not change during copying, its hash can be recomputed afterward and compared with the image’s. If the source changes, there is no fixed value to compare against, and the hash only binds the image to itself. This lesson works out three numbers separately: how many items are bound to the source by a hash, how many items a partial copy misses, and how many items change during copying.
- FE7. The source is modeled as a 512 GiB storage volume divided into 4,096 slices. A slice here is a unit of measurement, not a file-system block; no real storage volume is read.
- FE8. A write blocker does not pass through write requests directed at the source. The source does not change during or after copying.
- FE9. A forensic image is a sequential copy of every slice within scope. The copying speed is 97 MiB/s, and it is fictional.
- FE10. A copy is verifiable only if the source’s hash can be recomputed after copying and compared with the image’s hash.
- FE11. On a running system, each zone changes at its own rate; the rate is the number of slices that change per 1,000 seconds, and it is fictional.
- FE12. An evidence item occupies five consecutive slices; the item’s hash is computed from the digests of those five slices.
Source, Zone, and Scope
// forensics/disk.mjs — slice model of a 512 GiB source, its zones, and its evidence items. // A MODEL: no real disk is read; slice content comes from the generator. import { createHash } from "node:crypto"; export const SEED = 20260803, SLICE = 4096, MIB = 128, SPEED = 97; // slice 128 MiB, 97 MiB/s export const DURATION = Math.round(SLICE * MIB / SPEED); // full copy duration (s) const generator = (t) => { let s = t >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; }; // zone -> [slice count, slices changed per 1,000 seconds (on a running system)] export const ZONE = { "partition-table": [8, 0], "filesystem-meta": [240, 9], "active-files": [2400, 26], "filesystem-journal": [128, 14], "swap-area": [512, 11], "unallocated-space": [760, 2], "off-partition-area": [48, 0], }; // Copying scope -> which zones it takes. export const SCOPE = { "selective directory": ["active-files"], // only the selected directories "logical copy": ["active-files", "filesystem-meta"], "partition block": ["active-files", "filesystem-meta", "filesystem-journal", "swap-area", "unallocated-space"], "full block": Object.keys(ZONE), }; // item -> [zone, whether inside the selected directory] export const ITEM = { "configuration-file": ["active-files", true], "service-definition": ["active-files", true], "application-log": ["active-files", true], "scheduled-task": ["active-files", true], "user-document": ["active-files", false], "cache-file": ["active-files", false], "deleted-installer-remnant": ["unallocated-space", false], "deleted-log-fragment": ["unallocated-space", false], "temp-file-residue": ["unallocated-space", false], "deleted-file-name": ["filesystem-meta", false], "timestamp-record": ["filesystem-meta", false], "log-entry": ["filesystem-journal", false], "swap-space-residue": ["swap-area", false], "off-partition-configuration": ["off-partition-area", false], "partition-table-record": ["partition-table", false], }; // Slices are laid out zone by zone; each item occupies five consecutive slices. export function source() { const r = generator(SEED), d = []; for (const [b, [n]] of Object.entries(ZONE)) for (let i = 0; i < n; i += 1) d.push({ no: d.length, zone: b, content: `${b}:${i}:${Math.floor(r() * 1e9)}` }); const item = {}; for (const [name, [b, selected]] of Object.entries(ITEM)) { const zoneSlices = d.filter((x) => x.zone === b).map((x) => x.no); const start = Math.floor(r() * (zoneSlices.length - 5)); item[name] = { zone: b, selected, slice: zoneSlices.slice(start, start + 5) }; } return { slice: d, item }; } export const digest = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16); export const wholeHash = (d) => digest(d.map((x) => digest(x.content)).join("|")); export const itemHash = (d, k) => digest(k.slice.map((n) => digest(d[n].content)).join("|")); export const covered = (scope, name) => { const [b, selected] = ITEM[name]; return SCOPE[scope].includes(b) && (scope !== "selective directory" || selected); };
The zone list is this lesson’s real framework. A storage volume is not just files: file-system metadata carries the names and timestamps of deleted files, unallocated space carries the not-yet-overwritten remnants of deleted content, swap space carries fragments dropped out of running processes’ memory, and the area outside the partition table is never seen by the file system at all. Most evidence items live in these zones.
How Many Items Scope Misses
// forensics/scope.mjs — slices, duration, and items missed by copying scope. import { source, ZONE, SCOPE, ITEM, SLICE, MIB, SPEED, DURATION, covered } from "./disk.mjs"; const y = (g, ...s) => console.log(s.map((v, i) => (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join("").trimEnd()); const { slice } = source(); const selectedShare = 0.35; // selected directories are 35% of active files console.log(`model (seed 20260803): ${SLICE} slices x ${MIB} MiB = ` + `${SLICE * MIB / 1024} GiB, ${SPEED} MiB/s, full copy ${DURATION} s`); console.log(`${Object.keys(ZONE).length} zones, ${Object.keys(ITEM).length} evidence items`); const A = [-24, 8, 10, 12, 9, 9]; console.log(""); y(A, "scope", "slices", "GiB", "duration s", "items", "missed"); for (const name of Object.keys(SCOPE)) { let n = SCOPE[name].reduce((a, b) => a + ZONE[b][0], 0); if (name === "selective directory") n = Math.round(n * selectedShare); const taken = Object.keys(ITEM).filter((k) => covered(name, k)).length; y(A, name, n, (n * MIB / 1024).toFixed(1), Math.round(n * MIB / SPEED), taken, Object.keys(ITEM).length - taken); } const B = [-32, -22, 22, 17, 17, 12]; console.log(""); y(B, "item", "zone", "selective directory", "logical copy", "partition block", "full block"); for (const name of Object.keys(ITEM)) y(B, name, ITEM[name][0], ...Object.keys(SCOPE).map((k) => (covered(k, name) ? "yes" : "-"))); const missed = Object.keys(ITEM).filter((k) => !covered("partition block", k)); console.log(`\nitems missed by partition block: ${missed.join(", ")}`); console.log(`logical copy takes ${Math.round((ZONE["active-files"][0] + ZONE["filesystem-meta"][0]) * 100 / slice.length)}% of slices, takes ` + `${Object.keys(ITEM).filter((k) => covered("logical copy", k)).length} / ` + `${Object.keys(ITEM).length} items`);
model (seed 20260803): 4096 slices x 128 MiB = 512 GiB, 97 MiB/s, full copy 5405 s 7 zones, 15 evidence items scope slices GiB duration s items missed selective directory 840 105.0 1108 4 11 logical copy 2640 330.0 3484 8 7 partition block 4040 505.0 5331 13 2 full block 4096 512.0 5405 15 0 item zone selective directory logical copy partition block full block configuration-file active-files yes yes yes yes service-definition active-files yes yes yes yes application-log active-files yes yes yes yes scheduled-task active-files yes yes yes yes user-document active-files - yes yes yes cache-file active-files - yes yes yes deleted-installer-remnant unallocated-space - - yes yes deleted-log-fragment unallocated-space - - yes yes temp-file-residue unallocated-space - - yes yes deleted-file-name filesystem-meta - yes yes yes timestamp-record filesystem-meta - yes yes yes log-entry filesystem-journal - - yes yes swap-space-residue swap-area - - yes yes off-partition-configuration off-partition-area - - - yes partition-table-record partition-table - - - yes items missed by partition block: off-partition-configuration, partition-table-record logical copy takes 64% of slices, takes 8 / 15 items
The scope table traces a curve, and the curve is not straight. The selective directory copy takes twenty percent of the source and brings back four of 15 items. The logical copy triples the area, and the item count doubles. The partition block copy adds fifty percent more area on top of the logical copy and raises the item count from 8 to 13. The last step is the cheapest: 56 more slices, 74 more seconds, two more items.
This last step is the most instructive row in the table. The partition block copy has taken 98.6 percent of the source and looks “nearly complete”; the two items it misses are the partition table record and the configuration in the off-partition area. A decision that measures scope by ratio treats the remaining 1.4 percent as negligible. A decision that measures scope by item sees that the same 1.4 percent carries two items. Evidence is counted by item, not by ratio.
The second table shows the same result item by item. The deleted file name and the timestamp record are in metadata; a file copier sees these but does not read the deleted record. The deleted installer remnant, the deleted log fragment, and the temp-file residue are in unallocated space; no file copy can reach them, because the file system no longer binds these slices to a file. Counted by item, a file copy leaves at best half, at worst three-quarters of the evidence set outside its scope.
Copying from a Running System
// forensics/live.mjs — verifiability of an offline copy versus a copy from a running system. import { source, ZONE, ITEM, SLICE, DURATION, wholeHash, itemHash } from "./disk.mjs"; const y = (g, ...s) => console.log(s.map((v, i) => (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join("").trimEnd()); const { slice, item } = source(); const start = slice.map((d) => ({ ...d })); // source state at the start of copying const r = (() => { let s = 20260804 >>> 0; return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296; })(); // Offline copy: the write blocker does not change the source. const offline = start.map((d) => ({ ...d })); console.log(`offline copy: source hash ${wholeHash(start)}, image hash ` + `${wholeHash(offline)}, equal ${wholeHash(start) === wholeHash(offline) ? "yes" : "no"}`); // Running system: each zone changes at its own rate, copying advances slice by slice. const end = start.map((d) => ({ ...d })); const image = start.map((d) => ({ ...d })); const copyMoment = (no) => (no + 1) * DURATION / SLICE; const before = new Set(), after = new Set(); for (const [b, [n, rate]] of Object.entries(ZONE)) { const pool = start.filter((d) => d.zone === b).map((d) => d.no); for (let e = 0; e < Math.round(rate * DURATION / 1000); e += 1) { const no = pool[Math.floor(r() * n)], t = r() * DURATION; end[no].content += `|write${e}`; if (t < copyMoment(no)) { image[no].content = end[no].content; before.add(no); } else after.add(no); } } for (const no of before) after.delete(no); const A = [-22, 9, 10, 16, 17]; console.log(""); y(A, "zone", "slices", "changed", "before copy", "after copy"); for (const b of Object.keys(ZONE)) { const h = start.filter((d) => d.zone === b).map((d) => d.no); y(A, b, h.length, h.filter((n) => end[n].content !== start[n].content).length, h.filter((n) => before.has(n)).length, h.filter((n) => after.has(n)).length); } const changed = start.filter((d, i) => end[i].content !== d.content).length; y(A, "total", SLICE, changed, before.size, after.size); console.log(`\nrunning system: source at copy start ${wholeHash(start)}, source at copy ` + `end ${wholeHash(end)}, image ${wholeHash(image)}`); console.log(`all three differ; there is no single source state the image can be equated with`); const B = [-30, 16, 16, 12]; console.log(""); y(B, "item", "image=copy start", "image=copy end", "verified"); let clean = 0, neither = 0; for (const [name, k] of Object.entries(item)) { const a = itemHash(image, k) === itemHash(start, k); const b = itemHash(image, k) === itemHash(end, k); if (a && b) clean += 1; else y(B, name, a ? "yes" : "no", b ? "yes" : "no", "no"); if (!a && !b) neither += 1; } const N = Object.keys(ITEM).length; console.log(`${clean} of ${N} items are the same under both source states; ` + `${N - clean} items changed during copying, and ${neither} of those cannot be equated ` + `with either source state`); console.log(`items verified in the offline copy ${N}/${N}, in the running-system copy ${clean}/${N}`);
offline copy: source hash 8db5b10c2d423fe9, image hash 8db5b10c2d423fe9, equal yes zone slices changed before copy after copy partition-table 8 0 0 0 filesystem-meta 240 43 4 39 active-files 2400 139 43 96 filesystem-journal 128 56 37 19 swap-area 512 54 43 11 unallocated-space 760 11 8 3 off-partition-area 48 0 0 0 total 4096 303 135 168 running system: source at copy start 8db5b10c2d423fe9, source at copy end 429ed9b9ddd32a58, image 8128d3063118f7f3 all three differ; there is no single source state the image can be equated with item image=copy start image=copy end verified scheduled-task yes no no cache-file no yes no deleted-file-name yes no no timestamp-record yes no no log-entry no no no swap-space-residue no yes no 9 of 15 items are the same under both source states; 6 items changed during copying, and 1 of those cannot be equated with either source state items verified in the offline copy 15/15, in the running-system copy 9/15
The first line carries copying’s evidentiary side by itself. Behind a write blocker the source does not change; after copying finishes, the source’s hash is recomputed and compared with the image’s. The two values are equal. What is proven is that the image is a copy of the source, and that is the strongest statement a hash can make.
The same computation cannot be done on a running system. Copying takes 5,405 seconds, and in that time 303 of 4,096 slices change. Where the change concentrates directly reflects the zone rates: 56 of the file-system journal’s 128 slices, 54 of swap space’s 512 slices, only 11 of unallocated space’s 760 slices. Unallocated space, the most valuable zone for evidence, is also the most stable among the data-bearing zones; the most active zones are the ones that turn over from the system’s own operation.
All three hashes come out different, and this result has only one reading: there is no source state the image can be equated with. The image is neither the source at the start of copying nor the source at the end; it carries the new content in 135 slices and the old content in 168. What results is a mixture that never existed at any single moment.
The item table counts the cost of this: six of 15 items change during copying, and verified items drop from 15 to 9. A warning is needed here. The model’s “image = copy start” column cannot be observed in a real examination, because the source’s state at the start of copying no longer exists. All that remains is the image’s own hash, and that hash shows only that the image did not change afterward — not that it is identical to the source. In a live copy, the number of verified items is counted as zero, because it cannot be measured.
This does not make a live copy the wrong move. On a system that cannot be shut down, it is the only option, and this lesson has already counted how many items scope brings back. What must be done is to write the condition under which the image was taken into the chain-of-custody record: the source was running, copying took this long, verification is limited to the image itself. A condition left unwritten later looks like a gap that was never even noticed.
Summary
- A copy’s verifiability depends on whether the source’s hash can be recomputed; behind a write blocker, the two hashes come out equal and 15 of 15 items are verified.
- The scope curve is not straight: a selective directory brings back 4 items, a logical copy 8, a partition block 13, and a full block copy 15; the last step adds two items for 56 slices and 74 seconds.
- A partition block copy that takes 98.6 percent of the source still misses two items; scope is measured by item, not by ratio.
- Most evidence items are in zones that are not files: 3 in unallocated space, 2 in metadata, 1 in the file-system journal, 1 in swap space.
- In a copy taken from a running system, 303 of 4,096 slices change, all three hashes come out different, and no source state remains that the image can be equated with; verified items drop from 15 to 9.
Next Step
One item never appeared in this lesson. The model’s zone list has no memory, and rightly so: memory is not on disk, and no copying scope takes it. Swap-space residue carries fragments dropped out of memory, but that is memory’s remnant, not memory itself — and that remnant is among the zones that change during copying.
The next lesson works out this topic’s most important number. Evidence items are arranged in an order of volatility by their lifetime, and it calculates how many items are lost when the collection order does not follow that order. The same 28 items, the same durations, three different orders: by volatility, host by host, and largest item first. The difference between them is not a matter of preference — it is directly the number of pieces of evidence lost.
To keep your progress and take notes, Log in
My notes
Log in to take notes.