利用xposed自动获得支付宝个人收款二维码链接和收款记录

一、说明

       现在的App一般都会带有支付功能,而现在比较流行的支付一般有支付宝、微信、银行卡等,一般情况下,应用开发者会直接对接支付宝、微信或者第三方支付公司的Api,以完成支付,但是都需要收取不小的费率,于是,有的第三方支付平台就想到了钻空子的方法,利用一些特殊的手段获得收款二维码以及收款记录,这样就可以绕过支付平台完成支付过程了,本篇文章的目的就是分析如何完成这样一个流程,本文的意图只有一个就是通过分析app学习更多的逆向技术,如果有人利用本文知识和技术进行非法操作进行牟利,带来的任何法律责任都将由操作者本人承担,和本文作者无任何关系,最终还是希望大家能够秉着学习的心态阅读此文:想获得支付宝的个人收款二维码,和用户最近的收款记录,于是研究了一下方法,最终用xposed解决了。流程如下:

1、获得收款二维码链接流程

       服务器推送金额和备注任务到xposed插件(或者xposed插件主动请求任务)--》xposed插件发送广播通知支付宝--》支付宝打开设置金额页面并自动设置金额和备注,点击确认

       --》xposed hook支付宝处理收款二维码链接的回调方法--》获得收款链接--》发送广播将收款链接回传给xposed插件--》xposed插件将二维码链接发送给服务器

2、获得账单信息流程

       服务器推送账单任务到xposed插件(或者xposed插件主动请求任务)--》xposed插件发送广播通知支付宝获得账单消息--》支付宝打开账单页面获得账单信息--》xposed hook支付宝处理账单信息的回调方法-->获得账单信息--》发送广播将账单信息回传给xposed插件--》xposed插件将账单信息发送给服务器

3、自动登录流程

       服务器推送登录任务到xposed插件,信息包括支付宝账号和密码(或者xposed插件主动请求任务)--》xposed插件发送广播通知支付宝自动登录--》支付宝打开登录页面自动设置账号和密码,点击登录--》xposed hook支付宝登录的回调方法-->获得登录状态(是否登录成功)--》发送广播将登录状态回传给xposed插件--》xposed插件将登录状态发送给服务器

4、自动退出登录流程

       服务器推送退出登录任务到xposed插件(或者xposed插件主动请求任务)--》xposed插件发送广播通知支付宝退出登录--》xposed调用支付宝退出登录的代码完成退出任务--》发送广播通知xposed插件退出任务已经完成

5、获得当前登录用户信息流程

       服务器推送获得当前登录用户信息任务到xposed插件(或者xposed插件主动请求任务)--》xposed插件发送广播通知支付宝广播需要获得用户信息--》支付宝广播调用获得当前登录用户信息代码获得用户信息--》xposed发送广播通知插件获得了用户信息-->xposed插件广播接收用户信息--》xposed插件将用户信息发送给服务器

备注:网络通信的过程可以采用推送(websocket长连接)或者轮询(客户端主动发起http请求)的方式,只要能够正常让插件程序和服务端通信就行。

二、问题分析

1、支付宝的个人收钱界面

我用的支付宝版本是10.1.20

获得个人收钱二维码的流程如下:

打开支付宝主界面--》点击收钱---》进入到个人收钱界面--》点击设置金额--》进入设置金额界面--》设置金额和理由--》点击确定--》返回个人收钱界面并刷新收钱二维码

个人收钱界面如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第1张图片

设置金额界面如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第2张图片

点击个人收钱界面下面的收款记录,我们可以看到用户当天的收款情况,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第3张图片

三、反编译支付宝并分析

反编译应用的方法可以参考:https://blog.csdn.net/xiao_nian/article/details/79391417,这篇文章反编译的是微信的apk,方法是一样的。

1、收款二维码

首先我们用hierarchy view查看设置金额页面,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第4张图片

在反编译代码中找到PayeeQRSetMoneyActivity类,发现下面有一个方法定义如下:

  protected final void a(ConsultSetAmountRes paramConsultSetAmountRes)
  {
    runOnUiThread(new di(this, paramConsultSetAmountRes));
  }

而di的定义如下:

package com.alipay.mobile.payee.ui;

import android.content.Intent;
import com.alipay.android.hackbyte.ClassVerifier;
import com.alipay.mobile.commonui.widget.APInputBox;
import com.alipay.mobile.payee.R.string;
import com.alipay.mobile.payee.util.Logger;
import com.alipay.transferprod.rpc.result.ConsultSetAmountRes;

final class di
  implements Runnable
{
  di(PayeeQRSetMoneyActivity paramPayeeQRSetMoneyActivity, ConsultSetAmountRes paramConsultSetAmountRes)
  {
    if (Boolean.FALSE.booleanValue()) {
      ClassVerifier.class.toString();
    }
  }
  
  public final void run()
  {
    PayeeQRSetMoneyActivity.a.b("call processConsultSetAmountRes(), ConsultSetAmountRes = " + this.a);
    if (this.a != null)
    {
      if (!this.a.success) {
        break label140;
      }
      Intent localIntent = new Intent();
      localIntent.putExtra("codeId", this.a.codeId);
      localIntent.putExtra("qr_money", this.b.g);
      localIntent.putExtra("beiZhu", this.b.c.getInputedText());
      localIntent.putExtra("qrCodeUrl", this.a.qrCodeUrl);
      localIntent.putExtra("qrCodeUrlOffline", this.a.printQrCodeUrl);
      this.b.setResult(-1, localIntent);
      this.b.finish();
    }
    for (;;)
    {
      return;
      label140:
      this.b.alert("", this.a.message, this.b.getString(R.string.payee_confirm), null, null, null);
    }
  }
}

di的run方法里面主要是设置用户设置的金额,备注,服务端返回的二维码链接(qrCodeUrl)到intent中,然后再传递给个人收款(PayeeQRActivity)页面,可以看一下个人收款页面的onActivityResult方法

  public void onActivityResult(int paramInt1, int paramInt2, Intent paramIntent)
  {
    super.onActivityResult(paramInt1, paramInt2, paramIntent);
    if ((paramInt1 == 10) && (paramInt2 == -1) && (paramIntent != null)) {}
    try
    {
      this.c = paramIntent.getStringExtra("qr_money");
      this.d = paramIntent.getStringExtra("beiZhu");
      this.i = paramIntent.getStringExtra("qrCodeUrl");
      this.j = paramIntent.getStringExtra("qrCodeUrlOffline");
      e();
      return;
    }
    catch (Exception paramIntent)
    {
      for (;;)
      {
        LoggerFactory.getTraceLogger().warn(a, paramIntent);
      }
    }
  }

这里主要是根据设置金额页面传过来的qrCodeUrl刷新收款二维码。

经过上面分析,可以有这样一种思路,当手机接收要生成收款二维码的请求后,可以启动支付宝的设置金额页面,然后在自动将金额和备注设置到页面上,最后在模拟点击确定按钮,这个时候支付宝就会将备注和金额发送给服务端,请求二维码链接,请求回来后,会调用PayeeQRSetMoneyActivity的

protected final void a(ConsultSetAmountRes paramConsultSetAmountRes)

方法,ConsultSetAmountRes paramConsultSetAmountRes里面有服务端返回的二维码链接信息,ConsultSetAmountRes类定义如下:

package com.alipay.transferprod.rpc.result;

import com.alipay.android.hackbyte.ClassVerifier;

public class ConsultSetAmountRes
  extends RPCResponse
{
  public String codeId;
  public String printQrCodeUrl;
  public String qrCodeUrl;
  
  public ConsultSetAmountRes()
  {
    if (Boolean.FALSE.booleanValue()) {
      ClassVerifier.class.toString();
    }
  }
  
  public String toString()
  {
    return "ConsultSetAmountRes{codeId='" + this.codeId + '\'' + ", qrCodeUrl='" + this.qrCodeUrl + '\'' + ", printQrCodeUrl='" + this.printQrCodeUrl + '\'' + "} " + super.toString();
  }
}

其中qrCodeUrl即服务端返回的收款二维码链接,我们只需要hook设置金额界面(PayeeQRSetMoneyActivity)的

protected final void a(ConsultSetAmountRes paramConsultSetAmountRes)

方法,即可得到收款二维码链接

            // hook获得二维码url的回调方法
            findAndHookMethod("com.alipay.mobile.payee.ui.PayeeQRSetMoneyActivity", lpparam.classLoader, "a",
                    findClass("com.alipay.transferprod.rpc.result.ConsultSetAmountRes", lpparam.classLoader), new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                    log("com.alipay.mobile.payee.ui.PayeeQRSetMoneyActivity a" + "\n");
               
                    Object consultSetAmountRes = param.args[0];
                    String consultSetAmountResString = "";
                    if (consultSetAmountRes != null) {
                        consultSetAmountResString = (String) callMethod(consultSetAmountRes, "toString");
                    }
                    log("consultSetAmountResString:" + consultSetAmountResString + "\n");
                }
            });

安装插件并重启手机后,打开支付宝界面,弹出非法操作弹框,并且不让操作支付宝界面,我擦,支付宝看来是有反hook机制的

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第5张图片

那么如何解决呢?支付宝肯定也是通过代码去检查应用是否被hook了,我们只需要用xposed hook住支付宝的检测方法,并且修改返回值,这样就可以骗过支付宝了。代码如下:

            XposedHelpers.findAndHookMethod(Application.class,
                    "attach",
                    Context.class, new XC_MethodHook() {
                        @Override
                        protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable {
                            super.afterHookedMethod(param);
                            Context context = (Context) param.args[0];
                            ClassLoader appClassLoader = context.getClassLoader();
                            securityCheckHook(appClassLoader);
                        }
                    });

    // 解决支付宝的反hook
    private void securityCheckHook(ClassLoader classLoader) {
        try {
            Class securityCheckClazz = XposedHelpers.findClass("com.alipay.mobile.base.security.CI", classLoader);
            XposedHelpers.findAndHookMethod(securityCheckClazz, "a", String.class, String.class, String.class, new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                    Object object = param.getResult();
                    XposedHelpers.setBooleanField(object, "a", false);
                    param.setResult(object);
                    super.afterHookedMethod(param);
                }
            });

            XposedHelpers.findAndHookMethod(securityCheckClazz, "a", Class.class, String.class, String.class, new XC_MethodReplacement() {
                @Override
                protected Object replaceHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable {
                    return (byte) 1;
                }
            });
            XposedHelpers.findAndHookMethod(securityCheckClazz, "a", ClassLoader.class, String.class, new XC_MethodReplacement() {
                @Override
                protected Object replaceHookedMethod(MethodHookParam param) throws Throwable {
                    return (byte) 1;
                }
            });
            XposedHelpers.findAndHookMethod(securityCheckClazz, "a", new XC_MethodReplacement() {
                @Override
                protected Object replaceHookedMethod(MethodHookParam param) throws Throwable {
                    return false;
                }
            });

        } catch (Error | Exception e) {
            e.printStackTrace();
        }
    }

在应用加载完成后hook住支付宝的检测是否被hook的方法,修改返回值。重新运行并重启手机,发现没有弹出非法操作弹框。

或者将securityCheckHook代码修改为如下代码也可以:

    // 解决支付宝的反hook
    private void securityCheckHook(ClassLoader classLoader) {

        try {
            Class securityCheckClazz = XposedHelpers.findClass("com.alipay.mobile.base.security.CI", classLoader);

            XposedHelpers.findAndHookMethod(securityCheckClazz, "a", securityCheckClazz, Activity.class, new XC_MethodReplacement() {
                @Override
                protected Object replaceHookedMethod(MethodHookParam param) throws Throwable {
                    return null;
                }
            });
        } catch (Error | Exception e) {
        }
    }

第一种方式是通过修改支付宝检查是否被hook的方法的返回值来骗过支付宝,第二种方式是通过替换支付宝弹出非法操作弹框方法执行逻辑的方式来屏蔽非法操作弹框弹出。

下面我们来分析一下怎样找到支付宝反hook的代码的,首先用hierarchy view查看非法操作弹框布局,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第6张图片

反编译代码中全局搜索"非法操作,当前手机不安全!",没有找到对应的信息,全局搜索"R.id.message",发现有好几个地方有用到这个id,经过加入log测试都不是非法操作弹框使用的,换一种思路,既然是弹框,肯定会继承"android.app.Dialog"类,弹框显示的时候肯定会调用其"show"方法,我们只需要hook住"android.app.Dialog"类的"show"方法,然后打印出方法调用的堆栈来跟踪代码调用逻辑,不就可以知道支付宝弹框非法操作弹框的代码了吗?代码如下:

        findAndHookMethod(Dialog.class, "show", new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                super.afterHookedMethod(param);
                try {
                    throw new NullPointerException(); // 故意抛出一个异常以便打印堆栈信息
                } catch (Exception e) {
                    XposedLogUtils.log("securityCheckHook:" + Log.getStackTraceString(e)); // 打印堆栈信息分析代码的调用逻辑
                }
            }
        });

打开支付宝,弹出非法操作弹框后,可以看到以下日志:

06-02 15:26:23.449 I/Xposed  ( 5792): securityCheckHook:java.lang.NullPointerException
06-02 15:26:23.449 I/Xposed  ( 5792): 	at com.hhly.pay.alipay.Main$6.afterHookedMethod(Main.java:266)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at de.robv.android.xposed.XposedBridge.handleHookedMethod(XposedBridge.java:374)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at android.app.Dialog.show()
06-02 15:26:23.449 I/Xposed  ( 5792): 	at android.app.AlertDialog.show(AlertDialog.java:1246)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at android.app.AlertDialog$Builder.show(AlertDialog.java:1126)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at com.alipay.mobile.base.security.CI.a(CI.java:2463)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at com.alipay.mobile.base.security.CI$1.run(CI.java:114)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at android.os.Handler.handleCallback(Handler.java:739)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at android.os.Handler.dispatchMessage(Handler.java:95)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at android.os.Looper.loop(Looper.java:148)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at android.app.ActivityThread.main(ActivityThread.java:5666)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at java.lang.reflect.Method.invoke(Native Method)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:775)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:665)
06-02 15:26:23.449 I/Xposed  ( 5792): 	at de.robv.android.xposed.XposedBridge.main(XposedBridge.java:107)

在日志中,我们可以看到,弹出非法操作的弹框的代码在"com.alipay.mobile.base.security.CI"的"a"方法中,打开lipay.mobile.base.security.CI"类,发现其中有如下方法定义:

    static /* synthetic */ void a(CI ci, Activity activity) {
        try {
            // 显示非法操作弹框
            Builder builder = new Builder(activity);
            builder.setMessage(new String(Base64.decode("6Z2e5rOV5pON5L2c77yM5b2T5YmN5omL5py65LiN5a6J5YWo77yB", 0), SymbolExpUtil.CHARSET_UTF8)); // 弹框提示内容,这里支付宝对提示文字进行了加密
            builder.setPositiveButton(new String(Base64.decode("56Gu5a6a", 0), SymbolExpUtil.CHARSET_UTF8), new c(ci, activity)); // 确认按钮
            builder.setNegativeButton(R.string.detail, new d(ci, activity)); // 查看详情按钮
            builder.setCancelable(false);
            builder.show();
        } catch (Exception e) {
        }
    }

其中确认按钮和查看详情按钮的点击事件最终都会调用到"com.alipay.mobile.base.security.CI"的下面方法:

    static /* synthetic */ void a(Activity activity) {
        try {
            AlipayApplication.getInstance().getMicroApplicationContext().exit(); // 退出应用
        } catch (Throwable th) {
            activity.finish(); // 退出应用
            System.exit(-1);
        }
    }

在"com.alipay.mobile.base.security.CI"类中,还有一些检查是否被hook的方法,这里不具体分析了。

打开支付宝设置金额界面,设置金额和备注并点击确认,在xposed的log中可以看到以下日志:

04-10 17:11:09.647 I/Xposed  ( 7116): consultSetAmountResString:ConsultSetAmountRes{codeId='1804106465231431', qrCodeUrl='HTTPS://QR.ALIPAY.COM/FKX007021VPOLKNEMJRV5C', printQrCodeUrl='HTTPS://QR.ALIPAY.COM/FKX024385RNIN3NEYG3MDD'} RPCResponse{success=true, code='null', message='null'}

其中qrCodeUrl即收款二维码的支付链接,可以通过支付链接生成一个二维码,然后用支付宝客户端扫码即可向用户付款。

2、用户的收款记录

点击收款记录进入收款记录页面,发现是一个h5的页面,用Charles抓包工具抓包,发现收款记录的请求信息如下:

url
https://mbillexprod.alipay.com/enterprise/simpleTradeOrderQuery.json?beginTime=1523289600000&limitTime=1523376000000&pageSize=20&pageNum=1&channelType=ALL&ctoken=Sf6-M33mBqAxZZKNtUxr8BfA

Referer
https://render.alipay.com/p/z/merchant-mgnt/simple-order.html?beginTime=2018-04-10&endTime=2018-04-10&fromBill=true&channelType=ALL

Cookie
JSESSIONID=RZ13WJ3MUC3KkSLP9Hl0p50jfGkM8464mobilegwRZ13; session.cookieNameId=ALIPAYJSESSIONID; JSESSIONID=DB2789AEA01160BC04A582168D1E5F56; devKeySet={"apdidToken":"2TvE1a0uTmOgw66ehO7iVGekSrqGuHzgMYEaoqbZS\/mgr+jE6sCfYgEB"}; ALIPAYJSESSIONID=RZ13xrqd7gCXa98nzw9FjaXQj5XCC564mobilegwRZ13GZ00; ctoken=Sf6-M33mBqAxZZKNtUxr8BfA; zone=RZ13B; rtk=z02vdaECH12mfnbsHEjoVSXRlX+5t9MESl8UVjAWb0Pkt9vKHEK; ssl_upgrade=0; spanner=B6pqxJF5iOiQ90i4CSoZsIIs1GQtygX7

Method
GET

User-Agent
Mozilla/5.0 (Linux; U; Android 6.0.1; zh-CN; PRO 6 Plus Build/MMB29T) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/40.0.2214.89 UCBrowser/11.6.4.950 UWS/2.11.1.49 Mobile Safari/537.36 UCBS/2.11.1.49_180322095406 NebulaSDK/1.8.100112 Nebula AlipayDefined(nt:WIFI,ws:360|0|4.0) AliApp(AP/10.1.20.556) AlipayClient/10.1.20.556 Language/zh-Hans useStatusBar/true

其中beginTime表示查询的开始时间,limitTime表示查询的截止时间,将上面的信息用浏览器请求,发现能够返回数据,注意编辑请求设置上面的信息,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第7张图片

 

 

 

 

 

经过尝试发现url中的ctoken可以去除,并且Referer可以简化成Referer: https://render.alipay.com/p/z/merchant-mgnt/simple-order.html,后面的参数全部去除,然后Cookie中只需要设置ALIPAYJSESSIONID就可以了,User-Agent可以不修改,最终请求信息如下:

url: https://mbillexprod.alipay.com/enterprise/simpleTradeOrderQuery.json?beginTime=1522425600000&limitTime=1523289600000&pageSize=20&pageNum=1&channelType=ALL
Cookie: ALIPAYJSESSIONID=RZ115A3WmakZXV6KlujBgYoG0I9HoS31mobilegwRZ11GZ00;
Referer: https://render.alipay.com/p/z/merchant-mgnt/simple-order.html
user-agent:Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1

也就是说,我们只需要ALIPAYJSESSIONID就可以获得到用户的收款记录了,查询时间可以自己设置,这个查询时间间隔好像不能超过1个月,超过了就会返回{"exception_marking":"搜索条件的范围过大"}。

我们再postman中模拟发送数据:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第8张图片

可以看到,我们拿到了支付宝的账单数据,但是账单数据里面没有备注,要想获得备注信息,我们还需要查询单个账单的详情,接口如下:

https://tradeeportlet.alipay.com/wireless/tradeDetail.htm?tradeNo=2018040421001004450524815080
Cookie:ALIPAYJSESSIONID=RZ13ik0FHP2IeX6b6LsZrDBFM1yHW464mobilegwRZ13;

其中tradeNo表示订单号,这个在账单列表中有返回,其他的只需要设置Cookie就可以了,用Postman模拟请求:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第9张图片

返回的是一个html页面,我们再页面中可以找到订单的备注信息,上面的订单对应的备注信息是“收款”。

接下来我们只需要想办法获得ALIPAYJSESSIONID就可以了,在反编译代码中全局搜索“ALIPAYJSESSIONID”,发现AmnetUserInfo类中有相关的信息,ALIPAYJSESSIONID类中有如下代码:

  private static String getSessionid()
  {
    for (;;)
    {
      try
      {
        if (MiscUtils.isInAlipayClient(ExtTransportEnv.getAppContext())) {
          continue;
        }
        str1 = "";
      }
      catch (Throwable localThrowable)
      {
        String str1;
        LogCatUtil.error("ext_AmnetUserInfo", "getSessionid ex:" + localThrowable.toString());
        LogCatUtil.debug("ext_AmnetUserInfo", "getSessionid return null");
        String str2 = "";
        continue;
        str2 = getSessionidFromCookiestr(CookieAccessHelper.getCookie((String)localObject, ExtTransportEnv.getAppContext()));
        if (TextUtils.isEmpty(str2)) {
          continue;
        }
        Object localObject = new java/lang/StringBuilder;
        ((StringBuilder)localObject).("sessionidFromCookieStore:");
        LogCatUtil.debug("ext_AmnetUserInfo", str2);
        continue;
      }
      return str1;
      localObject = ReadSettingServerUrl.getInstance().getGWFURL(ExtTransportEnv.getAppContext());
      str1 = getSessionidFromCookiestr(GwCookieCacheHelper.getCookie((String)localObject));
      if (TextUtils.isEmpty(str1)) {
        continue;
      }
      localObject = new java/lang/StringBuilder;
      ((StringBuilder)localObject).("sessionidFromCache:");
      LogCatUtil.debug("ext_AmnetUserInfo", str1);
    }
  }
  
  private static String getSessionidFromCookiestr(String paramString)
  {
    try
    {
      if (!TextUtils.isEmpty(paramString)) {
        break label12;
      }
      paramString = "";
    }
    catch (Throwable paramString)
    {
      for (;;)
      {
        label12:
        int j;
        int i;
        LogCatUtil.error("ext_AmnetUserInfo", "getAlipayJsessionidFromCookiestr ex:" + paramString.toString());
        label96:
        paramString = "";
      }
    }
    return paramString;
    paramString = paramString.split("; ");
    j = paramString.length;
    for (i = 0;; i++)
    {
      if (i >= j) {
        break label96;
      }
      CharSequence localCharSequence = paramString[i];
      if ((!TextUtils.isEmpty(localCharSequence)) && (localCharSequence.contains("ALIPAYJSESSIONID")))
      {
        paramString = localCharSequence.substring(localCharSequence.indexOf("=") + 1);
        break;
      }
    }
  }

其中getSessionid方法感觉就是获得ALIPAYJSESSIONID的方法,在xposed中调用该静态方法并打印返回值,发现返回的是字符串“ALIPAYJSESSIONID”,在hook getSessionidFromCookiestr方法,打印传入的参数,结果就是我们抓包获得的cookie,而cookie中是包含ALIPAYJSESSIONID的信息的,通过

      localObject = ReadSettingServerUrl.getInstance().getGWFURL(ExtTransportEnv.getAppContext());
      str1 = getSessionidFromCookiestr(GwCookieCacheHelper.getCookie((String)localObject));

这两行代码,我们知道可以通过如下代码获得cookie

      cookieStr = getSessionidFromCookiestr(GwCookieCacheHelper.getCookie((String)ReadSettingServerUrl.getInstance().getGWFURL(ExtTransportEnv.getAppContext())));

在xposed中对应的代码如下:

                    String cookieStr = "";
                    // 获得cookieStr
                    Context context = (Context) callStaticMethod(findClass("com.alipay.mobile.common.transportext.biz.shared.ExtTransportEnv", lpparam.classLoader), "getAppContext");
                    if (context != null) {
                        Object readSettingServerUrl = callStaticMethod(findClass("com.alipay.mobile.common.helper.ReadSettingServerUrl", lpparam.classLoader), "getInstance");
                        if (readSettingServerUrl != null) {
                            String gWFURL = (String) callMethod(readSettingServerUrl, "getGWFURL", context);
                            cookieStr = (String) callStaticMethod(findClass("com.alipay.mobile.common.transport.http.GwCookieCacheHelper", lpparam.classLoader), "getCookie", gWFURL);
                        }
                    }

打印日志如下:

04-10 17:11:09.647 I/Xposed  ( 7116): cookieStr:session.cookieNameId=ALIPAYJSESSIONID; ssl_upgrade=0; spanner=PWDKfHD/i7Rh9gQCMkMP+DTzT8PATh824EJoL7C0n0A=; ctoken=Sf6-M33mBqAxZZKNtUxr8BfA; rtk=vokrGCgjMQ9UdSJNIgY0Tnw6Os8MF2zV3TThTYLGJohQF2zBIgB; ALIPAYJSESSIONID=RZ13nkgR2GBxkRKbRrX11rVYzOI6Vi64mobilegwRZ13; devKeySet={"apdidToken":"oBdC1a0uTmOgw66ehO7iVGekSnlK3Y00XLuw5BGCZ6yVyRla+q2qYgEB"}; zone=RZ13A

可以看到其中包含了ALIPAYJSESSIONID的信息。

备注:上面获得的账单信息有一个明显的缺点,就是接口返回的账单数据中没有备注信息,而一般我们是需要根据备注信息来确认账单的唯一性,从而判断是否收款成功,之后会进行优化。

 

四、xposed插件和支付宝应用通信

我们写的插件是单独一个进程,而支付宝也是单独一个进程,两个进程之间的通信有很多方法,比如Binder,Socket,BroadcastReceiver等,这里选择最简单的BroadcastReceiver。

xposed插件的主界面如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第10张图片

添加收钱按钮的点击事件:

        mShouQianButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Intent intent = getPackageManager().getLaunchIntentForPackage(ALIPAY_PACKAGE_NAME);
                intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                startActivity(intent);

                Intent broadCastIntent = new Intent();
                Random random = new Random();
                broadCastIntent.putExtra("qr_money", String.valueOf(random.nextInt(100) + 1));
                broadCastIntent.putExtra("beiZhu", "测试");
                broadCastIntent.setAction(AlipayBroadcast.INTENT_FILTER_ACTION);
                sendBroadcast(broadCastIntent);
            }
        });

点击收钱按钮后,会切换到支付宝应用,并且随机生成一个1-100的金额,设置备注,然后将信息通过广播的形式发送出去,支付宝要收到对应的广播,必须先要注册广播,我们可以在支付宝的主界面注册广播,hook支付宝主界面的onCreate方法,注册广播,hook支付宝主界面的onDestory方法,销毁广播,代码如下:

            // hook 支付宝主界面的onCreate方法,获得主界面对象并注册广播
            findAndHookMethod("com.alipay.mobile.quinox.LauncherActivity", lpparam.classLoader, "onCreate", Bundle.class, new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                    log("com.alipay.mobile.quinox.LauncherActivity onCreated" + "\n");
                    launcherActivity = (Activity) param.thisObject;
                    alipayBroadcast = new AlipayBroadcast();
                    IntentFilter intentFilter = new IntentFilter();
                    intentFilter.addAction(AlipayBroadcast.INTENT_FILTER_ACTION);
                    launcherActivity.registerReceiver(alipayBroadcast, intentFilter);
                }
            });

            // hook 支付宝的主界面的onDestory方法,销毁广播
            findAndHookMethod("com.alipay.mobile.quinox.LauncherActivity", lpparam.classLoader, "onDestroy", new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                    log("com.alipay.mobile.quinox.LauncherActivity onDestroy" + "\n");
                    if (alipayBroadcast != null) {
                        ((Activity) param.thisObject).unregisterReceiver(alipayBroadcast);
                    }
                    launcherActivity = null;
                }
            });


广播类定义如下:

package com.hhly.pay.alipay.boradcast;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;

import com.hhly.pay.alipay.Main;

import de.robv.android.xposed.XposedHelpers;

import static de.robv.android.xposed.XposedBridge.log;

/**
 * Created by dell on 2018/4/4.
 */

public class AlipayBroadcast extends BroadcastReceiver{
    public static String INTENT_FILTER_ACTION = "com.hhly.pay.alipay.info";
    @Override
    public void onReceive(Context context, Intent intent) {
        if (intent.getAction().contentEquals(INTENT_FILTER_ACTION)) {
            String qr_money = intent.getStringExtra("qr_money");
            String beiZhu = intent.getStringExtra("beiZhu");
            log("AlipayBroadcast onReceive " + qr_money + " " + beiZhu + "\n");
            if (!qr_money.contentEquals("")) {
                Intent launcherIntent = new Intent(context, XposedHelpers.findClass("com.alipay.mobile.payee.ui.PayeeQRSetMoneyActivity", Main.launcherActivity.getApplicationContext().getClassLoader()));
                launcherIntent.putExtra("qr_money", qr_money);
                launcherIntent.putExtra("beiZhu", beiZhu);
                Main.launcherActivity.startActivity(launcherIntent);
            }
        }
    }
}

可以看到,支付宝在接受到广播后会打开设置金额页面,并且将金额和备注传过去,接下来我们需要hook住设置金额页面的onCreate方法,取得金额和备注,设置到界面上并且模拟点击确认按钮,这样我们只需要hook住设置金额的"a"方法,

protected final void a(ConsultSetAmountRes paramConsultSetAmountRes)

就获得到付款链接了,可以在这里顺便获得cookie,然后通过广播的形式发送给xposed插件,代码如下:

          findAndHookMethod("com.alipay.mobile.payee.ui.PayeeQRSetMoneyActivity", lpparam.classLoader, "a",
                    findClass("com.alipay.transferprod.rpc.result.ConsultSetAmountRes", lpparam.classLoader), new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                    log("com.alipay.mobile.payee.ui.PayeeQRSetMoneyActivity a" + "\n");
                    String cookieStr = "";
                    // 获得cookieStr
                    Context context = (Context) callStaticMethod(findClass("com.alipay.mobile.common.transportext.biz.shared.ExtTransportEnv", lpparam.classLoader), "getAppContext");
                    if (context != null) {
                        Object readSettingServerUrl = callStaticMethod(findClass("com.alipay.mobile.common.helper.ReadSettingServerUrl", lpparam.classLoader), "getInstance");
                        if (readSettingServerUrl != null) {
                            String gWFURL = (String) callMethod(readSettingServerUrl, "getGWFURL", context);
                            cookieStr = (String) callStaticMethod(findClass("com.alipay.mobile.common.transport.http.GwCookieCacheHelper", lpparam.classLoader), "getCookie", gWFURL);
                        }
                    }
                    Object consultSetAmountRes = param.args[0];
                    String consultSetAmountResString = "";
                    if (consultSetAmountRes != null) {
                        consultSetAmountResString = (String) callMethod(consultSetAmountRes, "toString");
                    }
                    Intent broadCastIntent = new Intent();
                    broadCastIntent.putExtra("consultSetAmountResString", consultSetAmountResString);
                    broadCastIntent.putExtra("cookieStr", cookieStr);
                    broadCastIntent.setAction(PluginBroadcast.INTENT_FILTER_ACTION);
                    Activity activity = (Activity) param.thisObject;
                    activity.sendBroadcast(broadCastIntent);
                    log("consultSetAmountResString:" + consultSetAmountResString + "\n");
                    log("cookieStr:" + cookieStr + "\n");
                }
            });

同样,在xposed插件中需要注册广播:

在xposed插件的MainActivity的onCreate方法中注册广播,并在其onDestory中销毁广播,如下:

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);


        pluginReceiver = new PluginBroadcast();
        IntentFilter intentFilter = new IntentFilter();
        intentFilter.addAction(PluginBroadcast.com.eg.android.AlipayGphone.info);
        registerReceiver(pluginReceiver, intentFilter);
    }




    @Override
    protected void onDestroy() {
        super.onDestroy();
        unregisterReceiver(pluginReceiver);
    }

PluginBroadcast的定义如下:

package com.hhly.pay.alipay.boradcast;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import com.hhly.pay.alipay.App;

/**
 * Created by dell on 2018/4/4.
 */

public class PluginBroadcast extends BroadcastReceiver{
    public static String INTENT_FILTER_ACTION = "com.eg.android.AlipayGphone.info";
    @Override
    public void onReceive(Context context, Intent intent) {
        if (intent.getAction().contentEquals(INTENT_FILTER_ACTION)) {
            App.dealAlipayInfo(context, intent);
        }
    }
}

dealAlipayInfo方法的定义:

    public static void dealAlipayInfo(Context context, Intent intent) {
        String consultSetAmountResString = intent.getStringExtra("consultSetAmountResString");
        String cookieStr = intent.getStringExtra("cookieStr");
        String toastString = consultSetAmountResString + " " + cookieStr;
        Log.i("liunianprint:", toastString);
        Toast.makeText(context, toastString, Toast.LENGTH_SHORT).show();
    }

这里只是打印了consultSetAmountResString和cookieStr,正常流程是应该将信息上传给服务端,打印的日志如下:

04-10 18:47:03.288 7097-7097/? I/liunianprint:: ConsultSetAmountRes{codeId='1804106465250342', qrCodeUrl='HTTPS://QR.ALIPAY.COM/FKX03573WKXOYREEFL2686', printQrCodeUrl='HTTPS://QR.ALIPAY.COM/FKX01907CEYS5GOWTI9PB1'} RPCResponse{success=true, code='null', message='null'} session.cookieNameId=ALIPAYJSESSIONID; ssl_upgrade=0; spanner=PWDKfHD/i7Rh9gQCMkMP+DTzT8PATh824EJoL7C0n0A=; ctoken=Sf6-M33mBqAxZZKNtUxr8BfA; rtk=vokrGCgjMQ9UdSJNIgY0Tnw6Os8MF2zV3TThTYLGJohQF2zBIgB; ALIPAYJSESSIONID=RZ13nkgR2GBxkRKbRrX11rVYzOI6Vi64mobilegwRZ13; devKeySet={"apdidToken":"oBdC1a0uTmOgw66ehO7iVGekSnlK3Y00XLuw5BGCZ6yVyRla+q2qYgEB"}; zone=RZ13A

到此为止,我们已经可以在插件中获得收款链接和ALIPAYJSESSIONID,只需要将其发送给服务端就可以了,服务端可以根据收款链接生成收款二维码,根据ALIPAYJSESSIONID请求到收款记录。

顺便说一句,支付宝请求收款二维码链接是通过rpc协议进行的,在PayeeQRSetMoneyActivity如下方法:

  final void a()
  {
    ConsultSetAmountReq localConsultSetAmountReq = new ConsultSetAmountReq();
    localConsultSetAmountReq.amount = this.g;
    localConsultSetAmountReq.desc = this.c.getUbbStr();
    localConsultSetAmountReq.sessionId = this.h;
    new RpcRunner(new dk(this), new dj(this)).start(new Object[] { localConsultSetAmountReq });
  }

点击确认按钮后会调用该方法去向支付宝的服务器请求支付链接,用Charles抓取不到rpc的请求,后面可以考虑直接模拟rpc请求直接向支付宝的服务器请求付款链接。

 

五、优化账单

通过上面的接口获得的账单信息中是没有备注的,估计支付宝为了安全没有将这块信息加入到接口中,但是在服务端判断收款是否到账就是根据收款记录中的备注信息确认的,只需要将设置金额页面的备注信息设置为每个账单唯一,就可以根据备注信息确认收款是否到账,在支付宝的账单页面,我们可以看到账单的备注信息,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第11张图片

那下面就从账单页面入手,找到带备注信息的账单数据,用hierarchy view看一下账单界面,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第12张图片

可以看到账单页面对应的activity为"com.alipay.mobile.bill.list.ui.BillListActivity_",在反编译代码中搜索"BillListActivity_"类,发现找不到这个类,通过xposed hook这个类,也提示无法找到该类。代码如下:

        findAndHookMethod("com.alipay.mobile.bill.list.ui.BillListActivity_", mClassLoader, "onCreate", Bundle.class, new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                XposedLogUtils.log("com.alipay.mobile.bill.list.ui.BillListActivity_" + ":onCreated");
                mBillActivity = (Activity) param.thisObject;
            }
        });

报错信息如下:

05-24 17:07:14.977 E/Xposed  ( 6047): de.robv.android.xposed.XposedHelpers$ClassNotFoundError: java.lang.ClassNotFoundException: com.alipay.mobile.bill.list.ui.BillListActivity_
05-24 17:07:14.977 E/Xposed  ( 6047): 	at de.robv.android.xposed.XposedHelpers.findClass(XposedHelpers.java:71)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at de.robv.android.xposed.XposedHelpers.findAndHookMethod(XposedHelpers.java:260)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at com.sunny.aliplugin.hook.AliHook.o(AliHook.java:497)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at com.sunny.aliplugin.hook.AliHook.b(AliHook.java:58)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at com.sunny.aliplugin.hook.AliHook$6.afterHookedMethod(AliHook.java:245)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at de.robv.android.xposed.XposedBridge.handleHookedMethod(XposedBridge.java:374)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at com.alipay.mobile.quinox.classloader.BundleClassLoader.()
05-24 17:07:14.977 E/Xposed  ( 6047): 	at com.alipay.mobile.quinox.classloader.c.run(BundleClassLoaderFactory.java:213)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at com.alipay.mobile.quinox.asynctask.PipelineRunnable.run(PipelineRunnable.java:124)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1113)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:588)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at java.lang.Thread.run(Thread.java:818)
05-24 17:07:14.977 E/Xposed  ( 6047): Caused by: java.lang.ClassNotFoundException: com.alipay.mobile.bill.list.ui.BillListActivity_
05-24 17:07:14.977 E/Xposed  ( 6047): 	at java.lang.Class.classForName(Native Method)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at java.lang.Class.forName(Class.java:324)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at external.org.apache.commons.lang3.ClassUtils.getClass(ClassUtils.java:823)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at de.robv.android.xposed.XposedHelpers.findClass(XposedHelpers.java:69)
05-24 17:07:14.977 E/Xposed  ( 6047): 	... 11 more
05-24 17:07:14.977 E/Xposed  ( 6047): Caused by: java.lang.ClassNotFoundException: Didn't find class "com.alipay.mobile.bill.list.ui.BillListActivity_" on path: DexPathList[[zip file "/system/framework/org.simalliance.openmobileapi.jar", zip file "/data/app/com.eg.android.AlipayGphone-1/base.apk"],nativeLibraryDirectories=[/data/user/0/com.eg.android.AlipayGphone/app_plugins_lib, /data/app/com.eg.android.AlipayGphone-1/lib/arm, /data/app/com.eg.android.AlipayGphone-1/base.apk!/lib/armeabi, /vendor/lib, /system/lib]]
05-24 17:07:14.977 E/Xposed  ( 6047): 	at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:56)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at java.lang.ClassLoader.loadClass(ClassLoader.java:511)
05-24 17:07:14.977 E/Xposed  ( 6047): 	at java.lang.ClassLoader.loadClass(ClassLoader.java:469)
05-24 17:07:14.977 E/Xposed  ( 6047): 	... 15 more
05-24 17:07:14.977 E/Xposed  ( 6047): 	Suppressed: java.lang.ClassNotFoundException: HostClassLoader

那这样就奇怪了,既然支付宝能够显示账单页面,那么对应的代码肯定是存在的,考虑一下,是不是支付宝用了分包技术,账单页面在其他的jar包中呢?在需要显示账单页面时才去加载对应的jar包,如果是这样,那么我们在当前apk的主ClassLoader就无法找到账单页面。现在需要想办法验证一下我们的想法,首先如果我们能够知道账单页面对应的ClassLoader,就可以打印出ClassLoader的名称,就能知道账单页面对应的代码所在位置。那么如何获得账单页面对应的ClassLoader呢?我们知道,Class类有一个getClassLoader()方法,如果能够获得账单页面对应的对象,然后通过getClass()方法获得其对应的Class,再调用Class的getClassLoader()方法,就可以知道账单页面对应的ClassLoader了。那么如何获得账单页面Activity对象呢?我们现在是不知道账单页面对应的ClassLoader的,也就找不到其对应的Class,无法在Xposed中注册Hook它的方法,额,感觉有点麻烦了,换个角度思考一下,BillListActivity_是一个Activity,那么它肯定是要继承Activity类的,BillListActivity_在创建时肯定会调用到Activity的onCreate方法,我们可以Hook Activity类的onCreate方法,获得当前Activity对象,然后获得当前Activity对象对应的类名,再来比较类名是不是账单页面对应的类名,如果是,那么当前Activity对象就是BillListActivty_对象,那么就可以打印出其对应的ClassLoader了。代码如下:

        findAndHookMethod(Activity.class, "onCreate", Bundle.class, new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                if (param.thisObject != null && param.thisObject.getClass().getName().contentEquals("com.alipay.mobile.bill.list.ui.BillListActivity_")) {
                    XposedLogUtils.log(param.thisObject.getClass().getClassLoader().toString());
                }
            }
        });

打印结果如下:

05-24 17:43:17.051 I/Xposed  ( 6055): BundleClassLoader[/data/user/0/com.eg.android.AlipayGphone/lib/libandroid-phone-wallet-billlist.so]

可以看到账单页面的代码在lib目录的"libandroid-phone-wallet-billlist.so"这个库中,打开apk的lib目录下,发现可以找到这个库:

并且ClassLoader对应的类名为"BundleClassLoader",我擦,居然是一个so库,我们知道,so库是不能直接看到代码的,如果是so库就难搞了,尝试将"libandroid-phone-wallet-billlist.so"重命名为"libandroid-phone-wallet-billlist.zip"并且解压,对应目录如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第13张图片

呃呃呃,原来是一个假的so库,其实和apk包是一样的,反编译一下classes.dex,获得账单页面对应的jar包,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第14张图片

获得账单页面对应的jar包并且用jd-gui打开,搜索BillListActivity_,可以看到BillListActivity_的代码:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第15张图片

ok,现在我们获得账单页面的代码了,接下来就是Hook账单页面对应的方法然后获得账单数据,搞到这里又会发现一个问题,想要hook账单页面的方法首先需要获得账单页面对应的class,而class是需要从ClassLoader中找的,账单页面的ClassLoader是动态加载的,只有当启动支付宝后首次打开账单页面时,才会去加载账单页面对应的库,从而生成对应的ClassLoader,我们上面获得账单页面对应的ClassLoader的步骤如下:

1、hook Activity的onCreate方法

2、通过类名判断当前Activity是否是账单页面

3、如果是账单页面,则可以通过getClass().getClassLoader()获得账单页面对应的ClassLoader

4、记录账单页面的ClassLoader当静态变量中

5、之后要使用账单页面的ClassLoader就可以直接使用静态变量中的ClassLoader了

上面方法有一个问题是我们必须要在支付宝启动后手动打开一次账单页面,这样支付宝才会去加载账单页面对应的库,然后才能找到其对应的ClassLoader,有没有什么办法可以不用手动打开账单页面呢?通过查看点击进入账单页面的方法,最终我们发现可以通过下面的代码打开账单页面:

    LauncherAppUtils.a("20000003");

LauncherAppUtils应该是支付宝为了启动动态库中的Activity而写的一个辅助类,"20000003"应该代表账单页面,这个在支付宝AppId类中有配置:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第16张图片

我们可以在支付宝的首页启动时调用该方法,从而实现在支付宝启动后自动打开支付宝的账单页面,这样就可以获得账单页面对应的ClassLoader了。代码如下:

        // hook 支付宝主界面的onCreate方法,获得主界面对象并注册广播
        findAndHookMethod(AliParamUtils.mLauncherActivityClassfullName, classLoader, "onCreate", Bundle.class, new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                XposedLogUtils.log(AliParamUtils.mLauncherActivityClassfullName + ":onCreated方法");
                mLauncherActivity = (Activity) param.thisObject;

                if (AliParamUtils.mBillListActivityIsFromSoLib) {
                    // 打开账单页面,并加载其对应的库
                    callStaticMethod(findClass("com.alipay.android.phone.home.manager.LauncherAppUtils", classLoader), "a", "20000003", null);
                }
            }
        });

另外一个问题是,我们不想通过hook Activity的onCreate方法,然后判断类名的方式获得账单页面对应的ClassLoader,那么有没有其他的办法呢?通过上面的对应账单页面ClassLoader的打印,我们知道,账单页面的ClassLoader对应的类名为"BundleClassLoader",在反编译代码中搜索"BundleClassLoader",可以看到如下代码:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第17张图片

观察"BundleClassLoader"所有的构造方法,发现其最终都会调用下面的这个构造方法:

  @SuppressLint({"DefaultLocale"})
  public BundleClassLoader(ClassLoader paramClassLoader, Bundle paramBundle, BundleManager paramBundleManager, HostClassLoader paramHostClassLoader)

并且在该构造方法中可以看到如下代码:

 if ((Build.HARDWARE.toLowerCase().contains("mt6592")) && (paramBundle.getLocation().endsWith(".so")))

猜想paramBundle.getLocation()应该是获得动态库的路径,我们可以打印paramBundle.getLocation()的值,在首次打开账单页面时可以看到如下日志:

05-12 10:55:28.861 I/Xposed  ( 6891): ------------so库    /data/user/0/com.eg.android.AlipayGphone/lib/libandroid-phone-wallet-billlist.so

说明paramBundle.getLocation()就是获得动态库的路径,既然这样,我们就可以通过动态库的名称来判断当前的ClassLoader是否是账单页面的ClassLoader,代码如下:

        // hook BundleClassLoader构造方法,获得so库对应的classloader并hook来自so库中的类
        findAndHookConstructor("com.alipay.mobile.quinox.classloader.BundleClassLoader", classLoader,
                ClassLoader.class,
                findClass("com.alipay.mobile.quinox.bundle.Bundle", classLoader),
                findClass("com.alipay.mobile.quinox.bundle.BundleManager", classLoader),
                findClass("com.alipay.mobile.quinox.classloader.HostClassLoader", classLoader),
                new XC_MethodHook() {
                    @Override
                    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                        try {
                            if (param.args[1] != null) {
                                String soLibName = (String) XposedHelpers.callMethod(param.args[1], AliParamUtils.mBundleGetLocationMethodName); // 获得so库名称
                                if (soLibName != null) {
                                    if (soLibName.contains("wallet-billlist")) {
                                        mBillListActivityClassLoader = (ClassLoader) param.thisObject;
                                        XposedLogUtils.log("账单页面classloader: " + mBillListActivityClassLoader.toString());
                                        hookBillListActivityMethod();
                                    }
                                }
                            }
                        } catch (Exception e) {
                        }
                    }
                });

首次打开账单页面,可以看到如下日志:

05-12 10:55:28.861 I/Xposed  ( 6891): 账单页面classloader: BundleClassLoader[/data/user/0/com.eg.android.AlipayGphone/lib/libandroid-phone-wallet-billlist.so]

到现在为止,我们已经可以在支付宝应用启动后自动获得账单页面的ClassLoader并将其保存在静态变量mBillListActivityClassLoader中,终于能够正常hook账单页面的方法了,接下来我们就通过hook账单页面来获得账单数据。

用hierarchy view查看账单界面,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第18张图片

发现其账单信息是在一个APListView控件中,并且ApListView外面又套了一个APPullRefreshView,这个应该是可以猜到的,账单页面是一个列表,并且支持下拉刷新和上拉加载更多,一般的套路就是下拉刷新控件套上一个ListView或者RecyclerView,既然这样,那么如果我们找到ListView对应的Adpater,账单的数据应该就存在Adpater中的某个类型为List的对象中,直接在反编译代码中查看BillListActivity_的代码,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第19张图片

并没有找到对应的APPullRefreshView或者ApListView之类的信息,打开其父类BillListActivity,如下:

@EActivity(resName="activity_bill_list")
public class BillListActivity
  extends BillListBaseActivity
{
  private BroadcastReceiver A;
  private BroadcastReceiver B;
  private boolean C = false;
  private boolean D = false;
  private boolean E = false;
  private boolean F = false;
  private long G = 0L;
  private String H;
  private boolean I = false;
  private RpcRunner J;
  private String K;
  private String L;
  private String M;
  private boolean N;
  private String O;
  private boolean P;
  private String Q;
  private RpcRunner R;
  private RpcRunner S;
  private List T;
  private SelectDateWindow U;
  private CategoryListRes V;
  private boolean W;
  private String X;
  private AUFloatMenu Y;
  private BillCacheManager Z;
  private BillCacheManager aa;
  private BillListNewCategoryManager ab;
  private boolean ac = false;
  private FilterPopUpWindow ad;
  private NewCategoryFilterPopUpWindow ae;
  private boolean af;
  private String ag;
  private String ah;
  private boolean ai = true;
  private boolean aj = false;
  private boolean ak = false;
  private String al;
  private boolean am = false;
  private boolean an = true;
  @ViewById(resName="bill_list_title_bar")
  protected AUTitleBar c;
  @ViewById(resName="bill_list_view")
  protected APListView d;
  @ViewById(resName="bill_list_container")
  protected View e;
  @ViewById(resName="bill_list_month_header")
  protected ViewGroup f;
  @ViewById(resName="bill_list_pull_refresh")
  protected APPullRefreshView g;
  @ViewById(resName="bill_list_loading")
  protected View h;
  protected BillListFilterBar i;
  protected BillListFilterBar j;
  protected TextView k;
  private ViewGroup l;
  private ViewGroup m;
  private TextView n;
  private View o;
  private BadgeView p;
  private String q;
  private APOverView r;
  private AuthService s;
  private String t = "NO";
  private QueryListReq u;
  private boolean v = false;
  private boolean w = true;
  private boolean x = false;
  private BillListViewFooterView y;
  private BillListAdapter z;

可以看到其中有如下字段的定义:

  @ViewById(resName="bill_list_pull_refresh")
  protected APPullRefreshView g;
  @ViewById(resName="bill_list_view")
  protected APListView d;
  private BillListAdapter z;

可以看到账单页面对应的Adapter为BillListAdater,字段名称为"z",打开BillListAdater类,可以看到如下代码:

    public List a = new ArrayList();

    public final void a(List paramList)
    {
        this.a.addAll(paramList);
        notifyDataSetChanged();
    }

其中字段"a"应该就是账单列表的数据,而方法"a"应该是用来添加账单数据到列表中的方法,在这个类中搜索"this.a.add",发现只有这个方法中有添加账单数据到列表中,由此,我们可以判断,只要账单数据有增加,肯定会调用该方法。我们可以通过hook 该方法,一旦账单数据有添加,我们就可以监控到,代码如下:

        findAndHookMethod("com.alipay.mobile.bill.list.ui.adapter.BillListAdapter", mBillListActivityClassLoader, "a", List.class, new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                XposedLogUtils.log("com.alipay.mobile.bill.list.ui.adapter.BillListAdapter" + "a called" + "\n");
                if (param.args[0] != null) {
                    Field billListFiled = XposedHelpers.findField(param.thisObject.getClass(), "a"); // 通过反射找到账单数据列表对应的字段
                    final Object billList = billListFiled.get(param.thisObject); // 获得账单数据列表
                    List bList_obj = (List) billList;
                    if (bList_obj != null) {
                        sendBillListBroadCast(bList_obj); // 将账单数据列表通过广播发送回给插件程序
                    }
                }
            }
        }); 
  

其中sendBillListBroadCast(bList_obj)的作用是将账单数据列表通过广播发送回给插件程序,通过上面的分析我们知道账单数据的类型为SingleListItem,打开SingleListItem类,可以看到如下字段的定义:

  @ProtoField(tag=15)
  public ActionParam actionParam;
  @ProtoField(tag=1, type=Message.Datatype.STRING)
  public String bizInNo;
  @ProtoField(tag=6, type=Message.Datatype.STRING)
  public String bizStateDesc;
  @ProtoField(tag=10, type=Message.Datatype.STRING)
  public String bizSubType;
  @ProtoField(tag=9, type=Message.Datatype.STRING)
  public String bizType;
  @ProtoField(tag=11, type=Message.Datatype.BOOL)
  public Boolean canDelete;
  @ProtoField(tag=23, type=Message.Datatype.STRING)
  public String categoryName;
  @ProtoField(tag=3, type=Message.Datatype.STRING)
  public String consumeFee;
  @ProtoField(tag=4, type=Message.Datatype.STRING)
  public String consumeStatus;
  @ProtoField(tag=2, type=Message.Datatype.STRING)
  public String consumeTitle;
  @ProtoField(tag=26, type=Message.Datatype.INT32)
  public Integer contentRender;
  @ProtoField(tag=8, type=Message.Datatype.STRING)
  public String createDesc;
  @ProtoField(tag=16, type=Message.Datatype.STRING)
  public String createTime;
  @ProtoField(tag=14, type=Message.Datatype.STRING)
  public String destinationUrl;
  @ProtoField(tag=7, type=Message.Datatype.INT64)
  public Long gmtCreate;
  @ProtoField(tag=18, type=Message.Datatype.BOOL)
  public Boolean isAggregatedRec;
  @ProtoField(tag=17, type=Message.Datatype.STRING)
  public String memo;
  @ProtoField(tag=13, type=Message.Datatype.STRING)
  public String month;
  @ProtoField(tag=5, type=Message.Datatype.STRING)
  public String oppositeLogo;
  @ProtoField(tag=20, type=Message.Datatype.STRING)
  public String oppositeMemGrade;
  @ProtoField(tag=12, type=Message.Datatype.ENUM)
  public RecordType recordType;
  @ProtoField(tag=19, type=Message.Datatype.STRING)
  public String sceneId;
  @ProtoField(tag=25, type=Message.Datatype.STRING)
  public String statistics;
  @ProtoField(tag=24, type=Message.Datatype.STRING)
  public String subCategoryName;
  @ProtoField(label=Message.Label.REPEATED, tag=21, type=Message.Datatype.STRING)
  public List tagNameList;
  @ProtoField(tag=22, type=Message.Datatype.INT32)
  public Integer tagStatus;

根据名称,我们大致可以猜测出每一个字段代表的意思,比如consumeFee应该代表进账或者消费的金额,我们在插件程序中创建一个BillObject类,并且实现Parcelable接口,以便其能够被序列化,如下:

   /**
     * 账单信息对象
     */
    public static class BillObject implements Parcelable {
        public String bizInNo;
        public String bizStateDesc;
        public String bizSubType;
        public String canDelete;
        public String bizType;
        public String consumeFee;
        public String consumeStatus;
        public String consumeTitle;
        public String createDesc;
        public String createTime;
        public String destinationUrl;
        public String gmtCreate;
        public String isAggregatedRec;
        public String memo;
        public String month;
        public String oppositeLogo;
        public String oppositeMemGrade;
        public String sceneId;

        public BillObject() {

        }

        protected BillObject(Parcel in) {
            bizInNo = in.readString();
            bizStateDesc = in.readString();
            bizSubType = in.readString();
            canDelete = in.readString();
            bizType = in.readString();
            consumeFee = in.readString();
            consumeStatus = in.readString();
            consumeTitle = in.readString();
            createDesc = in.readString();
            createTime = in.readString();
            destinationUrl = in.readString();
            gmtCreate = in.readString();
            isAggregatedRec = in.readString();
            memo = in.readString();
            month = in.readString();
            oppositeLogo = in.readString();
            oppositeMemGrade = in.readString();
            sceneId = in.readString();
        }

        public static final Creator CREATOR = new Creator() {
            @Override
            public BillObject createFromParcel(Parcel in) {
                return new BillObject(in);
            }

            @Override
            public BillObject[] newArray(int size) {
                return new BillObject[size];
            }
        };

        @Override
        public String toString() {
            return "bizInNo:" + bizInNo + "," +
                    "bizStateDesc:" + bizStateDesc + "," +
                    "bizSubType:" + bizSubType + "," +
                    "canDelete:" + canDelete + "," +
                    "bizType:" + bizType + "," +
                    "consumeFee:" + consumeFee + "," +
                    "consumeStatus:" + consumeStatus + "," +
                    "consumeTitle:" + consumeTitle + "," +
                    "createDesc:" + createDesc + "," +
                    "createTime:" + createTime + "," +
                    "destinationUrl:" + destinationUrl + "," +
                    "gmtCreate:" + gmtCreate + "," +
                    "isAggregatedRec:" + isAggregatedRec + "," +
                    "memo:" + memo + "," +
                    "month:" + month + "," +
                    "oppositeLogo:" + oppositeLogo + "," +
                    "oppositeMemGrade:" + oppositeMemGrade + "," +
                    "sceneId:" + sceneId + "\n";
        }

        @Override
        public int describeContents() {
            return 0;
        }

        @Override
        public void writeToParcel(Parcel dest, int flags) {
            dest.writeString(bizInNo);
            dest.writeString(bizStateDesc);
            dest.writeString(bizSubType);
            dest.writeString(canDelete);
            dest.writeString(bizType);
            dest.writeString(consumeFee);
            dest.writeString(consumeStatus);
            dest.writeString(consumeTitle);
            dest.writeString(createDesc);
            dest.writeString(createTime);
            dest.writeString(destinationUrl);
            dest.writeString(gmtCreate);
            dest.writeString(isAggregatedRec);
            dest.writeString(memo);
            dest.writeString(month);
            dest.writeString(oppositeLogo);
            dest.writeString(oppositeMemGrade);
            dest.writeString(sceneId);
        }
    }

然后将账单数据存到我们自己的Object对象中,并且发送广播给插件:

    /**
     * 发送账单数据广播
     *
     * @param objectList
     * @throws IllegalAccessException
     */
    private void sendBillListBroadCast(List objectList) throws IllegalAccessException {
        boolean isFound = false; // 是否查到相应位置
        boolean isLast = false;  // 已是最新数据
        int invalidCount = 0; //
        ArrayList billObjectList = new ArrayList<>();

        XposedLogUtils.log("objectList size:" + objectList.size());

        for (int i = 0; i < objectList.size(); i++) {
            Object obj = objectList.get(i);
            BillObject billObject = new BillObject();
            billObject.bizInNo = getStringField(obj, "bizInNo");
            billObject.bizStateDesc = getStringField(obj, "bizStateDesc");
            billObject.bizSubType = getStringField(obj, "bizSubType");
            billObject.canDelete = getStringField(obj, "canDelete");
            billObject.bizType = getStringField(obj, "bizType");
            billObject.consumeFee = getStringField(obj, "consumeFee");
            billObject.consumeStatus = getStringField(obj, "consumeStatus");
            billObject.consumeTitle = getStringField(obj, "consumeTitle");
            billObject.createDesc = getStringField(obj, "createDesc");
            billObject.createTime = getStringField(obj, "createTime");
            billObject.destinationUrl = getStringField(obj, "destinationUrl");
            billObject.gmtCreate = getStringField(obj, "gmtCreate");
            billObject.isAggregatedRec = getStringField(obj, "isAggregatedRec");
            billObject.memo = getStringField(obj, "memo");
            billObject.month = getStringField(obj, "month");
            billObject.oppositeLogo = getStringField(obj, "oppositeLogo");
            billObject.oppositeMemGrade = getStringField(obj, "oppositeMemGrade");
            billObject.sceneId = getStringField(obj, "sceneId");

            XposedLogUtils.log("bizInNo:" + billObject.bizInNo + ", consumeFee:" + billObject.consumeFee + ", gmtCreate:" + billObject.gmtCreate);
            billObjectList.add(billObject);
        }


        Intent broadCastIntent = new Intent();
        broadCastIntent.putParcelableArrayListExtra(AliParamUtils.mBillListParcelString, billObjectList);
        broadCastIntent.setAction(PluginReceiver.BILL_LIST_INTENT_FILTER_ACTION);
        mBillActivity.sendBroadcast(broadCastIntent);
    }

    private String getStringField(final Object obj, final String fieldName) throws IllegalAccessException {
        Field sField = XposedHelpers.findField(obj.getClass(), fieldName);
        if (sField == null) {
            return null;
        }
        return String.valueOf(sField.get(obj));
    } 
  

我们在插件的广播接收类中取出账单数据并打印出来:

    @Override
    public void onReceive(Context context, Intent intent) {
        if (intent.getAction().contentEquals(BILL_LIST_INTENT_FILTER_ACTION)) {
            List billObjectList = intent.getParcelableArrayListExtra(AliParamUtils.mBillListParcelString);
            LogUtils.e("billObjectListISNull ", billObjectList == null ? "true" : "false" + billObjectList.size());

            if (billObjectList != null && billObjectList.size() > 0) {
                for (AliHook.BillObject billObject : billObjectList) {
                    LogUtils.e("billObject:", billObject.toString());
                }
            }
        }
    }

可以看到如下日志:

bizInNo:2018050521001004450538532688,bizStateDesc:null,bizSubType:1041,canDelete:true,bizType:TRADE,consumeFee:+0.01,consumeStatus:2,consumeTitle:信息,createDesc:周六,createTime:05-05,destinationUrl:null,gmtCreate:1525488487000,isAggregatedRec:false,memo:null,month:null,oppositeLogo:http://tfs.alipayobjects.com/images/partner/TB1GZtrXXmb81Jjme7TXXc6FpXa_160X160,oppositeMemGrade:golden,sceneId:null
bizInNo:2018050521001004640504157182,bizStateDesc:null,bizSubType:1041,canDelete:true,bizType:TRADE,consumeFee:-0.01,consumeStatus:2,consumeTitle:小鸡蛋,createDesc:周六,createTime:05-05,destinationUrl:null,gmtCreate:1525488253000,isAggregatedRec:false,memo:null,month:null,oppositeLogo:https://gw.alipayobjects.com/zos/mwalletmng/mukPPhtdXrnqECpCXXDq.png,oppositeMemGrade:null,sceneId:null
bizInNo:2018050521001004640504337006,bizStateDesc:null,bizSubType:1041,canDelete:true,bizType:TRADE,consumeFee:-0.01,consumeStatus:2,consumeTitle:收款,createDesc:周六,createTime:05-05,destinationUrl:null,gmtCreate:1525488220000,isAggregatedRec:false,memo:null,month:null,oppositeLogo:https://gw.alipayobjects.com/zos/mwalletmng/mukPPhtdXrnqECpCXXDq.png,oppositeMemGrade:null,sceneId:null
bizInNo:2018050421001004640500816650,bizStateDesc:null,bizSubType:73,canDelete:true,bizType:TRADE,consumeFee:-31.40,consumeStatus:2,consumeTitle:家家乐生活超市,createDesc:周五,createTime:05-04,destinationUrl:null,gmtCreate:1525432915000,isAggregatedRec:false,memo:null,month:null,oppositeLogo:1lhro7mDT_S35zjkEc5I9AAAACMAAQQD,oppositeMemGrade:null,sceneId:null

这不就是账单信息吗,其中"consumeFee"对应的是收账或者消费的金额,"consumeTitle"对应的是备注,"bizInNo"对应的是订单编号,"gmtCreate"对应的账单的时间戳,如果我们只想要收款信息,可以判断"consumeTitle"字段的值是否以"+"开头,

到此,我们已经可以获得账单数据了,但是还有问题,账单数据时分页加载的,每次大概加载20条数据,要想获得全部的账单数据,我们必须要模拟下拉获得更多数据,通过查看账单页面的代码,我们最终找到下拉加载更多是通过调用下面的方法来请求更多账单数据的:

  public final void e()
  {
    this.w = false;
    if (this.v) { // 判断是否加载到底了
      d(); // 生成请求获取账单数据
    }
  }

其中d()方法是真正的请求数据的方法:

  protected final void d()
  {
    Object localObject;
    if (this.w)
    {
      this.u.pageType = "WaitPayConsumeQuery";
      localObject = new PagingCondition();
      this.u.paging = ((PagingCondition)localObject);
    }
    if ((this.u.startTime != null) && (this.u.startTime.longValue() != 0L) && (this.u.endTime != null) && (this.u.endTime.longValue() != 0L))
    {
      this.u.needMonthSeparator = Boolean.valueOf(false);
      boolean bool = s();
      if (this.w)
      {
        if (this.J != null)
        {
          this.J.getRpcSubscriber().cancelRpc();
          this.J.getRpcSubscriber().getRpcUiProcessor().hideFlowTipViewIfShow();
          if (this.R != null) {
            this.R.getRpcSubscriber().cancelRpc();
          }
        }
        localObject = new GetBillListDataRunnable();
        o localo = new o(this, this, bool);
        RpcRunConfig localRpcRunConfig = new RpcRunConfig();
        localRpcRunConfig.showFlowTipOnEmpty = true;
        localRpcRunConfig.showNetError = true;
        localRpcRunConfig.exceptionMode = "exception_all";
        localRpcRunConfig.loadingMode = LoadingMode.SILENT;
        localRpcRunConfig.cacheKey = this.K;
        localRpcRunConfig.flowTipHolderViewId = R.id.bill_list_flow_tip;
        localRpcRunConfig.cacheType = new p(this);
        this.J = new RpcRunner(localRpcRunConfig, (RpcRunnable)localObject, localo, new QueryListResProcessor()); // 生成一个RpcRunner对象
      }
      if ((!bool) || (!this.w)) {
        break label380;
      }
      if (this.aj) {
        break label364;
      }
      this.J.getRpcRunConfig().cacheMode = CacheMode.CACHE_AND_RPC;
      label292:
      if (!bool) {
        break label410;
      }
      if ((!this.w) || (this.z == null) || (this.z.getCount() != 0)) {
        break label396;
      }
      this.J.getRpcRunConfig().showFlowTipOnEmpty = true;
    }
    for (;;)
    {
      this.J.start(new Object[] { this.u }); // 发起rpc请求,其中this.u是请求参数
      return;
      this.u.needMonthSeparator = Boolean.valueOf(true);
      break;
      label364:
      this.J.getRpcRunConfig().cacheMode = CacheMode.RPC_AND_SAVE_CACHE;
      break label292;
      label380:
      this.J.getRpcRunConfig().cacheMode = CacheMode.NONE;
      break label292;
      label396:
      this.J.getRpcRunConfig().showFlowTipOnEmpty = false;
      continue;
      label410:
      if (this.w) {
        this.J.getRpcRunConfig().showFlowTipOnEmpty = true;
      } else {
        this.J.getRpcRunConfig().showFlowTipOnEmpty = false;
      }
    }
  }

我们在需要加载更多账单数据时只需要调用e()方法,即可加载更多账单数据

callMethod(mBillActivity, "e");

其中mBillActivity是账单页面对象,我们hook BillListActivity_的onCreate方法,然后记录账单Activity对象,并且在onDestory是将mBillActivity设置为null,代码如下:

        findAndHookMethod("com.alipay.mobile.bill.list.ui.BillListActivity_", mBillListActivityClassLoader, "onCreate", Bundle.class, new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                XposedLogUtils.log("com.alipay.mobile.bill.list.ui.BillListActivity_" + ":onCreated");
                mBillActivity = (Activity) param.thisObject;
            }
        });

        // hook BaseActivity onDestroy方法,BaseActivity是支付宝Activity共有的父类,通过类名判断是否是账单页面被销毁,因为BillListActivity_并没有重写onDestory方法,所有是不能直接hook它的onDestory方法的
        findAndHookMethod("com.alipay.mobile.framework.app.ui.BaseActivity", classLoader, "onDestroy", new XC_MethodHook() {
            @Override
            protected void afterHookedMethod(MethodHookParam param) throws Throwable {

                if (param.thisObject.getClass().getName().equals("com.alipay.mobile.bill.list.ui.BillListActivity_")) {
                    XposedLogUtils.log("com.alipay.mobile.bill.list.ui.BillListActivity_" + ":onDestroy方法");
                    mBillActivity = null;
                }
            }
        });

ok,加载更多账单数据的问题解决,在考虑一下,我们是否能够在每次获得最新的账单数据时不需要重新打开账单页面,而是模拟账单页面的下拉刷新呢?我们知道账单页面是通过APListView外面套了一个APPullRefreshView来实现下拉刷新的,打开APPullRefreshView的代码:

public class APPullRefreshView
  extends APFrameLayout
  implements GestureDetector.OnGestureListener
{
  private APPullRefreshView.RefreshListener d;
  // 应该是这下拉刷新的监听类    
  public void setRefreshListener(APPullRefreshView.RefreshListener paramRefreshListener)
  {
    if (this.mOverView != null) {
      removeView(this.mOverView);
    }
    this.d = paramRefreshListener;
    this.mOverView = this.d.getOverView();
    paramRefreshListener = new FrameLayout.LayoutParams(-1, -2);
    addView(this.mOverView, 0, paramRefreshListener);
    getViewTreeObserver().addOnGlobalLayoutListener(new APPullRefreshView.1(this));
  }
}

在看一下APPullRefreshView.RefreshListener的代码:

package com.alipay.mobile.commonui.widget;

public abstract interface APPullRefreshView$RefreshListener
{
  public abstract boolean canRefresh();
  
  public abstract APOverView getOverView();
  
  public abstract void onRefresh();
}

可以看到APPullRefreshView.RefreshListener中有一个onRefresh()方法,猜想这个应该是控件下拉刷新的回调方法,在具体的下拉刷新监听类中肯定要去实现该方法,完成下拉刷新的回调。现在我们可以获得账单页面的APPullRefreshView对象,然后又可以通过反射获得其APPullRefreshView.RefreshListener对象,那么我们直接调用其onRefresh方法,不就可以模拟下拉刷新了吗?代码如下:

    Field aPPullRefreshViewFiled = XposedHelpers.findField(mBillActivity.getClass().getSuperclass(), "g"); // 获得账单页面APPullRefreshView对应的Field
    final Object aPPullRefreshView = aPPullRefreshViewFiled.get(mBillActivity); // 获得账单页面APPullRefreshView对象
    Field refreshListenerField = XposedHelpers.findField(findClass(AliParamUtils.mAPPullRefreshViewClassFullName, mClassLoader), "d"); // 获得账单页面APPullRefreshView.RefreshListener对应的Field
    final Object refreshListener = refreshListenerField.get(aPPullRefreshView); // 获得账单页面APPullRefreshView.RefreshListener对象
    callMethod(refreshListener, "onRefresh"); // 调用账单页面APPullRefreshView.RefreshListener对象的onRefresh方法,模拟下拉刷新

到这里,已经可以通过调用下拉刷新的回调方法获得最新的账单数据,通过调用上拉加载更多的回调方法获得更多的账单数据,然而,支付宝每次请求账单数据时,大概只会请求20条左右的数据,如果账单数据量比较大,那么就需要发送多次网络请求才能获得全部账单数据。我们知道支付宝是通过Rpc来进行网络传输的,而要发起网络请求,会调用RpcRunner的start方法:

  public void start(Object... paramVarArgs)
  {
    if ((this.rpcTask != null) && (paramVarArgs != null)) {
      this.rpcTask.setParams(paramVarArgs);
    }
    start(this.rpcTask);
  }

其中"Object... paramVarArgs"是请求参数,例如请求账单数据调用的代码如下:

this.J.start(new Object[] { this.u });

其中this.J是一个RpcRunner对象,this.u是账单页面的请求参数,定义如下:

private QueryListReq u;
package com.alipay.mobilebill.common.service.model.pb;

import com.squareup.wire.Message;
import com.squareup.wire.Message.Datatype;
import com.squareup.wire.Message.Label;
import com.squareup.wire.ProtoField;
import java.util.Collections;
import java.util.List;

public final class QueryListReq
  extends Message
{
  @ProtoField(tag=28, type=Message.Datatype.INT64)
  public Long asyncQueryTaskId;
  @ProtoField(tag=27, type=Message.Datatype.STRING)
  public String batchTagId;
  @ProtoField(tag=24, type=Message.Datatype.STRING)
  public String billMonthCategoryId;
  @ProtoField(tag=25, type=Message.Datatype.STRING)
  public String billMonthSubCategoryId;
  @ProtoField(tag=14, type=Message.Datatype.STRING)
  public String bizState;
  @ProtoField(tag=8, type=Message.Datatype.STRING)
  public String bizSubType;
  @ProtoField(tag=7, type=Message.Datatype.STRING)
  public String bizType;
  @ProtoField(tag=1, type=Message.Datatype.STRING)
  public String category;
  @ProtoField(tag=20, type=Message.Datatype.STRING)
  public String categoryId;
  @ProtoField(tag=23, type=Message.Datatype.STRING)
  public String ceilAmount;
  @ProtoField(tag=15, type=Message.Datatype.STRING)
  public String consumeStatus;
  @ProtoField(tag=10, type=Message.Datatype.STRING)
  public String date;
  @ProtoField(tag=29, type=Message.Datatype.STRING)
  public String dateType;
  @ProtoField(tag=18, type=Message.Datatype.INT64)
  public Long endTime;
  @ProtoField(tag=16, type=Message.Datatype.STRING)
  public String extReq;
  @ProtoField(tag=26, type=Message.Datatype.STRING)
  public String extraFilter;
  @ProtoField(tag=22, type=Message.Datatype.STRING)
  public String floorAmount;
  @ProtoField(tag=11, type=Message.Datatype.STRING)
  public String inout;
  @ProtoField(tag=6, type=Message.Datatype.STRING)
  public String month;
  @ProtoField(tag=5, type=Message.Datatype.BOOL)
  public Boolean needMonthSeparator;
  @ProtoField(tag=30, type=Message.Datatype.STRING)
  public String oldCategoryName;
  @ProtoField(tag=13, type=Message.Datatype.STRING)
  public String oppositeCardNo;
  @ProtoField(tag=2, type=Message.Datatype.STRING)
  public String pageType;
  @ProtoField(tag=3)
  public PagingCondition paging; // 配置当前页信息,比如页面条数,是否有下一页等
  @ProtoField(tag=12, type=Message.Datatype.STRING)
  public String product;
  @ProtoField(tag=9, type=Message.Datatype.STRING)
  public String scene;
  @ProtoField(tag=4, type=Message.Datatype.STRING)
  public String searchKeyWords;
  @ProtoField(tag=17, type=Message.Datatype.INT64)
  public Long startTime;
  @ProtoField(tag=21, type=Message.Datatype.STRING)
  public String subCategoryId;
  @ProtoField(label=Message.Label.REPEATED, tag=19, type=Message.Datatype.STRING)
  public List tagIdList;
}


public final class PagingCondition
  extends Message
{
  @ProtoField(tag=7, type=Message.Datatype.STRING)
  public String defQueryEndTime;
  @ProtoField(tag=4, type=Message.Datatype.BOOL)
  public Boolean hasNextPage;
  @ProtoField(tag=5, type=Message.Datatype.INT32)
  public Integer listQueryTime;
  @ProtoField(tag=6, type=Message.Datatype.INT32)
  public Integer nextPageDate;
  @ProtoField(tag=3, type=Message.Datatype.INT32)
  public Integer nextPageMonth;
  @ProtoField(tag=2, type=Message.Datatype.INT32)
  public Integer nextPageNum;
  @ProtoField(tag=1, type=Message.Datatype.INT32)
  public Integer pageSize; // 每页请求的数据条数
}

我们只关注当前页的请求数据条数,通过日志打印我们知道QueryListReq.paging.pageSize配置的就是当前页请求的数据条数,我们只需要在发起请求前修改这个参数,就可以修改每页请求的数据条数了。那么怎样修改呢?可以考虑hook RpcRunner的start方法,判断参数是否是QueryListReq类型,如果是,则表示是在请求账单数据,然后修改参数中的pageSize,最后再发起请求,即可达到修改参数的目的,代码如下:

       // hook rpc执行请求方法,可以在这里修改请求参数
        findAndHookMethod(AliParamUtils.mRpcRunnerFullClassName, classLoader, "start", Object[].class, new XC_MethodHook() {
            @Override
            protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
                if (param.args[0] != null) {
                    XposedLogUtils.log(param.args[0].toString());
                    XposedLogUtils.log(param.args[0].getClass().getName());
                    if (param.args[0] instanceof Object[]) {
                        Object[] objects = (Object[]) param.args[0];
                        if (objects.length > 0) {
                            Object queryListReq = objects[0];
                            if (queryListReq != null && queryListReq.getClass().getName().contentEquals("com.alipay.mobilebill.common.service.model.pb.QueryListReq")) {
                                Object queryListReqPaging = XposedHelpers.getObjectField(queryListReq, "paging");
                                if (queryListReqPaging != null) {
                                    XposedHelpers.setObjectField(queryListReqPaging, "pageSize", 200); // 修改账单请求参数,自定义每次请求数据
                                }
                            }
                        }
                    }
                }
                super.beforeHookedMethod(param);
            }
        });

上面将每页的请求数据修改为200条,经过测试是没有问题的,成功一次请求到了200条账单数据,但当将其修改为1000条时,提示获得账单数据失败,估计支付宝后台有限制,毕竟1000条账单数据量还是挺大的,至于最大可以设置为多少有兴趣可以自己测试。到此,我们实现了可以动态的修改每次账单数据的请求条数。

另外一种情况是,当没有账单数据时,是不会调用BillListAdapter的"a"方法来添加数据,这个时候我们就需要hook其他的方法,来判断是否有账单数据,通过观察,我们找到了账单页面的在收到账单数据的回调方法,如下:

 @UiThread
  protected void a(QueryListRes paramQueryListRes, boolean paramBoolean);

也就是说,只要账单数据返回来了,一定是会调用该方法的,我们可以在这个方法中判断账单数据是否为空,并且判断账单数据是否加载完成:

        Method method = billActivityClass.getDeclaredMethod("a", findClass("com.alipay.mobilebill.common.service.model.pb.QueryListRes", mClassLoader), boolean.class);

        if (method != null) {
            XposedBridge.hookMethod(method, new XC_MethodHook() {
                @Override
                protected void afterHookedMethod(MethodHookParam param) throws Throwable {
                    XposedLogUtils.log(AliParamUtils.mBillListActivityClassFullName + ":called a");
                    Activity activity = (Activity) param.thisObject;
                    if (activity != null && mLastTimestamp > 0) {
                        int noRecordId = FindResourceIdUtils.getFieldId("com.alipay.mobile.bill.list", "id", "bill_list_flow_tip", mBillListActivityClassLoader);
                        View noRecordView = activity.findViewById(noRecordId);
                        if (noRecordView != null && noRecordView.getVisibility() == View.VISIBLE) {
                            XposedLogUtils.log("------" + "用户账单数据为空" + "-----------");
                        } else {
                            boolean isCanLoadMore = (boolean) callMethod(param.thisObject, AliParamUtils.mBillListCanLoadMoreMethodName);
                            boolean isFromCache = (boolean) param.args[1];
                            XposedLogUtils.log("------" + isCanLoadMore + " " + isFromCache + "-----------");
                            if (!(isCanLoadMore || isFromCache)) {
                                XposedLogUtils.log("------没有更多数据了,到底了");
                            }
                        }

                    }
                }
            });
        }

通过查找账单页面是否存在账单为空的view来判断账单数据是否为空。

六、优化设置金额页面

现在每次获得二维码链接时,都必须打开和关闭一次设置金额页面,这个对速度是有影响的,那么我们是否能够只打开一次设置金额页面,获得二维码链接后不关闭页面呢?下面来分析一下设置金额页面在获得服务端返回的二维码链接数据后的回调方法:

public class PayeeQRSetMoneyActivity
  extends BaseActivity

  protected final void a(ConsultSetAmountRes paramConsultSetAmountRes)
  {
    runOnUiThread(new ct(this, paramConsultSetAmountRes));
  }
}
final class ct
  implements Runnable
{
  ct(PayeeQRSetMoneyActivity paramPayeeQRSetMoneyActivity, ConsultSetAmountRes paramConsultSetAmountRes) {}
  
  public final void run()
  {
    PayeeQRSetMoneyActivity.a.b("call processConsultSetAmountRes(), ConsultSetAmountRes = " + this.a);
    if (this.a != null)
    {
      if (!this.a.success) {
        break label140;
      }
      Intent localIntent = new Intent();
      localIntent.putExtra("codeId", this.a.codeId);
      localIntent.putExtra("qr_money", this.b.g);
      localIntent.putExtra("beiZhu", this.b.c.getInputedText());
      localIntent.putExtra("qrCodeUrl", this.a.qrCodeUrl);
      localIntent.putExtra("qrCodeUrlOffline", this.a.printQrCodeUrl);
      this.b.setResult(-1, localIntent); // 设置数据到intent,回传给收款页面生成对应的二维码
      this.b.finish(); // 关闭设置金额页面
    }
    for (;;)
    {
      return;
      label140:
      this.b.alert("", this.a.message, this.b.getString(R.string.payee_confirm), null, null, null);
    }
  }
}

通过上面的代码我们知道,在收款二维码的回调方法中,会在UI线程关闭当前页面,并将数据传递给收款页面,我们只需要替换掉a方法,就可以不关闭设置金额页面了,代码如下:

        // hook获得二维码url的回调方法(替换方法 )
        findAndHookMethod(findClass("com.alipay.mobile.payee.ui.PayeeQRSetMoneyActivity", classLoader), "a", findClass("com.alipay.transferprod.rpc.result.ConsultSetAmountRes", classLoader), new XC_MethodReplacement() {
            @Override
            protected Object replaceHookedMethod(MethodHookParam methodHookParam) throws Throwable {
                Object consultSetAmountRes = methodHookParam.args[0];
                String consultSetAmountResString = "";
                if (consultSetAmountRes != null) {
                    consultSetAmountResString = (String) callMethod(consultSetAmountRes, "toString");
                }
                // 获得返回数据后发送广播将数据传给支付宝插件程序
                Activity activity = (Activity) methodHookParam.thisObject;
                sendQrLinkBroadCast(activity, consultSetAmountResString, mJinEr, mBeiZhu);
                return null;
            }
        });

七、自动登录

从服务端获得账户名密码后,我们需要调用支付宝的方法来实现自动登录,首先用hierarchy view看一下登录页面:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第20张图片

首先可以看到登录页面名称为"com.alipay.mobile.security.login.ui.AlipayUserLoginActivity",在新的支付宝版本,添加了刷脸登录功能,如果之前登录过支付宝,它会让最近登录的账号选择是刷脸登录还是密码登录,选择密码登录就会显示密码登录的布局,注意,这个时候并没有跳转到另外的Activity,只是改变了界面的布局而已,看一下密码登录界面的布局:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第21张图片

用hierarchy view看一下布局结构:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第22张图片

可以看到账号和密码的输入框对应的类型是AUInputBox类,账号的id为"userAccountInput",密码的id为"userPasswordInput",我们可以通过findViewById找到这两个控件,再打开AUInputBox类,看一下其有没有设置内容的方法,通过查找,发现有如下方法:

  public void setText(CharSequence paramCharSequence)
  {
    this.mInputEditText.setText(paramCharSequence);
    paramCharSequence = this.mInputEditText.getSafeText();
    if ((paramCharSequence instanceof Spannable)) {
      Selection.setSelection((Spannable)paramCharSequence, paramCharSequence.length());
    }
  }

猜想这个应该就是设置输入框内容的方法。

再看一下选择登录方式页面,如果需要选择登录方式,我们首先要选择密码登录切换到密码登录布局,然后才能设置账号和密码输入框的内容,最后再找到登录,调用其performClick模拟点击登录按钮,具体代码如下:

    // hook 支付宝登陆界面的onCreate方法,获得主界面对象并注册广播
    findAndHookMethod("com.alipay.mobile.security.login.ui.AlipayUserLoginActivity", mClassLoader, "onCreate", Bundle.class, new XC_MethodHook() {
        @Override
        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
            XposedLogUtils.log("com.alipay.mobile.security.login.ui.AlipayUserLoginActivity" + ":onCreated方法");
            mLoginActivity = (Activity) param.thisObject;
            Intent intent = ((Activity) param.thisObject).getIntent();
            if (intent != null) {
                String account = intent.getStringExtra("account");
                String password = intent.getStringExtra("password");
                XposedLogUtils.log("account:" + account);
                XposedLogUtils.log("password:" + password);
                executeLogin(account, password); // 执行模拟登录
            }
        }
    });

    // hook 支付宝的登陆界面的onDestory方法,将保持的登录页面对象设置为空
    findAndHookMethod("com.alipay.mobile.security.login.ui.AlipayUserLoginActivity", mClassLoader, "onDestroy", new XC_MethodHook() {
        @Override
        protected void afterHookedMethod(MethodHookParam param) throws Throwable {
            XposedLogUtils.log("com.alipay.mobile.security.login.ui.AlipayUserLoginActivity" + ":onDestroy方法");
            mLoginActivity = null;
        }
    });


    /**
     * 模拟登录
     *
     * @param account
     * @param password
     */
    public static void startLogin(String account, String password) {
        XposedLogUtils.log("mLauncherActivity :" + mLauncherActivity);
        if (mLauncherActivity != null) {
            if (mLoginActivity != null) {  // 如果登录界面已经启动则直接在当前登录界面模拟登录
                executeLogin(account, password);
            } else { // 如果登录页面没有启动则先启动登录页面
                XposedLogUtils.log("startLogin" + " " + account + " " + password);
                Class alipayUserLoginActivityClass = XposedHelpers.findClass(AliParamUtils.mAlipayUserLoginActivityClassFullName, mClassLoader);
                Intent launcherIntent;
                launcherIntent = new Intent(mLauncherActivity, alipayUserLoginActivityClass);
                launcherIntent.putExtra("account", account);
                launcherIntent.putExtra("password", password);
                mLauncherActivity.startActivity(launcherIntent);
            }
        }
    }


    /**
     * 模拟登录
     *
     * @param account
     * @param password
     */
    public static void executeLogin(String account, String password) {
        if (mLoginActivity != null && !TextUtils.isEmpty(account) && !TextUtils.isEmpty(password)) {
            XposedLogUtils.log("executeLogin" + " " + account + " " + password);
            mAccount = account;
            mPassword = password;
            View switchToPasswordLogin = mLoginActivity.findViewById(FindResourceIdUtils.getFieldId("com.ali.user.mobile.security.ui", "id", "switchToPasswordLogin", mClassLoader)); // 找到切换到密码登录按钮
            if (switchToPasswordLogin != null) { // 要判断该按钮是否存在,存在则需要模拟点击切换到密码登录
                switchToPasswordLogin.performClick();
                switchToPasswordLogin.postDelayed(new Runnable() { // 这里最好做一下延时处理,因为后面的密码登录布局是动态添加到布局中的,延时一下能够确保密码登录布局已经加载
                    @Override
                    public void run() {
                        executeLogin();
                    }
                }, 200);
            } else {
                executeLogin(); // 如果找不到切换到密码登录按钮则说明当前密码登录布局已经加载了
            }
        }
    }

    /**
     * 自动登录
     */
    private static void executeLogin() {
        View userAccountInput = mLoginActivity.findViewById(FindResourceIdUtils.getFieldId("com.ali.user.mobile.security.ui", "id", "userAccountInput", mClassLoader));
        View userPasswordInput = mLoginActivity.findViewById(FindResourceIdUtils.getFieldId("com.ali.user.mobile.security.ui", "id", "userPasswordInput", mClassLoader));
        XposedHelpers.callMethod(userAccountInput, "setText", mAccount);
        XposedHelpers.callMethod(userPasswordInput, "setText", mPassword);
        XposedHelpers.callMethod(userAccountInput, "setText", mAccount); // 要在设置一遍,否则账号数据无法设置上去,可能是账号输入框上保留了上一次的登录账号导致的,具体原因还不知道
        View loginButton = mLoginActivity.findViewById(FindResourceIdUtils.getFieldId("com.ali.user.mobile.security.ui", "id", "loginButton", mClassLoader));
        loginButton.performClick();
    }

上面就是所有的自动登录的代码,其中在查找控件id的时候用到了一个辅助类,定义如下:

public class FindResourceIdUtils {
    /**
     * 根据给定的类型名和字段名,返回R文件中的字段的值
     */
    public static int getFieldId(String rPackageName, String typeName, String fieldName, ClassLoader classLoader){
        int i = -1;
        try {
            Class clazz = XposedHelpers.findClass(rPackageName + ".R$" + typeName, classLoader);
            i = clazz.getField(fieldName).getInt(null);
        } catch (Exception e) {
            log("没有找到"+  rPackageName +".R$" + typeName + "类型资源 " + fieldName + "请copy相应文件到对应的目录.");
            return -1;
        }
        return i;
    }
}

八、模拟退出

在收到服务器退出当前账号的消息后,需要调用支付宝的方法退出登录,一样的,首先看一下支付宝自己的退出方式,在设置界面的最底部有一个退出登录按钮,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第23张图片

用hierarchy view看一下布局结构:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第24张图片

可以看到,设置页面对应的Class名称为UserSettingActivity_,退出登录控件的id为logout,打开UserSettingActivity_类,可以找到如下代码:

public final class UserSettingActivity_
  extends UserSettingActivity
  implements HasViews, OnViewChangedListener
{
  private final OnViewChangedNotifier i = new OnViewChangedNotifier();
  
  public final void onViewChanged(HasViews paramHasViews)
  {
    this.e = ((UserContainer)paramHasViews.findViewById(R.id.user_container));
    this.f = paramHasViews.findViewById(R.id.logout); // 找到退出登录按钮
    if (this.f != null) {
      this.f.setOnClickListener(new h(this)); // 设置点击监听
    }
    ...
  }
}

可以看到退出登录按钮的点击监听类为"h"类,"h"类定义如下:

package com.alipay.android.phone.home.user.ui;

import android.view.View;
import android.view.View.OnClickListener;

final class h
  implements View.OnClickListener
{
  h(UserSettingActivity_ paramUserSettingActivity_) {}
  
  public final void onClick(View paramView)
  {
    this.a.a();
  }
}

点开this.a.a()方法:

  @Click(resName={"logout"})
  protected final void a()
  {
    try
    {
      ((LogoutService)this.mApp.getMicroApplicationContext().getExtServiceByInterface(LogoutService.class.getName())).showChangeAccountDialog(this);
      SpmLogUtil.p();
      return;
    }
    catch (Exception localException)
    {
      for (;;)
      {
        LoggerFactory.getTraceLogger().error(this.i, localException.toString());
      }
    }
  }

大致的意思是调用了退出登录的服务的显示切换登录账号的弹框,注意,我们在点击退出登录的时候是不会直接退出登录的,而是会显示一个弹框,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第25张图片

再点击退出登录才能真正的退出登录,试想一下,既然支付宝有提供退出登录的服务,那么退出登录的功能应该也是在该服务中实现的,点击showChangeAccountDialog方法,发现打开了一个抽象类,定义如下:

package com.alipay.mobile.framework.service.ext.security;

import android.app.Activity;
import android.content.Intent;
import com.alipay.mobile.framework.service.ext.ExternalService;
import com.alipay.mobile.framework.service.ext.security.bean.UserInfo;

public abstract class LogoutService
  extends ExternalService
{
  public static final String TYPE_DEVICE_LOCK = "SingleDevice";
  public static final String TYPE_LOGOUT = "Logout";
  public static final String TYPE_NO_TOKEN = "LogoutNoToken";
  
  public abstract void localLogout(String paramString);
  
  public abstract void logout();
  
  public abstract void showChangeAccountDialog(Activity paramActivity);
  
  public abstract void syncLogout(String paramString, UserInfo paramUserInfo, Intent paramIntent);
}

其中有一个logout()方法,猜想真正的退出登录应该就是调用的该方法,现在我们只需获得退出登录的服务,并且调用其logout方法,即可完成退出登录,看上面的代码,是通过如下代码获得退出登录服务的:

this.mApp.getMicroApplicationContext().getExtServiceByInterface(LogoutService.class.getName()))

先要获得App对象,然后调用其getMicroApplicationContext()方法获得一个MicroApplicationContext对象,在调用MicroApplicationContext对象的getExtServiceByInterface方法,传入服务的类名即可获得对应的服务,而这里的mApp对象已经在界面中初始化好了,通过查看代码,我们有其他的方式获得App对象,通过调用AlipayApplication的getInstance方法,可以获得一个App对象的实例,然后就可以获得退出登录的服务了,最后在调用其logout方法即可实现退出登录:

    /**
     * 退出登录
     */
    public static void startLogout() {
        XposedLogUtils.log("startLogout");
        Object alipayApplication = callStaticMethod(findClass("com.alipay.mobile.framework.AlipayApplication", mLoadPackageParam.classLoader), "getInstance");
        Object microApplicationContext = callMethod(alipayApplication, "getMicroApplicationContext");
        Object logoutService = callMethod(microApplicationContext, "getExtServiceByInterface", "com.alipay.mobile.framework.service.ext.security.LogoutService");
        callMethod(logoutService, "logout");
    }

九、获得用户信息

想要获得的用户信息包括当前登录用户的账号,头像,登录token等,首先我们在支付宝"我的"页面"是可以看到当前登录用户的账号,头像等信息的,如下:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第26张图片

用hierarchy view看一下布局结构:

利用xposed自动获得支付宝个人收款二维码链接和收款记录_第27张图片

发现这一块信息是在一个叫"AccountInfoView"的控件中,在反编译代码中搜索AccountInfoView类,发现有如下代码:

 public void refreshUserData()
  {
    Object localObject = UserInfoCacher.a().a;
    ...
  }

而用户的信息都是从这个localObject中获取到的,点开"UserInfoCacher.a().a"的定义,如下:

package com.alipay.android.widgets.asset.utils;

import com.alipay.mobile.common.helper.UserInfoHelper;
import com.alipay.mobile.framework.AlipayApplication;
import com.alipay.mobile.framework.MicroApplicationContext;
import com.alipay.mobile.framework.service.ext.security.bean.UserInfo;

public class UserInfoCacher
{
  private static UserInfoCacher b;
  public UserInfo a;
  private MicroApplicationContext c = AlipayApplication.getInstance().getMicroApplicationContext();
  
  public static UserInfoCacher a()
  {
    try
    {
      if (b == null)
      {
        localUserInfoCacher = new com/alipay/android/widgets/asset/utils/UserInfoCacher;
        localUserInfoCacher.();
        b = localUserInfoCacher;
      }
      UserInfoCacher localUserInfoCacher = b;
      return localUserInfoCacher;
    }
    finally {}
  }
  
  public final void b()
  {
    this.a = UserInfoHelper.getInstance().getUserInfo(this.c);
  }
}

可以看到对象"a"的类型为UserInfo,猜想其应该是对应的用户信息类,而UserInfoCacher类应该是用户信息的缓存类,打开UserInfo类,定义如下:

package com.alipay.mobile.framework.service.ext.security.bean;

import android.os.Parcel;
import android.os.Parcelable;
import android.os.Parcelable.Creator;
import android.text.TextUtils;
import com.ali.user.mobile.utils.StringUtil;
import com.alipay.mobile.security.util.AuthUtil;
import com.j256.ormlite.field.DatabaseField;
import java.io.Serializable;

public class UserInfo
  implements Parcelable, Serializable, Cloneable
{
  public static final Parcelable.Creator CREATOR = new a();
  public static final String GENDER_FEMALE = "f";
  public static final String GENDER_MALE = "m";
  private static final long serialVersionUID = 1L;
  @DatabaseField
  private boolean autoLogin;
  @DatabaseField
  private String colorStr;
  @DatabaseField
  private String customerType;
  @DatabaseField
  private String extern_token;
  private String fingerprintAuthInfo;
  @DatabaseField
  private String gender;
  @DatabaseField
  private String gestureAppearMode;
  @DatabaseField
  private String gestureErrorNum;
  @DatabaseField
  private boolean gestureOrbitHide;
  @DatabaseField
  private String gesturePwd;
  @DatabaseField
  private boolean gestureSkip = false;
  @DatabaseField
  private String gestureSkipStr;
  @DatabaseField
  private String havanaId;
  @DatabaseField
  private boolean isBindCard;
  @DatabaseField
  private String isCertified;
  @DatabaseField
  private boolean isNewUser;
  @DatabaseField
  private boolean isShowWalletEditionSwitch = false;
  @DatabaseField
  private boolean isWirelessUser;
  @DatabaseField
  private String loginEmail;
  @DatabaseField
  private String loginMobile;
  @DatabaseField
  private String loginTime;
  @DatabaseField
  private String loginToken;
  @DatabaseField
  private String logonId;
  @DatabaseField
  private String memberGrade;
  @DatabaseField
  private String mobileNumber;
  @DatabaseField
  private String nick;
  @DatabaseField
  private boolean noPayPwd;
  @DatabaseField
  private String noQueryPwdUser;
  @DatabaseField
  private String otherLoginId;
  @DatabaseField
  private String realName;
  @DatabaseField
  private String realNamed;
  @DatabaseField
  private String sessionId;
  @DatabaseField
  private String shippingAddressCount;
  @DatabaseField
  private String studentCertify;
  @DatabaseField
  private String taobaoNick;
  @DatabaseField
  private String taobaoSid;
  @DatabaseField
  private String userAvatar;
  @DatabaseField(id=true)
  private String userId;
  @DatabaseField
  private String userName;
  @DatabaseField
  private String userType;
  @DatabaseField
  private String walletEdition;
  ...
}

在其中可以看到很多用户信息的定义,比如用户名称userName,用户登录账号logonId,用户登录token对应的是loginToken,还有其他的比如用户头像url对应的是userAvatar,我们可以模拟支付宝获得用户信息的代码来获得用户信息,代码如下:

    /**
     * @return
     */
    public static UserInfo getUserInfo() {
        UserInfo userInfo = new UserInfo();
        // 获得用户信息
        if (mLoadPackageParam != null) {
            XposedLogUtils.log("getUserInfo");
            Object userInfoCacherObject = callStaticMethod(findClass("com.alipay.android.widgets.asset.utils.UserInfoCacher", mClassLoader), "a");
            callMethod(userInfoCacherObject, "b");
            Field userInfoField = findField(findClass("com.alipay.android.widgets.asset.utils.UserInfoCacher", mClassLoader), "a");
            try {
                Object userInfoObject = userInfoField.get(userInfoCacherObject);
                if (userInfoCacherObject != null) {
                    userInfo.userName = getStringField(userInfoObject, "userName");
                    userInfo.userAvatar = getStringField(userInfoObject, "userAvatar");
                    userInfo.userId = getStringField(userInfoObject, "userId");
                    userInfo.nick = getStringField(userInfoObject, "nick");
                    userInfo.loginToken = getStringField(userInfoObject, "loginToken");
                    userInfo.loginEmail = getStringField(userInfoObject, "loginEmail");
                    userInfo.loginMobile = getStringField(userInfoObject, "loginMobile");
                    userInfo.mobileNumber = getStringField(userInfoObject, "mobileNumber");
                    userInfo.userType = getStringField(userInfoObject, "userType");
                    userInfo.taobaoNick = getStringField(userInfoObject, "taobaoNick");
                    userInfo.customerType = getStringField(userInfoObject, "customerType");
                    userInfo.logonId = getStringField(userInfoObject, "logonId");
                    XposedLogUtils.log(userInfo.toString());
                }
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        }
        return userInfo;
    }


    private static String getStringField(final Object obj, final String fieldName) throws IllegalAccessException {
        Field sField = XposedHelpers.findField(obj.getClass(), fieldName);
        if (sField == null) {
            return null;
        }
        return String.valueOf(sField.get(obj));
    }

其中UserInfo类是我们自己定义的用来保存需要的用户信息类,定义如下:

    /**
     * 用户信息类
     */
    public static class UserInfo implements Parcelable{
        public String nick; // 昵称
        public String userName; // 用户名
        public String userId; // 用户id
        public String loginToken; // 登录token
        public String userAvatar; // 头像
        public String loginEmail;
        public String loginMobile;
        public String mobileNumber;
        public String userType;
        public String taobaoNick;
        public String customerType;
        public String logonId; // 登录账号

        public UserInfo() {

        }

        protected UserInfo(Parcel in) {
            nick = in.readString();
            userName = in.readString();
            userId = in.readString();
            loginToken = in.readString();
            userAvatar = in.readString();
            loginEmail = in.readString();
            loginMobile = in.readString();
            mobileNumber = in.readString();
            userType = in.readString();
            taobaoNick = in.readString();
            customerType = in.readString();
            logonId = in.readString();
        }

        public static final Creator CREATOR = new Creator() {
            @Override
            public UserInfo createFromParcel(Parcel in) {
                return new UserInfo(in);
            }

            @Override
            public UserInfo[] newArray(int size) {
                return new UserInfo[size];
            }
        };

        @Override
        public String toString() {
            return "nick:" + nick + "," +
                    "userName:" + userName + "," +
                    "userId:" + userId + "," +
                    "loginToken:" + loginToken + "," +
                    "loginEmail:" + loginEmail + "," +
                    "loginMobile:" + loginMobile + "," +
                    "mobileNumber:" + mobileNumber + "," +
                    "userType:" + userType + "," +
                    "taobaoNick:" + taobaoNick + "," +
                    "customerType:" + customerType + "," +
                    "userAvatar:" + userAvatar + "," +
                    "logonId:" + logonId + "\n";
        }

        @Override
        public int describeContents() {
            return 0;
        }

        @Override
        public void writeToParcel(Parcel dest, int flags) {
            dest.writeString(nick);
            dest.writeString(userName);
            dest.writeString(userId);
            dest.writeString(loginToken);
            dest.writeString(userAvatar);
            dest.writeString(loginEmail);
            dest.writeString(loginMobile);
            dest.writeString(mobileNumber);
            dest.writeString(userType);
            dest.writeString(taobaoNick);
            dest.writeString(customerType);
            dest.writeString(logonId);
        }
    }

注意,因为这里获得用户信息是从支付宝缓存中获得的,在退出状态时,登录账号、用户名称等信息还是保存的上一次登录的用户信息,如果要判断当前是否有用户登录,可以直接判断loginToken是否为空,当在退出登录状态下时,loginToken是为空的,而登录状态下loginToken是不为空的。

 

十、总结

1、进程直接的通信有很多种方式,比如Binder,Socket,BroadcastReceiver等,后面需要详细了解一下。这里我们使用广播的方式实现进程间的通信,有两个原因,第一是因为广播简单;第二是因为可以动态注册,不需要在AndroidManifest.xml中注册,而使用Binder的方式需要注册Service,而Service又只能在AndroidManifest.xml文件中注册,但是我们是无法修改支付宝的AndroidManifest.xml文件的;

2、支付宝有反hook机制,可以利用xposed hook住支付宝的hook检测方法,修改返回参数来解决;

3、支付宝的收款记录页面是一个h5页面,这个时候可以通过抓包工具来拦截网络请求,分析请求信息,然后模拟请求来获得收款记录(没有备注信息);

4、支付宝的账单页面代码在动态库中,在第一次启动账单页面时才会去加载,它对应的ClassLoader和支付宝主程序的ClassLoader不是同一个ClassLoader对象,我们可以找到启动账单页面的代码,在打开支付宝主界面后直接启动账单页面,然后hook支付宝加载动态库时创建的BundleClassLoader对象的构造方法,通过判断动态库名称来确认是否是账单页面的动态库,这样在支付宝打开主界面后自动获得账单页面对应的ClassLoader了;

5、支付宝的动态库在lib目录下,都是以so文件结尾,但是其实它是假的so库,应该属于apk包之类的,将so文件重命名为.zip文件,解压之后通过反编译就可以看到对应的代码;

6、利用xposed hook有很多技巧,下面举例说明:

a、当查看代码分析不清楚流程时,可以尝试将对应类的全部方法都hook住,然后打印方法的调用流程,这样就能知道对应操作的方法调用流程;

b、当实在找不到对应的反编译代码时,可以通过反射将对应类的所有字段(包括字段名称和类型)、方法(包括方法名称、传入参数、返回参数)都打印出来,这样对类的结构能有一个大致的印象(之前一直以为账单动态库是so库,无法反编译,就是通过这种方式分析账单页面的)

c、当一个问题正面不好突破时,可以尝试反向思维,比如我们最开始通过hook Activity的onCreate方法,然后判断类名的方式获得了账单页面的对象,并且知道了其动态加载库的位置和其ClassLoader的类型

d、hierarchy view是一个很好的查看布局的工具,我们只有对界面的布局有所了解之后,才能更好的找到突破点

e、在分析反编译代码时,全局搜索觉得是一个好办法,可以在搜索内容中找到很多相关的信息;

e、最后一点,一定要大胆猜想和尝试,通过不断的打印日志来验证自己的猜想,这样才能找到对应的解决办法。

最后附上源码地址:https://github.com/2449983723/alipay-master

(源码未更新,只有最基本的获得二维码链接功能)

 

 

严重声明

本文的意图只有一个就是通过分析app学习更多的逆向技术,如果有人利用本文知识和技术进行非法操作进行牟利,带来的任何法律责任都将由操作者本人承担,和本文作者无任何关系,最终还是希望大家能够秉着学习的心态阅读此文。

 


 

 

 

 

 

 


 

 

 

      

你可能感兴趣的:(android)