1720 lines
72 KiB
Python
1720 lines
72 KiB
Python
#!/usr/bin/env python3
|
|
"""vmp_diff_harness.py -- differential-hardening (known-plaintext) harness for Dex-VMP work.
|
|
|
|
The idea this implements is the only black-box route to a private opcode table:
|
|
compile a fixture whose every instruction is labelled, get the *same* hardening
|
|
platform to harden it, align the returned dex against the original
|
|
instruction-by-instruction, and read the substitution off the alignment. The
|
|
original supplies the instruction boundaries that the hardened stream cannot
|
|
supply for itself -- that is the whole trick, and it is also its limit (see
|
|
`compare`'s shape verdict and references/vmp-differential-analysis.md).
|
|
|
|
Subcommands
|
|
build compile the labelled coverage fixture into a dex (and, with
|
|
--apk, a minimal APK) and report which opcodes it actually covers
|
|
audit report opcode coverage of any dex
|
|
compare align an original dex against a hardened one; emit a candidate
|
|
opcode map with per-entry confidence and a run-level verdict
|
|
simulate forge a "hardened" dex from a *known* private table -- the local
|
|
fixture that proves compare() recovers a table it never saw
|
|
emit-smali render private-opcode method bodies back into a smali skeleton
|
|
|
|
What is measured and what is not: `build`, `audit`, `simulate` and the
|
|
`compare`/`emit-smali` code paths are exercised against locally produced
|
|
fixtures (see docs/tool-verification/EXTENSION-vmp-diff.md). No third-party
|
|
hardening platform was used: its output is what `simulate` stands in for. A
|
|
`compare` run against a real hardened dex is therefore an inference built on a
|
|
measured mechanism, not a measured result.
|
|
|
|
Dependencies: stdlib only, plus `dexutil.py` next to this file. External
|
|
toolchain (javac, d8, aapt2) is only needed by `build` and is passed explicitly.
|
|
|
|
Examples
|
|
python vmp_diff_harness.py build --build-tools E:\\tools\\android-14 --out work/fixture
|
|
python vmp_diff_harness.py simulate work/fixture/dex/classes.dex --out work/sim.dex --seed 7
|
|
python vmp_diff_harness.py compare work/fixture/dex/classes.dex work/sim.dex
|
|
python vmp_diff_harness.py emit-smali work/sim.dex --table work/map.json --class LOpCoverProbe;
|
|
"""
|
|
import argparse
|
|
import collections
|
|
import json
|
|
import os
|
|
import random
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
try:
|
|
import dexutil
|
|
except ImportError: # pragma: no cover
|
|
dexutil = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# opcode reachability, measured (see EXTENSION-vmp-diff.md for the run)
|
|
#
|
|
# A javac+d8 fixture cannot reach every slot in the format table. The reasons
|
|
# are structural, not incidental, and they are what the coverage report is for:
|
|
# you cannot read a substitution for an opcode your fixture never emitted.
|
|
# ---------------------------------------------------------------------------
|
|
UNREACHABLE = {
|
|
0x1B: "const-string/jumbo needs a string_ids index > 65535, i.e. a fixture "
|
|
"carrying 65536+ string constants",
|
|
0x2A: "goto/32 needs a >32767-code-unit backward jump; javac refuses the "
|
|
"method first with 'code too large' (its own 64 KB per-method bytecode cap)",
|
|
0xFE: "const-method-handle has no Java-language literal; reachable only by "
|
|
"hand-written smali or direct dex construction",
|
|
0xFF: "const-method-type has no Java-language literal (same as 0xFE)",
|
|
0x09: "move-object/16 (32x) needs both registers >= 256; the object frame "
|
|
"reaches 256 on the source side only, which d8 encodes as 0x08",
|
|
0xFD: "invoke-custom/range: d8 emitted the 35c form for every lambda shape "
|
|
"tried, including a six-parameter one",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the fixture
|
|
# ---------------------------------------------------------------------------
|
|
_JAVA_PROBE = r'''
|
|
// OpCoverProbe -- opcode coverage fixture for differential-hardening analysis.
|
|
//
|
|
// Each method is a labelled probe for one dalvik opcode family. The point is not
|
|
// that the code does anything useful: it is that the compiled instruction stream
|
|
// contains a known, labelled instance of every opcode a Java compiler can emit,
|
|
// so that a hardened counterpart of this dex can be aligned instruction-by-
|
|
// instruction against it (known-plaintext attack).
|
|
public class OpCoverProbe {
|
|
|
|
// ---------------------------------------------------------------- 1. const
|
|
public static int constFamily(int seed) {
|
|
int c4 = 7; // const/4
|
|
int c16 = 1000; // const/16
|
|
int c32 = 100000; // const
|
|
int ch16 = 16777216; // const/high16
|
|
long w16 = 123L; // const-wide/16
|
|
long w32 = 1234567L; // const-wide/32
|
|
long w = 1234567890123L; // const-wide
|
|
float fl = 1.5f; // const/high16 (float)
|
|
double db = 3.14159; // const-wide (double)
|
|
String s = "opcover-probe"; // const-string
|
|
Class<?> k = String.class; // const-class
|
|
int acc = c4 + c16 + c32 + ch16;
|
|
acc += (int) w16 + (int) w32;
|
|
acc += (int) w + (int) fl + (int) db;
|
|
acc += s.length() + k.getName().length();
|
|
return acc + seed;
|
|
}
|
|
|
|
// ----------------------------------------------------------- 2. int arith
|
|
public static int arithInt(int x, int y) {
|
|
int r = x + y; // add-int
|
|
r = r + 1000; // add-int/lit16
|
|
r = r + 7; // add-int/lit8
|
|
r = r - y; // sub-int
|
|
r = r - 1000; // sub-int/lit16
|
|
r = r * y; // mul-int
|
|
r = r / (y | 1); // div-int
|
|
r = r % (x | 3); // rem-int
|
|
r = r << 2; // shl-int/lit8
|
|
r = r >> 1; // shr-int/lit8
|
|
r = r >>> 1; // ushr-int/lit8
|
|
r = r & x; // and-int
|
|
r = r | y; // or-int
|
|
r = r ^ x; // xor-int
|
|
r = -r; // neg-int
|
|
r = 0 - r; // rsub-int
|
|
return r;
|
|
}
|
|
|
|
// ------------------------------------------------------ 3. lit8/lit16 forms
|
|
public static int litForms(int x, int y) {
|
|
int r = x;
|
|
r = r * 255; // mul-int/lit8
|
|
r = r * 1000; // mul-int/lit16
|
|
r = r + 100000; // const + add-int (beyond lit16)
|
|
r = r / 3; // div-int/lit8
|
|
r = r % 5; // rem-int/lit8
|
|
r = r & 255; // and-int/lit16
|
|
r = r | 255; // or-int/lit16
|
|
r = r ^ 255; // xor-int/lit16
|
|
r = r << y; // shl-int
|
|
r = r >> y; // shr-int
|
|
r = r >>> y; // ushr-int
|
|
return r;
|
|
}
|
|
|
|
// -------------------------------------------------------------- 4. wide arith
|
|
public static long arithWide(long a, long b) {
|
|
long r = a * b; // mul-long
|
|
r = r + a; // add-long
|
|
r = r - b; // sub-long
|
|
r = r / (b | 1L); // div-long
|
|
r = r % (a | 3L); // rem-long
|
|
r = r & a; // and-long
|
|
r = r | b; // or-long
|
|
r = r ^ a; // xor-long
|
|
r = r << 2; // shl-long
|
|
r = r >> 1; // shr-long
|
|
r = r >>> 1; // ushr-long
|
|
r = -r; // neg-long
|
|
r = r + 1000L; // add-long with const-wide
|
|
return r;
|
|
}
|
|
|
|
// ----------------------------------------------------------- 5. conversions
|
|
public static double conversions(int i, long l, float f, double d, short sh, byte by, char ch) {
|
|
long a = (long) i; // int-to-long
|
|
float b = (float) i; // int-to-float
|
|
double c = (double) i; // int-to-double
|
|
int e = (int) l; // long-to-int
|
|
float g = (float) l; // long-to-float
|
|
double h = (double) l; // long-to-double
|
|
int j = (int) f; // float-to-int
|
|
long k = (long) f; // float-to-long
|
|
double m = (double) f; // float-to-double
|
|
int n = (int) d; // double-to-int
|
|
long o = (long) d; // double-to-long
|
|
float p = (float) d; // double-to-float
|
|
int q = sh;
|
|
int s = by;
|
|
int t = ch;
|
|
byte u = (byte) i; // int-to-byte
|
|
char v = (char) i; // int-to-char
|
|
short w = (short) i; // int-to-short
|
|
return a + e + j + n + q + s + t + u + v + w + b + c + g + h + k + m + o + p;
|
|
}
|
|
|
|
// ------------------------------------------------------- 6. float/double arith
|
|
public static float arithFloat(float a, float b) {
|
|
float r = a + b; // add-float
|
|
r = r - b; // sub-float
|
|
r = r * a; // mul-float
|
|
r = r / b; // div-float
|
|
r = r % a; // rem-float
|
|
r = -r; // neg-float
|
|
return r + 1.5f;
|
|
}
|
|
|
|
public static double arithDouble(double a, double b) {
|
|
double r = a + b; // add-double
|
|
r = r - b; // sub-double
|
|
r = r * a; // mul-double
|
|
r = r / b; // div-double
|
|
r = r % a; // rem-double
|
|
r = -r; // neg-double
|
|
return r + 2.5;
|
|
}
|
|
|
|
// ------------------------------------------------------------- 7. cmp family
|
|
public static int cmps(long a, long b, float f, float g, double p, double q) {
|
|
int r = 0;
|
|
if (a < b) r += 1; // cmp-long
|
|
if (f > g) r += 2; // cmpg-float
|
|
if (f < g) r += 4; // cmpl-float
|
|
if (p > q) r += 8; // cmpg-double
|
|
if (p <= q) r += 16; // cmpl-double
|
|
return r;
|
|
}
|
|
|
|
// --------------------------------------------------------------- 8. branches
|
|
public static int branches(int x, int y) {
|
|
int r = 0;
|
|
if (x == 0) r += 1; // if-eqz
|
|
if (x != 0) r += 2; // if-nez
|
|
if (x < 0) r += 3; // if-ltz
|
|
if (x >= 0) r += 4; // if-gez
|
|
if (x > 0) r += 5; // if-gtz
|
|
if (x <= 0) r += 6; // if-lez
|
|
if (x == y) r += 7; // if-eq
|
|
if (x != y) r += 8; // if-ne
|
|
if (x < y) r += 9; // if-lt
|
|
if (x >= y) r += 10; // if-ge
|
|
if (x > y) r += 11; // if-gt
|
|
if (x <= y) r += 12; // if-le
|
|
return r;
|
|
}
|
|
|
|
public static int objectBranches(Object a, Object b) {
|
|
int r = 0;
|
|
if (a == null) r += 1; // if-eqz on an object register
|
|
if (a != null) r += 2;
|
|
if (a == b) r += 3; // if-eq
|
|
if (a != b) r += 4; // if-ne
|
|
return r;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 9. switches
|
|
public static int switchPacked(int x) {
|
|
switch (x) { // packed-switch + packed-switch-payload
|
|
case 0: return 10;
|
|
case 1: return 11;
|
|
case 2: return 12;
|
|
case 3: return 13;
|
|
case 4: return 14;
|
|
default: return 15;
|
|
}
|
|
}
|
|
|
|
public static int switchSparse(int x) {
|
|
switch (x) { // sparse-switch + sparse-switch-payload
|
|
case -1000000: return 20;
|
|
case 0: return 21;
|
|
case 12345: return 22;
|
|
case 999999999: return 23;
|
|
default: return 24;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 10. arrays
|
|
public static int arrays(int n, byte by, char ch, short sh, boolean bo,
|
|
long l, float f, double d, Object o) {
|
|
int[] ia = new int[n]; // new-array
|
|
long[] la = new long[n];
|
|
float[] fa = new float[n];
|
|
double[] da = new double[n];
|
|
byte[] ba = new byte[n];
|
|
char[] ca = new char[n];
|
|
short[] sa = new short[n];
|
|
boolean[] za = new boolean[n];
|
|
Object[] oa = new Object[n];
|
|
ia[0] = 1; // aput
|
|
la[0] = l; // aput-wide
|
|
fa[0] = f;
|
|
da[0] = d;
|
|
ba[0] = by; // aput-byte
|
|
ca[0] = ch; // aput-char
|
|
sa[0] = sh; // aput-short
|
|
za[0] = bo; // aput-boolean
|
|
oa[0] = o; // aput-object
|
|
int r = ia[0]; // aget
|
|
r += (int) la[0]; // aget-wide
|
|
r += (int) fa[0];
|
|
r += (int) da[0];
|
|
r += ba[0]; // aget-byte
|
|
r += ca[0]; // aget-char
|
|
r += sa[0]; // aget-short
|
|
r += za[0] ? 1 : 0; // aget-boolean
|
|
r += oa[0] == null ? 0 : 1; // aget-object
|
|
r += ia.length + la.length + ba.length + oa.length; // array-length
|
|
return r;
|
|
}
|
|
|
|
public static int fillArrayData() {
|
|
int[] a = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
|
|
long[] b = {11L, 22L, 33L};
|
|
short[] c = {44, 55, 66};
|
|
int s = 0;
|
|
for (int v : a) s += v;
|
|
for (long v : b) s += (int) v;
|
|
for (short v : c) s += v;
|
|
return s;
|
|
}
|
|
|
|
public static int filledNewArray() {
|
|
int[] a = new int[]{7, 8, 9};
|
|
return a[0] + a[1] + a[2];
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 11. fields
|
|
static int sInt;
|
|
static long sLong;
|
|
static float sFloat;
|
|
static double sDouble;
|
|
static Object sObj;
|
|
static boolean sBool;
|
|
static byte sByte;
|
|
static char sChar;
|
|
static short sShort;
|
|
static String sStr;
|
|
|
|
int iInt;
|
|
long iLong;
|
|
float iFloat;
|
|
double iDouble;
|
|
Object iObj;
|
|
boolean iBool;
|
|
byte iByte;
|
|
char iChar;
|
|
short iShort;
|
|
String iStr;
|
|
|
|
public static int staticFields(int a) {
|
|
sInt = a; // sput
|
|
sLong = a; // sput-wide
|
|
sFloat = a;
|
|
sDouble = a;
|
|
sObj = null; // sput-object
|
|
sStr = "static-field";
|
|
sBool = true;
|
|
sByte = (byte) a;
|
|
sChar = (char) a;
|
|
sShort = (short) a;
|
|
int r = sInt; // sget
|
|
r += (int) sLong; // sget-wide
|
|
r += (int) sFloat;
|
|
r += (int) sDouble;
|
|
r += sBool ? 1 : 0;
|
|
r += sByte;
|
|
r += sChar;
|
|
r += sShort;
|
|
r += sObj == null ? 0 : 1; // sget-object
|
|
r += sStr.length();
|
|
return r;
|
|
}
|
|
|
|
public int instanceFields(int a) {
|
|
iInt = a; // iput
|
|
iLong = a; // iput-wide
|
|
iFloat = a;
|
|
iDouble = a;
|
|
iObj = this; // iput-object
|
|
iStr = "instance-field";
|
|
iBool = false;
|
|
iByte = (byte) a;
|
|
iChar = (char) a;
|
|
iShort = (short) a;
|
|
int r = iInt; // iget
|
|
r += (int) iLong; // iget-wide
|
|
r += (int) iFloat;
|
|
r += (int) iDouble;
|
|
r += iBool ? 1 : 0;
|
|
r += iByte;
|
|
r += iChar;
|
|
r += iShort;
|
|
r += iObj == null ? 0 : 1; // iget-object
|
|
r += iStr.length();
|
|
return r;
|
|
}
|
|
|
|
// ----------------------------------------------------------------- 12. invokes
|
|
public interface Iface {
|
|
int call(int x);
|
|
}
|
|
|
|
public static class Base {
|
|
public int baseCall(int x) { return x + 1; }
|
|
}
|
|
|
|
public static class Impl extends Base implements Iface {
|
|
public int call(int x) { return x + 2; } // invoke-virtual
|
|
private int privateCall(int x) { return x + 4; } // invoke-direct
|
|
public static int staticCall(int x) { return x + 3; } // invoke-static
|
|
public int superCall(int x) { return super.baseCall(x); } // invoke-super
|
|
public int useAll(int x) {
|
|
return call(x) + superCall(x) + staticCall(x) + privateCall(x);
|
|
}
|
|
}
|
|
|
|
public static int helper(int x) { return x * 2; }
|
|
|
|
public static Iface prov(int x) { return new Impl(); }
|
|
|
|
public static int invokeVirtualAndStatic(int x) {
|
|
Impl i = new Impl();
|
|
return i.call(x) + Impl.staticCall(x) + helper(x);
|
|
}
|
|
|
|
public static int invokeInterface(Iface f, int x) {
|
|
return f.call(x); // invoke-interface
|
|
}
|
|
|
|
public static int invokeSuper(int x) {
|
|
Impl i = new Impl();
|
|
return i.superCall(x); // invoke-super
|
|
}
|
|
|
|
public static int lambdaProbe(int x) {
|
|
Iface f = (v) -> v + 100; // invoke-custom
|
|
java.util.function.IntUnaryOperator g = (v) -> v * 2;
|
|
java.util.function.IntBinaryOperator h = OpCoverProbe::combine; // method ref
|
|
return f.call(x) + g.applyAsInt(x) + h.applyAsInt(x, 3);
|
|
}
|
|
|
|
public static int combine(int a, int b) { return a + b * 7; }
|
|
|
|
// ---------------------------------------------------------------- 13. exceptions
|
|
public static int exceptions(int x) {
|
|
int r = 0;
|
|
try {
|
|
r = 100 / x; // div-int inside a try range
|
|
} catch (ArithmeticException e) { // move-exception
|
|
r = -1;
|
|
} finally {
|
|
r = r + 1;
|
|
}
|
|
try {
|
|
if (x == 0) throw new IllegalStateException("boom"); // throw
|
|
r += 2;
|
|
} catch (IllegalStateException e) {
|
|
r += 3;
|
|
}
|
|
return r;
|
|
}
|
|
|
|
public static void rethrow() {
|
|
throw new RuntimeException("always"); // throw on a new-instance
|
|
}
|
|
|
|
// ------------------------------------------------------------------ 14. monitor
|
|
public static int monitor(Object lock) {
|
|
synchronized (lock) { // monitor-enter / monitor-exit
|
|
return 42;
|
|
}
|
|
}
|
|
|
|
public static int monitorOnThis(Object lock, int x) {
|
|
synchronized (OpCoverProbe.class) {
|
|
x += 1;
|
|
}
|
|
synchronized (lock) {
|
|
x += 2;
|
|
}
|
|
return x;
|
|
}
|
|
|
|
// ------------------------------------------------------------------- 15. types
|
|
public static int typeOps(Object o, int x) {
|
|
int r = 0;
|
|
if (o instanceof String) r += 1; // instance-of
|
|
String s = (String) o; // check-cast
|
|
r += s.length();
|
|
Object n = new Object(); // new-instance
|
|
Impl i = new Impl();
|
|
r += i.call(x);
|
|
return r + (n == null ? 0 : 1);
|
|
}
|
|
|
|
// ------------------------------------------------------------------- 16. loops
|
|
public static int loops(int n) {
|
|
int s = 0;
|
|
for (int i = 0; i < n; i++) s += i; // goto + if-lt
|
|
int j = 0;
|
|
while (j < 10) { j++; }
|
|
int k = 0;
|
|
do { k++; } while (k < 5);
|
|
for (int a = 0; a < 3; a++) {
|
|
for (int b = 0; b < 3; b++) {
|
|
for (int c = 0; c < 3; c++) {
|
|
s += a * b * c;
|
|
}
|
|
}
|
|
}
|
|
return s + j + k;
|
|
}
|
|
|
|
// ------------------------------------- 16b. non-2addr (tri-register) forms
|
|
// javac emits the /2addr form whenever the destination is one of the
|
|
// operands. Three distinct locals per operation are what forces the plain
|
|
// three-register opcode to exist in the stream at all.
|
|
public static int triInt(int a1, int a2, int a3, int a4, int a5, int a6, int a7,
|
|
int a8, int a9, int a10, int a11, int a12, int a13, int a14) {
|
|
int t1 = a1 - a2; // sub-int
|
|
int t2 = a3 / (a4 | 1); // div-int
|
|
int t3 = a5 % (a6 | 1); // rem-int
|
|
int t4 = a7 & a8; // and-int
|
|
int t5 = a9 | a10; // or-int
|
|
int t6 = a11 ^ a12; // xor-int
|
|
int t7 = a13 << (a14 & 7); // shl-int
|
|
int t8 = a13 >> (a14 & 7); // shr-int
|
|
int t9 = a13 >>> (a14 & 7); // ushr-int
|
|
int t10 = ~t1; // not-int
|
|
return t1 + t2 + t3 + t4 + t5 + t6 + t7 + t8 + t9 + t10;
|
|
}
|
|
|
|
public static long triLong(long b1, long b2, long b3, long b4, long b5,
|
|
long b6, long b7, long b8, long b9, long b10) {
|
|
long u1 = b1 + b2; // add-long
|
|
long u2 = b3 - b4; // sub-long
|
|
long u3 = b5 / (b6 | 1L); // div-long
|
|
long u4 = b7 % (b8 | 1L); // rem-long
|
|
long u5 = b9 & b10; // and-long
|
|
long u6 = b1 | b2; // or-long
|
|
long u7 = b3 ^ b4; // xor-long
|
|
long u8 = b5 << 2; // shl-long
|
|
long u9 = b5 >> 2; // shr-long
|
|
long u10 = b5 >>> 2; // ushr-long
|
|
long u11 = ~u1; // not-long
|
|
return u1 + u2 + u3 + u4 + u5 + u6 + u7 + u8 + u9 + u10 + u11;
|
|
}
|
|
|
|
public static float triFloat(float f1, float f2, float f3, float f4, float f5, float f6) {
|
|
float g1 = f1 - f2; // sub-float
|
|
float g2 = f3 * f4; // mul-float
|
|
float g3 = f5 / f6; // div-float
|
|
float g4 = f1 % f2; // rem-float
|
|
return g1 + g2 + g3 + g4;
|
|
}
|
|
|
|
public static double triDouble(double d1, double d2, double d3, double d4, double d5, double d6) {
|
|
double h1 = d1 - d2; // sub-double
|
|
double h2 = d3 * d4; // mul-double
|
|
double h3 = d5 / d6; // div-double
|
|
double h4 = d1 % d2; // rem-double
|
|
return h1 + h2 + h3 + h4;
|
|
}
|
|
|
|
// ------------------------------------------------ 16c. literal-width variants
|
|
public static int litVariants(int x) {
|
|
int r = x;
|
|
r = r / 1000; // div-int/lit16 (beyond lit8)
|
|
r = r % 1000; // rem-int/lit16
|
|
r = r & 15; // and-int/lit8
|
|
r = r ^ 15; // xor-int/lit8
|
|
r = 1000 - r; // rsub-int
|
|
r = r + 1000; // add-int/lit16
|
|
return r;
|
|
}
|
|
|
|
// ------------------------------------------------- 16d. per-type field access
|
|
// Read straight into a fresh local instead of accumulating: the accumulator
|
|
// form is where d8 collapses a typed access into a narrower one.
|
|
public int fieldVariants(int a, Object o, String s) {
|
|
iBool = true;
|
|
iByte = (byte) a;
|
|
iChar = (char) a;
|
|
iShort = (short) a;
|
|
iObj = o;
|
|
iStr = s;
|
|
long l = iLong; // iget-wide
|
|
Object ob = iObj; // iget-object
|
|
boolean b = iBool; // iget-boolean
|
|
byte by = iByte; // iget-byte
|
|
char ch = iChar; // iget-char
|
|
short sh = iShort; // iget-short
|
|
return (b ? 1 : 0) + by + ch + sh + (int) l + (ob == null ? 0 : 1);
|
|
}
|
|
|
|
public static int staticFieldVariants(int a, Object o, String s) {
|
|
sBool = true;
|
|
sByte = (byte) a;
|
|
sChar = (char) a;
|
|
sShort = (short) a;
|
|
sObj = o;
|
|
sStr = s;
|
|
sLong = a;
|
|
long l = sLong; // sget-wide
|
|
Object ob = sObj; // sget-object
|
|
boolean b = sBool; // sget-boolean
|
|
byte by = sByte; // sget-byte
|
|
char ch = sChar; // sget-char
|
|
short sh = sShort; // sget-short
|
|
return (b ? 1 : 0) + by + ch + sh + (int) l + (ob == null ? 0 : 1);
|
|
}
|
|
|
|
// ------------------------------------------------------- 16e. /range invokes
|
|
// The 35c invoke forms carry at most 5 argument registers. Seven (this plus
|
|
// six ints) is what makes the /range encoding the only legal one.
|
|
public interface Iface2 { int call6(int a, int b, int c, int d, int e, int f); }
|
|
|
|
public static class Base6 {
|
|
public int base6(int a, int b, int c, int d, int e, int f) { return a + b + c + d + e + f; }
|
|
}
|
|
|
|
public static class Impl6 extends Base6 implements Iface2 {
|
|
public int call6(int a, int b, int c, int d, int e, int f) { return a * f; }
|
|
private int priv6(int a, int b, int c, int d, int e, int f) { return a - f; }
|
|
public int superRange(int a, int b, int c, int d, int e, int f) { return super.base6(a, b, c, d, e, f); }
|
|
public int directRange(int a, int b, int c, int d, int e, int f) { return priv6(a, b, c, d, e, f); }
|
|
}
|
|
|
|
public static int rangeInvokes(int a, int b, int c, int d, int e, int f) {
|
|
Impl6 i = new Impl6();
|
|
int r = i.call6(a, b, c, d, e, f); // invoke-virtual/range
|
|
r += i.superRange(a, b, c, d, e, f); // invoke-super/range
|
|
r += i.directRange(a, b, c, d, e, f); // invoke-direct/range
|
|
Iface2 f2 = i;
|
|
r += f2.call6(a, b, c, d, e, f); // invoke-interface/range
|
|
return r;
|
|
}
|
|
|
|
// ------------------------------------------------- 16f. polymorphic invoke
|
|
public static int polyProbe(int x) {
|
|
try {
|
|
java.lang.invoke.MethodHandle mh = java.lang.invoke.MethodHandles.lookup()
|
|
.findStatic(OpCoverProbe.class, "combine",
|
|
java.lang.invoke.MethodType.methodType(int.class, int.class, int.class));
|
|
return (int) mh.invokeExact(x, 3); // invoke-polymorphic
|
|
} catch (Throwable t) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
// ------------------------------------- 16g. remaining reachable opcodes
|
|
public static long mulLong2addr(long a, long b) {
|
|
long r = a;
|
|
r = r * b; // mul-long/2addr (the only /2addr form javac skipped)
|
|
return r;
|
|
}
|
|
|
|
public static long moveWide(int seed) {
|
|
long a = seed;
|
|
long b = a;
|
|
long c = b;
|
|
return a + b + c;
|
|
}
|
|
|
|
public static Object manyElements(Object o1, Object o2, Object o3, Object o4,
|
|
Object o5, Object o6, Object o7) {
|
|
Object[] a = {o1, o2, o3, o4, o5, o6, o7}; // filled-new-array/range
|
|
return a;
|
|
}
|
|
|
|
public interface Iface6 { int call6(int a, int b, int c, int d, int e, int f); }
|
|
|
|
public static int sum6(int a, int b, int c, int d, int e, int f) {
|
|
return a + b + c + d + e + f;
|
|
}
|
|
|
|
public static int polyRangeProbe(int a, int b, int c, int d, int e, int f) {
|
|
try {
|
|
java.lang.invoke.MethodHandle mh = java.lang.invoke.MethodHandles.lookup()
|
|
.findStatic(OpCoverProbe.class, "sum6",
|
|
java.lang.invoke.MethodType.methodType(int.class, int.class, int.class,
|
|
int.class, int.class, int.class, int.class));
|
|
return (int) mh.invokeExact(a, b, c, d, e, f); // invoke-polymorphic/range
|
|
} catch (Throwable t) {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
public static int customRangeProbe(int a, int b, int c, int d, int e, int f) {
|
|
Iface6 g = (p1, p2, p3, p4, p5, p6) -> p1 + p2 + p3 + p4 + p5 + p6;
|
|
return g.call6(a, b, c, d, e, f);
|
|
}
|
|
|
|
// ---------------------------------------------- 17. many registers / move forms
|
|
public static int manyRegs(int seed) {
|
|
int r0 = seed + 0, r1 = seed + 1, r2 = seed + 2, r3 = seed + 3;
|
|
int r4 = seed + 4, r5 = seed + 5, r6 = seed + 6, r7 = seed + 7;
|
|
int r8 = seed + 8, r9 = seed + 9, r10 = seed + 10, r11 = seed + 11;
|
|
int r12 = seed + 12, r13 = seed + 13, r14 = seed + 14, r15 = seed + 15;
|
|
int r16 = seed + 16, r17 = seed + 17, r18 = seed + 18, r19 = seed + 19;
|
|
int r20 = seed + 20, r21 = seed + 21, r22 = seed + 22, r23 = seed + 23;
|
|
int r24 = seed + 24, r25 = seed + 25, r26 = seed + 26, r27 = seed + 27;
|
|
int r28 = seed + 28, r29 = seed + 29, r30 = seed + 30, r31 = seed + 31;
|
|
int s = 0;
|
|
s += r0; s += r1; s += r2; s += r3; s += r4; s += r5; s += r6; s += r7;
|
|
s += r8; s += r9; s += r10; s += r11; s += r12; s += r13; s += r14; s += r15;
|
|
s += r16; s += r17; s += r18; s += r19; s += r20; s += r21; s += r22; s += r23;
|
|
s += r24; s += r25; s += r26; s += r27; s += r28; s += r29; s += r30; s += r31;
|
|
return s;
|
|
}
|
|
|
|
// ------------------------------------------------------- 18. ternary / boolean
|
|
public static int ternaryBoolean(boolean a, boolean b, int x) {
|
|
int r = a ? x : -x;
|
|
r += b ? 1 : 0;
|
|
r += (a && b) ? 2 : 0;
|
|
r += (a || b) ? 4 : 0;
|
|
return r;
|
|
}
|
|
|
|
// ------------------------------------------------------------- 19. string ops
|
|
public static int stringOps(String s, int x) {
|
|
int r = s.length(); // invoke-virtual
|
|
r += s.charAt(0);
|
|
r += s.indexOf('a');
|
|
r += s.equals("abc") ? 1 : 0;
|
|
r += s.hashCode();
|
|
String t = s + x; // StringBuilder
|
|
return r + t.length();
|
|
}
|
|
|
|
// an entry point that references everything, so nothing is dead-stripped
|
|
public static int runAll(int seed) {
|
|
int r = 0;
|
|
r += constFamily(seed);
|
|
r += arithInt(seed, seed + 1);
|
|
r += litForms(seed, 3);
|
|
r += (int) arithWide(seed, seed + 2);
|
|
r += (int) conversions(seed, seed, seed, seed, (short) seed, (byte) seed, (char) seed);
|
|
r += (int) arithFloat(seed, seed + 1);
|
|
r += (int) arithDouble(seed, seed + 1);
|
|
r += cmps(seed, seed + 1, seed, seed + 1, seed, seed + 1);
|
|
r += branches(seed, seed + 1);
|
|
r += objectBranches(null, null);
|
|
r += switchPacked(seed % 5);
|
|
r += switchSparse(seed);
|
|
r += arrays(4, (byte) 1, 'a', (short) 2, true, 3L, 4f, 5.0, null);
|
|
r += fillArrayData();
|
|
r += filledNewArray();
|
|
r += staticFields(seed);
|
|
r += new OpCoverProbe().instanceFields(seed);
|
|
r += invokeVirtualAndStatic(seed);
|
|
r += invokeInterface(prov(seed), seed);
|
|
r += invokeSuper(seed);
|
|
r += lambdaProbe(seed);
|
|
r += exceptions(seed);
|
|
r += monitor(new Object());
|
|
r += monitorOnThis(new Object(), seed);
|
|
r += typeOps("hello", seed);
|
|
r += loops(seed % 7);
|
|
r += manyRegs(seed);
|
|
r += ternaryBoolean(true, false, seed);
|
|
r += stringOps("abcdef", seed);
|
|
r += triInt(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14);
|
|
r += (int) triLong(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L);
|
|
r += (int) triFloat(1f, 2f, 3f, 4f, 5f, 6f);
|
|
r += (int) triDouble(1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
|
|
r += litVariants(seed);
|
|
r += new OpCoverProbe().fieldVariants(seed, null, "fv");
|
|
r += staticFieldVariants(seed, null, "sfv");
|
|
r += rangeInvokes(1, 2, 3, 4, 5, 6);
|
|
r += polyProbe(seed);
|
|
r += (int) mulLong2addr(seed, seed + 1);
|
|
r += (int) moveWide(seed);
|
|
r += manyElements(null, null, null, null, null, null, null) == null ? 0 : 1;
|
|
r += polyRangeProbe(1, 2, 3, 4, 5, 6);
|
|
r += customRangeProbe(1, 2, 3, 4, 5, 6);
|
|
return r;
|
|
}
|
|
|
|
public static void main(String[] args) {
|
|
int seed = args.length > 0 ? args.length : 1;
|
|
System.out.println(runAll(seed));
|
|
}
|
|
}
|
|
'''
|
|
|
|
|
|
def gen_big_probe(n_int=300, n_long=140, n_obj=160, goto16_units=200):
|
|
"""Generate the part of the fixture a Java compiler can only reach with bulk.
|
|
|
|
Three opcode classes exist only past a register-number or branch-distance
|
|
threshold, so no amount of hand-written Java reaches them -- the source has
|
|
to be long:
|
|
|
|
move/16, move-wide/16, move-object/from16 register number >= 256
|
|
goto/16 backward branch > 127 code units
|
|
|
|
Measured thresholds for this generator (see EXTENSION-vmp-diff.md):
|
|
n_int=300 -> move/16 seen 46x; n_long=140 -> move-wide/16 seen 20x;
|
|
n_obj=160 -> move-object/from16 seen 161x; goto16_units=200 -> goto/16 seen 1x.
|
|
"""
|
|
src = ["// generated by vmp_diff_harness.py build -- do not edit by hand",
|
|
"public class OpCoverBig {", ""]
|
|
|
|
src.append(" // %d locals: register number crosses 8 bits" % n_int)
|
|
src.append(" public static int hugeFrame(int seed) {")
|
|
for i in range(n_int):
|
|
src.append(" int v%d = seed + %d;" % (i, i % 100))
|
|
src.append(" int s = 0;")
|
|
for i in range(n_int):
|
|
src.append(" s += v%d;" % i)
|
|
src.append(" return s;")
|
|
src.append(" }")
|
|
src.append("")
|
|
|
|
src.append(" // wide values occupy two slots each, so fewer of them suffice")
|
|
src.append(" public static long hugeWideFrame(long seed) {")
|
|
for i in range(n_long):
|
|
src.append(" long w%d = seed + %dL;" % (i, i % 50))
|
|
src.append(" long s = 0L;")
|
|
for i in range(n_long):
|
|
src.append(" s += w%d;" % i)
|
|
src.append(" return s;")
|
|
src.append(" }")
|
|
src.append("")
|
|
|
|
src.append(" // object frame: move-object/from16")
|
|
src.append(" public static int hugeObjectFrame(Object o) {")
|
|
for i in range(n_obj):
|
|
src.append(" Object a%d = o;" % i)
|
|
src.append(" int s = 0;")
|
|
for i in range(n_obj):
|
|
src.append(" if (a%d == null) s += 1;" % i)
|
|
src.append(" return s;")
|
|
src.append(" }")
|
|
src.append("")
|
|
|
|
src.append(" // %d statements in the body: the backward branch no longer fits")
|
|
src.append(" // in the 8-bit goto form and must use goto/16")
|
|
src.append(" public static int gotoNear(int x) {")
|
|
src.append(" int r = x;")
|
|
src.append(" while (r > 0) {")
|
|
for i in range(goto16_units):
|
|
src.append(" r += %d;" % (i % 120 + 1))
|
|
src.append(" r -= 1;")
|
|
src.append(" }")
|
|
src.append(" return r;")
|
|
src.append(" }")
|
|
src.append("")
|
|
|
|
src.append(" public static int runAll(int seed) {")
|
|
src.append(" int r = hugeFrame(seed);")
|
|
src.append(" r += (int) hugeWideFrame(seed);")
|
|
src.append(" r += hugeObjectFrame(null);")
|
|
src.append(" r += gotoNear(seed % 5);")
|
|
src.append(" return r;")
|
|
src.append(" }")
|
|
src.append("}")
|
|
return "\n".join(src) + "\n"
|
|
|
|
|
|
MINIMAL_MANIFEST = '''<?xml version="1.0" encoding="utf-8"?>
|
|
<manifest package="probe.synthetic.vmpdiff">
|
|
<application />
|
|
</manifest>
|
|
'''
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _require_dexutil():
|
|
if dexutil is None:
|
|
sys.stderr.write("error: dexutil.py is not importable; keep this script "
|
|
"next to the rest of skills/apkseep/scripts/\n")
|
|
raise SystemExit(2)
|
|
|
|
|
|
def _which_in_dir(directory, stem):
|
|
"""Locate a build-tool binary under `directory`, tolerating .exe/.bat/.cmd."""
|
|
for suffix in (".exe", ".bat", ".cmd", ""):
|
|
candidate = os.path.join(directory, stem + suffix)
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _methods(dex):
|
|
"""{(class, name, desc): code_off} for every method with a class_def."""
|
|
out = {}
|
|
for i in range(dex.header["class_defs_size"]):
|
|
off = dex.header["class_defs_off"] + i * 32
|
|
for _section, _idx, cls, name, desc, code_off in dex.methods_at(off):
|
|
out[(cls, name, desc)] = code_off
|
|
return out
|
|
|
|
|
|
def _count_opcodes(dex):
|
|
"""collections.Counter of decoded opcode byte -> number of instructions."""
|
|
counts = collections.Counter()
|
|
insns = 0
|
|
desync = 0
|
|
for _key, code_off in _methods(dex).items():
|
|
if not code_off:
|
|
continue
|
|
listing, clean, _expected = dex.decode_all(code_off)
|
|
if not clean:
|
|
desync += 1
|
|
for insn in listing:
|
|
counts[insn["op"]] += 1
|
|
insns += 1
|
|
return counts, insns, desync
|
|
|
|
|
|
CLASS_FLAGS = [(0x1, "public"), (0x10, "final"), (0x200, "interface"),
|
|
(0x400, "abstract"), (0x1000, "synthetic"), (0x2000, "annotation"),
|
|
(0x4000, "enum")]
|
|
METHOD_FLAGS = [(0x1, "public"), (0x2, "private"), (0x4, "protected"),
|
|
(0x8, "static"), (0x10, "final"), (0x20, "synchronized"),
|
|
(0x40, "bridge"), (0x80, "varargs"), (0x100, "native"),
|
|
(0x400, "abstract"), (0x1000, "synthetic"), (0x10000, "constructor")]
|
|
|
|
|
|
def _flags(value, table):
|
|
return " ".join(name for bit, name in table if value & bit)
|
|
|
|
|
|
def _methods_with_acc(dex, class_def_off):
|
|
"""Like Dex.methods_at(), but also yields each method's access_flags.
|
|
|
|
`dexutil.Dex.methods_at` reads `_acc` and drops it, which is fine for the
|
|
callers it already has; a smali skeleton needs it, and changing that shared
|
|
generator's tuple shape would break them.
|
|
"""
|
|
p = dexutil.u32(dex.d, class_def_off + 24)
|
|
if p == 0:
|
|
return
|
|
sf, p = dexutil.read_uleb(dex.d, p)
|
|
inf, p = dexutil.read_uleb(dex.d, p)
|
|
dm, p = dexutil.read_uleb(dex.d, p)
|
|
vm, p = dexutil.read_uleb(dex.d, p)
|
|
for _ in range(sf + inf): # skip field lists
|
|
_i, p = dexutil.read_uleb(dex.d, p)
|
|
_a, p = dexutil.read_uleb(dex.d, p)
|
|
for section, count in (("direct", dm), ("virtual", vm)):
|
|
running = 0
|
|
for _ in range(count):
|
|
diff, p = dexutil.read_uleb(dex.d, p)
|
|
running += diff # indices are delta-encoded
|
|
acc, p = dexutil.read_uleb(dex.d, p)
|
|
code_off, p = dexutil.read_uleb(dex.d, p)
|
|
cls, name, desc = dex.method(running)
|
|
yield section, running, cls, name, desc, acc, code_off
|
|
|
|
|
|
def _class_matches(class_type, wanted):
|
|
"""Accept 'LOpCoverProbe;', 'LOpCoverProbe' and 'OpCoverProbe' alike.
|
|
|
|
The first is how the dex spells it, the third is how a reader thinks of it,
|
|
and a filter that accepts only one of the three silently matches nothing --
|
|
which reads exactly like a class that is not in the file.
|
|
"""
|
|
if not wanted:
|
|
return True
|
|
bare = class_type.strip("L;").replace("/", ".")
|
|
return wanted in (class_type, class_type.rstrip(";"), class_type.strip("L;"), bare)
|
|
|
|
|
|
def _fmt_table(rows, columns):
|
|
widths = [max(len(str(r[i])) for r in [columns] + rows) for i in range(len(columns))]
|
|
out = [" " + " ".join(str(c).ljust(widths[i]) for i, c in enumerate(columns))]
|
|
out.append(" " + " ".join("-" * w for w in widths))
|
|
for row in rows:
|
|
out.append(" " + " ".join(str(c).ljust(widths[i]) for i, c in enumerate(row)))
|
|
return "\n".join(out)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# build / audit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cmd_audit(args):
|
|
_require_dexutil()
|
|
rc = 0
|
|
for path in args.dex:
|
|
dex, entry = dexutil.load_dex(path)
|
|
problems = dex.check()
|
|
counts, insns, desync = _count_opcodes(dex)
|
|
known = set(dexutil.OP_NAMES)
|
|
covered = sorted(set(counts) & known)
|
|
missing = sorted(known - set(counts))
|
|
print("== %s (entry %s, %d bytes) ==" % (path, entry, len(dex.d)))
|
|
for p in problems:
|
|
print(" [structure] %s" % p)
|
|
print(" classes=%d methods_with_code=%d instructions=%d desynced=%d"
|
|
% (dex.header["class_defs_size"],
|
|
sum(1 for v in _methods(dex).values() if v), insns, desync))
|
|
print(" opcode coverage: %d/%d (%.1f%%)"
|
|
% (len(covered), len(known), 100.0 * len(covered) / max(1, len(known))))
|
|
if missing:
|
|
print(" not emitted by this fixture (%d):" % len(missing))
|
|
for op in missing:
|
|
note = UNREACHABLE.get(op)
|
|
print(" 0x%02x %-24s %s"
|
|
% (op, dexutil.OP_NAMES[op], note or "(no recorded reason)"))
|
|
if args.json:
|
|
payload = {
|
|
"dex": path,
|
|
"classes": dex.header["class_defs_size"],
|
|
"instructions": insns,
|
|
"desynced_methods": desync,
|
|
"structural_problems": problems,
|
|
"covered": len(covered),
|
|
"total_known": len(known),
|
|
"missing": [{"op": "0x%02x" % op, "name": dexutil.OP_NAMES[op],
|
|
"reason": UNREACHABLE.get(op)} for op in missing],
|
|
"counts": {"0x%02x" % op: n for op, n in sorted(counts.items())},
|
|
}
|
|
with open(args.json, "w", encoding="utf-8") as fh:
|
|
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
print(" wrote %s" % args.json)
|
|
if problems or desync:
|
|
rc = 1
|
|
return rc
|
|
|
|
|
|
def cmd_build(args):
|
|
_require_dexutil()
|
|
out = args.out
|
|
src_dir = os.path.join(out, "src")
|
|
cls_dir = os.path.join(out, "classes")
|
|
dex_dir = os.path.join(out, "dex")
|
|
for d in (src_dir, cls_dir, dex_dir):
|
|
os.makedirs(d, exist_ok=True)
|
|
|
|
# --- toolchain -------------------------------------------------------
|
|
javac = args.javac or shutil.which("javac")
|
|
if not javac:
|
|
sys.stderr.write("error: javac not found; pass --javac (the JDK's bin/"
|
|
"javac, not the Oracle javapath forwarder)\n")
|
|
return 2
|
|
if not args.build_tools:
|
|
sys.stderr.write("error: --build-tools is required (the directory holding "
|
|
"d8 and aapt2); this script does not guess at a path\n")
|
|
return 2
|
|
d8 = _which_in_dir(args.build_tools, "d8")
|
|
aapt2 = _which_in_dir(args.build_tools, "aapt2")
|
|
if not d8:
|
|
sys.stderr.write("error: d8/d8.bat not found in %s\n" % args.build_tools)
|
|
return 2
|
|
if args.apk and not aapt2:
|
|
sys.stderr.write("error: aapt2 not found in %s (needed for --apk)\n"
|
|
% args.build_tools)
|
|
return 2
|
|
print("javac: %s" % javac)
|
|
print("d8: %s" % d8)
|
|
if args.apk:
|
|
print("aapt2: %s" % aapt2)
|
|
|
|
# --- source ----------------------------------------------------------
|
|
probe_java = os.path.join(src_dir, "OpCoverProbe.java")
|
|
with open(probe_java, "w", encoding="utf-8") as fh:
|
|
fh.write(_JAVA_PROBE.strip() + "\n")
|
|
sources = [probe_java]
|
|
if args.with_big:
|
|
big_java = os.path.join(src_dir, "OpCoverBig.java")
|
|
with open(big_java, "w", encoding="utf-8") as fh:
|
|
fh.write(gen_big_probe(args.n_int, args.n_long, args.n_obj, args.goto16))
|
|
sources.append(big_java)
|
|
print("generated: %s (%d B)" % (big_java, os.path.getsize(big_java)))
|
|
|
|
# --- javac -----------------------------------------------------------
|
|
cmd = [javac, "-g", "--release", "11", "-d", cls_dir] + sources
|
|
print("\n$ %s" % " ".join(cmd))
|
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
if proc.stdout.strip():
|
|
print(proc.stdout.strip())
|
|
if proc.returncode != 0:
|
|
sys.stderr.write(proc.stderr.strip() + "\n")
|
|
sys.stderr.write("error: javac failed (rc=%d)\n" % proc.returncode)
|
|
return 1
|
|
|
|
classes = []
|
|
for root, _dirs, files in os.walk(cls_dir):
|
|
classes += [os.path.join(root, f) for f in sorted(files) if f.endswith(".class")]
|
|
if not classes:
|
|
sys.stderr.write("error: javac produced no .class files\n")
|
|
return 1
|
|
print("javac ok: %d class files" % len(classes))
|
|
|
|
# --- d8 (the output directory must already exist) ---------------------
|
|
cmd = [d8, "--min-api", str(args.min_api)]
|
|
if not args.desugar:
|
|
cmd.append("--no-desugaring")
|
|
cmd += ["--output", dex_dir] + classes
|
|
print("\n$ %s" % " ".join(cmd))
|
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
if proc.stdout.strip():
|
|
print(proc.stdout.strip())
|
|
if proc.returncode != 0:
|
|
sys.stderr.write((proc.stderr or "").strip()[:2000] + "\n")
|
|
sys.stderr.write("error: d8 failed (rc=%d)\n" % proc.returncode)
|
|
return 1
|
|
dex_path = os.path.join(dex_dir, "classes.dex")
|
|
if not os.path.isfile(dex_path):
|
|
sys.stderr.write("error: d8 reported success but %s does not exist\n" % dex_path)
|
|
return 1
|
|
print("d8 ok: %s (%d B)" % (dex_path, os.path.getsize(dex_path)))
|
|
|
|
audit_args = argparse.Namespace(dex=[dex_path], json=os.path.join(out, "audit.json"))
|
|
audit_rc = cmd_audit(audit_args)
|
|
|
|
# --- optional APK ----------------------------------------------------
|
|
apk_rc = 0
|
|
if args.apk:
|
|
apk_dir = os.path.join(out, "apk")
|
|
os.makedirs(apk_dir, exist_ok=True)
|
|
manifest = os.path.join(apk_dir, "AndroidManifest.xml")
|
|
with open(manifest, "w", encoding="utf-8") as fh:
|
|
fh.write(MINIMAL_MANIFEST)
|
|
base = os.path.join(apk_dir, "base.apk")
|
|
# No -I android.jar: the manifest deliberately uses no android: attribute,
|
|
# because aapt2 refuses to link resource references it has no framework
|
|
# package to resolve (measured: "attribute android:versionCode not found").
|
|
cmd = [aapt2, "link", "--manifest", manifest, "-o", base]
|
|
print("\n$ %s" % " ".join(cmd))
|
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
if proc.stdout.strip():
|
|
print(proc.stdout.strip())
|
|
if proc.returncode != 0:
|
|
sys.stderr.write((proc.stderr or "").strip()[:2000] + "\n")
|
|
sys.stderr.write("error: aapt2 link failed (rc=%d)\n" % proc.returncode)
|
|
apk_rc = 1
|
|
else:
|
|
apk_path = os.path.join(out, "probe.apk")
|
|
with zipfile.ZipFile(base) as zin, \
|
|
zipfile.ZipFile(apk_path, "w", zipfile.ZIP_DEFLATED) as zout:
|
|
for item in zin.namelist():
|
|
zout.writestr(item, zin.read(item))
|
|
zout.write(dex_path, "classes.dex")
|
|
print("aapt2 ok: %s (%d B, unsigned)" % (apk_path, os.path.getsize(apk_path)))
|
|
print(" unsigned by design: add your own signing step before "
|
|
"handing this to a hardening platform that demands it")
|
|
return max(audit_rc, apk_rc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# simulate -- the local stand-in for a hardening platform
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _private_permutation(seed):
|
|
"""A deterministic bijection over 0..255 standing in for a private opcode space.
|
|
|
|
Real engines do not merely relabel bytes -- they re-encode the stream and
|
|
carry their own length table. `simulate` deliberately does the *weakest*
|
|
version of the attack (relabel only, lengths preserved) because that is the
|
|
only version this harness can check itself against; see the reference file
|
|
for why the stronger version is where the method earns its limits.
|
|
"""
|
|
rng = random.Random(seed)
|
|
targets = [b for b in range(256) if b != 0x00]
|
|
shuffled = targets[:]
|
|
rng.shuffle(shuffled)
|
|
table = {0x00: 0x00}
|
|
for src, dst in zip(targets, shuffled):
|
|
table[src] = dst
|
|
return table
|
|
|
|
|
|
def cmd_simulate(args):
|
|
_require_dexutil()
|
|
with open(args.dex, "rb") as fh:
|
|
data = bytearray(fh.read())
|
|
dex = dexutil.Dex(bytes(data))
|
|
problems = dex.check()
|
|
if problems:
|
|
for p in problems:
|
|
sys.stderr.write("error: refusing to simulate on a broken read: %s\n" % p)
|
|
return 1
|
|
|
|
table = _private_permutation(args.seed)
|
|
out = bytearray(data)
|
|
rewritten = 0
|
|
if args.mode == "relabel":
|
|
for _key, code_off in _methods(dex).items():
|
|
if not code_off:
|
|
continue
|
|
# Boundaries come from the dex we are rewriting, which is still in the
|
|
# clear at this point -- that is exactly the advantage simulate gives
|
|
# compare, and exactly what is missing against a real hardened sample.
|
|
for insn in dex.decode(code_off):
|
|
out[insn["off"]] = table[insn["op"]]
|
|
rewritten += 1
|
|
elif args.mode == "stub":
|
|
# The extraction shape: every body is replaced by a filler stub of the
|
|
# same length. Instruction counts still line up, so compare will align
|
|
# happily -- and every original opcode will claim the same private byte,
|
|
# which is the signal that this is not a relabelling at all.
|
|
for _key, code_off in _methods(dex).items():
|
|
if not code_off:
|
|
continue
|
|
info = dex.code_info(code_off)
|
|
pos, end = info["insns_off"], info["insns_off"] + info["insns_size"] * 2
|
|
while pos + 2 <= end:
|
|
out[pos], out[pos + 1] = 0x0E, 0x00 # return-void
|
|
pos += 2
|
|
rewritten += 1
|
|
elif args.mode == "shrink":
|
|
# The length-changing shape. This builds a deliberately structurally
|
|
# inconsistent image -- insns_size is rewritten without moving the bytes
|
|
# -- purely to drive compare's `resized` branch on demand. It is a probe
|
|
# for the detector, not something you could load.
|
|
for _key, code_off in _methods(dex).items():
|
|
if not code_off:
|
|
continue
|
|
info = dex.code_info(code_off)
|
|
new_size = max(1, info["insns_size"] // 2)
|
|
out[code_off + 12:code_off + 16] = new_size.to_bytes(4, "little")
|
|
rewritten += 1
|
|
if not args.keep_header:
|
|
dexutil.fix_dex_header(out)
|
|
with open(args.out, "wb") as fh:
|
|
fh.write(bytes(out))
|
|
print("simulated: %s -> %s" % (args.dex, args.out))
|
|
print(" mode=%s, %d method body(ies)/instruction(s) touched (seed %d)"
|
|
% (args.mode, rewritten, args.seed))
|
|
if args.mode == "shrink":
|
|
print(" NOTE: mode=shrink writes a structurally inconsistent dex "
|
|
"on purpose; it exists to exercise compare's resized detector")
|
|
if not args.keep_header:
|
|
ck, sg = dexutil.verify_dex_header(out)
|
|
print(" header recomputed: checksum_ok=%s signature_ok=%s" % (ck, sg))
|
|
if args.table and args.mode == "relabel":
|
|
with open(args.table, "w", encoding="utf-8") as fh:
|
|
json.dump({"seed": args.seed,
|
|
"note": "ground-truth private table (orig opcode -> private byte)",
|
|
"map": {"0x%02x" % k: "0x%02x" % v for k, v in sorted(table.items())}},
|
|
fh, indent=2, sort_keys=True)
|
|
print(" ground-truth table written to %s (compare must re-derive it)"
|
|
% args.table)
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# compare -- the differential itself
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cmd_compare(args):
|
|
_require_dexutil()
|
|
orig, orig_entry = dexutil.load_dex(args.original)
|
|
hard, hard_entry = dexutil.load_dex(args.hardened)
|
|
for label, dex in (("original", orig), ("hardened", hard)):
|
|
problems = dex.check()
|
|
if problems:
|
|
for p in problems:
|
|
sys.stderr.write("error: %s (%s) does not parse: %s\n"
|
|
% (label, dex.name, p))
|
|
return 1
|
|
|
|
om = _methods(orig)
|
|
hm = _methods(hard)
|
|
|
|
shape = collections.Counter()
|
|
pairs = collections.defaultdict(collections.Counter)
|
|
per_method = []
|
|
total_slots = 0
|
|
|
|
for key in sorted(set(om) | set(hm)):
|
|
cls, name, desc = key
|
|
label = "%s->%s%s" % (cls, name, desc)
|
|
if key not in hm:
|
|
shape["missing"] += 1
|
|
per_method.append((label, "missing", 0, 0))
|
|
continue
|
|
if key not in om:
|
|
shape["added"] += 1
|
|
per_method.append((label, "added", 0, 0))
|
|
continue
|
|
o_off, h_off = om[key], hm[key]
|
|
if not o_off:
|
|
shape["no-original-body"] += 1
|
|
continue
|
|
if not h_off:
|
|
# The body left the dex entirely: native sink, or extracted to an
|
|
# interpreter. There is no byte stream to align against.
|
|
shape["stripped"] += 1
|
|
per_method.append((label, "stripped", 0, 0))
|
|
continue
|
|
o_info = orig.code_info(o_off)
|
|
h_info = hard.code_info(h_off)
|
|
o_size = o_info["insns_size"]
|
|
h_size = h_info["insns_size"]
|
|
if o_size != h_size:
|
|
shape["resized"] += 1
|
|
per_method.append((label, "resized", o_size, h_size))
|
|
continue
|
|
shape["aligned"] += 1
|
|
shifted = 0
|
|
for insn in orig.decode(o_off):
|
|
pos = h_info["insns_off"] + (insn["off"] - o_info["insns_off"])
|
|
byte = hard.d[pos]
|
|
pairs[insn["op"]][byte] += 1
|
|
total_slots += 1
|
|
shifted += 1
|
|
per_method.append((label, "aligned", o_size, shifted))
|
|
|
|
# --- aggregate into a table -----------------------------------------
|
|
rows = []
|
|
undetermined = []
|
|
conflicts = 0
|
|
for op in sorted(pairs):
|
|
cands = pairs[op]
|
|
ranked = sorted(cands.items(), key=lambda kv: (-kv[1], kv[0]))
|
|
samples = sum(cands.values())
|
|
if len(ranked) == 1:
|
|
verdict = "high"
|
|
else:
|
|
verdict = "conflict"
|
|
conflicts += 1
|
|
rows.append({
|
|
"orig": op,
|
|
"orig_hex": "0x%02x" % op,
|
|
"name": dexutil.OP_NAMES.get(op, "op_%02x" % op),
|
|
"samples": samples,
|
|
"candidates": ["0x%02x" % b for b, _n in ranked],
|
|
"candidate_counts": {"0x%02x" % b: n for b, n in ranked},
|
|
"verdict": verdict,
|
|
})
|
|
seen = set(pairs)
|
|
for op in sorted(set(dexutil.OP_NAMES) - seen):
|
|
undetermined.append({
|
|
"orig": op, "orig_hex": "0x%02x" % op,
|
|
"name": dexutil.OP_NAMES[op],
|
|
"reason": UNREACHABLE.get(op) or
|
|
"the original never emits this opcode, so there is no "
|
|
"known-plaintext instance to read a substitution from",
|
|
})
|
|
|
|
# reverse direction: a private byte claimed by two different originals
|
|
reverse = collections.defaultdict(set)
|
|
for op, cands in pairs.items():
|
|
if len(cands) == 1:
|
|
reverse[next(iter(cands))].add(op)
|
|
non_injective = {("0x%02x" % b): sorted("0x%02x" % o for o in ops)
|
|
for b, ops in reverse.items() if len(ops) > 1}
|
|
|
|
# --- run-level verdict ----------------------------------------------
|
|
comparable = shape["aligned"]
|
|
decided = len(rows)
|
|
notes = []
|
|
if comparable == 0:
|
|
verdict = "not-applicable"
|
|
if shape["stripped"]:
|
|
notes.append(
|
|
"%d method(s) lost their code_item: the hardened build moved the "
|
|
"bodies out of the dex. There is no byte stream to align, so "
|
|
"opcode differencing cannot start -- this is the extraction/VMP "
|
|
"shape, and the differential route stops here." % shape["stripped"])
|
|
if shape["resized"]:
|
|
notes.append(
|
|
"%d method(s) kept a body but changed its length. Boundaries "
|
|
"cannot be projected across a length change; a private opcode "
|
|
"stream with its own length table is not reachable this way."
|
|
% shape["resized"])
|
|
if not notes:
|
|
notes.append("no method pair was comparable; inspect --json per-method rows")
|
|
elif decided == 0:
|
|
verdict = "not-applicable"
|
|
notes.append("bodies align by length but no opcode pair was accumulated")
|
|
else:
|
|
conflict_ratio = conflicts / float(decided)
|
|
worst_share = max((len(v) for v in non_injective.values()), default=0)
|
|
if worst_share >= 3:
|
|
# The stub shape. Every original opcode maps to the *same* private
|
|
# byte, so `conflicts` is zero and the per-opcode reading looks
|
|
# perfect -- the only thing that gives it away is the reverse
|
|
# direction. This is why the map is checked for injectivity at all.
|
|
verdict = "not-usable"
|
|
notes.append(
|
|
"%d private byte(s) are claimed by up to %d original opcodes each. "
|
|
"No injective relabelling can do that: it is the signature of method "
|
|
"bodies replaced by one common stub, and any substitution read off "
|
|
"such a pair describes the stub, not a private opcode space."
|
|
% (len(non_injective), worst_share))
|
|
elif conflict_ratio > 0.30:
|
|
verdict = "not-usable"
|
|
notes.append("%d of %d opcode(s) saw more than one candidate byte; a "
|
|
"stable map cannot be read off this pair"
|
|
% (conflicts, decided))
|
|
elif conflict_ratio > 0.05 or non_injective:
|
|
verdict = "partially-usable"
|
|
if conflicts:
|
|
notes.append("%d of %d opcode(s) saw more than one candidate byte; the "
|
|
"substitution is not a pure opcode relabelling (or the "
|
|
"alignment slipped) -- treat those rows as unknown"
|
|
% (conflicts, decided))
|
|
if non_injective:
|
|
notes.append("%d private byte(s) are claimed by more than one original "
|
|
"opcode (%d-way at worst), which no injective relabelling "
|
|
"can produce" % (len(non_injective), worst_share))
|
|
else:
|
|
verdict = "usable"
|
|
|
|
# --- report ----------------------------------------------------------
|
|
print("== differential hardening: %s (%s) vs %s (%s) =="
|
|
% (args.original, orig_entry, args.hardened, hard_entry))
|
|
print(" aligned=%d resized=%d stripped=%d missing=%d added=%d"
|
|
% (shape["aligned"], shape["resized"], shape["stripped"],
|
|
shape["missing"], shape["added"]))
|
|
print(" aligned slots compared: %d" % total_slots)
|
|
print(" verdict: %s" % verdict.upper())
|
|
for n in notes:
|
|
print(" ! %s" % n)
|
|
if not args.quiet:
|
|
print("\n-- candidate opcode map (original -> private byte) --")
|
|
table_rows = []
|
|
for r in rows:
|
|
table_rows.append((r["orig_hex"], r["name"], r["samples"],
|
|
",".join(r["candidates"]), r["verdict"]))
|
|
if table_rows:
|
|
print(_fmt_table(table_rows, ["orig", "name", "samples", "candidate(s)", "verdict"]))
|
|
else:
|
|
print(" (none: no aligned instruction pairs)")
|
|
if undetermined:
|
|
print("\n-- undetermined opcodes (%d) --" % len(undetermined))
|
|
for u in undetermined:
|
|
print(" %s %-24s %s" % (u["orig_hex"], u["name"], u["reason"]))
|
|
if non_injective:
|
|
print("\n-- non-injective private bytes --")
|
|
for b, ops in sorted(non_injective.items()):
|
|
print(" %s <- %s" % (b, ",".join(ops)))
|
|
if args.verbose:
|
|
print("\n-- per-method --")
|
|
rows2 = [(m[0], m[1], m[2], m[3]) for m in per_method]
|
|
print(_fmt_table(rows2, ["method", "state", "orig_size", "compared"]))
|
|
|
|
if args.json:
|
|
payload = {
|
|
"original": args.original,
|
|
"hardened": args.hardened,
|
|
"verdict": verdict,
|
|
"shape": dict(shape),
|
|
"aligned_slots": total_slots,
|
|
"map": rows,
|
|
"undetermined": undetermined,
|
|
"non_injective": non_injective,
|
|
"notes": notes,
|
|
}
|
|
with open(args.json, "w", encoding="utf-8") as fh:
|
|
json.dump(payload, fh, indent=2, sort_keys=True)
|
|
print("\nwrote %s" % args.json)
|
|
return 0 if verdict in ("usable", "partially-usable") else 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# emit-smali
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_table(path):
|
|
"""Build private-byte -> standard-opcode from a compare/simulate JSON table.
|
|
|
|
Two shapes are accepted, because the two producers differ: `simulate` writes
|
|
its ground truth as {"map": {"0x90": "0xe1"}} (original -> private), while
|
|
`compare` writes a list of rows carrying candidates and a verdict. Only
|
|
rows whose verdict is `high` are usable -- a conflict row would have to pick
|
|
a winner, and guessing here is how a wrong table gets laundered into a
|
|
confident-looking disassembly.
|
|
"""
|
|
with open(path, encoding="utf-8") as fh:
|
|
doc = json.load(fh)
|
|
priv2std = {}
|
|
skipped = []
|
|
raw_map = doc.get("map")
|
|
if isinstance(raw_map, dict):
|
|
for orig_hex, priv_hex in raw_map.items():
|
|
priv2std[int(priv_hex, 0)] = int(orig_hex, 0)
|
|
return priv2std, skipped, doc
|
|
for row in raw_map or []:
|
|
orig = row.get("orig")
|
|
cands = row.get("candidates", [])
|
|
verdict = row.get("verdict")
|
|
if orig is None or not cands:
|
|
continue
|
|
if verdict != "high" or len(cands) != 1:
|
|
skipped.append((orig, verdict, cands))
|
|
continue
|
|
priv2std[int(cands[0], 0)] = int(orig)
|
|
return priv2std, skipped, doc
|
|
|
|
|
|
def _restore(data, priv2std):
|
|
"""Rewrite private opcode bytes to their standard values, in place.
|
|
|
|
Boundaries come from the table itself: once a byte is mapped back to a
|
|
standard opcode, the standard length table applies to that instruction and
|
|
the walk can step to the next one. An unmapped byte ends the walk for that
|
|
method -- and the count of such events is the honest measure of how much of
|
|
the body the table actually covers.
|
|
"""
|
|
dex = dexutil.Dex(bytes(data))
|
|
out = bytearray(data)
|
|
unknown = collections.Counter()
|
|
resolved = 0
|
|
for _key, code_off in _methods(dex).items():
|
|
if not code_off:
|
|
continue
|
|
info = dex.code_info(code_off)
|
|
pos = info["insns_off"]
|
|
end = pos + info["insns_size"] * 2
|
|
while pos < end:
|
|
byte = data[pos]
|
|
std = priv2std.get(byte)
|
|
if std is None:
|
|
if byte == 0x00:
|
|
# a genuine nop or a payload header: the payload's own layout
|
|
# is data, not opcodes, so its leading byte stays as it is
|
|
std = 0x00
|
|
else:
|
|
unknown[byte] += 1
|
|
break
|
|
out[pos] = std
|
|
resolved += 1
|
|
units = dexutil.insn_units(std, data, pos, end)
|
|
if units < 1 or pos + units * 2 > end:
|
|
break
|
|
pos += units * 2
|
|
return out, unknown, resolved
|
|
|
|
|
|
def cmd_emit_smali(args):
|
|
_require_dexutil()
|
|
with open(args.dex, "rb") as fh:
|
|
data = bytearray(fh.read())
|
|
priv2std, skipped, _doc = _load_table(args.table)
|
|
restored, unknown, resolved = _restore(data, priv2std)
|
|
if not args.keep_header:
|
|
dexutil.fix_dex_header(restored)
|
|
dex = dexutil.Dex(bytes(restored))
|
|
problems = dex.check()
|
|
|
|
lines = []
|
|
lines.append("; smali skeleton -- generated by vmp_diff_harness.py emit-smali")
|
|
lines.append("; source dex : %s" % args.dex)
|
|
lines.append("; table : %s (%d private bytes mapped, %d entries skipped as "
|
|
"non-high-confidence)" % (args.table, len(priv2std), len(skipped)))
|
|
lines.append("; instructions restored: %d ; unmapped-opcode stops: %d"
|
|
% (resolved, sum(unknown.values())))
|
|
lines.append(";")
|
|
lines.append("; THIS IS A READING SKELETON, NOT ASSEMBLABLE SMALI.")
|
|
lines.append("; Instruction boundaries and opcode names are restored from the")
|
|
lines.append("; differential table; the operands below are rendered by the standard")
|
|
lines.append("; dex format. Register numbers, field/method/string indices and any")
|
|
lines.append("; register re-allocation the engine performed are NOT recovered --")
|
|
lines.append("; no map for those exists in this method.")
|
|
if unknown:
|
|
lines.append("; UNMAPPED private bytes encountered (%d kind(s)):"
|
|
% len(unknown))
|
|
for byte, n in sorted(unknown.items()):
|
|
lines.append("; 0x%02x x%d -- body truncated at the first occurrence" % (byte, n))
|
|
lines.append("")
|
|
for p in problems:
|
|
lines.append("; [structure] %s" % p)
|
|
if problems:
|
|
lines.append("")
|
|
|
|
classes = 0
|
|
methods_out = 0
|
|
for i in range(dex.header["class_defs_size"]):
|
|
off = dex.header["class_defs_off"] + i * 32
|
|
class_type = dex.type_(dexutil.u32(dex.d, off))
|
|
if not _class_matches(class_type, args.cls):
|
|
continue
|
|
classes += 1
|
|
super_idx = dexutil.u32(dex.d, off + 8)
|
|
access = dexutil.u32(dex.d, off + 4)
|
|
lines.append("# access_flags=0x%x" % access)
|
|
lines.append(".class %s%s" % (_flags(access, CLASS_FLAGS) + " "
|
|
if _flags(access, CLASS_FLAGS) else "", class_type))
|
|
if super_idx:
|
|
lines.append(".super %s" % dex.type_(super_idx))
|
|
source_idx = dexutil.u32(dex.d, off + 16)
|
|
if source_idx:
|
|
lines.append('.source "%s"' % dex.string_safe(source_idx))
|
|
lines.append("")
|
|
for _section, _idx, _cls, name, desc, acc, code_off in _methods_with_acc(dex, off):
|
|
if args.method and name != args.method:
|
|
continue
|
|
mflags = _flags(acc, METHOD_FLAGS)
|
|
head = ".method %s%s%s" % (mflags + " " if mflags else "", name, desc)
|
|
if not code_off:
|
|
lines.append(head)
|
|
lines.append(" # no code_item (abstract or native)")
|
|
lines.append(".end method")
|
|
lines.append("")
|
|
methods_out += 1
|
|
continue
|
|
info = dex.code_info(code_off)
|
|
listing, clean, expected = dex.decode_all(code_off)
|
|
lines.append("# ---- %s%s registers=%d insns_size=%d %s"
|
|
% (name, desc, info["registers"], info["insns_size"],
|
|
"" if clean else "DECODE-DID-NOT-END-CLEANLY"))
|
|
lines.append(head)
|
|
lines.append(" .registers %d" % info["registers"])
|
|
for insn in listing:
|
|
text = dex.describe(insn)
|
|
body = text.split(": ", 1)[1] if ": " in text else text
|
|
lines.append(" # 0x%04x %s"
|
|
% (insn["off"] - info["insns_off"], body))
|
|
lines.append(".end method")
|
|
lines.append("")
|
|
methods_out += 1
|
|
|
|
text = "\n".join(lines) + "\n"
|
|
if args.out:
|
|
with open(args.out, "w", encoding="utf-8") as fh:
|
|
fh.write(text)
|
|
print("wrote %s (%d classes matched, %d methods)" % (args.out, classes, methods_out))
|
|
else:
|
|
sys.stdout.write(text)
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cli
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(
|
|
prog="vmp_diff_harness.py",
|
|
description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
sub = parser.add_subparsers(dest="cmd")
|
|
|
|
p = sub.add_parser("build", help="compile the labelled opcode-coverage fixture",
|
|
description="Compile a fixture dex whose every instruction is "
|
|
"known and labelled. Needs an explicit toolchain.")
|
|
p.add_argument("--out", required=True, help="output directory")
|
|
p.add_argument("--build-tools", default=None,
|
|
help="directory holding d8 and aapt2 (not guessed)")
|
|
p.add_argument("--javac", default=None, help="path to javac (default: PATH)")
|
|
p.add_argument("--min-api", type=int, default=26,
|
|
help="d8 --min-api (26 keeps invoke-custom/invoke-polymorphic "
|
|
"instead of desugaring them away); default 26")
|
|
p.add_argument("--desugar", action="store_true",
|
|
help="allow d8 desugaring (default: --no-desugaring)")
|
|
p.add_argument("--with-big", action="store_true", default=True,
|
|
help="also generate the bulk fixture (wide registers, long jump); "
|
|
"default on")
|
|
p.add_argument("--no-big", dest="with_big", action="store_false",
|
|
help="skip the bulk fixture")
|
|
p.add_argument("--n-int", type=int, default=300, help="int locals in the wide frame")
|
|
p.add_argument("--n-long", type=int, default=140, help="long locals in the wide frame")
|
|
p.add_argument("--n-obj", type=int, default=160, help="object locals in the wide frame")
|
|
p.add_argument("--goto16", type=int, default=200,
|
|
help="statements in the long-jump loop body")
|
|
p.add_argument("--apk", action="store_true",
|
|
help="also pack classes.dex into a minimal unsigned APK via aapt2")
|
|
p.set_defaults(func=cmd_build)
|
|
|
|
p = sub.add_parser("audit", help="report opcode coverage of a dex",
|
|
description="Count decoded opcodes and report the gap against "
|
|
"the format table, with the reason each gap exists.")
|
|
p.add_argument("dex", nargs="+", help="dex or APK to audit")
|
|
p.add_argument("--json", default=None, help="write the report as JSON here")
|
|
p.set_defaults(func=cmd_audit)
|
|
|
|
p = sub.add_parser("compare", help="align original vs hardened dex; emit a candidate opcode map",
|
|
description="Project the original's instruction boundaries onto the "
|
|
"hardened body and read the opcode substitution off the "
|
|
"alignment. Reports a run-level verdict so a shape the "
|
|
"method cannot handle is said out loud instead of "
|
|
"producing a plausible map.")
|
|
p.add_argument("original", help="the pre-hardening dex (or APK)")
|
|
p.add_argument("hardened", help="the post-hardening dex (or APK)")
|
|
p.add_argument("--json", default=None, help="write the full report here")
|
|
p.add_argument("--quiet", action="store_true", help="suppress the map listing")
|
|
p.add_argument("--verbose", action="store_true", help="also print per-method rows")
|
|
p.set_defaults(func=cmd_compare)
|
|
|
|
p = sub.add_parser("simulate", help="forge a hardened dex from a known private table",
|
|
description="Local fixture that makes compare() falsifiable: relabel "
|
|
"every opcode through a known bijection, then require "
|
|
"compare to re-derive that table. This is NOT a hardening "
|
|
"engine -- it preserves instruction lengths, which a real "
|
|
"one need not do.")
|
|
p.add_argument("dex", help="the dex to relabel")
|
|
p.add_argument("--out", required=True, help="where to write the simulated dex")
|
|
p.add_argument("--mode", choices=("relabel", "stub", "shrink"), default="relabel",
|
|
help="relabel: private opcode bytes, lengths preserved (the shape "
|
|
"the differential can actually crack); stub: every body "
|
|
"replaced by an equal-length return-void fill (the extraction "
|
|
"shape -- compare must refuse it); shrink: rewrite insns_size "
|
|
"to drive compare's length-mismatch detector (writes a "
|
|
"deliberately inconsistent image)")
|
|
p.add_argument("--seed", type=int, default=1, help="bijection seed")
|
|
p.add_argument("--table", default=None,
|
|
help="also write the ground-truth table here (orig -> private)")
|
|
p.add_argument("--keep-header", action="store_true",
|
|
help="do not recompute checksum/signature (default: recompute)")
|
|
p.set_defaults(func=cmd_simulate)
|
|
|
|
p = sub.add_parser("emit-smali", help="render private-opcode bodies into a smali skeleton",
|
|
description="Restore opcode bytes through a differential table and "
|
|
"render method bodies as an annotated smali reading "
|
|
"skeleton. The output is NOT assembliable.")
|
|
p.add_argument("dex", help="the hardened dex")
|
|
p.add_argument("--table", required=True, help="table JSON from compare/simulate")
|
|
p.add_argument("--class", dest="cls", default=None,
|
|
help="only this class (Lpkg/Name; or pkg.Name)")
|
|
p.add_argument("--method", default=None, help="only this method name")
|
|
p.add_argument("--out", default=None, help="output file (default: stdout)")
|
|
p.add_argument("--keep-header", action="store_true",
|
|
help="do not recompute the restored dex header")
|
|
p.set_defaults(func=cmd_emit_smali)
|
|
|
|
args = parser.parse_args(argv)
|
|
if not getattr(args, "cmd", None):
|
|
parser.print_help()
|
|
return 2
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|