Skip to content

Latest commit

 

History

History
1452 lines (1388 loc) · 92.8 KB

rooting.md

File metadata and controls

1452 lines (1388 loc) · 92.8 KB

Rooting

firebreak takes a Dot from stock Fire OS 6 to rooted Fire OS 5.5.5.4. It polls the Dot every 2 seconds, does the stage for the state it finds, and stops when the Dot is in the state its optional target names. The table gives the stages for the default, amonet-biscuit-v1.1.0-bboe; Targets has the others, and Back to stock the stock target.

state how it is recognised what the run does
stock-booted adb get-state says unauthorized, or Fire OS 6 without root prints the fastboot gesture, and the + gesture for a Dot already on amonet v2.0.0
stock-fastboot unlock_status is false amonet v2.0.0 fastbrick
stock-fireos5-fastboot unlock_status is false, and lk_build_desc is f379dba-20170906_000423, Fire OS 5's, which amonet v1.1.0 keeps: Fire OS 5's own fastboot, locked, on a stock Dot or one on amonet v1.1.0 the same
emos no adb or fastboot, and one serial port with USB ID 1949:2007 /init recovery at emOS's serial console
amonet-v2.0.0-booted Fire OS 6, and id or su -c id answers uid 0 adb reboot recovery, into v2.0.0's TWRP; ends an amonet-biscuit-v2.0.0 run
amonet-v2.0.0-twrp unlocked, lk_build_desc is not amonet v1.1.0's, mtp in sys.usb.config makes room for amonet v1.1.0 in the table, then reboots so the kernel rereads it
amonet-v2.0.0-twrp-v1.1.0-table amonet-v2.0.0-twrp, and sgdisk --print lists boot_a_x writes amonet v1.1.0's chain and Fire OS 5.5.5.4, preloader last
amonet-v2.0.0-fastboot unlocked, lk_build_desc is not amonet v1.1.0's downgrade to amonet v1.1.0 through the bootrom
amonet-v1.1.0-fastboot unlock_status is true, and lk_build_desc is f379dba-20170906_000423 amonet v1.1.0 TEE and TWRP 3.7.0_9-bboe2, then recovery
amonet-v1.1.0-twrp recovery, amonet v1.1.0's lk_build_desc, another ro.twrp.version, mtp in sys.usb.config writes TWRP 3.7.0_9-bboe2 to recovery, reboots into it
amonet-v1.1.0-bboe-twrp recovery, amonet v1.1.0's lk_build_desc, ro.twrp.version 3.7.0_9-bboe2, mtp in sys.usb.config Fire OS 5.5.5.4, boot and /system patches, Magisk 17.3
amonet-v1.1.0-bboe-booted, amonet-v1.1.0-booted, rooted sys.boot_completed is 1 and su -c id answers uid 0; recovery hashes as TWRP 3.7.0_9-bboe2, as v1.1.0's TWRP 3.2.3, or as neither the target's state: hides the updater and checks it; another: writes the target's TWRP to recovery
  • A probe can land part way through a boot. TWRP answers adb before it sets ro.twrp.version, and while USB drops for MTP, adb shell answers with adb's error text. Either would read as amonet v2.0.0's and start a downgrade. So an lk_build_desc not shaped like f379dba-20170906_000423, or a version not starting with a digit, reads as starting. The downgrade checks the shape too.
  • An empty lk_build_desc would read as amonet v2.0.0's and repeat the downgrade, boot0 erase included. So an empty or timed-out fastboot read, and any poll that times out, reads as starting too.
  • A rooted Fire OS 6 Dot is on amonet v2.0.0, because Fire OS 6 has no root without the unlock. Its root is Magisk's su, or an adbd that runs as root and no su at all, which the XDA thread's boot-root.zip leaves. So the probe asks both. Before this state, the first read as rooted and the second as stock-booted, which asked for a gesture adb can replace.
  • A Dot unlocked with amonet v2.0.0 that boots Fire OS 6 without root also answers unauthorized, so it reads as stock-booted. amonet v2.0.0 removes stock fastboot, and the action-button gesture on an amonet v2.0.0 Dot was never tried. So the message also names + at power-on, which starts v2.0.0's TWRP.
  • On a stock Dot with LK 63cb91b, 2026-10-12, the gesture on one USB port of a Mac laptop left a solid blue ring, and the Dot showed nothing on USB. On another port the same gesture gave a green ring and fastboot. The same day, from stock 8149, the gesture on the first port went green, so the cause is not known. The gesture's message says to try again or try another port when the ring stays solid blue.
  • su answers about 27 seconds into Fire OS 5's first boot, while dumpsys package still answers Can't find service: package. So rooted also needs sys.boot_completed to be 1.
  • Each state is acted on at most once per run, so a stale read cannot repeat a stage. The downgrade ends with amonet-v1.1.0-fastboot's stage, and counts as both.
  • That stage needs amonet's fastboot, and the bootrom step does not always leave the Dot in it. On a resumed run reported from Windows the step wrote v1.1.0 and the Dot came back in v1's own locked fastboot: a solid green ring, unlock_status false, and restricted on locked hw from the first fastboot flash, which is all a locked LK answers. So the stage reads unlock_status first and returns where it is false, and a stage that returns that way counts none of the states it would otherwise pass. That leaves stock-fireos5-fastboot's own stage to unlock the Dot again, where the once-per-run rule would otherwise have the run wait out its 10 minutes on a state already counted. The chain then goes in from v2.0.0's TWRP, which is the route a rerun took to rooted Fire OS 5 in 7 min 45 s.
  • Why the Dot came back locked is not known. Its table already held boot_a_x, so the surgery had finished, and the preloader and LK were v1.1.0's. What that leaves is the microloader in amonet's own boot_a and boot_b, or the boot control block.
  • The script waits without limit for a Dot and for the fastboot gesture. Any other state unchanged for 10 minutes stops it, and the next run redoes that stage.
  • Rooting formats userdata. A registered Dot loses its Wi-Fi and its Alexa registration, and finishes in setup mode.
  • After boot0 is erased the Dot shows up only as the bootrom's serial port, which no probe sees. So the script creates boot0-erased in its cache before the erase. It deletes the file once amonet logs Reboot to unlocked fastboot, when boot0 is written, and whenever it sees the Dot booted or in fastboot, which only a preloader in boot0 reaches. TWRP does not count: it keeps running from RAM after the restore to stock clears boot0.
  • A run that finds the file and no Dot runs the bootrom step again, without the erase. A run stopped inside amonet's payload leaves the Dot there, and the payload never restarts. So the resume first sends every MediaTek port the payload's reboot command, 0xf00dd00d then 0x3000; the Dot came back as its bootrom within 5 seconds. Sent to a live bootrom on a Dot, it did no harm: the handshake that followed went through.
  • The run then waits up to those 5 seconds for each port it poked to go, so it does not open a payload's port while the payload reboots.
  • On macOS, a run stopped 12 seconds into amonet's tz write resumed this way: amonet found the bootrom 4 seconds after the run started, and the root finished.
  • From Linux, with boot0 erased, the resume went on after 30 seconds, twice, with no one touching the Dot. From macOS the bootrom gives one window per power-on: after adb reboot from TWRP it appeared 4 s later, stayed 40 s, and then showed nothing on USB for the 2.5 minutes watched. Only a replug opened another window.
  • So a resume started inside that window needs no replug: a Dot was replugged, and a resume started 8 s after its bootrom appeared took that port. The reset command had reached the bootrom first, and the handshake still went through. A resume started after the window closes asks for the replug.
  • A run stopped while amonet sends its payload, before the payload runs, leaves the bootrom inside its write command, waiting for the rest of the payload's words. The reset command reaches it as data. amonet's handshake sends 0xa0 and waits for 0x5f with no limit, and the bootrom echoes a word only after 4 bytes, so 3 reads in 4 wait out amonet's 5-second timeout. Stopped 0.3 s into a 0.8 s send, about 12 KB were left: 12 hours of handshake. The run had disabled the watchdog, so only a power cycle clears it.
  • So the handshake gives up after 10 seconds without 0x5f, where a live bootrom answers in about 2 ms. The run then asks for a replug, waits for the port to go and come back, and tries again. Under --short it stops instead: a replug without the short brings up the preloader, and the Enter that ends amonet's short prompt has already been sent, so a second short would get no countdown. Before this, the replug failed the run with Device not configured, and a rerun inside the new window went on through the root. With the limit, on a Dot stopped the same way, the resume asked for the replug 10 s after it took the port, and the same run finished the root to amonet-biscuit-v1.1.0-bboe in 9 min 6 s.
  • The replug gets its own 10 minutes for the port to return, and the step stops 30 minutes after it starts. Without that cap, a bootrom that answers 0x5f and nothing more is reopened every 10 seconds for good: a probe run reached 51 handshakes. On the erase route nothing else ends the run.

Which device is plugged in

  • Before a run that installs a target, firebreak names the device and refuses a target made for another one, before it downloads anything. report prints the same, and does not refuse. write-test does not check: it writes only cache, by name, on any device.
  • It counts every USB device from MediaTek (0e8d) and Lab126 (1949), and from Google and HTC only the IDs an Echo uses: TWRP's 18d1:d001 and 18d1:4ee2, and fastboot's 0bb4:0c01. A phone in Android's generic mtp,adb, 18d1:4ee2, still counts.
  • With ANDROID_SERIAL set, only the USB device with that serial counts. Without it, two Echo devices that name two different devices name none.
  • In fastboot it asks getvar product, which needs no authorization: BISCUIT or RADAR.
  • Elsewhere it reads the USB product string, iProduct, which the host has even when adb answers unauthorized. Fire OS sets it to ro.product.model, and TWRP to its own model. biscuit is AEOBC in Fire OS 5 and 6, Echo Dot (2nd Gen) in v2.0.0's TWRP and bboe2, and Echo Dot 2nd Gen in v1.1.0's TWRP 3.2.3. radar is AEORD and Echo (2nd Gen). The vendor and product IDs are the same on both, so they cannot tell them apart.
  • macOS reads ioreg -a -l -p IOUSB, Linux /sys/bus/usb/devices, and Windows DEVPKEY_Device_BusReportedDeviceDesc through PowerShell, which gives no manufacturer. Each read AEOBC from a Dot on Fire OS 6, the macOS one with adb unauthorized, where adb devices -l gives no model. The read took about 0.05 s on macOS and 0.8 s on Windows. radar's strings are read from its images, not from a radar.
  • A device that matches nothing, or no device, is let through: the checks before each write still guard it. The bootrom and the preloader name no product, so a Dot that shows no light is left to the bootrom step's own check.
  • Two devices can have a target of the same name: stock, and the default when no target is named. Then the device found chooses which one runs. Until one is found, the run waits, as it waits for a Dot, and says once that an Echo that shows no light never names one. With --short, or with boot0-erased in the cache, the Echo is in its bootrom, so the run stops at once and asks for --device. It never guesses: each unlock writes a preloader made for its own device, and nothing here has run one device's preloader on another. A target name that only one device has, or one --device names, is checked as above.
  • In fastboot, the report also prints antirback_pl_version, antirback_tee_version and antirback_lk_version.
  • The report shows only the first 4 characters of a serial number. Amazon's start with a prefix that may name the device type: G090 on every biscuit seen here.

