Files
apk-reverse/references/repack-and-sign.md
T
inliver233 1a5cb6245f Broaden skill with unpacking, TLS triage, third-party build auditing, runtime probe
references: recon gains packer identification + unpacking workflow (payload/plaintext size mapping, dump validation, hash de-duplication, business-dex heuristic); repack-and-sign gains the de-shelled repack pipeline, why apktool over binary AXML, and the apksigner scheme-selection caveat; new tls-and-cert documents feature-scoped network failures, dual trust chains and the fix template; new third-party-builds covers auditing a cracked/modded APK before trusting it; dynamic-frida gains version pinning, remote-device path, the four-layer probe and hook-never-fires triage; environment gains UI automation pitfalls; ad-removal gains the single convergence point, dynamic-plugin SDKs and the global timestamp gate; pitfalls gains P13-P17; verification gains rung 7 and the three-signal ad check.

scripts: new frida_probe.js (four-layer runtime probe), run_probe.py and tls_check.py; repack.py fixes two crash-on-default bugs and a misleading META-INF note; datastore_inject.py, PatchMethodExample.java and build_scripts.py lose hardcoded values - the example class name did not match its file and could not compile.
2026-09-19 21:44:00 +08:00

7.3 KiB

Repack and sign

Rebuilding is the step where otherwise-correct patches die. Two mistakes account for nearly all of it: stripping META-INF/ and recompressing entries that must stay stored.

The rules

1. Strip only signature artifacts, never the whole directory.

META-INF/ contains ServiceLoader registrations the app needs at runtime. Details and the exact failure in references/pitfalls.md P1.

SIG_EXT = ('.SF', '.RSA', '.DSA', '.EC')
def is_signature_entry(name):
    if not name.upper().startswith('META-INF/'):
        return False
    rest = name[len('META-INF/'):]
    if '/' in rest:                 # keep services/, androidx/, native-image/ ...
        return False
    up = rest.upper()
    return up == 'MANIFEST.MF' or up.endswith(SIG_EXT)

2. AndroidManifest.xml and resources.arsc must stay uncompressed (STORED).

Recompressing them produces builds that fail to install or misbehave.

3. Keep the APK's zip entry metadata. Preserve compress_type, external_attr, date_time for entries you copy through.

4. Changing any dex changes nothing about resources. If you only edited dex, do not touch res/, assets/, or lib/.

