378 lines
16 KiB
Python
378 lines
16 KiB
Python
"""静态整理 ``passport_account_image`` / ``WeaponHI.dd(21)`` 链路证据。
|
||
|
||
这个工具只读 apktool 反编译目录,输出机器可读 JSON。目标不是替代动态
|
||
抓证,而是把当前本地 APK 中能静态证明的链路固定下来,避免后续继续靠记忆
|
||
判断:
|
||
|
||
``/f/a/p`` 上传态 ``VIMG_`` → 服务端/配置回写 ``wcfg`` →
|
||
``WeaponHI.dd(21)`` 读取 ``wcfg["a_y_q_z"]`` →
|
||
账号登录请求 body 注入 ``passport_account_image``。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import base64
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
DEFAULT_APKTOOL_DIR = ROOT / "out" / "agent_apk_triage" / "apktool"
|
||
|
||
|
||
RELATIVE_FILES = {
|
||
"weaponhi": Path("smali_classes15/com/kuaishou/weapon/i/WeaponHI.smali"),
|
||
"gzip": Path("smali_classes15/com/kuaishou/weapon/ks/i0.smali"),
|
||
"z0": Path("smali_classes15/com/kuaishou/weapon/ks/z0.smali"),
|
||
"x0": Path("smali_classes15/com/kuaishou/weapon/ks/x0.smali"),
|
||
"r1": Path("smali_classes15/com/kuaishou/weapon/ks/r1.smali"),
|
||
"i": Path("smali_classes15/com/kuaishou/weapon/ks/i.smali"),
|
||
"y0": Path("smali_classes15/com/kuaishou/weapon/ks/y0.smali"),
|
||
"zvl_c": Path("smali_classes8/zvl/c.smali"),
|
||
}
|
||
|
||
|
||
def _read_lines(path: Path) -> list[str]:
|
||
return path.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||
|
||
|
||
def _rel(path: Path, base: Path) -> str:
|
||
try:
|
||
return path.relative_to(base).as_posix()
|
||
except ValueError:
|
||
return path.as_posix()
|
||
|
||
|
||
def _line_refs(path: Path, base: Path, marker: str, *, limit: int = 20) -> list[dict[str, Any]]:
|
||
if not path.exists():
|
||
return []
|
||
refs: list[dict[str, Any]] = []
|
||
for line_no, line in enumerate(_read_lines(path), 1):
|
||
if marker in line:
|
||
refs.append(
|
||
{
|
||
"file": _rel(path, base),
|
||
"line": line_no,
|
||
"text": line.strip(),
|
||
}
|
||
)
|
||
if len(refs) >= limit:
|
||
break
|
||
return refs
|
||
|
||
|
||
def _first_line(path: Path, base: Path, marker: str) -> dict[str, Any]:
|
||
refs = _line_refs(path, base, marker, limit=1)
|
||
return refs[0] if refs else {}
|
||
|
||
|
||
def _method_bounds(lines: list[str], method_marker: str) -> tuple[int, int] | None:
|
||
start = -1
|
||
for index, line in enumerate(lines):
|
||
if method_marker in line:
|
||
start = index
|
||
break
|
||
if start < 0:
|
||
return None
|
||
end = len(lines)
|
||
for index in range(start + 1, len(lines)):
|
||
if lines[index].strip() == ".end method":
|
||
end = index + 1
|
||
break
|
||
return start, end
|
||
|
||
|
||
def _first_line_in_method(path: Path, base: Path, method_marker: str, marker: str) -> dict[str, Any]:
|
||
if not path.exists():
|
||
return {}
|
||
lines = _read_lines(path)
|
||
bounds = _method_bounds(lines, method_marker)
|
||
if bounds is None:
|
||
return {}
|
||
start, end = bounds
|
||
for index in range(start, end):
|
||
if marker in lines[index]:
|
||
return {
|
||
"file": _rel(path, base),
|
||
"line": index + 1,
|
||
"text": lines[index].strip(),
|
||
}
|
||
return {}
|
||
|
||
|
||
def _exists(path: Path) -> bool:
|
||
return path.exists() and path.is_file()
|
||
|
||
|
||
def _extract_field_string(path: Path, field_name: str) -> str:
|
||
if not path.exists():
|
||
return ""
|
||
pattern = re.compile(rf"\.field\s+public\s+static\s+{re.escape(field_name)}:.*?=\s+\"([^\"]*)\"")
|
||
for line in _read_lines(path):
|
||
match = pattern.search(line)
|
||
if match:
|
||
return match.group(1)
|
||
return ""
|
||
|
||
|
||
def _decode_base64_text(value: str) -> str:
|
||
if not value:
|
||
return ""
|
||
try:
|
||
return base64.b64decode(value).decode("utf-8")
|
||
except Exception: # noqa: BLE001 - 诊断工具保留空值即可
|
||
return ""
|
||
|
||
|
||
def _grep_tree(root: Path, marker: str, *, subdir: str = "smali_classes15/com/kuaishou/weapon", limit: int = 80) -> list[dict[str, Any]]:
|
||
start = root / subdir
|
||
if not start.exists():
|
||
return []
|
||
refs: list[dict[str, Any]] = []
|
||
for path in start.rglob("*.smali"):
|
||
try:
|
||
lines = _read_lines(path)
|
||
except OSError:
|
||
continue
|
||
for line_no, line in enumerate(lines, 1):
|
||
if marker in line:
|
||
refs.append({"file": _rel(path, root), "line": line_no, "text": line.strip()})
|
||
if len(refs) >= limit:
|
||
return refs
|
||
return refs
|
||
|
||
|
||
def _file_map(apktool_dir: Path) -> dict[str, Path]:
|
||
return {name: apktool_dir / rel for name, rel in RELATIVE_FILES.items()}
|
||
|
||
|
||
def analyze_static_chain(apktool_dir: str | Path = DEFAULT_APKTOOL_DIR) -> dict[str, Any]:
|
||
"""分析本地 smali,返回 ``passport_account_image`` 静态证据报告。"""
|
||
|
||
root = Path(apktool_dir)
|
||
if not root.is_absolute():
|
||
root = (ROOT / root).resolve()
|
||
files = _file_map(root)
|
||
|
||
encoded_fap = _extract_field_string(files["x0"], "d")
|
||
encoded_cfg_pull = _extract_field_string(files["x0"], "c")
|
||
encoded_cfg_push = _extract_field_string(files["x0"], "b")
|
||
|
||
direct_a_y_q_z_refs = _grep_tree(root, "a_y_q_z")
|
||
generic_writer_refs = _grep_tree(root, "Lcom/kuaishou/weapon/ks/z0;->a(ZLjava/lang/String;Ljava/lang/String;)V")
|
||
|
||
facts: dict[str, Any] = {
|
||
"files": {
|
||
name: {
|
||
"path": _rel(path, root),
|
||
"exists": _exists(path),
|
||
}
|
||
for name, path in files.items()
|
||
},
|
||
"weaponhi_b_upload_encrypt": {
|
||
"summary": "WeaponHI.b(str) = str.getBytes -> i0.a([B) GZIP -> MXSec.atlasEncrypt(privacykit, UUID, 0, bytes) -> Base64",
|
||
"method": _first_line(files["weaponhi"], root, ".method public static b(Ljava/lang/String;)Ljava/lang/String;"),
|
||
"gzip_call": _first_line_in_method(
|
||
files["weaponhi"],
|
||
root,
|
||
".method public static b(Ljava/lang/String;)Ljava/lang/String;",
|
||
"Lcom/kuaishou/weapon/ks/i0;->a([B)[B",
|
||
),
|
||
"gzip_impl": _first_line(files["gzip"], root, "Ljava/util/zip/GZIPOutputStream;"),
|
||
"product": _first_line_in_method(
|
||
files["weaponhi"],
|
||
root,
|
||
".method public static b(Ljava/lang/String;)Ljava/lang/String;",
|
||
'const-string v1, "privacykit"',
|
||
),
|
||
"sdk_id": _first_line_in_method(
|
||
files["weaponhi"],
|
||
root,
|
||
".method public static b(Ljava/lang/String;)Ljava/lang/String;",
|
||
'const-string v2, "7e46b28a-8c93-4940-8238-4c60e64e3c81"',
|
||
),
|
||
"atlas_encrypt": _first_line_in_method(
|
||
files["weaponhi"],
|
||
root,
|
||
".method public static b(Ljava/lang/String;)Ljava/lang/String;",
|
||
"->atlasEncrypt(Ljava/lang/String;Ljava/lang/String;I[B)[B",
|
||
),
|
||
"base64": _first_line_in_method(
|
||
files["weaponhi"],
|
||
root,
|
||
".method public static b(Ljava/lang/String;)Ljava/lang/String;",
|
||
"Lcom/kuaishou/weapon/ks/q;->c([BI)Ljava/lang/String;",
|
||
),
|
||
},
|
||
"weaponhi_dd21_read": {
|
||
"summary": "WeaponHI.dd(21) 先复用内存 img;不是 VIMG_ 时读取 SharedPreferences wcfg['a_y_q_z'] 并缓存回 img",
|
||
"method": _first_line(files["weaponhi"], root, ".method public static dd(I)Ljava/lang/String;"),
|
||
"vimg_cache_guard": _first_line_in_method(
|
||
files["weaponhi"],
|
||
root,
|
||
".method public static dd(I)Ljava/lang/String;",
|
||
'const-string v0, "VIMG_"',
|
||
),
|
||
"z0_singleton": _first_line_in_method(
|
||
files["weaponhi"],
|
||
root,
|
||
".method public static dd(I)Ljava/lang/String;",
|
||
"Lcom/kuaishou/weapon/ks/z0;->a(Landroid/content/Context;)Lcom/kuaishou/weapon/ks/z0;",
|
||
),
|
||
"wcfg_key": _first_line_in_method(
|
||
files["weaponhi"],
|
||
root,
|
||
".method public static dd(I)Ljava/lang/String;",
|
||
'const-string v0, "a_y_q_z"',
|
||
),
|
||
"z0_get_string": _first_line_in_method(
|
||
files["weaponhi"],
|
||
root,
|
||
".method public static dd(I)Ljava/lang/String;",
|
||
"Lcom/kuaishou/weapon/ks/z0;->a(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
|
||
),
|
||
},
|
||
"z0_wcfg_store": {
|
||
"summary": "z0 构造函数打开 SharedPreferences 'wcfg';a(String,String) 读取 getString;a(boolean,key,value) 是通用 putString 写入",
|
||
"open_wcfg": _first_line_in_method(
|
||
files["z0"],
|
||
root,
|
||
".method public constructor <init>(Landroid/content/Context;)V",
|
||
'const-string v0, "wcfg"',
|
||
),
|
||
"get_shared_preferences": _first_line_in_method(
|
||
files["z0"],
|
||
root,
|
||
".method public constructor <init>(Landroid/content/Context;)V",
|
||
"->getSharedPreferences(Ljava/lang/String;I)Landroid/content/SharedPreferences;",
|
||
),
|
||
"get_string": _first_line_in_method(
|
||
files["z0"],
|
||
root,
|
||
".method public a(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
|
||
"->getString(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;",
|
||
),
|
||
"generic_write_method": _first_line(files["z0"], root, ".method public a(ZLjava/lang/String;Ljava/lang/String;)V"),
|
||
"put_string": _first_line_in_method(
|
||
files["z0"],
|
||
root,
|
||
".method public a(ZLjava/lang/String;Ljava/lang/String;)V",
|
||
"SharedPreferences$Editor;->putString(Ljava/lang/String;Ljava/lang/String;)",
|
||
),
|
||
},
|
||
"passport_map_injection": {
|
||
"summary": "账号登录公共参数开关 ugAccoutNetAddRiskParam 开启时调用 WeaponHI.dd(0x15),再 put('passport_account_image', value)",
|
||
"switch": _first_line(files["zvl_c"], root, 'const-string v2, "ugAccoutNetAddRiskParam"'),
|
||
"dd21_call": _first_line(files["zvl_c"], root, "Lcom/kuaishou/weapon/i/WeaponHI;->dd(I)Ljava/lang/String;"),
|
||
"map_key": _first_line(files["zvl_c"], root, 'const-string v1, "passport_account_image"'),
|
||
"map_put": _first_line(files["zvl_c"], root, "Ljava/util/Map;->put(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;"),
|
||
},
|
||
"fap_upload": {
|
||
"summary": "x0.d 解码为 /f/a/p;r1.a 组 JSON {data: 'VIMG_'+WeaponHI.b(payload)} 后经 i/y0 POST",
|
||
"encoded_endpoint": encoded_fap,
|
||
"decoded_endpoint": _decode_base64_text(encoded_fap),
|
||
"encoded_cfg_pull_endpoint": encoded_cfg_pull,
|
||
"decoded_cfg_pull_endpoint": _decode_base64_text(encoded_cfg_pull),
|
||
"encoded_cfg_push_endpoint": encoded_cfg_push,
|
||
"decoded_cfg_push_endpoint": _decode_base64_text(encoded_cfg_push),
|
||
"x0_endpoint_line": _first_line(files["x0"], root, f'"{encoded_fap}"') if encoded_fap else {},
|
||
"r1_weaponhi_b_call": _first_line(files["r1"], root, "Lcom/kuaishou/weapon/i/WeaponHI;->b(Ljava/lang/String;)Ljava/lang/String;"),
|
||
"r1_vimg_prefix": _first_line(files["r1"], root, 'const-string v4, "VIMG_"'),
|
||
"r1_payload_data_key": _first_line(files["r1"], root, 'const-string v5, "data"'),
|
||
"r1_http_dispatch": _first_line(files["r1"], root, "Lcom/kuaishou/weapon/ks/i;->a(Landroid/content/Context;Lcom/kuaishou/weapon/ks/k1;)Ljava/lang/String;"),
|
||
"okhttp_execute": _first_line(files["i"], root, "Lokhttp3/Call;->execute()Lokhttp3/Response;"),
|
||
"urlconnection_post": _first_line(files["y0"], root, 'const-string v2, "POST"'),
|
||
},
|
||
"wcfg_writer_search": {
|
||
"summary": "静态全库只发现 dd(21) 处直接出现 a_y_q_z;写入端更像服务端响应驱动的通用 key/value 落盘,需要动态证据确认具体 value",
|
||
"direct_a_y_q_z_refs": direct_a_y_q_z_refs,
|
||
"direct_a_y_q_z_write_found": any("putString" in item["text"] for item in direct_a_y_q_z_refs),
|
||
"generic_z0_writer_refs": generic_writer_refs,
|
||
},
|
||
}
|
||
|
||
proven = {
|
||
"weaponhi_b_upload_encrypt": bool(
|
||
facts["weaponhi_b_upload_encrypt"]["method"]
|
||
and facts["weaponhi_b_upload_encrypt"]["gzip_call"]
|
||
and facts["weaponhi_b_upload_encrypt"]["atlas_encrypt"]
|
||
),
|
||
"weaponhi_dd21_reads_a_y_q_z": bool(
|
||
facts["weaponhi_dd21_read"]["method"]
|
||
and facts["weaponhi_dd21_read"]["wcfg_key"]
|
||
and facts["weaponhi_dd21_read"]["z0_get_string"]
|
||
),
|
||
"z0_uses_wcfg": bool(facts["z0_wcfg_store"]["open_wcfg"] and facts["z0_wcfg_store"]["get_string"]),
|
||
"passport_map_injection": bool(
|
||
facts["passport_map_injection"]["dd21_call"] and facts["passport_map_injection"]["map_key"]
|
||
),
|
||
"fap_upload_endpoint": facts["fap_upload"]["decoded_endpoint"] == "/f/a/p",
|
||
"direct_local_a_y_q_z_writer": bool(facts["wcfg_writer_search"]["direct_a_y_q_z_write_found"]),
|
||
}
|
||
|
||
chain = [
|
||
"r1.a(payload, context) 构造上传态 VIMG_ + WeaponHI.b(payload)",
|
||
"x0.d Base64 解码后命中 /f/a/p",
|
||
"i/y0 发送 POST 并返回响应字符串",
|
||
"wcfg 通用写入方法 z0.a(boolean,key,value) 可写任意 key",
|
||
"WeaponHI.dd(21) 读取 wcfg['a_y_q_z']",
|
||
"zvl.c 把 dd(21) 结果注入 passport_account_image",
|
||
]
|
||
|
||
unresolved = []
|
||
if not proven["direct_local_a_y_q_z_writer"]:
|
||
unresolved.append(
|
||
"未在 smali 中发现硬编码 a_y_q_z 的本地写入;最终 VIMG_...$AI_... 仍需 /f/a/p 响应或 wcfg 动态抓证。"
|
||
)
|
||
if not all(proven[key] for key in ("weaponhi_b_upload_encrypt", "weaponhi_dd21_reads_a_y_q_z", "z0_uses_wcfg", "passport_map_injection", "fap_upload_endpoint")):
|
||
unresolved.append("部分静态锚点缺失,需检查 apktool 目录是否对应当前 APP 版本。")
|
||
|
||
return {
|
||
"apktool_dir": str(root),
|
||
"facts": facts,
|
||
"proven": proven,
|
||
"chain": chain,
|
||
"pure_calc_status": {
|
||
"upload_vimg_building_block": bool(proven["weaponhi_b_upload_encrypt"]),
|
||
"final_passport_ticket": "server_or_wcfg_state",
|
||
"final_ticket_offline_proven": False,
|
||
"next_evidence": [
|
||
"抓 /f/a/p 返回体完整内容",
|
||
"抓 z0.a(boolean,key,value) 或 SharedPreferences.Editor.putString 写入 a_y_q_z 的 value_full",
|
||
"对比写入 value 与随后 mobile/checker / login body 的 passport_account_image",
|
||
],
|
||
},
|
||
"unresolved": unresolved,
|
||
}
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(description="静态整理 passport_account_image / WeaponHI.dd(21) 链路证据")
|
||
parser.add_argument("--apktool-dir", default=str(DEFAULT_APKTOOL_DIR), help="apktool 反编译目录")
|
||
parser.add_argument("--out", default="", help="可选 JSON 输出路径")
|
||
parser.add_argument("--pretty", action="store_true", help="格式化 JSON 输出")
|
||
return parser
|
||
|
||
|
||
def main() -> int:
|
||
args = build_parser().parse_args()
|
||
result = analyze_static_chain(args.apktool_dir)
|
||
text = json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None)
|
||
if args.out:
|
||
out = Path(args.out)
|
||
if not out.is_absolute():
|
||
out = ROOT / out
|
||
out.parent.mkdir(parents=True, exist_ok=True)
|
||
out.write_text(text + "\n", encoding="utf-8")
|
||
print(f"[OK] 写入 {out}")
|
||
print(text)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|