A Dot on emOS

  • EchoMuse's emOS replaces Fire OS's boot image with its own init. It runs no adbd, so adb and fastboot see nothing. Its init offers a root shell on a USB serial port instead: 1949:2007, named EchoMuse and emOS, with the Dot's serial number. Measured on a Dot with emOS 0.10 on amonet v2.0.0.
  • That shell's /init recovery reboots into the bootloader's TWRP, and the port goes away within a second. From v2.0.0's TWRP, 20 s later, the root goes on as from amonet-v2.0.0-twrp. stock takes the same path: from emOS 0.10 on amonet v1.1.0 with TWRP 3.7.0_9-bboe2 it restored 8146 in 3 min 3 s.
  • Finding the port by USB ID needs pyserial, so the probe runs a child that imports it. It runs only when adb lists no Dot.
  • A port that will not open, as on Linux without the udev tty line, stops the run with the rules and the commands that add them.
  • A program that has the port open keeps firebreak out. On macOS, with EchoMuse's wizard tab still open in Chrome after it installed emOS 0.10, 2026-10-12, pyserial's open failed with errno 16, EBUSY. The run had printed Linux's udev commands, which do not apply. So EBUSY reads as busy. The udev commands are Linux's only, so any failed open off Linux reads as busy too. Windows was not measured. The message names the wizard's tab.
  • The child opens the port and sends a newline. A prompt ending in # gets /init recovery. One ending in password: stops the run: EchoMuse's dashboard sets that password, and the user types the command or clears it. Nothing that matches in 5 seconds stops the run too.
  • pyserial opens a port raw. A port left to echo, as a terminal opens one, feeds the shell its own output and every command returns 127.

A Dot that shows no light

  • A Dot whose preloader runs but never starts LK shows no light and offers no fastboot: only 0e8d:2000, the preloader, for about a second every 30 seconds. Its boot0 is intact, so only the eMMC test-point short reaches the bootrom. --short runs the bootrom step for it, with no erase.
  • Neither the gesture nor FACTFACT sent to the preloader reached fastboot. mtkclient's four ways to crash a preloader into the bootrom all failed on Amazon's: each came back as the preloader. Its register writes echoed but took no effect.
  • The bootrom step takes only a port it can identify as the bootrom, 0e8d:0003, vendor and product both, and skips a stock preloader's, which --short reports as a missed short. amonet would otherwise take whichever port appears first.
  • Before it resolves its plan, the step reads the first 512 KiB of lk_a and lk_b over the payload, so the wrong device hears that first. Each LK names its fastboot product as \0product\0NAME\0: BISCUIT in every biscuit LK, stock Fire OS 5.5.0.3 to 8149 and amonet v1.1.0's and v2.0.0's, and RADAR in every radar LK, each by 0x4c000. The step refuses an LK that names another product, and refuses when neither names BISCUIT. One LK is enough, because the plan writes lk_a, then lk_b, so a run cut short leaves the other whole. It looks only on a table whose CRCs pass; a torn table goes on to the plan, which refuses it.
  • Nothing else in this step can tell a biscuit from an Echo (2nd Generation): its table has biscuit's 16 partitions and the same bootrom answers. Without the check, --short on a radar that shows no light would write biscuit's amonet v1.1.0 chain, preloader included. This is read from the code and the LK images, not seen on a radar.
  • It reads ports from pyserial's USB list and opens only the bootrom's, where amonet opened every port to see it. On Linux the preloader's /dev/ttyACM* is dialout's, and the udev rule covers only 0e8d:0003, so amonet's way would never see a missed short.
  • It remembers each port by name and USB ID, and checks every port on each poll. The bootrom usually reuses the preloader's name: on Linux both are /dev/ttyACM0, and macOS names a port by its USB location. A port whose ID cannot be read yet, or that will not open yet, is looked at again on the next poll.
  • --short applies only to a Dot first seen with nothing on USB. Any other state turns it off, so a later reboot cannot start the short flow. That includes starting, which covers a probe that timed out as well as a Dot half started; after a timeout the run asks for fastboot, and a rerun with --short goes on.
  • amonet takes any port that appears after it lists the ports at start. So --short asks for the short only once amonet logs that it is waiting, and sends no reset first: a Dot plugged in earlier would be in that list and never count as new.
  • The short must be off before the payload starts the eMMC, its first act. Nothing before that touches the eMMC, and the handshake disables the watchdog. So the run holds amonet at its "Remove the short" prompt, counts down 5 seconds, then lets it go on.
  • amonet's first write clears boot0's header. Under --short its bootrom step creates boot0-erased just before each write, after amonet's first boot0 check, as the erase path creates it before the erase. A run that stops before that leaves boot0 intact and no file. A run that fails or is stopped after it is resumed by a plain rerun.
  • The bootrom step logs the error for a port it still cannot open after 1 second, once, and the run shows it at once. udev can set a new port's mode a moment after it appears, so a first failure alone means nothing. A missing udev rule or ModemManager would otherwise hold a --short run silent for 10 minutes.
  • The payload starts the eMMC once. With the short still on, either it never comes up and amonet times out waiting for it, or the bootrom step's first read, the partition table's 55 AA, fails. Both stop the run before amonet writes anything, and a new short starts over.

Recording a run

  • The simulator runs firebreak against a simulated Dot, and records every command for a diff.
  • --verbose prints each adb and fastboot command and its exit status with the time, and each state the run sees. The 2-second polls are not printed.
  • In verbose mode, or when the output is not a terminal, a stage prints a line when it starts and when it ends, with no running count. In verbose mode both lines start with the time. Output that is not a terminal also gets no download meter. Either would fill a log with \r frames.
  • A running stage shows a Braille spinner where its mark will go, and a finished stage prints ✅. Where the output's encoding cannot represent them, the spinner is |/-\ and the mark is done. On Windows that is output redirected to a file: since Python 3.6 a console reports UTF-8 whatever its code page.
  • A finished line is at most 79 columns, so it does not wrap on an 80-column terminal. That caps a stage label at 36 characters.
  • Steps are numbered from 1 in each run, against the total that Targets says how the run counts. The restore stage is 16 of them, and 17 from a rooted Dot, with the reboot into recovery. A restore write whose target already reads back the right md5 ends skip, in place of its seconds.
  • A plan carried out from TWRP prints one line per stage, not one per write. A root from stock printed 22 lines, 12 of them writes that took under a second. The boot0 clear, the table and every write but TWRP and the preloader share one line, such as write amonet v1.1.0-bboe. TWRP and the preloader keep their own lines, the preloader because a stop after it cannot be undone. Other writes in a row that share a label share a line, such as the table's primary and backup. A shared line ends skip only when every write in it was skipped. A root from stock now prints 11 lines. --verbose still prints every command. The bootrom step still prints one line per write.
  • Each stage's estimate is an upper bound: the slowest time measured for it on any host, plus 5%, rounded up to the next 15 seconds, or the next 5 seconds under a minute, over roots on macOS, Linux and Windows 11. An estimate is only ever raised. It is in seconds, as the time beside it is. On 2026-10-12 Fire OS 5's first boot took 319 s on Linux against 240 s, and its /system 108 s on Windows 11 against 100 s. A Fire OS 6 slot took 119 s on Linux, 1 s under its 120 s. A Mac took 272 s, 41 s and 85 s. One case is left out: a first run on a computer that finds the Dot already in TWRP waits up to 35 s more for the system image.
  • The estimates and the rings are from TWRP 3.2.3. One root on macOS with TWRP 3.7.0_9-bboe1 came in under every estimate. Its replace step took 37 s on a Dot.

The ring during firebreak stock 6302, from a rooted Dot not set up:

ring when
purple rooted Fire OS 5, setup timed out (anim_OOBE_start_error)
off, about 12 s adb reboot recovery
deep blue, about 10 s, a brighter segment turning in its last 3 v1.1.0's TWRP starting, before adb answers
a blink off, then a cyan arc turning, about 3 s TWRP up: adb wait-for-recovery returns
cyan, whole ring, steady through the restore's steps
off, about 8 s the reboot into stock
deep blue, about 50 s stock Fire OS 6 booting
cyan and blue, turning Fire OS 6 starting Alexa
orange setup mode, about 90 s after the reboot

The ring during a root from stock 8138, in daylight, 14 min 49 s in all. That root went through the bootrom; a root from stock now does not:

ring when
green stock fastboot, from the button held at power-on
off 1 s, then yellow, shrinking to an arc over about 10 s the fastbrick flashed
red-orange, about 7 s, then green, about 5 s, then off, about 5 s the exploit, before TWRP
blue, about 9 s v2.0.0's TWRP starting
a white flash, a blink off, a cyan arc v2.0.0's TWRP up; the script reboots it at once
off, about 7 s adb reboot bootloader
a white flash, then every color, turning, about 5 s the exploit's unlocked fastboot
off, about 5 min the bootrom step: boot0 erased, then amonet v1.1.0
blue, about 10 s, then every color, turning, about 3 s v1.1.0's fastboot, while tee2 and recovery are flashed
off, about 5 s fastboot oem reboot-recovery
deep blue, about 13 s v1.1.0's TWRP starting
blinks off, a cyan arc turning, about 5 s adb answers, MTP not yet on
cyan, whole ring, steady from MTP on, through the Fire OS push and install
off, green, off, green, off, about 5 s the Fire OS install finishing
cyan, whole ring, steady the boot image, /system and Magisk
off, about 6 s the first reboot into Fire OS 5
deep blue, about 35 s Fire OS 5 booting
cyan and blue, turning, about 3 min the first boot, until the setup app starts
orange setup mode, about 1.5 minutes before the script finishes

A run from stock 5041, at night, showed the same, except that the fastbrick ring read white.

  • The ring turns orange before the boot is done. On a Dot, 2026-10-04, the setup app started and the ring turned orange 30 s before BOOT_COMPLETED went out. The first su came 13 s after that. The last step ended about a minute later, 322 s after adb reboot. The end is an estimate from the Dot's uptime, because the step logged no time. The minute held Magisk's first su, the 2-second probes, and hide_updater's dumpsys package.

