#!/srv/rev-tools/venv/bin/python3
# -*- coding: utf-8 -*-
"""
rev — 逆向一条龙（服务器版，给顾总用）

  rev info      <apk>              包名/版本/组件数/权限/签名/加壳
  rev manifest  <apk>              组件全表（exported / permission / intent-filter）
  rev pack      <apk>              加壳检测（360/梆梆/爱加密/乐固/娜迦…）
  rev urls      <apk>              提取所有 http(s) URL 与域名
  rev strings   <apk> [正则]        提取字符串（可过滤）
  rev cert      <apk>              签名证书详情
  rev unpack    <apk> [outdir]     apktool 解码 + jadx 反编译（一次到位）
  rev src       <apk> [outdir]     只 jadx 出 java 源码
  rev grep      <dir> <正则>        在反编译结果里搜（ripgrep）
  rev native    <so|apk>           native 库速览（readelf/strings）
  rev car       <pkg>              车机上定位该包（经隧道，列出 APK 路径）
  rev car-pull  <pkg> [out.apk]    从车机把该包 APK 拉回服务器
"""
import os
import re
import struct
import subprocess
import sys
import zipfile

# androguard 4.x 用 loguru，会把 DEBUG 全打到 stdout，淹掉正文 —— 先静音
try:
    from loguru import logger as _lg
    _lg.remove()
except Exception:
    pass
try:
    import logging as _logging
    _logging.getLogger("androguard").setLevel(_logging.ERROR)
    _logging.disable(_logging.WARNING)
except Exception:
    pass

RT = "/srv/rev-tools"
BIN = os.path.join(RT, "bin")
AAPT = os.path.join(BIN, "aapt")
JADX = shutil_which = None
for cand in ("/usr/local/bin/jadx", "/usr/bin/jadx", os.path.join(BIN, "jadx")):
    if os.path.exists(cand):
        JADX = cand
        break
APKTOOL = os.path.join(BIN, "apktool")
CAR_TUN = ("127.0.0.1", "15555")

PACKERS = [
    (r"assets/libjiagu", "360 加固 (jiagu)"),
    (r"com/stub/StubApp|com\.stub\.StubApp", "360 加固壳入口 com.stub.StubApp"),
    (r"libDexHelper\.so", "梆梆加固 (bangcle)"),
    (r"assets/classes\.jar", "梆梆加固 (assets/classes.jar)"),
    (r"libsecexe\.so|libsecmain\.so|libexecmain\.so", "爱加密 / 几维安全"),
    (r"libtup\.so|libexec\.so", "乐固 (tencent legu)"),
    (r"libshell\.so", "娜迦 (nagain) / 通用壳"),
    (r"libnesec\.so|libbaiduprotect\.so|libDexHelper", "其他商业壳"),
    (r"libexec", "未知 exec 壳"),
]


def out(*a):
    print(*a, flush=True)


def sh(cmd, timeout=600):
    p = subprocess.run(cmd, shell=isinstance(cmd, str), capture_output=True,
                       text=True, timeout=timeout)
    return p.returncode, (p.stdout or "") + (p.stderr or "")


def car(cmd, timeout=25):
    """经 frp 隧道在车机执行一条命令"""
    try:
        p = subprocess.run(["nc", "-w", str(timeout), CAR_TUN[0], CAR_TUN[1]],
                           input=cmd + "\n", capture_output=True, text=True,
                           timeout=timeout + 10)
        return (p.stdout or "").strip()
    except Exception as e:
        return "[car error] %s" % e


# ------------------------------------------------------------------ androguard
def load_apk(path):
    from androguard.core.apk import APK
    return APK(path)


