如何使用 Xposed 保护自己的权益

3,467 阅读9分钟

如何使用 Xposed 保护自己的权益

本文仅是 Xposed 简单入门使用

我们在注册有些 App 时候会遇到一些关键性证件的上传,包括但不限于身份证,银行卡, 驾照等,有时还会伴有自己正面手持身份证免冠照。这些关键性信息被上传后怎么处置,对大多数人来说都是未知的,这些信息有可能有滥用,而我们只能眼看着被滥用。所以这时候我们可能需要将照片打上水印,比如:仅限 XX App 注册审核使用类似的字样,但是不巧的时候,部分 App 并不能从相册选择图片,只能拍照,然后上传。这时候就 Xposed 大法好

本次针对某共享汽车 App 注册提交身份信息部分进行操作,想做到将它拍得的照片替换为我们已经处理过加上水印的照片

思路

  • 简单逆向分析 App
  • 寻找合适时机 Hook 函数
  • 将拍摄的照片修改/替换为本地加上水印的图片
详细分析

App 经过使用可知整个拍照上传的逻辑依次是:长按进入拍照界面,点击拍照,跳转到预览界面,点击取消回到拍照界面,点击确定回到证件上传界面,然后立即开始上传,同样的步骤要进行三次,流程图如下

不知道为何,掘金上的 Markdown 无法显示 flow 流程图 :笑哭:

所以从上可知,Hook 的最佳时机就是拍完照之后预览之前,狸猫换太子,将拍照得到的数据替换为我们本地数据

简单逆向

我们使用 TopActivity 获取到 App 包名和证件类型选择并上传界面的名字以备用,打开 jadx-gui 分析 App:

  • 根据包名和界面名字,定位至所需界面 CameraActivity
protected void a(Bundle bundle) {
    setRequestedOrientation(0);
    setHideVirtualKey(getWindow());
    getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(
        new OnSystemUiVisibilityChangeListener(this) {
            final /* synthetic */ CameraActivity a;
            {
                this.a = r1;
            }
            public void onSystemUiVisibilityChange(int i) {
                this.a.setHideVirtualKey(this.a.getWindow());
            }
        });
    Intent intent = getIntent();
        if (intent != null) {
            this.j = intent.getIntExtra("type", 0);
            switch (this.j) {
                case 0:
                    this.i = intent.getIntegerArrayListExtra("list");
                    this.h = intent.getStringExtra("photo_type");
                    h();
                    return;
                case 1:
                case 2:
                    this.mEg.setVisibility(8);
                    this.mBg.setBackgroundResource(R.drawable.img_camera_frame_4);
                    this.textLayout.setVisibility(8);
                    return;
                case 3:
                    this.mEg.setVisibility(8);
                    this.mBg.setBackgroundResource(R.drawable.img_camera_frame_1);
                    this.textLayout.setVisibility(8);
                    return;
                case 4:
                case 8:
                    this.mEg.setVisibility(8);
                    this.mBg.setBackgroundResource(R.drawable.img_camera_frame_7);
                    this.textLayout.setVisibility(8);
                    return;
                case 5:
                    this.mEg.setVisibility(8);
                    this.mBg.setBackgroundResource(R.drawable.img_camera_frame_6);
                    this.textLayout.setVisibility(8);
                    return;
                case 6:
                    this.mEg.setVisibility(8);
                    this.mBg.setBackgroundResource(R.drawable.img_camera_frame_3);
                    this.textLayout.setVisibility(8);
                    return;
                case 7:
                    this.mEg.setVisibility(8);
                    this.mBg.setBackgroundResource(R.drawable.img_camera_frame_2);
                    this.textLayout.setVisibility(8);
                    return;
                default:
                    return;
            }
        }
}

通过分析,基本可以肯定这部分代码就是 onCreate(Bundle bundle)。其中取出了 Intent 的数据进行界面渲染(这个界面在拍照时是通用的,只是拍照界面的蒙版不同,所以这里取出了 Intent 进行判断蒙版类型)。我们 Hook 这个界面,intnet 的数据也是需要的,我们在代码中需要对照片类型进行判断(主要是身份证正反面的判断),所以我在此处多留意了一下

  • 既然是拍照,那去看看点击事件:
@OnClick(a = {2131296340, 2131296620, 2131297361})
public void onClick(View view) {
     switch (view.getId()) {
         case R.id.back:
             onBackPressed();
             return;
         case R.id.flash:
             this.e = (this.e + 1) % a.length;
             this.mFlash.setImageResource(c[this.e]);
             ((a) this.a_).a(a[this.e]);
             return;
         case R.id.take_photo:
             this.mTakePhoto.setEnabled(false);
             ((a) this.a_).a(new i<byte[]>(this) {
                 final /* synthetic */ CameraActivity a;
                 {
                     this.a = r1;
                 }
                 public void a(byte[] bArr, Camera camera) {
                     this.a.getBitMap(bArr, new File(this.a.getCacheDir(), System.currentTimeMillis() + ".jpg"));
                 }
             });
             return;
         default:
             return;
     }
 }

根据资源名字可以得知监听了返回键、闪光灯、拍照键、这也和我们界面对得上

Screenshot_2018-08-14-15-11-43-589_com.x.y

接下来重点关注拍照,可以看到有个回调 public void a(byte[] bArr, Camera camera),这应该就是拍照之后的回调了,其中的 bArr 包含的就是相机拍照后携带的数据,回调内的 this.a.getBitMap(byte[] b , File file) 猜测应该是将byte[]图片数据转为Bitmap的,我们继续跟

  • this.a.getBitMap(byte[] b , File file)
public void getBitMap(byte[] bArr, File file) {
        FileUtil.byte2File(bArr, file);
        b(file);
    }

果不其然,通过相机回调中的入参到这里可以知道,拍照结束后,初始化了一个空的 File 出来,文件路径位于data/data/包名/cache 下,文件名为系统当前时间.jpg。紧接着在 getBitMap() 中通过 FileUtil 将照片的 byte[] 数据写入到 File 中。其实可以在这里进行 hook 了,我们只需要将入参 byte[] bArr 拦截修改为自己本地的数据就好,但我嫌麻烦....毕竟还要转成字节,于是跟着看了看 b(File file)

  • b(File file)
private void b(final File file) {
        f.a((Context) this).a(file).b(100).a(new g(this) {
            final /* synthetic */ CameraActivity b;
            public void a() {
            }
            public void a(File file) {
                if (this.b.j == 0) {
                    this.b.k = BitmapFactory.decodeFile(file.getPath());
                    this.b.a(file);
                    return;
                }
                this.b.mTakePhoto.setEnabled(true);
                Intent intent = new Intent(this.b, PreviewActivity.class);
                intent.putExtra(au.a, file.getPath());
                intent.putExtra("type", this.b.j);
                this.b.startActivity(intent);
            }
            public void a(Throwable th) {
                if (this.b.j == 0) {
                    this.b.k = FileUtil.getHortalBitmap(file.getPath());
                    this.b.a(file);
                    return;
                }
                this.b.mTakePhoto.setEnabled(true);
                Intent intent = new Intent(this.b, PreviewActivity.class);
                intent.putExtra(au.a, file.getPath());
                intent.putExtra("type", this.b.j);
                this.b.startActivity(intent);
            }
        }).a();
    }

这段代码就有意思了,用过鲁班的人应该就比较清楚了(我也是这段时间重新把我们项目中的鲁班看了看,刚好有印象),这段代码极像鲁班的调用方式,最后的 .a() 不出意外应该是鲁班的 .launch()方法,后面我特地去验证了下,确实是鲁班,哈哈,当然这是题外话了。内部有点像 Rx 的回调,当然这个也不是重点...

寻找合适的 Hook 时机

看到这,流程基本清楚了:通过点击拍照,然后初始化一个 File,通过辅助工具类将图片数据写入 File 中,然后紧接着使用鲁班进行压缩,完毕后带着图片路径和拍照类型进入到预览界面

到此,Hook 入手点就有了,谋求简单的话,直接 Hook 方法 b(File file)的入参,我们将入参修改为我们自己的本地图片即可

开始 Hook

  • 导入 Xposed 框架

    主工程下的 Gradle 下引入依赖

    compileOnly 'de.robv.android.xposed:api:82'
    

    当然也可以一并在引入源码,方面查阅

  • 新建一个类实现 IXposedHookLoadPackage 接口并实现其方法

    class Hook : IXposedHookLoadPackage { 
    	@Throws(Throwable::class)
        override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
        }
    }
    
  • 在主工程下新建 assets 目录,然后在其中新建一个无后缀名的文件 xposed_init,然后在其中将上面步骤新建的 Hook 类的完整类名写入

    com.hidetag.xposed.test.Hook
    
  • handleLoadPackage 回调中,我们先用回调参数 lpparam 取出包名,然后和我们的目标 App(需要进行 Hook 的 App)的包名进行比较,排除其他 App 干扰

    if ("com.x.y" == lpparam.packageName) {
        // 骚操作
    }
    
  • Xposed 给我们提供了很多个 Hook 方式,在这里我们采用 XposedHelpers.findAndHookMethod()

    XposedHelpers.findAndHookMethod("com.x.y.module.camera.CameraActivity",
                        lpparam.classLoader, "b", File::class.java, object : 	XC_MethodHook() {
    
            @Throws(Throwable::class)
            override fun beforeHookedMethod(param: XC_MethodHook.MethodHookParam) {
                // 骚操作
            }
    })
    

    入参分别是:Hook 的目标界面完整包名,classLoader(可不传),需要精确 Hook 的函数名,函数入参的参数列表,Xposed Hook 函数的回调

    这里我们目标完整包名是 com.x.y.module.camera.CameraActivity,需要 Hook 的函数是 b(File file)(上面逆向时已经知道),参数列表只有一个File

  • 上面提到过,这个拍照界面是公用的,所以为了偷懒,我想一次性把图片都丢进去,免得多次配置。于是我需要知道当前界面的拍照类型(上面 onCreate(Bundle bundle))有提过那个 Intent

    val activity = lpparam.thisObject as Activity
    val intent = activity.intent
    val type = intent.getIntExtra("type", 0)
    

    根据 lpparam 可以获取当前环境的 Context,我们在 Activity 中玩耍的,所以可以强转为 Activity,然后根据上面逆向的结果,取出 intent 中的 type

    那我们怎么知道我们那个界面是什么 type 呢?方法有二:

    • 根据我们的点击/长按将这个 type 打印出来,获取我们所需的 type

    • 根据上面逆向中的 switch case 中的资源文件分辨 this.mBg.setBackgroundResource(R.drawable.img_camera_frame_4);。我之前说过,这个界面的蒙版是动态根据 type 设置上去的,所以我们去看看资源文件就明白了

    • 反编译 App 之后根据上述资源文件名字找到图片,再反向找到我们的身份证和国徽页面蒙版,根据这两个蒙版的名字反向到逆向获得的代码中找到 case对应的 type,过程略过,我通过这种方式找到,身份证人像页面 type 为1,身份证国徽页为6

    • XposedBridge.log("(type = 1 身份证头像页;type = 6 身份证国徽页)type:$type")
      
  • 接下来就是狸猫换太子的过程了,我们将目标 App 中的 File 替换为手机 SD 卡的图片

    private fun getPic(name: String): File? {
        val isSdCardExist = Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
        if (isSdCardExist) {
            val sdpath = Environment.getExternalStorageDirectory().absolutePath
            val filepath = sdpath + File.separator + name
            return File(filepath)
        }
        return null
    }
    
  • 最后我们将这个狸猫设置回去

    private fun setParams(param: XC_MethodHook.MethodHookParam, localPic: File) {
        if (param.args[0] is File) {
            param.args[0] = localPic
        }
    }
    
  • 完整代码如下

    class Hook : IXposedHookLoadPackage {
    
        @Throws(Throwable::class)
        override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
            if ("com.gvsoft.gofun" == lpparam.packageName) {
                XposedHelpers.findAndHookMethod("com.x.y.module.camera.CameraActivity",
                        lpparam.classLoader, "b", File::class.java, object : XC_MethodHook() {
    
                    @Throws(Throwable::class)
                    override fun beforeHookedMethod(param: XC_MethodHook.MethodHookParam) {
                        val activity = param.thisObject as Activity
                        val intent = activity.intent
                        val type = intent.getIntExtra("type", 0)
                        XposedBridge.log("(type = 1 身份证头像页;type = 6 身份证国徽页)type:$type")
    
                        when (type) {
                            1 -> getPic("1.png")
                            6 -> getPic("2.png")
                            else -> null
                        }?.let { setParams(param, it) }
                    }
                })
    
                XposedHelpers.findAndHookMethod("com.x.y.module.camera.CameraVerticalActivity",
                        lpparam.classLoader, "a", File::class.java, object : XC_MethodHook() {
                    @Throws(Throwable::class)
                    override fun beforeHookedMethod(param: XC_MethodHook.MethodHookParam) {
                        XposedBridge.log("手持证件照")
                        getPic("3.png")?.let { setParams(param, it) }
                    }
                })
            }
        }
    
        private fun getPic(name: String): File? {
            val isSdCardExist = Environment.MEDIA_MOUNTED == Environment.getExternalStorageState()
            if (isSdCardExist) {
                val sdpath = Environment.getExternalStorageDirectory().absolutePath
                val filepath = sdpath + File.separator + name
                return File(filepath)
            }
            return null
        }
    
        private fun setParams(param: XC_MethodHook.MethodHookParam, localPic: File) {
            if (param.args[0] is File) {
                param.args[0] = localPic
            }
        }
    }
    
  • 里面的 1.png 2.png 3.png 都是我自己为了简单而命名的,直接写死,方便。。如果做成公用的,可以考虑开放名字和图片选择..

  • 这样就完成了狸猫换太子,在目标 App 的 b(File file) 函数执行之前,我们将入参替换为自己的数据,之后依靠原逻辑进行运算,产生我们想要的结果

  • 至于后面另一个 com.x.y.module.camera.CameraVerticalActivity#b(File file) 同上,因为有个是手持身份证的照片,我希望也将这个替换了,所以又 Hook 了一波,原理和思路不变

结果

Hook 成功,替换成功,我的带有水印的身份证也上传成功了,但遗憾的是没有通过审核,说我图片不清晰(毕竟加了水印) 😂 😂 😂 😂 😂 😂 😂 😂 😂 😂

尴尬😂😂😂😂😂

番外篇,如何使用 PS 给图片加水印

  • 首先新建一个空白文档,大小自定,我一般是 5CM x 5CM

    D14677AB-0328-4279-9CBB-609C2A90D066

  • 接下来输入你想打在照片上的水印内容,颜色无妨,随意即可

    B82B0DA7-BB5B-467C-A221-D6B20DE7CA40

  • 调整文字,选中状态下按住 command 或者 control 键,然后鼠标移到四个顶点任意位置,开始旋转文本,一般斜着 45° 就行,然后文字放中间,调整字号大小到自己满意就行

    7C187D6C-AD74-4434-AB75-4C2AF8DB75C2

  • 给文本加点效果,右键右边文字图层,选择图层样式,在打开的界面里选择描边,秒变颜色随意,一般都是灰色棕色之类的,我这边是 d0cfcb,然后点击确定,查看文本状态

    5D2832F0-8515-4F64-BF8E-7055A10EF7FD

  • 最后调整效果,将文本图层的填充调整为0,也可以不是0,不透明度调低点,免得最后的水印挡住原图太多,一般百分之六十左右足矣

    1CD49452-BEBA-4CDF-A098-0A56B3EEAAA9

  • 最后一步,保存为图案,编辑->定义图案->输入名字(随意)->保存

    8FE8A90F-981B-47B0-9895-527247F54B23

  • 使用:先打开需要打水印的图片,然后编辑->填充->选择图案->再选择自己保存的图案,点击确定

    BDEAF3FE-8ADB-4C88-89EA-BB6B2D3D9A97

    BA84E0D0-1379-4597-AE1D-FE1861AF2925

    D11C56DC-CA50-4F1D-BF2D-C6EF418DED37