Reporting a problem

  • An issue report used to carry the failing step and nothing about the Dot. report collects firebreak's version, the host, the state, the build, the eMMC, the partition table with each name's node, and the kernel's own mmc lines. The version matters because uvx firebreak@latest can run a newer firebreak than the run that failed.
  • Only a recovery answers all of that, so it starts one. A Dot already in a recovery is left alone: the one a failed run left is the safe place to be, and a first version cost a reporter theirs.
  • system_a is the reason it exists. Fire OS 5's image is 805,257,216 bytes against a 805,306,368-byte partition, so the write has 48 KiB of headroom and nothing checks it. Build 8149's report showed only a mismatched md5.
  • Wear comes from sysfs where a part reports it, and the erase count only from the kernel log, which reaches back about 3,000 seconds, so uptime is printed beside it. cid and serial are left unread, and GUIDs and the Dot's serial are masked: the run sets ANDROID_SERIAL to the Dot it picks, and the mask reads it.
  • The kernel log's mmc lines are its last 60, and the card's first line is read on its own, so a log that still reaches the boot says so even after 60 later lines.
  • Each answer the report parses into claims comes behind a marker of its own, because adb cuts the tail: sgdisk's table, /proc/partitions, the by-name listing and the kernel log's two reads. One that does not arrive whole is printed as it came, or not matched at all, rather than parsed into claims about which names point nowhere. The other answers are printed as they came. A failed system write discards the image's first slice and keeps the record of its size, which is the number a report is run for.
  • The report reads the Dot's state without the root run's probe, which stops on a partition table it cannot read: a damaged table is the case a report is run for. Any recovery is left alone, and one that is not TWRP stops the report rather than being restarted. A Dot that runs Fire OS without root is not restarted either, because its recovery is Amazon's. An unlocked fastboot is restarted into its recovery, which holds Amazon's until a root run writes TWRP; when no adb recovery answers within 3 minutes, the report says that is the likely reason. The Dot is left in its recovery afterwards, and the report ends by saying so.
  • write-test separates the card, the cable and the host, which a failed install's md5 cannot. It writes one pattern over two paths that share no hardware: into /tmp, which is RAM, for adb and the cable with no eMMC in it; and from the Dot to its own cache, for the card with no USB in it. The eMMC leg writes the file the USB leg verified, so both carry the same bytes, and it is skipped when the USB leg proved nothing.
  • It writes over cache, which holds nothing the Dot needs, so a part-written system_a stays as evidence. The two amonet-biscuit-v1.1.0 targets do not format cache, so the filesystem this test leaves is the one the Dot boots with. The pattern is not zeros, because a card that writes nothing passes a zero test. It is every byte value followed by a word, so nothing a transport mangles goes unnoticed, and its odd length makes a slice written at the wrong offset hash differently.
  • cache is formatted first, and small enough that its own blocks end well before the pattern starts, so the writes stay raw, like the /system write they stand in for, nothing is mounted while they land, and a run stopped part way leaves a Dot that still boots. The layout is pinned rather than inherited, because the boundary has to be an input: -J size=4 -N 8192 with -O ^sparse_super,^resize_inode -E packed_meta_blocks=1 puts the primary superblock, the group descriptors, the bitmaps, the inode tables and the journal inside the first 6.11 MiB of a 784 MiB cache, measured, against a 16 MiB margin. ^sparse_super also puts a backup superblock in every block group, at 128, 256 and up to 768 MiB (mke2fs 1.47), and the pattern overwrites those. The filesystem still mounts on its primary, and the last format writes all of it again: a filesystem over all of cache but its last 1 MiB.
  • A write that does not answer in 30 minutes leaves cache as it is. dd may still be writing on the Dot, and a format under it could leave a filesystem that reads as fresh and is not. The one already there ends before the pattern starts, so the Dot still boots, and a rerun formats it first.
  • e2fsck will not catch it if that boundary is wrong. With the default layout the journal is one extent at 12.33-28.33 MiB, and after overwriting from 16 MiB e2fsck -fn passed all five passes on a filesystem missing 12.3 MiB of its 16 MiB journal: the journal's superblock survived, and its body is not checked on a clean filesystem. A mount, a write, an unmount and a second e2fsck are what prove a journal works.
  • A test that cannot run must not read as a card that failed. So the digest comes back behind a marker, the writing dd's errors are kept, and a refused umount, a target that is not a block device and a format that did not happen each say so: the read-back names the node it wrote, and a file in TWRP's RAM /dev would match itself. The reverse holds too: a dd that cannot write the card ends with a marker of its own, which blames the card. Each skip says which of its four reasons it was. The whole command is one line with no quotes, or Windows loses it.
  • Measured on bryce in TWRP 3.7.0_9-bboe2: 128 MiB over USB at 20 MiB/s, then 768 MiB written and read back at 18 MiB/s, both digests matching.

A cache with no filesystem

  • A Dot whose cache holds no filesystem roots fine and then cannot boot. fs_mgr_mount_all fails in init's on fs, so post-fs-data and class_start main never run: zygote, netd and installd have no init.svc.* property at all, while every class core service is up. So the Dot answers adb as root, su works, /system is mounted and read correctly -- and sys.boot_completed never arrives. It reads like a bad /system or a slow first boot. The reason is in dmesg, not logcat: e2fsck: Bad magic number in super-block, then fs_mgr: Failed to mount an un-encryptable or wiped partition, then fs_mgr_mount_all returned unexpected error 255.
  • amonet-biscuit-v2.0.0 wipes cache and stock formats it. The two amonet-biscuit-v1.1.0 targets format userdata only, so a cache damaged before a run survives it.
  • write-test --short runs the test through the bootrom instead, for a Dot that shows no light and so has no recovery. It reads the last 64 blocks of cache three times, then writes 1, 34 and 64 blocks there and reads each back, then writes the first read back. 34 is the partition table, the first bootrom write of a --short run; 64 is the most one command carries.
  • v2.0.0's payload answers a block write only when the eMMC took it, and the same silence follows when the bytes never all arrive. So after a write with no answer the test sends a 4-byte memory read, 0x5000. A payload that answers took the whole write and refused it, which blames the card; one that answers with the write's own acknowledgement was slow; one that stays silent is still waiting for bytes, or for the card.
  • After a write the card refused or answered late, the test puts cache's blocks back. After one the payload lost track of, it cannot, and says to unplug the Dot: the pattern stays at the end of cache, which holds nothing the Dot needs.
  • A late answer is followed by the ping's own 4 bytes. When they do not come, the ping filled the end of a write that lost bytes on USB, so that blames USB, not the card.
  • It reads the table first and skips a table that is not intact, so a stale cache entry cannot point the writes at another partition.
  • Measured on a Dot from Windows 11, with boot0's header cleared so no short was needed: three reads agreed, writes of 1, 34 and 64 blocks read back unchanged, and cache read back as it was.

The host

  • Python 3.9 or later, because stock macOS's /usr/bin/python3 is 3.9.6. CI runs its --help on that interpreter, and tests/test_python_floor.py checks that the package uses no module, attribute or import 3.9.6 lacks.
  • The standard library only, so the script runs on stock macOS, Linux and Windows. adb and fastboot are the only external tools.
  • A Python from the Microsoft Store writes under %LOCALAPPDATA% into its own Packages\PythonSoftwareFoundation.Python.3.13_qbz5n2kfra8p0\LocalCache copy, and the processes it starts do not see that copy. Measured on Windows 11 with Store Python 3.13.14, alone and under a uv environment: Python saw a file it wrote, and a cmd it started in that folder did not, by a relative or an absolute path. The fastbrick's fastboot loads its image from the cache, which fits one user's run on that Python: each of its 10 flashes exited 1 at once. The run stops when its Python, or the one its environment was made from, is under WindowsApps. It names uvx --managed-python, or for a pyz, a python.org python.exe by its full path or uv run --managed-python --no-project python. It does not name py: the launcher lists the Store Python too, and py -3.13 ran it on that host.
  • On a terminal, ERROR: lines are red and warnings yellow. NO_COLOR, TERM=dumb, a pipe or a file turns that off. On Windows only Windows Terminal (WT_SESSION) gets color; the old console prints the escape codes as text. A blank line separates one kind of message from another.
  • adb must be 1.0.36 (platform-tools r24) or newer: 1.0.32 answers wait-for-recovery with unknown host service and passes shell -n to the device. Ubuntu 22.04 and 24.04 ship 1.0.41. fastboot must accept -S, as every release back to r19 does. Every run checks adb version and fastboot --help for -S, the stock target included, because a route to stock can pass through fastboot. Old releases, run with no device, set these floors.
  • Downloads go to $XDG_CACHE_HOME/firebreak (default ~/.cache/firebreak), or %LOCALAPPDATA%\firebreak on Windows. The script moves what the old overdub-firmware, overdub-root and overdub-stock directories hold into it before it reads the cache, so an interrupted downgrade's marker survives. A name already in the new directory stays in the old. Each is checked against a pinned SHA-256 before use and on every run. The amonet trees unpacked from the two zips are not, and neither is the system image built from Fire OS's.
  • The system image's directory is named after the zip's hash, so a new zip builds a new image. Its md5 file is written last and synced with the slices before the directory is renamed into place. It names the image's md5, its 4 KiB blocks and its slice count, and the script trusts a directory only when all three are there and every slice starts with the gzip magic. It rebuilds otherwise, so a directory from a firebreak that wrote one system.img.gz is rebuilt, and one from overdub's dot_firmware.py, which writes the same slices, is used as it is. If the md5 read back still fails after the last try, the script deletes the first slice, which works even when another process holds the rest open.
  • Each stage names the downloads it uses. A run fetches those of the stages on the route from the Dot's state to the target's goal, and no others. A stock run that finds TWRP fetches only the build. A run to amonet-biscuit-v2.0.0 from v1.1.0's TWRP does not fetch Fire OS 5. A rooted Fire OS 5 Dot needs TWRP 3.7.0_9-bboe2 only when the target is amonet-biscuit-v1.1.0-bboe.
  • tests/test_needs.py checks each stage's downloads against its code, for every target. It reads the source of the stage and of each firebreak function it calls, and collects the downloads passed to fetch and unpack. It runs nothing. It decides an if only when the condition has no call, such as a check on the target's goal; otherwise it takes both branches. A stage must name every download its code always makes, and nothing else. The one exception is v1.1.0's zip in the LK check, below, which a stage may name.
  • amonet v1.1.0's zip is a need only where the Dot still runs v1.1.0's LK. On v2.0.0's TWRP the LK check matches v2.0.0's lk.bin first, so a run to amonet-biscuit-v2.0.0 from stock fastboot does not fetch it. Were lk_a to match neither, the check would fetch it itself.
  • The run downloads nothing while it waits for a Dot it has not seen. That Dot can be another device, or can need nothing. A Dot that waits where the next state is known starts the downloads in a background thread: an unmodified Dot, booted, comes back in its own fastboot after the gesture, and amonet v2.0.0's fastboot comes back as its TWRP. So the downloads overlap the gesture.
  • boot-root.zip comes through the browser, so the background thread never fetches it. The main thread fetches it before the other downloads, so the one step that needs the user comes first and does not land in the gesture's instructions.
  • The run names the device before it starts that thread, and again before the first stage. A device plugged in after the run started is then refused before the route's downloads. The probe of a rooted Fire OS 5 Dot still fetches amonet v1.1.0 first: it compares recovery with v1.1.0's TWRP to name the state. For TWRP 3.7.0_9-bboe2 it compares against the pinned size and SHA-256, so it downloads nothing.
  • The main thread waits for that thread's locks half a second at a time. On Windows, Ctrl-C does not interrupt a blocking lock wait, so one long wait would ignore it until a 397 MB download ended.
  • The downloads are at most about 476 MB. With the unpacked trees and the system image, the cache holds about 1.3 GB.
  • Before each stage, the script waits for every download on the route and checks it. A bad download then stops the run with the Dot unchanged. A route can end before the goal: emOS reboots into a recovery the run names only when it sees it. The next stage then downloads the rest.
  • The downloads run one at a time, in the route's order, so the first stage's come first: amonet v2.0.0's zip for the fastbrick. Fire OS is 397 MB of the 476, and one download filled the line at about 40 MB/s, so parallel downloads would only delay that zip.
  • Nothing deletes a download, so a later run downloads nothing. A rooted run prints the cache's path and size and says it is safe to delete.
  • The emOS probe and the bootrom step need pyserial, which a wheel or source install declares as a dependency, pinned to the same version the download holds. A test reads the pin out of pyproject.toml and checks it names that wheel, so the two cannot drift.
  • A pyz carries no pyserial, so the run fetches the pinned pure wheel from PyPI when the host has none and imports pyserial from that zip: on this process's sys.path for the bootrom, on the emOS child's PYTHONPATH. A host with pyserial fetches nothing.
  • A run asks PyPI once whether a newer firebreak exists, through update_checker, after it refuses root and before it starts adb, so a root run cannot leave a root-owned ~/.cache. It prints a notice only when one does, with the way to upgrade: the latest release's firebreak.pyz for a pyz, and uvx firebreak@latest for any other run, as the README gives both. The command or link goes on a line of its own, outside the 79-column wrap, so a copied line holds all of it.
  • The check keeps each answer for an hour. --no-update-check skips it. A child never checks.
  • Each request has a 1 s timeout: with PyPI unreachable through a dead proxy, a run that stopped at the adb check took 1.2 s. The timeout does not cover DNS: with a resolver that never answers, the same run took 8.8 s. A failed check is also kept for an hour. The check catches every exception, so it cannot stop a run.
  • The pyz bundles update_checker: its 2 modules and its dist-info, which holds the BSD-2-Clause notice. The build installs the pinned dependencies from pyproject.toml into build/bundle and zips firebreak and update_checker* from there. The bundled version is the pin, and a test checks the pyz holds it and no pyserial.
  • The pyz has no dist-info for firebreak, so firebreak's version is __version__ in firebreak/__init__.py. hatchling reads the package's version from there too, and a test checks the installed one equals it.
  • The one child left is emOS's, python -m firebreak _child emos, with the pyz or site-packages on its PYTHONPATH. A path to re-run would not do: the wheel's Windows launcher is an .exe.
  • -m puts a child's working directory first on its import path, so a child runs in the cache or amonet's modules, never where the run started. A firebreak directory there would replace the package, and a serial.py the pinned wheel. The child drops that entry before it does anything else. It checks the entry first: under PYTHONSAFEPATH Python adds no such directory.
  • adb get-state reports unauthorized on stderr, so the script reads both streams.
  • Without ANDROID_SERIAL, the run uses the one Dot on USB in adb devices -l, and ignore network adb: a rooted Dot with tcp/5555 open would make adb get-state answer more than one device/emulator. The run picks again on every poll, because each reboot drops the Dot off USB. With two Dots on USB the run stops and asks for ANDROID_SERIAL, which fastboot follows too.
  • An ANDROID_SERIAL of the form host:port is refused: TWRP starts no Wi-Fi, and fastboot and the bootrom are USB-only.
  • Windows 11's adb prints no usb: field, so there a line counts as USB unless its serial is host:port or emulator-*.

What adb shell carries

  • Binary from the host goes by adb push, binary from the Dot by adb exec-out, and adb shell carries only text whose shape is checked. The one exception is the /system write, which puts the image on adb shell's stdin where a probe has shown that this host's adb carries 1 KiB holding 0x1a unchanged, and pushes it in pieces where it has not.
  • A Windows adb reads its own stdin in text mode, where 0x1a ends the file, so adb shell truncates a stream there. Every other byte survives: NUL, CR, LF and EOT all arrive. Fire OS 5's system image holds 1,421,626 of them and the first is at offset 238, so 238 of 385,745,930 bytes reach the Dot and gunzip writes 12.
  • Nothing reports it. A pipeline carries its last command's status, so gunzip fails, dd exits 0, and the only symptom is an md5 that does not match -- which reads as a failing eMMC. On Fire OS 6, which has no gunzip at all, the same stream also exited 0 having written nothing. So each such command ends in a word of its own, gunzip touches a file when it fails, and dd's own status ends the pipeline. The word is what the host accepts: silence is a failure, because adb shell can exit 0 whatever happened remotely, and a dd that cannot write the card would otherwise read the same as a stream that arrived.
  • gunzip reports a damaged gzip but not a missing one. toybox 0.7.6 exits 1 on a stream cut at 0x1a (gzclose: Illegal seek) and on a corrupted body (gzread: incorrect data check), so the marker appears for both. On input with no gzip magic it exits 0 and behaves like cat: 17,000 bytes in, 17,000 straight out, which dd would then write to the partition. So each slice is checked for the gzip magic on the host before the cache is used. A build that did not finish never reaches the cache: it is renamed into place only after its md5 file is written.
  • A slice that arrived intact, by its md5 on the Dot, and still did not unpack can only be bad on the host. The sliced route deletes it and stops, so the next run rebuilds the image rather than failing the same way. The stream route cannot tell which slice failed, so it unpacks each on the host and deletes the ones that fail there too.
  • Outbound is the same class. adb shell on Windows expands LF to CRLF, so the partition table, its backup copy and misc's boot control block come off the Dot by exec-out, and each read checks its own length.
  • exec-in is not the way round it. It is binary-safe, but it returns before the Dot has finished -- 4.7 MB still in flight at 16 MiB, complete and identical 5 s later -- and reports no status at all: exec-in 'exit 7' answers 0. On amonet v2.0.0's TWRP a pipeline through it either fails at once or hangs with nothing started on the Dot.
  • Measured on one Dot from two hosts minutes apart, same recovery: Windows 11 with adb 36.0.1 against macOS with adb 37.0.0.

Linux permissions

  • adb, fastboot and the bootrom's serial port need the Dot's USB devices open to the user. Ubuntu's android-sdk-platform-tools-common and android-udev-rules, which LineageOS points to, list other Lab126 products but not a booted Fire OS 5, 1949:0110. Fire OS 6 is MediaTek's 0e8d:201c, which Ubuntu's package marks as an adb device and a distribution without it leaves to root. Other guides for this Dot use sudo. These rules, in /etc/udev/rules.d/51-echo-dot.rules, cover every state, the serial port included:

    SUBSYSTEM=="usb", ATTR{idVendor}=="1949", MODE="0660", GROUP="plugdev", TAG+="uaccess"
    SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", ATTR{idProduct}=="4ee2", MODE="0660", GROUP="plugdev", TAG+="uaccess"
    SUBSYSTEM=="usb", ATTR{idVendor}=="18d1", ATTR{idProduct}=="d001", MODE="0660", GROUP="plugdev", TAG+="uaccess"
    SUBSYSTEM=="usb", ATTR{idVendor}=="0bb4", ATTR{idProduct}=="0c01", MODE="0660", GROUP="plugdev", TAG+="uaccess"
    SUBSYSTEM=="usb", ATTR{idVendor}=="0e8d", ATTR{idProduct}=="0003", MODE="0660", GROUP="plugdev", TAG+="uaccess"
    SUBSYSTEM=="usb", ATTR{idVendor}=="0e8d", ATTR{idProduct}=="201c", MODE="0660", GROUP="plugdev", TAG+="uaccess"
    SUBSYSTEM=="tty", ATTRS{idVendor}=="0e8d", ATTRS{idProduct}=="0003", MODE="0660", GROUP="plugdev", TAG+="uaccess"
    SUBSYSTEM=="tty", ATTRS{idVendor}=="1949", ATTRS{idProduct}=="2007", MODE="0660", GROUP="plugdev", TAG+="uaccess"
    

    Then sudo udevadm trigger. A rooted Fire OS 5 is 1949:0110 (adb alone, set in its boot image; the mtp,adb Fire OS 5 otherwise runs is 1949:0112), a rooted Fire OS 6 8146 0e8d:201c (adb alone, one interface), TWRP 18d1:d001 and 18d1:4ee2, fastboot 0bb4:0c01, the bootrom 0e8d:0003, and emOS's serial console 1949:2007. The usb line for 1949 covers emOS's USB device but not its /dev/ttyACM* node, which is dialout's without the tty line.

  • uaccess covers a user at the machine; over SSH the user must be in plugdev. A new group reaches only new processes, and a running adb server keeps its old permissions: with the group added and the old server up, adb devices listed nothing. So after adb kill-server, sg plugdev -c runs a script with the group, without a new login.

  • The script refuses to run as root: the downloads would belong to root, and the rules make sudo needless.

  • It checks at start that the process has plugdev, even for a desktop user covered by uaccess, so the check is one question. A user not in the group gets the rules and the commands to add them, starting with groupadd -f plugdev because Fedora and Arch have no such group. A user in /etc/group whose shell predates that gets adb kill-server and the sg line. The sg line repeats the command as run, interpreter, path and flags included, because ./firebreak.pyz is wrong from another directory.

  • Without the rules adb lists the Dot as no permissions. When that lasts 5 seconds and nothing else listed is usable, the run stops and prints the same rules and commands. A shorter spell is udev still setting permissions. A phone the user cannot open, beside a Dot that answers, does not stop them.

Unlock: amonet v2.0.0

  • The fastbrick image is fastbrick-20221007.img when lk_build_desc is 63cb91b-20221007_072309 or 41fb3ce-20221007_151724, else fastbrick.img. That one payload takes both bootloaders, where amonet v2.0.0's own map lists only the first. The second ships in 4310, 4313 and 4315, which the default image does not unlock.
  • A flash that returns means the exploit did not start, so it is retried, up to 10 times. A flash still running after 8 seconds means the exploit is running: the script stops fastboot and leaves the Dot to reach TWRP.
  • eMMC-RO or Device mismatch means the payload refused, and the Dot is unchanged.

