diff --git a/.gitignore b/.gitignore index 3354bfb..ad5518c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .claude dist/ .grepai/ +.DS_Store diff --git a/cmd/ipadecrypt/bootstrap.go b/cmd/ipadecrypt/bootstrap.go index 8c46954..264ff9b 100644 --- a/cmd/ipadecrypt/bootstrap.go +++ b/cmd/ipadecrypt/bootstrap.go @@ -256,6 +256,7 @@ func bootstrapHandler(cmd *cobra.Command, args []string) { tui.Bullet("AppSync Unified bypasses installd's signature check") tui.Bullet(" add repo: https://lukezgd.github.io/repo") tui.Bullet("appinst installs modified IPAs on the device") + tui.Bullet("dpkg installs the trusted helper package on rootless jailbreaks") tui.Bullet("zip packages the decrypted IPA on-device") tui.Info("A reboot may be needed after installing; that's fine, we'll reconnect.") @@ -285,6 +286,7 @@ func bootstrapHandler(cmd *cobra.Command, args []string) { }{ {"AppSync Unified", pdev.LocateAppSync}, {"appinst", pdev.LocateAppinst}, + {"dpkg", func() (string, error) { return pdev.LocateBinary("dpkg") }}, {"zip", func() (string, error) { return pdev.LocateBinary("zip") }}, } for _, c := range checks { diff --git a/cmd/ipadecrypt/decrypt.go b/cmd/ipadecrypt/decrypt.go index 7984a65..6a6ec18 100644 --- a/cmd/ipadecrypt/decrypt.go +++ b/cmd/ipadecrypt/decrypt.go @@ -542,7 +542,7 @@ func runDecryptOnBundle(dev *device.Client, helperPath, bundleID, bundlePath, ve helperStderr = stderrNoter } - _, _, code, err := dev.RunHelper(helperPath, bundleID, bundlePath, outRemote, onEvent, helperStderr) + _, _, code, err := dev.RunHelper(helperPath, bundleID, bundlePath, outRemote, decryptSkipAppex, onEvent, helperStderr) if stderrNoter != nil { stderrNoter.Flush() @@ -617,6 +617,19 @@ func runDecryptOnBundle(dev *device.Client, helperPath, bundleID, bundlePath, ve return } + ignoredEncrypted := 0 + if decryptSkipAppex && len(res.Encrypted) > 0 { + encrypted := res.Encrypted[:0] + for _, n := range res.Encrypted { + if isAppexPath(n) { + ignoredEncrypted++ + continue + } + encrypted = append(encrypted, n) + } + res.Encrypted = encrypted + } + if len(res.Encrypted) > 0 { live.Fail("%d binary(ies) still have cryptid != 0", len(res.Encrypted)) @@ -631,6 +644,9 @@ func runDecryptOnBundle(dev *device.Client, helperPath, bundleID, bundlePath, ve if len(res.Skipped) > 0 { suffix = fmt.Sprintf(" (%d skipped)", len(res.Skipped)) } + if ignoredEncrypted > 0 { + suffix += fmt.Sprintf(" (%d encrypted appex ignored)", ignoredEncrypted) + } live.OK("%d Mach-O(s) verified cryptid=0%s", res.Scanned, suffix) } @@ -670,6 +686,16 @@ func runDecryptOnBundle(dev *device.Client, helperPath, bundleID, bundlePath, ve cleanupDecrypt(dev, decryptNoCleanup, stagingRemote, outRemote) } +func isAppexPath(name string) bool { + for _, part := range strings.Split(name, "/") { + if strings.HasSuffix(part, ".appex") { + return true + } + } + + return false +} + func lookupTargetApp(as *appstore.Client, acc *appstore.Account, target decryptTarget) (appstore.App, error) { if target.appId != "" { return as.LookupByAppID(acc, target.appId) @@ -1106,6 +1132,11 @@ func (p *helperProgress) HandleEvent(ev device.Event) helperUpdate { case "spawn_failed": return helperUpdate{note: fmt.Sprintf("could not spawn %s (skipped)", path.Base(ev.Attr("src")))} + case "appex": + if ev.Attr("phase") == "skipped" { + return helperUpdate{note: "skipped app extensions"} + } + case "dyld": switch ev.Attr("phase") { case "resuming": diff --git a/cmd/ipadecrypt/main.go b/cmd/ipadecrypt/main.go index 617117c..47b4fa0 100644 --- a/cmd/ipadecrypt/main.go +++ b/cmd/ipadecrypt/main.go @@ -25,6 +25,7 @@ var ( decryptUseInstalled bool decryptPatchDevType bool decryptVerbose bool + decryptSkipAppex bool versionsLogResponses bool ) @@ -61,6 +62,7 @@ func main() { decrypt.Flags().BoolVarP(&decryptFromAppStore, "from-appstore", "f", false, "fetch from App Store and reinstall, ignoring what's installed on the device") decrypt.Flags().BoolVar(&decryptUseInstalled, "use-installed", false, "decrypt the installed build directly; skip the App Store path even if a newer version exists") decrypt.Flags().BoolVar(&decryptPatchDevType, "patch-device-type", false, "if the IPA's UIDeviceFamily excludes this device, append the device's family (iPadOS apps then run on iOS)") + decrypt.Flags().BoolVar(&decryptSkipAppex, "skip-appex", false, "skip app extension (.appex) launch/decrypt attempts") decrypt.Flags().BoolVarP(&decryptVerbose, "verbose", "v", false, "stream the on-device helper's LOG/ERR lines to stderr (useful for debugging decryption failures)") versions := &cobra.Command{ diff --git a/helper/entitlements.plist b/helper/entitlements.plist index cc4e5c7..a61c7b0 100644 --- a/helper/entitlements.plist +++ b/helper/entitlements.plist @@ -2,6 +2,9 @@ + get-task-allow + + task_for_pid-allow @@ -9,6 +12,12 @@ com.apple.private.cs.debugger + com.apple.private.thread-set-state + + com.apple.system-task-ports + + com.apple.system-task-ports.control + platform-application + com.apple.private.security.container-required + com.apple.private.security.no-sandbox com.apple.private.security.no-container diff --git a/helper/helper.c b/helper/helper.c index b0747d2..a0fab6e 100644 --- a/helper/helper.c +++ b/helper/helper.c @@ -7,7 +7,7 @@ // hijack. Patches cryptid=0, packs IPA. Walks PlugIns/*.appex + // Extensions/*.appex separately. // -// CLI: ipadecrypt-helper-arm64 [-v] +// CLI: ipadecrypt-helper-arm64 [-v] [--skip-appex] // bundle-id - CFBundleIdentifier; "" skips the main-app pass (appex only). // Used for SpringBoard SBS launch - launchd-lineage spawn that // bypasses Sandbox kext's hook_execve gate on Dopamine. @@ -122,6 +122,7 @@ extern char **environ; // ----- logging --------------------------------------------------------- static int g_verbose = 0; +static int g_skip_appex = 0; #define LOG(...) do { if (g_verbose) fprintf(stderr, __VA_ARGS__); } while (0) #define ERR(fmt, ...) fprintf(stderr, "[helper] ERROR: " fmt "\n", ##__VA_ARGS__) @@ -1975,7 +1976,6 @@ static int decrypt_bundle(const char *bundle_src, const char *bundle_dst, EVT("event=spawn_failed src=\"%s\"", bundle_src); return 0; // non-fatal; continue with rest of the IPA } - // 1) Dump the main exec. Fingerprint runtime arch from the loaded // mach_header so slice selection matches what dyld actually mapped. // FairPlay decrypts on page fault during mach_vm_read_overwrite, @@ -2231,11 +2231,12 @@ int main(int argc, char **argv) { int pi = 1; while (pi < argc && argv[pi][0] == '-') { if (strcmp(argv[pi], "-v") == 0) { g_verbose = 1; pi++; continue; } + if (strcmp(argv[pi], "--skip-appex") == 0) { g_skip_appex = 1; pi++; continue; } break; } if (argc - pi != 3) { fprintf(stderr, - "usage: %s [-v] \n" + "usage: %s [-v] [--skip-appex] \n" " bundle-id CFBundleIdentifier (for SBS), or \"\" to skip main app\n" " bundle-src absolute path to the installed .app on disk\n" " out-ipa where to write the decrypted IPA\n", @@ -2273,7 +2274,11 @@ int main(int argc, char **argv) { if (bundle_id && bundle_id[0]) { decrypt_bundle(bundle_src, bundle_dst, bundle_id); } - decrypt_appexes(bundle_src, bundle_dst); + if (g_skip_appex) { + EVT("event=appex phase=skipped"); + } else { + decrypt_appexes(bundle_src, bundle_dst); + } LOG("[helper] zipping → %s\n", out_ipa); EVT("event=pack phase=start ipa=\"%s\"", out_ipa); diff --git a/internal/device/ipadecrypt-helper-arm64 b/internal/device/ipadecrypt-helper-arm64 index 32f2c01..b5564f9 100755 Binary files a/internal/device/ipadecrypt-helper-arm64 and b/internal/device/ipadecrypt-helper-arm64 differ diff --git a/internal/device/ops.go b/internal/device/ops.go index 890c146..29041cc 100644 --- a/internal/device/ops.go +++ b/internal/device/ops.go @@ -1,8 +1,10 @@ package device import ( + "archive/tar" "bufio" "bytes" + "compress/gzip" "crypto/sha256" _ "embed" "encoding/hex" @@ -11,6 +13,7 @@ import ( "io" "path" "strings" + "time" "howett.net/plist" ) @@ -113,7 +116,22 @@ func (c *Client) LocateBinary(name string) (string, error) { return "", err } - return strings.TrimSpace(out), nil + if p := strings.TrimSpace(out); p != "" { + return p, nil + } + + for _, candidate := range []string{ + path.Join("/var/jb/usr/bin", name), + path.Join("/var/jb/usr/local/bin", name), + path.Join("/usr/bin", name), + path.Join("/usr/local/bin", name), + } { + if c.Exists(candidate) { + return candidate, nil + } + } + + return "", nil } func (c *Client) LocateAppSync() (string, error) { @@ -156,8 +174,16 @@ func (c *Client) Install(appinstPath, ipaRemote string) error { func (c *Client) EnsureHelper() (string, error) { sum := sha256.Sum256(helperArm64) - remote := path.Join(RemoteRoot, "helpers", - fmt.Sprintf("ipadecrypt-helper-arm64-%s.bin", hex.EncodeToString(sum[:])[:12])) + name := fmt.Sprintf("ipadecrypt-helper-arm64-%s.bin", hex.EncodeToString(sum[:])[:12]) + remote := path.Join(c.helperInstallRoot(), name) + + if strings.HasPrefix(remote, "/var/jb/") { + if err := c.installRootlessHelperPackage(remote, name); err != nil { + return "", err + } + + return remote, nil + } if c.Exists(remote) { return remote, nil @@ -170,6 +196,128 @@ func (c *Client) EnsureHelper() (string, error) { return remote, nil } +func (c *Client) installRootlessHelperPackage(remote, name string) error { + deb, err := buildHelperDeb(name, remote, helperArm64) + if err != nil { + return err + } + + debRemote := path.Join(RemoteRoot, "helpers", "."+name+".deb") + if err := c.Upload(bytes.NewReader(deb), debRemote, 0o644); err != nil { + return fmt.Errorf("upload helper package: %w", err) + } + + _, errOut, code, err := c.RunSudo(fmt.Sprintf("dpkg -i %q", debRemote)) + c.Remove(debRemote) + if code != 0 { + return fmt.Errorf("install helper package exit %d: %s", code, strings.TrimSpace(errOut)) + } + if err != nil { + return fmt.Errorf("install helper package: %w", err) + } + + return nil +} + +func buildHelperDeb(name, remote string, payload []byte) ([]byte, error) { + packageName := "com.londek.ipadecrypt.helper." + strings.TrimSuffix(strings.TrimPrefix(name, "ipadecrypt-helper-arm64-"), ".bin") + control := fmt.Sprintf("Package: %s\nName: ipadecrypt helper\nVersion: 1.0\nArchitecture: iphoneos-arm64\nMaintainer: ipadecrypt\nDescription: ipadecrypt on-device decrypt helper\n", packageName) + + controlTar, err := gzipTar(map[string]tarEntry{ + "./control": {body: []byte(control), mode: 0o644}, + }) + if err != nil { + return nil, fmt.Errorf("build helper package control: %w", err) + } + + dataEntries := map[string]tarEntry{} + dir := path.Dir(strings.TrimPrefix(remote, "/")) + for dir != "." && dir != "/" && dir != "" { + dataEntries["./"+dir] = tarEntry{mode: 0o755, dir: true} + dir = path.Dir(dir) + } + dataEntries["./"+strings.TrimPrefix(remote, "/")] = tarEntry{body: payload, mode: 0o755} + + dataTar, err := gzipTar(dataEntries) + if err != nil { + return nil, fmt.Errorf("build helper package data: %w", err) + } + + var deb bytes.Buffer + deb.WriteString("!\n") + writeArMember(&deb, "debian-binary", []byte("2.0\n"), 0o644) + writeArMember(&deb, "control.tar.gz", controlTar, 0o644) + writeArMember(&deb, "data.tar.gz", dataTar, 0o644) + + return deb.Bytes(), nil +} + +type tarEntry struct { + body []byte + mode int64 + dir bool +} + +func gzipTar(entries map[string]tarEntry) ([]byte, error) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for name, entry := range entries { + typ := byte(tar.TypeReg) + size := int64(len(entry.body)) + if entry.dir { + typ = tar.TypeDir + size = 0 + } + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: entry.mode, + Size: size, + Typeflag: typ, + ModTime: time.Unix(0, 0), + Uid: 0, + Gid: 0, + Uname: "root", + Gname: "wheel", + }); err != nil { + return nil, err + } + if !entry.dir { + if _, err := tw.Write(entry.body); err != nil { + return nil, err + } + } + } + if err := tw.Close(); err != nil { + return nil, err + } + if err := gz.Close(); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func writeArMember(w *bytes.Buffer, name string, body []byte, mode int64) { + if len(name) > 15 { + name = name[:15] + } + fmt.Fprintf(w, "%-16s%-12d%-6d%-6d%-8o%-10d`\n", name, 0, 0, 0, mode, len(body)) + w.Write(body) + if len(body)%2 != 0 { + w.WriteByte('\n') + } +} + +func (c *Client) helperInstallRoot() string { + if c.Exists("/var/jb") { + return "/var/jb/usr/local/libexec/ipadecrypt/helpers" + } + + return path.Join(RemoteRoot, "helpers") +} + // HashFile computes the sha256 of a path on-device. Installed bundles under // /var/containers are readable only by root + _installd, hence sudo. Relies // on a `shasum` binary being on PATH (procursus/dopamine/palera1n all ship @@ -294,27 +442,47 @@ func (c *Client) FindInstalled(appDirName string) (string, error) { // should exit 2 with a usage string we can recognize. Catches common issues // (binary not executable, sudo denied, missing codesign). func (c *Client) VerifyHelper(helperPath string) error { - cmd := fmt.Sprintf("%s 2>&1 | head -1", helperPath) + cmd := fmt.Sprintf("sh -c %q", helperPath+" 2>&1") - out, _, _, err := c.RunSudo(cmd) + out, errOut, code, err := c.RunSudo(cmd) if err != nil { return fmt.Errorf("verify helper: %w", err) } if !strings.Contains(out, "usage:") { - return fmt.Errorf("helper didn't respond with usage (got %q)", strings.TrimSpace(out)) + got := strings.TrimSpace(out) + if got == "" { + got = strings.TrimSpace(errOut) + } + if got == "" { + got = fmt.Sprintf("exit %d with no output", code) + } + + return fmt.Errorf("helper didn't respond with usage (got %q)", firstLine(got)) } return nil } +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + + return s +} + type EventHandler func(Event) // RunHelper spawns the on-device helper for a bundle. bundleID goes to the // SpringBoard SBS SPI (only accepted for the main app; empty string skips // the main-app pass and just decrypts PlugIns/*.appex + Extensions/*.appex). -func (c *Client) RunHelper(helperPath, bundleID, bundlePath, outIPA string, onEvent EventHandler, humanFallback io.Writer) (string, string, int, error) { - cmd := fmt.Sprintf("%s -v %q %q %q", helperPath, bundleID, bundlePath, outIPA) +func (c *Client) RunHelper(helperPath, bundleID, bundlePath, outIPA string, skipAppex bool, onEvent EventHandler, humanFallback io.Writer) (string, string, int, error) { + flags := "-v" + if skipAppex { + flags += " --skip-appex" + } + cmd := fmt.Sprintf("%s %s %q %q %q", helperPath, flags, bundleID, bundlePath, outIPA) // @evt lines on stdout → splitter; LOG/ERR on stderr → humanFallback. splitter := newEventSplitter(onEvent, humanFallback) defer splitter.Close()