Repository: ChiChou/frida-ipa-dump Branch: main Commit: 71fbd4d72868 Files: 21 Total size: 41.0 KB Directory structure: gitextract_1gu8ebsy/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.md │ └── workflows/ │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── README.zh-CN.md ├── agent/ │ ├── .gitignore │ ├── .npmignore │ ├── package.json │ ├── src/ │ │ ├── app.ts │ │ ├── shared.ts │ │ └── springboard.ts │ └── tsconfig.json ├── bin/ │ └── bagbak.ts ├── index.ts ├── lib/ │ └── utils.ts ├── package.json ├── tsconfig.json └── tsdown.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: ChiChou ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- Before you submit the issue, please check the FAQ section in Wiki: https://github.com/ChiChou/bagbak/wiki#faq Then delete this section. **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. Ubuntu] - nodejs: [e.g. v18.16.0] - frida on device version - iOS and jailbreak version - The app you are trying to work on [e.g. com.example.app, better with AppStore link] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/workflows/npm-publish.yml ================================================ name: Node.js Package on: push: tags: - v* jobs: publish-npm: runs-on: ubuntu-latest permissions: contents: write id-token: write steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 24 registry-url: 'https://registry.npmjs.org' - name: Ensure npm version for Trusted Publishing run: npm install -g npm@latest - name: Install agent dependencies run: npm ci working-directory: agent - name: Build agent run: npm run build working-directory: agent - name: Install dependencies run: npm ci - name: Build run: npm run build - name: Pack tarball run: npm pack - name: Publish to npm run: npm publish --provenance --access public env: NODE_AUTH_TOKEN: dummy - name: Create Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: Release ${{ github.ref_name }} files: '*.tgz' ================================================ FILE: .gitignore ================================================ dist/ .vscode/ .ccls-cache/ dump/ output/ *.app/ *.ipa .DS_Store # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env .env.test # parcel-bundler cache (https://parceljs.org/) .cache # Next.js build output .next # Nuxt.js build / generate output .nuxt # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and *not* Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port ================================================ FILE: .npmignore ================================================ !agent/dist/springboard.js !agent/dist/app.js ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2019 CodeColorist Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # bagbak [![version](https://img.shields.io/npm/v/bagbak)](https://www.npmjs.com/package/bagbak) [![downloads](https://img.shields.io/npm/dm/bagbak)](https://www.npmjs.com/package/bagbak) [![issues](https://img.shields.io/github/issues/chichou/bagbak)](https://github.com/chichou/bagbak/issues) [![sponsers](https://img.shields.io/github/sponsors/chichou)](https://github.com/sponsors/chichou) [![license](https://img.shields.io/github/license/chichou/bagbak)](LICENSE) Yet another frida based App decryptor. Requires jailbroken iOS device and [frida.re](https://www.frida.re/) Tested on iOS 15 (Dopamine) and iOS 16 (palera1n). *The name of this project doesn't have any meaning. I was just listening to that song while typing.* ## Prerequisites **Note:** bagbak@5 requires frida@17. If your frida-server is v16, use `npm install -g bagbak@4` instead. ### On device * [frida.re](https://www.frida.re/docs/ios/) ### On desktop - [node.js](https://nodejs.org/). - `npm install -g bagbak` ## Usage bagbak [bundle id or name] ``` Options: -l, --list list apps -j, --json output as json (only works with --list) -U, --usb connect to USB device (default) -R, --remote connect to remote frida-server -D, --device connect to device with the given ID -H, --host connect to remote frida-server on HOST -d, --debug enable debug output -o, --output ipa filename or directory to dump to --remove-keys Info.plist keys to remove (comma-separated) -h, --help display help for command ``` Dump modes (second argument): * `all` (default) — full IPA with all binaries decrypted * `main` (alias: `app`) — decrypt main app binary only * `extensions` (aliases: `ext`, `exts`) — decrypt extension binaries only * `binaries` (aliases: `bin`, `executables`) — decrypt all binaries, output as zip Environments variables: * `DEBUG=1` enable debug output for troubleshooting Example: * `bagbak -l` to list all apps * `bagbak com.google.chrome.ios` to dump app to `com.google.chrome.ios-[version].ipa` * `bagbak com.google.chrome.ios main` to dump only the main binary * `bagbak --remove-keys UISupportedDevices com.google.chrome.ios` to remove device restrictions from Info.plist ================================================ FILE: README.zh-CN.md ================================================ # bagbak [![version](https://img.shields.io/npm/v/bagbak)](https://www.npmjs.com/package/bagbak) [![downloads](https://img.shields.io/npm/dm/bagbak)](https://www.npmjs.com/package/bagbak) [![issues](https://img.shields.io/github/issues/chichou/bagbak)](https://github.com/chichou/bagbak/issues) [![sponsers](https://img.shields.io/github/sponsors/chichou)](https://github.com/sponsors/chichou) [![license](https://img.shields.io/github/license/chichou/bagbak)](LICENSE) 又一个基于 Frida 的 App 解密工具。需要越狱的 iOS 设备和 [frida.re](https://www.frida.re/) 已在 iOS 15 (Dopamine) 和 iOS 16 (palera1n) 上测试通过。 *这个项目的名字没有任何含义,我写代码的时候正在听那首歌。* ## 环境要求 **注意:** bagbak@5 需要 frida@17。如果你的 frida-server 是 v16,请使用 `npm install -g bagbak@4`。 ### 设备端 * [frida.re](https://www.frida.re/docs/ios/) ### 电脑端 - [node.js](https://nodejs.org/) - `npm install -g bagbak` ## 使用方法 bagbak [bundle id 或应用名称] ``` Options: -l, --list 列出所有应用 -j, --json 以 JSON 格式输出 (仅配合 --list 使用) -U, --usb 连接 USB 设备 (默认) -R, --remote 连接远程 frida-server -D, --device 连接到指定 ID 的设备 -H, --host 连接到指定主机的远程 frida-server -d, --debug 启用调试输出 -o, --output ipa 文件名或输出目录 --remove-keys 需要移除的 Info.plist 键 (逗号分隔) -h, --help 显示帮助信息 ``` 解密模式 (第二个参数): * `all` (默认) — 完整 IPA,解密所有二进制文件 * `main` (别名: `app`) — 仅解密主应用二进制文件 * `extensions` (别名: `ext`, `exts`) — 仅解密扩展二进制文件 * `binaries` (别名: `bin`, `executables`) — 解密所有二进制文件,输出为 zip 环境变量: * `DEBUG=1` 启用调试输出 示例: * `bagbak -l` 列出所有应用 * `bagbak com.google.chrome.ios` 解密应用并输出到 `com.google.chrome.ios-[version].ipa` * `bagbak com.google.chrome.ios main` 仅解密主应用二进制文件 * `bagbak --remove-keys UISupportedDevices com.google.chrome.ios` 移除 Info.plist 中的设备限制