def cmd_info(path):
    a = load_apk(path)
    out("== 应用信息 ==")
    out("  app_name :", a.get_app_name())
    out("  package  :", a.get_package())
    out("  version  :", a.get_androidversion_name(), "(", a.get_androidversion_code(), ")")
    out("  minSdk   :", a.get_min_sdk_version(), " targetSdk:", a.get_target_sdk_version())
    out("  main     :", a.get_main_activity())
    out("  components: %d activity / %d service / %d receiver / %d provider" % (
        len(a.get_activities()), len(a.get_services()),
        len(a.get_receivers()), len(a.get_providers())))
    out("")
    out("== 权限 (%d) ==" % len(a.get_permissions()))
    for p in sorted(a.get_permissions()):
        out("  ", p)
    out("")
    out("== 证书 ==")
    try:
        for c in a.get_certificates():
            out("  subject:", c.subject.human_friendly)
            out("  issuer :", c.issuer.human_friendly)
            out("  sha256 :", c.sha256.hex())
    except Exception as e:
        out("  读证书失败:", e)
    out("")
    pk = detect_packers(path)
    out("== 加壳检测 ==")
    if pk:
        for name, why in pk:
            out("  [!] %s   <- %s" % (name, why))
    else:
        out("  [ok] 未见常见壳特征")
    out("")
    out("== 资源/资产体积 top 8 ==")
    for i in sorted((z.getinfo(n) for z in [zipfile.ZipFile(path)]
                     if not z.getinfo(n).filename.endswith("/")),
                    key=lambda x: -x.file_size)[:8]:
        out("  %-50s %9d" % (i.filename, i.file_size))


def detect_packers(path):
    z = zipfile.ZipFile(path)
    names = z.namelist()
    blob = b""
    for n in names:
        if n.endswith(".dex"):
            blob += z.read(n)
    hits = []
    for pat, name in PACKERS:
        rx = re.compile(pat)
        if any(rx.search(n) for n in names):
            hits.append((name, "文件名匹配"))
        elif rx.search(blob.decode("latin-1", "ignore")):
            hits.append((name, "dex 内字符串匹配"))
    return hits


def cmd_manifest(path):
    a = load_apk(path)
    xml = a.get_android_manifest_xml()
    if hasattr(xml, "getroot"):
        xml = xml.getroot()
    NS = "{http://schemas.android.com/apk/res/android}"

    def attr(node, name):
        # androguard 的 lxml 版带命名空间，ElementTree 版不带
        v = node.get(NS + name)
        if v is None:
            v = node.get(name)
        if v is None:
            for k, val in node.attrib.items():
                if k == name or k.endswith("}" + name):
                    return val
        return v

    def lname(node):
        t = node.tag
        if isinstance(t, str) and "}" in t:
            return t.split("}", 1)[1]
        return t if isinstance(t, str) else ""

    root = xml
    if lname(root) != "manifest":
        found = [n for n in root.iter() if lname(n) == "manifest"]
        if not found:
            out("解析 manifest 失败")
            return
        root = found[0]
    for kind in ("activity", "activity-alias", "service", "receiver", "provider"):
        nodes = [n for n in root.iter() if lname(n) == kind]
        if not nodes:
            continue
        out("== %s (%d) ==" % (kind.upper(), len(nodes)))
        for nd in nodes:
            name = attr(nd, "name") or "?"
            exp = attr(nd, "exported")
            perm = attr(nd, "permission")
            line = "  %s" % name
            if exp is not None:
                line += "  exported=%s" % exp
            if perm:
                line += "  perm=%s" % perm
            out(line)
            for itf in [n for n in nd if lname(n) == "intent-filter"]:
                acts = [attr(x, "name") for x in itf if lname(x) == "action"]
                cats = [attr(x, "name") for x in itf if lname(x) == "category"]
                datas = [(attr(x, "scheme"), attr(x, "host"),
                          attr(x, "pathPattern") or attr(x, "path"))
                         for x in itf if lname(x) == "data"]
                if any(acts):
                    out("      action: %s" % ", ".join(x for x in acts if x))
                if any(cats):
                    out("      category: %s" % ", ".join(x for x in cats if x))
                for d in datas:
                    if any(d):
                        out("      data: scheme=%s host=%s path=%s" % d)
        out("")


