HardStacked
Share:

SD cloning · data integrity

An SD clone that boots is not an SD clone that works

2026-07-26 · HardStacked · 7 min read

You migrate your Raspberry Pi to an endurance card using the bundled tool. The new card boots — but the password screen is slow to appear, and once the password is in, a long black screen goes by before the desktop shows up. The reflex, “the card is slow”, points in the wrong direction.

We ran this migration on a Pi 5 under Debian 13, from a generic V30 to a SanDisk High Endurance 128 GB, measuring every step: the hot clone that failed, the byte-by-byte autopsy, the cold clone that worked. Here are the numbers, our own mistakes included, because that is where the lessons live.

1. The stopwatch said faster, the screen said otherwise

piclone (the “SD Card Copier” preinstalled on Raspberry Pi OS) is not a bit-for-bit dd. Inside the binary: a dd if=/dev/zero over the target's first sector, an mkfs.ext4, then a cp -ax of a live system — file by file, while the OS keeps writing.

The result boots, and systemd-analyze reports 24.5 s and 29.8 s across two runs, against 39.029 s for the source. On screen, the exact opposite: the wait is noticeably longer than before, both before and after the password.

Both are true. systemd-analyze stops its stopwatch when graphical.target is reached; the greeter, then the session, start after that — outside its scope. That is exactly where the failure lives. With the suspect card mounted read-only from a healthy system, the journal names it:

Failed to start gvfs-daemon.service Failed to activate service 'org.gtk.vfs.Daemon': timed out (service_start_timeout=120000ms) ← 120 s of waiting, every session

The greeter asks for gvfs, then the user session asks in turn: D-Bus waits 120 seconds each time. Hence the two waits — the one delaying the password, with its error at the top of the screen, and the one after login. The second is black because pcmanfm --desktop is what draws the wallpaper and the icons: while it waits on gvfs, it draws nothing. Why gvfs dies:

$ stat -c %s usr/libexec/gvfsd 67760 ← exactly the right size $ md5sum usr/libexec/gvfsd 593a1ef6a5d338baa4fecb47ce98e886 ← dpkg expected 100c13dbd4613636479dc979b274b6d8

Read three times with drop_caches in between: same sum every time. The bad data is written on the card; this is not a flaky read.

The habit

“It boots” proves nothing, and a stopwatch that contradicts what you see on screen does not win the argument: start by looking at what it measures, and where it stops. Then mount the clone read-only from a healthy system, read its journal, and compare binaries against dpkg's md5 sums before calling the copy good.

2. Wrong bytes have a shape, and that shape clears the card

The full sweep found 69 files with wrong content, scattered with no logical connection: gvfsd, libcrypto.a, libnssckbi.so, a Wi-Fi firmware, icons, kernel headers. The apparent disorder hid a perfect pattern:

$ cmp -l gvfsd.src gvfsd.clone | awk '{print $3}' | sort -u 0 ← 100% of diverging bytes are 0x00

Contiguous ranges, ending either at end of file or exactly on 2 MiB (2,097,152). For gvfsd, 67,760 bytes: the divergence runs from byte 63,673 to 67,745 — a tail of zeros.

Failing media returns random noise. A lost write returns aligned zeros. The card and the USB reader are therefore cleared: it was the hot copy that dropped writes in flight.

The habit

Before blaming hardware, look at the shape of the wrong bytes with cmp -l. Random noise: the media. Aligned contiguous zeros: the copy tool.

3. Our first sweep was off by a factor of 300

We'll own this one: the first md5sum -c ran without root. Verdict: 21,513 “failures”. The real number: 69. The other 21,444 were simply unreadable — HIDS files restricted to root, compilers at -rwx------. md5sum -c mixes “wrong content” and “could not open” into the same stream; we nearly concluded the card was massively rotten.

The habit

Sweep as root, and always separate read failures from content failures.

4. Our cold clone nearly reproduced the same failure, in the script

The method that works: source unmounted, e2fsck -fn, e2image -ra -p (copies used blocks only), dd conv=fsync writing in full, e2fsck -fp.

Second confession: the first draft carried dd conv=sparse, caught on re-reading. sparse skips blocks of zeros: the target's old corrupted content would never be overwritten, and a file of genuine zeros would read back the stale data. The same failure, by another road. blkdiscard was refused by the USB reader — without consequence, precisely because the write is a full one.

The habit

On an already-written target, full writes only — re-read your dd line, never conv=sparse.

5. A “zero divergence” verdict would have rejected the perfect clone