想看更多中文技术分享?欢迎关注我的公众号

================================================ FILE: agent/.gitignore ================================================ /node_modules/ /dist/springboard.js /dist/app.js ================================================ FILE: agent/.npmignore ================================================ !dist/springboard.js !dist/app.js ================================================ FILE: agent/package.json ================================================ { "name": "bagbak-agent", "version": "1.0.0", "description": "Frida agent written in TypeScript", "private": true, "main": "src/index.ts", "type": "module", "scripts": { "prepare": "npm run build", "build": "frida-compile src/springboard.ts -o dist/springboard.js -c && frida-compile src/app.ts -o dist/app.js -c", "watch": "frida-compile src/springboard.ts -o dist/springboard.js -w & frida-compile src/app.ts -o dist/app.js -w" }, "devDependencies": { "@types/frida-gum": "^19.0.0", "@types/node": "^18.14.0", "frida-compile": "^19.0.5" }, "dependencies": { "frida-objc-bridge": "^8.0.5", "frida-remote-stream": "^6.0.3" } } ================================================ FILE: agent/src/app.ts ================================================ import ObjC from "frida-objc-bridge"; import type { MachOTasks } from "./shared.js"; import { getApi } from "./shared.js"; const HIGH_WATER_MARK = 1024 * 1024; const O_RDWR = 0x0002; const EXTENSION_SYMBOLS = [ "libxpc.dylib`xpc_main", "Foundation`NSExtensionMain", "ExtensionFoundation`EXExtensionMain", ]; const APP_SYMBOLS = ["UIKitCore`UIApplicationMain", "AppKit`NSApplicationMain"]; function replaceWithRunLoop(symbols: string[]) { const CFRunLoopRun = Process.getModuleByName("CoreFoundation").getExportByName("CFRunLoopRun"); for (const symbol of symbols) { const [dylib, func] = symbol.split("`"); try { const p = Process.getModuleByName(dylib).getExportByName(func); console.log("replace function", symbol, p); Interceptor.replace(p, CFRunLoopRun); } catch (e: unknown) { console.log("skip", symbol, (e as Error).message); } } ObjC.schedule(ObjC.mainQueue, () => { console.log("mainQueue scheduled"); }); } rpc.exports = { hookAppMain() { replaceWithRunLoop(APP_SYMBOLS); }, hookExtensionMain() { replaceWithRunLoop(EXTENSION_SYMBOLS); }, dump( remoteRoot: string, tempRoot: string, binaries: MachOTasks, isExtension: boolean = false, ) { const { open, close, pwrite, exit } = getApi(); for (const [relative, info] of Object.entries(binaries)) { console.log("decrypt", relative); const { offset, size } = info.encrypt; const absoluteOriginal = remoteRoot + "/" + relative; const absoluteTemp = tempRoot + "/" + relative; const mod = Module.load(absoluteOriginal); const fatOffset = Process.findRangeByAddress(mod.base)!.file!.offset; console.log("module =>", mod.name, mod.base, mod.size); console.log("encrypted =>", offset, size, "fatOffset =>", fatOffset); const fd = open(Memory.allocUtf8String(absoluteTemp), O_RDWR) as number; if (fd < 0) { console.error("Failed to open", absoluteTemp); continue; } let p = mod.base.add(offset); let fileOffset = offset + fatOffset; let remaining = size; while (remaining > 0) { const chunk = Math.min(HIGH_WATER_MARK, remaining); pwrite(fd, p, chunk, fileOffset); p = p.add(chunk); fileOffset += chunk; remaining -= chunk; } // patch LC_ENCRYPTION_INFO_64: zero out cryptoff, cryptsize, cryptid const zeros = Memory.alloc(12); pwrite(fd, zeros, 12, info.offset + 8 + fatOffset); close(fd); send({ event: "patch", name: relative }); recv("ack", () => {}).wait(); } if (isExtension) { ObjC.schedule(ObjC.mainQueue, () => { exit(0); }); } return true; }, }; ================================================ FILE: agent/src/shared.ts ================================================ import ObjC from "frida-objc-bridge"; export interface EncryptInfo { offset: number; size: number; id: number; } export interface MachOInfo { type: number; encrypt: EncryptInfo; offset: number; // offset of LC_ENCRYPTION_INFO_64 command } export interface ExtensionInfo { id: string; path: string; exec: string; abs: string; } export type MachOTasks = Record; type NativeAPI = { open: NativeFunction; close: NativeFunction; read: NativeFunction; pwrite: NativeFunction; exit: NativeFunction; }; let api: NativeAPI | null = null; export function nsError(fn: (pError: NativePointer) => T): T { const pError = Memory.alloc(Process.pointerSize); pError.writePointer(NULL); const result = fn(pError); const err = pError.readPointer(); if (!err.isNull()) throw new Error(new ObjC.Object(err).toString()); return result; } export function getApi(): NativeAPI { if (api) return api; api = { open: new NativeFunction(Module.getGlobalExportByName("open"), "int", [ "pointer", "int", ]), close: new NativeFunction(Module.getGlobalExportByName("close"), "int", [ "int", ]), read: new NativeFunction(Module.getGlobalExportByName("read"), "long", [ "int", "pointer", "long", ]), pwrite: new NativeFunction(Module.getGlobalExportByName("pwrite"), "long", [ "int", "pointer", "long", "long", ]), exit: new NativeFunction(Module.getGlobalExportByName("exit"), "void", [ "int", ]), }; return api; } ================================================ FILE: agent/src/springboard.ts ================================================ import ObjC from "frida-objc-bridge"; import Controller, { type Packet } from "frida-remote-stream"; import type { ExtensionInfo, MachOInfo, MachOTasks } from "./shared.js"; import { getApi, nsError } from "./shared.js"; const MH_MAGIC_64 = 0xfeedfacf; const LC_ENCRYPTION_INFO_64 = 0x2c; const HEADER_SIZE_64 = 32; const O_RDONLY = 0; const STREAM_CHUNK = 2 * 1024 * 1024; const EXCLUDE_DIRS = new Set(["SC_Info", "_CodeSignature"]); const EXCLUDE_FILES = new Set([ "iTunesMetadata.plist", "embedded.mobileprovision", ]); function fileMgr() { return ObjC.classes.NSFileManager.defaultManager(); } function parseMachO(path: string): MachOInfo | null { const { open, close, read } = getApi(); const fd = open(Memory.allocUtf8String(path), O_RDONLY) as number; if (fd < 0) return null; try { const hdr = Memory.alloc(HEADER_SIZE_64); if ((read(fd, hdr, HEADER_SIZE_64) as number) < HEADER_SIZE_64) return null; if (hdr.readU32() !== MH_MAGIC_64) return null; const type = hdr.add(12).readU32(); const ncmds = hdr.add(16).readU32(); const sizeOfCmds = hdr.add(20).readU32(); const cmds = Memory.alloc(sizeOfCmds); if ((read(fd, cmds, sizeOfCmds) as number) < sizeOfCmds) return null; const result: MachOInfo = { type, encrypt: { offset: 0, size: 0, id: 0 }, offset: 0, }; for (let off = 0, i = 0; i < ncmds && off + 8 <= sizeOfCmds; i++) { const cmd = cmds.add(off).readU32(); const cmdsize = cmds.add(off + 4).readU32(); if (cmd === LC_ENCRYPTION_INFO_64) { result.encrypt = { offset: cmds.add(off + 8).readU32(), size: cmds.add(off + 12).readU32(), id: cmds.add(off + 16).readU32(), }; result.offset = off + HEADER_SIZE_64; } off += cmdsize; } return result; } finally { close(fd); } } function scanDir(root: string, dir: string, tasks: MachOTasks): void { const items = fileMgr().contentsOfDirectoryAtPath_error_(dir, NULL); if (!items) return; for (let i = 0; i < items.count(); i++) { const name: string = items.objectAtIndex_(i).toString(); const full = dir + "/" + name; const pIsDir = Memory.alloc(Process.pointerSize); pIsDir.writeU8(0); if (!fileMgr().fileExistsAtPath_isDirectory_(full, pIsDir)) continue; if (pIsDir.readU8()) { if (!EXCLUDE_DIRS.has(name)) scanDir(root, full, tasks); } else { const info = parseMachO(full); if (info && info.encrypt.id !== 0) { tasks[full.substring(root.length + 1)] = info; } } } } function removeExcludedDirs(dir: string): void { const items = fileMgr().contentsOfDirectoryAtPath_error_(dir, NULL); if (!items) return; for (let i = 0; i < items.count(); i++) { const name: string = items.objectAtIndex_(i).toString(); const full = dir + "/" + name; const pIsDir = Memory.alloc(Process.pointerSize); pIsDir.writeU8(0); if (!fileMgr().fileExistsAtPath_isDirectory_(full, pIsDir)) continue; if (!pIsDir.readU8()) continue; if (EXCLUDE_DIRS.has(name)) { fileMgr().removeItemAtPath_error_(full, NULL); } else { removeExcludedDirs(full); } } } function zipDirectory(sourceDir: string, destPath: string): string { const coordinator = ObjC.classes.NSFileCoordinator.alloc().initWithFilePresenter_(null); const folderURL = ObjC.classes.NSURL.fileURLWithPath_(sourceDir); const block = new ObjC.Block({ retType: "void", argTypes: ["object"], implementation(newURL: ObjC.Object) { nsError((e) => fileMgr().copyItemAtPath_toPath_error_( newURL.path().toString(), destPath, e, ), ); }, }); const NSFileCoordinatorReadingForUploading = 1 << 3; nsError((e) => coordinator.coordinateReadingItemAtURL_options_error_byAccessor_( folderURL, NSFileCoordinatorReadingForUploading, e, block, ), ); return destPath; } rpc.exports = { prepare(bundlePath: string, bundleId: string, removeKeys: string[] = []) { const tmp = Process.getTmpDir().replace(/\/$/, ""); const bundleName = bundlePath.split("/").pop()!; const base = tmp + "/.bagbak-" + bundleName; const payloadDir = base + "/Payload"; const root = payloadDir + "/" + bundleName; fileMgr().removeItemAtPath_error_(base, NULL); nsError((e) => fileMgr().createDirectoryAtPath_withIntermediateDirectories_attributes_error_( payloadDir, true, NULL, e, ), ); nsError((e) => fileMgr().copyItemAtPath_toPath_error_(bundlePath, root, e)); removeExcludedDirs(root); for (const f of EXCLUDE_FILES) { fileMgr().removeItemAtPath_error_(root + "/" + f, NULL); } const infoPlist = root + "/Info.plist"; const infoPlistURL = ObjC.classes.NSURL.fileURLWithPath_(infoPlist); const dict = ObjC.classes.NSMutableDictionary.alloc().initWithContentsOfURL_( infoPlistURL, ); if (dict && removeKeys.length > 0) { for (const key of removeKeys) { dict.removeObjectForKey_(key); } dict.writeToURL_atomically_(infoPlistURL, true); } const tasks: MachOTasks = {}; scanDir(root, root, tasks); ObjC.classes.NSBundle.bundleWithPath_( "/System/Library/Frameworks/CoreServices.framework/", ).load(); const app = ObjC.classes.LSApplicationProxy.applicationProxyForIdentifier_(bundleId); if (!app) throw new Error(`app ${bundleId} not found`); const extensions: ExtensionInfo[] = []; const plugins = app.plugInKitPlugins(); for (let i = 0; i < plugins.count(); i++) { const plugin = plugins.objectAtIndex_(i); const plist = plugin.infoPlist(); const exec: string = plist.objectForKey_("CFBundleExecutable").toString(); const path: string = plist.objectForKey_("Path").toString(); extensions.push({ id: plugin.bundleIdentifier().toString(), path, exec, abs: path + "/" + exec, }); } const mainBinary: string = app.bundleExecutable().toString(); return { base, root, tasks, extensions, mainBinary }; }, zip(base: string) { return zipDirectory(base + "/Payload", base + "/app.ipa"); }, zipFiles(root: string, files: string[]) { const base = root.replace(/\/Payload\/[^/]+$/, ""); const staging = base + "/binaries"; const zipDest = base + "/binaries.zip"; fileMgr().removeItemAtPath_error_(staging, NULL); fileMgr().removeItemAtPath_error_(zipDest, NULL); for (const relative of files) { const src = root + "/" + relative; const dst = staging + "/" + relative; const dstDir = dst.substring(0, dst.lastIndexOf("/")); nsError((e) => fileMgr().createDirectoryAtPath_withIntermediateDirectories_attributes_error_( dstDir, true, NULL, e, ), ); nsError((e) => fileMgr().copyItemAtPath_toPath_error_(src, dst, e)); } return zipDirectory(staging, zipDest); }, stream(filePath: string) { const { open, close, read } = getApi(); const attrs = fileMgr().attributesOfItemAtPath_error_(filePath, NULL); const fileSize: number = attrs .objectForKey_("NSFileSize") .unsignedLongLongValue(); console.log("stream:", filePath, "size:", fileSize); const fd = open(Memory.allocUtf8String(filePath), O_RDONLY) as number; if (fd < 0) throw new Error("Failed to open " + filePath); const controller = new Controller(); // agent → host: send stanza requests controller.events.on("send", (packet: Packet) => { const buf = packet.data as Buffer | null; send( packet.stanza, buf ? (buf.buffer.slice( buf.byteOffset, buf.byteOffset + buf.byteLength, ) as ArrayBuffer) : null, ); }); // host → agent: receive stanza responses function listen() { recv("stream", (message: any, data: ArrayBuffer | null) => { controller.receive({ stanza: message, data: data as any }); listen(); }); } listen(); const sink = controller.open("ipa", { size: fileSize }); return new Promise((resolve, reject) => { sink.on("error", reject); sink.on("finish", () => { close(fd); resolve(true); }); const buf = Memory.alloc(STREAM_CHUNK); let remaining = fileSize; function writeNext() { while (remaining > 0) { const n = Math.min(STREAM_CHUNK, remaining); const bytesRead = read(fd, buf, n) as number; if (bytesRead <= 0) { sink.end(); return; } const chunk = Buffer.from(buf.readByteArray(bytesRead)!); remaining -= bytesRead; if (remaining <= 0) { sink.end(chunk); return; } if (!sink.write(chunk)) { sink.once("drain", writeNext); return; } } sink.end(); } writeNext(); }); }, cleanup(base: string) { fileMgr().removeItemAtPath_error_(base, NULL); }, }; ================================================ FILE: agent/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "lib": ["ES2022"], "module": "Node16", "strict": true, "noEmit": true, }, "include": ["src/**/*.ts"], } ================================================ FILE: bin/bagbak.ts ================================================ #!/usr/bin/env node import chalk from "chalk"; import { Command } from "commander"; import { DeviceManager, getDevice, getRemoteDevice, getUsbDevice, Scope, } from "frida"; import type { Device } from "frida"; import { BagBak, type DumpMode } from "../index.ts"; import { enableDebug, version } from "../lib/utils.ts"; const VALID_MODES = ["all", "main", "extensions", "binaries"] as const; const MODE_ALIASES: Record = { app: "main", ext: "extensions", exts: "extensions", executables: "binaries", bin: "binaries", }; interface Options { device?: string; usb?: boolean; remote?: boolean; host?: string; debug?: boolean; list?: boolean; json?: boolean; output?: string; removeKeys?: string; } function getDeviceFromOptions(opts: Options): Promise { let count = 0; if (opts.device) count++; if (opts.usb) count++; if (opts.remote) count++; if (opts.host) count++; if (count === 0 || opts.usb) return getUsbDevice(); if (count > 1) throw new Error( "Only one of --device, --usb, --remote, --host can be specified", ); if (opts.device) return getDevice(opts.device); if (opts.remote) return getRemoteDevice(); if (opts.host) { const manager = new DeviceManager(); return manager.addRemoteDevice(opts.host); } return getUsbDevice(); } async function main() { const program = new Command(); program .name("bagbak") .option("-l, --list", "list apps") .option("-j, --json", "output as json (only works with --list)") .option("-U, --usb", "connect to USB device (default)") .option("-R, --remote", "connect to remote frida-server") .option("-D, --device ", "connect to device with the given ID") .option("-H, --host ", "connect to remote frida-server on HOST") .option("-d, --debug", "enable debug output") .option("-o, --output ", "ipa filename or directory") .option("--remove-keys ", "Info.plist keys to remove (comma-separated)") .argument("[target]", "bundle id or name") .argument("[mode]", "dump mode: all, main (app), extensions (ext, exts), binaries (bin, executables)", "all") .version(version()); program.parse(process.argv); const opts = program.opts(); if (opts.debug) enableDebug(true); const device = await getDeviceFromOptions(opts); const info = await device.querySystemParameters(); if ( info.access !== "full" || info.os.id !== "ios" || info.platform !== "darwin" || info.arch !== "arm64" ) { console.error("This tool requires a jailbroken 64bit iOS device"); process.exit(1); } if (opts.list) { const apps = await device.enumerateApplications({ scope: Scope.Metadata }); if (opts.json) { console.log(JSON.stringify(apps, null, 2)); return; } const verWidth = Math.max( ...apps.map((app) => (app.parameters?.version as string)?.length || 0), ); const idWidth = Math.max(...apps.map((app) => app.identifier.length)); console.log( chalk.gray("Version".padStart(verWidth)), chalk.gray("Identifier".padEnd(idWidth)), chalk.gray("Name"), ); console.log(chalk.gray("\u2500".repeat(10 + verWidth + idWidth))); for (const app of apps) { console.log( chalk.yellowBright( ((app.parameters?.version as string) || "").padStart(verWidth), ), chalk.greenBright(app.identifier.padEnd(idWidth)), app.name, ); } return; } if (program.args.length >= 1) { const target = program.args[0]; const rawMode = program.args[1] || "all"; const mode = (MODE_ALIASES[rawMode] || rawMode) as DumpMode; if (!VALID_MODES.includes(mode)) { console.error( chalk.red(`Invalid mode "${rawMode}". Must be one of: ${VALID_MODES.join(", ")} (aliases: ${Object.keys(MODE_ALIASES).join(", ")})`), ); process.exit(1); } const apps = await device.enumerateApplications({ scope: Scope.Metadata }); const app = apps.find( (app) => app.name === target || app.identifier === target, ); if (!app) throw new Error(`Unable to find app ${target}`); const job = new BagBak(device, app); job .on("status", (msg: string) => { console.log(chalk.greenBright("[info]"), msg); }) .on("patch", (name: string) => { console.log(chalk.redBright("[decrypt]"), name); }) .on("streaming", (totalSize: number) => { console.log( chalk.greenBright("[info]"), `Streaming ${(totalSize / 1024 / 1024).toFixed(1)} MB from device...`, ); }) .on("progress", (transferred: number, totalSize: number) => { const percent = Math.min(transferred / totalSize, 1); const barWidth = 30; const filled = Math.round(barWidth * percent); const bar = chalk.greenBright("\u2588".repeat(filled)) + chalk.gray("\u2591".repeat(barWidth - filled)); const mb = (transferred / 1024 / 1024).toFixed(1); const totalMb = (totalSize / 1024 / 1024).toFixed(1); const pct = (percent * 100).toFixed(0).padStart(3); process.stdout.write( `\r ${bar} ${pct}% ${mb}/${totalMb} MB`, ); if (transferred >= totalSize) { process.stdout.write("\n"); } }); const removeKeys = opts.removeKeys ? opts.removeKeys.split(",").map((k) => k.trim()) : []; const saved = await job.pack(opts.output, mode, removeKeys); console.log(`Saved to ${chalk.yellow(saved)}`); return; } program.help(); } main(); ================================================ FILE: index.ts ================================================ import { EventEmitter } from "events"; import { createWriteStream, type PathLike } from "fs"; import { resolve } from "path"; import { Transform } from "stream"; import { pipeline } from "stream/promises"; import chalk from "chalk"; import type { Application, Device, Script } from "frida"; import Controller from "frida-remote-stream"; import { debug, directoryExists, readFromPackage, sleep } from "./lib/utils.ts"; const MH_EXECUTE = 0x2; const MAX_DECRYPT_RETRIES = 3; export type DumpMode = "all" | "main" | "extensions" | "binaries"; interface Extension { id: string; path: string; abs: string; } interface BinaryInfo { type: number; [key: string]: unknown; } interface PrepareResult { base: string; root: string; tasks: Record; extensions: Extension[]; mainBinary: string; } export class BagBak extends EventEmitter { #device: Device; #app: Application; constructor(device: Device, app: Application) { super(); this.#app = app; this.#device = device; } get bundle() { return this.#app.identifier; } get remote() { return this.#app.parameters.path as string; } async #attach() { const session = await this.#device.attach("SpringBoard"); const code = await readFromPackage("agent", "dist", "springboard.js"); const script = await session.createScript(code.toString()); script.logHandler = (level, text) => console.log("[springboard]", level, text); await script.load(); return { session, script }; } async #decrypt( pid: number, remoteRoot: string, root: string, binaries: Record, isExtension: boolean, ) { const session = await this.#device.attach(pid); const code = await readFromPackage("agent", "dist", "app.js"); const script = await session.createScript(code.toString()); script.logHandler = (level, text) => debug("[app]", level, text); script.message.connect((message) => { if (message.type === "send" && message.payload?.event === "patch") { this.emit("patch", message.payload.name); script.post({ type: "ack" }); } }); await script.load(); if (isExtension) { await script.exports.hookExtensionMain(); } else { await script.exports.hookAppMain(); } await this.#device.resume(pid); const result = await script.exports.dump( remoteRoot, root, binaries, isExtension, ); debug("dump result =>", result); await script.unload(); await session.detach(); } async #decryptWithRetry( label: string, spawnTarget: string | string[], remoteRoot: string, root: string, binaries: Record, isExtension: boolean, ): Promise { let lastError: unknown; for (let attempt = 1; attempt <= MAX_DECRYPT_RETRIES; attempt++) { const pid = await this.#device.spawn(spawnTarget, { env: { DISABLE_TWEAKS: "1" }, }); debug("spawned", label, "pid =>", pid); try { await this.#decrypt(pid, remoteRoot, root, binaries, isExtension); return; } catch (e) { lastError = e; if (attempt < MAX_DECRYPT_RETRIES) { this.emit( "status", `Retry ${attempt}/${MAX_DECRYPT_RETRIES} for ${label}...`, ); await sleep(1000); } } finally { await this.#device.kill(pid).catch(() => {}); } } throw lastError; } async #pull(coordScript: Script, zipPath: string, destPath: string) { const controller = new Controller(); const done = new Promise((resolve, reject) => { controller.events.on("stream", (source: any) => { const totalSize: number = source.details.size; this.emit("streaming", totalSize); let transferred = 0; const progress = new Transform({ transform(chunk, _encoding, callback) { transferred += chunk.length; this.push(chunk); callback(); }, }); const interval = setInterval(() => { this.emit("progress", transferred, totalSize); }, 200); pipeline(source, progress, createWriteStream(destPath)).then( () => { clearInterval(interval); this.emit("progress", totalSize, totalSize); resolve(); }, (err) => { clearInterval(interval); reject(err); }, ); }); }); controller.events.on("send", (packet: any) => { coordScript.post({ type: "stream", ...packet.stanza }, packet.data); }); const handler = (message: any, data: any) => { if ( message.type === "send" && typeof message.payload?.name === "string" ) { controller.receive({ stanza: message.payload, data }); } }; coordScript.message.connect(handler); try { await coordScript.exports.stream(zipPath); await done; } finally { coordScript.message.disconnect(handler); } } async pack(suggested?: PathLike, mode: DumpMode = "all", removeKeys: string[] = []): Promise { const { session: coordSession, script: coordScript } = await this.#attach(); try { this.emit("status", "Preparing app bundle..."); const { base, root, tasks, extensions, mainBinary } = (await coordScript.exports.prepare( this.remote, this.bundle, removeKeys, )) as PrepareResult; const taskCount = Object.keys(tasks).length; debug("root", root); debug("tasks", taskCount, "encrypted binaries"); debug("extensions", extensions.length); if (taskCount === 0) { this.emit("status", "No encrypted binaries found"); } const groupByExtensions = new Map>( extensions.map((ext) => [ext.id, {}]), ); const binariesForMain: Record = {}; for (const [relative, info] of Object.entries(tasks)) { const absolute = this.remote + "/" + relative; const ext = extensions.find((ext) => absolute.startsWith(ext.path)); if (ext) { debug("scope for", chalk.green(relative), "is", chalk.gray(ext.id)); groupByExtensions.get(ext.id)![relative] = info; } else if ( info.type === MH_EXECUTE && absolute !== this.remote + "/" + mainBinary ) { console.error(chalk.red("Executable"), chalk.yellowBright(relative)); console.error( chalk.red( "is not within any extension. Likely requires higher MinimumOSVersion.", ), ); console.error(chalk.red("This binary will be left encrypted.")); } else { debug("scope for", relative, "is", chalk.green("main app")); binariesForMain[relative] = info; } } const decryptMain = mode !== "extensions"; const decryptExtensions = mode !== "main"; if (decryptMain && Object.keys(binariesForMain).length) { this.emit("status", "Decrypting main app..."); await this.#decryptWithRetry( "main app", this.bundle, this.remote, root, binariesForMain, false, ); } if (decryptExtensions) { for (const [extId, binaries] of groupByExtensions.entries()) { if (Object.keys(binaries).length === 0) continue; const ext = extensions.find((e) => e.id === extId)!; this.emit("status", `Decrypting extension ${extId}...`); await this.#decryptWithRetry( extId, [ext.abs], this.remote, root, binaries, true, ); } } const ver = (this.#app.parameters.version as string) || "Unknown"; let remotePath: string; let defaultFilename: string; let ext: string; if (mode === "all") { this.emit("status", "Packaging IPA..."); remotePath = (await coordScript.exports.zip(base)) as string; ext = ".ipa"; defaultFilename = `${this.bundle}-${ver}.ipa`; } else { const files: string[] = []; if (mode === "main" || mode === "binaries") { files.push(...Object.keys(binariesForMain)); } if (mode === "extensions" || mode === "binaries") { for (const [extId, binaries] of groupByExtensions.entries()) { files.push(...Object.keys(binaries)); } } const plists = new Set(); for (const f of files) { const dir = f.lastIndexOf("/"); plists.add(dir === -1 ? "Info.plist" : f.substring(0, dir) + "/Info.plist"); } files.push(...plists); this.emit("status", `Compressing ${files.length} files...`); remotePath = (await coordScript.exports.zipFiles( root, files, )) as string; ext = ".zip"; defaultFilename = `${this.bundle}-${ver}-${mode}.zip`; } debug("remote path:", remotePath); const suggestedStr = suggested?.toString(); const dest = suggestedStr ? (await directoryExists(suggestedStr)) ? suggestedStr + "/" + defaultFilename : suggestedStr : defaultFilename; if (ext && !dest.endsWith(ext)) throw new Error(`Invalid filename ${dest}, expected ${ext} extension`); this.emit("status", "Downloading..."); await this.#pull(coordScript, remotePath, resolve(process.cwd(), dest)); await coordScript.exports.cleanup(base); return dest; } finally { await coordScript.unload().catch(() => {}); await coordSession.detach().catch(() => {}); } } } ================================================ FILE: lib/utils.ts ================================================ import { readFile, stat } from "fs/promises"; import { type PathLike } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import pkg from "../package.json" with { type: "json" }; export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); export const directoryExists = (path: PathLike): Promise => stat(path) .then((info) => info.isDirectory()) .catch(() => false); const __dirname = dirname(fileURLToPath(import.meta.url)); const packageRoot = join( __dirname, process.env.TSDOWN_BUILD ? join("..", "..") : "..", ); export function readFromPackage(...components: string[]): Promise { return readFile(join(packageRoot, ...components), "utf8"); } export function version(): string { return pkg.version; } let __debug = "DEBUG" in process.env; export function debug(...args: unknown[]) { if (__debug) console.log(...args); } export function enableDebug(value?: boolean): boolean { if (value !== undefined) __debug = value; return __debug; } ================================================ FILE: package.json ================================================ { "name": "bagbak", "version": "5.1.2", "description": "Dump iOS app from a jailbroken device, based on frida.re", "main": "dist/index.mjs", "types": "dist/index.d.mts", "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs" } }, "scripts": { "build": "tsdown", "prepublishOnly": "npm run build", "test": "echo 'Not supported'" }, "author": "CodeColorist", "license": "MIT", "bin": { "bagbak": "dist/bin/bagbak.mjs" }, "publishConfig": { "access": "public", "provenance": true }, "files": [ "/dist", "/agent/dist/app.js", "/agent/dist/springboard.js" ], "engines": { "node": ">=18.0.0" }, "type": "module", "devDependencies": { "@types/frida-gum": "^19.0.2", "@types/node": "^25.5.0", "tsdown": "^0.21.4", "typescript": "^5.9.3" }, "dependencies": { "chalk": "^5.6.2", "commander": "^14.0.3", "frida": "^17.8.2", "frida-remote-stream": "^6.0.3" }, "repository": { "type": "git", "url": "git+https://github.com/ChiChou/bagbak.git" }, "keywords": [ "ios", "reverse-engineering", "frida", "jailbreak" ], "bugs": { "url": "https://github.com/ChiChou/bagbak/issues" }, "homepage": "https://github.com/ChiChou/bagbak#readme" } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "module": "nodenext", "noEmit": true, "rewriteRelativeImportExtensions": true, }, } ================================================ FILE: tsdown.config.ts ================================================ import { defineConfig } from "tsdown"; export default defineConfig({ entry: ["index.ts", "bin/bagbak.ts"], format: "esm", dts: true, outDir: "dist", unbundle: true, define: { "process.env.TSDOWN_BUILD": "'1'", }, });