一种普遍存在于java系统的缺陷 - Memory DoS

一、什么是DoS?

DoS是Denial of Service的简称,即拒绝服务,造成DoS的攻击行为被称为DoS攻击,其目的是使计算机或网络无法提供正常的服务。拒绝服务存在于各种网络服务上,这个网络服务可以是c、c++实现的,也可以是go、java、php、python等等语言实现。

二、Java DoS的现状

在各种开源和闭源的java系统产品中,我们经常能看到有关DoS的缺陷公告,其中大部分都是耗尽CPU类型或者业务卸载类型的DoS。耗尽CPU类型的DoS大体上主要包括“正则回溯耗尽CPU、代码大量重复执行耗尽CPU、死循环代码耗尽CPU”等。而业务卸载DoS这一类型的DoS则和系统业务强耦合,一般情况下并不具备通用性。下面用几个简单例子对其进行简单描述。

  • 正则回溯耗尽CPU

    1
    Pattern.matches(controllableVariableRegex, controllableVariableText)
  • 代码大量重复执行耗尽CPU

    1
    2
    3
    for(int i = 0; i < controllableVariable; i++) {
    //do something,e.g. consume cpu
    }
  • 死循环耗尽CPU

    1
    2
    3
    while(controllableBoolVariable) {
    //do something,e.g. consume cpu
    }
  • 业务卸载DoS(当任意用户可访问到uninstall方法的情况下,业务就可能会被恶意卸载,导致正常用户的服务被拒绝)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    private final Set<String> availables = new HashSet();

    public void service(String type, String name) {
    if (availables.contains(type)) {
    //do service
    } else {
    //reject service
    }
    }

    public void uninstall(String type) {
    availables.remove(type)
    }

我曾经在挖掘XStream反序列化利用链的时候,也找到过可以发起“正则回溯耗尽CPU、死循环耗尽CPU”类型DoS攻击的利用链,并且最终获得了XStream的多个CVE编号标记和署名。

难道Java系统中,只存在这些类型的DoS吗?我不认为是这样的,我认为Java系统中,必然存在着大量其他类型的DoS缺陷,只是我们还没发现。当我在某一天审计一个Java系统时,灵光一闪,突然发现了一个和这些DoS类型都不一样的缺陷,并且,在通过对其他大量的Java系统审计时,它普遍存在,我知道了这是一个具有普遍性存在的缺陷 - Memory DoS

三、Java异常机制

在c、c++等语言实现的网络服务中,可能存在空指针DoS、CPU耗尽DoS等等各种各样类型的DoS,为什么在Java中,DoS的类型却少得可怜?这又不得不说起Java中的异常机制了。

Java异常在JRE源码实现中,主要分为了java.lang.Exception和java.lang.Error,它们都有一个共同的实现java.lang.Throwable。经常写Java代码的程序员,可能最不喜欢就是遇到这样的麻烦了。


(关于异常的描述,简单参考了一下runoob)

异常是程序中的一些错误,但并不是所有的错误都是异常,并且错误有时候是可以避免的。

比如说,你的代码少了一个分号,那么运行出来结果是提示是错误 java.lang.Error;如果你用System.out.println(11/0),那么你是因为你用0做了除数,会抛出 java.lang.ArithmeticException 的异常。

异常发生的原因有很多,通常包含以下几大类:

  • 用户输入了非法数据。
  • 要打开的文件不存在。
  • 网络通信时连接中断,或者JVM内存溢出。

这些异常有的是因为用户错误引起,有的是程序错误引起的,还有其它一些是因为物理错误引起的。-
要理解Java异常处理是如何工作的,你需要掌握以下三种类型的异常:

  • 检查性异常:最具代表的检查性异常是用户错误或问题引起的异常,这是程序员无法预见的。例如要打开一个不存在文件时,一个异常就发生了,这些异常在编译时不能被简单地忽略。
  • 运行时异常: 运行时异常是可能被程序员避免的异常。与检查性异常相反,运行时异常可以在编译时被忽略。
  • 错误: 错误不是异常,而是脱离程序员控制的问题。错误在代码中通常被忽略。例如,当栈溢出时,一个错误就发生了,它们在编译也检查不到的。

关于Java异常机制的描述,上述已经说得很清楚了,当出现异常的时候,往往大部分是可以被捕获处理的,但是,当出现错误的时候,意味着程序已经不能正常运行了。也就是说,我们在Java系统中产生的大部分异常,是没办法导致DoS的,只有造成了错误,才会使程序不能正常运行,导致DoS,这就是为什么在Java中,DoS的类型相对少的原因了。

翻看JRE中关于错误java.lang.Error的实现,可以看到非常非常之多,而今天的主角是java.lang.OutOfMemoryError,也就是说,我们如果能让程序产生java.lang.OutOfMemoryError错误,就可以实现DoS。大多数java程序员应该都很熟悉它,抛出java.lang.OutOfMemoryError错误,一般都出现在jvm内存不足,或者内存泄露导致gc无法回收中。

四、一种普遍存在的Memory DoS

上一节说到了,我们如果能让程序产生java.lang.OutOfMemoryError错误,就可以实现DoS,它叫Memory DoS,一种耗尽内存,导致程序抛出错误的DoS攻击。

那么,如何让一个Java系统产生java.lang.OutOfMemoryError错误呢?答案必然是“耗尽内存”!

我曾经通过简单的代码扫描工具,对多个java系统、组件进行了扫描,其中发现了大量可利用的Memory DoS缺陷,是的,这意味着我能让这些系统产生java.lang.OutOfMemoryError错误。而这些系统、组件中包含了Java SE、WebLogic、Spring、Sentinel、Jackson、xstream等等比较著名的系统和组件。

0x01 Java SE

我在对Java SE的扫描中,发现了有三个class在反序列化的时候,可以导致系统产生java.lang.OutOfMemoryError错误,对系统进行Memory DoS攻击。我马上报告给了Oracle,最终在Java SE 8u301中得到修复,并且我在2021.07的安全通知中https://www.oracle.com/security-alerts/cpujul2021.html,得到了”Security-In-Depth”的署名

image

让我们先看看Java SE 8u301的修复和修复前,它们之间的差异对比吧。

java.time.zone.ZoneRules#readExternal

修复前:

1
2
3
4
5
6
7
8
9
static ZoneRules readExternal(DataInput in) throws IOException, ClassNotFoundException {
int stdSize = in.readInt();
long[] stdTrans = (stdSize == 0) ? EMPTY_LONG_ARRAY
: new long[stdSize];
for (int i = 0; i < stdSize; i++) {
stdTrans[i] = Ser.readEpochSec(in);
}
...
}

修复后:

1
2
3
4
5
6
7
8
9
10
11
12
static ZoneRules readExternal(DataInput in) throws IOException, ClassNotFoundException {
int stdSize = in.readInt();
if (stdSize > 1024) {
throw new InvalidObjectException("Too many transitions");
}
long[] stdTrans = (stdSize == 0) ? EMPTY_LONG_ARRAY
: new long[stdSize];
for (int i = 0; i < stdSize; i++) {
stdTrans[i] = Ser.readEpochSec(in);
}
...
}

java.awt.datatransfer.MimeType#readExternal

修复前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void readExternal(ObjectInput in) throws IOException,
ClassNotFoundException {
String s = in.readUTF();
if (s == null || s.length() == 0) { // long mime type
byte[] ba = new byte[in.readInt()];
in.readFully(ba);
s = new String(ba);
}
try {
parse(s);
} catch(MimeTypeParseException e) {
throw new IOException(e.toString());
}
}

修复后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void readExternal(ObjectInput in) throws IOException,
ClassNotFoundException {
String s = in.readUTF();
if (s == null || s.length() == 0) { // long mime type
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int len = in.readInt();
while (len-- > 0) {
baos.write(in.readByte());
}
s = baos.toString();
}
try {
parse(s);
} catch(MimeTypeParseException e) {
throw new IOException(e.toString());
}
}

com.sun.deploy.security.CredentialInfo#readExternal

修复前:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void readExternal(ObjectInput var1) throws IOException, ClassNotFoundException {
try {
this.userName = (String)var1.readObject();
this.sessionId = var1.readLong();
this.domain = (String)var1.readObject();
this.encryptedPassword = new byte[var1.readInt()];

for(int var2 = 0; var2 < this.encryptedPassword.length; ++var2) {
this.encryptedPassword[var2] = var1.readByte();
}
} catch (Exception var3) {
Trace.securityPrintException(var3);
}

}

修复后:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void readExternal(ObjectInput var1) throws IOException, ClassNotFoundException {
try {
this.userName = (String)var1.readObject();
this.sessionId = var1.readLong();
this.domain = (String)var1.readObject();
int var2 = var1.readInt();
if (var2 > 4096) {
throw new SecurityException("Invalid password length (" + var2 + "). It should not exceed " + 4096 + " bytes.");
}

this.encryptedPassword = new byte[var2];

for(int var3 = 0; var3 < this.encryptedPassword.length; ++var3) {
this.encryptedPassword[var3] = var1.readByte();
}
} catch (Exception var4) {
Trace.securityPrintException(var4);
}

}

通过这三个例子,大家看出来了什么了吗?

是的,这是一种利用数组在初始化时,容量参数可控,从而存在的一种Memory DoS缺陷。当恶意用户控制了容量参数,把参数值大小设置为int最大值2147483647-2(2147483645是数组初始化最大限制),那么,在数组初始化时,JVM会因为内存不足,从而导致系统产生java.lang.OutOfMemoryError错误,实现Memory DoS。

0x02 WebLogic

我在对Weblogic的扫描中,发现了有几十个class在反序列化的时候,可以导致系统产生java.lang.OutOfMemoryError错误,对系统进行Memory DoS攻击。扫描虽然使用了几分钟,但我写报告却花了大量的时间:)。

image

image

在报告给了Oracle后,2021.07的安全通知中https://www.oracle.com/security-alerts/cpujul2021.html,我得知其被修复,并且得到了CVE-2021-2344, CVE-2021-2371, CVE-2021-2376, CVE-2021-2378四个CVE以及署名。

WebLogic中的Memory DoS和Java SE的没有太大的差别,就不一一列出来了。

com.tangosol.run.xml.SimpleDocument#readExternal(java.io.ObjectInput)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
int cch = in.readInt();
char[] ach = new char[cch];
Utf8Reader reader = new Utf8Reader((InputStream)in);

int cchBlock;
for(int of = 0; of < cch; of += cchBlock) {
cchBlock = reader.read(ach, of, cch - of);
if (cchBlock < 0) {
throw new EOFException();
}
}

XmlHelper.loadXml(new String(ach), this, false);
}

不过,前面WebLogic以及Java SE中,举的例子都是在数组初始化时进行的攻击的sink。实际上,还有另外一种,它就是java集合Collection,当一个Collection在初始化时,往往在其内部实现中,会初始化一个或多个数组,存储数据。那么,如果在Collection初始化时,我们可以控制它的容量参数,就能让JVM内存不足,从而导致系统产生java.lang.OutOfMemoryError错误,造成Memory DoS。

weblogic.deployment.jms.PooledConnectionFactory#readExternal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void readExternal(ObjectInput in) throws IOException {
int extVersion = in.readInt();
if (extVersion != 1) {
throw new IOException(JMSPoolLogger.logInvalidExternalVersionLoggable(extVersion).getMessage());
} else {
this.wrapStyle = in.readInt();
this.poolName = in.readUTF();
this.containerAuth = in.readBoolean();
int numProps = in.readInt();
this.poolProps = new HashMap(numProps);

for(int inc = 0; inc < numProps; ++inc) {
String name = in.readUTF();
String value = in.readUTF();
this.poolProps.put(name, value);
}

this.poolManager = JMSSessionPoolManager.getSessionPoolManager();
this.poolManager.incrementReferenceCount(this.poolName);
JMSPoolDebug.logger.debug("In PooledConnectionFactory.readExternal()[poolManager=" + this.poolManager + "]");
}
}

可以看到,代码中的这一行HashMap初始化,我们是可以控制它的构造参数值大小的。

1
this.poolProps = new HashMap(numProps);

现在HashMap的构造参数我们可控了,意味着,我们可以自由指定其初始化容量的大小,若其大小超过了JVM可用内存,将会导致系统产生java.lang.OutOfMemoryError错误,实现Memory DoS。

0x03 Spring

在Spring中,我对其mvc框架的源码进行了简单的审计,发现在一个HttpMessageConverter中,存在数组初始化容量参数可控的情况,当对http进行简单构造后,将能导致系统产生java.lang.OutOfMemoryError错误,实现Memory DoS。

我在报告给Spring官方后,他们认为这虽然是一个安全问题,但是理应由其他系统去对其进行限制,所以,不予修复。

image

我也向Spring开发者提供了修复的建议,但他们最终没有采纳,所以,这个安全问题依然存在,不过幸运的是,大家无需担心,因为这个漏洞的利用,需要一定的前提条件。

image

org.springframework.http.converter.ByteArrayHttpMessageConverter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Override
public byte[] readInternal(Class<? extends byte[]> clazz, HttpInputMessage inputMessage) throws IOException {
long contentLength = inputMessage.getHeaders().getContentLength();
ByteArrayOutputStream bos =
new ByteArrayOutputStream(contentLength >= 0 ? (int) contentLength : StreamUtils.BUFFER_SIZE);
StreamUtils.copy(inputMessage.getBody(), bos);
return bos.toByteArray();
}

public ByteArrayOutputStream(int size) {
if (size < 0) {
throw new IllegalArgumentException("Negative initial size: "
+ size);
}
buf = new byte[size];
}

可以看到,当我们发起的http请求中,我们是可以利用Content-Length这个http header,控制byte数组的初始化容量,如果我们传入的Content-Length的大小为Long类型的最大值,将会导致系统产生java.lang.OutOfMemoryError错误,实现Memory DoS攻击。

不过想要利用这个漏洞,前提是需要开发者编写了某种实现的Controller,它需要Controller接收一个byte[]类型的参数,因为只有这样,Spring在http payload转换时,才是使用org.springframework.http.converter.ByteArrayHttpMessageConverter对其进行处理。

1
2
3
4
5
6
7
8
9
10
11
/**
* @author threedr3am
*/
@RestController
public class TestController {

@PostMapping(value = "/test")
public String test(@RequestBody byte[] bytes) {
return "ok";
}
}

0x04 Sentinel

github:https://github.com/alibaba/Sentinel

前面说了Java SE、WebLogic、Spring,但是所有无外乎都是针对数组初始化参数的攻击,那么,还有没有其他能造成系统产生java.lang.OutOfMemoryError错误的Memory DoS呢?

答案是“有”的,我在对Alibaba Sentinel进行审计的时候,发现了它的管控平台,也可以称之为注册中心(sentinel-dashboard),存在一个无需认证即可访问的http endpoint,稍加利用,就能导致系统产生java.lang.OutOfMemoryError错误。如果熟悉Sentinel的人都清楚,它是一个开源的限流熔断组件,在官方实现中,接入Sentinel的客户端,都会向注册中心进行服务注册,可能是为了降低使用、接入Sentinel的难度,这个服务注册的http endpoint是无需认证即可访问的。

这个http endpoint是/registry/machine

com.alibaba.csp.sentinel.dashboard.controller.MachineRegistryController#receiveHeartBeat

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@ResponseBody
@RequestMapping("/machine")
public Result<?> receiveHeartBeat(String app,
@RequestParam(value = "app_type", required = false, defaultValue = "0")
Integer appType, Long version, String v, String hostname, String ip,
Integer port) {
if (StringUtil.isBlank(app) || app.length() > 256) {
return Result.ofFail(-1, "invalid appName");
}
if (StringUtil.isBlank(ip) || ip.length() > 128) {
return Result.ofFail(-1, "invalid ip: " + ip);
}
if (port == null || port < -1) {
return Result.ofFail(-1, "invalid port");
}
if (hostname != null && hostname.length() > 256) {
return Result.ofFail(-1, "hostname too long");
}
if (port == -1) {
logger.warn("Receive heartbeat from " + ip + " but port not set yet");
return Result.ofFail(-1, "your port not set yet");
}
String sentinelVersion = StringUtil.isBlank(v) ? "unknown" : v;

version = version == null ? System.currentTimeMillis() : version;
try {
MachineInfo machineInfo = new MachineInfo();
machineInfo.setApp(app);
machineInfo.setAppType(appType);
machineInfo.setHostname(hostname);
machineInfo.setIp(ip);
machineInfo.setPort(port);
machineInfo.setHeartbeatVersion(version);
machineInfo.setLastHeartbeat(System.currentTimeMillis());
machineInfo.setVersion(sentinelVersion);
appManagement.addMachine(machineInfo);
return Result.ofSuccessMsg("success");
} catch (Exception e) {
logger.error("Receive heartbeat error", e);
return Result.ofFail(-1, e.getMessage());
}
}

-> com.alibaba.csp.sentinel.dashboard.discovery.AppManagement#addMachine

1
2
3
4
@Override
public long addMachine(MachineInfo machineInfo) {
return machineDiscovery.addMachine(machineInfo);
}

-> com.alibaba.csp.sentinel.dashboard.discovery.SimpleMachineDiscovery#addMachine

1
2
3
4
5
6
7
8
9
private final ConcurrentMap<String, AppInfo> apps = new ConcurrentHashMap<>();

@Override
public long addMachine(MachineInfo machineInfo) {
AssertUtil.notNull(machineInfo, "machineInfo cannot be null");
AppInfo appInfo = apps.computeIfAbsent(machineInfo.getApp(), o -> new AppInfo(machineInfo.getApp(), machineInfo.getAppType()));
appInfo.addMachine(machineInfo);
return 1;
}

通过跟踪上述代码,可以看到,用户提交的数据,径直得往内存中存储。因为这个http endpoint支持GET、POST请求,所以当我们在发送http请求中使用POST方式,并且在body中添加非常大的数据,比如app参数,放一个1MBytes或者10MBytes,亦或者更大的数据,那么,将会导致服务端内存耗尽,而产生java.lang.OutOfMemoryError错误,实现Memory DoS。

0x05 注意之处

有的读者在测试数组初始化Memory DoS的时候,发现虽然可以使系统产生java.lang.OutOfMemoryError错误,但是系统并没有因此而崩溃,实现完整的Memory DoS,这是什么原因呢?

且看下面这三个例子:

  • 完整Memory DoS

    1
    2
    3
    4
    5
    6
    private byte[] bytes;

    public ? service(int size) {
    bytes = new byte[size];
    //do something
    }
  • 一定时间内的Memory DoS

    1
    2
    3
    4
    public ? service(int size) {
    byte[] bytes = new byte[size];
    //do 5s something
    }
  • 短暂的Memory DoS

    1
    2
    3
    4
    public ? service(int size) {
    byte[] bytes = new byte[size];
    //do 100ms something
    }

在看完这三个例子之后,我相信大部分熟悉JVM gc机制的人都能立马懂了,其实这就是共享变量引用对象和局部变量引用对象之间的区别。

因为JVM的gc机制是根据对象引用来确定对象内存是否需要被回收的,而这里,我们初始化的数组对象如果是局部变量引用,并且仅有局部变量引用,那么,这就意味着,当这个线程栈执行完成之后,如果引用已经不存在了,那么JVM在执行gc的时候就会回收这一块内存,所以,单独这样我们只能得到短暂时间内的Memory DoS,可能是5秒(已经比较优质了),也可能只有0.1秒,没办法完全实现Memory DoS,而只有让这个对象生命周期足够长,或者引用一直存在,那么,在其生命周期内,我们才能长时间占用JVM堆足够多的内存,让JVM无法回收这部分内存,从而实现Memory DoS。

还有一个最重要的点,JVM对应数组初始化的大小是有限制的,最大数组长度是2147483645,约等于2047.999997138977051MBytes,所以,如果遇到可控共享变量引用对象的场景,我们只能控制一个对象数组大小,一旦JVM最大可用堆内存远比其数组大的话,对于实现Memory DoS也是比较难的。

五、总结

  • java中只要能产生Error,大概率就能造成DoS
  • 通过控制数组Array、集合Collection初始化容量参数,可以实现Memory DoS
  • 往集合Collection中插入大量的垃圾数据,也可以实现Memory DoS

由于这篇文章实际上没什么硬核的东西,所以,就不写太多的例子了,以免又臭又长。

六、一些关于Memory DoS的CVE