Loading...
Loading...
End-to-end pipeline to extract, decrypt, and visualize WeChat Mac favorites from encrypted SQLite DB into an interactive HTML report.
npx skill4agent add aradotso/trending-skills wx-favorites-reportSkill by ara.so — Daily 2026 Skills collection.
favorite.dbpip3 install frida frida-tools pycryptodome~/.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 testingkillall WeChat 2>/dev/null; sleep 2
cp -R /Applications/WeChat.app ~/Desktop/WeChat.app
codesign --force --deep --sign - ~/Desktop/WeChat.appNever run with— doing so changes the data directory tosudoand breaks DB path resolution./var/root/…
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) {}
}
});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:is only opened when the user navigates to the Favorites tab. If you hook before opening Favorites, the key won't appear.favorite.db
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?")# 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.dbfav_db_itemFavItemsFavDataItem# 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.jsonpython3 generate_report.py --input /tmp/data.json --output /tmp/report.htmlfile://cd /tmp && python3 -m http.server 8765
open http://localhost:8765/report.html| 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) | | 3.x used |
| Article title field | | Not |
wc -l /tmp/wechat_frida_keys.logxxd ~/…/favorite.db | head -2KDF_ITER=256000PAGE_SIZE=4096kdf_iter=64000-codesign --force --deep --sign - ~/Desktop/WeChat.apponerror"img_tag = f'<img src="{url}" onerror="this.style.display="none"">'file://onclickdocument.getElementById("card-list").addEventListener("click", function(e) {
var card = e.target.closest(".fav-card");
if (card) showDetail(card.dataset.id);
});libcommonCrypto.dylib| 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 |
tagNames