AAB to APK and APK Unpack: Extract Assets and Scripts with REA
On this page
An APK is a ZIP file, so unzip will open it. What unzip will not tell you is what each entry is, whether the bytes you wrote to disk match the archive, or where the app’s real logic lives: DEX, a JavaScript bundle or native libraries. REA, the open-source project by morluto, adds that layer: a content-addressed inventory, a safe extractor that verifies every byte it writes, and an Apktool wrapper that decodes resources.
This guide is about unpacking and taking stock, not decompiling Java. If you want to read classes and methods, go to Android APK Analysis with REA or the JADX MCP setup guide after you finish here.
Who this is for: developers and researchers who have an .apk or .aab and want to know what is inside before choosing a deeper tool.
What you will learn:
- Convert an Android App Bundle (AAB) to a universal APK with bundletool
- Inventory an APK with
inspect-artifact, and what that inventory cannot see - Extract every file with
extract-artifact, and why it refuses some archives - Decode the manifest and strings with
decode-android-resources - Recognize React Native, Hermes and other payloads by file name and header, and know where REA 6.7.0 stops
Targets used: the F-Droid client APK (GPL-3.0, source at gitlab.com/fdroid/fdroidclient) and an .aab you build yourself from an open-source project. Do not use these steps to pull paid content out of commercial apps.
Authorization first. Only unpack apps you wrote, open-source apps, or apps you have explicit permission to examine. Licenses and terms of service still apply to the extracted files. Read REA’s DISCLAIMER.md before you start.
Prerequisites
- REA 6.7.0.
npm install --global [email protected], or prefix commands withnpx rea-agents@latest. Ifrea --versionprints something unexpected, check you are running the real package with How to verify a real REA install. - bundletool from its GitHub releases, plus Java to run the JAR. Only needed for AAB input.
- Apktool 2.7.0 or newer for resource decoding. REA’s own doc was verified with 2.7.0 (apktool-resource-analysis.md).
- Optional:
xxdorfileto look at file headers, andapksignerfrom the Android SDK build-tools if you care about signatures.
REA installs none of the external tools. The artifact inventory and extraction commands are built in and need no extra provider.
We re-ran this guide on macOS with REA 6.7.0 from npm, bundletool 1.18.1, Apktool 2.12.1 and Temurin JDK 17.0.18.
APK vs AAB: what REA actually sees
Both formats start with the ZIP signature PK\x03\x04, but they are different things:
- An APK is an installable package:
AndroidManifest.xmlin binary XML, one or moreclasses*.dex,resources.arsc,res/,assets/,lib/<abi>/*.soandMETA-INF/. - An AAB is a publishing format. Google Play generates device-specific APKs from it. Its layout is per module (
base/manifest/,base/dex/,base/res/and so on), and you cannot install it directly.
REA 6.7.0, like 6.4.0, decides the package format by path suffix after checking the ZIP magic. In dist/domain/zipPackageFormat.js, a .apk name maps to the format apk, and the code carries the comment “An Android App Bundle is a ZIP archive, not a standalone APK.” There is no aab format: an .aab with valid ZIP magic is classified as plain zip.
That has two practical consequences:
inspect-artifactandextract-artifactwork on an.aab. You get a ZIP inventory of the bundle layout.- The Android tools (
decode-android-resources,inspect-android-packageand the JADX family) expect an APK. REA’s android-analysis.md states that these operations do not provide split APK/AAB handling. Convert the AAB first. The JADX tools say so: on a test.aab,inspect-android-packagerefused with “is zip; provide one standalone APK”.decode-android-resourcesgives no such warning (see Troubleshooting).
Step 1: Convert an AAB to an APK with bundletool
Skip this step if you already have an APK.
Build the bundle from source you control, for example with ./gradlew bundleRelease in an open-source Android project, then ask bundletool for one universal APK:
java -jar bundletool.jar build-apks \
--bundle=/absolute/path/app-release.aab \
--output=/absolute/path/app-universal.apks \
--mode=universal
Why --mode=universal: by default bundletool generates the full set of split APKs Play would serve. The official bundletool documentation describes universal mode as a single APK that “includes all of your app’s code and resources” for every device configuration your app supports. One file is much easier to inventory than a split set.
Three details from the docs and the bundletool source:
- Signing. If you pass no
--ks, bundletool “attempts to sign your APKs with a debug key.” In the source (DebugKeystoreUtils.java) it looks for.android/debug.keystoreunderANDROID_SDK_HOME, the user home directory orHOME, with aliasAndroidDebugKey. A machine with Android Studio usually has one. A debug-signed APK is fine for analysis but is not the developer’s release signature. - Feature modules. Only modules that declare
<dist:fusing dist:include="true"/>in their manifest go into the universal APK. Anything else is missing from it, so do not conclude a feature is absent from the app. - Existing output. bundletool fails if the
--outputfile exists, unless you add--overwrite. bundletool 1.18.1 printsError: File '...apks' already exists.
The output is an .apks file, which is itself a ZIP archive (an “APK set”). In universal mode bundletool names the APK universal.apk at the archive root (ApkPathManager.java in the bundletool source). Take it out with plain unzip:
mkdir /absolute/path/apks-out
unzip /absolute/path/app-universal.apks universal.apk -d /absolute/path/apks-out
You could also run REA on the .apks: it is classified as zip, and extract-artifact writes toc.pb and universal.apk out as two files. We built one from a small test bundle with bundletool 1.18.1. The inventory listed universal.apk (12,490 bytes) with artifact_kind: "container", artifact_format: "apk" and a verified hash, but none of the entries inside it. REA does not open a nested APK. Either way, continue with the extracted universal.apk.
Step 2: Inventory the APK with inspect-artifact
Download the F-Droid client from f-droid.org (or use your universal.apk), record its digest, then inspect it. Our copy, downloaded on 2026-10-20, was version 2.0.2 (version code 2000052), 12,569,951 bytes, SHA-256 19d51fad…7b52:
shasum -a 256 /absolute/path/F-Droid.apk
set -o pipefail
rea inspect-artifact /absolute/path/F-Droid.apk --json | jq . > fdroid-inventory.json
Why inventory before extracting: the result is the complete content-addressed artifact graph in one call: every central-directory entry with its logical path, entry kind, declared and compressed size, encryption flag, SHA-256, plus integrity observations, limitations and suggested next probes. You can search it with jq and decide what to pull out before writing anything to disk. pipefail keeps REA’s exit status if jq follows it.
For that file the inventory took 0.9 seconds and listed 974 entries.
What to look for first:
classes*.dexcount, which tells you whether the app is multidex (F-Droid 2.0.2 hasclasses.dextoclasses3.dex)lib/<abi>/*.so, which REA classifies asdynamic-library(here onlylibandroidx.graphics.path.so, for four ABIs)- everything under
assets/, where scripts and engine data usually live res/andresources.arsc, which hold compiled resources (decoded in Step 4)
--integrity-policy takes fail (default) or record-and-continue. It matters most for formats that declare their own hashes, such as ASAR; leave it at the default for APKs unless REA reports a contradiction you want to keep as untrusted data.
What the inventory cannot see. REA 6.7.0 records this limitation verbatim: “ZIP inventory lists central-directory entries only. Non-entry bytes, including an APK Signing Block before the central directory or a ZIP comment after it, are not occurrences.” (6.4.0 wrongly placed the signing block after the central directory; 6.7.0 fixed the wording.) v2+ signatures are not ZIP entries, so you will not see them in the list, and inspect-artifact does not validate them. If signatures matter, run apksigner verify --print-certs /absolute/path/F-Droid.apk. Only v1 (JAR) signature files under META-INF/ appear as normal entries.
Over MCP: open the APK as the active target (open_binary), then ask your agent to call inspect_artifact. Besides integrity_policy, 6.7.0 accepts detail: "summary" (added in 6.5.0; see mcp-contracts.md). Use it on real apps: for F-Droid the complete result was 1,076,771 characters, the summary 3,951. The summary gives counts (851 nodes, 974 occurrences) and a parent_evidence_id; page the entries you need with inspect_analysis_view and {"kind": "inventory", "collection": "occurrences", "offset": 0, "limit": 64}. Each such page was about 38,500 characters for F-Droid, with next_offset pointing to the next one.
Step 3: Extract APK assets with extract-artifact
rea extract-artifact /absolute/path/F-Droid.apk --json > fdroid-extract.json
There is no output-directory option. REA creates a fresh directory named rea-extracted-<uuid> under the system temp directory, and the result reports it as output_root. Each extracted file is listed with its relative_path, sha256 and bytes_written.
Why use this instead of unzip: the extractor in dist/artifacts/extraction/ArtifactExtraction.js and SafeOutputTree.js is built for untrusted archives:
- Entry paths must be relative and normalized. Absolute paths and
..segments are rejected, so a crafted entry cannot write outside the output root (the classic “zip slip”). - Symlink entries are not followed, and the output root is created with mode
0700. - Every file is created exclusively (
O_EXCL). Nothing is ever overwritten, and an existing output root is an error. - Bytes written are checked against the inventory’s size and SHA-256. A mismatch fails the extraction.
- Names that differ only in case are detected when the destination filesystem cannot store both, as on default macOS volumes.
- Encrypted entries stop the whole extraction. REA never decrypts archive entries, so it reports the archive as unsupported rather than producing a partial tree.
Nested archives are written as files, not expanded. That is deliberate: if assets/ contains a ZIP, run inspect-artifact on the extracted copy as a separate step, so each layer keeps its own digest.
On macOS this APK did not extract. The F-Droid run stopped with:
Destination filesystem cannot store both res/2f.xml and res/2F.xml; inventory retains both logical names.
This build ships shortened resource paths such as res/2f.xml, and two of them differ only in case. The default APFS volume is case-insensitive, so REA refuses rather than let one file overwrite the other (6.4.0 does the same). The inventory warns about this ahead of time with “Logical archive names collide under English (en-US) Unicode case folding”. Since the output goes under the system temp directory, we pointed TMPDIR at a case-sensitive disk image:
hdiutil create -size 200m -fs "Case-sensitive APFS" -volname reacs -type SPARSE cs.sparseimage
hdiutil attach cs.sparseimage -mountpoint /absolute/path/csmnt -nobrowse
mkdir /absolute/path/csmnt/tmp
TMPDIR=/absolute/path/csmnt/tmp rea extract-artifact /absolute/path/F-Droid.apk --json > fdroid-extract.json
That extracted all 973 files (28 MB) in about 10 seconds. On Linux the default filesystem is case-sensitive, so this does not come up.
Copy what you need out of the temp directory. It is in $TMPDIR, which your OS may clean up.
Step 4: Decode the manifest and strings with Apktool
AndroidManifest.xml and resources.arsc are binary formats. Raw extraction gives you the bytes, not readable XML. Apktool decodes them:
export REA_APKTOOL_COMMAND=/absolute/path/apktool # optional; otherwise PATH
rea inspect-apktool-client
rea decode-android-resources /absolute/path/F-Droid.apk --json
rea decode-android-resources /absolute/path/F-Droid.apk --locale de --json
rea decode-android-resources /absolute/path/F-Droid.apk --no-strings --json
rea decode-android-resources /absolute/path/F-Droid.apk --locales de --locales fr --json
For F-Droid 2.0.2 the default run returned min SDK 24, target SDK 37, 45 locales and 541 default strings. --no-strings is still accepted in 6.7.0 even though the help now lists the flag as --strings (default true). --locales is new in 6.7.0: repeat it to get several tables at once in string_tables. Here that gave 541 default, 502 German and 504 French entries.
What REA does with Apktool (from dist/apktool/ApktoolProvider.js):
- It copies the APK into a private temporary workspace, hashes it during the copy and refuses the input if the file changes meanwhile.
- It runs
apktool d --no-src -f -o <workspace> --frame-path <private dir> <copy>.--no-srcmeans no smali is produced; code belongs to the JADX tools. The private framework path keeps Apktool from touching your global framework directory. - It returns
apktool.ymlmetadata (version name and code, min and target SDK), the decoded manifest, alocaleslist and one strings table, then deletes the workspace. You do not get a decodedres/tree on disk.
The bounds are fixed in 6.7.0: decoding times out after 600 seconds, the manifest is read up to 512 KiB, strings.xml up to 8 MiB, at most 2,000 string entries are returned per table, and the decoded tree enumeration stops after 100,000 entries. --locales takes up to 20 locales, with a combined cap of 10,000 entries and 32 MiB. Each cap that is hit appears in limitations, so a truncated answer is never silent.
REA_APKTOOL_COMMAND must be an absolute path. A relative value is rejected as not_absolute. inspect-apktool-client only runs apktool --version, so run it first and a missing launcher fails in seconds instead of mid-decode.
If you need the full decoded res/ tree, run Apktool yourself on a copy of the APK. REA’s job here is a reproducible projection tied to the APK’s SHA-256.
Step 5: Find the scripts and engine data
Many apps keep their real logic outside DEX. Use the inventory from Step 2 to find it, then confirm with the file header. File names are hints; headers are better evidence. The signatures below tell you how a build is packaged, which is useful when you ship or audit your own app or study an open-source one. They are not a route to modifying games or extracting assets from apps you do not own.
xxd -l 16 /path/to/rea-extracted-xxxx/assets/some-file
file /path/to/rea-extracted-xxxx/assets/some-file
React Native and Hermes
React Native’s Gradle plugin packs the JavaScript bundle as an asset named index.android.bundle by default (bundleAssetName in ReactExtension.kt). The presence of libhermes.so or libhermes_executor.so in lib/<abi>/ is a hint that Hermes is the engine.
- If the bundle is plain JavaScript, continue with recovering JavaScript source from a bundle.
- If it is Hermes bytecode, the header magic is the 64-bit constant
0x1F1903C103BC1FC6(MAGICin Hermes’s BytecodeFileFormat.h). Stored little-endian,xxdshows it asc6 1f bc 03 c1 03 19 1f. REA 6.7.0 still has no Hermes bytecode support: it can inventory and extract the file and hash it, nothing more. The only Hermes references in itsdist/are library file names used as framework hints.
Game engines
Game engines can usually be identified from their file signatures, for example libil2cpp.so together with global-metadata.dat for a Unity IL2CPP build.
Flutter
lib/<abi>/libapp.so and libflutter.so mean Flutter. Since npm 6.5.0, REA reads the Dart snapshot directly from the APK with identify-flutter-build and inspect-dart-aot. Run them on the APK, not on an extracted libapp.so. Flutter App Reverse Engineering with REA walks through both on a Flutter release build.
Other assets
Images, fonts, audio, JSON configuration and HTML for WebViews sit under assets/ and res/raw/ as ordinary files. After Step 3 they are already on disk with verified hashes.
Troubleshooting
decode-android-resources returns nothing useful for an .aab. It does not fail. On a test bundle, 6.7.0 exited 0 with package name, versions and SDK levels all null, no strings and coverage: "complete", and labelled the subject format apk. 6.4.0 gave the same empty result. Apktool decodes APKs, so run Step 1 first.
extract-artifact says the destination filesystem “cannot store both” two names. The archive has paths that differ only in case and your volume is case-insensitive. Extract on a case-sensitive volume as shown in Step 3.
extract-artifact says an entry is encrypted. REA never decrypts ZIP entries, so it refuses the whole archive. This is a format boundary, not a bug. Do not try to work around encryption in apps you do not own.
“Extraction output root already exists”. Extremely unlikely with the generated UUID name; if it happens, rerun. REA never writes into an existing directory.
Apktool reports capability_unavailable. The launcher was not found or did not report a version. Set REA_APKTOOL_COMMAND to the launcher’s absolute path and rerun rea inspect-apktool-client.
Strings are cut off at 2,000. Use --locale to read one locale at a time, or run Apktool directly for the full table.
The universal APK lacks a feature. The module was not marked for fusing. Build that module’s APKs from the .apks without --mode=universal, or inspect the .aab itself with inspect-artifact.
Two runs report different digests for the “same” app. The F-Droid build and the developer’s own build are signed by different keys, and a bundletool universal APK is re-signed with your debug key. Different bytes, different hashes. Pin the file you analyzed.
A quick note on ASAR
The same pattern (inventory, verified extraction, then a specialized tool) applies to Electron’s app.asar. See Reverse Engineer an Electron App with REA for that walkthrough, including the integrity-policy case where it really matters.
Next steps
- Read classes and methods with JADX: /android-apk-analysis-rea/
- Connect headless JADX to Claude Code or Codex: /jadx-mcp-server-claude-code/
- Analyze JNI libraries in Ghidra: /rea-ghidra-native-binary/
- Compare two app versions: /rea-evidence-snapshots-compare/
- Make sure your REA is genuine: /verify-real-rea-install/
- Reference docs: android-analysis.md, apktool-resource-analysis.md, bundletool