reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
# Extension verification — split APK / App Bundle sets
|
||||
|
||||
Covers the pass that added split-set support to `scripts/repack.py` and wrote
|
||||
`references/split-apk.md`. Strength labels as defined in this directory's README:
|
||||
**observed** (exact command + output reproduced here), **inferred** (follows from observed
|
||||
facts, step not executed), **unverified** (assumed or externally reported, not independently
|
||||
confirmed).
|
||||
|
||||
Reference environment for this pass: Windows 11 host, Python 3.14.0, Java 17.0.4.1
|
||||
(`keytool` NOT on PATH — it was passed explicitly as
|
||||
`<jdk>/bin/keytool.exe`), `apksigner` 0.9 run from `lib/apksigner.jar`, `zipalign.exe` from the
|
||||
same build-tools layout. **No `uber-apk-signer` anywhere on the machine**, which is the condition
|
||||
that made the signing-route fallback a requirement rather than a nicety. Target: one rooted physical
|
||||
Android 11 (API 30) arm64-v8a device, written `<DEVICE>` throughout.
|
||||
|
||||
All package names, application directory names and file names are replaced with placeholders
|
||||
(`<PKG>`, `<SET>`, `<SET_A>`, `<SET_B>`). The measurements, byte counts, entry counts and outputs
|
||||
are unchanged; only the identifiers are.
|
||||
|
||||
Fixtures: two **real split sets already installed on the device**, copied out read-only
|
||||
(`adb pull` of the system directory; nothing on the device was uninstalled, replaced or modified).
|
||||
They were chosen because one exercises each side of the merge/resign decision:
|
||||
|
||||
| | Members | Shape |
|
||||
|---|---|---|
|
||||
| Set A | 8 | base + 7 density splits, every density split carrying its own resources |
|
||||
| Set B | 9 | base + 1 ABI split (native libraries only) + 7 density splits that carry a placeholder table and nothing else |
|
||||
|
||||
Neither set is third-party: `pm list packages -3` reported **zero** split sets among ~40 installed
|
||||
third-party packages, while four preinstalled Google components were split. That distribution is
|
||||
recorded because "pick a third-party split app off the device" is not reliably available.
|
||||
|
||||
## 1. Reading a set: what the members actually contain — observed
|
||||
|
||||
`pm path <PKG>` on each package returned one line per member (8 and 9 lines respectively), which is
|
||||
how the targets were identified in the first place:
|
||||
|
||||
```
|
||||
$ adb shell 'pm path <PKG>'
|
||||
package:/system/priv-app/<DIR>/<SET>.apk
|
||||
package:/system/priv-app/<DIR>/<SET>-arm64_v8a.apk
|
||||
package:/system/priv-app/<DIR>/<SET>-hdpi.apk
|
||||
... (one line per member)
|
||||
```
|
||||
|
||||
`scripts/repack.py --split-mode analyze` over set A (identifiers replaced):
|
||||
|
||||
```
|
||||
== split set: 8 apk(s), 6629510 bytes total
|
||||
density <SET_A>-hdpi.apk 49623 B split=config.hdpi res=39 entries arsc=9392
|
||||
density <SET_A>-ldpi.apk 45501 B split=config.ldpi res=39 entries arsc=9392
|
||||
density <SET_A>-mdpi.apk 45501 B split=config.mdpi res=39 entries arsc=9392
|
||||
density <SET_A>-tvdpi.apk 74227 B split=config.tvdpi res=78 entries arsc=15124
|
||||
density <SET_A>-xhdpi.apk 53754 B split=config.xhdpi res=39 entries arsc=9432
|
||||
density <SET_A>-xxhdpi.apk 57878 B split=config.xxhdpi res=39 entries arsc=10324
|
||||
density <SET_A>-xxxhdpi.apk 57876 B split=config.xxxhdpi res=39 entries arsc=11200
|
||||
base <SET_A>.apk 6245150 B split=- dex=classes.dex res=581 entries arsc=1924488 assets=1
|
||||
[plan] base = <SET_A>.apk (package=<PKG>)
|
||||
[plan] MERGE BLOCKED: merging is not legal for 7 of 7 splits: config.hdpi, config.ldpi, config.mdpi,
|
||||
config.tvdpi, config.xhdpi, config.xxhdpi, config.xxxhdpi carry their own resources, and folding those in
|
||||
means merging resources.arsc (a resource-compiler job). Use --split-mode resign, or pass
|
||||
--drop-split-resources to accept losing exactly those resources
|
||||
[plan] resign = legal: one keystore for all 8 members, install the set with
|
||||
[result] analyze only: nothing was written. Pass --split-mode merge|resign|auto to act on this set.
|
||||
```
|
||||
|
||||
And over set B:
|
||||
|
||||
```
|
||||
== split set: 9 apk(s), 1411272 bytes total
|
||||
abi <SET_B>-arm64_v8a.apk 255861 B split=config.arm64_v8a libs=arm64-v8a
|
||||
density <SET_B>-hdpi.apk 16797 B split=config.hdpi arsc=40
|
||||
... (6 more density members, all 16797 B, all arsc=40)
|
||||
base <SET_B>.apk 1037832 B split=- dex=classes.dex res=2 entries arsc=71316 assets=1
|
||||
[plan] base = <SET_B>.apk (package=<PKG>)
|
||||
[plan] note: every non-base split carries only code/native libraries, so a merged single APK is legal
|
||||
[plan] note: config.arm64_v8a provides libs for arm64-v8a -- keep only the ABIs the target device runs,
|
||||
selected with --abi
|
||||
[plan] merge = legal: 8 member(s) fold into the base
|
||||
```
|
||||
|
||||
Four facts this pass established with those numbers, and that the reference file now depends on:
|
||||
|
||||
- **The ABI member carries no `resources.arsc` at all** (set B: `libs=arm64-v8a`, no arsc column).
|
||||
Its two `.so` files are the whole payload. This is the member that folds cleanly.
|
||||
- **Each density member carries its own table and its own `res/` entries** (set A: 39-78 entries,
|
||||
9.0-15.1 KB tables). `resources.arsc` is per member, not per set — the base's 1.88 MB table does
|
||||
not contain the density variants.
|
||||
- **A `resources.arsc` can be a 40-byte placeholder.** All seven density members of set B carry
|
||||
`arsc=40` and zero `res/` entries, while still being legally required to carry a table. A
|
||||
"has an arsc → owns resources" test would have blocked a merge that is entirely legal here, which
|
||||
is why the script's test is *`res/` entries or a table over 1 KB*.
|
||||
- **Base and split share one package name**, read from the binary AXML `package` attribute of every
|
||||
member, and the base is identifiable as the member whose manifest carries **no `split` attribute**
|
||||
(set A: `split=-`).
|
||||
|
||||
## 2. Unified re-signing — observed
|
||||
|
||||
`--split-mode resign` over set A with one keystore, run through the new `apksigner` route:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_A>/ --split-mode resign \
|
||||
--split-out-dir signed_set/ --ks key.jks \
|
||||
--keytool <jdk>/bin/keytool.exe --apksigner <build-tools>/lib/apksigner.jar \
|
||||
--zipalign <build-tools>/zipalign.exe
|
||||
[resign] signer route: apksigner
|
||||
[resign] <SET_A>-hdpi.apk -> signed_set/<SET_A>-hdpi.apk (41298 bytes)
|
||||
[resign] <SET_A>-ldpi.apk -> signed_set/<SET_A>-ldpi.apk (37202 bytes)
|
||||
[resign] <SET_A>-mdpi.apk -> signed_set/<SET_A>-mdpi.apk (37202 bytes)
|
||||
[resign] <SET_A>-tvdpi.apk -> signed_set/<SET_A>-tvdpi.apk (61642 bytes)
|
||||
[resign] <SET_A>-xhdpi.apk -> signed_set/<SET_A>-xhdpi.apk (41337 bytes)
|
||||
[resign] <SET_A>-xxhdpi.apk -> signed_set/<SET_A>-xxhdpi.apk (49563 bytes)
|
||||
[resign] <SET_A>-xxxhdpi.apk -> signed_set/<SET_A>-xxxhdpi.apk (49577 bytes)
|
||||
[resign] <SET_A>.apk -> signed_set/<SET_A>.apk (4172089 bytes)
|
||||
[result] OK: 8 members, one certificate (sha256 1eb31d9c…2c44)
|
||||
```
|
||||
|
||||
Every member's independent `apksigner verify --print-certs --verbose --min-sdk-version 21
|
||||
--max-sdk-version 34` returned `Verifies` with:
|
||||
|
||||
```
|
||||
Verified using v1 scheme (JAR signing): true
|
||||
Verified using v2 scheme (APK Signature Scheme v2): true
|
||||
Verified using v3 scheme (APK Signature Scheme v3): true
|
||||
Signer #1 certificate DN: CN=apkreverse, OU=dev, O=dev, L=NA, ST=NA, C=NA
|
||||
Signer #1 certificate SHA-256 digest: 1eb31d9c9c81104367a66a0201125aa91d28a6a09eb1dcb5029c36bc0bde2c44
|
||||
```
|
||||
|
||||
All eight printed the **same** SHA-256 — that, not per-file `Verifies`, is the acceptance
|
||||
criterion for a set, and it is the check the run performs explicitly.
|
||||
|
||||
**The signing-route fallback is the load-bearing part of this section.** With no
|
||||
`uber-apk-signer` on the machine, the pre-change script could not sign anything at all: it exited
|
||||
with `signer jar not found`, naming a jar the user never had. The new `--signer auto` resolves to
|
||||
`zipalign -p -f 4` + `apksigner sign --v1 --v2 --v3`, which produced the output above. `--apksigner`
|
||||
accepts `apksigner.bat` (verified: `rc=0`, `--version` → `0.9`), a `lib/apksigner.jar` value run
|
||||
through `java -jar` (verified: same), and the extensionless shell wrapper (which is a shell script
|
||||
and does not run on Windows — that is a fact about the wrapper, not about this script).
|
||||
|
||||
### The signing route this pass actually used — and why it had to change
|
||||
|
||||
`uber-apk-signer` is `repack.py`'s default signer, and it is **not installed on this machine**
|
||||
(no `uber-apk-signer.jar`, `APK_SIGNER_JAR` unset). Before this change that made every signed build
|
||||
impossible here: the script exited with `signer jar not found` and named a jar the user never had.
|
||||
What actually ran in all four measured routes:
|
||||
|
||||
| Item | Value used |
|
||||
|---|---|
|
||||
| signer | `zipalign.exe` + `apksigner`, the latter run from `lib/apksigner.jar` through `java -jar` |
|
||||
| `--apksigner` | `<build-tools>/lib/apksigner.jar`. A `.bat` value was also tested and works (`rc=0`, `--version` → `0.9`); the extensionless wrapper in the same layout is a shell script and does not run on Windows |
|
||||
| `--zipalign` | `<build-tools>/zipalign.exe` |
|
||||
| `--keytool` | `<jdk>/bin/keytool.exe` — **not on PATH**, and signing aborts without a way to create the keystore, so this flag is mandatory on such a machine |
|
||||
| scheme flags | `--v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true` |
|
||||
|
||||
Alignment is handled **per member, and in this order**:
|
||||
|
||||
1. the script writes the archive itself, keeping `AndroidManifest.xml` and `resources.arsc` STORED
|
||||
and 4-byte-aligning `resources.arsc` plus uncompressed `lib/**`, then re-reads the file and
|
||||
prints the gate (`== alignment gate: resources.arsc STORED and 4-byte aligned (OK)`);
|
||||
2. `zipalign -p -f 4` runs over that output (`-p` adds the page alignment the loader wants for
|
||||
`extractNativeLibs="false"` libraries);
|
||||
3. `apksigner sign` runs **last** — it adds signature entries without reshuffling the archive,
|
||||
whereas a `jarsigner`-style rewrite after aligning destroys the alignment and the install is then
|
||||
refused with `[-124]`.
|
||||
|
||||
For a split set this is repeated for every member, which is why `resign_split_set()` de-signs and
|
||||
re-aligns each member individually before signing it: a single member whose `resources.arsc` is
|
||||
compressed or unaligned is refused by the installer on its own, and the resulting error names that
|
||||
member's package rather than the set.
|
||||
|
||||
## 3. Merging — observed, including the refusal
|
||||
|
||||
Set B, where every non-base member is code or native libraries:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_B>/ --split-mode merge \
|
||||
--out merged.apk --abi arm64-v8a --ks key.jks --apksigner <...>/lib/apksigner.jar
|
||||
[merge] + lib/arm64-v8a/lib<name>_compat_libc++.so (18944 bytes <- <SET_B>-arm64_v8a.apk)
|
||||
[merge] + lib/arm64-v8a/lib<name>utilsjni.so (206680 bytes <- <SET_B>-arm64_v8a.apk)
|
||||
[merge] folded: dex=0 libs=2 assets=0 conflicts=0
|
||||
[merge] DROPPED 7 entry/ies. This is a DOWNGRADED build -- say so when you deliver it:
|
||||
[zip] classes.dex 872160 bytes method=8 crc=<...>
|
||||
[zip] resources.arsc STORED offset=0x581fc aligned
|
||||
== alignment gate: resources.arsc STORED and 4-byte aligned (OK)
|
||||
[sign:zipalign] rc=0
|
||||
[sign:apksigner] rc=0
|
||||
[out] merged.apk (603824 bytes)
|
||||
[verify:apksigner] rc=0 Verifies / v1 true / v2 true / v3 true
|
||||
[result] OK
|
||||
```
|
||||
|
||||
`dex=0` is correct rather than suspicious: the base owns `classes.dex` and no member of this set
|
||||
carries a second one, so no renumbering was needed. The `libs=2` fold is the whole point — a
|
||||
one-file APK that now carries the native libraries the ABI member used to provide separately.
|
||||
|
||||
Set A, where merging is **not** legal, refuses rather than producing a broken artifact:
|
||||
|
||||
```
|
||||
$ python skills/apk-reverse/scripts/repack.py --split-dir <SET_A>/ --split-mode merge \
|
||||
--out merged.apk --no-sign
|
||||
error: refusing to merge <SET_A>-hdpi.apk -- it carries its own resources (39 res entries,
|
||||
arsc=9392 bytes).
|
||||
Folding it in would require merging resources.arsc, which this script does not do.
|
||||
Either use --split-mode resign (recommended for a resource split), or pass
|
||||
--drop-split-resources to accept that the resources it owns are lost.
|
||||
exit code 1
|
||||
```
|
||||
|
||||
This is the enforcement the reference file promises: the refusal happens **before** any output is
|
||||
written (the `--out` path does not exist afterwards), and the escape hatch is explicit, printed, and
|
||||
required. The mechanism is **observed**; whether a build produced through that hatch merely loses
|
||||
its density variants or misrenders is **inferred** — it was not exercised, because the only sets
|
||||
available were a legal-merge set and a refused set, and manufacturing the third case would have
|
||||
meant hand-editing a resource table.
|
||||
|
||||
## 4. Single-APK behaviour is unchanged — observed
|
||||
|
||||
Both pre-existing paths were re-run through the same new signing route, on an unmodified sample
|
||||
APK from `tools/_work/bench/apks/`:
|
||||
|
||||
- **zero-change roundtrip** (`--apk … --out … --ks …`, no `--dexdir`): the run dropped exactly the
|
||||
three original signature artifacts (`META-INF/CERT.RSA`, `META-INF/CERT.SF`, `META-INF/MANIFEST.MF`),
|
||||
wrote a 60964-byte unsigned archive, signed to 70647 bytes, reported `== alignment gate:
|
||||
resources.arsc STORED and 4-byte aligned (OK)` and `[result] OK` with v1/v2/v3 all `true`;
|
||||
- **explicit dex replacement** (`--dexdir` supplying the same `classes.dex`, 5528 bytes):
|
||||
`[repl] classes.dex<-…/classes.dex`, the entry re-added at the same size and CRC, alignment gate
|
||||
OK, `[result] OK`.
|
||||
|
||||
Both were run with `--no-sign` as well, which exits 0 and reports `UNSIGNED (--no-sign)`. `--help`
|
||||
works, exits 0, and carries the split examples and the route-decision text; `--split-mode analyze`
|
||||
exits 0; a merge refusal exits 1; a failed verification would exit 2.
|
||||
|
||||
The output fix worth recording: `AndroidManifest.xml` used to be printed as `NOT ALIGNED` in the zip
|
||||
layout (it only has to be STORED, unlike `resources.arsc`). It now prints `-` for entries that need
|
||||
no alignment, so the report cannot be misread as a failing gate.
|
||||
|
||||
## 5. Install and launch on the device — **unverified**
|
||||
|
||||
**Not executed.** The device was held by a concurrent device experiment for the whole window
|
||||
available to this pass (`python tools/_work/devlock.py status` reported the lock held by another
|
||||
teammate for 1417 s, note naming its own install/launch and Frida work), and the lead's decision was
|
||||
to ship this gap labelled rather than wait for it. Heavy device work must be serialised
|
||||
(`tools/_work/ENV.md`), so the honest label for everything below is **unverified** — a claim about
|
||||
installability that was never installed is exactly the failure `references/verification.md` exists
|
||||
to prevent.
|
||||
|
||||
Prepared, and not run — the sequence is the one in `references/split-apk.md` §4, against the
|
||||
re-signed renamed fixture:
|
||||
|
||||
```
|
||||
adb install-multiple -r <signed_set>/*.apk
|
||||
adb shell cmd package resolve-activity --brief <PKG>
|
||||
adb shell am start -n <PKG>/<launcher-activity>
|
||||
adb shell 'dumpsys activity activities | grep -m2 ResumedActivity'
|
||||
adb exec-out screencap -p > launch.png
|
||||
```
|
||||
|
||||
What the pass *did* establish without a device, and what each fact does not cover:
|
||||
|
||||
- **The artifacts are installable in principle, not in fact.** All eight members of the re-signed set
|
||||
verify with v1/v2/v3 `true` and one identical SHA-256 (§2), and the merged single APK verifies the
|
||||
same way (§3, §4). Uniformity of the certificate and a passing `apksigner verify` are necessary
|
||||
conditions for a set install; the platform installer accepting the set is the step that was not run.
|
||||
- **The negative control was not run either.** The signature-conflict text quoted in
|
||||
`references/split-apk.md` §6 (a pulled set reinstalled under its own package name) is the
|
||||
platform's documented shape and the shape a competing tool reports; no captured output from
|
||||
`<DEVICE>` backs it.
|
||||
- Every statement about **what the archives contain** — split roles, arsc ownership, the 40-byte
|
||||
placeholder tables, the fold set, the refusal — is **observed** and device-independent.
|
||||
- Every statement about **what the package manager does with them** is **inferred**.
|
||||
|
||||
**What closing it needs:** one device window that is not shared, then the sequence above (about three
|
||||
minutes of device time). The two things most likely to show up there, and already accounted for in
|
||||
the script: an OEM installer interception on `adb install-multiple` (there is a root
|
||||
`pm install-multiple` path for it) and a launcher activity that needs resolving rather than guessing.
|
||||
|
||||
## 6. Maintenance gate — observed
|
||||
|
||||
Run at the repository root with the change in place:
|
||||
|
||||
```
|
||||
$ python check_repo.py
|
||||
== result: 1 problem(s)
|
||||
- skills/apk-reverse/scripts/dex_strpatch.py --help crashed:
|
||||
|
||||
$ python check_refs.py
|
||||
== section references: 242 reachable ==
|
||||
== result: 0 dangling, 0 warning(s)
|
||||
```
|
||||
|
||||
`check_refs.py` is clean, which covers the three `references/repack-and-sign.md` §-references the new
|
||||
file adds, and `check_repo.py` reports one problem in a **different** file belonging to a different
|
||||
task; `repack.py` parses and answers `--help` with exit 0.
|
||||
|
||||
`check_repo.py` requires every `references/*.md` to be named in `SKILL.md` or the root `README.md`,
|
||||
and this task was not permitted to edit either, so the reachability step was pre-checked in a **copy**
|
||||
of the repository (`tools/_work/repo-check/`, workbench only, never in the tree) by running the gate
|
||||
twice on one snapshot with the index lines as the only variable:
|
||||
|
||||
```
|
||||
without the index lines: == result: 5 problem(s) ... UNLISTED references/split-apk.md
|
||||
with the index lines: == result: 4 problem(s) ... (the UNLISTED line is gone)
|
||||
```
|
||||
|
||||
The new file therefore contributes nothing to the gate once listed. The listing itself was added to
|
||||
`README.md` by the lead, and the run quoted above is the result.
|
||||
|
||||
## 7. Deliberately not done — and what stays unverified
|
||||
|
||||
- **No `.aab` was processed.** A bundle input would need `bundletool` (absent here, and it is the
|
||||
tool that owns the correct resource merge). This pass only ever handled maps of an *installed* set.
|
||||
- **No fixture was hand-built with `aapt2`/`d8`.** Those became available on this machine only after
|
||||
the fixtures were chosen (a build-tools archive was unpacked late in the round), and the two real
|
||||
sets already exercised both branches of the decision. Building a minimal split set by hand remains
|
||||
the cheapest way to reproduce this topic on a machine with no installed bundle and no device, and
|
||||
the route is **inferred** — it was not attempted here. It would not change the resource-merge
|
||||
boundary either: `aapt2 link` merges resources when it *builds* an APK from sources, not when it is
|
||||
handed two finished ones.
|
||||
- **No resource merge was implemented or attempted**, by design
|
||||
(`references/split-apk.md` §5). The consequence is that a bundle whose resources live in splits
|
||||
cannot be reduced to one faithful APK by anything in this repository.
|
||||
- **`apktool` was not exercised, because it is not usable on this machine.** Measured:
|
||||
`apktool.bat d …` fails with `Error: Unable to access jarfile <missing-path>`, while
|
||||
`lib/apksigner.jar` and `zipalign.exe` in the same layout work. Any manifest edit that needs
|
||||
`apktool` is therefore **unverified** here, and AXML attributes are read-only in this pass (the
|
||||
script parses the string pool and the root element's attributes; nothing writes AXML).
|
||||
- **Feature splits (`isFeatureSplit`) were not measured.** Neither available set has one. The
|
||||
handling of `isFeatureSplit` / `configForSplit` in the reference file is **inferred** from the
|
||||
platform's documented semantics plus the fact that both attributes are readable through the same
|
||||
AXML reader that was verified against real manifests.
|
||||
- **Split APKs that are not App-Bundle-derived** (the older per-density `-hdpi.apk` naming style,
|
||||
or an OEM's own module set) were not available; both sets here use the standard `config.<qualifier>`
|
||||
split names, and the reader accepts either because it reads the manifest attribute rather than the
|
||||
file name.
|
||||
- **A XAPK/APKS archive** (a store bundle of bundles, usually a zip holding the split set plus
|
||||
`manifest.json`) was not processed. The script's `--split-dir` recursion handles a *directory* of
|
||||
APKs, so an unpacked XAPK works; the archive itself is not read.
|
||||
Reference in New Issue
Block a user