Wx Favorites Report
End-to-end pipeline to extract, decrypt, and visualize WeChat Mac favorites from encrypted SQLite DB into an interactive HTML report.
- Skill ID
- reason-machines/trending-skills/wx-favorites-report
- Publisher
- reason-machines
- Repository
- trending-skills
- Installs
- 0
- Files
- 1
- Synced
- Sep 23, 2026
Open any RiverX project, open the Skills panel in the chat, and search for this identifier. The files are fetched from the source repository at install time.
reason-machines/trending-skills/wx-favorites-reportInstalls these files- SKILL.md
What this skill tells the agent
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 pycryptodomeProject 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 testingFull 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.appNever 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.dbStep 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"] = tMore from reason-machines
Use this skill in a project
Open any RiverX project, search the Skills panel for reason-machines/trending-skills/wx-favorites-report, and install it in one click.
