Wrote a device‑mapper “XOR filter” and ext2 got mad at me. Where did I go wrong?
Body:
I’m building a learning target for device‑mapper that should decrypt a block image “on the fly” (and encrypt on writes). For simplicity the cipher is a one‑byte XOR.
Minimal lab:
dd if=/dev/zero of=myfs bs=1K count=60 mkfs.ext2 myfs
# Primitive "encryption":
# newfs = xor(myfs, 0x0F) byte-by-byte
Then I write the DM target: in map I iterate over the bio segments and do ^= 0x0F.
On a plain (unencrypted) image it mounts.
On the XOR’ed image, during mount I get:
mount: wrong fs type, bad option, bad superblock on /dev/mapper/xor # sometimes: mount: ... Structure needs cleaning
I tried both via page_address and like this:
bio_for_each_segment(bvec, bio, iter) { void *kaddr = kmap_atomic(bvec.bv_page); u8 *p = (u8 *)kaddr + bvec.bv_offset; for (unsigned int i = 0; i < bvec.bv_len; ++i) p[i] ^= 0x0F; kunmap_atomic(kaddr); }
Out of curiosity I printed the sectors being read — on the XOR image strange jumps start happening. The contents of the first requests (which hit the superblock) look like zeros, and then the FS “goes off the rails” and reads unexpected places.
Question: where in the target should I correctly insert decryption/encryption so that the FS sees the right bytes and mounts calmly? Should I even touch the first kilobytes with metadata, or would that be a band‑aid?
[UPD‑1] 22.08.2025, 15:45
Moved XOR to end_io for reads and used a bounce for writes — ext2 mounts, reads/writes match on checksums.
[UPD‑2] 22.08.2025, 17:55
Marked Karl Schneider’s answer as accepted. Thanks everyone! And yes, I’ll keep XOR in the lab — in prod I’ll use something less “comedic.”
Answers
Karl Schneider
(Edited)
Decrypt after read, encrypt before write. And don’t poison the upper cache.
Essence:
The main mistake is mutating data in map.
For reads, the data aren’t there yet — the pages will be filled later by the lower layer. Do decryption in the clone’s end_io.
For writes, you mustn’t XOR pages owned by the upper FS page cache. Use a bounce bio with new pages: copy → XOR → submit downward.
Pipeline step by step:
1. READ:
Clone the bio (thin clone), set bi_end_io, submit down. In end_io walk the segments, apply XOR, complete the original via bio_endio.
2. WRITE:
Create a bounce bio with its own pages, copy data from the original bio, XOR in the bounce pages, submit the bounce; in its end_io complete the original bio.
3. Routing:
In map do bio_set_dev(...) and adjust the sector.
On reads — submit_bio(clone) and return DM_MAPIO_SUBMITTED.
On writes — also SUBMITTED (since you submit a bounce).
4. API nuances:
For newer kernels look at bio_op(bio)/REQ_OP_WRITE or op_is_write(bio->bi_opf); for older ones — bio_data_dir(bio). Use GFP_NOIO allocations; a bio_set/mempool will help.
Mini‑skeleton (abridged, pseudo‑C):
Why do you have “zeroed” pages?
Because they haven’t been filled yet; by changing them prematurely you break the FS’s expectations and poison the cache. For writes it’s even worse: XOR’ing the FS’s “native” pages leaves encrypted bytes in the cache, and the FS then “reads itself in cipher.”
Bottom line: copy the dm-crypt logic: before the disk — encrypt, after the disk — decrypt. In your case it’s a teaching XOR instead of AES.
Markus Vogel
(Edited)
You can live with a 0x0F key, but only in the lab 😉
Johannes Müller
(Edited)
Confirmed: after moving XOR to read end_io and using a bounce on write, ext2 mounts.
Nils Wagner
(Edited)
Don’t forget mempools, or under load you’ll dive into allocator recursion.
Tobias Becker
(Edited)
How about not reinventing the wheel? dm‑crypt, cryptsetup and friends
Gist:
Everything you’re wiring by hand (BIO clones, end_io, bounce pages) is already battle‑tested in dm-crypt. Yes, XOR isn’t a “cipher” there (and that’s a good thing), but the architecture is the same: encrypt before write, decrypt after read, don’t poison the upper cache.
If the goal is educational, the dm-crypt code is a great reference. If you want practicality, cryptsetup solves the task without kernel hacking. For experiments you can also try FUSE at the file level (easier to debug, but that’s no longer a block filter).
Felix Weber
(Edited)
TrueCrypt/VeraCrypt are also worth a look for I/O handling tricks, but check licensing.
Lukas Fischer
(Edited)
Quick proof‑of‑concept: don’t encrypt metadata (for debugging only!)
Idea:
To separate “broken pipeline” from “wrong layout,” temporarily skip XOR for the first 1024 bytes of the device (that’s where ext2/3/4 keep the superblock). The FS will stop yelling bad superblock — which means the root cause really is the transformation timing, as in A1.
Cons:
Johannes Müller
(Edited)
Worked. But yeah, looks like duct tape on a jet.”
Markus Vogel
(Edited)
For a quick test you can leave the first 1024 bytes alone — it’ll make it clearer whether the problem is the pipeline or the layout.
Felix Weber
(Edited)
ext2’s superblock is after the first kilobyte. If you encrypt everything blindly, the FS reads something it doesn’t expect.
Karl Schneider
(Edited)
XOR in map on a read is too early. There’s nothing to XOR yet
Johannes Müller
(Edited)
Okay, I get the hint. But I’m seeing ‘zero’ pages. Where are they from?
Karl Schneider
(Edited)
Those aren’t your data yet — they’re future buffers that the lower layer will fill. Touching them before end_io is asking for trouble.