-
Notifications
You must be signed in to change notification settings - Fork 1.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add basic protection against untrusted deserialization by introducing blacklisting/whitelisting capabilities #12230
Conversation
c86e877
to
b95ae6f
Compare
d480d78
to
d9d2708
Compare
af2fb28
to
d5e1c22
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure about the config style, but this is already discussed in Slack, so we can see what the outcome is there.
I'm also not sure about the configuration parsing and check order. If I read the code correctly, the DEFAULT_BLACKLIST
is overwritten as soon as I define a blacklist property. So I need to look into the code to add the defaults + my custom classes. Maybe adding the configured blacklist is better? But then the whitelist should overrule the blacklist, so you can override our blacklisted defaults.
} | ||
|
||
/** | ||
* Throws {@link ClassNotFoundException} if the given class name appears on the blacklist or does not appear on a non-empty |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ClassNotFoundException
doesn't seem a good fit here, since the class might be deployed, but still unwanted to be deserialized. Maybe HazelcastSerializationException
?
ruleSysPropBlacklist.setOrClear("java.lang.Test3,java.lang.Test2,java.lang.Test1"); | ||
DeserializationChecker.checkClassNameForResolution("java.lang.Test1"); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing newline at EOF
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi guys, original reporter here! If a whitelist is present, only classes on the union of all whitelists should be allowed. If no whitelist is present, classes not present on the union of all blacklists should be allowed. In both cases, any defaults should be merged with any additions by user configuration. The user should be required to explicitly remove items on the default blacklist.
For example, Weblogic allows a -class
syntax in user filters which removes any matching classes or packages from the list.
I also agree that ClassNotFoundException
isn't the right exception to throw here, because it doesn't communicate the correct information. The exception message back to the client should include something indicating that deserialization failed due to filtering, but not give too much information. If local logging is configured, the local log should include more information.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or more accurately:
If (a class matches a pattern in the whitelist or the whitelist is empty), and it does not also match a pattern in the blacklist, then it is allowed.
If a class matches a pattern in the blacklist, and it does not also match a pattern in the whitelist, then it is not allowed.
Else, it is allowed.
This allows whitelisting com.package.*
, then disallowing specific classes in the package.
@Donnerbart, Thanks for the comments, I'll work on them. I'll rework the configuration to avoid using properties. Unill the changes are in place, I'm flagging this PR with a "Don't merge" prefix. |
@drosenbauer Thanks for comments. Current plan is to have following configuration: <hazelcast>
<serialization>
<java-serialization-filter>
<whitelist>
<class>java.lang.String</class>
<class>example.Foo</class>
<package>com.acme.app</package>
<package>com.acme.app.subpkg</package>
</whitelist>
<blacklist>
<class>com.acme.app.BeanComparator</class>
</blacklist>
</java-serialization-filter>
</serialization>
</hazelcast> The exception, which will be thrown by look-ahead ObjectInputStream is Filtering rules
|
1c5a63e
to
35030f1
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Litter is created on the fast path for package checks. This is not acceptable from a performance point of view. So I'm blocking this PR from merging.
private final ShadeOfGreyList whitelist; | ||
|
||
public SerializationClassNameFilter(JavaSerializationFilterConfig config) { | ||
if (config == null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have a preconditions utility class for such checks.
|
||
package com.hazelcast.nio; | ||
|
||
public interface ClassNameFilter { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add documentation to this interface? I guess an exception is thrown when the class isn't allowed, but that isn't clear from the end user.
Also the com.hazelcast.nio package is a mixed bag of public and private code. If the end user shouldn't see this code, this logic should be placed to com.hazelcast.internal.serialization.
} | ||
|
||
@Override | ||
protected Class<?> resolveClass(ObjectStreamClass desc) throws ClassNotFoundException { | ||
return ClassLoaderUtil.loadClass(classLoader, desc.getName()); | ||
String name = desc.getName(); | ||
if (classFilter != null) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
AFAIK it can't happen that for a regular created serializationservice, the classFilter can be null since if the config is null, a default config is created and used. So afaik this branch normally can't be null.. also there is no proper way to get rid of the check completely (the end user can't trigger the filter to become null).
return true; | ||
} | ||
if (packages != null) { | ||
int dotPosition = className.lastIndexOf("."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Litter (the substring) is being created on the fast path. This isn't acceptable.
A potential solution:
1: determine if the class with this package is allowed; so you create your substring. This will only be done once per class.
2: once you have determined the class with this package is valid, replace the classes set by current set with the new class added. So there will be some litter while the system is learning about the classes which are valid, but after that there is no litter.
The only form of synchronization that is needed is to make the classes field volatile and make sure you update the classes field with a cas. the HashSet of classes can remain immutable. Another similar approach would be to use a mutable threadsafe (based on ConcurrentHashMap) set.
This task has been moved to the 3.11 release. So I'm going to update the milestone. |
7f673cb
to
4169cb1
Compare
I've updated the PR. The comments were mostly addressed. The feature is not enabled by default now. The default blacklist is used after adding the I don't think the package name extraction from a class name is a litter, I like it more than adding classnames without limit to the classname |
if (!packages.isEmpty()) { | ||
int dotPosition = className.lastIndexOf("."); | ||
if (dotPosition > 0) { | ||
String packageName = className.substring(0, dotPosition); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will lead to litter. You can optimize it by storing the clasname in the 'classes'. So this substring check will be done one per class and then it will be stored in 'classes'. Then you need to make classes modifiable by using either a CHM or replace the whole collection and consider it immutable.
This is the second time I placed this comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the second time I object the suggestion for adding classes to a Set if their packages are listed. It would create another unsafe piece in the code - possible DOS by causing OOM.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, can't we settle this issue with a JFR test? The current implementation vs. a cached one. And a normal usage scenario vs. a DOS based one (so a handful of classes vs. an attack based with thousands of generated classes). Would be four scenarios in total to compare.
EDIT: Sorry, six scenarios, we should include the current master of course, to see the impact of the feature.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 I'm going to put together a JMH test.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
package com.hazelcast;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.infra.Blackhole;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Fork(value = 1, warmups = 1)
@Warmup(iterations = 2)
@Measurement(iterations = 5)
public class ClassFilterBenchmark {
private ClassFilter classFilter;
@Setup
public void prepare() {
classFilter = new ClassFilter();
classFilter.addPackages("foo");
}
@Benchmark
public void litter(Blackhole blackhole) {
blackhole.consume(classFilter.isListed_litter("foo.bar"));
}
@Benchmark
public void noLitter(Blackhole blackhole) {
blackhole.consume(classFilter.isListed_noLitter("foo.bar"));
}
}
Benchmark Mode Cnt Score Error Units
ClassFilterBenchmark.litter avgt 5 30.205 ± 7.995 ns/op
ClassFilterBenchmark.noLitter avgt 5 6.386 ± 0.091 ns/op
package com.hazelcast;
import static com.hazelcast.util.Preconditions.checkNotNull;
import static java.util.Collections.unmodifiableSet;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* Holds blacklist and whitelist configuration in java deserialization configuration.
*/
public class ClassFilter {
private final Set<String> classes = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());
private final Set<String> packages = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());
/**
* Returns unmodifiable set of class names.
*/
public Set<String> getClasses() {
return unmodifiableSet(classes);
}
/**
* Returns unmodifiable set of package names.
*/
public Set<String> getPackages() {
return unmodifiableSet(packages);
}
public ClassFilter addClasses(String... names) {
checkNotNull(names);
for (String name : names) {
classes.add(name);
}
return this;
}
public ClassFilter setClasses(Collection<String> names) {
checkNotNull(names);
classes.clear();
classes.addAll(names);
return this;
}
public ClassFilter addPackages(String... names) {
checkNotNull(names);
for (String name : names) {
packages.add(name);
}
return this;
}
public ClassFilter setPackages(Collection<String> names) {
checkNotNull(names);
packages.clear();
packages.addAll(names);
return this;
}
public boolean isEmpty() {
return classes.isEmpty() && packages.isEmpty();
}
public boolean isListed_litter(String className) {
if (classes.contains(className)) {
return true;
}
if (!packages.isEmpty()) {
int dotPosition = className.lastIndexOf(".");
if (dotPosition > 0) {
String packageName = className.substring(0, dotPosition);
return packages.contains(packageName);
}
}
return false;
}
public boolean isListed_noLitter(String className) {
if (classes.contains(className)) {
return true;
}
if (!packages.isEmpty()) {
int dotPosition = className.lastIndexOf(".");
if (dotPosition > 0) {
String packageName = className.substring(0, dotPosition);
if(packages.contains(packageName)){
classes.add(className);
return true;
}
}
}
return false;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((classes == null) ? 0 : classes.hashCode());
result = prime * result + ((packages == null) ? 0 : packages.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
ClassFilter other = (ClassFilter) obj;
return ((classes == null && other.classes == null) || (classes != null && classes.equals(other.classes)))
&& ((packages == null && other.packages == null) || (packages != null && packages.equals(other.packages)));
}
@Override
public String toString() {
return "ClassFilter{classes=" + classes + ", packages=" + packages + "}";
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added the gc profiler
Benchmark Mode Cnt Score Error Units
ClassFilterBenchmark.litter avgt 5 29.432 ± 1.990 ns/op
ClassFilterBenchmark.litter:·gc.alloc.rate avgt 5 1555.331 ± 103.111 MB/sec
ClassFilterBenchmark.litter:·gc.alloc.rate.norm avgt 5 48.000 ± 0.001 B/op
ClassFilterBenchmark.litter:·gc.churn.PS_Eden_Space avgt 5 1550.413 ± 577.469 MB/sec
ClassFilterBenchmark.litter:·gc.churn.PS_Eden_Space.norm avgt 5 47.876 ± 18.812 B/op
ClassFilterBenchmark.litter:·gc.churn.PS_Survivor_Space avgt 5 0.181 ± 0.231 MB/sec
ClassFilterBenchmark.litter:·gc.churn.PS_Survivor_Space.norm avgt 5 0.006 ± 0.007 B/op
ClassFilterBenchmark.litter:·gc.count avgt 5 21.000 counts
ClassFilterBenchmark.litter:·gc.time avgt 5 43.000 ms
ClassFilterBenchmark.noLitter avgt 5 6.365 ± 0.122 ns/op
ClassFilterBenchmark.noLitter:·gc.alloc.rate avgt 5 0.001 ± 0.001 MB/sec
ClassFilterBenchmark.noLitter:·gc.alloc.rate.norm avgt 5 ≈ 10⁻⁵ B/op
ClassFilterBenchmark.noLitter:·gc.count avgt 5 ≈ 0 counts
Codecov Report
@@ Coverage Diff @@
## master #12230 +/- ##
===========================================
+ Coverage 76.22% 76.43% +0.2%
- Complexity 34722 34868 +146
===========================================
Files 3032 3035 +3
Lines 129595 129819 +224
Branches 15168 15203 +35
===========================================
+ Hits 98785 99227 +442
+ Misses 25073 24913 -160
+ Partials 5737 5679 -58
|
Thanks @pveentjer for review and pushing for the solution with better performance! I'm going to squash the commits. |
Untrusted deserialization protection for Hazelcast
This pull request introduces java deserialization protection based on class names blacklisting and whitelisting.
The new feature is controlled by a new section in Hazelcast serialization configuration. The feature is not enabled by default, you can enable it by adding
<java-serialization-filter/>
element into<serialization/>
configuration section.Example:
Once the feature is enabled, following filtering rules are used when objects are deserialized.
Filtering rules
Failed deserialization means a
SecurityException
is thrown.When the blacklist is not explicitly provided, a default hardcoded value with some well known vulnerable class names is used.
The safest way to protect against untrusted deserialization is to use whitelisting, nevertheless it's also hard to maintain such a whitelist.