Targets

target ends in stops at
amonet-biscuit-v1.1.0-bboe, the default amonet v1.1.0, TWRP 3.7.0_9-bboe2, rooted Fire OS 5.5.5.4 amonet-v1.1.0-bboe-booted
amonet-biscuit-v1.1.0 the same, with v1.1.0's own TWRP 3.2.3 amonet-v1.1.0-booted
amonet-biscuit-v2.0.0 amonet v2.0.0's TWRP, Fire OS 6 8146 in both slots, boot-root.zip amonet-v2.0.0-booted
stock [BUILD] Amazon's Fire OS 6 BUILD, 8149 when left out, the whole Dot erased stock-booted
  • Each poll picks the stage for the state found and the target, so a run goes from any state to any target, a rooted Dot included. Every state has at most one stage per target, so a table does what a search over the stages would.
  • Each stage in the table says how many steps it prints and which state it leaves the Dot in. Before each stage the run walks the table from the current state to the target's and shows that total. A walk that meets a state it cannot predict past, such as emOS, whose TWRP depends on the chain under it, shows ?. A Dot that lands somewhere else is walked again from there, so the total changes only when the Dot surprised the run. Step numbers only go forward.
  • amonet-biscuit-v1.1.0 and amonet-biscuit-v1.1.0-bboe write the same /system, boot image and Magisk. Only recovery differs, and the write from v2.0.0's TWRP puts the target's own TWRP there. A rooted Dot moves between the two with that one write, from Fire OS through su.
  • Booted Fire OS 5 has no md5sum. So the probe and the write read recovery back through adb exec-out and su, and hash it on the host. On a Dot, 13,953,024 bytes took 2.2 s. One read, at the larger image's length, answers for both TWRPs.

amonet-biscuit-v2.0.0

  • In v2.0.0's TWRP it runs twrp wipe cache, twrp wipe data, and twrp install of the OTA twice, with a reboot into recovery between. Then it runs twrp install of boot-root.zip and reboots.
  • It pushes the OTA to /sdcard before each install. A /sdcard on a /data that did not mount is TWRP's RAM: the push and its read-back pass, and the reboot loses the file.
  • The XDA procedure flips the active slot with bcbtool between the two installs. The run does not: a TWRP install of an A/B OTA writes the inactive slot and makes it active, so the flip would make the second install write the same slot again. The run reads bcbtool get_active before and after each install, and stops unless it changed.
  • boot-root.zip patches both slots. It is an XDA attachment, and XDA sends a script a JavaScript challenge in place of the file. So before it touches the Dot, the run opens it in the user's browser and waits up to 2 minutes for it in ~/Downloads, matched by size and sha256 de49cc88.... With no graphical browser, the run stops and says where to get the file.
  • Once the cache holds the file, the run deletes its copies from ~/Downloads. A new file with other bytes is deleted only when the browser recorded the attachment as its source.
  • Safari unpacks a zip into ~/Downloads and moves the zip to the Trash. So on macOS the run also looks in ~/.Trash. When it opened the browser and took the file from there, it also deletes a new directory that holds exactly the zip's files.
  • Fire OS 6 without root for a minute after the install means boot-root.zip did not take, and the run stops. A single such read during the first boot does not stop it.
  • From amonet v1.1.0 the run carries out amonet v2.0.0's plan from either amonet v1.1.0 TWRP, in place of its zip, the XDA route from amonet v1.1.0. A rooted Dot first goes to recovery. The plan resolves against the Dot's table before anything is written, so a table that does not fit is refused with nothing changed.
  • boot0's header is cleared first, with boot0-erased, as the zip copy did and the restore does. From there to the preloader, any stop lands in the bootrom, and the resume writes amonet v1.1.0 and goes on from its TWRP. A stock table under amonet v1.1.0's LK would not boot, so nothing is written before the clear.
  • Then the table goes back to stock, as the zip's restore_gpt does, by the TWRP route below. Only userdata and the two appended partitions change, so every partition the plan writes keeps its node, and no reboot comes between.
  • Then the TEE, both LKs, kaeru and the TEE payload are written and read back, and misc is zeroed, as the zip does. v2.0.0's TWRP comes after them, so they share one line, and the preloader is written last. The plan's RPMB zero, FASTBOOT_PLEASE and reboots are left out, as for v1.1.0's chain from v2.0.0's TWRP; the run reboots into recovery itself.
  • The zip wrote the preloader first. A stop after that left a valid preloader in front of a mixed LK and TEE: no bootrom, and maybe no LK, so only the eMMC short reached it. The zip is no longer installed, and no patched copy of it is built.
  • Cut on a Dot, with the patched zip this replaced: a reboot from the Dot itself when the zip logged Updating tz, after LK and before the preloader. The bootrom appeared 4 s later, the same run's resume took it with no one touching the Dot, wrote amonet v1.1.0, installed the zip again from bboe2, and went on to amonet v2.0.0: [1/8] to [13/13], 6 min 43 s in all. This TWRP has no /proc/sysrq-trigger and its reboot takes no -f, so the cut was a plain reboot. The plan clears boot0 first and writes the preloader last, as that copy did.
  • A resume without an erase or --short that sees the preloader, 0e8d:2000, takes boot0 as intact: it stops the bootrom step, deletes the marker, and goes on polling. On a Dot rebooted from Fire OS 6 with the marker set by hand, the run said so 2 s later and ended at amonet-v2.0.0-booted, with nothing written.
  • An amonet v1.1.0 Dot started in fastboot reads as stock-fireos5-fastboot, and the fastbrick leaves it in v2.0.0's TWRP on amonet v1.1.0's partition table. The probe reads that as amonet-v2.0.0-twrp-v1.1.0-table, and for amonet-biscuit-v2.0.0 the run carries out the plan there first. The amonet v2.0.0 install itself stops if boot_a_x is still there, or if sgdisk prints no userdata.
  • Fastboot cannot tell the two tables apart, and a stock Fire OS 5 Dot is the common case. So stock-fireos5-fastboot's stage plans for the stock table, as stock-fastboot's does. Where the probe then finds amonet v1.1.0's table, the step counter plans again from there.
  • In v2.0.0's fastboot the run asks for the + button at power-on, which starts v2.0.0's TWRP, and waits for it without limit.
  • --short goes with any target. Its bootrom step writes amonet v1.1.0, and the run goes on from amonet v1.1.0's TWRP like any other.
  • Measured on a Dot, 2026-10-04, each with no one touching the Dot:
from to took
amonet-v1.1.0-bboe-booted amonet-biscuit-v1.1.0 9 s
amonet-v1.1.0-booted amonet-biscuit-v1.1.0-bboe 9 s
amonet-v1.1.0-bboe-booted amonet-biscuit-v2.0.0 5 min 11 s, on 2026-10-09, by the plan: recovery 28 s, boot0 under 1 s, the table 1 s, the writes 5 s with TWRP 3 s of it, the preloader under 1 s, recovery 19 s, the wipes 4 s, the installs 96 s and 116 s, boot-root.zip 8 s, Fire OS 6's boot 24 s. The zip took 5 min 22 s, its install 19 s and its reboot 19 s
amonet-v2.0.0-booted amonet-biscuit-v1.1.0 8 min 28 s through the bootrom, which this starting state no longer uses, kept to compare against: recovery 19 s, the downgrade 43 s, TWRP 27 s, the install 86 s, Fire OS 5's first boot 321 s, TWRP 3.2.3 7 s
amonet-v2.0.0-booted amonet-biscuit-v1.1.0-bboe 7 min 42 s, with no bootrom: recovery 19 s, the table 1 s, recovery 19 s, boot0 under 1 s, the chain 6 s, misc under 1 s, userdata 4 s, /system 79 s, the boot image 3 s, Magisk 1 s, the preloader under 1 s, Fire OS 5's first boot 321 s
stock-fastboot amonet-biscuit-v1.1.0-bboe 8 min 3 s, with no bootrom: the fastbrick, then the same route as from amonet-v2.0.0-booted
amonet-v1.1.0-bboe-booted stock 8146 3 min 11 s: recovery 30 s, system_a 81 s, the rest under 7 s each, stock's first boot 32 s
  • bcbtool get_active printed a bare a or b, and each OTA install changed it. The downgrade ran on the stock partition table the zip left.
  • Not run: the plan on amonet v1.1.0's partition table in v2.0.0's TWRP, and --short to amonet v2.0.0.

The TWRP route

Both installs from an amonet TWRP are one route: amonet v2.0.0's plan from either amonet v1.1.0 TWRP, and amonet v1.1.0's from v2.0.0's TWRP.

  • The plan resolves against the table on the disk, less the steps TWRP does another way: the RPMB, forcing fastboot, the reboots, and boot0's header, which the route clears itself. It writes zeros there, as the v2.0.0 install and the stock restore did; amonet v1.1.0's plan writes EMMC_BOOT and then zeros, and either leaves the bootrom no preloader. A table that does not fit is refused with nothing written.
  • When the plan's table moves a partition it writes, the table goes in alone and the Dot restarts into recovery, because BLKRRPART is EBUSY while TWRP is up. The next run finds the table in place and writes the rest. Otherwise every partition the plan writes must be a block device of the size the table gives, or the Dot restarts into recovery with nothing written: a kernel holding another table would put a write by name in RAM.
  • Then boot0's header is cleared, with boot0-erased set, then the table, the writes but TWRP, TWRP, and the preloader last. amonet v2.0.0's writes end with zeroing misc, as its zip does. amonet v1.1.0's route installs Fire OS 5.5.5.4 between TWRP and the preloader.
  • The table is the layout's own bytes, both copies, written by dd and read back like the rest. The backup copy goes first, so a stop between the two leaves the old primary in front and the next run writes whichever copy differs.
  • The backup copy lies at block 7651295 of mmcblk0; every other write goes to a partition's own node, near its start. bboe2's and v2.0.0's TWRPs carry a dd that answers EINVAL past block 4194304 beside toybox dd, which reaches it, and the writes take toybox dd where it exists. TWRP 3.2.3 has no toybox, and its dd, busybox's, reaches it. losetup -o reads 0 bytes there. So before anything is written, the route reads that block with the dd it writes with, and refuses a table it cannot reach.
  • The layout also wipes userdata's first blocks. This route leaves that out: amonet v1.1.0's step formats userdata, and amonet v2.0.0's installs wipe it.
  • Measured on a Dot at TWRP 3.7.0_9-bboe2: toybox dd wrote the backup copy back over itself at block 7651295, 33 records out, and sgdisk --verify passed. sgdisk --load-backup of a layout's two copies, onto a loop device the size of the disk, gave both back byte for byte, but it adds a tool the route does not need. Its exit status proves nothing: on a file over 2 GiB it read the size wrong, did not write the backup copy, and exited 0.
  • Measured on a Dot, 2026-10-10, each run from where the last ended: amonet-v1.1.0-bboe-booted to amonet-biscuit-v2.0.0 in 4 min 54 s, then amonet-biscuit-v1.1.0 in 7 min 47 s, then amonet-biscuit-v2.0.0 from TWRP 3.2.3 in 5 min 17 s, then amonet-biscuit-v1.1.0-bboe in 7 min 48 s. Each table copy took under 1 s. On all three TWRPs the backup copy went in as 33 records and read back whole, and the Dot booted rooted at the end. The same four runs on another Dot from Windows, with /system in slices, took 4 min 51 s, 8 min 17 s, 5 min 14 s and 8 min 16 s.

