skills/aradotso/trending-skills/wx-favorites-report

wx-favorites-report

Installation
SKILL.md

wx-favorites-report

Skill by ara.so — Daily 2026 Skills collection.

End-to-end pipeline that hooks into the WeChat Mac client via Frida, extracts PBKDF2-derived encryption keys, decrypts the favorite.db SQLCipher database, parses XML-encoded favorites, and renders a single-file interactive HTML report with charts, word cloud, and filterable card browser.


Prerequisites

  • macOS (Apple Silicon or Intel)
  • WeChat Mac 4.x installed and logged in
  • Python 3.9+
  • Frida 17.x
pip3 install frida frida-tools pycryptodome

Project Layout

~/.claude/skills/wechat-favorites-viz/
├── SKILL.md
└── scripts/
    ├── parse_favorites.py      # SQLite/CSV/JSON → unified JSON
    ├── generate_report.py      # JSON → single-file HTML
    └── demo_data.py            # synthetic data for testing

Full Pipeline (Step-by-Step)

Step 1 — Strip Hardened Runtime from WeChat

The App Store build blocks Frida injection. Copy and re-sign without entitlements:

killall WeChat 2>/dev/null; sleep 2
cp -R /Applications/WeChat.app ~/Desktop/WeChat.app
codesign --force --deep --sign - ~/Desktop/WeChat.app

Never run with sudo — doing so changes the data directory to /var/root/… and breaks DB path resolution.

Step 2 — Hook PBKDF2 with Frida

Save as hook_wechat.js:

// hook_wechat.js — capture all CCKeyDerivationPBKDF calls
var CCKeyDerivationPBKDF = Module.findExportByName(
  "libcommonCrypto.dylib",
  "CCKeyDerivationPBKDF"
);

Interceptor.attach(CCKeyDerivationPBKDF, {
  onEnter: function (args) {
    // args[3] = password ptr, args[4] = password len
    // args[5] = salt ptr,     args[6] = salt len
    // args[9] = iterations
    // args[10]= derived key ptr, args[11]= derived key len
    this.saltPtr = args[5];
    this.saltLen = args[6].toInt32();
    this.dkPtr   = args[10];
    this.dkLen   = args[11].toInt32();
  },
  onLeave: function (_retval) {
    try {
      var salt = Memory.readByteArray(this.saltPtr, this.saltLen);
      var dk   = Memory.readByteArray(this.dkPtr,   this.dkLen);
      var entry = {
        salt: Array.from(new Uint8Array(salt))
              .map(b => b.toString(16).padStart(2, "0")).join(""),
        key:  Array.from(new Uint8Array(dk))
              .map(b => b.toString(16).padStart(2, "0")).join(""),
        ts:   Date.now()
      };
      var line = JSON.stringify(entry) + "\n";
      // Write to log via send()
      send(line);
    } catch (e) {}
  }
});

Run the hook:

frida ~/Desktop/WeChat.app/Contents/MacOS/WeChat \
  -l hook_wechat.js \
  --runtime=v8 \
  2>/dev/null | tee /tmp/wechat_frida_keys.log &

# WeChat will launch — log in, then open the 收藏 (Favorites) tab.
# Wait ~60 seconds for all DB keys to be derived, then Ctrl+C.

Key insight: favorite.db is only opened when the user navigates to the Favorites tab. If you hook before opening Favorites, the key won't appear.

Step 3 — Match Key to favorite.db

# match_key.py
import json, sqlite3, pathlib

LOG = pathlib.Path("/tmp/wechat_frida_keys.log")
DB  = pathlib.Path.home() / (
    "Library/Containers/com.tencent.xinWeChat/Data/Documents/"
    "xwechat_files"
)

def find_db(wxid=None):
    """Locate favorite.db under the first (or named) wxid folder."""
    root = DB
    candidates = sorted(root.glob("*/db_storage/favorite/favorite.db"))
    if not candidates:
        raise FileNotFoundError("favorite.db not found")
    if wxid:
        return next(p for p in candidates if wxid in str(p))
    return candidates[0]

def read_salt(db_path: pathlib.Path) -> bytes:
    """First 16 bytes after the 16-byte SQLCipher header = salt."""
    with open(db_path, "rb") as f:
        f.read(16)               # skip "SQLite format 3\x00"
        return f.read(16)        # salt

def match(db_path: pathlib.Path) -> str | None:
    salt_hex = read_salt(db_path).hex()
    for line in LOG.read_text().splitlines():
        try:
            entry = json.loads(line)
            if entry["salt"] == salt_hex:
                return entry["key"]
        except Exception:
            continue
    return None