5. Signing creates new MANIFEST.MF/*.SF/*.RSA. That is expected — the check is that no signature artifact from the ORIGINAL survives, and no non-signature entry was lost.

Repacking an unpacked (de-shelled) app

When the sample was packed, the dex you are about to patch came out of a memory dump while the APK still carries the shell. Build a de-shelled base APK first, then treat it as an ordinary APK for the rest of this document.

  1. Assemble the base. Start from the original APK: replace classes*.dex with the dumped real dexes, and delete the shell library under lib/<abi>/ plus the encrypted assets/ payloads. Strip only signature artifacts — META-INF/*.SF|*.RSA|*.DSA|*.EC and the top-level MANIFEST.MF. Never delete the whole META-INF/ (references/pitfalls.md P1).
  2. Decode with apktool. apktool d base.apk gives the smali tree and a readable text manifest.
  3. Fix the manifest. Point application android:name at the app's real Application class and remove android:appComponentFactory. Grep the decoded tree for leftover shell class references before building.
  4. Patch, then rebuild. apktool b → zipalign -p -f 4 → apksigner (v1+v2+v3) → install and verify against the checklist at the end of this file.

Why apktool rather than editing binary AXML in place: changing android:name in binary AXML means hand-editing the string pool and the attribute/chunk sizes around it, and a mis-sized chunk produces an APK that installs but throws far from the edit, typically in component lookup at startup. The text round-trip moves the risk to "did the rebuild preserve everything else", which item 5 of the checklist verifies directly.

Pipeline

scripts/repack.py implements all of the above. Conceptually:

open original (zip)
  for each entry:
    skip entries that are signature artifacts (per rule 1)
    skip entries being replaced
    copy through, preserving compression + attributes
  write replacement entries
close
sign (v1 + v2 + v3)
verify

Usage:

python scripts/repack.py --apk original.apk --dexdir extracted_dex_dir --out out.apk
# or replace specific dex files:
python scripts/repack.py --apk original.apk --dexdir extracted_dex_dir \
  --dex "classes7.dex=patched/classes7.dex" \
  --dex "classes8.dex=patched/classes8.dex" \
  --out out.apk

--dexdir supplies the untouched dex files; --dex name=path overrides specific ones.

Signing

Two viable routes.

Route A — uber-apk-signer (handy, wraps zipalign + apksigner):

java -jar uber-apk-signer.jar --apks in.apk --ks ks.jks \
  --ksAlias <alias> --ksPass <pass> --ksKeyPass <pass> -o out_dir

Known quirk: -o and --overwrite are mutually exclusive; passing both errors out. Also, it silently skips already-signed APKs (0 processed) — so de-sign first.

Route B — zipalign + apksigner directly (most explicit):

zipalign -p -f 4 unsigned.apk aligned.apk
apksigner sign --ks ks.jks --ks-pass pass:<pass> --key-pass pass:<pass> \
  --v1-signing-enabled true --v2-signing-enabled true --v3-signing-enabled true \
  --out signed.apk aligned.apk
apksigner verify --print-certs --verbose \
  --min-sdk-version 21 --max-sdk-version 34 signed.apk

Enable v1 + v2 + v3. v1 is needed for older Android; v2/v3 for modern verification.

Generate a keystore once:

keytool -genkeypair -v -keystore ks.jks -alias <alias> \
  -keyalg RSA -keysize 2048 -validity 36500 \
  -storepass <pass> -keypass <pass> \
  -dname "CN=<name>, OU=dev, O=dev, L=NA, ST=NA, C=NA"

apksigner verify picks schemes from the APK's own minSdk

apksigner verify decides which signature schemes to check from the APK's own minSdkVersion. With minSdk >= 24 it prints v1/v2 as false by design, even though META-INF/*.SF is present and the signature is valid. That output is indistinguishable from "signing did not apply" and sends you into a re-signing loop over a file that was never wrong.

Always pass the explicit range (Route B above) and read the scheme list from that run only. Never judge a build from a default-argument apksigner verify.

Installing over an existing app

Situation Command
Same signing key as installed version pm install -r — keeps app data (login state, caches)
Different signing key pm uninstall first, then install. App data is lost.
OEM installer refuses (INSTALL_FAILED_*, vendor restrictions) push the APK and install via root: su -c 'pm install -r -t -d /data/local/tmp/app.apk'
Downgrade needed add -d
Test-only flag needed add -t

Useful detail: keeping the same keystore across builds lets you iterate with -r and preserve a logged-in session, which matters a lot when the feature you are testing needs auth.

Post-install / post-upgrade hazards

  • Data directory uid mismatch. After reinstall the app uid increments; a restored /data/user/0/<pkg> directory owned by the old uid is unreadable. Symptom: crash in a DB-init path (Cannot open database). Fix:
    su -c "chown -R <uid>:<uid> /data/user/0/<pkg>"
    su -c "restorecon -R /data/user/0/<pkg>"
    
    Get <uid> from dumpsys package <pkg> | grep userId=.
  • Restoring a data backup can itself cause this. Prefer cp -f over an existing file (preserves owner) rather than deleting and re-extracting.

Verify the build, not just the signature

Signature verification proves the file is well-formed. It does not prove the app works. Always:

  1. apksigner verify passes with the explicit SDK range above — a default-args run can report v1/v2 false on a perfectly valid signature.
  2. Install succeeds.
  3. Launch succeeds; process stays alive.
  4. logcat shows no FATAL EXCEPTION / VerifyError / IncompatibleClassChangeError / uncaughtException.
  5. META-INF/services/* count matches the original.
  6. Unchanged dex files are byte-identical to the original (compare hashes); in a de-shelled build, compare against the de-shelled base instead.

Build a control at least once: same pipeline, zero patches. If the control fails, your pipeline or environment is at fault, not your patch (references/pitfalls.md P9).