Writing amonet v1.1.0 from v2.0.0's TWRP

  • The run writes v1.1.0's chain and Fire OS 5.5.5.4 from v2.0.0's TWRP. The bootrom step stays for --short and for amonet v2.0.0's fastboot, where no TWRP is up.
  • The fastbrick appears to zero RPMB itself: its payload holds Zeroing RPMB block 0... and RPMB block 0 cleared and verified. If so, a Dot that has never seen amonet v1.1.0 needs no bootrom either. This is read from the payload's strings, not measured. Every Dot here already had a zeroed RPMB, and the bootrom step it replaces verified the zero where this route assumes it. RPMB is not readable from TWRP, so nothing checks it at run time. A wrong assumption here is the one failure on this page that costs the eMMC short.
  • The table goes first, then a reboot, then the rest by the TWRP route above. Clearing boot0 before that reboot would land there.
  • The chain is amonet v1.1.0's plugin plan (docs/plugins.md), less the steps this route does another way: the RPMB, forcing fastboot, and the reboots. A region that already holds its bytes is skipped, so a rerun resumes where the last one stopped. Measured from stock 8146, on the route this one replaced: a run stopped during the first LK write, and the rerun skipped the four boot writes and tee1, wrote the rest, and the Dot booted rooted. It skipped the BCB too, which the stock install had already written. That route skipped boot0's header as well; this one clears it again on every run that writes past the table.
  • ERASED is set only after the plan resolves and every node checks out, so those stops leave the next run on its usual path. A stop between that and the boot0 clear leaves it set with boot0 intact, which costs the next run a bootrom handshake that times out.
  • Its writes use toybox dd with conv=notrunc where it exists, as the stock path does; the dd on the path refuses conv, and does not truncate at a seek without it.
  • Each target is checked as a block device, at the length its table entry gives, before anything is written, boot0 included. A table that goes in alone is the exception: the run after its reboot checks them. dd to a name that is not one writes a file in RAM /dev that reads back and matches.
  • The by-name bootloader nodes are decoys on both TWRPs, so the chain names mmcblk0pN. bboe2 repoints by-name boot_a at boot_a_x and v2.0.0's TWRP does not, so the boot image names boot<slot>_x.
  • lk_build_desc does not say a Dot is unlocked. amonet v2.0.0 patches the stock LK and keeps its build string, so an amonet v2.0.0 Dot and a locked stock 8146 both read 63cb91b-20221007_072309; unlock_status is in fastboot only. The stock target writes no recovery, so a restored Dot keeps the TWRP it had, and the probe would read that as amonet-v2.0.0-twrp.
  • A locked Dot cannot reach that state, so the reading above is a gap in the logic and not a route. Measured twice on a Dot restored to stock 8146 with an amonet TWRP left in recovery, v2.0.0's and 3.7.0_9-bboe2's: fastboot reboot recovery answers OKAY and then nothing appears on USB for 90 s, in any mode. A locked LK refuses an unsigned recovery.
  • The TWRP route and stage_install_fireos6 still hash lk_a against amonet's lk.bin, amonet v2.0.0's then amonet v1.1.0's, and refuse the rest, so a chain write needs proof the Dot is amonet's rather than a build string it shares with stock. amonet v1.1.0's LK passes, so a part-written chain still resumes. When lk_a matches neither, lk_b is read too: the chain writes lk_a first, so a run stopped inside that write leaves lk_a torn and lk_b as it was. Both routes into the chain leave amonet v2.0.0's LK in both slots: its installer writes both, and so does the fastbrick, measured on a Dot read the moment its TWRP came up from stock. Every stock LK is 241664 or 245760 bytes, against 359744 and 372368, so none can match. The check is defence in depth, not a fix for a reachable failure.
  • The probe waits for mtp on every TWRP, not only amonet v1.1.0's. adb answers first, and the switch to mtp,adb re-enumerates USB, so a stage starting in that window reads truncated output with exit 255. So each device-side answer this route reads ends in a marker of its own, sgdisk --print included, because adb cuts the tail: a marker at the front leaves a cut line parsing as an empty, and therefore passing, result. A cut md5 is shorter than 32 characters, but a read that returned nothing hashes to a whole one, so the chain's reads and writes also require dd's record count.
  • v2.0.0's TWRP costs the install nothing. It returns adb shell's exit status and has every tool the install needs, and /system took 81 s there, as under bboe2: gunzip and the eMMC are the limit, not adb.
  • misc's boot control block is written, as a read of block 1, 7 bytes patched at 0x160, and a write back, which is what v1.1.0's reset_bcb does. Leaving it inherits whatever ran before: after an amonet-biscuit-v2.0.0 install a Dot read 00 41 42 42 01 af 3e, slot b at prio 14 with 3 tries, a live fallback to the Fire OS 6 images an amonet v1.1.0 chain cannot boot. The boot image goes to slot a to match, not to ro.boot.slot_suffix, which here names the slot amonet v2.0.0 booted.
  • Measured on a Dot: the timings table above has the figures.

Downgrade: amonet v2.0.0 to v1.1.0

  • firebreak erases boot0, then opens the port whose USB product id is the bootrom's, so a port that was already there does not matter.
  • The engine is the GCPU, gcpu@10210000 in the device tree: a 4 KiB window that holds every register the exploit touches. Fire OS hands it to the TEE, which is encrypted, so the registers are read from the preloader instead.
  • The preloader drives the engine with the same sequence, at 0x1d1bc as Thumb-2. It names every gcpu.Register and gives ACQUIRE_VALUES and SLOT_POINTER_VALUES. 0x400, 0xC48 and 0xC68 are computed, not literals, so a byte search misses them. The routines are identical in every cached preloader: biscuit's, crown's, and the radar one amonet-radar v1.0.0 writes. tests/test_preloader.py checks them, and skips without a cache, so CI never runs it.
  • The bootrom owns 0x102868 and 0x1028A8, so the preloader does not hold them. Both agree with mtkclient (GPL-3.0); nothing was copied. mtkclient also clears 0x1072DC. One write to 0x102868 has been enough on every run, and why is not known.
  • This route now runs only from amonet v2.0.0's fastboot, for --short. From amonet v2.0.0's TWRP the targets write the chain in place instead; the section above has it.
  • The script stops if lk_build_desc is already v1.1.0's: a second erase gains nothing. It reads getvar, and refuses a Dot whose unlock_status is not true, or whose product is not BISCUIT. Every unlocked amonet LK answers this fastboot. Without the product check, an Echo (2nd Generation) on amonet-radar, whose LK says RADAR, would read as amonet-v2.0.0-fastboot, and a run would erase its boot0 and write biscuit's preloader and LK to it. This is read from the code and the LK images, not seen on a radar.
  • It runs fastboot erase boot0 and fastboot reboot, and the Dot drops into its bootrom.
  • The step ends with the plan's own reboot. Every write reports its own result, so no log is read.
  • The bootrom is 0e8d:0003. On macOS it is /dev/cu.usbmodem*. On Linux it is /dev/ttyACM*, owned by dialout unless the udev tty line gives it to plugdev, and ModemManager can grab it first. On Windows it is a COM port that needs MediaTek's VCOM driver (cdc-acm.inf, class Ports, 3.0.1504.0, "MediaTek USB Port" in the Microsoft Update Catalog), installed by hand with pnputil /add-driver; without it 0e8d:0003 is an unknown device.
  • No other mode needs a hand-installed driver. On a Windows machine that rooted a Dot, the driver store held only MediaTek's and Amazon's FireDevicesUsbDeviceClass, which serves amonet fastboot (0bb4:0c01) and can come through Windows Update. Windows bound adb (1949:0110 and Fire OS 6's 0e8d:201c, by its interface class through its own winusb.inf) and v1.1.0's TWRP (18d1:4ee2) itself. TWRP 3.7.0_9-bboeN is untried on Windows. macOS and Linux need no driver.
  • A port that will not open is named once and retried: udev or a modem manager can hold it for a moment. The wait is 60 seconds after the erase, and 600 when the run waits for a short.
  • The run uploads v2.0.0's payload from the fastbrick's zip, reads the Dot's partition table over USB, resolves v1.1.0's plan against it, and reads every write back. On macOS and Windows the step takes 18 s.
  • The bootrom's word commands are big-endian and the Dot's memory is little-endian, so the payload and the AES input go out byte-swapped per word. Unswapped bytes load in the wrong order and nothing on the wire shows it, which is why write_words takes words.
  • It reads and writes 64 blocks per command, 0x1004 and 0x1003. One block per command was 10 times slower: every region is read twice, and each round trip costs about 10 ms.
  • The payload does not answer the area switch, 0x1002, so block 0 is checked after each one: boot0 starts EMMC_BOOT or is zeros, and the user area ends 55 AA. A read-back alone cannot catch a lost switch, because it reads through the same wrong area.
  • A read-back proves the transfer, not that the eMMC kept the bytes: its 256 KiB write cache answers reads. The cache is off at power-on and the payload never turns it on, so this step writes uncached.
  • v2.0.0's payload ends a 512-byte block read and a 256-byte RPMB read on a full packet, and Windows' VCOM driver waits for a short packet. So each such read is followed by a 4-byte read, 0x5000, whose bytes are dropped.
  • v1.1.0's fastboot-step.sh ships a Linux-only fastboot. The script runs its three commands with the host's fastboot instead: bin/tz.img to tee2, TWRP 3.7.0_9-bboe2 to recovery in place of v1.1.0's bin/twrp.img, then fastboot oem reboot-recovery.

Install: TWRP 3.7.0_9-bboe2

  • The image is the biscuit build of bboe/twrp_device_amazon_echo-mt8163, on bboe/android_kernel_amazon_biscuit. GitHub Actions builds and attests both. The script pins the SHA-256.
  • bboe2 adds v1.1.0's TWRP 3.2.3 links: /dev/block/other-boot and other-system name the current slot's kernel and system, and other-lk is /dev/null. EchoMuse's installer finds the kernel through other-boot, and stopped on bboe1 without it. Measured on a Dot: other-boot was mmcblk0p10 and other-system mmcblk0p13, and both followed a slot change.
  • v1.1.0's TWRP 3.2.3 moved 5.1 MB/s over adb, and 7.0 MB/s with cpu0 at performance. This one moves 21.5 to 22.2 MB/s with either governor.
  • v2.0.0's TWRP cannot replace 3.2.3. Its kernel is 32-bit, and v1.1.0's LK starts only a 64-bit one.
  • The kernel is Amazon's 5.5.5.4 one with two back-ports for Android 9's init. Without SELinux ioctl permissions, init fails with avtab: invalid type or class. Without mmap_rnd_bits, it fails with Unable to set adequate mmap entropy value!.
  • amonet v1.1.0 keeps its image in boot_a and boot_b. Fire OS boots from boot_a_x and boot_b_x. This build points /boot at _x. The script does not rely on that. It writes boot<slot>_x and system<slot> by name.
  • dd to a by-name path that does not exist writes a file in TWRP's RAM /dev, and the read-back of that file matches. So each target must be a block device.
  • A Dot in TWRP 3.2.3 gets this TWRP written to recovery and checked by md5. Then the script runs adb reboot recovery. The script drops 3.2.3's __bionic_open_tzdata... lines from adb shell output.
  • adb answers before MTP is on, as 18d1:d001 in this TWRP. The switch to mtp,adb puts the Dot back on USB under a new ID. In 3.2.3 the switch failed a push with failed to read copy response: EOF. So the run waits for mtp in sys.usb.config, and each push gets 3 tries.
  • This TWRP's adb shell returns the exit status. So each device-side step is a pushed script, judged by its status. The scripts have \n line endings.
  • The userdata script unmounts /sdcard and /data. It formats userdata with mke2fs -t ext4 -b 4096 <userdata> <blocks - 256>, which leaves 1 MiB for the crypto footer. Then it mounts it with mount -t ext4. The script sets the size, block size and type itself, so the result does not depend on the TWRP's fstab.
  • /data is then checked as a mountpoint. Otherwise Magisk's database lands in TWRP's RAM and is gone after the reboot.
  • This TWRP mounts system at /system_root. So the script patches Fire OS's system at /tmp/fireos-system.
  • A failed patch leaves that mount. So the write first unmounts both. /proc/mounts keeps the name mount was given. So the check looks for the by-name link and the mmcblk0p node.
  • Never umount -a in TWRP: it unmounts /proc.
  • Every push is read back by md5 on the device.

Writing Fire OS

  • The OTA zip's installer does 2 writes that matter: system.new.dat to other-system and boot.img to other-boot. In TWRP 3.2.3 those name the current slot's system and boot_x. Its LK write goes nowhere: amonet's TWRP links other-lk to /dev/null. Its TEE and preloader are byte for byte the ones v1.1.0 already wrote to tee1 and boot0. Its last write, target.blocklist to /cache/recovery/last_blocklist, is skipped: it is a file in /cache, not a partition the Dot boots from.
  • So the zip never goes to the Dot. The host builds the partition image from system.transfer.list once, into the cache: 805 MB, 386 MB gzipped. It takes 22 s on macOS and 31 s on Windows 11, in a background thread, while the Dot unlocks and downgrades.
  • The built image matched system_a after a twrp install in every MiB that the root's own edits and ext4's mount metadata leave alone.
  • It is built as 128 MiB slices, each its own gzip member, because a concatenation of members is one stream to any decompressor. So one cache serves both routes.
  • Where adb shell carries a stream, the slices go out back to back on its stdin, gunzip -c reads them and dd writes the partition, which overlaps the transfer with the write and needs no room on the Dot. Where it does not, each slice is pushed to /tmp and unpacked at its own offset: 35 MB at a time against a 240 MiB tmpfs, so nothing is staged on the eMMC.
  • The route is chosen by sending 1 KiB holding 0x1a and hashing it on the Dot, not by naming an operating system. A probe that does not complete takes the pieces, which need no stream, and says so; a probe that arrives cut takes them without a word.
  • A Windows adb cannot carry it. Measured on bryce in amonet v2.0.0's TWRP, from Windows 11 with adb 36.0.1: gunzip read 12 bytes and stopped with gzclose: Illegal seek, having written 12 bytes, while macOS carried 64 MiB through the same recovery minutes apart. adb push of the same bytes is intact from both, at 21.5 MB/s, so the sync protocol is the one that crosses hosts. What adb shell carries has the byte and the counts.
  • The command's last line says stream-ok, slice-ok or gunzip-failed, and a write is accepted on that line alone, not on silence or on the word elsewhere in the output.
  • The stream's deadline covers the transfer, not only the wait after it: one thread feeds the slices, another reads adb's output, and a stalled adb is killed when the 30 minutes run out.
  • TWRP 3.2.3 used adb exec-in: 65 s on macOS, 76 s on Windows 11. It is binary-safe but returns before the Dot has finished, with no status of its own, so a read taken when it returns can differ from what lands.
  • On a Dot the gzip reaches cat > /dev/null in 17.5 s. Through gunzip, with the output discarded, it takes 36.4 to 39.3 s. So gunzip on the Dot is the limit. The whole stage, with the eMMC and the md5 read-back, took 80 s while it streamed, and about 77 s with TWRP 3.2.3. Slicing costs 23.4 s against 15.9 s for 256 MiB. The sliced route took 108 s to 109 s across a whole install from Windows 11.
  • The partition is then read back by md5: 12 s with 3.2.3.

The boot image

  • The host builds it from the zip's boot.img and Magisk 17.3's zip, in about 2 s. Nothing runs magiskboot or Magisk's installer.
  • 17.3 is the last 17.x. Up to v25.2 support Android 5.1, but from v18 the boot patch and service.d path change, and the host's boot image build follows 17.3's.
  • The ramdisk loses verify from every fstab, and default.prop gets ro.secure=0, ro.debuggable=1 and persist.sys.usb.config=adb.
  • adb alone, not stock's mtp, and not mtp,adb. On Ubuntu, GNOME's gvfs-mtp-volume-monitor reset a booted Dot on mtp,adb about every 7 s, so the last step's wait for Fire OS 5 timed out; 2 owners hit it. With adb alone the Dot has 1 interface, the ADB one, and nothing to probe. Nothing here uses MTP, and TWRP's mtp,adb is its own ramdisk's.
  • The property holds: init.fosflags.sh, made to take its adb branch, only appends ,adb where adb is missing, and a fresh root leaves no persisted value in /data/property. bryce and nether's Dot each read adb after a reboot, and bryce kept one USB device number for 2 minutes.
  • The cmdline is the 512-byte header field at offset 64. Stock is bootopt=64S3,32N2,64N2; the script appends a space and androidboot.selinux=permissive.
  • The field ends in a NUL, so the line with that addition must stay under 512 bytes. Padding the field pads but never cuts, so a longer line grew the header past its page, moved the kernel and the ramdisk off their offsets, and raised nothing. The script stops instead. Biscuit's stock line leaves room; this guards another device's.
  • Then Magisk's patch, as its boot_patch.sh does it with KEEPVERITY and KEEPFORCEENCRYPT false. init becomes magiskinit and verity_key goes; both originals go into .backup, with .magisk. In the kernel, skip_initramfs becomes want_initramfs.
  • Magisk's installer also kept a gzipped copy of the image as it found it, /data/stock_boot_<sha1>.img.gz, and named it in .backup/.sha1. Only Magisk Manager's image restore reads them, and stock is the way back to stock here, so neither is written.
  • The kernel is a 512-byte MTK header, a gzip stream and the dtb. Only the stream and the header's size field change. The ramdisk's cpio is written as magiskboot writes one: sorted, inodes from 300000, every mtime 0.
  • Reading a cpio ends at the trailer or at the end of the data. A ramdisk cut short before its trailer read an empty magic past the end, and the run then blamed the format rather than the length.
  • The stock image ends in a 2,048-byte signature. It is dropped, as magiskboot dropped it: the unlocked LK does not check it.
  • Compared with the image that twrp install of Magisk wrote on a Dot, every header field, the kernel, the dtb and every ramdisk file are the same, except in 3 places. There is no .backup/.sha1. 3 symlinks keep their stock modes, where busybox's cpio had made them 0777. The MTK header keeps the stock 0xff padding, where magiskboot wrote zeros.
  • The image is padded to a 4096-byte multiple and written with dd. After sync the page cache is dropped, and the partition is read back by md5 over the image's length.
  • The host's copies go in a directory under the cache, not in %TEMP%. On Windows 11, reading a newly written boot image from %TEMP% stalled for 10.3 s in 10 of 15 trials, and in 0 of 23 trials elsewhere.

/system

  • /system/etc/init.fosflags.sh strips adb from the USB configuration on every boot after the first. The script forces its FOS_FLAGS_ADB_ON branch true and neutralizes its unset_adb_persistent_property calls, then checks both edits.
  • The 4 Amazon update hosts go into /system/etc/hosts as 127.0.0.1.

Magisk

  • The zip's installer is not run. Its writes go to the Dot as one cpio archive, which TWRP's cpio unpacks into /data. The files are then read back as one md5, over all of them in name order.
  • /data/adb/magisk gets arm/, common/ and chromeos/ from the zip, mode 755, and the installer's busybox: base64 of xz, in update-binary as BB_ARM.
  • It also gets magisk, which boot_patch.sh wrote with magiskinit -x magisk, and which module installs and --unlock-blocks run. It is the xz stream in magiskinit that unpacks to an ELF. Unpacked on the host and by magiskinit -x on a Dot, its md5 is the same. /sbin/magisk.bin differs from it in 62 bytes, which magiskinit randomizes at boot.
  • On a new userdata the installer wrote to /data/magisk, because /data/adb did not exist yet. Magisk's daemon moved it to /data/adb/magisk at the first boot. The script writes there directly.
  • The rest of the installer changes nothing on this Dot: there is no /system/addon.d, no su in /system, and nothing in /data to migrate.
  • Its database is seeded so the adb shell gets root without a prompt the Dot cannot show: /data/adb/magisk.db, mode 600, with policies(uid INT, package_name TEXT, policy INT, until INT, logging INT, notification INT) holding (2000, 'com.android.shell', 2, 0, 1, 0).

The updater

  • An update would replace the boot image and remove root. On the first rooted run the script runs pm hide com.amazon.device.software.ota, then reads hidden=true back from dumpsys package.
  • Right after the first boot, dumpsys package and pm hide can both exit 1 in the same second that sys.boot_completed reads 1 and su -c id answers. Seen on 3 of 3 roots from Windows 11; a rerun a minute later hid it each time. So the step says so once, then waits 1, 2, 4, 8 and 16 seconds between tries, and stops 60 seconds after the first try.
  • Measured on a Dot from Windows 11, writing Fire OS 5.5.5.4 again from TWRP 3.7.0_9-bboe2: the first try failed as before, and the second, 1 second later, hid it.

Back to stock: the stock target

firebreak stock <build> returns a Dot on amonet v1.1.0 or v2.0.0 to stock Fire OS 6 or Fire OS 5, to test a root from a clean start. stock is a target like the others, so every state has a route to it: a rooted Dot goes to recovery, amonet v1.1.0's fastboot gets amonet v1.1.0's TWRP, Fire OS 5's locked fastboot gets the fastbrick, and in any TWRP the restore runs.

  • Only stock takes a BUILD, and without one it installs 8149. The run downloads the build before it touches the Dot.
  • Fire OS 5.5.3.1 to 5.5.5.4 carry Fire OS 6's anti-rollback versions, so they restore as Fire OS 6 does.
  • 5.5.0.3's PL is 3, below RPMB's 5, so its preloader would stop at version mismatch!. Its restore leaves boot0 clear and reboots into the bootrom. There amonet v2.0.0's payload zeroes RPMB, writes the preloader and reboots. A PL 5 preloader that started in between would raise RPMB to 5 again.
  • Stock Fire OS 5 shows nothing on USB once it starts, so a Fire OS 5 restore ends after the reboot.
  • Measured on a Dot, 2026-10-13: each Fire OS 5 build restored in about 2.5 min, then read locked with its own lk_build_desc, and unlocked again.
  • Measured on a Dot, 2026-10-14: 5.5.0.3 restored from amonet v2.0.0 in 3 min 50 s. The bootrom came up 5 s after the reboot, and its RPMB began AMZN. The Dot read locked with 5.5.0.3's lk_build_desc. Its LK does not report antirback_pl_version. The fastbrick then unlocked it, and the default target rooted it in 8 min 4 s.
  • The restore ends at stock-booted: the run waits for stock to start, which shows as adb answering unauthorized about 1.5 minutes after the reboot. A Dot already stock-booted, or in stock fastboot, has nothing to restore. An amonet v2.0.0-unlocked Dot booted into Fire OS 6 without root reads the same. So the message says "appears", then names the + gesture, which starts v2.0.0's TWRP, for a Dot unlocked with amonet v2.0.0. The condition comes first, so the owner of a plain stock Dot can skip it.
  • The restore clears boot0 first and writes it back last. It creates boot0-erased before the clear and deletes it once boot0 reads back, as the downgrade does. So a restore stopped in between, then rebooted, is resumed through amonet v1.1.0's bootrom step and amonet v1.1.0's TWRP, and restored again. Before the marker, stock could not resume that Dot: it needed a TWRP, and the Dot showed up only as its bootrom.
  • A resume clears the TWRP states from the run's acted-on set, so a run that started in TWRP acts on TWRP again after the bootrom step.
  • Measured on a Dot, 2026-10-04: a restore stopped during the system write, then adb reboot from TWRP, then a rerun about 20 s later. The bootrom's one window was open when the rerun's bootrom step started, and amonet then skipped ports already present. The window closed, and nothing showed on USB for 9.5 minutes, until the cable was unplugged and plugged back in. The bootrom step then found it, and the run went on through amonet v1.1.0's TWRP and the whole restore to stock 8146: [1/19] to [19/19], 13 min 50 s with the wait. The resume now takes a bootrom port already present, and asks for the replug only if nothing happens within a minute.
  • A restore stopped without a reboot leaves TWRP running, so a rerun restores from there.
  • The wait for stock to start has the 10-minute limit of any other state.
  • A booted Fire OS 5 Dot without root gets adb reboot recovery, as the stock subcommand did. For the other targets that state is a boot still in progress, and the run waits.
  • v2.0.0's TWRP mounts by name, under /dev/block/platform/.../by-name/, so an unmount pattern anchored on /dev/block/mmcblk0 matched nothing there and the writes went to mounted filesystems. What is left mounted is named back, because a refused umount is otherwise silent and the Dot has no screen to look at.
  • That TWRP carries two dd implementations which do not take the same operands: the one on the path answers conv option disabled. So the script uses toybox dd where it exists. TWRP 3.7.0_9-bboe1's toybox dd truncates at its seek offset, and the block device answers ftruncate: Invalid argument. So the one write with seek passes conv=notrunc to toybox dd.
  • Measured on a Dot, 2026-10-04, before stock was a target: stock 8146 from amonet-v1.1.0-booted, through v1.1.0's TWRP 3.2.3, in 6 min 9 s; from amonet-v2.0.0-booted, through v2.0.0's TWRP, in 2 min 59 s; from amonet-v1.1.0-bboe-booted in 1 min 52 s. Each was rooted again after the fastboot gesture: to amonet-biscuit-v2.0.0 in 7 steps, amonet-biscuit-v1.1.0 in 10, amonet-biscuit-v1.1.0-bboe in 9, as the table counted then.
  • As the target: from amonet-v1.1.0-bboe-booted in 3 min 11 s, [1/17] to [17/17], ending when stock started, and 4315 in 4 min 9 s, which rooted back in 9 min 8 s. Run again on the stock Dot, it reported nothing to restore and downloaded nothing.
  • sgdisk, mke2fs, blockdev and md5sum are looked for before the countdown, because sgdisk and mke2fs are not reached until after 1.6 GB has gone in.
  • 4315 ships a block OTA in place of a payload.bin: system.new.dat with a transfer list, and boot, LK, TZ and the preloader as files in the zip. system.img is built from the list, which must be version 3 or 4 and use only erase, zero and new, since the rest patch blocks this does not have. Its ranges must cover the image from 0, and no block may be written twice: Fire OS 5's own list erases the whole image and then writes over it, where 4315's erases only what it does not write. Only the new ranges are written, because the file is truncated to size first, and system.new.dat must end where the last of them does. A block OTA carries no hash per image, so they rest on the OTA's pinned SHA-256.
  • The built system.img stays in the build's own directory for the next run, which saves the 20 to 30 seconds it takes to build. It is built as system.img.part and then renamed, and its SHA-256 goes in system.img.sha256. A run reuses the image only when that hash matches, because an older run wrote the image in place, so a stopped run left one of full size with blocks missing.
  • LK, TZ and boot are padded to a multiple of 4096 bytes. A write is checked over whole sectors, n // 512 of them, and 4315's LK is 241,448 bytes, so the check left the tail out and failed on a write that had gone in. A payload build's images are already whole blocks.
  • Each OTA comes from Amazon's CDN, at the URL ftvdb lists for it, and is pinned by the SHA-256 of a copy that matched ftvdb's md5. The run does not ask ftvdb.
  • Each payload.bin operation is read where it lies, so peak memory is one image plus one operation: about 1 GB, for the 768 MB system image.
  • The manifest's own lengths are not trusted. A number cut short raised a bare IndexError, and a field cut short returned fewer bytes than it declared with no error, so the run ended in a traceback or wrote a short image. Both now stop with the reason. A field read as the wrong wire type stops too: it used to reach an attribute that an int does not have.
  • A passed check prints ok rather than the tick where the console cannot encode it, such as a legacy Windows code page, which would otherwise raise UnicodeEncodeError.

The partition table

  • amonet v1.1.0 renames the stock boot_a and boot_b to boot_a_x and boot_b_x, and adds its own boot_a and boot_b as partitions 17 and 18, cut from the end of userdata. The stock table is rebuilt from the Dot's own rather than from constants, so every offset and size is that Dot's.
  • The backup table goes in before the primary: a run cut during the primary's write leaves it mixed, and the backup is what the next run reads instead.
  • A table with no _x name is already stock and is kept: that is what makes a stopped run safe to repeat, and why v2.0.0's table needs no surgery.
  • The kernel must reread the table before cache and userdata are formatted. Against the old one, mke2fs would size userdata to amonet's cut.
  • The first run on a Dot saves that Dot's own table as current-gpt-<serial>.bin, and later runs keep it rather than saving the stock table over it.

The writes

  • boot0's header is cleared first. With no valid preloader the bootrom waits for a USB host instead of running anything, so a failure anywhere after it leaves a Dot that amonet's bootrom step can reach without opening the case. Forced on hardware: the bootrom appeared 4 seconds after the reboot and waited about 39 seconds, and amonet v2.0.0's bootrom step rewrote the whole chain in 22. The order of the bootchain writes is not what makes this survivable -- with the header gone, no slot is reachable anyway -- the bootrom is.
  • misc gets zeros and, at 0x360, the boot control block amonet v1.1.0's bootrom step writes: 00 41 42 42 01 8f 00. Slot a is then prio 15 tries 0 success 1, and slot b is unused. Each slot byte packs priority:4, tries:3 and success:1, low bits first.
  • A zeroed misc is not safe. Stock's first boot starts slot a with a few tries and success 0, and each boot cut short spends one. On a Dot, a gesture during the first boot and the power-ons after it left slot a at tries 0 success 0. The preloader then reset about every 30 s with the ring dark, never starting LK, so neither the gesture nor FACTFACT reached fastboot. It did not fall back to slot b, whose tries stayed at 3. It took the eMMC short to get back. A slot marked good spends no tries.
  • tee1 and tee2 are not slot-tied: every slot-tied partition takes a letter from ro.boot.slot_suffix and TEE takes digits, and amonet's bootrom step writes its LK to both slots unconditionally, but its TEE payload only to tee1. So they go backup first and primary last, rather than by slot.
  • boot is padded with zeros to the size of boot_a, which both slots share, so no byte of amonet's boot image survives and the read-back covers the whole partition.
  • The preloader is padded the same way, to boot0's size, which depends on the eMMC chip. devices.md has the sizes.
  • TWRP answers ro.product.device only because amonet's TWRP sets it in its default.prop; a recovery ramdisk has no reason to carry it otherwise.
  • A push and its read-back address different block devices, whose page caches are not coherent, so the cache drop between them is what makes the comparison mean anything.
  • adb push to a node that does not exist does not fail: adbd creates a regular file in TWRP's RAM-backed /dev and reports success. A partition's read-back still catches that, because it reads the raw disk at the computed offset rather than the node -- but only after 768 MB has gone into a tmpfs on a 512 MB device. So the start sector is checked first, which also catches a number that means a different partition in the table the kernel still holds.
  • boot0 is the one write whose read-back names the node it wrote, so there a regular file would match itself and pass. Hence the check that it is a block device. For the same reason the cleared header is counted twice, bytes read and bytes left non-zero: a read that returned nothing would otherwise look like a header that cleared.
  • The skip compares a mebibyte of head first, which costs 0.07 s and is enough because a different build differs within a kibibyte or two. Only a partition that looks right pays for the full comparison, 13 s against the 80 s a write would take.
  • A restored Dot has no Wi-Fi until it is set up in the Alexa app. To root it again, skip that setup: on Wi-Fi it can update to a build firebreak does not support, or away from the build under test.