Turning an e-reader into your own system: reverse-engineering the Kobo Libra 2
A 512 MB e-reader with an i.MX6SLL SoC, no secure boot, and a user partition you can mount over USB. We reverse-engineered its boot, wrote an init that verifies the system by sha256 on every boot, and turned Plato into the whole OS. This is the process, the results, and what broke along the way.
// Sec · QUANTARMY research
An e-reader as your own system
Reverse-engineering the Kobo Libra 2 and PlatoX
It is a book reader. An e-ink screen, Wi-Fi, 512 MB of RAM, a SoC NXP stopped calling new almost a decade ago. The kind of device nobody looks at with an attacker's eyes because "there's nothing inside." And yet it has everything that makes a hardware target interesting: a boot chain with no secure boot, an OTA update mechanism that applies packages on implicit trust, a user partition that mounts over USB like any thumb drive, and a counter in the MBR that decides whether the device wipes itself.
This article is the record of what we did to a Kobo Libra 2: reverse-engineering its boot and update mechanism, a static C boot selector that verifies the system by sha256 on every boot, an own kernel, and turning Plato —an open-source document reader— into the device's whole operating system. The public result is called PlatoX and is free software (AGPL-3.0); the personal version, with more things wired in, was called KoboX. We'll keep the two apart carefully, because security lives in the details and mixing editions is lying with precision.
This is not a jailbreak tutorial. It is a hardware-security case study: what you can learn from a closed device by reading its own firmware, where the real trust boundaries lie, and why "integrity" and "authenticity" are not the same word.
The ecosystem you had to understand before touching anything
First rule of any intervention on someone else's hardware: reconstruct the stock system before changing a single byte. You cannot defend —or break with control— what you don't understand.
The device is a Kobo Libra 2, internal name io, board E70K10, confirmed by reading the PCB table in the firmware's own kobo_config.sh. Inside: an NXP i.MX6SLL SoC (ARM Cortex-A9), a Rohm BD71828 PMIC, a Realtek RTL8723DS Wi-Fi part over SDIO, a 1264×1680 e-ink panel and 512 MB of RAM. The stock software stack, rebuilt piece by piece from the extracted firmware, is a museum of 2015-era embedded Linux:
- Boot: NXP's U-Boot (
2016.03-00128on the 4.38 branch), no signature verification. Whoever controls what the kernel runs as init controls the device. - Kernel: Linux 4.1.15, Kobo's exact build
-00868-g58a2758be07, compiled with GCC 5.3.0. - Userland:
busybox 1.31.1as PID 1, glibc 2.19, libstdc++ from GCC 4.9. udev 142,wpa_supplicant 2.9over OpenSSL 1.0.1u. - The reader: Nickel, Kobo's Qt application, with its store and account.
pickel: a shell wrapper that execspickel-imx(an ELF). Keep it in mind:pickelis what decides whether the device factory-resets or accepts an update. Without knowing it, it is a security component.
Storage is an eMMC /dev/mmcblk0 with three partitions and several raw zones outside them:
| Zone | What it is | Size / position |
|---|---|---|
mmcblk0p1 | rootfs (where Nickel runs) | 256 MB ext4 |
mmcblk0p2 | recoveryfs | 384 MB ext4 |
mmcblk0p3 | KOBOeReader (the "onboard", visible over USB) | 29.1 GB vfat |
| zImage | raw kernel | sector 2048 |
| hwconfig | hardware config | sector 1024 |
| waveform | e-ink table | sector 14336 |
That 256 MB rootfs and that vfat user partition are not trivia: both bit us later. The reference firmware was pinned to 4.38.23828 (the kernel hashes the system accepts are anchored to it).
Reverse engineering: process and results
With the map in hand, the operational question is a single one: how do you get your own code in without opening the device or touching U-Boot? The answer was inside Kobo's own update mechanism.
The vector: KoboRoot.tgz and implicit trust
Kobo updates like this: if the stock init finds a .kobo/KoboRoot.tgz file in storage, it unpacks it over / as root and reboots. In the stock rcS that happens around lines 251–269, with pickel can-upgrade as the gatekeeper. No signature. No origin check. It is a tar/gzip of root-owned files extracted over the system, and binaries must be mode 755.
Put in threat language: the updater is the vector. Anyone who can write to the user partition —which mounts over USB with no credentials— can drop a KoboRoot.tgz that the system will apply as root on the next boot. The device's whole chain of trust rests on nobody dropping a file into a folder. That is the link that turns a closed reader into an open system, and the one a secure-boot design would close.
The boot counter in the MBR
The prettiest finding of the whole reverse-engineering effort wasn't in a binary but in five bytes of the MBR. On /dev/mmcblk0, offsets 0x133–0x137, Kobo keeps a boot counter. On each boot, pickel should-factory-reset increments it; when the index reaches 4, the device shows the factory-reset screen and wipes itself. The only thing that resets that counter is Nickel, which writes the sequence 8d ef 02 d2 00 when it boots cleanly.
The security consequence runs deep: a counter on disk is security state, and if your system replaces Nickel, it inherits its obligations. An own system that boots instead of Nickel and doesn't reset that counter self-destructs in four reboots. We had to replicate Nickel's exact gesture —write 8d ef 02 d2 00 at offset 307, re-read and log— only once Plato had been alive past a time threshold. A mutation test (writing at offset 308) had to fail, and it did. That landed in version 1.0.3, after watching the device wipe itself on the bench.
The bricks, told as what they are: results
The honest part of any hardware work is the list of times you left it unusable. Each brick was a finding about the system:
- Permissions (
EACCES): we packagedrcSas mode644.busybox initinvokes it withexecv, which requires the execute bit →EACCES→ nothing boots. Fixed by shipping it755with acheck_execguard. (fix 1.0.1) - Space (
ENOSPC): we put 44.5 MB into a 256 MB rootfs that was already at 93%.etc/extracted half-way and the device stalled at the splash, never reaching Plato. The fix was to move Plato to the onboard (vfat) and leave the rootfs with a 29 KB delta, with a hard limit of ≤ 256 KB on the system partition. (fix 1.0.2) - The self-inflicted factory reset: an early debloat phase put everything under
/usr/local/Kobo/*on the delete list… includingpickel-imx. Next boot:pickel should-factory-resetreturns 127, the system reads that as "must reset", enters recovery, reflashes firmware 4.33.19747 and wipes the onboard. Out of that came the project's single most important rule: a protect-list that never touchespickel*,udev,init.d, the U-Boot images orld.so.preload. Debloat, from then on, was done with 0-byte placeholders inside theKoboRoot.tgz, neverrmon the live system.
Kernel and modules: trust rebuilt from the binary
Wanting to load our own modules (zram, tun) into Kobo's kernel forces you to rebuild the trust Kobo doesn't publish. The kernel verifies vermagic and the per-symbol CRCs; a module built against headers that don't match to the bit is rejected with Invalid argument.
The process was binary archaeology:
- Extract the device's own
config.gz(viaIKCONFIG_PROC) and itskallsymstable: 75,337 symbols. - Rebuild the stock kernel's
Module.symversstraight from the official zImage, with an own LZO1X decompressor to open it and locating__ksymtab/__kcrctabwith kallsyms: 6,847 symbols, all cross-checked againstkallsyms. - Verify each module with
check-module.py(vermagicidentity) andverify-crcs.sh, recomputing the CRCs from our own sources: 186 equal, 0 different.
The Libra 2 (2021) kernel tree didn't carry this board's drivers, but the Clara 2E (2023) one matched the device config except for one option. The builds came out reproducible: tun.ko and zsmalloc.ko byte-for-byte identical across builds. That's the difference between "seems to work" and "I know exactly what I'm loading into ring 0."
What Plato is, and what PlatoX is
Plato is an open-source document reader for e-readers, written in Rust by Bastien Dejean, meant as an alternative to Nickel: fast, no store, no account. It is the reader; it is not the system.
PlatoX is the idea that gives meaning to everything above: take Plato and turn it into the device's whole operating system. Instead of patching Nickel, we replace it entirely. The device boots a small Buildroot system from RAM, verified by sha256, and lands straight in Plato: no store, no account, no sync service. Technically, PlatoX is Plato 0.9.45 plus a nine-patch series (plato-patches/0001–0009) and a handful of own Rust crates. Nickel stays installed and untouched: if anything fails, the Kobo boots Nickel as always.
It's worth fixing the two names before we go on, because from here it matters:
- KoboX (and its evolution KoboX 2 / "0.2 Master Edition") is the full personal firmware, with things that depend on private infrastructure: Tailscale, a terminal, markets, weather, news, Home Assistant, library sync.
- PlatoX 0.1 is the public distillation (AGPL-3.0): Plato as the system, the optimizations, the AI assistant and the calculator. Everything personal was removed on purpose, and with it much of the attack surface.
The new features on Plato
On top of Plato we added features that turn it into a complete environment. I group them by edition, because a security reader will want to know exactly what code runs in the public version.
In public PlatoX (open source)
- Plato as the launcher (patch 0001): paths from the launcher, inputs by name. This is what lets Plato be the system rather than one more app.
- Network panel and Wi-Fi picker (patch 0002): a new
view/network_panel.rs(+281 lines) that opens, from any view, a panel with Wi-Fi status in readable sentences (Off / Looking for a known network… / Connected to X / No known network nearby), IP and signal. The top-bar icon has four states (wifi-off,wifi-connecting,wifi,wifi-secure) and a poller thread updates it every 2 s, never from the UI thread. - AI assistant (patches 0004 and 0009): on a text selection, Summarize, Translate or Explain; on a book, insights —a short review, an extended review, a summary or a navigable concept map (
view/concept_map.rs, +801 lines). It works with your own key: OpenAI, Claude (via the native Messages API with SSE streaming, withclaude-opus-5-5preselected when your key lists it), DeepSeek or any compatible server on your network. Answers are cached offline, and Wi-Fi only comes on when a request needs it, turning off after five idle minutes. - Phone-style calculator (patch 0006): replaces Plato's old command-line calculator with a keypad one. Behind it is the
platox-calccrate, with an exact decimal (0.1 + 0.2 = 0.3, not0.30000000000000004), percent and discount (120 − 15 % = 102, with a "Discount: 18" line), square root and guarded division by zero. - Clean menu, About and System Info (patch 0005): the menu fits on one page, Reboot and Shutdown stay visible, and Wi-Fi management moves entirely into the network panel. Along the way, System Info fixes a bug in Plato itself, which showed the load average multiplied by 100 with a "%" sign, and free memory instead of available memory.
- Developer tools (patch 0007): Test All and Benchmark, behind a developer marker, with secret redaction in the diagnostics (no api-keys, tokens or PSKs in the logs).
In KoboX (personal firmware, outside the public version)
This exists and works, but depends on private infrastructure and was removed from the public build to shrink the surface: Tailscale (first in userspace mode, then with TUN and an own module), a terminal (busybox shell over a pty, with fastfetch and ssh), Markets (Yahoo data), Weather (Open-Meteo), News (an own generator with trafilatura in an SSRF-hardened container), Home Assistant (lights and climate through a CONNECT tunnel) and PDF sync against an own Calibre-Web.
The final optimization: faster, more robust, more responsive, more up to date
A reader that takes two seconds to change chapter is unusable, however elegant its architecture. The last phase was a performance campaign with one strict rule: measure on the device, not on the host. These are the medians, iteration by iteration, of version 1.4:
| Metric (median) | Before (beta7) | After (beta12) |
|---|---|---|
| Page turn (EPUB) | 231 ms | 31 ms |
| Chapter change | 2967 ms | 46 ms |
| Open / reopen book | 1265 ms | 74 ms |
| First paint after power-on | 9.4 s | 6.9 s |
| Wi-Fi connected | 12–13 s | 5.0 s |
Faster
The work (patch 0003) attacks every layer of the cost:
- Page turn: a direct gray8-to-RGB565 copy with a 256-entry table (
framebuffer/fast16.rs) and the prerender of the neighbour page on a separate thread (nice 5). beta8's page turn fell from 230.7 to 42.8 ms. - Text and layout: per-word shaping cache, reused HarfBuzz buffers, hyphenation patterns loaded lazily per language, CSS parsed once per document.
- Chapters without waits: next-chapter layout in a worker and an on-disk layout cache (
prefetch.rs,layoutcache.rs,dlcodec.rs), verified by dev/inode/size/mtime. That's the bar that collapses in the chart. - Compilation: MuPDF, HarfBuzz, FreeType and DjVuLibre built from source with LTO and NEON (layout dropped from 13.4 to 9.8 ms/KB), and the release profile with
ltofat,codegen-units = 1andtarget-cpu=cortex-a9. The binary shrank from 10,494,280 to 8,851,200 bytes (−16%). - E-ink: partial refresh between pages and a full refresh every 20 to control ghosting, plus
zramto stretch the 512 MB.
More responsive
Responsiveness isn't only the UI. Wi-Fi took 10 seconds to scan because of a fixed timeout in the wext driver; switching it to nl80211,wext dropped the scan from 10.5 to 1.4 s and auto-connect to 5.0 s. Shutdown and restart were measured at 0.18–0.23 s between the request and the clean unmount of the onboard.
More robust
Robustness, in this project, is a security property. The heart is platox-boot, a static C program installed as PID 1 on Kobo's system partition. On every boot it verifies the kernel, a boot.manifest and the sha256 of the system image; and if anything fails —or if three boots in a row never reach Plato— it hands control to the stock init, Nickel. Plato's first paint resets that counter.
The kernel-slot writer is just as careful: it writes with dd conv=notrunc,fsync, reads back and checks the sha256, and if it doesn't match it restores the verified previous copy without rebooting, all under timeout 300 and only with the battery at 30% or charging. KoboX 1.4's own init goes further and rejects foreign update packages: it applies only its own, with a manifest (sha256, size, paths, space, battery), and sets any stray KoboRoot.tgz aside without running it. The stable version (Master Edition) rounded this off with atomic config writes and a clean clippy -D warnings across all eight crates.
More up to date
Where the stock system was a 2015 museum, the own userland is today's: Buildroot 2025.02 LTS, glibc 2.41, busybox 1.37, wpa_supplicant 2.12 with mbedTLS and WPA3/SAE/OWE support — and no OpenSSL, no udev and no systemd. There's even an own 4.1.15 kernel rebuilt from Kobo's GPL sources with reliable suspend: the device hung on suspend, and by config bisection we found the cause was, paradoxically, removing CONFIG_PM_DEBUG; those debug messages were hiding a race in the board's suspend, so the fix was to leave it as in stock.
Method note and threat model
What this is and what it isn't, stated plainly:
- There's integrity, not authenticity.
platox-bootverifies the system's sha256 on every boot, but there are no signatures. A local attacker who can write to the onboard partition can replace thecpioand its manifest at once. This design's threat model is anti-brick and anti-corruption, not anti-attacker with physical access and write permission. Closing that would require the one thing we didn't touch: U-Boot and a root of trust in the SoC. - The API key is stored in plain text, in
.platox/config/ai.toml, on the Kobo's own storage. It's documented that way and the recommendation is explicit: use a revocable key with a spending limit. The UI masks it to its last four characters and it never reaches the logs; the diagnostics redact secrets before writing anything. - It doesn't phone home. No telemetry, no account, no update check. The device talks to the AI provider you configure, only when you ask it to, and to
pool.ntp.orgfor the time. Wi-Fi is off unless an action needs it. - Debloat doesn't delete, it hides. It's done with 0-byte placeholders inside the update package and a protect-list that never touches the critical pieces. It is reversible and cannot, by construction, repeat the brick that inspired it.
- Supply chain: sources pinned by version and sha256, reproducible kernel and packages, and a
check-public.shthat refuses personal data, private infrastructure and Kobo's proprietary files before every release.
What we take away
- The updater is the vector. An OTA mechanism that applies packages on implicit trust —no signature, over a partition that mounts via USB— is the front door, not a side detail. If you design hardware, that's where the signature goes.
- State on disk is security state. Five bytes in the MBR decided whether the device wiped itself. Whoever replaces the system inherits its invariants; discovering them late costs a device.
- Integrity isn't authenticity. A sha256 per boot protects you from corruption and bricking, not from an attacker who controls the bit. Knowing which of the two problems you're solving is half the work.
- Bricks are data. Every device we left unusable —permissions, space, one too many
pickel-imxon a delete list— taught a system invariant no documentation told us.
A 512 MB reader turned out to be a full hardware-security target: an unsigned boot chain, an implicit-trust updater, security state on disk and a recovery mode with sharp teeth. Turning it into a verified, fast system of our own was the excuse; the trust map that came out of the process is what we take away.
Resources
- Plato, by Bastien Dejean — the open-source reader at the heart of it all: github.com/baskerville/plato.
- PlatoX — the public edition (AGPL-3.0) of this work; no public repository yet.
- Buildroot — the embedded system that boots in RAM: buildroot.org.