9

Android避坑指南,Gson与Kotlin碰撞出一个不安全的操作

 4 years ago
source link: https://blog.csdn.net/lmj623565791/article/details/106631386
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

本文已经授权「鸿洋」公众号原创首发。

最近发现微信多了个专辑功能,可以把一系列的原创文章聚合,刚好我每周都会遇到很多同学问我各种各样的问题,部分问题还是比较有意义的,我会在周末详细的写demo验证,简单扩展一下写成文章分享给大家。

当然不鼓励大家随便私聊我问问题,大家可以去星球提问,公众号后台回复「星球」就能看到入口了,那里有5000多人,我毕竟还是有工作要忙。

先看一个问题

来一起看一段代码:

public class Student  {
    private Student() {
        throw new IllegalArgumentException("can not create.");
    }
    public String name;
}

我们如何通过Java代码创建一个Student对象?

我们先想下通过Java创建对象大概有哪些方式:

  1. new Student() // 私有
  2. 反射调用构造方法 //throw ex
  3. 反序列化 // 需要实现相关序列化接口
  4. clone // 需要实现clone相关接口

好了,已经超出我的知识点范畴了。

不免心中嘀咕:

这题目太偏了,毫无意义,而且文章标题是 Android 避坑指南,看起来毫无关系

是的,确实很偏,跳过这个问题,我们往下看,看看是怎么在Android开发过程中遇到的,而且看完后,这个问题就迎刃而解了。

问题的来源

上周一个群有个小伙伴,遇到了一个Kotlin写的Bean,在做Gson将字符串转化成具体的Bean对象时,发生了一个不符合预期的问题。

因为是他们项目的代码,我就不贴了,我写了个类似的小例子来替代。

对于Java Bean,kotlin可以用data class,网上也有很多博客表示:

在 Kotlin 中,不需要自己动手去写一个 JavaBean,可以直接使用 DataClass,使用 DataClass 编译器会默默地帮我们生成一些函数。

我们先写个Bean:

data class Person(var name: String, var age: Int) {


}

这个Bean是用于接收服务器数据,通过Gson转化为对象的。

简化一下代码为:

val gson = Gson()
val person = gson.fromJson<Person>("{\"age\":\"12\"}", Person::class.java)

我们传递了一个json字符串,但是没有包含key为name的值,并且注意:

在Person中name的类型是String,也就是说是不允许name=null的

那么上面的代码,我运行起来结果是什么呢?

  1. 报错,毕竟没有传name的值;
  2. 不报错,name 默认值为"";
  3. 不报错,name=null;

感觉1最合理,也符合Kotlin的空安全检查。

验证一下,修改一下代码,看一下输出:

val gson = Gson()
val person = gson.fromJson<Person>("{\"age\":\"12\"}", Person::class.java)
println(person.name )

输出结果:

null

是不是有些奇怪,感觉意外绕过了Kotlin的空类型检查。

所以那位出问题的同学,在这里之后数据就出了问题,导致一直排查困难。

我们再改一下代码:

data class Person(var name: String, var age: Int): People(){

}

我们让Person继承自People类:

public class People {

    public People(){
        System.out.println("people cons");
    }

}

在People类的构造方法中打印日志。

我们都清楚,正常情况下,一般构造子类对象,必然会先执行父类的构造方法。

运行一下:

没有执行父类构造方法,但对象构造出来了

这里可以猜到,Person对象的构建,并不是常规的构建对象,没有走构造方法。

那么它是怎么做到的呢?

那只能去Gson的源码中取找答案了。

找到其怎么做的,其实就相当于解答了我们文首的问题。

Gson这样构造出一个对象,但是没有走父类构造这种,如果真是的这样,那么是极其危险的。

会让程序完全不符合运行预期,少了一些必要逻辑。

所以我们提前说一下,大家不用太惊慌,并不是Gson很容易出现这样的情况,而是恰好上例的写法碰上了,我们一会会说清楚。

首先我们把Person这个kotlin的类,转成Java,避免背后藏了一些东西:

# 反编译之后的显示
public final class Person extends People {
   @NotNull
   private String name;
   private int age;

   @NotNull
   public final String getName() {
      return this.name;
   }

   public final void setName(@NotNull String var1) {
      Intrinsics.checkParameterIsNotNull(var1, "<set-?>");
      this.name = var1;
   }

   public final int getAge() {
      return this.age;
   }

   public final void setAge(int var1) {
      this.age = var1;
   }

   public Person(@NotNull String name, int age) {
      Intrinsics.checkParameterIsNotNull(name, "name");
      super();
      this.name = name;
      this.age = age;
   }

   // 省略了一些方法。
}

可以看到Person有一个包含两参的构造方法,并且这个构造方法中有name的空安全检查。

也就是说,正常通过这个构造方法构建一个Person对象,是不会出现空安全问题的。

那么只能去看看Gson的源码了:

Gson的逻辑,一般都是根据读取到的类型,然后找对应的TypeAdapter去处理,本例为Person对象,所以会最终走到ReflectiveTypeAdapterFactory.create然后返回一个TypeAdapter。

我们看一眼其内部代码:

# ReflectiveTypeAdapterFactory.create
@Override 
public <T> TypeAdapter<T> create(Gson gson, final TypeToken<T> type) {
	Class<? super T> raw = type.getRawType();
	
	if (!Object.class.isAssignableFrom(raw)) {
	  return null; // it's a primitive!
	}
	
	ObjectConstructor<T> constructor = constructorConstructor.get(type);
	return new Adapter<T>(constructor, getBoundFields(gson, type, raw));
}

重点看constructor这个对象的赋值,它一眼就知道跟构造对象相关。

# ConstructorConstructor.get
public <T> ObjectConstructor<T> get(TypeToken<T> typeToken) {
    final Type type = typeToken.getType();
    final Class<? super T> rawType = typeToken.getRawType();
	
	// ...省略一些缓存容器相关代码

    ObjectConstructor<T> defaultConstructor = newDefaultConstructor(rawType);
    if (defaultConstructor != null) {
      return defaultConstructor;
    }

    ObjectConstructor<T> defaultImplementation = newDefaultImplementationConstructor(type, rawType);
    if (defaultImplementation != null) {
      return defaultImplementation;
    }

    // finally try unsafe
    return newUnsafeAllocator(type, rawType);
  }

可以看到该方法的返回值有3个流程:

  1. newDefaultConstructor
  2. newDefaultImplementationConstructor
  3. newUnsafeAllocator

我们先看第一个newDefaultConstructor

private <T> ObjectConstructor<T> newDefaultConstructor(Class<? super T> rawType) {
    try {
      final Constructor<? super T> constructor = rawType.getDeclaredConstructor();
      if (!constructor.isAccessible()) {
        constructor.setAccessible(true);
      }
      return new ObjectConstructor<T>() {
        @SuppressWarnings("unchecked") // T is the same raw type as is requested
        @Override public T construct() {
            Object[] args = null;
            return (T) constructor.newInstance(args);
            
            // 省略了一些异常处理
      };
    } catch (NoSuchMethodException e) {
      return null;
    }
  }

可以看到,很简单,尝试获取了无参的构造函数,如果能够找到,则通过newInstance反射的方式构建对象。

追随到我们的Person的代码,其实该类中只有一个两参的构造函数,并没有无参构造,从而会命中NoSuchMethodException,返回null。

返回null会走newDefaultImplementationConstructor,这个方法里面都是一些集合类相关对象的逻辑,直接跳过。

那么,最后只能走:**newUnsafeAllocator ** 方法了。

从命名上面就能看出来,这是个不安全的操作。

newUnsafeAllocator最终是怎么不安全的构建出一个对象呢?

往下看,最终执行的是:

public static UnsafeAllocator create() {
// try JVM
// public class Unsafe {
//   public Object allocateInstance(Class<?> type);
// }
try {
  Class<?> unsafeClass = Class.forName("sun.misc.Unsafe");
  Field f = unsafeClass.getDeclaredField("theUnsafe");
  f.setAccessible(true);
  final Object unsafe = f.get(null);
  final Method allocateInstance = unsafeClass.getMethod("allocateInstance", Class.class);
  return new UnsafeAllocator() {
    @Override
    @SuppressWarnings("unchecked")
    public <T> T newInstance(Class<T> c) throws Exception {
      assertInstantiable(c);
      return (T) allocateInstance.invoke(unsafe, c);
    }
  };
} catch (Exception ignored) {
}
  
// try dalvikvm, post-gingerbread use ObjectStreamClass
// try dalvikvm, pre-gingerbread , ObjectInputStream

}

可以看到Gson在没有找到无参的构造方法后,通过sun.misc.Unsafe构造了一个对象。

注意:Unsafe该类并不是所有的Android 版本中都包含,不过目前新版本都包含,所以Gson这个方法中有3段逻辑都是用来生成对象的,你可以认为3重保险,针对不同平台。 本文测试设备:Android 29模拟器

我们这里暂时只讨论sun.misc.Unsafe,其他的其实一个意思。

sun.misc.Unsafe和许API?

Unsafe是位于sun.misc包下的一个类,主要提供一些用于执行低级别、不安全操作的方法,如直接访问系统内存资源、自主管理内存资源等,这些方法在提升Java运行效率、增强Java语言底层资源操作能力方面起到了很大的作用。但由于Unsafe类使Java语言拥有了类似C语言指针一样操作内存空间的能力,这无疑也增加了程序发生相关指针问题的风险。在程序中过度、不正确使用Unsafe类会使得程序出错的概率变大,使得Java这种安全的语言变得不再“安全”,因此对Unsafe的使用一定要慎重。
https://tech.meituan.com/2019/02/14/talk-about-java-magic-class-unsafe.html

具体可以参考美团的这篇文章。

好了,到这里就真相大白了。

原因是我们Person没有提供默认的构造方法,Gson在没有找到默认构造方法时,它就直接通过Unsafe的方法,绕过了构造方法,直接构建了一个对象。

到这里,我们收获了:

  1. Gson是如何构建对象的?
  2. 我们在写需要Gson转化为对象的类的时候,一定要记得有默认的构造方法,否则虽然不报错,但是很不安全!
  3. 我们了解到了还有这种Unsafe黑科技的方式构造对象。

回到文章开始的问题

Java中咋么构造一个下面的Student对象呢?

public class Student  {
    private Student() {
        throw new IllegalArgumentException("can not create.");
    }
    public String name;
}

我们模仿Gson的代码,编写如下:

try {
    val unsafeClass = Class.forName("sun.misc.Unsafe")
    val f = unsafeClass.getDeclaredField("theUnsafe")
    f.isAccessible = true
    val unsafe = f.get(null)
    val allocateInstance = unsafeClass.getMethod("allocateInstance", Class::class.java)
    val student = allocateInstance.invoke(unsafe, Student::class.java)
    (student as Student).apply {
        name = "zhy"
    }
    println(student.name)
} catch (ignored: Exception) {
    ignored.printStackTrace()
}
zhy

成功构建。

Unsafe 一点用没有?

看到这里,大家可能最大的收获就是了解Gson构建对象流程,以及以后写Bean的时候会注意提供默认的无参构造方法,尤其在使用Kotlin data class的时候。

那么刚才我们所说的Unsafe方法就没有其他实际用处吗?

这个类,提供了类似C语言指针一样操作内存空间的能力。

大家都知道在Android P上面,Google限制了app对hidden API的访问。

但是,Google不能限制自己对hidden API访问对吧,所以它自己的相关类,是允许访问hidden API的。

那么Google是如何区分是我们app调用,还是它自己调用呢?

通过ClassLoader,系统认为如果ClassLoader为BootStrapClassLoader则就认为是系统类,则放行。

那么,我们突破P访问限制,其中一个思路就是,搞一个类,把它的ClassLoader换成BootStrapClassLoader,从而可以反射任何hidden api。

怎么换呢?

只要把这个类的classLoader成员变量设置为null就可以了。

参考代码:

private void testJavaPojie() {
	try {
	  Class reflectionHelperClz = Class.forName("com.example.support_p.ReflectionHelper");
	  Class classClz = Class.class;
	  Field classLoaderField = classClz.getDeclaredField("classLoader");
	  classLoaderField.setAccessible(true);
	  classLoaderField.set(reflectionHelperClz, null);
	} catch (Exception e) {
		  e.printStackTrace();
	}
}
来自:https://juejin.im/post/5ba0f3f7e51d450e6f2e39e0

但是这样有个问题,上面的代码用到了反射修改一个类的classLoader成员,假设google有一天把反射设置classLoader也完全限制掉,就不行了。

那么怎么办?原理还是换ClassLoader,但是我们不走Java反射的方式了,而是用Unsafe:

@Keep
public class ReflectWrapper {
 
    //just for finding the java.lang.Class classLoader field's offset
    @Keep
    private Object classLoaderOffsetHelper;
 
    static {
        try {
            Class<?> VersionClass = Class.forName("android.os.Build$VERSION");
            Field sdkIntField = VersionClass.getDeclaredField("SDK_INT");
            sdkIntField.setAccessible(true);
            int sdkInt = sdkIntField.getInt(null);
            if (sdkInt >= 28) {
                Field classLoader = ReflectWrapper.class.getDeclaredField("classLoaderOffsetHelper");
                long classLoaderOffset = UnSafeWrapper.getUnSafe().objectFieldOffset(classLoader);
                if (UnSafeWrapper.getUnSafe().getObject(ReflectWrapper.class, classLoaderOffset) instanceof ClassLoader) {
                    Object originalClassLoader = UnSafeWrapper.getUnSafe().getAndSetObject(ReflectWrapper.class, classLoaderOffset, null);
                } else {
                    throw new RuntimeException("not support");
                }
            }
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
来自作者区长:一种纯 Java 层绕过 Android P 私有函数调用限制的方式,一文。

Unsafe赋予了我们操作内存的能力,也就能完成一些平时只能依赖C++完成的代码。

好了,从一位朋友遇到的问题,由此引发了一整篇文章的讨论,希望你能有所收获。

感谢郭霖,淡蓝色星期三,天空等朋友。


Recommend

  • 78

  • 82

    一年一度的双十一马上就要来了,热衷于“买买买”的小伙伴们是否已经快要抑制不住体内的洪荒之力了呢?不过千万要记得:剁手需谨慎哟! 这两年的「双 11」,比以往来得都要早,去年阿里从 10 月 20 日开始造势,今年也不例外,想必这段时间以来,不少人已经各种求赞...

  • 63

  • 46
    • dockone.io 6 years ago
    • Cache

    Prometheus 不完全避坑指南

    Prometheus 是一个开源监控系统,它本身已经成为了云原生中指标监控的事实标准,几乎所有 Kubernetes 的核心组件以及其它云原生系统都以 Prometheus 的指标格式输出自己的运行时监控信息。我在工作中也比较深入地使用过 Prometheus,最大...

  • 64
    • www.tuicool.com 6 years ago
    • Cache

    C++避坑指南

    导语:如果,将编程语言比作武功秘籍,C++无异于《九阴真经》。《九阴真经》威力强大、博大精深,经中所载内功、轻功、拳、掌、腿、刀法、剑法、杖法、...

  • 62

    .NET Core 3.0将会在 .NET Conf 大会上正式发布,截止今日发布了9个预览版,改动也是不少,由于没有持续关注,今天将前面开源的动态WebApi项目迁移到.NET...

  • 57
    • 微信 mp.weixin.qq.com 5 years ago
    • Cache

    Golang 避坑指南(一):interface 之坑多多

    点击上方蓝色“ Go语言中文网 ”关注我们, 领全套Go资料 ,每天学习 Go 语言 interface{}和 void* /Object 是一样的吗?

  • 42

    通过上一篇对engine代码的分析,我们了解到 每展示一个platform view,flutter都会同时创建一个全屏的overlay view 。这个overlay view的作用,是解决platform view和flutter view遮挡的问题。 而这套实现机制,...

  • 18

    最近发现微信多了个专辑功能,可以把一系列的原创文章聚合,刚好我每周都会遇到很多同学问我各种各样的问题,部分问题还是比较有意义的,我会在周末详细的写demo验证,简单扩展一下写成文章分享给大家。 当然不鼓励大家随便私聊...

  • 17

    这是我之前项目同学遇到的一个问题,现实代码比较复杂,现在我将尽可能简单的描述这个问题,并且内容重心会放在预防阶段。 一、问题的起源 先看一个非常简单的model类Boy: public class Boy { public String boyName;...

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK