Commit 1c2bff36740cba716dad3d9b5e5bb2f93f0d4267
1 parent
7f20b54e
wip(wechat-spoof): 群 @ 红点(atuserlist)逆向脚手架 — 未成,搁置
红点通知需 atuserlist,深逆向未到底,跟原生引用气泡一起后面做。已确认: - 纯文字@不触发红点;atuserlist 不在 message 行(convertTo cv 无此列) - r0 第5参 obj≠msgSource(当它传会 NPE g9.q5,obj 是"消息key查f9") - 微信 UI 发送链路≠我们合成的 r0+z2.b:hook z2 自学习扫 atuserlist 字段零命中 已部署脚手架(WIP,生产不传 at 无影响,自动回复照常): - SEND_MSG_INTERNAL 支持 --es at → 构造 msgsource - trySendWithRetry/sendViaHighLevel 加 msgSource 参数 - hookAtDiscovery 自学习 z2 派发点 + sAtFieldPath 持久化(目前学不到) 详见 memory wechat-group-at-atuserlist。⚠️ 重编译 hook 会强退微信=要重登, 继续挖前一次想全要抓什么。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
2 changed files
with
157 additions
and
10 deletions
wechat_plain/scripts/auto-reply/README.md
| ... | ... | @@ -92,8 +92,8 @@ python wechat_reply_orchestrator.py --drain # 手动跑一轮编排 |
| 92 | 92 | |
| 93 | 93 | ## 待收尾(都非阻塞,系统已可用) |
| 94 | 94 | |
| 95 | -1. **群回复 @ 触发者**:✅ **文字前缀已做(2026-07-12)**——群消息回复自动加 `@昵称 `(U+2005=微信原生分隔符),`process_event` 里确定性拼接(不靠 LLM)。⚠️ 仅**文字 @**(看得见),**不带 `atuserlist`** → 未必真 ping 对方红点「有人@我」;真通知要给 `SEND_MSG_INTERNAL` hook 加 at 列表 + 逆向微信发送 API 的 at 字段(大工程,待定)。 | |
| 96 | -2. **原生引用气泡**:现发纯文本;微信原生引用(A11y 长按→引用)是环节5 增强。 | |
| 95 | +1. **群回复 @ 触发者**:✅ **文字前缀已做(2026-07-12)**——群消息回复自动加 `@昵称 `(U+2005=微信原生分隔符),`process_event` 里确定性拼接(不靠 LLM)。⚠️ 仅**文字 @**(看得见),**红点通知(atuserlist)未成,搁置**(跟下面引用气泡一起后面做)。逆向进度/坑见 memory `wechat-group-at-atuserlist`:atuserlist 不在 message 行、在微信 UI 发送链路(≠我们合成的 r0+z2.b),self-learning z2.b hook 没抓到;WeChatHook 里已有 `--es at` + 自学习脚手架(WIP,生产不传 at 无影响)。⚠️ 重编译 hook 会强退微信=要重登。 | |
| 96 | +2. **原生引用气泡**:现发纯文本;微信原生引用(A11y 长按→引用)是环节5 增强。**跟上面红点@一起做**(都涉及微信发送链路逆向)。 | |
| 97 | 97 | 3. **日志轮转**:watcher/orchestrator log 24/7 会长(ponytail 已标)。 |
| 98 | 98 | 4. **lifelog 人物增强**(二期):回链 `com.lemon.lifelog` 的 Person 库让回复带人物记忆。 |
| 99 | 99 | 5. ~~auto_send 开关搬进 viewer UI~~ ✅ **已做(2026-07-12)**:`/whitelist` 页顶部开关卡片,`GET/POST /api/auto_send` 读写 `policy_config.json`。 | ... | ... |
xhs_lsposed_a16_fix/wechathook_src/WeChatHook.java
| ... | ... | @@ -74,6 +74,11 @@ public class WeChatHook implements IXposedHookLoadPackage { |
| 74 | 74 | private static volatile String sPendingText = null; |
| 75 | 75 | private static volatile String sPendingProc = null; |
| 76 | 76 | |
| 77 | + // ── 群 @ 自学习(atuserlist):从微信自己发的真 @ 消息学到 r0 里 msgSource 存哪个字段 ── | |
| 78 | + private static volatile String sAtFieldPath = null; // "field" 或 "field.subField" | |
| 79 | + private static volatile boolean sAtLoaded = false; | |
| 80 | + private static final String AT_FIELD_FILE = "/data/data/com.tencent.mm/files/.at_field"; | |
| 81 | + | |
| 77 | 82 | @Override |
| 78 | 83 | public void handleLoadPackage(XC_LoadPackage.LoadPackageParam lpparam) throws Throwable { |
| 79 | 84 | if (!"com.tencent.mm".equals(lpparam.packageName)) return; |
| ... | ... | @@ -3059,6 +3064,7 @@ public class WeChatHook implements IXposedHookLoadPackage { |
| 3059 | 3064 | hookF9ConvertTo(sWeChatCL, procName); |
| 3060 | 3065 | hookF9ImgPathInjector(sWeChatCL, procName); |
| 3061 | 3066 | registerSendReceiver(ctx, procName); |
| 3067 | + hookAtDiscovery(sWeChatCL, procName); // 群 @ 自学习 | |
| 3062 | 3068 | } |
| 3063 | 3069 | }); |
| 3064 | 3070 | XposedBridge.log("[WeChatHook] Send hook registered in " + procName); |
| ... | ... | @@ -3294,6 +3300,12 @@ public class WeChatHook implements IXposedHookLoadPackage { |
| 3294 | 3300 | } |
| 3295 | 3301 | |
| 3296 | 3302 | private static void trySendWithRetry(String talker, String text, int attempt, String procName) { |
| 3303 | + trySendWithRetry(talker, text, null, attempt, procName); | |
| 3304 | + } | |
| 3305 | + | |
| 3306 | + // msgSource 非空 → 群 @(atuserlist)走 HighLevel 路径注入。Factory/R0 兜底路径不带 @ | |
| 3307 | + // (降级:@ 丢失但消息仍发出),因为 @ 通知只在主路径实现。 | |
| 3308 | + private static void trySendWithRetry(String talker, String text, String msgSource, int attempt, String procName) { | |
| 3297 | 3309 | // Non-main WeChat processes (:push, :tools, etc.) lack storage init — fail immediately. |
| 3298 | 3310 | if (procName != null && procName.contains(":")) { |
| 3299 | 3311 | if (attempt == 0) XposedBridge.log("[WeChatHook] Skip send in non-main proc=" + procName); |
| ... | ... | @@ -3301,7 +3313,7 @@ public class WeChatHook implements IXposedHookLoadPackage { |
| 3301 | 3313 | } |
| 3302 | 3314 | // Route A2: tg3.t1 high-level service locator |
| 3303 | 3315 | try { |
| 3304 | - sendViaHighLevel(talker, text); | |
| 3316 | + sendViaHighLevel(talker, text, msgSource); | |
| 3305 | 3317 | XposedBridge.log("[WeChatHook] HighLevel send OK to=" + talker + " proc=" + procName); |
| 3306 | 3318 | return; |
| 3307 | 3319 | } catch (Throwable t) { |
| ... | ... | @@ -3339,7 +3351,7 @@ public class WeChatHook implements IXposedHookLoadPackage { |
| 3339 | 3351 | XposedBridge.log("[WeChatHook] All paths not ready, polling proc=" + procName |
| 3340 | 3352 | + " to=" + talker); |
| 3341 | 3353 | new Handler(Looper.getMainLooper()).postDelayed( |
| 3342 | - () -> trySendWithRetry(talker, text, attempt + 1, procName), 1000); | |
| 3354 | + () -> trySendWithRetry(talker, text, msgSource, attempt + 1, procName), 1000); | |
| 3343 | 3355 | } else { |
| 3344 | 3356 | XposedBridge.log("[WeChatHook] GIVE UP after 60s proc=" + procName + " to=" + talker); |
| 3345 | 3357 | } |
| ... | ... | @@ -3364,9 +3376,122 @@ public class WeChatHook implements IXposedHookLoadPackage { |
| 3364 | 3376 | } |
| 3365 | 3377 | } |
| 3366 | 3378 | |
| 3379 | + // ── 群 @ 自学习:辅助 ───────────────────────────────────────── | |
| 3380 | + private static void loadAtField() { | |
| 3381 | + if (sAtLoaded) return; | |
| 3382 | + sAtLoaded = true; | |
| 3383 | + try { | |
| 3384 | + java.io.File f = new java.io.File(AT_FIELD_FILE); | |
| 3385 | + if (f.exists()) { | |
| 3386 | + java.io.FileInputStream in = new java.io.FileInputStream(f); | |
| 3387 | + byte[] b = new byte[(int) f.length()]; | |
| 3388 | + int n = in.read(b); in.close(); | |
| 3389 | + String p = new String(b, 0, Math.max(n, 0), "UTF-8").trim(); | |
| 3390 | + if (!p.isEmpty()) { sAtFieldPath = p; XposedBridge.log("[WeChatHook] AT loaded field=" + p); } | |
| 3391 | + } | |
| 3392 | + } catch (Throwable t) { XposedBridge.log("[WeChatHook] AT load failed: " + t); } | |
| 3393 | + } | |
| 3394 | + | |
| 3395 | + private static void recordAt(String path, String xml) { | |
| 3396 | + sAtFieldPath = path; | |
| 3397 | + XposedBridge.log("[WeChatHook] AT-LEARNED field=" + path + " xml=" + xml); | |
| 3398 | + try { | |
| 3399 | + java.io.FileOutputStream o = new java.io.FileOutputStream(AT_FIELD_FILE); | |
| 3400 | + o.write(path.getBytes("UTF-8")); o.close(); | |
| 3401 | + } catch (Throwable t) { XposedBridge.log("[WeChatHook] AT save failed: " + t); } | |
| 3402 | + } | |
| 3403 | + | |
| 3404 | + private static boolean isSimpleVal(Object v) { | |
| 3405 | + return v instanceof String || v instanceof Number || v instanceof Boolean | |
| 3406 | + || v instanceof Character || v.getClass().isArray() || v.getClass().isPrimitive(); | |
| 3407 | + } | |
| 3408 | + | |
| 3409 | + // 把 value 写进 root 的 path 字段("f" 直接 / "f.sub" 一层嵌套)。 | |
| 3410 | + private static boolean setFieldByPath(Object root, String path, String value) { | |
| 3411 | + try { | |
| 3412 | + String[] parts = path.split("\\."); | |
| 3413 | + Object obj = root; | |
| 3414 | + for (int i = 0; i < parts.length - 1; i++) { | |
| 3415 | + java.lang.reflect.Field f = obj.getClass().getDeclaredField(parts[i]); | |
| 3416 | + f.setAccessible(true); | |
| 3417 | + obj = f.get(obj); | |
| 3418 | + if (obj == null) return false; | |
| 3419 | + } | |
| 3420 | + java.lang.reflect.Field last = obj.getClass().getDeclaredField(parts[parts.length - 1]); | |
| 3421 | + last.setAccessible(true); | |
| 3422 | + last.set(obj, value); | |
| 3423 | + return true; | |
| 3424 | + } catch (Throwable t) { | |
| 3425 | + XposedBridge.log("[WeChatHook] setFieldByPath(" + path + ") failed: " + t); | |
| 3426 | + return false; | |
| 3427 | + } | |
| 3428 | + } | |
| 3429 | + | |
| 3430 | + // 扫 r0 找含 atuserlist 的字段(直接 String + 一层嵌套 String);同时全量 dump 兜底。 | |
| 3431 | + private static void scanForAtuserlist(Object r0) { | |
| 3432 | + try { | |
| 3433 | + if (!"w11.r0".equals(r0.getClass().getName())) return; | |
| 3434 | + StringBuilder dump = new StringBuilder("[WeChatHook] AT-DUMP r0 fields:\n"); | |
| 3435 | + for (java.lang.reflect.Field f : r0.getClass().getDeclaredFields()) { | |
| 3436 | + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; | |
| 3437 | + f.setAccessible(true); | |
| 3438 | + Object v; | |
| 3439 | + try { v = f.get(r0); } catch (Throwable e) { continue; } | |
| 3440 | + String vs = v == null ? "null" : v.toString(); | |
| 3441 | + if (vs.length() > 180) vs = vs.substring(0, 180); | |
| 3442 | + dump.append(" ").append(f.getName()).append(":") | |
| 3443 | + .append(f.getType().getSimpleName()).append(" = ").append(vs).append("\n"); | |
| 3444 | + if (v instanceof String && ((String) v).contains("atuserlist")) { | |
| 3445 | + recordAt(f.getName(), (String) v); | |
| 3446 | + } else if (v != null && !isSimpleVal(v)) { | |
| 3447 | + for (java.lang.reflect.Field sf : v.getClass().getDeclaredFields()) { | |
| 3448 | + if (java.lang.reflect.Modifier.isStatic(sf.getModifiers())) continue; | |
| 3449 | + if (sf.getType() != String.class) continue; | |
| 3450 | + sf.setAccessible(true); | |
| 3451 | + Object sv; | |
| 3452 | + try { sv = sf.get(v); } catch (Throwable e) { continue; } | |
| 3453 | + if (sv instanceof String && ((String) sv).contains("atuserlist")) { | |
| 3454 | + recordAt(f.getName() + "." + sf.getName(), (String) sv); | |
| 3455 | + } | |
| 3456 | + } | |
| 3457 | + } | |
| 3458 | + } | |
| 3459 | + XposedBridge.log(dump.toString()); | |
| 3460 | + } catch (Throwable t) { | |
| 3461 | + XposedBridge.log("[WeChatHook] scanForAtuserlist failed: " + t); | |
| 3462 | + } | |
| 3463 | + } | |
| 3464 | + | |
| 3465 | + // hook 发送派发 z2.b(scene):真实发送(非本 hook)时扫 r0 学 atuserlist 字段,学一次即止。 | |
| 3466 | + private void hookAtDiscovery(ClassLoader cl, String procName) { | |
| 3467 | + try { | |
| 3468 | + loadAtField(); | |
| 3469 | + Class<?> z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); | |
| 3470 | + int hooked = 0; | |
| 3471 | + // hook z2 所有"1 个引用参数"的方法(UI enqueue 方法名不确定,不只 b);扫描内部按 | |
| 3472 | + // scene 是不是 w11.r0 过滤,不会误伤别的 scene。学会后全部 short-circuit。 | |
| 3473 | + for (java.lang.reflect.Method m : z2c.getDeclaredMethods()) { | |
| 3474 | + Class<?>[] pt = m.getParameterTypes(); | |
| 3475 | + if (pt.length != 1 || pt[0].isPrimitive()) continue; | |
| 3476 | + XposedBridge.hookMethod(m, new XC_MethodHook() { | |
| 3477 | + @Override protected void beforeHookedMethod(MethodHookParam p) { | |
| 3478 | + if (sAtFieldPath != null || sCalledByUs) return; // 已学会 / 我们自己的发送 → 跳过 | |
| 3479 | + Object scene = (p.args != null && p.args.length > 0) ? p.args[0] : null; | |
| 3480 | + if (scene != null) scanForAtuserlist(scene); | |
| 3481 | + } | |
| 3482 | + }); | |
| 3483 | + hooked++; | |
| 3484 | + } | |
| 3485 | + XposedBridge.log("[WeChatHook] hookAtDiscovery OK in " + procName | |
| 3486 | + + " (" + hooked + " z2 methods, learned=" + sAtFieldPath + ")"); | |
| 3487 | + } catch (Throwable t) { | |
| 3488 | + XposedBridge.log("[WeChatHook] hookAtDiscovery failed in " + procName + ": " + t); | |
| 3489 | + } | |
| 3490 | + } | |
| 3491 | + | |
| 3367 | 3492 | // Route A2: high-level API via tg3.t1 service locator → dk5.s5.Ni(). |
| 3368 | 3493 | // This path is what WeChat UI uses — internally handles DI, account context, etc. |
| 3369 | - private static void sendViaHighLevel(String talker, String text) throws Throwable { | |
| 3494 | + private static void sendViaHighLevel(String talker, String text, String msgSource) throws Throwable { | |
| 3370 | 3495 | ClassLoader cl = sTinkerCL != null ? sTinkerCL : sWeChatCL; |
| 3371 | 3496 | if (cl == null) throw new IllegalStateException("classloader null"); |
| 3372 | 3497 | // Text = same PROVEN path as voice sendVoiceViaAPI: w11.r0 ctor (type=1) inserts |
| ... | ... | @@ -3374,18 +3499,34 @@ public class WeChatHook implements IXposedHookLoadPackage { |
| 3374 | 3499 | // Text carries no CDN attachment, so unlike voice it completes fully. (The s1.a / |
| 3375 | 3500 | // s1.b / dk5.s5.Ni builder paths either produced name-cards or silently failed to |
| 3376 | 3501 | // persist — they need hidden r1 fields + MsgRetransmitUI.P6 that we can't set robustly.) |
| 3502 | + // 群 @ 通知:atuserlist 不进 message 行(cv 里没有),而是当 r0 第5参数 obj 传给 | |
| 3503 | + // 发送 scene(newsendmsg CGI)。msgSource=<msgsource><atuserlist>wxid,..</atuserlist></msgsource> | |
| 3504 | + // 时对方才收到「有人@我」红点。无 @ 时 obj=null(纯文本原路径,逐字节不变)。 | |
| 3377 | 3505 | Class<?> r0Class = XposedHelpers.findClass("w11.r0", cl); |
| 3378 | 3506 | Object r0; |
| 3379 | 3507 | sCalledByUs = true; |
| 3380 | 3508 | try { |
| 3381 | - // Object-ctor is the one that actually inserts the f9 (confirmed by | |
| 3382 | - // sendVoiceViaAPI). obj=null for text (no audio/msgsource extras). | |
| 3509 | + // obj 恒为 null:obj 非 null 时 r0 把它当"消息 key 查已有 f9"(g9.q5)→ NPE,不是 msgsource。 | |
| 3383 | 3510 | r0 = XposedHelpers.newInstance(r0Class, |
| 3384 | 3511 | new Class<?>[]{String.class, String.class, int.class, int.class, Object.class}, |
| 3385 | 3512 | talker, text, 1, 0, null); // (talker, content, type=1 TEXT, flag=0, null) |
| 3386 | 3513 | } finally { sCalledByUs = false; } |
| 3514 | + // 群 @:把 msgSource 塞进"学到"的字段(sAtFieldPath),对方才收到红点。 | |
| 3515 | + // 字段由 hookAtDiscovery 从微信自己的真 @ 发送学得;没学会则只发文字(降级)。 | |
| 3516 | + if (msgSource != null && !msgSource.isEmpty()) { | |
| 3517 | + loadAtField(); | |
| 3518 | + if (sAtFieldPath != null) { | |
| 3519 | + boolean ok = setFieldByPath(r0, sAtFieldPath, msgSource); | |
| 3520 | + XposedBridge.log("[WeChatHook] @ apply msgsource → " + sAtFieldPath + " ok=" + ok); | |
| 3521 | + } else { | |
| 3522 | + XposedBridge.log("[WeChatHook] @ field 未学会 — 先用微信手动发一条真 @ 教它"); | |
| 3523 | + } | |
| 3524 | + } | |
| 3387 | 3525 | Class<?> z2c = XposedHelpers.findClass("com.tencent.mm.modelbase.z2", cl); |
| 3388 | - XposedHelpers.callStaticMethod(z2c, "b", r0); // enqueue → doScene → newsendmsg | |
| 3526 | + sCalledByUs = true; // 别让 discovery hook 扫我们自己的 dispatch | |
| 3527 | + try { | |
| 3528 | + XposedHelpers.callStaticMethod(z2c, "b", r0); // enqueue → doScene → newsendmsg | |
| 3529 | + } finally { sCalledByUs = false; } | |
| 3389 | 3530 | XposedBridge.log("[WeChatHook] sendViaHighLevel(TEXT via r0-obj+z2.b): OK to=" + talker); |
| 3390 | 3531 | } |
| 3391 | 3532 | |
| ... | ... | @@ -3412,10 +3553,16 @@ public class WeChatHook implements IXposedHookLoadPackage { |
| 3412 | 3553 | XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL: missing 'to' or 'text'/'text_b64'"); |
| 3413 | 3554 | return; |
| 3414 | 3555 | } |
| 3556 | + // --es at "wxid1,wxid2" → 群 @ 通知(atuserlist)。构造 msgSource,让对方收到红点。 | |
| 3557 | + String atList = intent.getStringExtra("at"); | |
| 3558 | + final String msgSource = (atList != null && !atList.trim().isEmpty()) | |
| 3559 | + ? "<msgsource><atuserlist>" + atList.trim() + "</atuserlist></msgsource>" | |
| 3560 | + : null; | |
| 3415 | 3561 | XposedBridge.log("[WeChatHook] SEND_MSG_INTERNAL proc=" + procName |
| 3416 | 3562 | + " pid=" + android.os.Process.myPid() |
| 3417 | - + " to=" + talker + " len=" + text.length()); | |
| 3418 | - new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, 0, procName)); | |
| 3563 | + + " to=" + talker + " len=" + text.length() | |
| 3564 | + + (msgSource != null ? " at=" + atList : "")); | |
| 3565 | + new Handler(Looper.getMainLooper()).post(() -> trySendWithRetry(talker, text, msgSource, 0, procName)); | |
| 3419 | 3566 | } |
| 3420 | 3567 | }; |
| 3421 | 3568 | ctx.registerReceiver(sendRecv, new IntentFilter(ACTION_SEND), Context.RECEIVER_NOT_EXPORTED); | ... | ... |