def cmd_pack(path):
    hits = detect_packers(path)
    if not hits:
        out("未见常见壳特征（可能是自研壳或未加固）")
        return
    for name, why in hits:
        out("[!] %s   <- %s" % (name, why))


def cmd_urls(path):
    z = zipfile.ZipFile(path)
    blob = b""
    for n in z.namelist():
        if n.endswith(".dex") or n.endswith(".arsc") or n.startswith("assets/"):
            try:
                blob += z.read(n)
            except Exception:
                pass
    txt = blob.decode("latin-1", "ignore")
    urls = sorted(set(re.findall(r"https?://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]{4,200}", txt)))
    out("== URL (%d) ==" % len(urls))
    for u in urls:
        out("  ", u)
    hosts = sorted(set(re.findall(r"https?://([A-Za-z0-9\-]+\.[A-Za-z0-9\-]+\.[A-Za-z]{2,})", txt)))
    out("")
    out("== 域名 (%d) ==" % len(hosts))
    for h in hosts:
        out("  ", h)


def cmd_strings(path, pattern=None):
    z = zipfile.ZipFile(path)
    blob = b""
    for n in z.namelist():
        if n.endswith(".dex"):
            blob += z.read(n)
    txt = blob.decode("utf-8", "ignore")
    pat = re.compile(pattern) if pattern else None
    seen = set()
    for m in re.finditer(r"[\x20-\x7e一-鿿]{4,200}", txt):
        s = m.group(0)
        if pat and not pat.search(s):
            continue
        if s in seen:
            continue
        seen.add(s)
        out(s)


def cmd_cert(path):
    a = load_apk(path)
    try:
        for c in a.get_certificates():
            out("subject  :", c.subject.human_friendly)
            out("issuer   :", c.issuer.human_friendly)
            out("serial   :", c.serial_number)
            out("sha1     :", c.sha1.hex())
            out("sha256   :", c.sha256.hex())
            out("validity :", c.not_validity_not_before, "->", c.not_validity_not_after)
    except Exception as e:
        out("读证书失败:", e)


def cmd_jiagu(path):
    """360 加固 payload 解析：XOR 0x52 -> KV 元数据表 + 分段表"""
    z = zipfile.ZipFile(path)
    names = z.namelist()
    if not any("jiagu" in n for n in names):
        out("未见 360 壳特征（assets/libjiagu.so / com.stub.StubApp）")
        return
    dex = z.read("classes.dex")
    map_off = struct.unpack_from("<I", dex, 0x34)[0]
    map_size = struct.unpack_from("<I", dex, map_off)[0]
    pay = dex[map_off + 4 + map_size * 12:]
    if not pay:
        out("classes.dex 里没找到 360 payload")
        return
    dec = bytes(b ^ 0x52 for b in pay)
    magic, size, cnt = struct.unpack_from("<III", dec, 0)
    out("== 360 payload ==")
    out("   magic=0x%08x  size=%d  count=%d" % (magic, size, cnt))

    off = 12
    recs = []
    while off + 14 <= len(dec):
        if dec[off:off + 2] != b"pk":
            break
        klen, vlen = struct.unpack_from("<II", dec, off + 4)
        k = dec[off + 12:off + 12 + klen]
        v = dec[off + 12 + klen:off + 12 + klen + vlen]
        if len(k) != klen or len(v) != vlen:
            break
        recs.append((k.decode("utf-8", "replace"), v.decode("utf-8", "replace")))
        off += 12 + klen + vlen
    out("")
    out("== 壳元数据 (%d 条) ==" % len(recs))
    for k, v in recs:
        out("   %-16s = %s" % (k, v if len(v) < 300 else v[:300] + "..."))

    blob = pay[off:]
    out("")
    out("== 加密分段表 ==")
    try:
        n = struct.unpack_from("<I", blob, 0)[0]
        p = 4
        out("   段数 = %d" % n)
        for i in range(n):
            if p + 4 > len(blob):
                break
            sz = struct.unpack_from("<I", blob, p)[0]
            p += 4
            out("   段 %d: 密文 %d bytes, 头 %s" % (i, sz, blob[p:p + 16].hex(" ")))
            p += sz
    except Exception as e:
        out("   分段解析失败:", e)
    out("")
    out("== 下一步 ==")
    out("   静态解不开（360 用自有算法加密 dex），走运行时 dump：")
    out("   车机上启动 app 后，真 dex 会落在 /data/data/<pkg>/.jiagu/*.dex")
    out("   用 rev car-pull 或 car-sh 取回，再用 rev src <dex> 反编译")


