wechat_export_all.py 11.3 KB
#!/usr/bin/env python3
"""
WeChat 全库解密导出工具
步骤:
  1. Frida hook 持续捕获 AES key
  2. 每个新 key 试匹配所有未解锁的数据库
  3. 匹配成功 → 立即用 sqlcipher_export() 导出为明文 SQLite
  4. 所有库解完 or 超时后退出

用法:
  python3 ~/Downloads/wechat_export_all.py

输出:
  ~/Documents/wechat_plain/  目录下各数据库的明文副本
"""

import sys, os, time, subprocess, threading, shutil

# ── 路径 ──────────────────────────────────────────────────────────────
sys.path.insert(0, "/Users/yibo/Library/Python/3.9/lib/python/site-packages")
import frida

DB_BASE   = "/Users/yibo/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/yibooo_ac07/db_storage"
OUT_DIR   = "/Users/yibo/Documents/wechat_plain"
KEY_CACHE = "/Users/yibo/Documents/wechat_plain/keys.txt"
SQLCIPHER = "/opt/homebrew/bin/sqlcipher"

# ── 所有数据库(相对 DB_BASE)──────────────────────────────────────────
ALL_DBS = [
    # 消息(按大小降序,优先处理)
    "message/message_1.db",
    "message/message_2.db",
    "message/message_3.db",
    "message/message_4.db",
    "message/message_5.db",
    "message/message_6.db",
    "message/message_7.db",
    "message/message_8.db",
    "message/message_9.db",
    "message/message_10.db",
    "message/message_0.db",
    "message/message_fts.db",
    "message/media_0.db",
    "message/media_1.db",
    "message/message_resource.db",
    "message/biz_message_0.db",
    "message/biz_message_1.db",
    # 联系人
    "contact/contact.db",
    "contact/contact_fts.db",
    # 会话
    "session/session.db",
    # 其他
    "sns/sns.db",
    "general/general.db",
    "favorite/favorite.db",
    "favorite/favorite_fts.db",
    "head_image/head_image.db",
    "hardlink/hardlink.db",
    "bizchat/bizchat.db",
    "emoticon/emoticon.db",
    "migrate/unspportmsg.db",
    "solitaire/solitaire.db",
]

os.makedirs(OUT_DIR, exist_ok=True)

# ── 已知 key(上次运行结果)────────────────────────────────────────────
known_keys = {}          # hex_key → set of verified db relpath
pending_dbs = list(ALL_DBS)   # 还没匹配到 key 的数据库
exported_dbs = set()

def load_key_cache():
    """从缓存文件加载已知 key"""
    if not os.path.exists(KEY_CACHE):
        return
    with open(KEY_CACHE) as f:
        for line in f:
            line = line.strip()
            if ':' in line:
                relpath, key = line.split(':', 1)
                known_keys[key] = known_keys.get(key, set())
                known_keys[key].add(relpath)
                if relpath in pending_dbs:
                    pending_dbs.remove(relpath)
    if known_keys:
        print(f"[cache] 加载 {len(known_keys)} 个已知 key,{len(pending_dbs)} 个库待解")

def save_key_cache():
    with open(KEY_CACHE, 'w') as f:
        for key, dbs in known_keys.items():
            for db in dbs:
                f.write(f"{db}:{key}\n")

# ── sqlcipher 验证 ──────────────────────────────────────────────────────
def try_open(key_hex, relpath):
    """返回 True 表示 key 能打开这个数据库"""
    db_path = os.path.join(DB_BASE, relpath)
    if not os.path.exists(db_path):
        return False
    r = subprocess.run(
        [SQLCIPHER, db_path],
        input=f"PRAGMA key = \"x'{key_hex}'\";\nSELECT count(*) FROM sqlite_master;\n",
        capture_output=True, text=True, timeout=15
    )
    lines = [l for l in r.stdout.split('\n') if l.strip() and l.strip() != 'ok']
    return bool(lines) and lines[0].strip().isdigit()

# ── 导出函数 ────────────────────────────────────────────────────────────
export_lock = threading.Lock()

def export_db(key_hex, relpath):
    """用 sqlcipher_export 把加密库导出为明文 SQLite"""
    src  = os.path.join(DB_BASE, relpath)
    name = relpath.replace('/', '_')
    dst  = os.path.join(OUT_DIR, name)
    tmp  = dst + '.tmp'

    # 跳过已导出
    if relpath in exported_dbs:
        return
    if os.path.exists(dst):
        sz = os.path.getsize(dst)
        if sz > 4096:
            print(f"  [skip] {name} 已存在 ({sz//1024//1024} MB)")
            exported_dbs.add(relpath)
            return

    src_sz = os.path.getsize(src)
    print(f"\n  [export] {name} ({src_sz//1024//1024} MB) → {dst}")
    t0 = time.time()

    sql = "\n".join([
        f"PRAGMA key = \"x'{key_hex}'\";",
        "PRAGMA cipher_page_size = 4096;",
        f"ATTACH DATABASE '{tmp}' AS plain KEY '';",
        "SELECT sqlcipher_export('plain');",
        "DETACH DATABASE plain;",
    ])

    r = subprocess.run(
        [SQLCIPHER, src],
        input=sql, capture_output=True, text=True, timeout=600
    )

    elapsed = time.time() - t0
    if r.returncode == 0 and os.path.exists(tmp) and os.path.getsize(tmp) > 4096:
        os.rename(tmp, dst)
        out_sz = os.path.getsize(dst)
        print(f"  [✓] {name}: {src_sz//1024//1024} MB → {out_sz//1024//1024} MB 明文  ({elapsed:.1f}s)")
        exported_dbs.add(relpath)
    else:
        if os.path.exists(tmp):
            os.remove(tmp)
        err = r.stderr.strip()[:200]
        print(f"  [✗] {name}: 导出失败  {err}")

def match_key(key_hex):
    """用新 key 尝试匹配所有待解数据库,匹配成功后后台导出"""
    global pending_dbs
    matched = []
    for relpath in list(pending_dbs):
        try:
            if try_open(key_hex, relpath):
                matched.append(relpath)
        except Exception:
            pass

    if matched:
        known_keys[key_hex] = known_keys.get(key_hex, set()) | set(matched)
        for relpath in matched:
            pending_dbs.remove(relpath)
        save_key_cache()
        print(f"\n[key] {key_hex[:16]}... → 匹配 {len(matched)} 个库: {matched}")
        # 后台并发导出
        for relpath in matched:
            t = threading.Thread(target=export_db, args=(key_hex, relpath), daemon=True)
            t.start()
    return matched

# ── 对已知 key 立即导出 ─────────────────────────────────────────────────
def export_known():
    threads = []
    for key_hex, dbs in list(known_keys.items()):
        for relpath in dbs:
            t = threading.Thread(target=export_db, args=(key_hex, relpath), daemon=True)
            t.start()
            threads.append(t)
    return threads

# ── Frida 主逻辑 ─────────────────────────────────────────────────────────
WECHAT_APP = '/Applications/微信.app/Contents/MacOS/WeChat'
WECHAT_BUNDLE = 'com.tencent.xinWeChat'

def make_on_msg():
    def on_msg(msg, data):
        if msg['type'] != 'send': return
        p = msg['payload']
        if p.get('type') == 'ready':
            hooks = []
            if p.get('cc1'): hooks.append('CCCryptorCreate')
            if p.get('cc2'): hooks.append('CCCryptorCreateWithMode')
            if p.get('cc3'): hooks.append('CCCrypt')
            print(f"[Frida] hooks active: {', '.join(hooks) if hooks else '(none!)'}")
        elif p.get('type') == 'key':
            key_hex = p['hex']
            via = p.get('via', '?')
            print(f"\n[key captured via {via}] {key_hex[:16]}...")
            match_key(key_hex)
    return on_msg

def run_frida_earlybird():
    """关掉微信,等用户手动打开后立即 attach(earlybird 模式)"""
    existing = subprocess.run(['pgrep', '-x', 'WeChat'],
                              capture_output=True, text=True).stdout.strip()
    if existing:
        print(f"[*] 关闭现有 WeChat (PID={existing.split()[0]})...")
        subprocess.run(['killall', 'WeChat'], capture_output=True)
        time.sleep(2)

    print("[*] 请手动打开微信.app(双击 Dock 图标)")
    print("    脚本会在进程出现时立即 attach...\n")

    pid = None
    for _ in range(120):
        out = subprocess.run(['pgrep', '-x', 'WeChat'],
                             capture_output=True, text=True).stdout.strip()
        if out:
            pid = int(out.split('\n')[0])
            break
        time.sleep(0.5)

    if not pid:
        print("[!] 2 分钟内未检测到 WeChat,退出")
        return None

    print(f"[*] WeChat PID={pid},立即 attach...")
    js_code = open('/Users/yibo/Downloads/wechat_key_hunter.js').read()
    session = frida.attach(pid)
    script = session.create_script(js_code)
    script.on('message', make_on_msg())
    script.load()
    print("[!] Hook 已就绪,请在微信里操作:")
    print("    · 滚动浏览聊天列表(触发各消息分片)")
    print("    · 打开朋友圈、收藏、搜索、表情、公众号")
    print("    · 按 Ctrl+C 停止监听并等待导出完成\n")
    return session

# ── 主程序 ──────────────────────────────────────────────────────────────
if __name__ == '__main__':
    print("=" * 60)
    print("WeChat 全库解密导出工具")
    print(f"输出目录: {OUT_DIR}")
    print("=" * 60)

    load_key_cache()

    # 先导出已知 key 对应的数据库
    threads = export_known()

    # 启动 Frida 捕获新 key
    if pending_dbs:
        print(f"\n[*] 还有 {len(pending_dbs)} 个库需要 key:")
        for db in pending_dbs:
            print(f"      {db}")

        session = run_frida_earlybird()
        if session:
            try:
                while pending_dbs:
                    time.sleep(2)
                    remaining = len(pending_dbs)
                    if remaining:
                        print(f"\r[待解] {remaining} 个库还需 key: "
                              f"{', '.join(db.split('/')[-1] for db in pending_dbs[:3])}"
                              f"{'...' if remaining > 3 else ''}   ", end='', flush=True)
            except KeyboardInterrupt:
                print("\n\n[*] 停止监听")
            finally:
                session.detach()
    else:
        print("\n[*] 所有 key 已从缓存加载,直接开始导出")

    # 等待所有导出线程完成
    print("\n[*] 等待导出任务完成...")
    active = threading.active_count()
    while threading.active_count() > 1:
        done = len(exported_dbs)
        total = len(ALL_DBS) - len(pending_dbs)
        print(f"\r    已完成 {done}/{total} 个数据库...  ", end='', flush=True)
        time.sleep(2)

    print(f"\n\n{'='*60}")
    print(f"完成!导出 {len(exported_dbs)} 个数据库")
    print(f"输出目录: {OUT_DIR}")
    print(f"{'='*60}")

    # 统计输出文件大小
    total_size = 0
    for f in os.listdir(OUT_DIR):
        if f.endswith('.db'):
            sz = os.path.getsize(os.path.join(OUT_DIR, f))
            total_size += sz
            print(f"  {f:<45} {sz//1024//1024:>5} MB")
    print(f"\n  总计: {total_size//1024//1024} MB")