Java 代码审计前置知识点

零、前言

Java代码审计自然需要Java语言的基本功底,这方面网上有很多Java教程,也有大量的书可以参考。理论上有其他语言的基础,学习Java也不是很困难,但是Java尤其是框架有一些比较“有意思”、“很强大”而又“不太好理解”的特性,这里尝试对其进行讲解,为Java代码审计做一个知识铺垫。

一、知识特性

1、泛型

一个类的成员变量,一个函数中的参数,都具有一种数据类型,可以为基本数据类型(如int类型)或者引用类型(如Interger类型)。假设我们想描述平面坐标的一个点,那么我们创建一个Point类,这个类要包含表示X坐标和Y坐标的成员变量:

public class Point<T> {
    private T x;
    private T y;

    public T getX() {
        return x;
    }

    public void setX(T x) {
        this.x = x;
    }

    public T getY() {
        return y;
    }

    public void setY(T y) {
        this.y = y;
    }
}

如果我们发现int类型描述平面坐标上的点精度不够,希望换做double类型,那么代码应该大部分是类似的,可是上面的代码我们却无法复用。我们只能再定义一个非常类似的DoublePoint重新定义为:

public class DoublePoint{
    private double x;
    private double y;
    
    public double getX() {
        return x;
    }
    public void setX(double x) {
        this.x = x;
    }
    public double getY() {
        return y;
    }
    public void setY(double y) {
        this.y = y;
    } 
}

能否只定义一个类就能满足坐标既可能为int类型,也可能为double类型的情况呢?如果可以的话将可以让代码更加通用,减少大量的重复代码。答案是肯定的,这个时候你需要泛型。在使用泛型时,我们可以把类型作为参数传入到泛型类中。类似于把参数传入到方法中一样。我们来实现一个通用的泛型Point类:

public class Point<T> {
    private T x;
    private T y;

    public T getX() {
        return x;
    }

    public void setX(T x) {
        this.x = x;
    }

    public T getY() {
        return y;
    }

    public void setY(T y) {
        this.y = y;
    }
}

此时Point成为了一个泛型类,T是则是类型参数,T具体是什么类型那要看程序运行的时候我们传入什么类型给他。因此你可以直观的理解“泛型”两个字,“泛”即是指一切,定义好之后它可以充当所有类型。使用泛型类时,注意实际传入的类型参数不能是原生类型,必须是引用类型,因此如果希望传入int类型的话,那么需要传入int对应的包装类Interger。对应地,double类型则要传入包装类Double。

public class Test{
    public static void main(String[] args){
    
      // 坐标为int类型,把int类型的包装类Integer作为参数传入泛型类中
      Point<Integer> point1 = new Point<Integer>();
      point1.setX(1);
      point1.setY(1);
  
      // 坐标为double类型,把double类型的包装类Double作为参数传入泛型类中
      Point<Double> point2 = new Point<Double>();
      point2.setX(3.456);
      point2.setY(4.789);
    }
}

泛型应用的典型的例子就是容器类,也叫集合类。以下面这个例子为例,我们定义一个容器类Container,这个容器中可以存放各种类型的对象,可以使用泛型类实现这一特性。

public class Container<T> {
    private T variable;

    public Container () {
        variable = null;
    }

    public Container (T variable) {
        this.variable = variable;
    }

    public T get() {
        return variable; 
    }

    public void set(T variable) {
        this.variable = variable;    
    }
    
    public static void main(String[] args) {
        Container<String> stringContainer = new Container<String>();
        stringContainer.set("this is a string");
    }
}

我们实例化Container对象时,只需设置它使用的类型,如:

Container<String> stringContainer = new Container<String>();
stringContainer.set("this is a string");

Java已经帮我们内置很多原生的集合类,比如ArrayList、LinkedList(List接口的实现)和HashMap(Map接口的实现),当我们声明一个集合时:

ArrayList<Integer> nums = new ArrayList<Integer>();
Map<Long, String> posts = new HashMap<Long, String>();

2、反射

反射是一种动态获取信息以及动态调用对象方法的机制。在程序运行状态中,通过反射能够知道某个类具有哪些属性和方法;能够访问某一个对象的方法和属性。具体来说,反射机制主要提供了以下功能:

  1. 在运行时判断任意一个对象所属的类;
  2. 在运行时构造任意一个类的对象;
  3. 在运行时判断任意一个类所具有的成员变量和方法;
  4. 在运行时调用任意一个对象的方法;
  5. 生成动态代理。

Java中用class这个关键字来声明一个类,同时Java中也有一个类对象叫 Class,它表示类本身这个概念,每一个类都可以获取一个代表自身Class对象,有以下三种方式进行获取

public class ReflectionTest {
    public static void main(String[] args) throws Exception {
        Class<?> class1 = null;
        Class<?> class2 = null;
        Class<?> class3 = null;
        
        // 第一种方式
        class1 = Class.forName("com.java.ReflectionTest");
        
        // 第二种方式
        class2 = new ReflectionTest().getClass();
        
        // 第三种方式
        class3 = ReflectionTest.class;
        
        System.out.println("类名称   " + class1.getName());
        System.out.println("类名称   " + class2.getName());
        System.out.println("类名称   " + class3.getName());
    }
}

获得一个类的Class对象后我们可以完全操纵这个类,访问它的所有方法和属性(包括private限定符下的),以调用类方法为例

public class ReflectionTest {

    public static void main(String[] args) throws Exception {
        Class<?> clazz = Class.forName("com.java.ReflectionTest");
        
        // 通过反射获取方法并调用方法
        Method method = clazz.getMethod("reflect");
        method.invoke(clazz.newInstance());
        
        // 通过反射获取方法,并进行传参调用方法
        method = clazz.getMethod("reflect", int.class, String.class);
        method.invoke(clazz.newInstance(), 20, "张三");
    }
    
    public void reflect() {
        System.out.println("Java 反射机制 - 调用无参方法");
    }
    
    public void reflect(int age, String name) {
        System.out.println("Java 反射机制 - 调用有参数方法");
        System.out.println("age -> " + age + ". name -> " + name);
    }
}

主要就是获得方法和执行方法两个步骤,在上一篇博客中介绍的Strust2反序列化漏洞就是通过连续反射去调用Runtime.getRuntime().exec(cmd)执行命令的。

3、控制反转(IOC)和依赖注入(DI)

控制反转(Inversion of Control)本质上是一种思想而不是具体的技术。它想要做的事情或者说达到的目的正如它的字面意思一样,将控制反转过来!那么我们要先明白,在一般情况下是谁控制谁。在传统的编程思想中,我们使用面向对象的编程方法,将想要表示的事务用类表达,逻辑实现的过程实际上是对类对象的声明和调用,整个过程都是由程序员去控制,这是一种很自然的做法。但是慢慢地,这种方式会显露出很多弊端,比如为了进行功能调用会在一个类中实例化其他类对象,或者一个类继承另外一个类,这些操作会造成各个模块代码的耦合度很高,而且类的实例化和赋值等操作全由程序员管理,使得代码很难维护和修改。

控制反转即是将类的实例化、赋值等操作全由容器来完成,也就是说将类的控制权从程序员手中转交到容器,此为反转。那么我们只要代码中指定的位置进行“标注”,容器会自动将我们需要类提供给我们,而我们只需要去使用它,专心于业务逻辑即可。

那么什么是依赖注入(Dependency Injection)呢,前面我们说控制反转是一种思想,依赖注入就是实现这种思想的具体方法。容器将上层代码需要的对象注入到需要的位置(这里一般通过配置文件说明或者使用注解标注),那么注入是如何实现的呢?使用的方法就是我们上文说的反射,反射能够动态的获取类对象,调用类属性和方法。下面有两个更好的解释:

一个形象化的解释:https://zhuanlan.zhihu.com/p/33492169

一个更形象化的解释:https://www.zhihu.com/question/23277575

4、注解

注解(Annotation)就像一种注释,不过注释是给人看的,而注解是给程序自己看的。它能够告诉程序当某中注解下的代码代表了什么含义,在执行的时候按照某种规约去执行。以上文的IOC和DI为例

@Component
public class UserDao {
    ...
}

这里定义了一个UserDao对象,使用@Component注解,它是Spring的一个注解,定义一个Spring Bean。启动了自动扫描后,被@Component标注的类都自动会注册为Spring Beans。

@Component
public class UserService {
    @Autowired
    private UserDao userDao;
    ...
}

然后在使用UserDao对象时,我们使用@Autowired注解,Spring框架会自动将这个我们需要的类对象装配(注入)到userDao变量中,我们不需要显示的声明。在学习注解时,不需要记住每个注解的含义,见到陌生的查一下即可,一般熟悉几个常用的即可。

一个形象的解释:https://blog.csdn.net/briblue/article/details/73824058

5、面向切面编程(AOP)

面向切面编程(Aspect Oriented Program)是一种很经典的编程思想,并不是什么新的事物,我们在开发程序时往往需要写各种各样的功能,这其中有一些是核心功能,有一些是通用功能。为了能够方便对核心/通用进行分离和组合,便促生了面向切面编程,这里的切面便是指“通用”。比如现在成熟的系统中一定会有日志记录功能,这并不是核心功能,但每个主要的功能或者程序运行的节点都要进行日志记录,如果写在一起势必会造成代码臃肿和耦合。根据切面的思想,就可以将日志功能单独分离出来,重点是如何将功能组合起来,这里就涉及AOP的底层技术啦,有三种方式:

  1. 编译时织入:在代码编译时,把切面代码融合进来,生成完整功能的Java字节码,这就需要特殊的Java编译器了,AspectJ属于这一类
  2. 类加载时织入:在Java字节码加载时,把切面的字节码融合进来,这就需要特殊的类加载器,AspectJ和AspectWerkz实现了类加载时织入
  3. 运行时织入:在运行时,通过动态代理的方式,调用切面代码增强业务功能,Spring采用的正是这种方式。动态代理会有性能上的开销,但是好处就是不需要特殊的编译器和类加载器啦,按照写普通Java程序的方式来就行了!

6、拦截器和过滤器

我们用Struts2的运行流程图来解释:

上图为Struts2的官方流程图,不同的颜色代表了不同的任务分工,下面从从一个请求的生命周期来完整的分析。
FilterDispacher
请求会首先经过Servlet Filters,一般最后一个FilterDispacher,它是必须存在的,负责将请求转发到Struts2。
ActionMapper
它会将请求转发给ActionMapper,ActionMapper会检查这个请求是否需要Struts2处理。
ActionProxy
当请求进入的时候会生成一个ActionProxy对象,它并不是某个具体的Action,它会通过ConfigurationManager去检查struts.xml文件,通过请求的url的匹配特定的Action
ActionInvocation
ActionInvocation会做真正的逻辑处理部分,它会首先执行Action之前所有的Interceptor,然后执行Action类中具体的方法,再根据返回结果调用具体的前端文件,然后反向执行之前的Interceptor,构造Response返回给浏览器。

一个很好的解释:https://www.jianshu.com/p/3e6433ead5c3

二、参考文献

这是两个很棒的Java基础和框架的入门教程:

http://how2j.cn

https://course.tianmaying.com

 

发表评论

电子邮件地址不会被公开。 必填项已用*标注