diff --git a/README.MD b/README.MD index cadea06..a2fc53d 100644 --- a/README.MD +++ b/README.MD @@ -31,6 +31,7 @@ The most up to date release can be found [here](https://github.com/notmarek/Lang 5. Type `;demo` into the Kindle search bar, then press enter to access the Demo Mode Configuration screen. 6. Select the `Sideload Content` option. 7. Connect your Kindle to a PC, then copy the contents of the `LanguageBreak` folder from the [latest release's *.tar.gz file](https://github.com/notmarek/LanguageBreak/releases) to the Kindle's root directory (if prompted, overrwrite the existing files). + - ⚠️ **Do not copy with a graphical file manager.** `.demo` is a *hidden* folder that Finder/Explorer will silently leave behind, and one payload file's *filename* is the exploit itself (it contains `;`, `$`, `(`, `)`, quotes and spaces) and must land byte-identical. Both failures are silent — the jailbreak simply does nothing. Use `rsync`/`cp` from a terminal, or [`troubleshooting/copy-payload.sh`](troubleshooting/copy-payload.sh), which copies and then verifies. 8. Eject, then *unplug your Kindle*, then return to the Demo Configuration Screen (using the method in step 5, if required). 9. Select `Resell Device`, then confirm. 10. As soon as the "Press the Power Button" screen appears, plug your Kindle back into your computer. (This is time sensitive, be quick.) @@ -46,10 +47,29 @@ The most up to date release can be found [here](https://github.com/notmarek/Lang 3. Eject your Kindle, then `;dsts` into the Kindle search bar to access the settings page. Locate the `Update your Kindle` option and press it, then confirm. - Using your phone to translate the menus is helpful if you don't happen to be able to read Chinese. - If you see an infoscreen that states "Update Error" or similar then try to apply the hotfix again (last three steps) + - **On PW3 the hotfix commonly has to be applied twice.** The first pass can leave the device looking fine (`;log` works, KUAL opens) while actually being broken: no `mkk` folder, permission errors on the rootfs, and KUAL apps that launch and silently die. If `mkk` is missing, apply the hotfix again — you do **not** need to re-enter demo mode, just copy the `.bin` and update. See [#48](https://github.com/notmarek/LanguageBreak/issues/48). This will reboot the device out of Demo mode. Your device may go into Managed mode after completing these steps. Managed devices have some settings greyed out, and ask the user to contact their system administrator. See below for steps on how to restore functionality (and the correct language) to your device. ## Restoring the Correct Language and Exiting Managed Mode + +> **If the `;` commands below do nothing**, the command channel itself is broken +> and neither procedure can work — `;demo`, `;uzb` and `;enter_demo` all fall +> through to an ordinary library search. This is the state reported in +> [#67](https://github.com/notmarek/LanguageBreak/issues/67), +> [#58](https://github.com/notmarek/LanguageBreak/issues/58) and +> [#52](https://github.com/notmarek/LanguageBreak/issues/52), usually alongside +> greyed-out Settings and USB that never mounts. +> +> Two things that recover it: +> +> - **A 40-second power-hold with the cable UNPLUGGED** often restores demo mode +> and the `;` channel. Holding it while connected only powers the device off. +> - **[`troubleshooting/fix-managed-mode.sh`](troubleshooting/fix-managed-mode.sh)** +> resets `/var/local` from root, which is what a factory reset does internally +> — `/opt/var/local` is a read-only squashfs holding the pristine copy. This +> works with Settings locked. **The jailbreak survives**, because +> `bridge.conf` restores `/var/local/mkk` from `/mnt/us/mkk`. ### If your kindle **is not** registered to an Amazon account: 1. Type `;demo` into the Kindle search bar. @@ -73,9 +93,12 @@ A: Not a question.. but checkout issue [#48](https://github.com/notmarek/Languag Q: How do I verify my installation? A: (before applying hotfix): Install hotfix, if you can do that then it worked. +**Note that `;log` does NOT work yet at this stage** — before the hotfix it falls through to an ordinary library search, and that is *expected*. It is not a sign of failure, and several issues in this tracker are people concluding otherwise. A: (after applying hotfix): Type `;log` into the Kindle search bar, some text should appear at the top right side of the screen. +A: (either stage, no shell needed): run [`troubleshooting/verify-jailbreak.sh`](troubleshooting/verify-jailbreak.sh). It checks the artefacts the exploit itself writes — `LanguageBreakRan`, `languagebreak_log` ending in `Finished installing jailbreak!`, and whether `mkk` exists — and writes the result to `/mnt/us/verify-jailbreak.txt`, readable over USB. + Q: Where are the hotfix files? A: The structure of the tarball is as follows @@ -94,6 +117,47 @@ LanguageBreak.tar.gz |-- Update_hotfix_languagebreak-*.bin ``` +# Recovering a device you cannot reach + +If the `;` commands are dead, Settings is locked and USB will not mount, it can +look like there is no way back in. There is, and it is built into the jailbreak: + +```sh +BRIDGE_EMERGENCY="/mnt/us/emergency.sh" +if [ -f "${BRIDGE_EMERGENCY}" ] ; then + [ -x "${BRIDGE_EMERGENCY}" ] || chmod +x "${BRIDGE_EMERGENCY}" + /bin/sh "${BRIDGE_EMERGENCY}" +fi +``` + +**Any script placed at `/mnt/us/emergency.sh` runs as root at boot** (see +`mkk/bridge.conf`). No KUAL, no MRPI, no USBNetwork. Copy a script there over +USB, reboot, and it runs. + +That is the intended route for the scripts here that need a boot-time root +shell — but it is not a universal one, and in particular it cannot work for +`fix-usb-disabled.sh`, whose symptom is that USB never mounts. See the +`HOW TO RUN IT` header in that script for the routes that do apply to it. + +Two rules if you write your own: + +1. **Rename the script before doing anything destructive.** If it dies partway, + `/mnt/us` (a `fuse.fsp` mount) can go with it, leaving the file in place to + re-run on every boot — a permanent boot loop that is worse than the original + problem. +2. **Call `/var/local/mkk/bridge.sh` at the end** if you are not rebooting, as + the emergency path makes the bridge skip its own setup. + +### USB never mounts after demo mode + +The host sees the device on the USB bus but no volume ever appears, which looks +exactly like a bad cable. `disableUSBInDemo.sh` creates +`/var/local/system/no_transitions`, and Amazon's own `enableUSBInDemo.sh` only +removes it *if `DEMO_MODE` still exists* — so once the demo flag is cleared the +blocker is stranded permanently. +[`troubleshooting/fix-usb-disabled.sh`](troubleshooting/fix-usb-disabled.sh) +removes it. + # Troubleshooting Having general issues? diff --git a/troubleshooting/copy-payload.sh b/troubleshooting/copy-payload.sh new file mode 100755 index 0000000..6f69f2c --- /dev/null +++ b/troubleshooting/copy-payload.sh @@ -0,0 +1,69 @@ +#!/bin/sh +# copy-payload.sh — copy the LanguageBreak payload to the Kindle correctly. +# +# ADDRESSES: silent jailbreak failures caused by copying with a GUI file +# manager. The README says "copy the contents of the LanguageBreak folder", +# which is easy to do wrongly in two ways that produce no error: +# +# 1. .demo/ is a HIDDEN directory. macOS Finder does not show it and a +# drag-copy leaves it behind entirely. +# 2. One payload file's NAME is the exploit itself: +# a; export SLASH=$(awk 'BEGIN {print substr(ARGV[1], 0, 1)}' ${PWD}); sh ${SLASH}mnt${SLASH}us${SLASH}jb +# containing ; $ ( ) { } quotes and spaces, and it must land byte-identical +# on FAT32. +# +# Also sweeps macOS AppleDouble "._" sidecars, which otherwise litter the +# Kindle root. +# +# USAGE: ./copy-payload.sh /path/to/extracted/LanguageBreak /Volumes/Kindle +# +# Run this at README step 7 AND again at step 11. + +set -eu +SRC="${1:?usage: copy-payload.sh }" +DST="${2:?usage: copy-payload.sh }" + +[ -d "$SRC" ] || { echo "no such directory: $SRC"; exit 1; } +[ -d "$DST" ] || { echo "Kindle not mounted at: $DST"; exit 1; } + +echo "copying payload -> $DST" +# Trailing slash on SRC copies the CONTENTS, not the folder. This matters. +# COPYFILE_DISABLE stops macOS writing ._ sidecars for extended attributes, +# which FAT32 cannot hold. +COPYFILE_DISABLE=1 rsync -rl "$SRC/" "$DST/" +command -v dot_clean >/dev/null 2>&1 && dot_clean -m "$DST" 2>/dev/null || true +sync + +echo +echo "verifying — all five must be present:" +rc=0 +for f in .demo documents DONT_CHECK_BATTERY jb patchedUks.sqsh; do + if [ -e "$DST/$f" ]; then echo " OK $f"; else echo " MISSING $f"; rc=1; fi +done + +echo +echo "verifying the exploit filename survived verbatim:" +# Compared in full, not grepped for a substring: a name that lost its trailing +# characters, gained a space or had a metacharacter rewritten still contains +# "export SLASH" while being useless as an exploit. +EXPLOIT="a; export SLASH=\$(awk 'BEGIN {print substr(ARGV[1], 0, 1)}' \${PWD}); sh \${SLASH}mnt\${SLASH}us\${SLASH}jb" +if [ -e "$DST/documents/dictionaries/$EXPLOIT" ]; then + echo " OK shell-injection dictionary file present, name byte-identical" +else + echo " MISSING exploit filename did not survive the copy" + echo " expected: $EXPLOIT" + echo " found in documents/dictionaries/:" + ls -1 "$DST/documents/dictionaries/" 2>/dev/null | sed 's/^/ /' || echo " (directory missing)" + rc=1 +fi + +echo +echo "stray AppleDouble sidecars in root:" +ls -1a "$DST" 2>/dev/null | grep '^\._' || echo " (none - clean)" + +if [ "$rc" = 0 ]; then + printf '\nPayload copied correctly. Eject before continuing.\n' +else + printf '\n*** COPY INCOMPLETE - do not continue, the jailbreak will fail silently. ***\n' +fi +exit $rc diff --git a/troubleshooting/fix-managed-mode.sh b/troubleshooting/fix-managed-mode.sh new file mode 100755 index 0000000..8a0fd77 --- /dev/null +++ b/troubleshooting/fix-managed-mode.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# fix-managed-mode.sh — escape "managed mode" after a LanguageBreak install. +# +# ADDRESSES: LanguageBreak issue #67 ("stuck in managed mode"), and the same +# symptom reported in #58 and #52. The only answer in #67 today is a bare +# "solved with a factory reset", which is unhelpful when Settings is greyed out +# and the factory-reset option is exactly what you cannot reach. +# +# SYMPTOM +# * Settings greyed out, "contact your administrator" +# * USB mass storage unavailable +# * ;log, ;uzb, ;enter_demo all fall through to a library search +# * so the documented exit (;demo -> Resell Device) is unreachable, because +# the ; command channel is itself what is broken +# +# HOW IT WORKS +# /opt/var/local is a READ-ONLY SQUASHFS holding the pristine factory copy of +# /var/local (visible as a /dev/loop mount in `mount`). The system repopulates +# /var/local from it. Wiping /var/local therefore performs the same reset the +# Settings menu would — and root can do it with Settings locked. +# +# THE JAILBREAK SURVIVES, two independent ways: +# 1. this script skips mkk, rp and linkfonts, and +# 2. /etc/upstart/bridge.conf restores /var/local/mkk from /mnt/us/mkk when it +# is missing — the jailbreak authors built that self-heal in already. +# +# HOW TO RUN IT WITHOUT A SHELL +# You almost certainly cannot ssh in at this point. Use the bridge's root +# hook: copy this file to /mnt/us/emergency.sh and reboot. It runs as root at +# boot. It renames itself when done, so it runs exactly once. +# +# *** WARNING: this wipes device state — registration, settings, caches. +# *** It does NOT touch /mnt/us, so your documents and the jailbreak payload +# *** are untouched. Verified on PW3 / 5.16.2.1.1. + +LOG=/mnt/us/fix-managed-mode.log +KEEP="mkk rp linkfonts" + +# ---- 1. Disarm FIRST, while /mnt/us is definitely writable. ---- +# If this script stops the framework or dies partway, /mnt/us (a fuse.fsp mount) +# can disappear, leaving the file in place to re-run on every boot. That failure +# mode produces a permanent boot loop and is much worse than the bug. +mv /mnt/us/emergency.sh /mnt/us/emergency.sh.done 2>/dev/null +sync + +{ + echo "===== fix-managed-mode $(date) =====" + id + echo + echo "-- factory template present? (must be a read-only squashfs) --" + mount | grep -i "opt/var/local" || echo " WARNING: /opt/var/local not mounted; reset may not repopulate" + echo + echo "-- /var/local before --" + ls -la /var/local 2>/dev/null + echo + echo "-- jailbreak backup on /mnt/us (used by bridge.conf to self-heal) --" + ls -la /mnt/us/mkk/ 2>/dev/null | head + echo + echo "-- wiping /var/local, keeping: $KEEP --" +} > "$LOG" 2>&1 + +for entry in /var/local/* /var/local/.[!.]* ; do + [ -e "$entry" ] || continue + base=$(basename "$entry") + skip=0 + for k in $KEEP; do [ "$base" = "$k" ] && skip=1; done + [ "$skip" = 1 ] && { echo " keep $base" >> "$LOG"; continue; } + chattr -i "$entry" 2>/dev/null + rm -rf "$entry" 2>/dev/null + echo " remove $base" >> "$LOG" +done +sync + +echo "" >> "$LOG" +echo "-- rebooting; /var/local repopulates from /opt/var/local --" >> "$LOG" +sync +sleep 2 + +# On-screen confirmation if fbink is available (LanguageBreak ships it). +[ -x /mnt/us/libkh/bin/fbink ] && /mnt/us/libkh/bin/fbink -y 2 "managed-mode fix applied, rebooting" 2>/dev/null + +reboot diff --git a/troubleshooting/fix-usb-disabled.sh b/troubleshooting/fix-usb-disabled.sh new file mode 100755 index 0000000..93344e6 --- /dev/null +++ b/troubleshooting/fix-usb-disabled.sh @@ -0,0 +1,61 @@ +#!/bin/sh +# fix-usb-disabled.sh — restore USB mass storage after demo mode. +# +# ADDRESSES: an undocumented dead end. The host enumerates "Amazon Kindle" on +# the USB bus but NO VOLUME EVER MOUNTS, which looks exactly like a bad cable +# and sends people hunting for hardware faults. +# +# CAUSE: /usr/bin/disableUSBInDemo.sh creates +# /var/local/system/no_transitions, which stops volumd mounting userstore. +# Amazon's own /usr/bin/enableUSBInDemo.sh only removes it IF DEMO_MODE still +# exists: +# +# DEMO_MODE_FILE=/var/local/system/DEMO_MODE +# NO_TRANSITIONS=/var/local/system/no_transitions +# [ -e "$DEMO_MODE_FILE" ] && rm -f "$NO_TRANSITIONS" +# +# So once the demo flag is cleared, the blocker is STRANDED with nothing left +# that will ever remove it. This removes it directly. +# +# DIAGNOSIS on the host: `ioreg -p IOUSB` (macOS) or `lsusb` shows the device +# while `diskutil list external` / `lsblk` shows no volume. That is this bug, +# not a cable. +# +# HOW TO RUN IT — read this first, because it cannot bootstrap itself over +# USB: USB not mounting is the symptom. This is a ROOT-SHELL convenience, not +# a rescue tool. If your `;` commands still work you do not need it at all: +# +# `;enter_demo` puts the device back in demo mode, which means DEMO_MODE +# exists again, which re-arms the device's own enableUSBInDemo.sh; `;uzb` — +# the same command the README uses at the hotfix step — then gets you a +# mount. Leaving demo mode afterwards the sanctioned way (`;demo` -> Resell +# Device) runs deleteDemoModeFlagFile.sh, which removes DEMO_MODE and +# no_transitions together, so nothing is stranded a second time. +# +# Use this script when you have root and would rather not do that round trip +# — over ssh if dropbear is installed, from KUAL, or as /mnt/us/emergency.sh +# if you can still reach /mnt/us. It is one `rm` instead of two reboots. +# +# If the `;` channel is dead too (the managed-mode lockout), neither route is +# open. A ~40 second power hold with the cable UNPLUGGED restored demo mode +# and the `;` channel on my PW3; held while plugged in it only powers the +# device off. + +LOG=/mnt/us/fix-usb.log +{ + echo "===== fix-usb-disabled $(date) =====" + if [ -e /var/local/system/no_transitions ]; then + rm -f /var/local/system/no_transitions + if [ -e /var/local/system/no_transitions ]; then + echo " FAILED to remove no_transitions" + else + echo " removed no_transitions - USB mass storage restored" + fi + else + echo " no_transitions not present; USB is not blocked by this" + fi + echo " DEMO_MODE: $([ -e /var/local/system/DEMO_MODE ] && echo set || echo clear)" +} >> "$LOG" 2>&1 +sync +[ -x /mnt/us/libkh/bin/fbink ] && /mnt/us/libkh/bin/fbink -y 2 "usb fix applied" 2>/dev/null +exit 0 diff --git a/troubleshooting/go/README.md b/troubleshooting/go/README.md new file mode 100644 index 0000000..94cf123 --- /dev/null +++ b/troubleshooting/go/README.md @@ -0,0 +1,40 @@ +# lbtool — host-side helpers in Go + +Optional. The shell scripts in `../` are the primary path and need no toolchain. + +This covers the two jobs that run on **your computer** while the Kindle is +plugged in over USB: + +``` +go build -o lbtool . + +./lbtool verify # did the jailbreak actually take? +./lbtool payload ./LanguageBreak # copy the payload, then prove the copy +``` + +Both take an optional trailing volume path; without one the Kindle is found by +looking for a mounted volume containing `documents/` and `system/` (the label is +user-changeable and localised, so it is not used for detection). + +## Why this exists alongside the shell scripts + +One file in the payload has a **name** that is the shell injection carrying the +exploit — semicolons, `$()`, `${}`, and a trailing space. Every hop through a +shell, a GUI file manager, or an archive tool is a chance to alter it, and the +failure is silent: the jailbreak simply does nothing and you are left debugging +the device. + +Go moves filenames as bytes. There is no shell in the copy path. `main_test.go` +round-trips that exact name, trailing space included, so a regression fails the +build rather than a stranger's Kindle. + +It also drops `._` AppleDouble sidecars (FAT32 cannot hold them) and fsyncs +every file rather than relying on a trailing `sync`, because people unplug the +moment a copy looks finished. + +## What is deliberately NOT here + +`fix-managed-mode.sh` and `fix-usb-disabled.sh` have no Go equivalent and should +not get one. They delete files under `/var/local` as root and reboot, so they +run **on the device** via `/mnt/us/emergency.sh`. Shell is the right tool there, +and a compiled binary would be the wrong one. diff --git a/troubleshooting/go/go.mod b/troubleshooting/go/go.mod new file mode 100644 index 0000000..13b601b --- /dev/null +++ b/troubleshooting/go/go.mod @@ -0,0 +1,3 @@ +module github.com/notmarek/LanguageBreak/troubleshooting/go + +go 1.21 diff --git a/troubleshooting/go/main.go b/troubleshooting/go/main.go new file mode 100644 index 0000000..f95755a --- /dev/null +++ b/troubleshooting/go/main.go @@ -0,0 +1,361 @@ +// Command lbtool is a host-side companion to the shell scripts in +// troubleshooting/. It does the two jobs that happen on YOUR computer while the +// Kindle is plugged in over USB: +// +// lbtool verify [volume] did the jailbreak actually take? +// lbtool payload [volume] copy the payload correctly, then prove it +// +// The other two fixes (fix-managed-mode.sh, fix-usb-disabled.sh) are NOT here +// and cannot be: they delete files under /var/local as root and reboot, so they +// run ON the device via /mnt/us/emergency.sh. Shell is the right and only tool +// for those. +// +// WHY A GO VERSION EXISTS AT ALL +// One file in the payload has a NAME that is the shell injection carrying the +// exploit — a leading "a;", $(), ${} expansions, no trailing space, 103 bytes +// exactly. Every hop through a shell, a GUI file manager, or an archive tool is +// a chance to alter it, and the failure is SILENT — the jailbreak simply does +// nothing and you are left debugging the device. Go moves filenames as bytes; +// there is no shell in the path. main_test.go round-trips that exact name. +// +// The shell scripts remain the primary, dependency-free path. This is for +// people who would rather run one binary than trust their file manager, and it +// needs no toolchain to READ — only to build. +package main + +import ( + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" +) + +const usage = `lbtool — host-side LanguageBreak helpers (see troubleshooting/*.sh for the rest) + + lbtool verify [volume] check the artefacts the exploit writes + lbtool payload [volume] copy the payload to the Kindle, then verify it + +[volume] is optional; the Kindle is found automatically by looking for a mounted +volume containing documents/ and system/. Pass it explicitly for unusual mounts. +` + +// The five entries that must exist at the volume root after a correct copy. +var payloadRoot = []string{".demo", "documents", "DONT_CHECK_BATTERY", "jb", "patchedUks.sqsh"} + +func main() { + if len(os.Args) < 2 { + fmt.Print(usage) + os.Exit(2) + } + var err error + switch os.Args[1] { + case "verify": + err = cmdVerify(os.Args[2:]) + case "payload": + err = cmdPayload(os.Args[2:]) + case "-h", "--help", "help": + fmt.Print(usage) + return + default: + err = fmt.Errorf("unknown command %q\n\n%s", os.Args[1], strings.TrimSpace(usage)) + } + if err != nil { + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) + os.Exit(1) + } +} + +// findVolume locates the mounted Kindle by CONTENT rather than by name: the +// volume label is user-changeable and localised, but a Kindle always carries +// documents/ and system/ at its root. +func findVolume() (string, error) { + var roots []string + if runtime.GOOS == "darwin" { + roots = []string{"/Volumes"} + } else { + u := os.Getenv("USER") + roots = []string{"/media/" + u, "/run/media/" + u, "/media", "/mnt"} + } + for _, root := range roots { + entries, err := os.ReadDir(root) + if err != nil { + continue + } + for _, e := range entries { + cand := filepath.Join(root, e.Name()) + if isKindle(cand) { + return cand, nil + } + } + } + return "", fmt.Errorf("no mounted Kindle found — plug it in and confirm the volume appears.\n" + + "If the device enumerates on USB but no volume ever mounts, that is the demo-mode\n" + + "USB fault: see troubleshooting/fix-usb-disabled.sh") +} + +func isKindle(dir string) bool { + for _, m := range []string{"documents", "system"} { + st, err := os.Stat(filepath.Join(dir, m)) + if err != nil || !st.IsDir() { + return false + } + } + return true +} + +func volumeArg(args []string) (string, error) { + if len(args) > 0 && args[0] != "" { + if !isKindle(args[0]) { + return "", fmt.Errorf("%s does not look like a Kindle volume (no documents/ and system/)", args[0]) + } + return args[0], nil + } + return findVolume() +} + +type checks struct{ pass, fail int } + +func (c *checks) ok(f string, a ...any) { c.pass++; fmt.Printf(" ok "+f+"\n", a...) } +func (c *checks) bad(f string, a ...any) { c.fail++; fmt.Printf(" FAIL "+f+"\n", a...) } + +func exists(p string) bool { _, err := os.Stat(p); return err == nil } +func isDir(p string) bool { st, err := os.Stat(p); return err == nil && st.IsDir() } + +func fileContains(p, needle string) bool { + b, err := os.ReadFile(p) + return err == nil && strings.Contains(string(b), needle) +} + +// cmdVerify answers "did the jailbreak take?" without needing a shell on the +// device. +// +// This exists because the documented check is misleading: `;log` only works +// AFTER the hotfix, so before it a fall-through to library search is EXPECTED +// and proves nothing. Issues #77, #71, #66 and #59 are all people reading a +// working install as a failure. +func cmdVerify(args []string) error { + vol, err := volumeArg(args) + if err != nil { + return err + } + fmt.Printf("volume: %s\n\n", vol) + c := &checks{} + + fmt.Println("exploit ran") + if exists(filepath.Join(vol, "LanguageBreakRan")) { + c.ok("LanguageBreakRan present") + } else { + c.bad("LanguageBreakRan MISSING — the exploit never ran") + } + log := filepath.Join(vol, "languagebreak_log") + if fileContains(log, "Finished installing jailbreak") { + c.ok("log reports the install finished") + } else { + c.bad("languagebreak_log missing or incomplete") + } + if fileContains(log, "I am root") { + c.ok("log confirms uid=0") + } else { + c.bad("log does not confirm root") + } + + fmt.Println("\nhotfix applied") + // mkk is the documented success marker. On PW3 the hotfix commonly needs + // applying TWICE (issue #48): the first pass leaves no mkk, rootfs + // permission errors, and KUAL apps that launch then silently die. + if isDir(filepath.Join(vol, "mkk")) { + c.ok("mkk present") + } else { + c.bad("mkk MISSING — apply the hotfix again; twice is normal on PW3, see issue #48") + } + if isDir(filepath.Join(vol, "libkh")) { + c.ok("libkh present") + } else { + c.bad("libkh missing") + } + if exists(filepath.Join(vol, "libkh", "bin", "fbink")) { + c.ok("fbink usable") + } else { + c.bad("fbink missing") + } + + fmt.Println("\nroot exec channel") + for _, f := range []string{"bridge.sh", "gandalf"} { + if exists(filepath.Join(vol, "mkk", f)) { + c.ok("mkk/%s present", f) + } else { + c.bad("mkk/%s missing", f) + } + } + if fileContains(filepath.Join(vol, "mkk", "bridge.conf"), "BRIDGE_EMERGENCY") { + c.ok("emergency hook available — a script at /mnt/us/emergency.sh runs as root at boot") + } else { + c.bad("no BRIDGE_EMERGENCY in bridge.conf — the recovery path is unavailable") + } + + fmt.Printf("\n%d ok, %d failed\n", c.pass, c.fail) + if c.fail > 0 { + return fmt.Errorf("verification failed") + } + fmt.Println("jailbreak is installed and the root exec channel is live.") + return nil +} + +// cmdPayload copies the payload, then proves the copy is usable. +// +// Copying with a GUI file manager fails SILENTLY in two ways: .demo/ is hidden +// so drag-copy leaves it behind, and macOS writes ._ AppleDouble sidecars that +// FAT32 cannot hold. Both produce a payload that looks complete and does +// nothing. +func cmdPayload(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: lbtool payload [volume]") + } + src := args[0] + if !isDir(src) { + return fmt.Errorf("no such directory: %s", src) + } + vol, err := volumeArg(args[1:]) + if err != nil { + return err + } + fmt.Printf("copying %s -> %s\n", src, vol) + + n, err := copyTree(src, vol) + if err != nil { + return err + } + fmt.Printf("%d files written and flushed\n\n", n) + + c := &checks{} + fmt.Println("all five root entries must be present") + for _, f := range payloadRoot { + if exists(filepath.Join(vol, f)) { + c.ok("%s", f) + } else { + c.bad("%s MISSING", f) + } + } + + fmt.Println("\nthe exploit filename must have survived verbatim") + if name, found := findExploit(filepath.Join(vol, "documents", "dictionaries")); found { + c.ok("shell-injection dictionary file present") + fmt.Printf(" %q\n", name) + } else { + c.bad("exploit filename did not survive the copy") + } + + fmt.Println("\nstray AppleDouble sidecars at the volume root") + if stray := strayDouble(vol); len(stray) == 0 { + c.ok("none") + } else { + c.bad("%d found: %s", len(stray), strings.Join(stray, " ")) + } + + fmt.Printf("\n%d ok, %d failed\n", c.pass, c.fail) + if c.fail > 0 { + return fmt.Errorf("copy incomplete — do NOT continue, the jailbreak will fail silently") + } + fmt.Println("payload copied correctly. Eject before continuing.") + return nil +} + +// copyTree copies src into dst, skipping AppleDouble sidecars and fsyncing each +// file. Filenames are passed through as bytes — no shell, no globbing, no +// interpolation, which is the entire point. +func copyTree(src, dst string) (int, error) { + n := 0 + err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil || rel == "." { + return err + } + if strings.HasPrefix(filepath.Base(rel), "._") { + return nil // FAT32 cannot hold these and they confuse the payload + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + if info.Mode()&os.ModeSymlink != 0 { + link, err := os.Readlink(path) + if err != nil { + return err + } + _ = os.Remove(target) + return os.Symlink(link, target) + } + if err := copyFileSync(path, target); err != nil { + return fmt.Errorf("%s: %w", rel, err) + } + n++ + return nil + }) + return n, err +} + +// copyFileSync copies and fsyncs. The shell version relies on a trailing +// sync(1); per-file fsync is stronger, and on a device people unplug the moment +// the copy "looks done" that difference is the whole point. +func copyFileSync(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + out, err := os.Create(dst) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Sync(); err != nil { + out.Close() + return err + } + return out.Close() +} + +// exploitFilename is the payload file whose NAME is the shell injection. +// Compared in full below, not by substring: a name that lost bytes, gained a +// space, or had a metacharacter mangled can still contain "export SLASH" +// while being useless as an exploit. +const exploitFilename = `a; export SLASH=$(awk 'BEGIN {print substr(ARGV[1], 0, 1)}' ${PWD}); sh ${SLASH}mnt${SLASH}us${SLASH}jb` + +// findExploit looks for the payload file whose NAME carries the injection. +func findExploit(dir string) (string, bool) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", false + } + for _, e := range entries { + if e.Name() == exploitFilename { + return e.Name(), true + } + } + return "", false +} + +func strayDouble(vol string) []string { + var out []string + entries, err := os.ReadDir(vol) + if err != nil { + return out + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), "._") { + out = append(out, e.Name()) + } + } + return out +} diff --git a/troubleshooting/go/main_test.go b/troubleshooting/go/main_test.go new file mode 100644 index 0000000..25c7b11 --- /dev/null +++ b/troubleshooting/go/main_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// exploitName is exploitFilename (main.go) under its old local name, kept so +// the test below reads standalone. Verified byte-for-byte against the real +// payload on disk: 103 bytes, no leading bare ";", no trailing space — a +// prior version of both this fixture and Copilot's PR review guessed wrong on +// both counts. No literal "/" appears because a filename cannot contain one; +// that is precisely why the exploit assigns SLASH. +const exploitName = exploitFilename + +func init() { + if len(exploitName) != 103 { + panic(fmt.Sprintf("exploitName fixture is %d bytes, want 103 — it no longer matches the real payload", len(exploitName))) + } +} + +// TestPayloadPreservesExploitFilename is the reason this Go program exists. +// If it ever fails, the copy path has grown a shell somewhere. +func TestPayloadPreservesExploitFilename(t *testing.T) { + src := t.TempDir() + dict := filepath.Join(src, "documents", "dictionaries") + if err := os.MkdirAll(dict, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dict, exploitName), []byte("payload"), 0o644); err != nil { + t.Fatalf("creating the exploit fixture: %v", err) + } + + dst := t.TempDir() + if _, err := copyTree(src, dst); err != nil { + t.Fatalf("copyTree: %v", err) + } + + got, found := findExploit(filepath.Join(dst, "documents", "dictionaries")) + if !found { + t.Fatal("exploit file absent after copy") + } + if got != exploitName { + t.Errorf("filename altered by the copy\n want %q\n got %q", exploitName, got) + } +} + +// TestPayloadSkipsAppleDouble — FAT32 cannot hold these, and macOS creates them +// freely. Copying them through is how a payload ends up subtly wrong. +func TestPayloadSkipsAppleDouble(t *testing.T) { + src := t.TempDir() + for _, n := range []string{"real", "._sidecar"} { + if err := os.WriteFile(filepath.Join(src, n), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + dst := t.TempDir() + if _, err := copyTree(src, dst); err != nil { + t.Fatal(err) + } + if !exists(filepath.Join(dst, "real")) { + t.Error("real file was not copied") + } + if exists(filepath.Join(dst, "._sidecar")) { + t.Error("AppleDouble sidecar was copied through") + } + if s := strayDouble(dst); len(s) != 0 { + t.Errorf("strayDouble reported %v after a clean copy", s) + } +} + +// TestPayloadCopiesHiddenDemo — .demo/ is hidden, so a drag-copy in Finder +// leaves it behind and the jailbreak does nothing. It must come across. +func TestPayloadCopiesHiddenDemo(t *testing.T) { + src := t.TempDir() + if err := os.MkdirAll(filepath.Join(src, ".demo"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, ".demo", "marker"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + dst := t.TempDir() + if _, err := copyTree(src, dst); err != nil { + t.Fatal(err) + } + if !exists(filepath.Join(dst, ".demo", "marker")) { + t.Error(".demo/ did not survive the copy — this is the Finder failure mode") + } +} + +// TestIsKindleDetectsByContent — volume labels are user-changeable and +// localised, so detection must not depend on the name. +func TestIsKindleDetectsByContent(t *testing.T) { + vol := t.TempDir() + if isKindle(vol) { + t.Error("empty directory reported as a Kindle") + } + for _, d := range []string{"documents", "system"} { + if err := os.MkdirAll(filepath.Join(vol, d), 0o755); err != nil { + t.Fatal(err) + } + } + if !isKindle(vol) { + t.Error("directory with documents/ and system/ not recognised") + } +} diff --git a/troubleshooting/verify-jailbreak.sh b/troubleshooting/verify-jailbreak.sh new file mode 100755 index 0000000..1917f2a --- /dev/null +++ b/troubleshooting/verify-jailbreak.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# verify-jailbreak.sh — did the jailbreak actually take? +# +# ADDRESSES: LanguageBreak issues #77 ("Log not showing after step 18"), #71, +# #66, #59 — all variants of "I followed the steps, did it work?". The current +# answer is "type ;log and see if text appears", which fails for a reason the +# FAQ does not mention: ;log ONLY works AFTER the hotfix. Before it, ;log +# falling through to a library search is EXPECTED and tells you nothing. +# +# This checks the artefacts the exploit itself writes, which are ground truth +# and readable over USB with no shell. +# +# RUN: copy to /mnt/us/emergency.sh and reboot, or run over ssh. +# Writes /mnt/us/verify-jailbreak.txt + +OUT=/mnt/us/verify-jailbreak.txt +pass=0; fail=0 +ok() { echo " PASS $1" >> "$OUT"; pass=$((pass+1)); } +bad() { echo " FAIL $1" >> "$OUT"; fail=$((fail+1)); } + +echo "===== verify-jailbreak $(date) =====" > "$OUT" +echo >> "$OUT" + +echo "-- exploit ran --" >> "$OUT" +[ -f /mnt/us/LanguageBreakRan ] && ok "LanguageBreakRan present" || bad "LanguageBreakRan MISSING - exploit never ran" +if grep -q "Finished installing jailbreak" /mnt/us/languagebreak_log 2>/dev/null; then + ok "languagebreak_log says 'Finished installing jailbreak!'" +else + bad "languagebreak_log missing or incomplete" +fi +grep -q "I am root" /mnt/us/languagebreak_log 2>/dev/null && ok "log confirms uid=0" || bad "log does not confirm root" + +echo >> "$OUT" +echo "-- hotfix applied --" >> "$OUT" +# mkk is the documented success marker. On PW3 the hotfix often needs applying +# TWICE (issue #48): the first pass leaves no mkk, rootfs permission errors, +# and KUAL apps that launch and silently die. +[ -d /mnt/us/mkk ] && ok "mkk present (hotfix applied)" || bad "mkk MISSING - apply the hotfix again; on PW3 twice is normal, see issue #48" +[ -d /mnt/us/libkh ] && ok "libkh present" || bad "libkh missing" +[ -x /mnt/us/libkh/bin/fbink ] && ok "fbink usable" || bad "fbink missing" + +echo >> "$OUT" +echo "-- root exec channel --" >> "$OUT" +[ -f /mnt/us/mkk/bridge.sh ] && ok "mkk/bridge.sh present" || bad "mkk/bridge.sh missing" +[ -f /mnt/us/mkk/gandalf ] && ok "gandalf (setuid root helper) present" || bad "gandalf missing" +grep -q "BRIDGE_EMERGENCY" /mnt/us/mkk/bridge.conf 2>/dev/null \ + && ok "emergency hook available: a script at /mnt/us/emergency.sh runs as root at boot" \ + || bad "no BRIDGE_EMERGENCY in bridge.conf" + +echo >> "$OUT" +echo "-- leftovers that should be gone --" >> "$OUT" +[ -e /mnt/us/jb ] && bad "jb still present - staging file not consumed" || ok "staging files consumed" +[ -e /mnt/us/.demo ] && bad ".demo still present" || ok ".demo consumed" + +echo >> "$OUT" +echo "-- state that commonly strands people --" >> "$OUT" +[ -e /var/local/system/DEMO_MODE ] && bad "DEMO_MODE still set - still in demo mode" || ok "DEMO_MODE clear" +if [ -e /var/local/system/no_transitions ]; then + bad "no_transitions present - USB mass storage is DISABLED (see fix-usb-disabled.sh)" +else + ok "USB not blocked" +fi + +echo >> "$OUT" +echo "===== $pass passed, $fail failed =====" >> "$OUT" +[ "$fail" = 0 ] && echo "Jailbreak looks healthy." >> "$OUT" \ + || echo "See the FAIL lines above." >> "$OUT" +chmod 666 "$OUT" 2>/dev/null +sync +[ -x /mnt/us/libkh/bin/fbink ] && /mnt/us/libkh/bin/fbink -y 2 "verify: $pass pass / $fail fail" 2>/dev/null +exit 0