def cmd_unpack(path, outdir=None, jadx_only=False):
    apk = os.path.abspath(path)
    base = outdir or ("/tmp/rev-out/" + re.sub(r"\W+", "_", os.path.basename(apk)))
    os.makedirs(base, exist_ok=True)
    apktool_dir = os.path.join(base, "apktool")
    src_dir = os.path.join(base, "src")
    if not jadx_only:
        out("== apktool 解码 -> %s ==" % apktool_dir)
        rc, log = sh([APKTOOL, "d", "-f", "-o", apktool_dir, apk], timeout=900)
        out(log.strip()[-1500:] if log.strip() else "(无输出)")
        if rc != 0:
            out("apktool 失败 rc=%d" % rc)
    out("== jadx 反编译 -> %s ==" % src_dir)
    if not JADX:
        out("找不到 jadx")
        return
    rc, log = sh([JADX, "-d", src_dir, "--show-bad-code", apk], timeout=1800)
    tail = log.strip()[-800:]
    if tail:
        out(tail)
    out("")
    out("完成。源码在: %s" % src_dir)
    out("用 rev grep %s <正则> 搜" % src_dir)


def cmd_grep(d, pattern):
    if not os.path.isdir(d):
        out("不是目录:", d)
        return
    if shutil_which("rg"):
        p = subprocess.run(["rg", "--no-heading", "-n", "-S", pattern, d],
                           capture_output=True, text=True, timeout=300)
        out(p.stdout[:20000] or "(无匹配)")
    else:
        p = subprocess.run(["grep", "-rnE", pattern, d], capture_output=True,
                           text=True, timeout=300)
        out(p.stdout[:20000] or "(无匹配)")


def cmd_native(path):
    if zipfile.is_zipfile(path):
        z = zipfile.ZipFile(path)
        libs = [n for n in z.namelist() if n.endswith(".so")]
        out("== APK 内 native 库 (%d) ==" % len(libs))
        for n in libs:
            out("  %-50s %9d" % (n, z.getinfo(n).file_size))
        tmp = "/tmp/rev-native"
        os.makedirs(tmp, exist_ok=True)
        for n in libs:
            with open(os.path.join(tmp, os.path.basename(n)), "wb") as f:
                f.write(z.read(n))
        out("")
        out("已解到 %s，用 rev native <so> 逐个看" % tmp)
        return
    if not os.path.exists(path):
        out("文件不存在:", path)
        return
    out("== file ==")
    sh(["file", path])
    out("== readelf -h ==")
    sh(["readelf", "-h", path])
    out("== 动态符号 (导出函数) ==")
    rc, log = sh(["readelf", "--dyn-syms", "-W", path])
    lines = [l for l in log.splitlines() if " FUNC " in l]
    out("\n".join(lines[:120]) or "(无)")
    out("== 可疑字符串 (http/key/encrypt/dex) ==")
    rc, log = sh(["strings", "-n", "6", path])
    keep = [l for l in log.splitlines()
            if re.search(r"https?://|key|KEY|encrypt|decrypt|dex|Dex|\.so|java/|com/", l)]
    out("\n".join(keep[:200]) or "(无)")


def cmd_car(pkg):
    out("== 车机上 %s 的信息 ==" % pkg)
    out(car("pm path %s" % pkg))
    out(car("dumpsys package %s | busybox grep -E 'versionName|firstInstallTime|lastUpdateTime'" % pkg))


def cmd_car_pull(pkg, outpath=None):
    """从车机把某包 APK 拉回服务器（起 httpd + 临时代理，用完即收）"""
    apk = outpath or ("/tmp/%s.apk" % pkg)
    out("== 1/5 找 APK 路径 ==")
    r = car("pm path %s" % pkg)
    m = re.search(r"package:(\S+)", r)
    if not m:
        out("车机上没找到:", pkg, "->", r)
        return 1
    src = m.group(1)
    out("   ", src)

    out("== 2/5 推代理配置 ==")
    cfg = open(os.path.join(RT, "frpc-carfile.toml"), "rb").read()
    b64 = subprocess.run(["base64", "-w0"], input=cfg, capture_output=True).stdout.decode()
    car("echo %s | busybox base64 -d > /data/local/tmp/frpc-carfile.toml" % b64)
    out("    md5 车机:", car("busybox md5sum /data/local/tmp/frpc-carfile.toml"))

    out("== 3/5 起 httpd + 第二个 frpc 实例 ==")
    car("busybox sh -c 'pidof httpd >/dev/null 2>&1 || busybox httpd -p 127.0.0.1:8088 -h /data/app'")
    for p in car("busybox pidof frpc").split():
        cl = car("busybox cat /proc/%s/cmdline | busybox tr '\\0' ' '" % p)
        if "frpc-carfile" in cl:
            car("kill %s" % p)
    time.sleep(3)
    car("busybox setsid /system/bin/frpc -c /data/local/tmp/frpc-carfile.toml "
        "</dev/null >/data/local/tmp/frpc-carfile.out 2>&1 &")
    time.sleep(8)

    out("== 4/5 下载 ==")
    url = "http://127.0.0.1:15559" + src
    rc, log = sh(["curl", "-sS", "-m", "300", "-o", apk, url], timeout=400)
    if rc != 0:
        out("下载失败:", log)
    else:
        out("    已下载:", apk, os.path.getsize(apk), "bytes")
        out("    md5:", sh(["md5sum", apk])[1].strip())

    out("== 5/5 收尾 ==")
    for p in car("busybox pidof frpc").split():
        cl = car("busybox cat /proc/%s/cmdline | busybox tr '\\0' ' '" % p)
        if "frpc-carfile" in cl:
            car("kill %s" % p)
    car("busybox sh -c 'for p in $(busybox pidof httpd); do kill $p; done'")
    out("    已清理（第二个 frpc 实例 + httpd 都停了，主隧道没动）")
    return 0


import time  # noqa: E402  (car_pull 用)

def main():
    if len(sys.argv) < 2:
        out(__doc__)
        return 1
    cmd = sys.argv[1]
    args = sys.argv[2:]
    try:
        if cmd == "info":
            cmd_info(args[0])
        elif cmd == "manifest":
            cmd_manifest(args[0])
        elif cmd == "pack":
            cmd_pack(args[0])
        elif cmd == "jiagu":
            cmd_jiagu(args[0])
        elif cmd == "urls":
            cmd_urls(args[0])
        elif cmd == "strings":
            cmd_strings(args[0], args[1] if len(args) > 1 else None)
        elif cmd == "cert":
            cmd_cert(args[0])
        elif cmd == "unpack":
            cmd_unpack(args[0], args[1] if len(args) > 1 else None)
        elif cmd == "src":
            cmd_unpack(args[0], args[1] if len(args) > 1 else None, jadx_only=True)
        elif cmd == "grep":
            cmd_grep(args[0], args[1])
        elif cmd == "native":
            cmd_native(args[0])
        elif cmd == "car":
            cmd_car(args[0])
        elif cmd == "car-pull":
            cmd_car_pull(args[0], args[1] if len(args) > 1 else None)
        else:
            out(__doc__)
            return 1
    except Exception as e:
        import traceback
        traceback.print_exc()
        return 2
    return 0


if __name__ == "__main__":
    sys.exit(main())
