Tiếp nối chủ đề về ManageEngine (Tales of Authentication Bypass Across ManageEngine Products) của người anh đi trước. Lần này, mình cũng may mắn tìm ra một lỗ hổng trên PAM360.
Trong các sản phẩm của ManageEngine, class SecurityFilter đóng vai trò như một filter trung tâm, buộc mọi request phải đi qua đây để dễ dàng kiểm soát. Sử dụng class này, Zoho đã thực hiện validate /* request thông qua các ActionRule được định nghĩa trong các file *.xml, tập trung vào việc validate path và parameter để chặn phần lớn các sink vẫn còn tồn tại trong ManageEngine. Ngoài SecurityFilter ra, còn có một loại Filter khác giúp xử lý web view client thuận tiện gọi là StateFilter.
StateFilter
StateFilter là một Java Servlet Filter dùng để xử lý các request có URL prefix /STATE_ID/*, bao gồm parse State Cookie và forward request đến path tương ứng.
<filter>
<filter-name>StateFilter</filter-name>
<filter-class>com.adventnet.client.view.web.StateFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>StateFilter</filter-name>
<url-pattern>/STATE_ID/*</url-pattern>
</filter-mapping>
Lý do có StateFilter là vì ManageEngine dùng web client framework dạng stateful view. Mỗi view/table có state riêng: view nào đang mở, page number, sort column, filter column/value, dynamic params, parent/child view. State đó được gửi qua /STATE_ID/* kèm cookie STATE_COOKIE. StateFilter đọc cookie, dựng lại map, rồi forward request /STATE_ID/<time>/<View>.ec tới endpoint thật /<View>.ec.
Nói ngắn gọn, đây là lớp hydrate state trước khi controller chạy. StateFilter không giống SecurityFilter, nó không kiểm tra authentication, không kiểm tra quyền, và cũng không validate giá trị input. Nó chỉ decode cookie và dựng ViewContext. SecurityFilter không thực hiện validate giá trị của các header, vì vậy, các sink lấy input từ STATE_COOKIE chính là phần đã bị vendor bỏ sót.
Cấu trúc của một STATE_COOKIE có dạng:
&<view-name>/<key>/<value>/<key>/<value>...&_REQS/<key>/<value>...& mở một view mới, ngay sau là / phân tách key và value.
Một cookie bình thường cho cùng view trông như sau:
GET /STATE_ID/1782338547382/RecentPasswords.ec?UNIQUE_ID=RecentPasswords&GROUPID=null&USERID=2 HTTP/1.1
Host: <<HOST>>
Cookie: STATE_COOKIE=<@urlencode_not_plus>&PassTrixMain/ID/733&RecentPasswords/ID/350/_D_RP/GROUPID%3Dnull%26USERID%3D2/_FI/0/_TI/0/_TL/0/_SO/A/_PL/25/_PN/1/_SB/3&_REQS/_RVID/RecentPasswords/_TIME/1782338547382/_ORVID/PassTrixMain</@urlencode_not_plus>| Nhóm | Key | Value | Vai trò |
|---|---|---|---|
PassTrixMain | ID | 733 | reference ID của view gốc |
RecentPasswords | ID | 360 | reference ID của view |
_D_RP | GROUPID=null&USERID=2 | request params của view | |
_FI, _TI, _TL, _SO, _PL, _PN, _SB | 0, 0, 0, A, 25, 1, 3 | trạng thái bảng/phân trang | |
_REQS | _RVID | RecentPasswords | root view ID |
_TIME | 1782338547382 | request timestamp | |
_ORVID | PassTrixMain | original root view ID |
_REQS là nhóm request state, không phải view, _TIME được so với timestamp trên URL.
com.adventnet.client.view.web.ViewContext#getStateParameter chính là hàm dùng để lấy giá trị của các parameter /<key>/<value>/<key>/<value>... trong STATE_COOKIE:
public Object getStateParameter(String paramName) {
Object stateData = this.stateMap != null ? this.stateMap.get(paramName) : null;
if (stateData == null && this.previousStateMap != null) {
stateData = this.previousStateMap.get(paramName);
}
return stateData != NULLOBJ ? stateData : null;
}SQL Injection
Mình đã thực hiện trace tất cả các class gọi đến getStateParameter và thật may mắn, trong PAM360 tồn tại sink nối chuỗi có thể dẫn SQL Injection com.adventnet.passtrix.client.controller.AutologonController#getSearchCriteria:
public String getSearchCriteria(ViewContext viewCtx) {
String searchValues;
String searchColumns = (String)viewCtx.getStateOrURLStateParameter("SEARCH_COLUMN");
if (searchColumns == null) {
searchColumns = viewCtx.getRequest().getParameter("SEARCH_COLUMN");
}
if ((searchValues = (String)viewCtx.getStateOrURLStateParameter("SEARCH_VALUE")) == null) {
searchValues = viewCtx.getRequest().getParameter("SEARCH_VALUE");
}
if (searchColumns != null && searchValues != null) {
String[] columnNames = searchColumns.split(",");
String[] columnValues = searchValues.split(",");
int colSize = columnNames.length;
StringBuilder criteriaString = new StringBuilder();
for (int count = 0; count < colSize; ++count) {
String valueMatch = "%" + columnValues[count] + "%";
...
valueMatch = ClientUtil.escapeSpecialCharacters(valueMatch);
valueMatch = TemplateAPI.getEscapedSearchString(valueMatch);
...
if (PersistenceHandler.isPGSQL) {
criteriaString.append("\"").append(columnNames[count]).append("\"").append(" LIKE '").append(valueMatch).append('\'');
continue;
}
...
}
...
return criteriaString.toString();
}
return "(1 = 1)";
}Hàm viewCtx.getStateOrURLStateParameter gọi đến getStateParameter. Giá trị của key SEARCH_COLUMN được split(",") thành String[] columnNames sau đó criteriaString.append(columnNames[count]). Đây là lúc StateFilter dẫn tới SQLi.
Input đã đi từ cookie → state map → ViewContext mà không gặp alphnumwith_dotatspacecomma - thứ đã được định nghĩa trong các ActionRule:
<param secret="true" description="" name="SEARCH_COLUMN" regex="alphnumwith_dotatspacecomma" max-occurrences="200"/>Nếu validate ở biên state hoặc tại sink, payload đã bị chặn. Thực tế vendor đã bỏ qua StateFilter và mặc định tin tưởng vào SecurityFilter.
AutologonController extends PMPSqlViewController và chỉ là một trong hơn chục class extends PMPSqlViewController khác tương tự cũng tồn tại lỗ hổng trong hàm getSearchCriteria. Vendor đã thực hiện patch bằng một đoạn code filter searchColumns giống hệt nhau tại tất cả các hàm getSearchCriteria trong class này.
Time-based PoC
Debug PoC:

Time-based 10s response:

SQLi to Privilege Escalation
PoC UPDATE để sửa bảng phân quyền:

From SQL Injection to RCE
Khi đã có quyền Privileged Administrator, mình đã tìm hiểu xem có chức năng nào có thể RCE được không và tìm được chức năng Password Reset Listener.
Password Reset Listener
Password Reset Listener là tính năng cho phép gắn một script (PowerShell, Shell Script, Batch,…) vào quá trình reset password của resource. Khi password được reset, PAM360 có thể chạy listener trước hoặc sau khi đổi password, tùy vào cách listener được config. Script có thể nhận các tham số như resource name, DNS name, account name, password cũ và password mới.

Cấu hình listener test.ps1 với một PowerShell reverse shell:

Khi mới tạo, listener chưa được kích hoạt.

Một administrator khác phải vào duyệt, có hai cách: tạo thêm một administrator để duyệt, hoặc dùng SQLi sửa thẳng trạng thái trong database:


Trigger Listener
Thực hiện reset password cho resource/account, sau khi ChangePassword sẽ gọi listener:

Kết quả cuối cùng là command execution trên host PAM360:

Others
StateFilter nằm trong AdventNetClientFramework.jar, Zoho dùng chung lib này trong khá nhiều product ManageEngine. Tuy nhiên, hiện tại mình mới chỉ tìm được sink về SQLi trong AdventNetPassTrix.jar - lib này ảnh hưởng đến cả PAM360, Password Manager Pro và Access Manager Plus. Vendor đã vá chúng nhưng StateFilter thì vẫn như vậy. Có thể trong tương lai, sẽ có AI đó tìm ra sink xịn sò có thể kết hợp với nó. XD
That’s enough internet for today.
