#!/usr/bin/env python3 """Checks every Android / Apache HttpClient method and field the dex references against published API declarations (NativeScript's android-platform-*.d.ts). Catches stub mistakes (wrong name, arity, parameter or return kind) that would otherwise surface as NoSuchMethodError on the phone. usage: check_api.py classes.dex android-platform-17.d.ts""" import re import sys from loguru import logger logger.remove() from androguard.core.dex import DEX dex_path, dts_path = sys.argv[1], sys.argv[2] # ----------------------------------------------------------- parse the .d.ts classes = {} # fqcn -> {"supers": [...], "methods": {name: [(params, ret)]}, "fields": set()} stack = [] # (kind, name, depth_at_open) depth = 0 cur = None cls_re = re.compile(r"export (?:abstract )?class (\w+)(?:<[^{]*?>)?(?:\s+extends ([\w.]+)(?:<.*?>)?)?(?:\s+implements (.+?))?\s*\{") mod_re = re.compile(r"(?:declare|export) module ([\w.]+) \{") meth_re = re.compile(r"^\s*(?:public |protected )?(?:static )?(\w+)(?:<[^(]*>)?\((.*)\): (.+);\s*$") field_re = re.compile(r"^\s*public (?:static )?(\w+): (.+);\s*$") def strip_generics(t): out, d = "", 0 for ch in t: if ch == "<": d += 1 elif ch == ">": d -= 1 elif d == 0: out += ch return out.strip() for line in open(dts_path, encoding="utf-8"): m = mod_re.search(line) c = cls_re.search(line) if c: name = ".".join([s[1] for s in stack if s[0] in ("mod", "cls")] + [c.group(1)]) supers = [] if c.group(2): supers.append(c.group(2)) if c.group(3): supers += [strip_generics(s) for s in strip_generics(c.group(3)).split(",")] classes.setdefault(name, {"supers": [], "methods": {}, "fields": set()})["supers"] += supers stack.append(("cls", c.group(1), depth)) cur = classes[name] elif m: for part in m.group(1).split("."): stack.append(("mod", part, depth)) elif cur is not None and stack and stack[-1][0] == "cls": mm = meth_re.match(line) if mm and mm.group(1) not in ("constructor",): params = [p for p in mm.group(2).split(", ") if p] if mm.group(2).strip() else [] ptypes = [strip_generics(p.split(": ", 1)[1]) if ": " in p else "any" for p in params] cur["methods"].setdefault(mm.group(1), []).append((ptypes, strip_generics(mm.group(3)))) elif "public constructor(" in line and "implementation" not in line: inner = line.split("public constructor(", 1)[1].rsplit(");", 1)[0] params = [p for p in inner.split(", ") if p] if inner.strip() else [] cur["methods"].setdefault("", []).append( ([strip_generics(p.split(": ", 1)[1]) for p in params], "void")) else: fm = field_re.match(line) if fm: cur["fields"].add(fm.group(1)) depth += line.count("{") - line.count("}") while stack and depth <= stack[-1][2]: stack.pop() cur = classes.get(".".join(s[1] for s in stack if s[0] in ("mod", "cls"))) if stack and stack[-1][0] == "cls" else None # ------------------------------------------------------------- type mapping NUM = {"I", "J", "F", "D", "S", "B", "C"} def ts_of(desc): if desc in NUM: return "number" if desc == "Z": return "boolean" if desc == "V": return "void" if desc.startswith("["): return "native.Array<%s>" % ts_of(desc[1:]) n = desc[1:-1].replace("/", ".").replace("$", ".") if n in ("java.lang.String", "java.lang.CharSequence"): return "string" return n def compatible(java, ts): ts = ts.replace(" ", "") if ts in ("any", "any[]"): return True if java.startswith("native.Array"): return ts.startswith("native.Array") or ts.endswith("[]") if java == "java.lang.Object": return True return java == ts or (java == "string" and ts == "string") def lookup(cls, name, params, ret, seen=None): seen = seen or set() if cls in seen or cls not in classes: return False seen.add(cls) for ptypes, r in classes[cls]["methods"].get(name, []): if len(ptypes) == len(params) and all(compatible(j, t) for j, t in zip(params, ptypes)): if name == "" or compatible(ret, r) or r == "any": return True return any(lookup(s, name, params, ret, seen) for s in classes[cls]["supers"]) def has_field(cls, name, seen=None): seen = seen or set() if cls in seen or cls not in classes: return False seen.add(cls) return name in classes[cls]["fields"] or any(has_field(s, name, seen) for s in classes[cls]["supers"]) # ------------------------------------------------------------- check the dex raw = open(dex_path, "rb").read() if raw[:2] == b"PK": import zipfile raw = zipfile.ZipFile(dex_path).read("classes.dex") d = DEX(raw) own = {c.get_name() for c in d.get_classes()} bad, checked, seen = [], 0, set() for mid in d.get_methods_id_item().get_obj(): cname = mid.get_class_name() if not (cname.startswith("Landroid/") or cname.startswith("Lorg/apache/http/")) or cname in own: continue desc = mid.get_descriptor().replace(" ", "") key = (cname, mid.get_name(), desc) if key in seen: continue seen.add(key) plist = re.findall(r"\[*(?:L[^;]+;|[ZBCSIJFD])", desc[1:desc.index(")")]) ret = desc[desc.index(")") + 1:] cls = cname[1:-1].replace("/", ".").replace("$", ".") checked += 1 if not lookup(cls, mid.get_name(), [ts_of(p) for p in plist], ts_of(ret)): bad.append("%s.%s%s" % (cls, mid.get_name(), desc)) for fid in d.get_fields_id_item().get_obj(): cname = fid.get_class_name() if not (cname.startswith("Landroid/") or cname.startswith("Lorg/apache/http/")) or cname in own: continue cls = cname[1:-1].replace("/", ".").replace("$", ".") checked += 1 if not has_field(cls, fid.get_name()): bad.append("field %s.%s" % (cls, fid.get_name())) print("checked %d framework references against %d declared classes" % (checked, len(classes))) for b in bad: print(" NOT FOUND:", b) sys.exit(1 if bad else 0)