状态:维护中 · 角色:基于 OhMyMeme 定制(独立开发) · 代码:公开 · 最后更新:2026-11

先说清楚:这是个 fork

MemeCollector 不是我从零写的。它基于 OhMyMeme(桌面,Python + Vue + pywebview)和 OhMyMeme-Android(Kotlin),两个上游都是 GPL-3.0,我按 GPL-3.0 分发,原始署名和许可证都随源码保留。上游固定在两个提交上(见仓库 upstream-lock.json),不是追着最新分支跑。

上游已经有的:整体框架、云同步、插件系统、GIF 隐写那一套。我加的:

  • Windows:候选勾选、惰性下载(预览或保存时才下原文件)、增量索引、严格格式校验、失败清理
  • Android:过滤与动图预览、结果分类(成功/重复/不支持/超限/失败)、权限引导、有界读取、独立包名和签名
  • 两端:默认本地、不自动装上游更新;构建脚本和测试
  • 以及下面要讲的那次安全审查,10 项修复

把这条边界写在最前面,是因为后面所有「我做了」都只指上面这些,不指上游本来就有的东西。

想解决什么

QQ 和微信里看到喜欢的表情,想存下来,结果常常是:动图存成静图,截图丢了动画,或者干脆存不下来。我要的是原文件——GIF 还是那个 GIF,动态 WebP 还是那个动态 WebP,不抽帧、不重编码。

还有一条隐私上的取舍:工具是主动选择后保存,你勾选哪个它存哪个,不持续读取全部聊天。微信那边甚至要你先在电脑端打开表情面板,它才去读清单。

原字节这件事

听起来简单,做起来全是坑。

按内容识别,不信扩展名。 缓存里的文件扩展名经常是错的。我在 vivo X100 Ultra(Android 16)上实测,一个原名 .jpg 的文件,按内容识别出来其实是 240×240、28 帧的 GIF,保存后逐字节和 SHA-256 都对得上。要是信了扩展名,这张动图就被当静图处理了。

不支持的就说不支持。 微信商城的专有格式、QQ 超级表情、解不开的文件,明确报「不支持」,而不是只存个首帧伪装成功。这条我觉得比多支持几种格式更重要——假装成功比失败更糟。

去重和增量。 SHA-256 去重;失败不登记成「已收集」,重扫能恢复;单个文件失败不影响别的。实测保存那一张 GIF 后,未保存候选从 755 降到 754。

默认导入上限 20 MiB、最长边 2560px(安卓为了控内存保持这个上限,桌面可调)。缩略图和原文件分开存。

微信的密钥从哪来

微信的表情库是个加密的 SQLite,密钥不在文件里,在微信进程的内存里。所以桌面端带了个 C++ 小 helper 做进程内存取证(只读,不联网,不写微信任何东西)。

取密钥的主路径是 mask recovery:表情库文件头 16 字节是已知的 salt,内存里有个 99 字节、被 32 字节 XOR mask 遮住的密钥缓冲,用 salt 就能把 mask 反推出来。好处是不依赖 RVA 偏移,微信换版本不用重新找偏移(旧的 RVA 扫描留作兜底)。SHA-512 这些原语我自己写在树里,不引 OpenSSL。

几个我比较在意的细节:

  • helper 的 SHA-256 在构建期钉死——MSVC 构建不可复现(嵌时间戳),所以脚本编译完算实际产物的哈希、写回源码、再打包,而不是手工提交一个哈希。校验 fail-closed:没配真实哈希就拒绝运行。
  • 测试用的 --key 参数(直接注入密钥跳过取证)在 release 里整个编译掉,只在显式开 CMake 选项时才存在。
  • 密钥不写进采集索引,也不发给云服务。Python 侧 90 秒超时兜底。

(这里只讲设计,不写怎么一步步操作——它是给我自己备份表情用的,不是教程。)

安卓这边:Shizuku 和批量 stat

安卓直读手机 QQ 缓存靠 Shizuku,不是 root。两个具体的坑:

  • 逐文件起 shell 太慢。 改成批量 stat 后,ADB 实测 756 个文件的大小/mtime/路径读取约 0.22 秒;最终签名 APK 从启动到列出 754 个未保存候选约 3 秒。
  • 远程进程等待的兼容性。 Shizuku 远程进程用 Java 默认超时轮询会触发 process hasn't exited,加了回归用例防它再犯。

没有 Shizuku 也能用:文件/相册导入、接收系统图片分享、电脑采集后传回手机、桌面 ADB 拉取,都是不依赖手机 Shizuku 的入口。Shizuku 只是「直读 QQ 缓存」这一条路的硬依赖,不是整个应用的前提。

给自己的 fork 做安全审查

这是我觉得最值得写的部分。fork 继承了上游的代码,也继承了上游的假设。要发布、要给别人用,就得自己把这些假设过一遍。我对着主要数据路径(QQ/Shizuku、导入与原文件保存、存储迁移、备份恢复、云同步、双端 LAN)审了一次,找出 4 个 P1、6 个 P2,挑几个说:

  • 拒绝设备后,LAN 数据接口还开着。 设备确认那段不管用户批准、拒绝还是超时,最后都 confirmed.set(),后续请求只查这个事件、不查 approved。官方安卓客户端收到拒绝会主动断开,但别的客户端可以继续拉数据。修成只有明确批准才授权,拒绝/超时立即断开。
  • 空密钥等于全零 AES key,还接受重放帧。 空密码派生成全零 32 字节,同网段谁都能解;就算设了密码,会话密钥也只由密码和固定 salt 派生,不绑定本次握手随机数,帧没有序号——我把同一个加密帧连送两次,两次都被接受。LAN v2 改成强制非空密钥、双方随机数派生方向密钥、带认证序号,拒绝重复/乱序帧。
  • 备份恢复先删后写,没回滚。 原来只要 ZIP 里有个叫 memes.db 的成员就敢覆盖现有库,先删光目标再逐个复制,中途失败就两头空。改成先校验备份格式/数据库完整性/引用媒体,完整写入新位置、可回滚提交,成功之前旧数据不动。
  • 云清单路径穿越。 批量拉取把清单里的文件名直接交给 createFile,../memes.db 这种能写到缓存外。在清单入口和写文件层各加一道约束。

10 项全部修了。修完桌面 pytest 552 passed、1 skipped,安卓 JVM 84 项全过,模拟器导入与恢复安全 10 项全过,独立 LAN 双端动画往返 1 项过。

但验证记录里我把话说死了:模拟器不等于真机,USB TCP 转发不等于 Wi-Fi 发现。 真实 Shizuku 启停、厂商 SAF 的失效行为、真实 Wi-Fi 下的 UDP 自动发现和防火墙,都还没在真机上补测。而且这 10 项修复目前在 codex/audit-hardening 分支,还没出安装包——v0.1.0 是已发布的版本,审查修复是下一版的事。

局限

  • Android 11 实机、Wi-Fi 自动发现、防火墙环境、手机微信新收藏到电脑再传回的完整往返,都还没补测
  • 微信商城专有表情、QQ 超级表情不支持,也不承诺后续客户端版本
  • 微信取密钥依赖特定客户端的内存布局,微信大版本更新可能让它失效
  • fork 的维护成本是实打实的:上游更新得重新合并,不能直接覆盖
  • 仅个人自用,请遵守 QQ/微信和所在平台的规定
查看源码