Verifying the cold clone: 193,497 package md5 sums, 849 divergences. On a perfect clone. All of them legitimate: 845 .pyc files from a HIDS that recompiles them at runtime, 2 configs from the same, an EXTERNALLY-MANAGED for which dpkg keeps two sums (the stdlib one and the Raspberry Pi package that overwrites it — a structural false positive), one g_ether.conf. Plus 229 “open errors”: that many nonexistent files (install scripts removed after deployment).

A script demanding “0 divergences” would have printed “COPY NOT SOUND, DO NOT boot it” on a perfectly good clone. The count of divergences does not measure corruption; the signature from section 2 does: a contiguous block of zeros of 64 KiB or more, or a tail of zeros of 64 KiB or more. On the cold clone: 0 files flagged.

And the detector has to test itself before it judges, because a broken detector returns “0” too. The test: a file full of zeros must be flagged, a healthy file with a 100k-zero tail must be flagged, a random file must not. Verified by deliberately sabotaging the threshold: the refusal to render a verdict fires.

The habit

Judge a copy on the signature of the failure you are hunting, not on a count of divergences. And make your detector fail on purpose once — if it cannot say “I am broken”, its “0” is worth nothing.

6. The boot was 13 seconds faster, and that still needed proving

After the migration: 25.948 s (1.700 s kernel + 24.247 s userspace), against 39.029 s before. The gain is real — but the corrupted clone from section 1 already showed 24-29 s on the same stopwatch, on a machine whose desktop never appeared. A good boot number can coexist with a broken system: it does not measure health.

The control that settles it: count enabled and masked systemd units, before and after. Here 47 and 21, identical. Same service count, less time: a genuine I/O gain. Confirmed entry by entry with systemd-analyze blame: the HIDS service, I/O-hungry at startup, drops from 21.8 s to 12.937 s — roughly 9 of the 13 seconds come from it alone.

The habit

A boot-time gain is only worth celebrating after you recount enabled and masked units. Otherwise your “faster card” may be a service that no longer starts.

7. Wear counters describe the source, not the card

$ dumpe2fs -h <partition> | grep 'Lifetime writes' Lifetime writes: 1188 GB ← on a card manufactured two months earlier

This dumpe2fs counter lives in the superblock — copied along with everything else. After a clone, any counter stored inside a copied structure describes the source. The reassuring cross-check: 364,661 inodes and 13,564,397 used blocks, counted independently on two machines, identical.

The habit

Wear is read from the controller (/sys/block/mmcblk0/device/: cid, name, date), never from the filesystem.

Five rules for an honest clone

  1. Cold only. A copy of a live system drops writes in flight, and the result boots anyway.
  2. Write the target in full, never conv=sparse: skipped blocks of zeros leave the old corrupted content in place.
  3. The verdict rests on a signature, not on a count. A perfect clone shows hundreds of legitimate divergences; a rotten clone is recognized by its ranges of zeros.
  4. Separate “wrong content” from “could not read”. Mixed into one stream, they inflate the verdict by a factor of 300.
  5. Self-test the detector. Broken, it returns “0” as well — and a “0” you have never managed to make fail is worth nothing.

What a real clone looks like

Measured on a Pi 5 under Debian 13, generic V30 to SanDisk High Endurance 128 GB. The hot clone booted with 69 wrong files, all in contiguous zeros; the cold clone, verified across 193,497 md5 sums, comes out with 0 files flagged and a double inode/block count identical on two machines.

One last consequence, counter-intuitive and easy to forget: a cold clone freezes the source at time T. Anything written after the copy starts does not exist on the card you burned — including the report of the copy itself, which stayed on the machine driving it. We went looking for that file on the wrong card before working it out.

The same lesson runs through the whole migration: “it boots” is not “it works”, “faster” is not “healthier”, “zero errors” is not “clean”. The number is the instrument, not the health — and every verdict has to come out of a measurement capable of telling things apart, rendered by an instrument you have made fail on purpose at least once.

The deeper lesson

A clone that boots, a stopwatch going down, a sweep returning zero: three reassuring signals, and three instruments that were not measuring what we assumed. The discipline that exposes them is the same everywhere — know what the number measures, hunt the signature of the failure rather than a count, and make the instrument fail before you trust it.

We are a security shop, so our incentives point the same way yours do: nothing we sell depends on your configuration staying weak. That alignment is exactly what the big platforms cannot offer, and it is verifiable, not a slogan. Scan hardstacked.net yourself, right now, on the same scanner.

The same philosophy drives HardVps: it applies a reviewed hardening baseline to your server, captures every original file first, and verifies any restore byte-exact by SHA-256. Strictness you can roll back is strictness you will actually deploy.

See what HardVps does →

On a Raspberry Pi? Start free with audit-pi, our open-source security audit.

Share: