
处理java中不可信的protocol buffers消息时,限制序列化字节大小相对直接。然而,精确控制反序列化后对象图所占用的内存却极具挑战性,这源于java内存模型的复杂性以及protobuf内部的动态分配机制。本文将深入探讨直接限制反序列化内存的固有难点,并提出包括避免不必要的反序列化以及采用系统级资源监控等替代策略,以增强系统的健壮性。
在构建处理外部不可信Protocol Buffers消息的系统时,一个核心的安全考量是防止资源耗尽攻击(如CPU和内存)。特别是在作为代理或转发服务的场景中,系统需要接受Protobuf消息,进行反序列化,然后将其转发到其他数据存储。由于消息内容和其描述符(schema)都可能来自不可信的源,因此对反序列化过程施加严格的资源限制至关重要。
主要面临两个维度的限制需求:
其中,第一个问题通常可以通过Protobuf库提供的机制解决,但第二个问题则复杂得多,尤其是在Java环境中。
Protobuf Java库提供了控制输入流大小的机制,以防止解析过大的原始字节流。com.google.protobuf.CodedInputStream 类中包含一个 setSizeLimit() 方法,允许开发者设定一个最大读取字节数。当尝试读取超过此限制时,将抛出 InvalidProtocolBufferException。
立即学习“Java免费学习笔记(深入)”;
示例代码:
import com.google.protobuf.CodedInputStream;
import com.google.protobuf.InvalidProtocolBufferException;
import com.google.protobuf.Message;
import com.google.protobuf.DynamicMessage;
import com.google.protobuf.Descriptors.Descriptor;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
public class ProtobufDeserializationLimiter {
/**
* 使用CodedInputStream限制序列化消息的最大字节数。
*
* @param dataStream 包含序列化Protobuf消息的输入流
* @param descriptor 消息的描述符
* @param maxSerializedBytes 允许的最大序列化字节数
* @return 反序列化后的消息对象
* @throws IOException 如果I/O操作失败或消息超出大小限制
*/
public static Message parseMessageWithSerializedLimit(
InputStream dataStream, Descriptor descriptor, int maxSerializedBytes) throws IOException {
CodedInputStream codedInputStream = CodedInputStream.newInstance(dataStream);
// 设置最大读取字节数限制
codedInputStream.setSizeLimit(maxSerializedBytes);
try {
// 使用DynamicMessage进行反序列化,因为描述符可能是动态加载的
return DynamicMessage.parseFrom(descriptor, codedInputStream);
} catch (InvalidProtocolBufferException e) {
// 当消息超过setSizeLimit设定的限制时,会抛出此异常
if (e.getMessage() != null && e.getMessage().contains("size limit was exceeded")) {
throw new IOException("Serialized message size exceeded the allowed limit of " + maxSerializedBytes + " bytes.", e);
}
throw e; // 其他Protobuf解析错误
}
}
public static void main(String[] args) {
// 假设我们有一个简单的Protobuf定义
// message MyMessage {
// string name = 1;
// int32 id = 2;
// }
// 实际应用中,descriptor会通过FileDescriptorSet动态获取
// 这里只是一个模拟的描述符获取过程
Descriptor myMessageDescriptor = getExampleDescriptor(); // 模拟获取描述符
// 模拟一个合法的短消息 (e.g., "name: 'test', id: 1")
byte[] smallMessageBytes = ByteBuffer.allocate(10)
.put((byte) (1 << 3 | 2)) // field 1, wire type 2 (length-delimited string)
.put((byte) 4) // length of "test"
.put("test".getBytes())
.put((byte) (2 << 3 | 0)) // field 2, wire type 0 (varint)
.put((byte) 1) // value 1
.array();
// 模拟一个过长的消息 (实际中可能是一个恶意构造的大消息)
byte[] largeMessageBytes = new byte[2000]; // 超过1KB限制
// 填充一些数据以模拟Protobuf消息
largeMessageBytes[0] = (byte) (1 << 3 | 2); // field 1, wire type 2
largeMessageBytes[1] = (byte) 127; // length prefix for a long string
for (int i = 2; i < 130; i++) { // Fill part of the string
largeMessageBytes[i] = 'a';
}
// 剩余部分保持0,或填充其他数据
int maxAllowedBytes = 1024; // 1KB限制
try {
// 尝试解析合法消息
InputStream smallStream = new java.io.ByteArrayInputStream(smallMessageBytes);
Message msg1 = parseMessageWithSerializedLimit(smallStream, myMessageDescriptor, maxAllowedBytes);
System.out.println("Successfully parsed small message: " + msg1.toString());
// 尝试解析过长消息
InputStream largeStream = new java.io.ByteArrayInputStream(largeMessageBytes);
parseMessageWithSerializedLimit(largeStream, myMessageDescriptor, maxAllowedBytes);
System.out.println("Successfully parsed large message (this should not happen)");
} catch (IOException e) {
System.err.println("Error parsing message: " + e.getMessage());
}
}
// 模拟获取描述符的方法 (在实际应用中,这会从FileDescriptorSet中解析)
private static Descriptor getExampleDescriptor() {
// 这是一个非常简化的模拟,实际需要使用DescriptorProtos和DescriptorPool
// 这里仅为示例提供一个虚拟的描述符
try {
// 使用Protobuf的反射机制来获取一个简单的描述符
// 假设你有一个proto文件定义了 MyMessage
// syntax = "proto3";
// package com.example;
// message MyMessage {
// string name = 1;
// int32 id = 2;
// }
// 你需要编译这个proto文件,然后使用生成的Java类来获取描述符
// 例如:return com.example.MyMessage.getDescriptor();
// 由于这里没有实际的.proto文件和生成的类,我们返回一个null或抛出异常
// 实际应用中,你需要确保这里能获取到正确的描述符
// 为了让示例编译通过,我们创建一个假的描述符,这在实际中不可取
// 这是一个复杂的步骤,通常涉及 FileDescriptorSet
System.out.println("Warning: Using a placeholder descriptor. In real applications, load from FileDescriptorSet.");
return com.google.protobuf.DescriptorProtos.DescriptorProto.newBuilder()
.setName("MyMessage")
.addField(com.google.protobuf.DescriptorProtos.FieldDescriptorProto.newBuilder()
.setName("name")
.setNumber(1)
.setType(com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_STRING)
.build())
.addField(com.google.protobuf.DescriptorProtos.FieldDescriptorProto.newBuilder()
.setName("id")
.setNumber(2)
.setType(com.google.protobuf.DescriptorProtos.FieldDescriptorProto.Type.TYPE_INT32)
.build())
.build()
.getDescriptorForType(); // 这是一个简化的获取方式,可能不完全正确,但用于演示
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}与限制序列化字节数不同,精确限制反序列化后Java对象在内存中的占用是一个非常困难的问题。
Java虚拟机(JVM)中的内存测量本身就具有挑战性。一个Java对象所占用的内存不仅仅是其字段的大小,还包括对象头、对齐填充以及引用类型所指向的实际对象(如果存在)的内存。对于复杂的对象图,如Protobuf消息,一个消息对象可能包含多个字段,其中重复字段(repeated fields)会进一步引入List对象、底层数组以及数组中元素的内存占用。
考虑一个简单的Protobuf消息:
message MyMessage {
repeated string names = 1;
repeated int32 ids = 2;
}反序列化这样一个消息时,即使names和ids字段为空,也会至少分配MyMessage对象本身,以及names和ids字段对应的List对象(或其内部表示)。如果List中有元素,还会涉及底层数组的分配以及每个元素的内存。例如,一个List<String>会包含对多个String对象的引用,而每个String对象又包含字符数组。这种多层次的引用和分配使得精确计算总内存变得极为复杂。
Protobuf库在反序列化时,会根据消息描述符动态创建Java对象。这些对象的具体内存布局和分配策略是Protobuf库的内部实现细节,并且可能随着库的版本更新而变化。开发者无法直接拦截或监听Protobuf的内存分配行为,因此很难在反序列化过程中实时监控并限制内存使用。
此外,反序列化内存占用(Y)与序列化字节数(X)之间的比率(Y/X)并没有一个固定的上限。这个比率主要取决于消息的描述符(schema),而非消息内容本身。例如,一个拥有成千上万个字段的Protobuf消息类型,即使其序列化消息体非常小(例如,所有字段都为空),反序列化后也需要分配一个包含所有这些字段引用或默认值的大型Java对象。如果消息描述符本身是恶意的(例如,定义了大量字段),那么即使是空消息也可能导致巨大的内存消耗。
目前,Protobuf Java库没有提供直接的API来在反序列化过程中设置内存上限。DynamicMessage.parseFrom() 等入口点允许传入 CodedInputStream,但没有参数或回调机制来在内存分配达到某个阈值时中断解析。
鉴于直接限制反序列化内存的困难性,以下是一些替代策略和最佳实践,以应对不可信Protobuf消息带来的资源风险:
如果系统的主要职责是转发消息到数据存储,并且数据存储能够处理原始的Protobuf字节流,那么最有效的方法是完全避免反序列化。直接将接收到的序列化字节数组转发到目的地,可以彻底消除反序列化带来的CPU和内存开销及安全风险。
// 假设这是接收到的原始字节数组 byte[] receivedProtobufBytes = getReceivedBytes(); // 如果仅需转发,直接将字节数组发送到数据存储 dataStoreService.storeRawProtobuf(receivedProtobufBytes); // 避免: // Message parsedMessage = DynamicMessage.parseFrom(descriptor, receivedProtobufBytes); // dataStoreService.storeParsedMessage(parsedMessage);
这种方法简单、高效且安全,是处理代理或转发场景的首选。
由于难以在单个反序列化操作中精确控制内存,可以考虑在更宏观的层面进行资源管理:
文章中提到,如果信任描述符的作者,那么极端退化的情况(Y/X比率极高)会减少。这意味着,如果能够确保消息描述符(schema)是经过审查和信任的,那么反序列化一个“空”消息导致巨大内存占用的风险会降低。
在反序列化之前,除了检查序列化字节大小,还可以尝试对消息的某些属性进行预检,尽管这不直接限制内存:
在Java中对Protobuf反序列化过程中的内存占用进行精确边界控制是一个极具挑战性的任务,主要是因为Java内存模型的复杂性、Protobuf内部实现的动态性以及缺乏直接的API支持。依赖于CodedInputStream.setSizeLimit()可以有效限制序列化消息的原始字节大小,但无法直接限制反序列化后的对象内存。
面对不可信的Protobuf消息,最稳健的策略是:
通过这些综合策略,可以有效地缓解因Protobuf反序列化操作可能导致的资源耗尽风险,从而构建更加健壮和安全的系统。
以上就是Java Protobuf 反序列化内存边界控制策略与挑战的详细内容,更多请关注php中文网其它相关文章!
每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号