if __name__ == "__main__":
    db = find_db()
    key = match(db)
    if key:
        print(f"enc_key (hex): {key}")
        print(f"db path      : {db}")
    else:
        print("Key not found — did you open the Favorites tab while Frida was running?")

Step 4 — Decrypt the Database

# decrypt_db.py
"""
SQLCipher 4 parameters:
  cipher       : AES-256-CBC
  hmac         : HMAC-SHA512
  kdf_iter     : 256000
  page_size    : 4096
  reserve      : 80  (64 HMAC + 16 IV)
"""
import hashlib, hmac, struct, pathlib
from Crypto.Cipher import AES

PAGE_SIZE = 4096
RESERVE   = 80
IV_SIZE   = 16
HMAC_SIZE = 64
KDF_ITER  = 256000

def decrypt_db(enc_path: pathlib.Path, key_hex: str, out_path: pathlib.Path):
    raw_key = bytes.fromhex(key_hex)
    data    = enc_path.read_bytes()

    # SQLCipher stores salt in first 16 bytes of file
    salt = data[:16]

    # Derive page key and HMAC key
    page_key  = hashlib.pbkdf2_hmac("sha512", raw_key, salt, KDF_ITER, dklen=32)
    hmac_key  = hashlib.pbkdf2_hmac("sha512", page_key, salt, 1, dklen=32)

    out_pages = bytearray()

    # Page 1: skip 16-byte salt header
    pages = [data[16:PAGE_SIZE]] + [
        data[i:i+PAGE_SIZE] for i in range(PAGE_SIZE, len(data), PAGE_SIZE)
    ]

    for page_num, page in enumerate(pages, start=1):
        content  = page[:PAGE_SIZE - RESERVE]
        reserved = page[PAGE_SIZE - RESERVE:]
        iv       = reserved[HMAC_SIZE:HMAC_SIZE + IV_SIZE]

        cipher    = AES.new(page_key, AES.MODE_CBC, iv)
        plaintext = cipher.decrypt(content)

        if page_num == 1:
            # Restore SQLite header
            out_pages += b"SQLite format 3\x00" + plaintext[16:]
        else:
            out_pages += plaintext

        # Zero-pad to full page size
        out_pages += bytes(RESERVE)

    out_path.write_bytes(bytes(out_pages))
    print(f"Decrypted → {out_path}")

if __name__ == "__main__":
    import sys
    enc_path = pathlib.Path(sys.argv[1])
    key_hex  = sys.argv[2]
    out_path = pathlib.Path(sys.argv[3])
    decrypt_db(enc_path, key_hex, out_path)
python3 decrypt_db.py \
    ~/Library/Containers/.../favorite.db \
    <32-byte-key-hex> \
    /tmp/favorite_decrypted.db

Step 5 — Parse Favorites

WeChat 4.x uses a single table fav_db_item with XML content (not the 3.x FavItems/FavDataItem split):

# parse_favorites.py (core logic excerpt)
import sqlite3, json, re, pathlib
from datetime import datetime
from xml.etree import ElementTree as ET

TYPE_MAP = {
    1: "text", 2: "image", 3: "voice", 4: "video",
    5: "playlist", 6: "location", 7: "attachment",
    8: "article", 43: "video_channel", 49: "link",
}

def parse_xml_content(xml_str: str, fav_type: int) -> dict:
    """Extract title, desc, source, url from XML blob."""
    result = {"title": "", "desc": "", "source": "", "url": ""}
    if not xml_str:
        return result
    try:
        root = ET.fromstring(xml_str)
    except ET.ParseError:
        return result

    def txt(tag):
        el = root.find(f".//{tag}")
        return el.text.strip() if el is not None and el.text else ""

    if fav_type == 8:           # article — WeChat 4.x uses <pagetitle>
        result["title"]  = txt("pagetitle") or txt("title")
        result["url"]    = txt("url")
        result["source"] = txt("sourcename") or txt("fromnickname")
        result["desc"]   = txt("desc")
    elif fav_type == 49:        # link
        result["title"]  = txt("title")
        result["url"]    = txt("url")
        result["source"] = txt("sourcename")
        result["desc"]   = txt("desc")
    elif fav_type in (3, 4, 43):  # voice/video
        for item in root.findall(".//dataitem"):
            t = item.findtext("datatitle", "").strip()
            if t:
                result["title"] = t
                break
        result["source"] = txt("fromnickname")
    else:                       # text, image, etc.
        result["title"]  = txt("title") or txt("pagetitle")
        result["desc"]   = txt("desc") or txt("content")
        result["source"] = txt("fromnickname")

    return result

def parse(db_path: pathlib.Path) -> list[dict]:
    con = sqlite3.connect(db_path)
    con.row_factory = sqlite3.Row
    rows = con.execute(
        "SELECT localId, favLocalId, type, createTime, updateTime, "
        "       xmlBuf, tagNames "
        "FROM   fav_db_item "
        "ORDER  BY createTime"
    ).fetchall()

    items = []
    for row in rows:
        parsed = parse_xml_content(row["xmlBuf"] or "", row["type"])
        items.append({
            "id":         row["localId"],
            "type":       TYPE_MAP.get(row["type"], f"unknown_{row['type']}"),
            "created_at": datetime.utcfromtimestamp(row["createTime"]).isoformat(),
            "updated_at": datetime.utcfromtimestamp(row["updateTime"]).isoformat(),
            "title":      parsed["title"],
            "desc":       parsed["desc"],
            "source":     parsed["source"],
            "url":        parsed["url"],
            "tags":       [t.strip() for t in (row["tagNames"] or "").split(",") if t.strip()],
        })
    con.close()
    return items

if __name__ == "__main__":
    import sys
    db   = pathlib.Path(sys.argv[1])
    out  = pathlib.Path(sys.argv[2])
    data = parse(db)
    out.write_text(json.dumps(data, ensure_ascii=False, indent=2))
    print(f"Parsed {len(data)} items → {out}")
python3 parse_favorites.py /tmp/favorite_decrypted.db /tmp/data.json

Step 6 — Generate HTML Report

python3 generate_report.py --input /tmp/data.json --output /tmp/report.html

Serve locally (required — file:// breaks ECharts event delegation):

cd /tmp && python3 -m http.server 8765
open http://localhost:8765/report.html

Key Configuration Reference

Parameter Value Notes
SQLCipher version 4 WeChat 4.x
Cipher AES-256-CBC
HMAC HMAC-SHA512
KDF iterations 256 000 PBKDF2
Page size 4 096 bytes
Reserve per page 80 bytes 64 HMAC + 16 IV
Salt location bytes 0–15 of file
Table name (4.x) fav_db_item 3.x used FavItems
Article title field <pagetitle> Not <title>

Common Issues & Fixes

"Key not found in log"

  1. Confirm you opened the 收藏 tab while Frida was attached.
  2. Check log for any entries: wc -l /tmp/wechat_frida_keys.log
  3. Salt mismatch — re-read salt: xxd ~/…/favorite.db | head -2 (bytes 16–31 after the ASCII header).

"database disk image is malformed"

Decryption parameters are wrong. Double-check KDF_ITER=256000 and PAGE_SIZE=4096. If WeChat updated, parameters may have changed — try kdf_iter=64000 (SQLCipher 3 default) as a fallback.

"codesign: No identity found"

Use - (ad-hoc signing), not a certificate name:

codesign --force --deep --sign - ~/Desktop/WeChat.app

Report images broken

Thumbnail URLs are WeChat CDN links — they require an active network session. Add onerror handler using &quot; to avoid quote conflicts in inline HTML:

img_tag = f'<img src="{url}" onerror="this.style.display=&quot;none&quot;">'

onclick not firing on file://

Use event delegation on a parent element instead of inline onclick:

document.getElementById("card-list").addEventListener("click", function(e) {
  var card = e.target.closest(".fav-card");
  if (card) showDetail(card.dataset.id);
});

WeChat updated — hook stopped working

Re-copy and re-sign the app bundle, then re-run the full pipeline. The PBKDF2 hook targets a system library (libcommonCrypto.dylib) so it is resilient to WeChat binary changes, but the re-signing step must be repeated.


Report Features

Section Chart type
Summary cards Static KPI tiles
Monthly trend ECharts line + area
Type distribution ECharts doughnut
Top 15 sources ECharts horizontal bar
Activity heatmap ECharts heatmap (weekday × hour)
Word cloud echarts-wordcloud
Tag cloud CSS flex tags
Favorites browser Card grid with type/tag filter + full-text search + pagination
Detail modal Full content, URL, source, tags

Known Limitations

  • Image/video/file binary blobs are stored in WeChat's encrypted CDN — not previewable offline.
  • Key extraction requires macOS + Frida; no Windows/Linux support.
  • After each WeChat update, the Desktop copy must be re-signed.
  • The tagNames column stores comma-separated tag strings; empty tags are filtered client-side.
Weekly Installs
159
GitHub Stars
39
First Seen
Today