通过Xposed插件,实现稳定的QQ聊天http接口

1. 起因

由于本人水平有限,无法分析客户端的协议。而qq的web版设计过于反人类,cookie过期速度非常快,每次登录都要求扫描二维码,所以想了这么一种方法。

2. 工作原理

首先,在安卓手机上安装xposed框架,通过编写xposed插件来hook手机QQ发消息的方法。然后和自己的服务器建立连接,当接收到服务器发消息的指令后,由注入qq客户端的xposed插件完成最终发送消息。 如此一来,就可以通过服务器提供一个http接口来控制qq发送消息,还不用去研究qq的通讯协议。

3. 实际操作

3.1 准备工作

一部可以联网的安卓手机,一台服务器。 QQ的版本:轻聊版 v3.7.1.704 (2018.11的最新版) Xposed Installer的版本: 2.4

3.2 手机端

3.2.1 寻找QQ发消息的方法

显然,直接反编译难度比较大。我的思路是: 编写一个 xposed插件,hook 安卓View 的setOnClickListener 方法,传入一个自己编写的OnClickListener,在视图被点击时记录用Log记录其ID,然后在源码中搜索该ID,就可以找出按钮的点击事件。而按钮的点击事件就是发消息的方法。

public class HookOnClickListener implements View.OnClickListener {
    private View.OnClickListener original;
    public HookOnClickListener(View.OnClickListener original) {
        this.original = original;
    }
    @Override
    public void onClick(View v) {
        int id=v.getId();
        Log.d("hookqq","View Id:"+id);
        original.onClick(v);
    }
}

以上是假OnClickListener的代码,类似于中间人攻击 通过上面的代码,获得聊天界面“发送”按钮的ID是0x7F09019C。在QQ反编译的代码中搜索可得“发送”按钮的onClick方法在com.tencent.mobileqq.activity.BaseChatPie里面,最后调用的是void b()方法,我们来看看b()方法的逻辑: BaseChatPie.b()方法 可以看出,b()方法在校验消息合法性后调用了com.tencent.mobileqq.activity.ChatActivityFacade的a方法。这是一个静态方法,一共五个参数:AppQQAppInterface,Context,SessionInfo,String,ArrayList. 从命名可以看出发消息时第1,2个参数应该不会变化,第4个参数时消息框中的内容,第五个参数为null. 所以只需要生成SessionInfo就可以通过Xposed插件发送消息。 SessionInfo的结构比较简单: SessionInfo的代码 使用xposed插件记录每次发消息时这些变量的值可以发现:

String a;//对方的QQ号
String d;//对方昵称
String b,c,d,e,f;//一直为null
long a;//当前时间戳,有时是-1
int a;//一直是0
int b;//一直是32
int c;//一直是1
int d;//10004,可能是消息类型

3.2.2 开始写插件

根据上面的分析,我们只需要hook ChatActivityFacade.a(…)方法,在它第一次调用时记录AppQQAppInterface,Context的值,然后再用过反射创建SessionInfo对象就可以通过插件发送消息。 另外,由于QQ编译时经过混淆,SessionInfo中出现了同名变量的情况。这种情况虽然在Java中是无法通过编译的,但是由于Dalvik字节码通过变量类型+变量名称来区分成员变量,所以不影响运行。 为了设置SessionInfo中成员变量的值,XposedHelper提供的反射工具已经无法满足我们的需求,所以需要自己编写一个方法,通过变量的类型和命名来获取Field对象。 如下所示:

public static Field getFieldByNameAndType(Class<?> target,String fieldName,Class<?> fieldType){
        Field[] fs=target.getDeclaredFields();
        for(Field f:fs){
            f.setAccessible(true);
            if(f.getType()==fieldType & f.getName().equals(fieldName)){
                return f;
            }
        }
        return null;
    }

为了方便使用,我们注册一个BroadcastReceiver,当收到广播后就调用原来的方法发消息。

然后创建一个线程,每个几秒查询服务器的消息,当有消息需要发送时再发送一个广播即可。

3.3 服务器端

这个比较简单,我是用python flask写的。只需要一个上传消息和查询消息的接口即可。直接贴代码:

from flask import Flask,request,abort
from flask_sqlalchemy import SQLAlchemy
import json
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"]="mysql+pymysql://<mysql用户名>:<mysql密码>@<mysql主机地址>/数据库名"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"]=False
db=SQLAlchemy(app)
#为了防止其他人查询到消息,设一个访问密码
access_key="查询消息的密码"

class msg(db.Model):
	id=db.Column(db.Integer,primary_key=True)
	receiver=db.Column(db.String(11))
	content=db.Column(db.String(4096))

	def __init__(self,receiver,content):
		self.receiver=receiver
		self.content=content
	def __repr__(self):
		return "<msg %r>"%self.id
	def get_json(self):
		data={"id":self.id,"receiver":self.receiver,"content":self.content}
		return json.dumps(data)
@app.route('/')
def hello_world():
    return 'Hello from Flask!'
@app.route("/getmsg/<key>")
def do_getmsg(key):
	min_=request.args.get("min")
	if not min_:
		min_=1
	if key!=access_key:
		return 'Access denied.'
	msgs=msg.query.filter(msg.id>int(min_)).order_by(msg.id.desc()).limit(10).all()
	json_str=[]
	for m in msgs:
		json_str.append(m.get_json())
	return "[%s]" % ",".join(json_str)
@app.route("/sendmsg/<int:qq>",methods={"POST"})
def do_sendMsg(qq):
	content=request.form.get("content",None)
	if not content:
		abort(500)
	msg_=msg(qq,content)
	db.session.add(msg_)
	db.session.commit()
	return "ok."
if __name__ == '__main__':
	app.run()

4 成果展示

只需要向 http://example.com/sendmsg/<QQ Number> 发送一个POST请求,表单 content 为消息内容,就可以实现控制QQ发消息。 发送POST请求我也是用python写的:

import requests
data={"content":"消息内容"}
r=requests.post("http://example.com/sendmsg/123456789",data=data)
print(r.text)

然后就能收到消息(图为TIM QQ): 消息